Add Social Scheduler — global social media post scheduling
- New /social page with left/right layout (form + post list) - Social topbar link - Tables: social_posts, social_targets (project-agnostic posts, per-target project refs) - CRUD: create, edit, duplicate, delete, retry - Image upload (JPEG/PNG/GIF/WebP, 10MB max) stored in data/social-uploads/ - Per-platform image toggle (Discord multipart, Mastodon /api/v2/media, LinkedIn Assets API) - bin/dispatch-social.php cron dispatcher (every minute via /etc/cron.d) - Settings: eligible projects section to enable bot projects as social account sources - Platforms: Discord (webhook), Mastodon, LinkedIn personal profile Cron setup: * * * * * www-data /usr/bin/php /opt/hackmancms/bin/dispatch-social.php >> /var/log/hackman_social.log 2>&1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
45b9e15152
commit
ac5c262bfb
9 changed files with 1081 additions and 0 deletions
322
bin/dispatch-social.php
Executable file
322
bin/dispatch-social.php
Executable file
|
|
@ -0,0 +1,322 @@
|
||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Social post dispatcher — runs every minute via cron.
|
||||||
|
*
|
||||||
|
* /etc/cron.d/hackmancms-social:
|
||||||
|
* * * * * * www-data /usr/bin/php /opt/hackmancms/bin/dispatch-social.php >> /var/log/hackman_social.log 2>&1
|
||||||
|
*/
|
||||||
|
|
||||||
|
define('ROOT', dirname(__DIR__));
|
||||||
|
require ROOT . '/lib/bootstrap.php';
|
||||||
|
|
||||||
|
// Prevent overlapping runs
|
||||||
|
$lock = fopen(sys_get_temp_dir() . '/hackman_social.lock', 'c');
|
||||||
|
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) exit(0);
|
||||||
|
|
||||||
|
$now = gmdate('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT t.id AS target_id, t.post_id, t.project_id, t.platform, t.target_key, t.include_image,
|
||||||
|
p.content, p.image_path,
|
||||||
|
pr.path AS project_path
|
||||||
|
FROM social_targets t
|
||||||
|
JOIN social_posts p ON p.id = t.post_id
|
||||||
|
JOIN projects pr ON pr.id = t.project_id AND pr.is_active = 1
|
||||||
|
WHERE t.status = 'pending'
|
||||||
|
AND p.status IN ('pending', 'partial')
|
||||||
|
AND p.scheduled_at <= ?
|
||||||
|
ORDER BY p.scheduled_at ASC, t.id ASC
|
||||||
|
");
|
||||||
|
$stmt->execute([$now]);
|
||||||
|
$targets = $stmt->fetchAll();
|
||||||
|
|
||||||
|
if (!$targets) exit(0);
|
||||||
|
|
||||||
|
function log_msg(string $msg): void {
|
||||||
|
echo '[' . gmdate('Y-m-d H:i:s') . '] ' . $msg . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function read_config(string $base): array {
|
||||||
|
$path = $base . '/data/config.json';
|
||||||
|
if (!file_exists($path)) return [];
|
||||||
|
$d = json_decode(file_get_contents($path), true);
|
||||||
|
return is_array($d) ? $d : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function write_config(string $base, array $data): void {
|
||||||
|
$path = $base . '/data/config.json';
|
||||||
|
$tmp = $path . '.tmp';
|
||||||
|
file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
rename($tmp, $path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function read_env(string $base): array {
|
||||||
|
$path = $base . '/.env';
|
||||||
|
if (!file_exists($path)) return [];
|
||||||
|
$env = [];
|
||||||
|
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (!$line || $line[0] === '#' || !str_contains($line, '=')) continue;
|
||||||
|
[$k, $v] = explode('=', $line, 2);
|
||||||
|
$env[trim($k)] = trim($v, " \t\"'");
|
||||||
|
}
|
||||||
|
return $env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_post_status(PDO $db, int $post_id): void {
|
||||||
|
$row = $db->prepare("SELECT COUNT(*) AS total, SUM(status='pending') AS pending, SUM(status='failed') AS failed FROM social_targets WHERE post_id=?");
|
||||||
|
$row->execute([$post_id]);
|
||||||
|
$c = $row->fetch();
|
||||||
|
if ((int)$c['pending'] > 0) return;
|
||||||
|
$status = (int)$c['failed'] > 0 ? 'partial' : 'done';
|
||||||
|
$db->prepare("UPDATE social_posts SET status=? WHERE id=?")->execute([$status, $post_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedIn token refresh
|
||||||
|
function ensure_li_token(string $base, array &$config): ?string {
|
||||||
|
$li = $config['linkedin'] ?? [];
|
||||||
|
if (empty($li['access_token'])) return null;
|
||||||
|
if (!empty($li['token_expiry'])) {
|
||||||
|
try {
|
||||||
|
$exp = new DateTimeImmutable($li['token_expiry'], new DateTimeZone('UTC'));
|
||||||
|
if ($exp <= new DateTimeImmutable('now', new DateTimeZone('UTC'))) { log_msg('LinkedIn token expired'); return null; }
|
||||||
|
} catch (Exception) {}
|
||||||
|
}
|
||||||
|
return $li['access_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload image to Mastodon, return media_id or null
|
||||||
|
function mastodon_upload_image(string $api_base, string $token, string $image_path): ?string {
|
||||||
|
if (!file_exists($image_path)) return null;
|
||||||
|
$ch = curl_init($api_base . '/api/v2/media');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => ['file' => new CURLFile($image_path)],
|
||||||
|
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
|
||||||
|
]);
|
||||||
|
$resp = json_decode(curl_exec($ch), true);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status === 200 || $status === 202) return $resp['id'] ?? null;
|
||||||
|
log_msg(' Mastodon media upload HTTP ' . $status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register + upload LinkedIn image, return asset URN or null
|
||||||
|
function linkedin_upload_image(string $token, string $author, string $image_path): ?string {
|
||||||
|
if (!file_exists($image_path)) return null;
|
||||||
|
|
||||||
|
// Step 1: register upload
|
||||||
|
$payload = json_encode([
|
||||||
|
'registerUploadRequest' => [
|
||||||
|
'recipes' => ['urn:li:digitalmediaRecipe:feedshare-image'],
|
||||||
|
'owner' => $author,
|
||||||
|
'serviceRelationships' => [['relationshipType' => 'OWNER', 'identifier' => 'urn:li:userGeneratedContent']],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$ch = curl_init('https://api.linkedin.com/v2/assets?action=registerUpload');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json', 'X-Restli-Protocol-Version: 2.0.0'],
|
||||||
|
]);
|
||||||
|
$resp = json_decode(curl_exec($ch), true);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status !== 200) { log_msg(' LinkedIn registerUpload HTTP ' . $status); return null; }
|
||||||
|
|
||||||
|
$upload_url = $resp['value']['uploadMechanism']['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']['uploadUrl'] ?? null;
|
||||||
|
$asset_urn = $resp['value']['asset'] ?? null;
|
||||||
|
if (!$upload_url || !$asset_urn) { log_msg(' LinkedIn registerUpload missing uploadUrl/asset'); return null; }
|
||||||
|
|
||||||
|
// Step 2: PUT binary
|
||||||
|
$ch = curl_init($upload_url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_CUSTOMREQUEST => 'PUT',
|
||||||
|
CURLOPT_POSTFIELDS => file_get_contents($image_path),
|
||||||
|
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: ' . (mime_content_type($image_path) ?: 'image/jpeg')],
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status < 200 || $status >= 300) { log_msg(' LinkedIn image PUT HTTP ' . $status); return null; }
|
||||||
|
|
||||||
|
return $asset_urn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discord multipart post (with optional file attachment)
|
||||||
|
function discord_post(string $webhook_url, string $text, ?string $image_path): array {
|
||||||
|
$boundary = '----FormBoundary' . bin2hex(random_bytes(8));
|
||||||
|
$body = "--$boundary\r\n";
|
||||||
|
$body .= "Content-Disposition: form-data; name=\"payload_json\"\r\n\r\n";
|
||||||
|
$body .= json_encode(['content' => $text]) . "\r\n";
|
||||||
|
if ($image_path && file_exists($image_path)) {
|
||||||
|
$body .= "--$boundary\r\n";
|
||||||
|
$body .= 'Content-Disposition: form-data; name="files[0]"; filename="' . basename($image_path) . '"' . "\r\n";
|
||||||
|
$body .= 'Content-Type: ' . (mime_content_type($image_path) ?: 'image/jpeg') . "\r\n\r\n";
|
||||||
|
$body .= file_get_contents($image_path) . "\r\n";
|
||||||
|
}
|
||||||
|
$body .= "--$boundary--\r\n";
|
||||||
|
|
||||||
|
$ch = curl_init($webhook_url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $body,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: multipart/form-data; boundary=' . $boundary],
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
return [$status, $resp];
|
||||||
|
}
|
||||||
|
|
||||||
|
$configs = [];
|
||||||
|
|
||||||
|
foreach ($targets as $t) {
|
||||||
|
$base = realpath($t['project_path']);
|
||||||
|
$pid = (int)$t['project_id'];
|
||||||
|
$target_id = (int)$t['target_id'];
|
||||||
|
$post_id = (int)$t['post_id'];
|
||||||
|
$platform = $t['platform'];
|
||||||
|
$target_key = $t['target_key'];
|
||||||
|
$content = $t['content'];
|
||||||
|
$with_image = (int)$t['include_image'] === 1 && $t['image_path'];
|
||||||
|
$image_path = $with_image ? ROOT . '/data/social-uploads/' . basename($t['image_path']) : null;
|
||||||
|
|
||||||
|
log_msg("post=$post_id target=$target_id platform=$platform key=$target_key" . ($image_path ? ' +image' : ''));
|
||||||
|
|
||||||
|
if (!isset($configs[$pid])) $configs[$pid] = read_config($base);
|
||||||
|
$config = &$configs[$pid];
|
||||||
|
|
||||||
|
$error = null;
|
||||||
|
|
||||||
|
if ($platform === 'discord') {
|
||||||
|
$channel = $config['discord_channels'][$target_key] ?? null;
|
||||||
|
if (!$channel) {
|
||||||
|
$error = "Discord channel '$target_key' not in config";
|
||||||
|
} else {
|
||||||
|
$env = read_env($base);
|
||||||
|
$webhook_url = $env['DISCORD_WEBHOOK_' . strtoupper($target_key)] ?? '';
|
||||||
|
if (!$webhook_url) {
|
||||||
|
$error = 'DISCORD_WEBHOOK_' . strtoupper($target_key) . ' not set in .env';
|
||||||
|
} else {
|
||||||
|
[$status, $resp] = discord_post($webhook_url, $content, $image_path);
|
||||||
|
if ($status !== 200 && $status !== 204) {
|
||||||
|
$error = 'Discord HTTP ' . $status . ': ' . (json_decode($resp, true)['message'] ?? substr($resp, 0, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} elseif ($platform === 'mastodon') {
|
||||||
|
$account = $config['mastodon'][$target_key] ?? null;
|
||||||
|
if (!$account) {
|
||||||
|
$error = "Mastodon account '$target_key' not in config";
|
||||||
|
} else {
|
||||||
|
$env = read_env($base);
|
||||||
|
$token = $env['MASTODON_TOKEN_' . strtoupper($target_key)] ?? '';
|
||||||
|
$api_base = rtrim($account['api_base_url'] ?? '', '/');
|
||||||
|
if (!$token || !$api_base) {
|
||||||
|
$error = "Mastodon '$target_key' missing token or api_base_url";
|
||||||
|
} else {
|
||||||
|
$media_ids = [];
|
||||||
|
if ($image_path) {
|
||||||
|
$mid = mastodon_upload_image($api_base, $token, $image_path);
|
||||||
|
if ($mid) $media_ids[] = $mid;
|
||||||
|
}
|
||||||
|
$params = ['status' => $content];
|
||||||
|
if ($media_ids) $params['media_ids[]'] = $media_ids[0];
|
||||||
|
$ch = curl_init($api_base . '/api/v1/statuses');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => http_build_query($params),
|
||||||
|
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/x-www-form-urlencoded'],
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status !== 200) {
|
||||||
|
$error = 'Mastodon HTTP ' . $status . ': ' . (json_decode($resp, true)['error'] ?? substr($resp, 0, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} elseif ($platform === 'linkedin') {
|
||||||
|
$page = $config['linkedin']['pages'][$target_key] ?? null;
|
||||||
|
if (!$page) {
|
||||||
|
$error = "LinkedIn page '$target_key' not in config";
|
||||||
|
} else {
|
||||||
|
$token = ensure_li_token($base, $config);
|
||||||
|
if (!$token) {
|
||||||
|
$error = 'LinkedIn token unavailable — re-run OAuth';
|
||||||
|
} else {
|
||||||
|
$page_type = $page['type'] ?? 'organization';
|
||||||
|
if ($page_type === 'personal') {
|
||||||
|
$member_id = $config['linkedin']['member_id'] ?? null;
|
||||||
|
if (!$member_id) { $error = 'member_id missing — re-run OAuth'; }
|
||||||
|
else { $author = str_starts_with($member_id, 'urn:') ? $member_id : "urn:li:person:$member_id"; }
|
||||||
|
} else {
|
||||||
|
$org_id = $page['organization_id'] ?? '';
|
||||||
|
if (!$org_id) { $error = "Organization page '$target_key' has no organization_id"; }
|
||||||
|
else { $author = "urn:li:organization:$org_id"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$error) {
|
||||||
|
$asset_urn = null;
|
||||||
|
if ($image_path) $asset_urn = linkedin_upload_image($token, $author, $image_path);
|
||||||
|
|
||||||
|
if ($asset_urn) {
|
||||||
|
$share_content = [
|
||||||
|
'shareCommentary' => ['text' => $content],
|
||||||
|
'shareMediaCategory' => 'IMAGE',
|
||||||
|
'media' => [['status' => 'READY', 'media' => $asset_urn]],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$share_content = [
|
||||||
|
'shareCommentary' => ['text' => $content],
|
||||||
|
'shareMediaCategory' => 'NONE',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode([
|
||||||
|
'author' => $author,
|
||||||
|
'lifecycleState' => 'PUBLISHED',
|
||||||
|
'specificContent' => ['com.linkedin.ugc.ShareContent' => $share_content],
|
||||||
|
'visibility' => ['com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC'],
|
||||||
|
]);
|
||||||
|
$ch = curl_init('https://api.linkedin.com/v2/ugcPosts');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json', 'X-Restli-Protocol-Version: 2.0.0'],
|
||||||
|
]);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($status !== 200 && $status !== 201) {
|
||||||
|
$error = 'LinkedIn HTTP ' . $status . ': ' . (json_decode($resp, true)['message'] ?? substr($resp, 0, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
$error = "Unknown platform '$platform'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
log_msg(" FAILED: $error");
|
||||||
|
$db->prepare("UPDATE social_targets SET status='failed', error=? WHERE id=?")->execute([$error, $target_id]);
|
||||||
|
} else {
|
||||||
|
log_msg(" OK");
|
||||||
|
$db->prepare("UPDATE social_targets SET status='sent', sent_at=? WHERE id=?")->execute([$now, $target_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_post_status($db, $post_id);
|
||||||
|
}
|
||||||
24
sql/010_social_posts.sql
Normal file
24
sql/010_social_posts.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- Global social post scheduler (project-agnostic posts, per-target project refs)
|
||||||
|
CREATE TABLE IF NOT EXISTS social_posts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
image_path TEXT,
|
||||||
|
scheduled_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending | processing | done | partial | failed
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS social_targets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
post_id INTEGER NOT NULL REFERENCES social_posts(id) ON DELETE CASCADE,
|
||||||
|
project_id INTEGER NOT NULL,
|
||||||
|
platform TEXT NOT NULL, -- discord | mastodon | linkedin
|
||||||
|
target_key TEXT NOT NULL,
|
||||||
|
include_image INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed
|
||||||
|
error TEXT,
|
||||||
|
sent_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_social_posts_status ON social_posts(status, scheduled_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_social_targets_post ON social_targets(post_id);
|
||||||
|
|
@ -33,6 +33,11 @@ $_page_title = isset($page_title) ? htmlspecialchars($page_title) . ' — ' : '
|
||||||
<i class="bi bi-journal-text me-1"></i>Audit
|
<i class="bi bi-journal-text me-1"></i>Audit
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link <?= $_nav_active === 'social' ? 'active' : '' ?>" href="/social">
|
||||||
|
<i class="bi bi-calendar-week me-1"></i>Social
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<?php $user = Auth::currentUser(); ?>
|
<?php $user = Auth::currentUser(); ?>
|
||||||
<ul class="navbar-nav">
|
<ul class="navbar-nav">
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,25 @@ $scan_paths = $db->query('SELECT * FROM scan_paths ORDER BY path')->fetchAll();
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mt-0">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-calendar-week text-primary"></i> Social scheduler — eligible projects
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Projects enabled here appear as platform sources in the Social Scheduler.
|
||||||
|
Only Discord Bot projects with configured social accounts are useful here.
|
||||||
|
</p>
|
||||||
|
<div id="socialProjectsList"><div class="text-muted small">Loading…</div></div>
|
||||||
|
<button class="btn btn-sm btn-primary mt-3" id="saveSocialProjectsBtn">Save</button>
|
||||||
|
<span class="text-muted small ms-2 d-none" id="socialProjectsSaved">Saved</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="scanResults" class="mt-4 d-none">
|
<div id="scanResults" class="mt-4 d-none">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h5 class="mb-0">Scan results</h5>
|
<h5 class="mb-0">Scan results</h5>
|
||||||
|
|
|
||||||
90
views/social.php
Normal file
90
views/social.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
$page_title = 'Social';
|
||||||
|
$nav_active = 'social';
|
||||||
|
include ROOT . '/views/_header.php';
|
||||||
|
?>
|
||||||
|
<h2 class="h4 mb-4"><i class="bi bi-calendar-week me-2"></i>Social Scheduler</h2>
|
||||||
|
|
||||||
|
<div class="row g-4" id="socialPage">
|
||||||
|
|
||||||
|
<!-- ── LEFT: Compose form ─────────────────────────────────────────── -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card" id="socialFormCard" style="position:sticky;top:1rem">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2">
|
||||||
|
<span id="socialFormTitle">Schedule a post</span>
|
||||||
|
<button class="btn btn-xs btn-outline-secondary ms-auto d-none" id="socialCancelEditBtn">
|
||||||
|
<i class="bi bi-x-lg me-1"></i>Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<input type="hidden" id="socialEditId">
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Content</label>
|
||||||
|
<textarea id="socialContent" class="form-control form-control-sm font-monospace"
|
||||||
|
rows="7" placeholder="What's on your mind?"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Schedule for</label>
|
||||||
|
<input type="datetime-local" id="socialScheduledAt" class="form-control form-control-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="small">Image <span class="text-muted">(optional)</span></span>
|
||||||
|
<div class="ms-auto d-flex gap-1">
|
||||||
|
<button type="button" class="btn btn-xs btn-outline-secondary" id="socialUploadBtn">
|
||||||
|
<i class="bi bi-image me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-xs btn-outline-danger d-none" id="socialRemoveImageBtn">
|
||||||
|
<i class="bi bi-x"></i> Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="socialImageInput" accept="image/jpeg,image/png,image/gif,image/webp" class="d-none">
|
||||||
|
<input type="hidden" id="socialImagePath">
|
||||||
|
<div id="socialImagePreview" class="d-none">
|
||||||
|
<img id="socialImageThumb" src="" alt="" class="img-fluid rounded border" style="max-height:140px;object-fit:contain">
|
||||||
|
<div class="text-muted small mt-1" id="socialImageNote">Toggle per-platform below</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Platform targets -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">Platforms</label>
|
||||||
|
<div id="socialTargets">
|
||||||
|
<div class="text-muted small">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="socialFormError" class="alert alert-danger py-2 small d-none mb-3"></div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary w-100" id="socialSubmitBtn">
|
||||||
|
<i class="bi bi-calendar-plus me-1"></i>Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── RIGHT: Post list ───────────────────────────────────────────── -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="d-flex align-items-center mb-3 gap-2">
|
||||||
|
<h5 class="mb-0">Posts</h5>
|
||||||
|
<div class="ms-auto btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-secondary active" id="socialFilterPending">Pending</button>
|
||||||
|
<button class="btn btn-outline-secondary" id="socialFilterAll">All</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" id="socialRefreshBtn" title="Refresh">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="socialPostsList">
|
||||||
|
<div class="text-muted small">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include ROOT . '/views/_footer.php'; ?>
|
||||||
196
web/api/social.php
Normal file
196
web/api/social.php
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
<?php
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$input = $method === 'POST' ? (json_decode(file_get_contents('php://input'), true) ?? []) : [];
|
||||||
|
|
||||||
|
function social_enabled_projects(PDO $db): array {
|
||||||
|
$row = $db->prepare("SELECT value FROM settings WHERE key = 'social_enabled_projects'");
|
||||||
|
$row->execute();
|
||||||
|
$val = $row->fetchColumn();
|
||||||
|
return $val ? (json_decode($val, true) ?? []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function social_project_targets(PDO $db): array {
|
||||||
|
$enabled = social_enabled_projects($db);
|
||||||
|
if (!$enabled) return [];
|
||||||
|
|
||||||
|
$placeholders = implode(',', array_fill(0, count($enabled), '?'));
|
||||||
|
$stmt = $db->prepare("SELECT id, name, path FROM projects WHERE id IN ($placeholders) AND is_active = 1");
|
||||||
|
$stmt->execute($enabled);
|
||||||
|
$projects = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($projects as $proj) {
|
||||||
|
$config_path = realpath($proj['path']) . '/data/config.json';
|
||||||
|
if (!file_exists($config_path)) continue;
|
||||||
|
$config = json_decode(file_get_contents($config_path), true);
|
||||||
|
if (!is_array($config)) continue;
|
||||||
|
|
||||||
|
$targets = ['discord' => [], 'mastodon' => [], 'linkedin' => []];
|
||||||
|
foreach ($config['discord_channels'] ?? [] as $key => $ch) {
|
||||||
|
$targets['discord'][] = ['key' => $key, 'label' => $ch['label'] ?? $key];
|
||||||
|
}
|
||||||
|
foreach ($config['mastodon'] ?? [] as $key => $acc) {
|
||||||
|
$targets['mastodon'][] = ['key' => $key, 'label' => $acc['label'] ?? $key];
|
||||||
|
}
|
||||||
|
foreach ($config['linkedin']['pages'] ?? [] as $key => $page) {
|
||||||
|
if (($page['type'] ?? 'organization') === 'personal') {
|
||||||
|
$targets['linkedin'][] = ['key' => $key, 'label' => $page['label'] ?? $key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($targets['discord'] || $targets['mastodon'] || $targets['linkedin']) {
|
||||||
|
$result[] = ['project_id' => (int)$proj['id'], 'project_name' => $proj['name'], 'targets' => $targets];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET ───────────────────────────────────────────────────────────────────────
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$action = $_GET['action'] ?? 'list';
|
||||||
|
|
||||||
|
if ($action === 'targets') {
|
||||||
|
echo json_encode(['groups' => social_project_targets($db)]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'settings') {
|
||||||
|
$enabled = social_enabled_projects($db);
|
||||||
|
$stmt = $db->query("SELECT id, name, type FROM projects WHERE is_active = 1 AND type = 'discord-bot' ORDER BY name");
|
||||||
|
$projects = $stmt->fetchAll();
|
||||||
|
echo json_encode(['enabled' => $enabled, 'projects' => $projects]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// list posts
|
||||||
|
$status_filter = $_GET['status'] ?? 'pending';
|
||||||
|
if ($status_filter === 'all') {
|
||||||
|
$stmt = $db->prepare("SELECT * FROM social_posts ORDER BY scheduled_at DESC LIMIT 200");
|
||||||
|
$stmt->execute();
|
||||||
|
} else {
|
||||||
|
$stmt = $db->prepare("SELECT * FROM social_posts WHERE status IN ('pending','partial') ORDER BY scheduled_at ASC LIMIT 200");
|
||||||
|
$stmt->execute();
|
||||||
|
}
|
||||||
|
$posts = $stmt->fetchAll();
|
||||||
|
foreach ($posts as &$post) {
|
||||||
|
$tgt = $db->prepare("SELECT t.*, p.name AS project_name FROM social_targets t JOIN projects p ON p.id = t.project_id WHERE t.post_id = ? ORDER BY t.id");
|
||||||
|
$tgt->execute([$post['id']]);
|
||||||
|
$post['targets'] = $tgt->fetchAll();
|
||||||
|
$post['image_url'] = $post['image_path'] ? '/api/social_upload?action=serve&path=' . rawurlencode(basename($post['image_path'])) : null;
|
||||||
|
}
|
||||||
|
echo json_encode(['posts' => $posts]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method !== 'POST') { http_response_code(405); echo json_encode(['error' => 'Method not allowed']); exit; }
|
||||||
|
|
||||||
|
$action = $input['action'] ?? '';
|
||||||
|
|
||||||
|
// ── SAVE SETTINGS ─────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'save_settings') {
|
||||||
|
$ids = array_map('intval', $input['project_ids'] ?? []);
|
||||||
|
$db->prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('social_enabled_projects', ?)")
|
||||||
|
->execute([json_encode($ids)]);
|
||||||
|
Audit::log($db, 'social_settings_save', null, implode(',', $ids));
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CREATE ────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'create') {
|
||||||
|
$content = trim($input['content'] ?? '');
|
||||||
|
$image_path = trim($input['image_path'] ?? '');
|
||||||
|
$scheduled_at = trim($input['scheduled_at'] ?? '');
|
||||||
|
$targets = $input['targets'] ?? [];
|
||||||
|
|
||||||
|
if (!$content) { echo json_encode(['error' => 'Content required']); exit; }
|
||||||
|
if (!$scheduled_at) { echo json_encode(['error' => 'Scheduled time required']); exit; }
|
||||||
|
if (!$targets) { echo json_encode(['error' => 'Select at least one platform target']); exit; }
|
||||||
|
|
||||||
|
$db->prepare("INSERT INTO social_posts (content, image_path, scheduled_at) VALUES (?, ?, ?)")
|
||||||
|
->execute([$content, $image_path ?: null, $scheduled_at]);
|
||||||
|
$post_id = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
$stmt = $db->prepare("INSERT INTO social_targets (post_id, project_id, platform, target_key, include_image) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
foreach ($targets as $t) {
|
||||||
|
$stmt->execute([$post_id, (int)$t['project_id'], $t['platform'], $t['target_key'], (int)($t['include_image'] ?? 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Audit::log($db, 'social_post_create', null, "post_id=$post_id");
|
||||||
|
echo json_encode(['ok' => true, 'id' => $post_id]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UPDATE ────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'update') {
|
||||||
|
$id = (int)($input['id'] ?? 0);
|
||||||
|
$content = trim($input['content'] ?? '');
|
||||||
|
$image_path = trim($input['image_path'] ?? '');
|
||||||
|
$scheduled_at = trim($input['scheduled_at'] ?? '');
|
||||||
|
$targets = $input['targets'] ?? [];
|
||||||
|
|
||||||
|
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
|
||||||
|
if (!$content) { echo json_encode(['error' => 'Content required']); exit; }
|
||||||
|
if (!$scheduled_at) { echo json_encode(['error' => 'Scheduled time required']); exit; }
|
||||||
|
if (!$targets) { echo json_encode(['error' => 'Select at least one platform target']); exit; }
|
||||||
|
|
||||||
|
$db->prepare("UPDATE social_posts SET content=?, image_path=?, scheduled_at=?, status='pending' WHERE id=?")
|
||||||
|
->execute([$content, $image_path ?: null, $scheduled_at, $id]);
|
||||||
|
$db->prepare("DELETE FROM social_targets WHERE post_id=?")->execute([$id]);
|
||||||
|
|
||||||
|
$stmt = $db->prepare("INSERT INTO social_targets (post_id, project_id, platform, target_key, include_image) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
foreach ($targets as $t) {
|
||||||
|
$stmt->execute([$id, (int)$t['project_id'], $t['platform'], $t['target_key'], (int)($t['include_image'] ?? 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Audit::log($db, 'social_post_update', null, "post_id=$id");
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DUPLICATE ─────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'duplicate') {
|
||||||
|
$id = (int)($input['id'] ?? 0);
|
||||||
|
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
|
||||||
|
|
||||||
|
$post = $db->prepare("SELECT * FROM social_posts WHERE id=?")->execute([$id]) ? null : null;
|
||||||
|
$stmt = $db->prepare("SELECT * FROM social_posts WHERE id=?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$post = $stmt->fetch();
|
||||||
|
if (!$post) { echo json_encode(['error' => 'Post not found']); exit; }
|
||||||
|
|
||||||
|
$tgt = $db->prepare("SELECT * FROM social_targets WHERE post_id=?");
|
||||||
|
$tgt->execute([$id]);
|
||||||
|
$targets = $tgt->fetchAll();
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'post' => ['content' => $post['content'], 'image_path' => $post['image_path'], 'image_url' => $post['image_path'] ? '/api/social_upload?action=serve&path=' . rawurlencode(basename($post['image_path'])) : null],
|
||||||
|
'targets' => $targets,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE ────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'delete') {
|
||||||
|
$id = (int)($input['id'] ?? 0);
|
||||||
|
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
|
||||||
|
$db->prepare("DELETE FROM social_posts WHERE id=?")->execute([$id]);
|
||||||
|
Audit::log($db, 'social_post_delete', null, "post_id=$id");
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RETRY ─────────────────────────────────────────────────────────────────────
|
||||||
|
if ($action === 'retry') {
|
||||||
|
$id = (int)($input['id'] ?? 0);
|
||||||
|
if (!$id) { echo json_encode(['error' => 'id required']); exit; }
|
||||||
|
$db->prepare("UPDATE social_targets SET status='pending', error=NULL WHERE post_id=? AND status='failed'")->execute([$id]);
|
||||||
|
$db->prepare("UPDATE social_posts SET status='pending' WHERE id=?")->execute([$id]);
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Unknown action']);
|
||||||
73
web/api/social_upload.php
Normal file
73
web/api/social_upload.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
define('UPLOAD_DIR', ROOT . '/data/social-uploads');
|
||||||
|
|
||||||
|
if (!is_dir(UPLOAD_DIR)) mkdir(UPLOAD_DIR, 0750, true);
|
||||||
|
|
||||||
|
// ── SERVE ─────────────────────────────────────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET' && ($_GET['action'] ?? '') === 'serve') {
|
||||||
|
$filename = basename($_GET['path'] ?? '');
|
||||||
|
if (!$filename) { http_response_code(400); exit; }
|
||||||
|
|
||||||
|
$full = UPLOAD_DIR . '/' . $filename;
|
||||||
|
$real = realpath($full);
|
||||||
|
if (!$real || !str_starts_with($real, realpath(UPLOAD_DIR) . '/') || !file_exists($real)) {
|
||||||
|
http_response_code(404); exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = mime_content_type($real) ?: 'application/octet-stream';
|
||||||
|
header('Content-Type: ' . $mime);
|
||||||
|
header('Content-Length: ' . filesize($real));
|
||||||
|
header('Cache-Control: private, max-age=86400');
|
||||||
|
readfile($real);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UPLOAD ────────────────────────────────────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'POST required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$file = $_FILES['image'] ?? null;
|
||||||
|
if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
echo json_encode(['error' => 'Upload failed — error code ' . ($file['error'] ?? 'none')]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
$mime = mime_content_type($file['tmp_name']);
|
||||||
|
if (!in_array($mime, $allowed_types, true)) {
|
||||||
|
echo json_encode(['error' => 'Only JPEG, PNG, GIF, and WebP images are allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['size'] > 10 * 1024 * 1024) {
|
||||||
|
echo json_encode(['error' => 'Image must be under 10 MB']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = match ($mime) {
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
default => 'jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
$filename = time() . '_' . bin2hex(random_bytes(6)) . '.' . $ext;
|
||||||
|
$dest = UPLOAD_DIR . '/' . $filename;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||||
|
echo json_encode(['error' => 'Failed to save image']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'ok' => true,
|
||||||
|
'path' => $filename,
|
||||||
|
'url' => '/api/social_upload?action=serve&path=' . rawurlencode($filename),
|
||||||
|
]);
|
||||||
|
|
@ -3819,3 +3819,352 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||||
loadLogs();
|
loadLogs();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
// ── Social Scheduler ─────────────────────────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
const page = document.getElementById('socialPage');
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
let _targets = [];
|
||||||
|
let _filter = 'pending';
|
||||||
|
let _hasImage = false;
|
||||||
|
|
||||||
|
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
|
||||||
|
// ── Targets ──────────────────────────────────────────────────────────────────
|
||||||
|
async function loadTargets() {
|
||||||
|
const r = await fetch('/api/social?action=targets');
|
||||||
|
const d = await r.json();
|
||||||
|
_targets = d.groups || [];
|
||||||
|
renderTargets(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTargets(prefill) {
|
||||||
|
const el = document.getElementById('socialTargets');
|
||||||
|
if (!_targets.length) {
|
||||||
|
el.innerHTML = '<div class="text-muted small">No eligible projects configured. <a href="/settings">Go to Settings</a> to enable projects.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
|
||||||
|
const platformLabel = { discord: 'Discord', mastodon: 'Mastodon', linkedin: 'LinkedIn' };
|
||||||
|
|
||||||
|
el.innerHTML = _targets.map(group => {
|
||||||
|
const platforms = ['discord', 'mastodon', 'linkedin'].filter(p => group.targets[p]?.length);
|
||||||
|
if (!platforms.length) return '';
|
||||||
|
return `<div class="mb-3">
|
||||||
|
<div class="small fw-semibold text-muted mb-1">${esc(group.project_name)}</div>
|
||||||
|
${platforms.map(platform => `
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="small text-muted mb-1"><i class="bi ${platformIcon[platform]}"></i> ${platformLabel[platform]}</div>
|
||||||
|
${group.targets[platform].map(t => {
|
||||||
|
const cbId = `st_${group.project_id}_${platform}_${t.key}`;
|
||||||
|
const imgId = `si_${group.project_id}_${platform}_${t.key}`;
|
||||||
|
const checked = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key) ? 'checked' : '';
|
||||||
|
const imgChecked = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key && x.include_image) ? 'checked' : '';
|
||||||
|
return `<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<input class="form-check-input social-target-cb" type="checkbox" id="${cbId}"
|
||||||
|
data-pid="${group.project_id}" data-platform="${platform}" data-key="${esc(t.key)}" ${checked}>
|
||||||
|
<label class="form-check-label small flex-grow-1" for="${cbId}">${esc(t.label)}</label>
|
||||||
|
<input class="form-check-input social-img-cb ${_hasImage ? '' : 'd-none'}" type="checkbox" id="${imgId}"
|
||||||
|
data-for="${cbId}" title="Include image" ${imgChecked}>
|
||||||
|
<label class="form-check-label small text-muted ${_hasImage ? '' : 'd-none'}" for="${imgId}">
|
||||||
|
<i class="bi bi-image"></i>
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleImageCols(show) {
|
||||||
|
_hasImage = show;
|
||||||
|
page.querySelectorAll('.social-img-cb, .social-img-cb + label').forEach(el => {
|
||||||
|
el.classList.toggle('d-none', !show);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTargets() {
|
||||||
|
const targets = [];
|
||||||
|
page.querySelectorAll('.social-target-cb:checked').forEach(cb => {
|
||||||
|
const imgCb = page.querySelector(`.social-img-cb[data-for="${cb.id}"]`);
|
||||||
|
targets.push({
|
||||||
|
project_id: +cb.dataset.pid,
|
||||||
|
platform: cb.dataset.platform,
|
||||||
|
target_key: cb.dataset.key,
|
||||||
|
include_image: imgCb?.checked ? 1 : 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image upload ─────────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('socialUploadBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('socialImageInput').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('socialImageInput').addEventListener('change', async function () {
|
||||||
|
const file = this.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('image', file);
|
||||||
|
const r = await fetch('/api/social_upload', { method: 'POST', body: fd });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { showSocialError(d.error); return; }
|
||||||
|
document.getElementById('socialImagePath').value = d.path;
|
||||||
|
document.getElementById('socialImageThumb').src = d.url;
|
||||||
|
document.getElementById('socialImagePreview').classList.remove('d-none');
|
||||||
|
document.getElementById('socialRemoveImageBtn').classList.remove('d-none');
|
||||||
|
toggleImageCols(true);
|
||||||
|
this.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('socialRemoveImageBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('socialImagePath').value = '';
|
||||||
|
document.getElementById('socialImageThumb').src = '';
|
||||||
|
document.getElementById('socialImagePreview').classList.add('d-none');
|
||||||
|
document.getElementById('socialRemoveImageBtn').classList.add('d-none');
|
||||||
|
toggleImageCols(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Form submit ───────────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('socialSubmitBtn').addEventListener('click', async () => {
|
||||||
|
const editId = document.getElementById('socialEditId').value;
|
||||||
|
const content = document.getElementById('socialContent').value.trim();
|
||||||
|
const scheduledAt = document.getElementById('socialScheduledAt').value;
|
||||||
|
const imagePath = document.getElementById('socialImagePath').value;
|
||||||
|
const targets = collectTargets();
|
||||||
|
|
||||||
|
hideSocialError();
|
||||||
|
if (!content) { showSocialError('Content is required'); return; }
|
||||||
|
if (!scheduledAt) { showSocialError('Scheduled time is required'); return; }
|
||||||
|
if (!targets.length) { showSocialError('Select at least one platform target'); return; }
|
||||||
|
|
||||||
|
const body = { action: editId ? 'update' : 'create', content, scheduled_at: scheduledAt, image_path: imagePath, targets };
|
||||||
|
if (editId) body.id = +editId;
|
||||||
|
|
||||||
|
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { showSocialError(d.error); return; }
|
||||||
|
resetForm();
|
||||||
|
loadPosts();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Edit ─────────────────────────────────────────────────────────────────────
|
||||||
|
function startEdit(post) {
|
||||||
|
document.getElementById('socialEditId').value = post.id;
|
||||||
|
document.getElementById('socialContent').value = post.content;
|
||||||
|
document.getElementById('socialScheduledAt').value = post.scheduled_at.replace(' ', 'T').substring(0, 16);
|
||||||
|
document.getElementById('socialFormTitle').textContent = 'Edit post';
|
||||||
|
document.getElementById('socialCancelEditBtn').classList.remove('d-none');
|
||||||
|
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-pencil me-1"></i>Update';
|
||||||
|
|
||||||
|
if (post.image_url) {
|
||||||
|
document.getElementById('socialImagePath').value = post.image_path || '';
|
||||||
|
document.getElementById('socialImageThumb').src = post.image_url;
|
||||||
|
document.getElementById('socialImagePreview').classList.remove('d-none');
|
||||||
|
document.getElementById('socialRemoveImageBtn').classList.remove('d-none');
|
||||||
|
toggleImageCols(true);
|
||||||
|
} else {
|
||||||
|
toggleImageCols(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTargets(post.targets || []);
|
||||||
|
document.getElementById('socialFormCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
hideSocialError();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('socialCancelEditBtn').addEventListener('click', resetForm);
|
||||||
|
|
||||||
|
// ── Duplicate ─────────────────────────────────────────────────────────────────
|
||||||
|
async function duplicatePost(id) {
|
||||||
|
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'duplicate', id }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.error) { alert(d.error); return; }
|
||||||
|
|
||||||
|
document.getElementById('socialEditId').value = '';
|
||||||
|
document.getElementById('socialContent').value = d.post.content;
|
||||||
|
document.getElementById('socialScheduledAt').value = '';
|
||||||
|
document.getElementById('socialFormTitle').textContent = 'Schedule a post';
|
||||||
|
document.getElementById('socialCancelEditBtn').classList.add('d-none');
|
||||||
|
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-calendar-plus me-1"></i>Schedule';
|
||||||
|
|
||||||
|
if (d.post.image_url) {
|
||||||
|
document.getElementById('socialImagePath').value = d.post.image_path || '';
|
||||||
|
document.getElementById('socialImageThumb').src = d.post.image_url;
|
||||||
|
document.getElementById('socialImagePreview').classList.remove('d-none');
|
||||||
|
document.getElementById('socialRemoveImageBtn').classList.remove('d-none');
|
||||||
|
toggleImageCols(true);
|
||||||
|
} else {
|
||||||
|
document.getElementById('socialImagePath').value = '';
|
||||||
|
document.getElementById('socialImagePreview').classList.add('d-none');
|
||||||
|
document.getElementById('socialRemoveImageBtn').classList.add('d-none');
|
||||||
|
toggleImageCols(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTargets(d.targets || []);
|
||||||
|
document.getElementById('socialFormCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
hideSocialError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
document.getElementById('socialEditId').value = '';
|
||||||
|
document.getElementById('socialContent').value = '';
|
||||||
|
document.getElementById('socialScheduledAt').value = '';
|
||||||
|
document.getElementById('socialImagePath').value = '';
|
||||||
|
document.getElementById('socialImageThumb').src = '';
|
||||||
|
document.getElementById('socialImagePreview').classList.add('d-none');
|
||||||
|
document.getElementById('socialRemoveImageBtn').classList.add('d-none');
|
||||||
|
document.getElementById('socialFormTitle').textContent = 'Schedule a post';
|
||||||
|
document.getElementById('socialCancelEditBtn').classList.add('d-none');
|
||||||
|
document.getElementById('socialSubmitBtn').innerHTML = '<i class="bi bi-calendar-plus me-1"></i>Schedule';
|
||||||
|
toggleImageCols(false);
|
||||||
|
renderTargets(null);
|
||||||
|
hideSocialError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Post list ─────────────────────────────────────────────────────────────────
|
||||||
|
async function loadPosts() {
|
||||||
|
const r = await fetch('/api/social?status=' + _filter);
|
||||||
|
const d = await r.json();
|
||||||
|
const el = document.getElementById('socialPostsList');
|
||||||
|
if (d.error) { el.innerHTML = `<div class="alert alert-danger small">${esc(d.error)}</div>`; return; }
|
||||||
|
if (!d.posts.length) { el.innerHTML = '<div class="text-muted small">No posts yet.</div>'; return; }
|
||||||
|
|
||||||
|
const statusBadge = s => {
|
||||||
|
const cls = { pending: 'bg-warning text-dark', sent: 'bg-success', failed: 'bg-danger', done: 'bg-success', partial: 'bg-warning text-dark', processing: 'bg-info text-dark' };
|
||||||
|
return `<span class="badge ${cls[s] || 'bg-secondary'} fw-normal">${esc(s)}</span>`;
|
||||||
|
};
|
||||||
|
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
|
||||||
|
const relTime = ts => {
|
||||||
|
const diff = (new Date(ts.replace(' ', 'T') + 'Z') - Date.now()) / 1000;
|
||||||
|
if (Math.abs(diff) < 60) return 'just now';
|
||||||
|
if (Math.abs(diff) < 3600) return Math.round(Math.abs(diff)/60) + (diff>0?' min':' min ago');
|
||||||
|
if (Math.abs(diff) < 86400) return Math.round(Math.abs(diff)/3600) + (diff>0?' h':' h ago');
|
||||||
|
return Math.round(Math.abs(diff)/86400) + (diff>0?' d':' d ago');
|
||||||
|
};
|
||||||
|
|
||||||
|
el.innerHTML = d.posts.map(post => {
|
||||||
|
const preview = post.content.length > 160 ? post.content.substring(0, 160) + '…' : post.content;
|
||||||
|
const targBadges = (post.targets || []).map(t =>
|
||||||
|
`<span class="badge bg-secondary fw-normal d-inline-flex align-items-center gap-1">
|
||||||
|
<i class="bi ${platformIcon[t.platform] || 'bi-dot'}"></i>${esc(t.target_key)}
|
||||||
|
${statusBadge(t.status)}
|
||||||
|
${t.error ? `<span title="${esc(t.error)}"><i class="bi bi-exclamation-circle text-danger"></i></span>` : ''}
|
||||||
|
</span>`).join(' ');
|
||||||
|
const imgHtml = post.image_url
|
||||||
|
? `<div class="flex-shrink-0"><img src="${esc(post.image_url)}" style="width:72px;height:72px;object-fit:cover;border-radius:4px;border:1px solid var(--hm-border)"></div>`
|
||||||
|
: '';
|
||||||
|
const canRetry = post.targets?.some(t => t.status === 'failed');
|
||||||
|
|
||||||
|
return `<div class="card mb-2 social-post-card" data-id="${post.id}">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex gap-3 align-items-start">
|
||||||
|
${imgHtml}
|
||||||
|
<div class="flex-grow-1 min-width-0">
|
||||||
|
<div class="small mb-2" style="white-space:pre-wrap">${esc(preview)}</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mb-2">${targBadges}</div>
|
||||||
|
<div class="d-flex align-items-center gap-3 flex-wrap">
|
||||||
|
<span class="small text-muted"><i class="bi bi-clock me-1"></i>${esc(post.scheduled_at)} <span class="text-muted">(${relTime(post.scheduled_at)})</span></span>
|
||||||
|
${statusBadge(post.status)}
|
||||||
|
<div class="ms-auto d-flex gap-1">
|
||||||
|
${canRetry ? `<button class="btn btn-xs btn-outline-warning retry-post-btn" data-id="${post.id}">Retry</button>` : ''}
|
||||||
|
<button class="btn btn-xs btn-outline-secondary edit-post-btn" data-id="${post.id}">Edit</button>
|
||||||
|
<button class="btn btn-xs btn-outline-secondary dup-post-btn" data-id="${post.id}">Duplicate</button>
|
||||||
|
<button class="btn btn-xs btn-outline-danger del-post-btn" data-id="${post.id}">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Store post data for edit
|
||||||
|
el._posts = d.posts;
|
||||||
|
|
||||||
|
el.querySelectorAll('.edit-post-btn').forEach(b => b.addEventListener('click', () => {
|
||||||
|
const post = el._posts.find(p => p.id == b.dataset.id);
|
||||||
|
if (post) startEdit(post);
|
||||||
|
}));
|
||||||
|
el.querySelectorAll('.dup-post-btn').forEach(b => b.addEventListener('click', () => duplicatePost(+b.dataset.id)));
|
||||||
|
el.querySelectorAll('.del-post-btn').forEach(b => b.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Delete this post?')) return;
|
||||||
|
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', id: +b.dataset.id }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) loadPosts(); else alert(d.error);
|
||||||
|
}));
|
||||||
|
el.querySelectorAll('.retry-post-btn').forEach(b => b.addEventListener('click', async () => {
|
||||||
|
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'retry', id: +b.dataset.id }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) loadPosts(); else alert(d.error);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Filters ───────────────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('socialFilterPending').addEventListener('click', function () {
|
||||||
|
_filter = 'pending';
|
||||||
|
this.classList.add('active');
|
||||||
|
document.getElementById('socialFilterAll').classList.remove('active');
|
||||||
|
loadPosts();
|
||||||
|
});
|
||||||
|
document.getElementById('socialFilterAll').addEventListener('click', function () {
|
||||||
|
_filter = 'all';
|
||||||
|
this.classList.add('active');
|
||||||
|
document.getElementById('socialFilterPending').classList.remove('active');
|
||||||
|
loadPosts();
|
||||||
|
});
|
||||||
|
document.getElementById('socialRefreshBtn').addEventListener('click', loadPosts);
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
function showSocialError(msg) {
|
||||||
|
const el = document.getElementById('socialFormError');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
function hideSocialError() {
|
||||||
|
document.getElementById('socialFormError').classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default datetime to now + 1 hour
|
||||||
|
const dt = new Date(Date.now() + 3600000);
|
||||||
|
dt.setSeconds(0, 0);
|
||||||
|
document.getElementById('socialScheduledAt').value = dt.toISOString().slice(0, 16);
|
||||||
|
|
||||||
|
loadTargets();
|
||||||
|
loadPosts();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Settings: social eligible projects ──────────────────────────────────────
|
||||||
|
(function () {
|
||||||
|
const list = document.getElementById('socialProjectsList');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
async function loadSocialSettings() {
|
||||||
|
const r = await fetch('/api/social?action=settings');
|
||||||
|
const d = await r.json();
|
||||||
|
const enabled = d.enabled || [];
|
||||||
|
if (!d.projects.length) {
|
||||||
|
list.innerHTML = '<div class="text-muted small">No Discord Bot projects found. Add one via project scan first.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = d.projects.map(p => `
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input social-proj-cb" type="checkbox" id="sp_${p.id}" value="${p.id}" ${enabled.includes(+p.id) ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label small" for="sp_${p.id}">${p.name}</label>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('saveSocialProjectsBtn').addEventListener('click', async () => {
|
||||||
|
const ids = [...list.querySelectorAll('.social-proj-cb:checked')].map(cb => +cb.value);
|
||||||
|
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'save_settings', project_ids: ids }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.ok) {
|
||||||
|
const saved = document.getElementById('socialProjectsSaved');
|
||||||
|
saved.classList.remove('d-none');
|
||||||
|
setTimeout(() => saved.classList.add('d-none'), 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadSocialSettings();
|
||||||
|
})();
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ if ($uri === '/' || $uri === '/dashboard') {
|
||||||
} elseif ($uri === '/audit') {
|
} elseif ($uri === '/audit') {
|
||||||
include ROOT . '/views/audit.php';
|
include ROOT . '/views/audit.php';
|
||||||
|
|
||||||
|
} elseif ($uri === '/social') {
|
||||||
|
include ROOT . '/views/social.php';
|
||||||
|
|
||||||
} elseif (preg_match('#^/project/(\d+)$#', $uri, $m)) {
|
} elseif (preg_match('#^/project/(\d+)$#', $uri, $m)) {
|
||||||
$project_id = (int)$m[1];
|
$project_id = (int)$m[1];
|
||||||
include ROOT . '/views/project/view.php';
|
include ROOT . '/views/project/view.php';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue