feat: Add Social Analytics module to Trinity Console (Task #108)

- Add database migration for social_posts and social_account_snapshots tables
- Create /admin/social route with full CRUD for posts
- Add dashboard view with platform tabs (TikTok, Facebook, Instagram, X, Bluesky)
- Add post entry form matching TikTok Studio metrics
- Add post detail view with update capability
- Add account snapshot form for follower/demographic tracking
- Register social router in admin index

Phase 1: Manual entry dashboard
Phase 2 (future): API integration when approved

Chronicler #76
This commit is contained in:
Claude
2026-04-10 20:14:27 +00:00
parent 685626f13f
commit b8ed2095ba
7 changed files with 1026 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
-- Social Analytics Tables
-- Task #108: TikTok Analytics Dashboard (Phase 1 - Manual Entry)
-- Created by Chronicler #76
-- Platform enum for future expansion
CREATE TYPE social_platform AS ENUM ('tiktok', 'facebook', 'instagram', 'x', 'bluesky');
-- Individual post tracking
CREATE TABLE social_posts (
id SERIAL PRIMARY KEY,
platform social_platform NOT NULL DEFAULT 'tiktok',
post_title VARCHAR(255) NOT NULL,
post_url VARCHAR(500),
posted_at TIMESTAMP NOT NULL,
video_length_seconds INTEGER,
-- Core engagement metrics
views INTEGER DEFAULT 0,
likes INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
shares INTEGER DEFAULT 0,
saves INTEGER DEFAULT 0,
-- Watch metrics
total_play_time_seconds INTEGER DEFAULT 0,
avg_watch_time_seconds DECIMAL(10,2) DEFAULT 0,
watched_full_pct DECIMAL(5,2) DEFAULT 0,
drop_off_seconds INTEGER,
-- Growth metrics
new_followers INTEGER DEFAULT 0,
-- Traffic source (store top source)
top_traffic_source VARCHAR(50),
top_traffic_pct DECIMAL(5,2),
-- Metadata
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
notes TEXT
);
-- Account-level snapshots (weekly or on-demand)
CREATE TABLE social_account_snapshots (
id SERIAL PRIMARY KEY,
platform social_platform NOT NULL DEFAULT 'tiktok',
snapshot_date DATE NOT NULL,
-- Account metrics
total_followers INTEGER DEFAULT 0,
profile_views INTEGER DEFAULT 0,
-- Top search queries (JSON array)
search_queries JSONB,
-- Audience demographics (JSON)
demographics JSONB,
created_at TIMESTAMP DEFAULT NOW(),
-- One snapshot per platform per day
UNIQUE(platform, snapshot_date)
);
-- Indexes for common queries
CREATE INDEX idx_social_posts_platform ON social_posts(platform);
CREATE INDEX idx_social_posts_posted_at ON social_posts(posted_at DESC);
CREATE INDEX idx_social_snapshots_platform_date ON social_account_snapshots(platform, snapshot_date DESC);
-- Comments
COMMENT ON TABLE social_posts IS 'Individual social media post analytics (manual entry for now, API later)';
COMMENT ON TABLE social_account_snapshots IS 'Account-level metrics snapshots for trend tracking';

View File

@@ -14,6 +14,7 @@ const rolesRouter = require('./roles');
const schedulerRouter = require('./scheduler');
const discordAuditRouter = require('./discord-audit');
const systemRouter = require('./system');
const socialRouter = require('./social');
router.use(requireTrinityAccess);
@@ -81,5 +82,6 @@ router.use('/roles', rolesRouter);
router.use('/scheduler', schedulerRouter);
router.use('/discord', discordAuditRouter);
router.use('/system', systemRouter);
router.use('/social', socialRouter);
module.exports = router;

View File

@@ -0,0 +1,262 @@
const express = require('express');
const router = express.Router();
const db = require('../../database');
// GET /admin/social - Main dashboard
router.get('/', async (req, res) => {
try {
const platform = req.query.platform || 'tiktok';
// Get recent posts (last 30 days)
const { rows: posts } = await db.query(`
SELECT * FROM social_posts
WHERE platform = $1
ORDER BY posted_at DESC
LIMIT 20
`, [platform]);
// Get aggregate stats for this platform
const { rows: stats } = await db.query(`
SELECT
COUNT(*) as total_posts,
COALESCE(SUM(views), 0) as total_views,
COALESCE(SUM(likes), 0) as total_likes,
COALESCE(SUM(comments), 0) as total_comments,
COALESCE(SUM(shares), 0) as total_shares,
COALESCE(SUM(new_followers), 0) as total_new_followers,
COALESCE(AVG(watched_full_pct), 0) as avg_completion,
COALESCE(AVG(avg_watch_time_seconds), 0) as avg_watch_time
FROM social_posts
WHERE platform = $1
AND posted_at > NOW() - INTERVAL '30 days'
`, [platform]);
// Get latest account snapshot
const { rows: snapshots } = await db.query(`
SELECT * FROM social_account_snapshots
WHERE platform = $1
ORDER BY snapshot_date DESC
LIMIT 1
`, [platform]);
// Calculate engagement rate
const totalViews = parseInt(stats[0]?.total_views || 0);
const totalLikes = parseInt(stats[0]?.total_likes || 0);
const totalComments = parseInt(stats[0]?.total_comments || 0);
const totalShares = parseInt(stats[0]?.total_shares || 0);
const engagementRate = totalViews > 0
? (((totalLikes + totalComments + totalShares) / totalViews) * 100).toFixed(2)
: 0;
res.render('admin/social/index', {
title: 'Social Analytics',
platform,
posts,
stats: {
...stats[0],
engagementRate
},
snapshot: snapshots[0] || null
});
} catch (error) {
console.error('Social Analytics Error:', error);
res.status(500).send('Error loading social analytics');
}
});
// GET /admin/social/add - Add post form
router.get('/add', (req, res) => {
const platform = req.query.platform || 'tiktok';
res.render('admin/social/add', {
title: 'Add Social Post',
platform
});
});
// POST /admin/social/add - Create new post
router.post('/add', async (req, res) => {
try {
const {
platform,
post_title,
post_url,
posted_at,
video_length_seconds,
views,
likes,
comments,
shares,
saves,
total_play_time_seconds,
avg_watch_time_seconds,
watched_full_pct,
drop_off_seconds,
new_followers,
top_traffic_source,
top_traffic_pct,
notes
} = req.body;
await db.query(`
INSERT INTO social_posts (
platform, post_title, post_url, posted_at, video_length_seconds,
views, likes, comments, shares, saves,
total_play_time_seconds, avg_watch_time_seconds, watched_full_pct,
drop_off_seconds, new_followers, top_traffic_source, top_traffic_pct, notes
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
`, [
platform || 'tiktok',
post_title,
post_url || null,
posted_at,
video_length_seconds || null,
views || 0,
likes || 0,
comments || 0,
shares || 0,
saves || 0,
total_play_time_seconds || 0,
avg_watch_time_seconds || 0,
watched_full_pct || 0,
drop_off_seconds || null,
new_followers || 0,
top_traffic_source || null,
top_traffic_pct || null,
notes || null
]);
res.redirect(`/admin/social?platform=${platform || 'tiktok'}`);
} catch (error) {
console.error('Add Post Error:', error);
res.status(500).send('Error adding post');
}
});
// GET /admin/social/post/:id - View post detail
router.get('/post/:id', async (req, res) => {
try {
const { rows } = await db.query(
'SELECT * FROM social_posts WHERE id = $1',
[req.params.id]
);
if (rows.length === 0) {
return res.status(404).send('Post not found');
}
res.render('admin/social/detail', {
title: 'Post Analytics',
post: rows[0]
});
} catch (error) {
console.error('Post Detail Error:', error);
res.status(500).send('Error loading post');
}
});
// POST /admin/social/post/:id/update - Update post metrics
router.post('/post/:id/update', async (req, res) => {
try {
const {
views, likes, comments, shares, saves,
total_play_time_seconds, avg_watch_time_seconds,
watched_full_pct, drop_off_seconds, new_followers,
top_traffic_source, top_traffic_pct, notes
} = req.body;
await db.query(`
UPDATE social_posts SET
views = $1, likes = $2, comments = $3, shares = $4, saves = $5,
total_play_time_seconds = $6, avg_watch_time_seconds = $7,
watched_full_pct = $8, drop_off_seconds = $9, new_followers = $10,
top_traffic_source = $11, top_traffic_pct = $12, notes = $13,
updated_at = NOW()
WHERE id = $14
`, [
views || 0, likes || 0, comments || 0, shares || 0, saves || 0,
total_play_time_seconds || 0, avg_watch_time_seconds || 0,
watched_full_pct || 0, drop_off_seconds || null, new_followers || 0,
top_traffic_source || null, top_traffic_pct || null, notes || null,
req.params.id
]);
res.redirect(`/admin/social/post/${req.params.id}`);
} catch (error) {
console.error('Update Post Error:', error);
res.status(500).send('Error updating post');
}
});
// GET /admin/social/snapshot - Add account snapshot form
router.get('/snapshot', (req, res) => {
const platform = req.query.platform || 'tiktok';
res.render('admin/social/snapshot', {
title: 'Account Snapshot',
platform
});
});
// POST /admin/social/snapshot - Save account snapshot
router.post('/snapshot', async (req, res) => {
try {
const {
platform,
snapshot_date,
total_followers,
profile_views,
search_queries,
demographics
} = req.body;
// Upsert - update if exists for this date, insert if not
await db.query(`
INSERT INTO social_account_snapshots
(platform, snapshot_date, total_followers, profile_views, search_queries, demographics)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (platform, snapshot_date)
DO UPDATE SET
total_followers = EXCLUDED.total_followers,
profile_views = EXCLUDED.profile_views,
search_queries = EXCLUDED.search_queries,
demographics = EXCLUDED.demographics
`, [
platform || 'tiktok',
snapshot_date,
total_followers || 0,
profile_views || 0,
search_queries ? JSON.parse(search_queries) : null,
demographics ? JSON.parse(demographics) : null
]);
res.redirect(`/admin/social?platform=${platform || 'tiktok'}`);
} catch (error) {
console.error('Snapshot Error:', error);
res.status(500).send('Error saving snapshot');
}
});
// DELETE /admin/social/post/:id - Delete post
router.post('/post/:id/delete', async (req, res) => {
try {
const { rows } = await db.query(
'SELECT platform FROM social_posts WHERE id = $1',
[req.params.id]
);
const platform = rows[0]?.platform || 'tiktok';
await db.query('DELETE FROM social_posts WHERE id = $1', [req.params.id]);
res.redirect(`/admin/social?platform=${platform}`);
} catch (error) {
console.error('Delete Post Error:', error);
res.status(500).send('Error deleting post');
}
});
module.exports = router;

View File

@@ -0,0 +1,195 @@
<div class="mb-6">
<a href="/admin/social?platform=<%= platform %>" class="text-blue-600 dark:text-blue-400 hover:underline">← Back to Dashboard</a>
</div>
<div class="max-w-2xl mx-auto">
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h1 class="text-2xl font-bold dark:text-white">📝 Add Post Analytics</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm">Enter metrics from TikTok Studio</p>
</div>
<form action="/admin/social/add" method="POST" class="p-6 space-y-6">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="platform" value="<%= platform %>">
<!-- Post Info -->
<div class="space-y-4">
<h3 class="font-semibold dark:text-white border-b pb-2 dark:border-gray-700">Post Information</h3>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Post Title/Description <span class="text-red-500">*</span>
</label>
<input type="text" name="post_title" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="Fire people build fast, break things...">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Posted Date <span class="text-red-500">*</span>
</label>
<input type="datetime-local" name="posted_at" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Video Length (seconds)
</label>
<input type="number" name="video_length_seconds" min="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="8">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Post URL (optional)
</label>
<input type="url" name="post_url"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="https://tiktok.com/@firefrostgaming/video/...">
</div>
</div>
<!-- Engagement Metrics -->
<div class="space-y-4">
<h3 class="font-semibold dark:text-white border-b pb-2 dark:border-gray-700">Engagement (from top right icons)</h3>
<div class="grid grid-cols-5 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Views</label>
<input type="number" name="views" min="0" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Likes</label>
<input type="number" name="likes" min="0" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Comments</label>
<input type="number" name="comments" min="0" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Shares</label>
<input type="number" name="shares" min="0" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Saves</label>
<input type="number" name="saves" min="0" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
</div>
</div>
<!-- Watch Metrics -->
<div class="space-y-4">
<h3 class="font-semibold dark:text-white border-b pb-2 dark:border-gray-700">Watch Metrics (from Overview tab)</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Total Play Time (seconds)
</label>
<input type="number" name="total_play_time_seconds" min="0" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="2389 (convert from 0h:39m:49s)">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Avg Watch Time (seconds)
</label>
<input type="number" name="avg_watch_time_seconds" min="0" step="0.01" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="2.84">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Watched Full Video (%)
</label>
<input type="number" name="watched_full_pct" min="0" max="100" step="0.1" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="5.5">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Drop-off Point (seconds)
</label>
<input type="number" name="drop_off_seconds" min="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="2">
</div>
</div>
</div>
<!-- Growth & Traffic -->
<div class="space-y-4">
<h3 class="font-semibold dark:text-white border-b pb-2 dark:border-gray-700">Growth & Traffic</h3>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Followers
</label>
<input type="number" name="new_followers" min="0" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Top Traffic Source
</label>
<select name="top_traffic_source"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
<option value="">-- Select --</option>
<option value="For You">For You</option>
<option value="Search">Search</option>
<option value="Following">Following</option>
<option value="Personal profile">Personal Profile</option>
<option value="Sound">Sound</option>
<option value="Direct messages">Direct Messages</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Traffic Source (%)
</label>
<input type="number" name="top_traffic_pct" min="0" max="100" step="0.1"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="99.3">
</div>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes (optional)
</label>
<textarea name="notes" rows="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="87% female audience, 57% age 18-24..."></textarea>
</div>
<!-- Submit -->
<div class="flex justify-end gap-3 pt-4 border-t dark:border-gray-700">
<a href="/admin/social?platform=<%= platform %>"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:text-white">
Cancel
</a>
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
Save Post
</button>
</div>
</form>
</div>
</div>

View File

@@ -0,0 +1,235 @@
<div class="mb-6">
<a href="/admin/social?platform=<%= post.platform %>" class="text-blue-600 dark:text-blue-400 hover:underline">← Back to Dashboard</a>
</div>
<div class="max-w-4xl mx-auto">
<!-- Post Header -->
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700 mb-6">
<div class="p-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-xl font-bold dark:text-white mb-2"><%= post.post_title %></h1>
<div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<span>📅 <%= new Date(post.posted_at).toLocaleDateString() %></span>
<% if (post.video_length_seconds) { %>
<span>⏱️ <%= post.video_length_seconds %>s</span>
<% } %>
<% if (post.post_url) { %>
<a href="<%= post.post_url %>" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">View on TikTok →</a>
<% } %>
</div>
</div>
<form action="/admin/social/post/<%= post.id %>/delete" method="POST"
onsubmit="return confirm('Delete this post?');">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="text-red-600 hover:text-red-800 dark:text-red-400 text-sm">
🗑️ Delete
</button>
</form>
</div>
</div>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400"><%= Number(post.views || 0).toLocaleString() %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Views</div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
<div class="text-2xl font-bold text-pink-600 dark:text-pink-400"><%= Number(post.likes || 0).toLocaleString() %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Likes</div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
<div class="text-2xl font-bold dark:text-white"><%= post.comments || 0 %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Comments</div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
<div class="text-2xl font-bold text-green-600 dark:text-green-400"><%= post.shares || 0 %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Shares</div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700 text-center">
<div class="text-2xl font-bold text-purple-600 dark:text-purple-400"><%= post.saves || 0 %></div>
<div class="text-xs text-gray-500 dark:text-gray-400">Saves</div>
</div>
</div>
<!-- Watch & Traffic Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Watch Performance -->
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 class="font-semibold dark:text-white mb-4">⏱️ Watch Performance</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Avg Watch Time</span>
<span class="font-bold dark:text-white"><%= Number(post.avg_watch_time_seconds || 0).toFixed(2) %>s</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Watched Full Video</span>
<span class="font-bold <%= post.watched_full_pct >= 20 ? 'text-green-600' : post.watched_full_pct >= 10 ? 'text-yellow-600' : 'text-red-600' %>">
<%= Number(post.watched_full_pct || 0).toFixed(1) %>%
</span>
</div>
<% if (post.drop_off_seconds) { %>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Drop-off Point</span>
<span class="font-bold text-red-600 dark:text-red-400"><%= post.drop_off_seconds %>s</span>
</div>
<% } %>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Total Play Time</span>
<span class="font-bold dark:text-white">
<%
const totalSecs = post.total_play_time_seconds || 0;
const hrs = Math.floor(totalSecs / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = totalSecs % 60;
%>
<%= hrs %>h:<%= mins %>m:<%= secs %>s
</span>
</div>
</div>
</div>
<!-- Traffic Source -->
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 class="font-semibold dark:text-white mb-4">📊 Traffic & Growth</h3>
<div class="space-y-3">
<% if (post.top_traffic_source) { %>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Top Source</span>
<span class="font-bold dark:text-white">
<%= post.top_traffic_source %>
<% if (post.top_traffic_pct) { %>
(<%= post.top_traffic_pct %>%)
<% } %>
</span>
</div>
<% } %>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">New Followers</span>
<span class="font-bold text-purple-600 dark:text-purple-400"><%= post.new_followers || 0 %></span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Engagement Rate</span>
<%
const engRate = post.views > 0
? (((post.likes + post.comments + post.shares) / post.views) * 100).toFixed(2)
: 0;
%>
<span class="font-bold text-orange-600 dark:text-orange-400"><%= engRate %>%</span>
</div>
</div>
</div>
</div>
<!-- Update Form -->
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="font-semibold dark:text-white">📝 Update Metrics</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Refresh numbers from TikTok Studio</p>
</div>
<form action="/admin/social/post/<%= post.id %>/update" method="POST" class="p-6 space-y-4">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<div class="grid grid-cols-5 gap-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Views</label>
<input type="number" name="views" value="<%= post.views || 0 %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Likes</label>
<input type="number" name="likes" value="<%= post.likes || 0 %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Comments</label>
<input type="number" name="comments" value="<%= post.comments || 0 %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Shares</label>
<input type="number" name="shares" value="<%= post.shares || 0 %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Saves</label>
<input type="number" name="saves" value="<%= post.saves || 0 %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
</div>
<div class="grid grid-cols-4 gap-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Total Play Time (s)</label>
<input type="number" name="total_play_time_seconds" value="<%= post.total_play_time_seconds || 0 %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Avg Watch (s)</label>
<input type="number" name="avg_watch_time_seconds" step="0.01" value="<%= post.avg_watch_time_seconds || 0 %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Watched Full (%)</label>
<input type="number" name="watched_full_pct" step="0.1" value="<%= post.watched_full_pct || 0 %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Drop-off (s)</label>
<input type="number" name="drop_off_seconds" value="<%= post.drop_off_seconds || '' %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">New Followers</label>
<input type="number" name="new_followers" value="<%= post.new_followers || 0 %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Top Traffic Source</label>
<select name="top_traffic_source"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
<option value="">-- Select --</option>
<option value="For You" <%= post.top_traffic_source === 'For You' ? 'selected' : '' %>>For You</option>
<option value="Search" <%= post.top_traffic_source === 'Search' ? 'selected' : '' %>>Search</option>
<option value="Following" <%= post.top_traffic_source === 'Following' ? 'selected' : '' %>>Following</option>
<option value="Personal profile" <%= post.top_traffic_source === 'Personal profile' ? 'selected' : '' %>>Personal Profile</option>
<option value="Sound" <%= post.top_traffic_source === 'Sound' ? 'selected' : '' %>>Sound</option>
<option value="Direct messages" <%= post.top_traffic_source === 'Direct messages' ? 'selected' : '' %>>Direct Messages</option>
<option value="Other" <%= post.top_traffic_source === 'Other' ? 'selected' : '' %>>Other</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Traffic %</label>
<input type="number" name="top_traffic_pct" step="0.1" value="<%= post.top_traffic_pct || '' %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Notes</label>
<textarea name="notes" rows="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"><%= post.notes || '' %></textarea>
</div>
<div class="flex justify-end">
<button type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
Update Metrics
</button>
</div>
</form>
</div>
<% if (post.notes) { %>
<div class="mt-6 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
<h4 class="font-semibold text-yellow-800 dark:text-yellow-200 mb-2">📝 Notes</h4>
<p class="text-yellow-700 dark:text-yellow-300"><%= post.notes %></p>
</div>
<% } %>
</div>

View File

@@ -0,0 +1,184 @@
<div class="mb-6 flex justify-between items-center">
<div>
<h1 class="text-2xl font-bold dark:text-white">📊 Social Analytics</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm">Track post performance and audience growth</p>
</div>
<div class="flex gap-2">
<a href="/admin/social/add?platform=<%= platform %>"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">
+ Add Post
</a>
<a href="/admin/social/snapshot?platform=<%= platform %>"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition">
📸 Snapshot
</a>
</div>
</div>
<!-- Platform Tabs -->
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav class="flex gap-4">
<a href="/admin/social?platform=tiktok"
class="pb-3 px-1 border-b-2 <%= platform === 'tiktok' ? 'border-pink-500 text-pink-600 dark:text-pink-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
🎵 TikTok
</a>
<a href="/admin/social?platform=facebook"
class="pb-3 px-1 border-b-2 <%= platform === 'facebook' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
📘 Facebook
</a>
<a href="/admin/social?platform=instagram"
class="pb-3 px-1 border-b-2 <%= platform === 'instagram' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
📷 Instagram
</a>
<a href="/admin/social?platform=x"
class="pb-3 px-1 border-b-2 <%= platform === 'x' ? 'border-gray-800 text-gray-800 dark:text-white' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
𝕏 X
</a>
<a href="/admin/social?platform=bluesky"
class="pb-3 px-1 border-b-2 <%= platform === 'bluesky' ? 'border-sky-500 text-sky-600 dark:text-sky-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400' %>">
🦋 Bluesky
</a>
</nav>
</div>
<!-- Stats Cards (Last 30 Days) -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Posts</div>
<div class="text-xl font-bold dark:text-white"><%= stats.total_posts || 0 %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Views</div>
<div class="text-xl font-bold text-blue-600 dark:text-blue-400"><%= Number(stats.total_views || 0).toLocaleString() %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Likes</div>
<div class="text-xl font-bold text-pink-600 dark:text-pink-400"><%= Number(stats.total_likes || 0).toLocaleString() %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Comments</div>
<div class="text-xl font-bold dark:text-white"><%= Number(stats.total_comments || 0).toLocaleString() %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Shares</div>
<div class="text-xl font-bold text-green-600 dark:text-green-400"><%= Number(stats.total_shares || 0).toLocaleString() %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">New Followers</div>
<div class="text-xl font-bold text-purple-600 dark:text-purple-400"><%= Number(stats.total_new_followers || 0).toLocaleString() %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Engagement</div>
<div class="text-xl font-bold text-orange-600 dark:text-orange-400"><%= stats.engagementRate %>%</div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">Avg Watch</div>
<div class="text-xl font-bold dark:text-white"><%= Number(stats.avg_watch_time || 0).toFixed(1) %>s</div>
</div>
</div>
<% if (snapshot) { %>
<!-- Account Snapshot -->
<div class="bg-gradient-to-r from-pink-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 rounded-lg p-4 mb-6 border border-pink-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Account Snapshot (<%= new Date(snapshot.snapshot_date).toLocaleDateString() %>)</span>
<div class="flex gap-6 mt-1">
<div>
<span class="text-gray-600 dark:text-gray-300">Followers:</span>
<span class="font-bold dark:text-white ml-1"><%= Number(snapshot.total_followers || 0).toLocaleString() %></span>
</div>
<div>
<span class="text-gray-600 dark:text-gray-300">Profile Views:</span>
<span class="font-bold dark:text-white ml-1"><%= Number(snapshot.profile_views || 0).toLocaleString() %></span>
</div>
</div>
</div>
<% if (snapshot.search_queries && snapshot.search_queries.length > 0) { %>
<div class="text-right">
<span class="text-sm text-gray-500 dark:text-gray-400">Top Searches</span>
<div class="text-sm dark:text-gray-300">
<% snapshot.search_queries.slice(0, 3).forEach(q => { %>
<span class="inline-block bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs mr-1"><%= q %></span>
<% }); %>
</div>
</div>
<% } %>
</div>
</div>
<% } %>
<!-- Posts Table -->
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-bold dark:text-white">Recent Posts</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Last 30 days performance</p>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Post</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Views</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Likes</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Comments</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Shares</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Completion</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Avg Watch</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<% if (posts && posts.length > 0) { %>
<% posts.forEach(post => { %>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3">
<div class="text-sm font-medium dark:text-white truncate max-w-xs" title="<%= post.post_title %>">
<%= post.post_title.substring(0, 50) %><%= post.post_title.length > 50 ? '...' : '' %>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
<%= new Date(post.posted_at).toLocaleDateString() %>
<% if (post.video_length_seconds) { %>
· <%= post.video_length_seconds %>s
<% } %>
</div>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm font-medium text-blue-600 dark:text-blue-400"><%= Number(post.views || 0).toLocaleString() %></span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm text-pink-600 dark:text-pink-400"><%= Number(post.likes || 0).toLocaleString() %></span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm dark:text-white"><%= post.comments || 0 %></span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm text-green-600 dark:text-green-400"><%= post.shares || 0 %></span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm <%= post.watched_full_pct >= 20 ? 'text-green-600 dark:text-green-400' : post.watched_full_pct >= 10 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400' %>">
<%= Number(post.watched_full_pct || 0).toFixed(1) %>%
</span>
</td>
<td class="px-4 py-3 text-right">
<span class="text-sm dark:text-white"><%= Number(post.avg_watch_time_seconds || 0).toFixed(1) %>s</span>
</td>
<td class="px-4 py-3 text-center">
<a href="/admin/social/post/<%= post.id %>"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 text-sm">
View
</a>
</td>
</tr>
<% }); %>
<% } else { %>
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
No posts tracked yet. <a href="/admin/social/add?platform=<%= platform %>" class="text-blue-600 dark:text-blue-400">Add your first post</a>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,76 @@
<div class="mb-6">
<a href="/admin/social?platform=<%= platform %>" class="text-blue-600 dark:text-blue-400 hover:underline">← Back to Dashboard</a>
</div>
<div class="max-w-xl mx-auto">
<div class="bg-white dark:bg-darkcard rounded-lg border border-gray-200 dark:border-gray-700">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h1 class="text-2xl font-bold dark:text-white">📸 Account Snapshot</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm">Record account-level metrics from TikTok Studio Overview</p>
</div>
<form action="/admin/social/snapshot" method="POST" class="p-6 space-y-6">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="platform" value="<%= platform %>">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Snapshot Date <span class="text-red-500">*</span>
</label>
<input type="date" name="snapshot_date" required
value="<%= new Date().toISOString().split('T')[0] %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Total Followers
</label>
<input type="number" name="total_followers" min="0" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="From Followers tab">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Profile Views (7 days)
</label>
<input type="number" name="profile_views" min="0" value="0"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder="From Overview">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Top Search Queries (JSON array)
</label>
<input type="text" name="search_queries"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder='["frost fire server minecraft", "fire trail minecraft"]'>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Copy from Overview → Search queries section</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Demographics (JSON)
</label>
<textarea name="demographics" rows="3"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
placeholder='{"gender": {"female": 87, "male": 13}, "age": {"18-24": 57, "25-34": 13}}'></textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">From Viewers tab (optional but useful)</p>
</div>
<div class="flex justify-end gap-3 pt-4 border-t dark:border-gray-700">
<a href="/admin/social?platform=<%= platform %>"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:text-white">
Cancel
</a>
<button type="submit"
class="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition">
Save Snapshot
</button>
</div>
</form>
</div>
</div>