mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 17:24:12 +00:00
feat: add YouTube Analytics Dashboard custom admin view
Custom admin view at /admin/youtube-analytics with 4 tabs: - Performance: Views, Watch Time, CTR, Subscribers with period comparison - Pipeline: Status distribution, scheduled videos, overdue tasks - Goals: Monthly target progress bars and custom KPIs - Community: Sentiment analysis, response time, top topics Includes channel selector, period selector (7d/30d/90d), and sidebar nav link in the YouTube section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8b037c91af
commit
06c93ba05c
10 changed files with 3309 additions and 3 deletions
155
docs/plans/2026-02-13-youtube-analytics-dashboard-design.md
Normal file
155
docs/plans/2026-02-13-youtube-analytics-dashboard-design.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# YouTube Analytics Dashboard - Design
|
||||
|
||||
**Datum:** 2026-02-13
|
||||
**Status:** Approved
|
||||
|
||||
## Übersicht
|
||||
|
||||
Custom Admin View für YouTube Analytics unter `/admin/youtube-analytics`.
|
||||
Kombiniertes Dashboard mit 4 Tabs: Performance, Pipeline, Goals, Community.
|
||||
Channel-Selector + Period-Selector oben. Sidebar Nav-Link im YouTube-Bereich.
|
||||
|
||||
## Architektur
|
||||
|
||||
### Ansatz
|
||||
Einzelnes Dashboard mit Tab-Navigation (Ansatz A).
|
||||
Jeder Tab lädt Daten lazy beim Wechsel über eine gemeinsame API-Route.
|
||||
|
||||
### Dateien
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/admin/
|
||||
│ ├── YouTubeAnalyticsDashboard.tsx # Haupt-Component (Client)
|
||||
│ ├── YouTubeAnalyticsDashboardView.tsx # Wrapper für Payload Custom View
|
||||
│ ├── YouTubeAnalyticsDashboard.scss # BEM-Styling
|
||||
│ └── YouTubeAnalyticsNavLinks.tsx # Sidebar Nav-Link Component
|
||||
├── app/(payload)/api/youtube/
|
||||
│ └── analytics/route.ts # API-Route für alle Tabs
|
||||
```
|
||||
|
||||
### Registrierung (payload.config.ts)
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
components: {
|
||||
afterNavLinks: [
|
||||
'@/components/admin/CommunityNavLinks#CommunityNavLinks',
|
||||
'@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks',
|
||||
],
|
||||
views: {
|
||||
TenantDashboard: { ... },
|
||||
YouTubeAnalyticsDashboard: {
|
||||
Component: '@/components/admin/YouTubeAnalyticsDashboardView#YouTubeAnalyticsDashboardView',
|
||||
path: '/youtube-analytics',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Datenfluss
|
||||
|
||||
```
|
||||
Browser → YouTubeAnalyticsDashboard (Client Component)
|
||||
→ fetch('/api/youtube/analytics?tab=X&channel=Y&period=Z')
|
||||
→ API Route → payload.find() / payload.count() parallel
|
||||
→ Aggregierte JSON-Response
|
||||
```
|
||||
|
||||
## Tab-Struktur & Metriken
|
||||
|
||||
### Tab 1: Performance
|
||||
|
||||
**Stat-Cards:**
|
||||
- Gesamt-Views (Zeitraum) mit Trend vs. Vorperiode
|
||||
- Gesamt Watch Time (Stunden)
|
||||
- Durchschnittliche CTR (%)
|
||||
- Subscriber-Gewinn (Zeitraum)
|
||||
|
||||
**Sektionen:**
|
||||
- Top 5 Videos (nach Views): Thumbnail-Text, Title, Views, CTR, Avg. Retention
|
||||
- Bottom 5 Videos (nach Views): gleiche Felder
|
||||
- Engagement-Übersicht: Likes, Comments, Shares aggregiert
|
||||
|
||||
### Tab 2: Pipeline
|
||||
|
||||
**Stat-Cards:**
|
||||
- Videos in Produktion (status < published)
|
||||
- Diese Woche geplant
|
||||
- Überfällige Aufgaben
|
||||
- Offene Approvals
|
||||
|
||||
**Sektionen:**
|
||||
- Pipeline-Balken: Visueller Balken mit Anzahl pro Status-Stufe (idea → published)
|
||||
- Nächste Veröffentlichungen: Liste der scheduled Videos mit Datum
|
||||
- Überfällige Tasks: Liste mit Assignee, Deadline, Video-Titel
|
||||
|
||||
### Tab 3: Goals
|
||||
|
||||
**Stat-Cards:**
|
||||
- Content-Ziel-Fortschritt (z.B. "8/12 Videos")
|
||||
- Subscriber-Ziel-Fortschritt
|
||||
- Views-Ziel-Fortschritt
|
||||
- Gesamt-Zielerreichung (%)
|
||||
|
||||
**Sektionen:**
|
||||
- Monatsziele-Übersicht: Alle Goals mit Progress-Bars (Target vs. Current)
|
||||
- Custom Goals: User-definierte Metriken aus YtMonthlyGoals
|
||||
|
||||
### Tab 4: Community
|
||||
|
||||
**Stat-Cards:**
|
||||
- Unbearbeitete Kommentare
|
||||
- Sentiment-Verteilung (% positiv)
|
||||
- Durchschnittliche Antwortzeit
|
||||
- Eskalationen offen
|
||||
|
||||
**Sektionen:**
|
||||
- Sentiment-Breakdown: Positiv/Neutral/Negativ mit Zähler
|
||||
- Letzte Interaktionen: 5 neueste unbearbeitete Kommentare
|
||||
- Top-Themen: Häufigste Topics aus AI-Analyse
|
||||
|
||||
## API-Route
|
||||
|
||||
**Endpoint:** `GET /api/youtube/analytics`
|
||||
|
||||
| Param | Werte | Default |
|
||||
|-------|-------|---------|
|
||||
| `tab` | `performance`, `pipeline`, `goals`, `community` | `performance` |
|
||||
| `channel` | `all` oder Channel-ID | `all` |
|
||||
| `period` | `7d`, `30d`, `90d` | `30d` |
|
||||
|
||||
**Auth:** Cookie-basiert, YouTube-Access Check.
|
||||
**Response:** Tab-spezifisches JSON. Nur Daten des angeforderten Tabs.
|
||||
|
||||
## UI/Styling
|
||||
|
||||
- BEM mit `.yt-analytics` Prefix
|
||||
- Payload CSS-Variablen (`--theme-elevation-*`, `--theme-success-*`, etc.)
|
||||
- Tab-Bar: Horizontal unter Header, aktiver Tab mit Border-Bottom
|
||||
- Stat-Cards: 4er Grid mit farbigen Left-Borders (wie TenantDashboard)
|
||||
- Pipeline-Balken: Horizontal segmentiert, jede Status-Stufe eine Farbe
|
||||
- Progress-Bars: Success-Color für On-Track, Warning für At-Risk
|
||||
- Responsive: 2-Spalten Grid auf Mobile, Tabs horizontal scrollbar
|
||||
- Dark Mode: `.dark .yt-analytics` Overrides
|
||||
|
||||
## Datenquellen
|
||||
|
||||
| Collection | Verwendung |
|
||||
|------------|------------|
|
||||
| YouTubeContent | Performance-Metriken, Pipeline-Status, Veröffentlichungen |
|
||||
| YouTubeChannels | Channel-Liste für Selector, Subscriber-Count |
|
||||
| YtMonthlyGoals | Goals-Tab: Target vs. Current |
|
||||
| YtTasks | Pipeline-Tab: Überfällige Tasks, Workload |
|
||||
| YtBatches | Pipeline-Tab: Batch-Fortschritt |
|
||||
| CommunityInteractions | Community-Tab: Sentiment, Antwortzeit, Themen |
|
||||
|
||||
## Patterns (aus TenantDashboard)
|
||||
|
||||
- `useTenantSelection()` für Tenant-Kontext
|
||||
- `useState` + `useEffect` + `useCallback` für Daten-Fetching
|
||||
- `credentials: 'include'` für Auth-Cookies
|
||||
- Loading/Error States mit Spinner/Error-Box
|
||||
- Period-Selector als `<select>`
|
||||
- Refresh-Button mit Spin-Animation
|
||||
971
docs/plans/2026-02-13-youtube-analytics-dashboard.md
Normal file
971
docs/plans/2026-02-13-youtube-analytics-dashboard.md
Normal file
|
|
@ -0,0 +1,971 @@
|
|||
# YouTube Analytics Dashboard Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a custom admin view at `/admin/youtube-analytics` with 4 tabs (Performance, Pipeline, Goals, Community), channel/period selectors, and a sidebar nav link.
|
||||
|
||||
**Architecture:** Single client component with tab navigation. One API route serves all tabs via `?tab=X` query parameter, loading data lazily per tab switch. Follows the TenantDashboard pattern (BEM CSS, Payload variables, `useTenantSelection()`).
|
||||
|
||||
**Tech Stack:** React 19, Next.js 16, Payload CMS 3.76.1, SCSS (BEM), Payload CSS variables
|
||||
|
||||
**Design doc:** `docs/plans/2026-02-13-youtube-analytics-dashboard-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: API Route — Performance Tab
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/(payload)/api/youtube/analytics/route.ts`
|
||||
|
||||
**Step 1: Create the API route with performance tab handler**
|
||||
|
||||
```typescript
|
||||
// src/app/(payload)/api/youtube/analytics/route.ts
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { subDays } from 'date-fns'
|
||||
|
||||
interface UserWithYouTubeRole {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
youtubeRole?: string
|
||||
}
|
||||
|
||||
function getPeriodDates(period: string) {
|
||||
const now = new Date()
|
||||
switch (period) {
|
||||
case '7d':
|
||||
return { start: subDays(now, 7), prevStart: subDays(now, 14), prevEnd: subDays(now, 7) }
|
||||
case '90d':
|
||||
return { start: subDays(now, 90), prevStart: subDays(now, 180), prevEnd: subDays(now, 90) }
|
||||
default:
|
||||
return { start: subDays(now, 30), prevStart: subDays(now, 60), prevEnd: subDays(now, 30) }
|
||||
}
|
||||
}
|
||||
|
||||
async function getPerformanceData(payload: any, channel: string, period: string) {
|
||||
const { start, prevStart, prevEnd } = getPeriodDates(period)
|
||||
|
||||
// Build where clause for published videos with performance data
|
||||
const where: Record<string, unknown> = {
|
||||
status: { equals: 'published' },
|
||||
actualPublishDate: { greater_than_equal: start.toISOString() },
|
||||
}
|
||||
if (channel !== 'all') {
|
||||
where.channel = { equals: parseInt(channel) }
|
||||
}
|
||||
|
||||
// Previous period where clause
|
||||
const prevWhere: Record<string, unknown> = {
|
||||
status: { equals: 'published' },
|
||||
actualPublishDate: {
|
||||
greater_than_equal: prevStart.toISOString(),
|
||||
less_than: prevEnd.toISOString(),
|
||||
},
|
||||
}
|
||||
if (channel !== 'all') {
|
||||
prevWhere.channel = { equals: parseInt(channel) }
|
||||
}
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'youtube-content',
|
||||
where,
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
sort: '-performance.views',
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'youtube-content',
|
||||
where: prevWhere,
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
}),
|
||||
])
|
||||
|
||||
const docs = current.docs
|
||||
const prevDocs = previous.docs
|
||||
|
||||
// Aggregate current period
|
||||
let totalViews = 0, totalWatchTime = 0, totalCtr = 0, totalSubscribers = 0
|
||||
let totalLikes = 0, totalComments = 0, totalShares = 0
|
||||
let ctrCount = 0
|
||||
|
||||
for (const doc of docs) {
|
||||
const p = doc.performance || {}
|
||||
totalViews += p.views || 0
|
||||
totalWatchTime += p.watchTimeMinutes || 0
|
||||
if (p.ctr != null) { totalCtr += p.ctr; ctrCount++ }
|
||||
totalSubscribers += p.subscribersGained || 0
|
||||
totalLikes += p.likes || 0
|
||||
totalComments += p.comments || 0
|
||||
totalShares += p.shares || 0
|
||||
}
|
||||
|
||||
// Aggregate previous period
|
||||
let prevViews = 0, prevWatchTime = 0, prevSubscribers = 0
|
||||
for (const doc of prevDocs) {
|
||||
const p = doc.performance || {}
|
||||
prevViews += p.views || 0
|
||||
prevWatchTime += p.watchTimeMinutes || 0
|
||||
prevSubscribers += p.subscribersGained || 0
|
||||
}
|
||||
|
||||
// Top 5 and Bottom 5
|
||||
const sorted = [...docs].sort((a, b) => (b.performance?.views || 0) - (a.performance?.views || 0))
|
||||
const mapVideo = (d: any) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
thumbnailText: d.thumbnailText || '',
|
||||
views: d.performance?.views || 0,
|
||||
ctr: d.performance?.ctr || 0,
|
||||
avgRetention: d.performance?.avgViewPercentage || 0,
|
||||
likes: d.performance?.likes || 0,
|
||||
})
|
||||
|
||||
return {
|
||||
stats: {
|
||||
totalViews,
|
||||
totalWatchTimeHours: Math.round((totalWatchTime / 60) * 10) / 10,
|
||||
avgCtr: ctrCount > 0 ? Math.round((totalCtr / ctrCount) * 100) / 100 : 0,
|
||||
subscribersGained: totalSubscribers,
|
||||
},
|
||||
comparison: {
|
||||
views: prevViews > 0 ? Math.round(((totalViews - prevViews) / prevViews) * 100) : 0,
|
||||
watchTime: prevWatchTime > 0 ? Math.round(((totalWatchTime - prevWatchTime) / prevWatchTime) * 100) : 0,
|
||||
subscribers: prevSubscribers > 0 ? Math.round(((totalSubscribers - prevSubscribers) / prevSubscribers) * 100) : 0,
|
||||
},
|
||||
topVideos: sorted.slice(0, 5).map(mapVideo),
|
||||
bottomVideos: sorted.length > 5 ? sorted.slice(-5).reverse().map(mapVideo) : [],
|
||||
engagement: { likes: totalLikes, comments: totalComments, shares: totalShares },
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const { user } = await payload.auth({ headers: req.headers })
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const typedUser = user as UserWithYouTubeRole
|
||||
if (!typedUser.isSuperAdmin && (!typedUser.youtubeRole || typedUser.youtubeRole === 'none')) {
|
||||
return NextResponse.json({ error: 'No YouTube access' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const tab = searchParams.get('tab') || 'performance'
|
||||
const channel = searchParams.get('channel') || 'all'
|
||||
const period = searchParams.get('period') || '30d'
|
||||
|
||||
// Channels always needed for selector
|
||||
const channels = await payload.find({
|
||||
collection: 'youtube-channels',
|
||||
where: { status: { equals: 'active' } },
|
||||
depth: 0,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
let tabData: unknown = {}
|
||||
|
||||
switch (tab) {
|
||||
case 'performance':
|
||||
tabData = await getPerformanceData(payload, channel, period)
|
||||
break
|
||||
// Pipeline, Goals, Community added in subsequent tasks
|
||||
default:
|
||||
tabData = {}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tab,
|
||||
channel,
|
||||
period,
|
||||
channels: channels.docs.map((c: any) => ({ id: c.id, name: c.name, slug: c.slug })),
|
||||
data: tabData,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[YouTube Analytics] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
```
|
||||
|
||||
**Step 2: Verify route loads**
|
||||
|
||||
Run: `curl -s -o /dev/null -w '%{http_code}' https://pl.porwoll.tech/api/youtube/analytics`
|
||||
Expected: 401 (no auth) — confirms route is registered
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/\(payload\)/api/youtube/analytics/route.ts
|
||||
git commit -m "feat(yt-analytics): add API route with performance tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: API Route — Pipeline, Goals, Community Tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(payload)/api/youtube/analytics/route.ts`
|
||||
|
||||
**Step 1: Add pipeline tab handler**
|
||||
|
||||
Add this function before the `GET` handler:
|
||||
|
||||
```typescript
|
||||
async function getPipelineData(payload: any, channel: string) {
|
||||
const channelFilter: Record<string, unknown> = channel !== 'all'
|
||||
? { channel: { equals: parseInt(channel) } }
|
||||
: {}
|
||||
|
||||
// Pipeline status counts (parallel)
|
||||
const statuses = ['idea', 'script_draft', 'script_review', 'script_approved',
|
||||
'shoot_scheduled', 'shot', 'rough_cut', 'fine_cut', 'final_review',
|
||||
'approved', 'upload_scheduled', 'published']
|
||||
|
||||
const counts = await Promise.all(
|
||||
statuses.map((s) =>
|
||||
payload.count({
|
||||
collection: 'youtube-content',
|
||||
where: { status: { equals: s }, ...channelFilter },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const pipeline: Record<string, number> = {}
|
||||
statuses.forEach((s, i) => { pipeline[s] = counts[i].totalDocs })
|
||||
|
||||
// In production count (everything between idea and published exclusive)
|
||||
const inProduction = Object.entries(pipeline)
|
||||
.filter(([k]) => k !== 'published')
|
||||
.reduce((sum, [, v]) => sum + v, 0)
|
||||
|
||||
// This week scheduled
|
||||
const weekStart = new Date()
|
||||
weekStart.setHours(0, 0, 0, 0)
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekEnd.getDate() + 7)
|
||||
|
||||
const weekWhere: Record<string, unknown> = {
|
||||
scheduledPublishDate: {
|
||||
greater_than_equal: weekStart.toISOString(),
|
||||
less_than: weekEnd.toISOString(),
|
||||
},
|
||||
...channelFilter,
|
||||
}
|
||||
|
||||
const [thisWeek, overdueTasks, pendingApprovals] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'youtube-content',
|
||||
where: weekWhere,
|
||||
sort: 'scheduledPublishDate',
|
||||
depth: 1,
|
||||
limit: 20,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'yt-tasks',
|
||||
where: {
|
||||
and: [
|
||||
{ status: { not_in: ['done', 'cancelled'] } },
|
||||
{ dueDate: { less_than: new Date().toISOString() } },
|
||||
...(channel !== 'all' ? [{ channel: { equals: parseInt(channel) } }] : []),
|
||||
],
|
||||
},
|
||||
depth: 1,
|
||||
limit: 20,
|
||||
sort: 'dueDate',
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'youtube-content',
|
||||
where: {
|
||||
status: { in: ['script_review', 'final_review'] },
|
||||
...channelFilter,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
stats: {
|
||||
inProduction,
|
||||
thisWeekCount: thisWeek.totalDocs,
|
||||
overdueTasksCount: overdueTasks.totalDocs,
|
||||
pendingApprovals: pendingApprovals.totalDocs,
|
||||
},
|
||||
pipeline,
|
||||
thisWeekVideos: thisWeek.docs.map((d: any) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
status: d.status,
|
||||
scheduledPublishDate: d.scheduledPublishDate,
|
||||
channel: typeof d.channel === 'object' ? d.channel?.name : d.channel,
|
||||
})),
|
||||
overdueTasks: overdueTasks.docs.map((d: any) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
dueDate: d.dueDate,
|
||||
assignedTo: typeof d.assignedTo === 'object' ? d.assignedTo?.email : d.assignedTo,
|
||||
video: typeof d.video === 'object' ? d.video?.title : null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add goals tab handler**
|
||||
|
||||
```typescript
|
||||
async function getGoalsData(payload: any, channel: string) {
|
||||
// Find current month's goals
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
month: {
|
||||
greater_than_equal: monthStart.toISOString(),
|
||||
less_than_equal: monthEnd.toISOString(),
|
||||
},
|
||||
}
|
||||
if (channel !== 'all') {
|
||||
where.channel = { equals: parseInt(channel) }
|
||||
}
|
||||
|
||||
const goals = await payload.find({
|
||||
collection: 'yt-monthly-goals',
|
||||
where,
|
||||
depth: 1,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
if (goals.docs.length === 0) {
|
||||
return { stats: { contentProgress: 0, subscriberProgress: 0, viewsProgress: 0, overall: 0 }, goals: [], customGoals: [] }
|
||||
}
|
||||
|
||||
// Aggregate across all matching goals (could be multiple channels)
|
||||
let totalContentTarget = 0, totalContentCurrent = 0
|
||||
let totalSubsTarget = 0, totalSubsCurrent = 0
|
||||
let totalViewsTarget = 0, totalViewsCurrent = 0
|
||||
const allCustomGoals: any[] = []
|
||||
|
||||
for (const goal of goals.docs) {
|
||||
const c = goal.contentGoals || {}
|
||||
totalContentTarget += (c.longformsTarget || 0) + (c.shortsTarget || 0)
|
||||
totalContentCurrent += (c.longformsCurrent || 0) + (c.shortsCurrent || 0)
|
||||
|
||||
const a = goal.audienceGoals || {}
|
||||
totalSubsTarget += a.subscribersTarget || 0
|
||||
totalSubsCurrent += a.subscribersCurrent || 0
|
||||
totalViewsTarget += a.viewsTarget || 0
|
||||
totalViewsCurrent += a.viewsCurrent || 0
|
||||
|
||||
if (goal.customGoals) {
|
||||
for (const cg of goal.customGoals) {
|
||||
allCustomGoals.push({
|
||||
metric: cg.metric,
|
||||
target: cg.target,
|
||||
current: cg.current,
|
||||
status: cg.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pct = (current: number, target: number) => target > 0 ? Math.round((current / target) * 100) : 0
|
||||
const contentPct = pct(totalContentCurrent, totalContentTarget)
|
||||
const subsPct = pct(totalSubsCurrent, totalSubsTarget)
|
||||
const viewsPct = pct(totalViewsCurrent, totalViewsTarget)
|
||||
const overall = Math.round((contentPct + subsPct + viewsPct) / 3)
|
||||
|
||||
return {
|
||||
stats: {
|
||||
contentProgress: contentPct,
|
||||
contentLabel: `${totalContentCurrent}/${totalContentTarget}`,
|
||||
subscriberProgress: subsPct,
|
||||
subscriberLabel: `${totalSubsCurrent}/${totalSubsTarget}`,
|
||||
viewsProgress: viewsPct,
|
||||
viewsLabel: `${totalViewsCurrent}/${totalViewsTarget}`,
|
||||
overall,
|
||||
},
|
||||
goals: goals.docs.map((g: any) => ({
|
||||
id: g.id,
|
||||
channel: typeof g.channel === 'object' ? g.channel?.name : g.channel,
|
||||
month: g.month,
|
||||
contentGoals: g.contentGoals,
|
||||
audienceGoals: g.audienceGoals,
|
||||
engagementGoals: g.engagementGoals,
|
||||
businessGoals: g.businessGoals,
|
||||
notes: g.notes,
|
||||
})),
|
||||
customGoals: allCustomGoals,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Add community tab handler**
|
||||
|
||||
```typescript
|
||||
async function getCommunityData(payload: any, channel: string, period: string) {
|
||||
const { start } = getPeriodDates(period)
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
publishedAt: { greater_than_equal: start.toISOString() },
|
||||
}
|
||||
if (channel !== 'all') {
|
||||
where.socialAccount = { equals: parseInt(channel) }
|
||||
}
|
||||
|
||||
const [interactions, unresolved, escalations] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'community-interactions',
|
||||
where,
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'community-interactions',
|
||||
where: { ...where, status: { equals: 'new' } },
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'community-interactions',
|
||||
where: { ...where, 'flags.requiresEscalation': { equals: true }, status: { not_equals: 'resolved' } },
|
||||
}),
|
||||
])
|
||||
|
||||
const docs = interactions.docs
|
||||
|
||||
// Sentiment distribution
|
||||
const sentiment = { positive: 0, neutral: 0, negative: 0, question: 0 }
|
||||
for (const d of docs) {
|
||||
const s = d.analysis?.sentiment as string
|
||||
if (s && s in sentiment) sentiment[s as keyof typeof sentiment]++
|
||||
}
|
||||
const total = docs.length
|
||||
const positivePct = total > 0 ? Math.round((sentiment.positive / total) * 100) : 0
|
||||
|
||||
// Avg response time (hours)
|
||||
let totalResponseTime = 0, responseCount = 0
|
||||
for (const d of docs) {
|
||||
if (d.response?.sentAt && d.publishedAt) {
|
||||
const diff = new Date(d.response.sentAt as string).getTime() - new Date(d.publishedAt as string).getTime()
|
||||
totalResponseTime += diff / (1000 * 60 * 60)
|
||||
responseCount++
|
||||
}
|
||||
}
|
||||
const avgResponseHours = responseCount > 0 ? Math.round(totalResponseTime / responseCount * 10) / 10 : 0
|
||||
|
||||
// Top topics
|
||||
const topicMap = new Map<string, number>()
|
||||
for (const d of docs) {
|
||||
const topics = d.analysis?.topics as string[] | undefined
|
||||
if (topics) {
|
||||
for (const t of topics) { topicMap.set(t, (topicMap.get(t) || 0) + 1) }
|
||||
}
|
||||
}
|
||||
const topTopics = [...topicMap.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([topic, count]) => ({ topic, count }))
|
||||
|
||||
// Recent unresolved (newest 5)
|
||||
const recent = await payload.find({
|
||||
collection: 'community-interactions',
|
||||
where: { ...where, status: { equals: 'new' } },
|
||||
sort: '-publishedAt',
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
return {
|
||||
stats: {
|
||||
unresolvedCount: unresolved.totalDocs,
|
||||
positivePct,
|
||||
avgResponseHours,
|
||||
escalationsCount: escalations.totalDocs,
|
||||
},
|
||||
sentiment,
|
||||
topTopics,
|
||||
recentUnresolved: recent.docs.map((d: any) => ({
|
||||
id: d.id,
|
||||
message: (d.message as string || '').slice(0, 120),
|
||||
authorName: d.author?.name || 'Unknown',
|
||||
authorHandle: d.author?.handle || '',
|
||||
publishedAt: d.publishedAt,
|
||||
platform: d.platform,
|
||||
sentiment: d.analysis?.sentiment,
|
||||
})),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Wire all tabs into the switch statement**
|
||||
|
||||
In the `GET` handler, replace the switch:
|
||||
|
||||
```typescript
|
||||
switch (tab) {
|
||||
case 'performance':
|
||||
tabData = await getPerformanceData(payload, channel, period)
|
||||
break
|
||||
case 'pipeline':
|
||||
tabData = await getPipelineData(payload, channel)
|
||||
break
|
||||
case 'goals':
|
||||
tabData = await getGoalsData(payload, channel)
|
||||
break
|
||||
case 'community':
|
||||
tabData = await getCommunityData(payload, channel, period)
|
||||
break
|
||||
default:
|
||||
tabData = await getPerformanceData(payload, channel, period)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/\(payload\)/api/youtube/analytics/route.ts
|
||||
git commit -m "feat(yt-analytics): add pipeline, goals, community tab handlers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Nav Link Component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/YouTubeAnalyticsNavLinks.tsx`
|
||||
- Create: `src/components/admin/YouTubeAnalyticsNavLinks.scss`
|
||||
|
||||
**Step 1: Create nav link component**
|
||||
|
||||
Follow the exact pattern from `CommunityNavLinks.tsx`:
|
||||
|
||||
```typescript
|
||||
// src/components/admin/YouTubeAnalyticsNavLinks.tsx
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import './YouTubeAnalyticsNavLinks.scss'
|
||||
|
||||
export const YouTubeAnalyticsNavLinks: React.FC = () => {
|
||||
const pathname = usePathname()
|
||||
|
||||
const links = [
|
||||
{
|
||||
href: '/admin/youtube-analytics',
|
||||
label: 'YouTube Analytics',
|
||||
icon: '📈',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="yt-nav-links">
|
||||
<div className="yt-nav-links__header">
|
||||
<span className="yt-nav-links__icon">🎬</span>
|
||||
<span className="yt-nav-links__title">YouTube</span>
|
||||
</div>
|
||||
<nav className="yt-nav-links__list">
|
||||
{links.map((link) => {
|
||||
const isActive = pathname === link.href
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`yt-nav-links__link ${isActive ? 'yt-nav-links__link--active' : ''}`}
|
||||
>
|
||||
<span className="yt-nav-links__link-icon">{link.icon}</span>
|
||||
<span className="yt-nav-links__link-label">{link.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default YouTubeAnalyticsNavLinks
|
||||
```
|
||||
|
||||
**Step 2: Create nav link SCSS**
|
||||
|
||||
```scss
|
||||
// src/components/admin/YouTubeAnalyticsNavLinks.scss
|
||||
.yt-nav-links {
|
||||
padding: 0 var(--base);
|
||||
margin-top: var(--base);
|
||||
border-top: 1px solid var(--theme-elevation-100);
|
||||
padding-top: var(--base);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--theme-elevation-800);
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-elevation-50);
|
||||
color: var(--theme-elevation-900);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--theme-elevation-100);
|
||||
color: var(--theme-text);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-elevation-150);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__link-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__link-label {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
.yt-nav-links {
|
||||
&__link {
|
||||
&:hover {
|
||||
background-color: var(--theme-elevation-100);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--theme-elevation-150);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-elevation-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/admin/YouTubeAnalyticsNavLinks.tsx src/components/admin/YouTubeAnalyticsNavLinks.scss
|
||||
git commit -m "feat(yt-analytics): add sidebar nav link component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: View Wrapper + Registration in payload.config.ts
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/YouTubeAnalyticsDashboardView.tsx`
|
||||
- Modify: `src/payload.config.ts:128-141`
|
||||
|
||||
**Step 1: Create wrapper component**
|
||||
|
||||
```typescript
|
||||
// src/components/admin/YouTubeAnalyticsDashboardView.tsx
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { YouTubeAnalyticsDashboard } from './YouTubeAnalyticsDashboard'
|
||||
|
||||
export const YouTubeAnalyticsDashboardView: React.FC = () => {
|
||||
return <YouTubeAnalyticsDashboard />
|
||||
}
|
||||
|
||||
export default YouTubeAnalyticsDashboardView
|
||||
```
|
||||
|
||||
Note: The `YouTubeAnalyticsDashboard` component is created in Task 5. This wrapper will fail to compile until Task 5 is done — that's expected.
|
||||
|
||||
**Step 2: Register in payload.config.ts**
|
||||
|
||||
Change lines 130-140 from:
|
||||
|
||||
```typescript
|
||||
components: {
|
||||
// Community Management Nav Links
|
||||
afterNavLinks: ['@/components/admin/CommunityNavLinks#CommunityNavLinks'],
|
||||
// Custom Views - re-enabled after Payload 3.76.1 upgrade (Issue #15241 test)
|
||||
views: {
|
||||
TenantDashboard: {
|
||||
Component: '@/components/admin/TenantDashboardView#TenantDashboardView',
|
||||
path: '/tenant-dashboard',
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
```typescript
|
||||
components: {
|
||||
afterNavLinks: [
|
||||
'@/components/admin/CommunityNavLinks#CommunityNavLinks',
|
||||
'@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks',
|
||||
],
|
||||
views: {
|
||||
TenantDashboard: {
|
||||
Component: '@/components/admin/TenantDashboardView#TenantDashboardView',
|
||||
path: '/tenant-dashboard',
|
||||
},
|
||||
YouTubeAnalyticsDashboard: {
|
||||
Component: '@/components/admin/YouTubeAnalyticsDashboardView#YouTubeAnalyticsDashboardView',
|
||||
path: '/youtube-analytics',
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/admin/YouTubeAnalyticsDashboardView.tsx src/payload.config.ts
|
||||
git commit -m "feat(yt-analytics): register custom view and nav link in config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Main Dashboard Component — Shell with Tabs
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/admin/YouTubeAnalyticsDashboard.tsx`
|
||||
- Create: `src/components/admin/YouTubeAnalyticsDashboard.scss`
|
||||
|
||||
**Step 1: Create main component with tab shell, channel/period selectors, loading/error states**
|
||||
|
||||
The component structure:
|
||||
- Header with title, channel selector, period selector, refresh button
|
||||
- Tab bar (Performance | Pipeline | Goals | Community)
|
||||
- Tab content area (renders based on `activeTab` state)
|
||||
- Each tab is a separate render function within the component
|
||||
- Data fetched via `useEffect` when tab/channel/period changes
|
||||
|
||||
The component should follow the TenantDashboard pattern exactly:
|
||||
- `'use client'` directive
|
||||
- `useTenantSelection()` for tenant context
|
||||
- `useState` for: `activeTab`, `data`, `loading`, `error`, `channel`, `period`, `channels`
|
||||
- `useCallback` for `fetchData`
|
||||
- `useEffect` to trigger `fetchData` on tab/channel/period change
|
||||
- `credentials: 'include'` on fetch
|
||||
- Loading spinner, error display, no-tenant fallback
|
||||
|
||||
Key types to define:
|
||||
|
||||
```typescript
|
||||
type Tab = 'performance' | 'pipeline' | 'goals' | 'community'
|
||||
type Period = '7d' | '30d' | '90d'
|
||||
|
||||
interface Channel { id: number; name: string; slug: string }
|
||||
|
||||
// Performance tab types
|
||||
interface PerformanceData {
|
||||
stats: { totalViews: number; totalWatchTimeHours: number; avgCtr: number; subscribersGained: number }
|
||||
comparison: { views: number; watchTime: number; subscribers: number }
|
||||
topVideos: VideoSummary[]
|
||||
bottomVideos: VideoSummary[]
|
||||
engagement: { likes: number; comments: number; shares: number }
|
||||
}
|
||||
interface VideoSummary { id: number; title: string; thumbnailText: string; views: number; ctr: number; avgRetention: number; likes: number }
|
||||
|
||||
// Pipeline tab types
|
||||
interface PipelineData {
|
||||
stats: { inProduction: number; thisWeekCount: number; overdueTasksCount: number; pendingApprovals: number }
|
||||
pipeline: Record<string, number>
|
||||
thisWeekVideos: { id: number; title: string; status: string; scheduledPublishDate: string; channel: string }[]
|
||||
overdueTasks: { id: number; title: string; dueDate: string; assignedTo: string; video: string | null }[]
|
||||
}
|
||||
|
||||
// Goals tab types
|
||||
interface GoalsData {
|
||||
stats: { contentProgress: number; contentLabel: string; subscriberProgress: number; subscriberLabel: string; viewsProgress: number; viewsLabel: string; overall: number }
|
||||
goals: any[]
|
||||
customGoals: { metric: string; target: string; current: string; status: string }[]
|
||||
}
|
||||
|
||||
// Community tab types
|
||||
interface CommunityData {
|
||||
stats: { unresolvedCount: number; positivePct: number; avgResponseHours: number; escalationsCount: number }
|
||||
sentiment: { positive: number; neutral: number; negative: number; question: number }
|
||||
topTopics: { topic: string; count: number }[]
|
||||
recentUnresolved: { id: number; message: string; authorName: string; authorHandle: string; publishedAt: string; platform: string; sentiment: string }[]
|
||||
}
|
||||
```
|
||||
|
||||
Tab rendering sections:
|
||||
|
||||
**Performance tab:**
|
||||
- 4 stat cards (Views, Watch Time, CTR, Subscribers) — each with comparison arrow
|
||||
- "Top 5 Videos" section with table rows
|
||||
- "Bottom 5 Videos" section
|
||||
- "Engagement" section with 3 inline stats
|
||||
|
||||
**Pipeline tab:**
|
||||
- 4 stat cards (In Production, This Week, Overdue Tasks, Pending Approvals)
|
||||
- Pipeline bar: horizontal segmented bar showing count per status
|
||||
- "Nächste Veröffentlichungen" list
|
||||
- "Überfällige Aufgaben" list
|
||||
|
||||
**Goals tab:**
|
||||
- 4 stat cards (Content %, Subscribers %, Views %, Overall %)
|
||||
- Progress bars for each goal category
|
||||
- Custom goals list with status badges
|
||||
|
||||
**Community tab:**
|
||||
- 4 stat cards (Unresolved, Positive %, Avg Response Time, Escalations)
|
||||
- Sentiment breakdown (colored bars)
|
||||
- Recent unresolved list
|
||||
- Top topics list
|
||||
|
||||
**Step 2: Create SCSS**
|
||||
|
||||
BEM structure with `.yt-analytics` prefix. Key classes:
|
||||
|
||||
```
|
||||
.yt-analytics — container
|
||||
.yt-analytics__header — title row
|
||||
.yt-analytics__controls — channel + period selectors + refresh
|
||||
.yt-analytics__tabs — tab bar
|
||||
.yt-analytics__tab — single tab
|
||||
.yt-analytics__tab--active — active tab
|
||||
.yt-analytics__content — tab content area
|
||||
.yt-analytics__stats-grid — 4-card grid (reuse TenantDashboard pattern)
|
||||
.yt-analytics__stat-card — single stat card
|
||||
.yt-analytics__stat-card--up — green trend arrow
|
||||
.yt-analytics__stat-card--down — red trend arrow
|
||||
.yt-analytics__section — content section
|
||||
.yt-analytics__pipeline-bar — horizontal pipeline visualization
|
||||
.yt-analytics__pipeline-segment — single pipeline segment
|
||||
.yt-analytics__progress-bar — goals progress bar
|
||||
.yt-analytics__progress-fill — progress bar fill
|
||||
.yt-analytics__video-row — video list item
|
||||
.yt-analytics__topic-tag — topic pill
|
||||
.yt-analytics__comment-item — community comment preview
|
||||
```
|
||||
|
||||
Colors for pipeline segments (CSS custom properties):
|
||||
- idea: `--theme-elevation-300`
|
||||
- script: `#6366f1` (indigo)
|
||||
- review: `#f59e0b` (amber)
|
||||
- production: `#3b82f6` (blue)
|
||||
- editing: `#8b5cf6` (violet)
|
||||
- ready: `#10b981` (emerald)
|
||||
- published: `var(--theme-success-500)`
|
||||
|
||||
Responsive: `@media (max-width: 768px)` — 2-col stat grid, stacked layout
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/components/admin/YouTubeAnalyticsDashboard.tsx src/components/admin/YouTubeAnalyticsDashboard.scss
|
||||
git commit -m "feat(yt-analytics): add main dashboard component with all 4 tabs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Build, Generate ImportMap, Deploy to Staging
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(payload)/admin/importMap.js` (auto-generated)
|
||||
|
||||
**Step 1: Generate importMap**
|
||||
|
||||
```bash
|
||||
pnpm payload generate:importmap
|
||||
```
|
||||
|
||||
Expected: importMap.js updated with new component paths
|
||||
|
||||
**Step 2: Build**
|
||||
|
||||
```bash
|
||||
pm2 stop payload && NODE_OPTIONS="--no-deprecation --max-old-space-size=2048" pnpm build
|
||||
```
|
||||
|
||||
Expected: Build succeeds without errors
|
||||
|
||||
**Step 3: Start and verify**
|
||||
|
||||
```bash
|
||||
pm2 start payload
|
||||
```
|
||||
|
||||
Test endpoints:
|
||||
- `curl -s -o /dev/null -w '%{http_code}' https://pl.porwoll.tech/admin/youtube-analytics` → 200
|
||||
- Login via browser and navigate to `/admin/youtube-analytics`
|
||||
- Verify nav link appears in sidebar
|
||||
- Verify all 4 tabs load data
|
||||
|
||||
**Step 4: Commit build artifacts**
|
||||
|
||||
```bash
|
||||
git add src/app/\(payload\)/admin/importMap.js
|
||||
git commit -m "chore: regenerate importMap for YouTube Analytics Dashboard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final Commit + Push
|
||||
|
||||
**Step 1: Push to develop**
|
||||
|
||||
```bash
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
**Step 2: Verify on staging**
|
||||
|
||||
Browser test:
|
||||
1. Navigate to `https://pl.porwoll.tech/admin`
|
||||
2. Login
|
||||
3. Check sidebar for "YouTube" section with "YouTube Analytics" link
|
||||
4. Click link → Dashboard loads
|
||||
5. Switch tabs: Performance, Pipeline, Goals, Community
|
||||
6. Change channel selector
|
||||
7. Change period selector
|
||||
8. Click refresh button
|
||||
|
|
@ -26,9 +26,11 @@ import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997e
|
|||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CommunityNavLinks as CommunityNavLinks_4431db66fbe96916dda5fdbfa979ee1e } from '@/components/admin/CommunityNavLinks'
|
||||
import { YouTubeAnalyticsNavLinks as YouTubeAnalyticsNavLinks_aa7af5798b0ff987bb9dde0b50fc07dc } from '@/components/admin/YouTubeAnalyticsNavLinks'
|
||||
import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||
import { TenantDashboardView as TenantDashboardView_1468a86d093d5b2444131ed5ce14599e } from '@/components/admin/TenantDashboardView'
|
||||
import { YouTubeAnalyticsDashboardView as YouTubeAnalyticsDashboardView_cda40d57a6ea0fffb8f4ea8808c0cdea } from '@/components/admin/YouTubeAnalyticsDashboardView'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
export const importMap = {
|
||||
|
|
@ -60,8 +62,10 @@ export const importMap = {
|
|||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@/components/admin/CommunityNavLinks#CommunityNavLinks": CommunityNavLinks_4431db66fbe96916dda5fdbfa979ee1e,
|
||||
"@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks": YouTubeAnalyticsNavLinks_aa7af5798b0ff987bb9dde0b50fc07dc,
|
||||
"@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62,
|
||||
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62,
|
||||
"@/components/admin/TenantDashboardView#TenantDashboardView": TenantDashboardView_1468a86d093d5b2444131ed5ce14599e,
|
||||
"@/components/admin/YouTubeAnalyticsDashboardView#YouTubeAnalyticsDashboardView": YouTubeAnalyticsDashboardView_cda40d57a6ea0fffb8f4ea8808c0cdea,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
|
|
|
|||
442
src/app/(payload)/api/youtube/analytics/route.ts
Normal file
442
src/app/(payload)/api/youtube/analytics/route.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { subDays } from 'date-fns'
|
||||
|
||||
interface UserWithYouTubeRole {
|
||||
id: number
|
||||
isSuperAdmin?: boolean
|
||||
youtubeRole?: string
|
||||
}
|
||||
|
||||
function getPeriodDates(period: string) {
|
||||
const now = new Date()
|
||||
switch (period) {
|
||||
case '7d':
|
||||
return { start: subDays(now, 7), prevStart: subDays(now, 14), prevEnd: subDays(now, 7) }
|
||||
case '90d':
|
||||
return { start: subDays(now, 90), prevStart: subDays(now, 180), prevEnd: subDays(now, 90) }
|
||||
default:
|
||||
return { start: subDays(now, 30), prevStart: subDays(now, 60), prevEnd: subDays(now, 30) }
|
||||
}
|
||||
}
|
||||
|
||||
async function getPerformanceData(payload: any, channel: string, period: string) {
|
||||
const { start, prevStart, prevEnd } = getPeriodDates(period)
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
status: { equals: 'published' },
|
||||
actualPublishDate: { greater_than_equal: start.toISOString() },
|
||||
}
|
||||
if (channel !== 'all') {
|
||||
where.channel = { equals: parseInt(channel) }
|
||||
}
|
||||
|
||||
const prevWhere: Record<string, unknown> = {
|
||||
status: { equals: 'published' },
|
||||
actualPublishDate: {
|
||||
greater_than_equal: prevStart.toISOString(),
|
||||
less_than: prevEnd.toISOString(),
|
||||
},
|
||||
}
|
||||
if (channel !== 'all') {
|
||||
prevWhere.channel = { equals: parseInt(channel) }
|
||||
}
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'youtube-content',
|
||||
where,
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
sort: '-performance.views',
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'youtube-content',
|
||||
where: prevWhere,
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
}),
|
||||
])
|
||||
|
||||
const docs = current.docs
|
||||
const prevDocs = previous.docs
|
||||
|
||||
let totalViews = 0, totalWatchTime = 0, totalCtr = 0, totalSubscribers = 0
|
||||
let totalLikes = 0, totalComments = 0, totalShares = 0
|
||||
let ctrCount = 0
|
||||
|
||||
for (const doc of docs) {
|
||||
const p = doc.performance || {}
|
||||
totalViews += p.views || 0
|
||||
totalWatchTime += p.watchTimeMinutes || 0
|
||||
if (p.ctr != null) { totalCtr += p.ctr; ctrCount++ }
|
||||
totalSubscribers += p.subscribersGained || 0
|
||||
totalLikes += p.likes || 0
|
||||
totalComments += p.comments || 0
|
||||
totalShares += p.shares || 0
|
||||
}
|
||||
|
||||
let prevViews = 0, prevWatchTime = 0, prevSubscribers = 0
|
||||
for (const doc of prevDocs) {
|
||||
const p = doc.performance || {}
|
||||
prevViews += p.views || 0
|
||||
prevWatchTime += p.watchTimeMinutes || 0
|
||||
prevSubscribers += p.subscribersGained || 0
|
||||
}
|
||||
|
||||
const sorted = [...docs].sort((a, b) => (b.performance?.views || 0) - (a.performance?.views || 0))
|
||||
const mapVideo = (d: any) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
thumbnailText: d.thumbnailText || '',
|
||||
views: d.performance?.views || 0,
|
||||
ctr: d.performance?.ctr || 0,
|
||||
avgRetention: d.performance?.avgViewPercentage || 0,
|
||||
likes: d.performance?.likes || 0,
|
||||
})
|
||||
|
||||
return {
|
||||
stats: {
|
||||
totalViews,
|
||||
totalWatchTimeHours: Math.round((totalWatchTime / 60) * 10) / 10,
|
||||
avgCtr: ctrCount > 0 ? Math.round((totalCtr / ctrCount) * 100) / 100 : 0,
|
||||
subscribersGained: totalSubscribers,
|
||||
},
|
||||
comparison: {
|
||||
views: prevViews > 0 ? Math.round(((totalViews - prevViews) / prevViews) * 100) : 0,
|
||||
watchTime: prevWatchTime > 0 ? Math.round(((totalWatchTime - prevWatchTime) / prevWatchTime) * 100) : 0,
|
||||
subscribers: prevSubscribers > 0 ? Math.round(((totalSubscribers - prevSubscribers) / prevSubscribers) * 100) : 0,
|
||||
},
|
||||
topVideos: sorted.slice(0, 5).map(mapVideo),
|
||||
bottomVideos: sorted.length > 5 ? sorted.slice(-5).reverse().map(mapVideo) : [],
|
||||
engagement: { likes: totalLikes, comments: totalComments, shares: totalShares },
|
||||
}
|
||||
}
|
||||
|
||||
async function getPipelineData(payload: any, channel: string) {
|
||||
const channelFilter: Record<string, unknown> = channel !== 'all'
|
||||
? { channel: { equals: parseInt(channel) } }
|
||||
: {}
|
||||
|
||||
const statuses = ['idea', 'script_draft', 'script_review', 'script_approved',
|
||||
'shoot_scheduled', 'shot', 'rough_cut', 'fine_cut', 'final_review',
|
||||
'approved', 'upload_scheduled', 'published']
|
||||
|
||||
const counts = await Promise.all(
|
||||
statuses.map((s) =>
|
||||
payload.count({
|
||||
collection: 'youtube-content',
|
||||
where: { status: { equals: s }, ...channelFilter },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const pipeline: Record<string, number> = {}
|
||||
statuses.forEach((s, i) => { pipeline[s] = counts[i].totalDocs })
|
||||
|
||||
const inProduction = Object.entries(pipeline)
|
||||
.filter(([k]) => k !== 'published')
|
||||
.reduce((sum, [, v]) => sum + v, 0)
|
||||
|
||||
const weekStart = new Date()
|
||||
weekStart.setHours(0, 0, 0, 0)
|
||||
const weekEnd = new Date(weekStart)
|
||||
weekEnd.setDate(weekEnd.getDate() + 7)
|
||||
|
||||
const weekWhere: Record<string, unknown> = {
|
||||
scheduledPublishDate: {
|
||||
greater_than_equal: weekStart.toISOString(),
|
||||
less_than: weekEnd.toISOString(),
|
||||
},
|
||||
...channelFilter,
|
||||
}
|
||||
|
||||
const [thisWeek, overdueTasks, pendingApprovals] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'youtube-content',
|
||||
where: weekWhere,
|
||||
sort: 'scheduledPublishDate',
|
||||
depth: 1,
|
||||
limit: 20,
|
||||
}),
|
||||
payload.find({
|
||||
collection: 'yt-tasks',
|
||||
where: {
|
||||
and: [
|
||||
{ status: { not_in: ['done', 'cancelled'] } },
|
||||
{ dueDate: { less_than: new Date().toISOString() } },
|
||||
...(channel !== 'all' ? [{ channel: { equals: parseInt(channel) } }] : []),
|
||||
],
|
||||
},
|
||||
depth: 1,
|
||||
limit: 20,
|
||||
sort: 'dueDate',
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'youtube-content',
|
||||
where: {
|
||||
status: { in: ['script_review', 'final_review'] },
|
||||
...channelFilter,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
stats: {
|
||||
inProduction,
|
||||
thisWeekCount: thisWeek.totalDocs,
|
||||
overdueTasksCount: overdueTasks.totalDocs,
|
||||
pendingApprovals: pendingApprovals.totalDocs,
|
||||
},
|
||||
pipeline,
|
||||
thisWeekVideos: thisWeek.docs.map((d: any) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
status: d.status,
|
||||
scheduledPublishDate: d.scheduledPublishDate,
|
||||
channel: typeof d.channel === 'object' ? d.channel?.name : d.channel,
|
||||
})),
|
||||
overdueTasks: overdueTasks.docs.map((d: any) => ({
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
dueDate: d.dueDate,
|
||||
assignedTo: typeof d.assignedTo === 'object' ? d.assignedTo?.email : d.assignedTo,
|
||||
video: typeof d.video === 'object' ? d.video?.title : null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function getGoalsData(payload: any, channel: string) {
|
||||
const now = new Date()
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
month: {
|
||||
greater_than_equal: monthStart.toISOString(),
|
||||
less_than_equal: monthEnd.toISOString(),
|
||||
},
|
||||
}
|
||||
if (channel !== 'all') {
|
||||
where.channel = { equals: parseInt(channel) }
|
||||
}
|
||||
|
||||
const goals = await payload.find({
|
||||
collection: 'yt-monthly-goals',
|
||||
where,
|
||||
depth: 1,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
if (goals.docs.length === 0) {
|
||||
return { stats: { contentProgress: 0, contentLabel: '0/0', subscriberProgress: 0, subscriberLabel: '0/0', viewsProgress: 0, viewsLabel: '0/0', overall: 0 }, goals: [], customGoals: [] }
|
||||
}
|
||||
|
||||
let totalContentTarget = 0, totalContentCurrent = 0
|
||||
let totalSubsTarget = 0, totalSubsCurrent = 0
|
||||
let totalViewsTarget = 0, totalViewsCurrent = 0
|
||||
const allCustomGoals: { metric: string; target: string; current: string; status: string }[] = []
|
||||
|
||||
for (const goal of goals.docs) {
|
||||
const c = (goal as any).contentGoals || {}
|
||||
totalContentTarget += (c.longformsTarget || 0) + (c.shortsTarget || 0)
|
||||
totalContentCurrent += (c.longformsCurrent || 0) + (c.shortsCurrent || 0)
|
||||
|
||||
const a = (goal as any).audienceGoals || {}
|
||||
totalSubsTarget += a.subscribersTarget || 0
|
||||
totalSubsCurrent += a.subscribersCurrent || 0
|
||||
totalViewsTarget += a.viewsTarget || 0
|
||||
totalViewsCurrent += a.viewsCurrent || 0
|
||||
|
||||
if ((goal as any).customGoals) {
|
||||
for (const cg of (goal as any).customGoals) {
|
||||
allCustomGoals.push({
|
||||
metric: cg.metric,
|
||||
target: cg.target,
|
||||
current: cg.current,
|
||||
status: cg.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pct = (current: number, target: number) => target > 0 ? Math.round((current / target) * 100) : 0
|
||||
const contentPct = pct(totalContentCurrent, totalContentTarget)
|
||||
const subsPct = pct(totalSubsCurrent, totalSubsTarget)
|
||||
const viewsPct = pct(totalViewsCurrent, totalViewsTarget)
|
||||
const overall = Math.round((contentPct + subsPct + viewsPct) / 3)
|
||||
|
||||
return {
|
||||
stats: {
|
||||
contentProgress: contentPct,
|
||||
contentLabel: `${totalContentCurrent}/${totalContentTarget}`,
|
||||
subscriberProgress: subsPct,
|
||||
subscriberLabel: `${totalSubsCurrent}/${totalSubsTarget}`,
|
||||
viewsProgress: viewsPct,
|
||||
viewsLabel: `${totalViewsCurrent}/${totalViewsTarget}`,
|
||||
overall,
|
||||
},
|
||||
goals: goals.docs.map((g: any) => ({
|
||||
id: g.id,
|
||||
channel: typeof g.channel === 'object' ? g.channel?.name : g.channel,
|
||||
month: g.month,
|
||||
contentGoals: g.contentGoals,
|
||||
audienceGoals: g.audienceGoals,
|
||||
engagementGoals: g.engagementGoals,
|
||||
businessGoals: g.businessGoals,
|
||||
notes: g.notes,
|
||||
})),
|
||||
customGoals: allCustomGoals,
|
||||
}
|
||||
}
|
||||
|
||||
async function getCommunityData(payload: any, channel: string, period: string) {
|
||||
const { start } = getPeriodDates(period)
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
publishedAt: { greater_than_equal: start.toISOString() },
|
||||
}
|
||||
if (channel !== 'all') {
|
||||
where.socialAccount = { equals: parseInt(channel) }
|
||||
}
|
||||
|
||||
const [interactions, unresolved, escalations] = await Promise.all([
|
||||
payload.find({
|
||||
collection: 'community-interactions',
|
||||
where,
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'community-interactions',
|
||||
where: { ...where, status: { equals: 'new' } },
|
||||
}),
|
||||
payload.count({
|
||||
collection: 'community-interactions',
|
||||
where: { ...where, 'flags.requiresEscalation': { equals: true }, status: { not_equals: 'resolved' } },
|
||||
}),
|
||||
])
|
||||
|
||||
const docs = interactions.docs
|
||||
|
||||
const sentiment: Record<string, number> = { positive: 0, neutral: 0, negative: 0, question: 0 }
|
||||
for (const d of docs) {
|
||||
const s = (d as any).analysis?.sentiment as string
|
||||
if (s && s in sentiment) sentiment[s]++
|
||||
}
|
||||
const total = docs.length
|
||||
const positivePct = total > 0 ? Math.round((sentiment.positive / total) * 100) : 0
|
||||
|
||||
let totalResponseTime = 0, responseCount = 0
|
||||
for (const d of docs) {
|
||||
if ((d as any).response?.sentAt && d.publishedAt) {
|
||||
const diff = new Date((d as any).response.sentAt as string).getTime() - new Date(d.publishedAt as string).getTime()
|
||||
totalResponseTime += diff / (1000 * 60 * 60)
|
||||
responseCount++
|
||||
}
|
||||
}
|
||||
const avgResponseHours = responseCount > 0 ? Math.round(totalResponseTime / responseCount * 10) / 10 : 0
|
||||
|
||||
const topicMap = new Map<string, number>()
|
||||
for (const d of docs) {
|
||||
const topics = (d as any).analysis?.topics as string[] | undefined
|
||||
if (topics) {
|
||||
for (const t of topics) { topicMap.set(t, (topicMap.get(t) || 0) + 1) }
|
||||
}
|
||||
}
|
||||
const topTopics = [...topicMap.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([topic, count]) => ({ topic, count }))
|
||||
|
||||
const recent = await payload.find({
|
||||
collection: 'community-interactions',
|
||||
where: { ...where, status: { equals: 'new' } },
|
||||
sort: '-publishedAt',
|
||||
limit: 5,
|
||||
depth: 0,
|
||||
})
|
||||
|
||||
return {
|
||||
stats: {
|
||||
unresolvedCount: unresolved.totalDocs,
|
||||
positivePct,
|
||||
avgResponseHours,
|
||||
escalationsCount: escalations.totalDocs,
|
||||
},
|
||||
sentiment,
|
||||
topTopics,
|
||||
recentUnresolved: recent.docs.map((d: any) => ({
|
||||
id: d.id,
|
||||
message: (d.message as string || '').slice(0, 120),
|
||||
authorName: d.author?.name || 'Unknown',
|
||||
authorHandle: d.author?.handle || '',
|
||||
publishedAt: d.publishedAt,
|
||||
platform: d.platform,
|
||||
sentiment: d.analysis?.sentiment,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
const { user } = await payload.auth({ headers: req.headers })
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const typedUser = user as UserWithYouTubeRole
|
||||
if (!typedUser.isSuperAdmin && (!typedUser.youtubeRole || typedUser.youtubeRole === 'none')) {
|
||||
return NextResponse.json({ error: 'No YouTube access' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const tab = searchParams.get('tab') || 'performance'
|
||||
const channel = searchParams.get('channel') || 'all'
|
||||
const period = searchParams.get('period') || '30d'
|
||||
|
||||
const channels = await payload.find({
|
||||
collection: 'youtube-channels',
|
||||
where: { status: { equals: 'active' } },
|
||||
depth: 0,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
let tabData: unknown = {}
|
||||
|
||||
switch (tab) {
|
||||
case 'performance':
|
||||
tabData = await getPerformanceData(payload, channel, period)
|
||||
break
|
||||
case 'pipeline':
|
||||
tabData = await getPipelineData(payload, channel)
|
||||
break
|
||||
case 'goals':
|
||||
tabData = await getGoalsData(payload, channel)
|
||||
break
|
||||
case 'community':
|
||||
tabData = await getCommunityData(payload, channel, period)
|
||||
break
|
||||
default:
|
||||
tabData = await getPerformanceData(payload, channel, period)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tab,
|
||||
channel,
|
||||
period,
|
||||
channels: channels.docs.map((c: any) => ({ id: c.id, name: c.name, slug: c.slug })),
|
||||
data: tabData,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[YouTube Analytics] Error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
722
src/components/admin/YouTubeAnalyticsDashboard.scss
Normal file
722
src/components/admin/YouTubeAnalyticsDashboard.scss
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
.yt-analytics {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
|
||||
// Header
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__title-section {
|
||||
h2 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-800);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__channel-select,
|
||||
&__period-select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--theme-elevation-250);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
background-color: var(--theme-elevation-0);
|
||||
color: var(--theme-elevation-800);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--theme-elevation-400);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--theme-success-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__refresh-btn {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--theme-elevation-250);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
background-color: var(--theme-elevation-0);
|
||||
color: var(--theme-elevation-600);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-color: var(--theme-elevation-400);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs
|
||||
&__tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--theme-elevation-150);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-elevation-500);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
color: var(--theme-elevation-700);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--theme-success-500);
|
||||
border-bottom-color: var(--theme-success-500);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// Stats Grid
|
||||
&__stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
&__stat-card {
|
||||
padding: 1.25rem;
|
||||
background-color: var(--theme-elevation-0);
|
||||
border: 1px solid var(--theme-elevation-150);
|
||||
border-radius: var(--style-radius-m, 8px);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
border-left: 3px solid var(--theme-elevation-150);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&--views {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
&--time {
|
||||
border-left-color: #8b5cf6;
|
||||
}
|
||||
|
||||
&--ctr {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
&--subs {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
&--production {
|
||||
border-left-color: #6366f1;
|
||||
}
|
||||
|
||||
&--warning {
|
||||
border-left-color: var(--theme-warning-500);
|
||||
}
|
||||
|
||||
&--error {
|
||||
border-left-color: var(--theme-error-500);
|
||||
}
|
||||
|
||||
&--success {
|
||||
border-left-color: var(--theme-success-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__stat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__stat-icon {
|
||||
display: flex;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-elevation-500);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--theme-elevation-800);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
// Trend
|
||||
&__trend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--up {
|
||||
color: var(--theme-success-500);
|
||||
}
|
||||
|
||||
&--down {
|
||||
color: var(--theme-error-500);
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
&__content {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
// Section
|
||||
&__section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
background-color: var(--theme-elevation-50);
|
||||
border: 1px solid var(--theme-elevation-150);
|
||||
border-radius: var(--style-radius-m, 8px);
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-700);
|
||||
}
|
||||
|
||||
// Video List
|
||||
&__video-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__video-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
}
|
||||
|
||||
&__video-title {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-elevation-800);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__video-metric {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-elevation-600);
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// Engagement
|
||||
&__engagement-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__engagement-item {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
}
|
||||
|
||||
&__engagement-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--theme-elevation-800);
|
||||
}
|
||||
|
||||
&__engagement-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
|
||||
// Pipeline Bar
|
||||
&__pipeline-bar {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__pipeline-segment {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
min-width: 30px;
|
||||
transition: flex-basis 0.3s ease;
|
||||
position: relative;
|
||||
|
||||
&--idea {
|
||||
background-color: var(--theme-elevation-400);
|
||||
}
|
||||
|
||||
&--script {
|
||||
background-color: #6366f1;
|
||||
}
|
||||
|
||||
&--review {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
&--production {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
|
||||
&--editing {
|
||||
background-color: #8b5cf6;
|
||||
}
|
||||
|
||||
&--ready {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
&--published {
|
||||
background-color: var(--theme-success-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__pipeline-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&__legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
&__legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
// Progress (Goals)
|
||||
&__progress-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
&__progress-label {
|
||||
width: 100px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-elevation-600);
|
||||
}
|
||||
|
||||
&__progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background-color: var(--theme-elevation-150);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background-color: var(--theme-success-500);
|
||||
transition: width 0.5s ease;
|
||||
|
||||
&--at-risk {
|
||||
background-color: var(--theme-warning-500);
|
||||
}
|
||||
|
||||
&--missed {
|
||||
background-color: var(--theme-error-500);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-value {
|
||||
width: 60px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-700);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// Status Badge
|
||||
&__status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
&--on_track {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
&--at_risk {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&--achieved {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
&--missed {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
// Community - Comments
|
||||
&__comment-item {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
&__comment-author {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-elevation-800);
|
||||
}
|
||||
|
||||
&__comment-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
|
||||
&__comment-message {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-elevation-600);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// Community - Topics
|
||||
&__topic-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__topic-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-radius: 12px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-elevation-700);
|
||||
}
|
||||
|
||||
&__topic-count {
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-500);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
// Community - Sentiment
|
||||
&__sentiment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__sentiment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__sentiment-label {
|
||||
width: 80px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-elevation-600);
|
||||
}
|
||||
|
||||
&__sentiment-bar-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background-color: var(--theme-elevation-150);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__sentiment-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease;
|
||||
|
||||
&--positive {
|
||||
background-color: var(--theme-success-500);
|
||||
}
|
||||
|
||||
&--neutral {
|
||||
background-color: var(--theme-elevation-400);
|
||||
}
|
||||
|
||||
&--negative {
|
||||
background-color: var(--theme-error-500);
|
||||
}
|
||||
|
||||
&--question {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
&__sentiment-count {
|
||||
width: 40px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--theme-elevation-700);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// Pipeline - Schedule
|
||||
&__schedule-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__schedule-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-elevation-800);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__schedule-date {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
|
||||
// Pipeline - Tasks
|
||||
&__task-item {
|
||||
padding: 0.75rem;
|
||||
background-color: rgba(var(--theme-error-500-rgb), 0.05);
|
||||
border: 1px solid rgba(var(--theme-error-500-rgb), 0.2);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&__task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
&__task-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--theme-elevation-800);
|
||||
}
|
||||
|
||||
&__task-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
|
||||
&__task-detail {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--theme-elevation-600);
|
||||
}
|
||||
|
||||
// Loading State
|
||||
&__loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--theme-elevation-500);
|
||||
gap: 1rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Error State
|
||||
&__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background-color: rgba(var(--theme-error-500-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--theme-error-500-rgb), 0.2);
|
||||
border-radius: var(--style-radius-s, 4px);
|
||||
color: var(--theme-error-500);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Spinner
|
||||
&__spinner {
|
||||
animation: yt-analytics-spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark Mode
|
||||
.dark .yt-analytics {
|
||||
&__stat-card {
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-color: var(--theme-elevation-200);
|
||||
}
|
||||
|
||||
&__section {
|
||||
background-color: var(--theme-elevation-100);
|
||||
border-color: var(--theme-elevation-200);
|
||||
}
|
||||
|
||||
&__video-row,
|
||||
&__engagement-item,
|
||||
&__comment-item,
|
||||
&__schedule-item {
|
||||
background-color: var(--theme-elevation-200);
|
||||
}
|
||||
|
||||
&__topic-tag {
|
||||
background-color: var(--theme-elevation-200);
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 768px) {
|
||||
.yt-analytics {
|
||||
padding: 1rem;
|
||||
|
||||
&__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
&__engagement-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
&__stat-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
&__video-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__video-metric {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__schedule-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keyframes
|
||||
@keyframes yt-analytics-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
865
src/components/admin/YouTubeAnalyticsDashboard.tsx
Normal file
865
src/components/admin/YouTubeAnalyticsDashboard.tsx
Normal file
|
|
@ -0,0 +1,865 @@
|
|||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import './YouTubeAnalyticsDashboard.scss'
|
||||
|
||||
// Types
|
||||
type Tab = 'performance' | 'pipeline' | 'goals' | 'community'
|
||||
type Period = '7d' | '30d' | '90d'
|
||||
|
||||
interface Channel {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
interface VideoSummary {
|
||||
id: number
|
||||
title: string
|
||||
thumbnailText: string
|
||||
views: number
|
||||
ctr: number
|
||||
avgRetention: number
|
||||
likes: number
|
||||
}
|
||||
|
||||
interface PerformanceData {
|
||||
stats: {
|
||||
totalViews: number
|
||||
totalWatchTimeHours: number
|
||||
avgCtr: number
|
||||
subscribersGained: number
|
||||
}
|
||||
comparison: {
|
||||
views: number
|
||||
watchTime: number
|
||||
subscribers: number
|
||||
}
|
||||
topVideos: VideoSummary[]
|
||||
bottomVideos: VideoSummary[]
|
||||
engagement: {
|
||||
likes: number
|
||||
comments: number
|
||||
shares: number
|
||||
}
|
||||
}
|
||||
|
||||
interface PipelineData {
|
||||
stats: {
|
||||
inProduction: number
|
||||
thisWeekCount: number
|
||||
overdueTasksCount: number
|
||||
pendingApprovals: number
|
||||
}
|
||||
pipeline: Record<string, number>
|
||||
thisWeekVideos: Array<{
|
||||
id: number
|
||||
title: string
|
||||
status: string
|
||||
scheduledPublishDate: string
|
||||
channel: string
|
||||
}>
|
||||
overdueTasks: Array<{
|
||||
id: number
|
||||
title: string
|
||||
dueDate: string
|
||||
assignedTo: string
|
||||
video: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
interface GoalsData {
|
||||
stats: {
|
||||
contentProgress: number
|
||||
contentLabel: string
|
||||
subscriberProgress: number
|
||||
subscriberLabel: string
|
||||
viewsProgress: number
|
||||
viewsLabel: string
|
||||
overall: number
|
||||
}
|
||||
goals: any[]
|
||||
customGoals: Array<{
|
||||
metric: string
|
||||
target: string
|
||||
current: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface CommunityData {
|
||||
stats: {
|
||||
unresolvedCount: number
|
||||
positivePct: number
|
||||
avgResponseHours: number
|
||||
escalationsCount: number
|
||||
}
|
||||
sentiment: Record<string, number>
|
||||
topTopics: Array<{
|
||||
topic: string
|
||||
count: number
|
||||
}>
|
||||
recentUnresolved: Array<{
|
||||
id: number
|
||||
message: string
|
||||
authorName: string
|
||||
authorHandle: string
|
||||
publishedAt: string
|
||||
platform: string
|
||||
sentiment: string
|
||||
}>
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
tab: string
|
||||
channel: string
|
||||
period: string
|
||||
channels: Channel[]
|
||||
data: PerformanceData | PipelineData | GoalsData | CommunityData
|
||||
}
|
||||
|
||||
const tabConfig: Array<{ key: Tab; label: string; icon: JSX.Element }> = [
|
||||
{
|
||||
key: 'performance',
|
||||
label: 'Performance',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'pipeline',
|
||||
label: 'Pipeline',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'goals',
|
||||
label: 'Ziele',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'community',
|
||||
label: 'Community',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const periodLabels: Record<Period, string> = {
|
||||
'7d': 'Letzte 7 Tage',
|
||||
'30d': 'Letzte 30 Tage',
|
||||
'90d': 'Letzte 90 Tage',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
idea: 'Idee',
|
||||
script_draft: 'Skript',
|
||||
script_review: 'Review',
|
||||
script_approved: 'Skript',
|
||||
shoot_scheduled: 'Produktion',
|
||||
shot: 'Produktion',
|
||||
rough_cut: 'Schnitt',
|
||||
fine_cut: 'Schnitt',
|
||||
final_review: 'Review',
|
||||
approved: 'Bereit',
|
||||
upload_scheduled: 'Bereit',
|
||||
published: 'Veröffentlicht',
|
||||
}
|
||||
|
||||
const formatNumber = (n: number): string => {
|
||||
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`
|
||||
return n.toString()
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const renderTrend = (value: number): JSX.Element | null => {
|
||||
if (value === 0) return null
|
||||
const isUp = value > 0
|
||||
return (
|
||||
<span
|
||||
className={`yt-analytics__trend ${isUp ? 'yt-analytics__trend--up' : 'yt-analytics__trend--down'}`}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
{isUp ? (
|
||||
<polyline points="18 15 12 9 6 15" />
|
||||
) : (
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
)}
|
||||
</svg>
|
||||
{Math.abs(value)}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const YouTubeAnalyticsDashboard: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('performance')
|
||||
const [period, setPeriod] = useState<Period>('30d')
|
||||
const [channel, setChannel] = useState<string>('all')
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [data, setData] = useState<ApiResponse['data'] | null>(null)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/youtube/analytics?tab=${activeTab}&channel=${channel}&period=${period}`,
|
||||
{
|
||||
credentials: 'include',
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const result: ApiResponse = await response.json()
|
||||
|
||||
if (result.channels && result.channels.length > 0) {
|
||||
setChannels(result.channels)
|
||||
}
|
||||
|
||||
setData(result.data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Laden der Daten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeTab, channel, period])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const renderPerformance = () => {
|
||||
const perfData = data as PerformanceData
|
||||
if (!perfData) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="yt-analytics__stats-grid">
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--views">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<div className="yt-analytics__stat-icon">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="yt-analytics__stat-label">Aufrufe</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">
|
||||
{formatNumber(perfData.stats.totalViews)}
|
||||
{renderTrend(perfData.comparison.views)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--time">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<div className="yt-analytics__stat-icon">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="yt-analytics__stat-label">Wiedergabezeit</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">
|
||||
{formatNumber(perfData.stats.totalWatchTimeHours)}h
|
||||
{renderTrend(perfData.comparison.watchTime)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--ctr">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<div className="yt-analytics__stat-icon">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="yt-analytics__stat-label">CTR</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{perfData.stats.avgCtr.toFixed(1)}%</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--subs">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<div className="yt-analytics__stat-icon">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="yt-analytics__stat-label">Neue Abos</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">
|
||||
{formatNumber(perfData.stats.subscribersGained)}
|
||||
{renderTrend(perfData.comparison.subscribers)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{perfData.topVideos && perfData.topVideos.length > 0 && (
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Top 5 Videos</h3>
|
||||
<div className="yt-analytics__video-list">
|
||||
{perfData.topVideos.map((video) => (
|
||||
<div key={video.id} className="yt-analytics__video-row">
|
||||
<div className="yt-analytics__video-title">{video.title}</div>
|
||||
<div className="yt-analytics__video-metric">{formatNumber(video.views)} Aufrufe</div>
|
||||
<div className="yt-analytics__video-metric">{video.ctr.toFixed(1)}% CTR</div>
|
||||
<div className="yt-analytics__video-metric">
|
||||
{video.avgRetention.toFixed(0)}% Retention
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{perfData.bottomVideos && perfData.bottomVideos.length > 0 && (
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Schwächste Videos</h3>
|
||||
<div className="yt-analytics__video-list">
|
||||
{perfData.bottomVideos.map((video) => (
|
||||
<div key={video.id} className="yt-analytics__video-row">
|
||||
<div className="yt-analytics__video-title">{video.title}</div>
|
||||
<div className="yt-analytics__video-metric">{formatNumber(video.views)} Aufrufe</div>
|
||||
<div className="yt-analytics__video-metric">{video.ctr.toFixed(1)}% CTR</div>
|
||||
<div className="yt-analytics__video-metric">
|
||||
{video.avgRetention.toFixed(0)}% Retention
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Engagement</h3>
|
||||
<div className="yt-analytics__engagement-grid">
|
||||
<div className="yt-analytics__engagement-item">
|
||||
<div className="yt-analytics__engagement-value">
|
||||
{formatNumber(perfData.engagement.likes)}
|
||||
</div>
|
||||
<div className="yt-analytics__engagement-label">Likes</div>
|
||||
</div>
|
||||
<div className="yt-analytics__engagement-item">
|
||||
<div className="yt-analytics__engagement-value">
|
||||
{formatNumber(perfData.engagement.comments)}
|
||||
</div>
|
||||
<div className="yt-analytics__engagement-label">Kommentare</div>
|
||||
</div>
|
||||
<div className="yt-analytics__engagement-item">
|
||||
<div className="yt-analytics__engagement-value">
|
||||
{formatNumber(perfData.engagement.shares)}
|
||||
</div>
|
||||
<div className="yt-analytics__engagement-label">Shares</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPipeline = () => {
|
||||
const pipeData = data as PipelineData
|
||||
if (!pipeData) return null
|
||||
|
||||
// Group statuses for pipeline bar
|
||||
const statusGroups: Record<string, { label: string; className: string }> = {
|
||||
idea: { label: 'Idee', className: 'idea' },
|
||||
script: { label: 'Skript', className: 'script' },
|
||||
review: { label: 'Review', className: 'review' },
|
||||
production: { label: 'Produktion', className: 'production' },
|
||||
editing: { label: 'Schnitt', className: 'editing' },
|
||||
ready: { label: 'Bereit', className: 'ready' },
|
||||
published: { label: 'Veröffentlicht', className: 'published' },
|
||||
}
|
||||
|
||||
const grouped = {
|
||||
idea: (pipeData.pipeline.idea || 0),
|
||||
script: (pipeData.pipeline.script_draft || 0) + (pipeData.pipeline.script_approved || 0),
|
||||
review: (pipeData.pipeline.script_review || 0) + (pipeData.pipeline.final_review || 0),
|
||||
production: (pipeData.pipeline.shoot_scheduled || 0) + (pipeData.pipeline.shot || 0),
|
||||
editing: (pipeData.pipeline.rough_cut || 0) + (pipeData.pipeline.fine_cut || 0),
|
||||
ready: (pipeData.pipeline.approved || 0) + (pipeData.pipeline.upload_scheduled || 0),
|
||||
published: (pipeData.pipeline.published || 0),
|
||||
}
|
||||
|
||||
const total = Object.values(grouped).reduce((sum, val) => sum + val, 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="yt-analytics__stats-grid">
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--production">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">In Produktion</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{pipeData.stats.inProduction}</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--success">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Diese Woche</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{pipeData.stats.thisWeekCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--error">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Überfällige Tasks</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{pipeData.stats.overdueTasksCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--warning">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Offene Freigaben</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{pipeData.stats.pendingApprovals}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Pipeline Übersicht</h3>
|
||||
<div className="yt-analytics__pipeline-legend">
|
||||
{Object.entries(statusGroups).map(([key, { label, className }]) => (
|
||||
<div key={key} className="yt-analytics__legend-item">
|
||||
<div className={`yt-analytics__legend-dot yt-analytics__pipeline-segment--${className}`} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="yt-analytics__pipeline-bar">
|
||||
{Object.entries(grouped).map(([key, count]) => {
|
||||
const percentage = total > 0 ? (count / total) * 100 : 0
|
||||
const { label, className } = statusGroups[key]
|
||||
return count > 0 ? (
|
||||
<div
|
||||
key={key}
|
||||
className={`yt-analytics__pipeline-segment yt-analytics__pipeline-segment--${className}`}
|
||||
style={{ flexBasis: `${percentage}%` }}
|
||||
title={`${label}: ${count}`}
|
||||
>
|
||||
{percentage > 5 && `${count}`}
|
||||
</div>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pipeData.thisWeekVideos && pipeData.thisWeekVideos.length > 0 && (
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Nächste Veröffentlichungen</h3>
|
||||
{pipeData.thisWeekVideos.map((video) => (
|
||||
<div key={video.id} className="yt-analytics__schedule-item">
|
||||
<div className="yt-analytics__schedule-title">{video.title}</div>
|
||||
<div className="yt-analytics__schedule-date">
|
||||
{formatDate(video.scheduledPublishDate)} • {video.channel}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pipeData.overdueTasks && pipeData.overdueTasks.length > 0 && (
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Überfällige Aufgaben</h3>
|
||||
{pipeData.overdueTasks.map((task) => (
|
||||
<div key={task.id} className="yt-analytics__task-item">
|
||||
<div className="yt-analytics__task-header">
|
||||
<div className="yt-analytics__task-title">{task.title}</div>
|
||||
<div className="yt-analytics__task-meta">Fällig: {formatDate(task.dueDate)}</div>
|
||||
</div>
|
||||
<div className="yt-analytics__task-detail">
|
||||
{task.assignedTo}
|
||||
{task.video && ` • ${task.video}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderGoals = () => {
|
||||
const goalsData = data as GoalsData
|
||||
if (!goalsData) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="yt-analytics__stats-grid">
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--production">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Content</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{goalsData.stats.contentLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--subs">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Abonnenten</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{goalsData.stats.subscriberLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--views">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Aufrufe</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{goalsData.stats.viewsLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--success">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Gesamt</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{goalsData.stats.overall.toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Fortschritt</h3>
|
||||
<div className="yt-analytics__progress-item">
|
||||
<div className="yt-analytics__progress-label">Content</div>
|
||||
<div className="yt-analytics__progress-bar">
|
||||
<div
|
||||
className="yt-analytics__progress-fill"
|
||||
style={{ width: `${Math.min(goalsData.stats.contentProgress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="yt-analytics__progress-value">{goalsData.stats.contentLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__progress-item">
|
||||
<div className="yt-analytics__progress-label">Abonnenten</div>
|
||||
<div className="yt-analytics__progress-bar">
|
||||
<div
|
||||
className="yt-analytics__progress-fill"
|
||||
style={{ width: `${Math.min(goalsData.stats.subscriberProgress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="yt-analytics__progress-value">{goalsData.stats.subscriberLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__progress-item">
|
||||
<div className="yt-analytics__progress-label">Aufrufe</div>
|
||||
<div className="yt-analytics__progress-bar">
|
||||
<div
|
||||
className="yt-analytics__progress-fill"
|
||||
style={{ width: `${Math.min(goalsData.stats.viewsProgress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="yt-analytics__progress-value">{goalsData.stats.viewsLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{goalsData.customGoals && goalsData.customGoals.length > 0 && (
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Benutzerdefinierte Ziele</h3>
|
||||
{goalsData.customGoals.map((goal, index) => (
|
||||
<div key={index} className="yt-analytics__video-row">
|
||||
<div className="yt-analytics__video-title">{goal.metric}</div>
|
||||
<div className="yt-analytics__video-metric">
|
||||
{goal.current} / {goal.target}
|
||||
</div>
|
||||
<span className={`yt-analytics__status-badge yt-analytics__status-badge--${goal.status}`}>
|
||||
{goal.status === 'on_track' && 'Im Plan'}
|
||||
{goal.status === 'at_risk' && 'Gefährdet'}
|
||||
{goal.status === 'achieved' && 'Erreicht'}
|
||||
{goal.status === 'missed' && 'Verfehlt'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCommunity = () => {
|
||||
const commData = data as CommunityData
|
||||
if (!commData) return null
|
||||
|
||||
const sentimentMax = Math.max(...Object.values(commData.sentiment))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="yt-analytics__stats-grid">
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--warning">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Unbearbeitet</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{commData.stats.unresolvedCount}</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--success">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Positiv</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{commData.stats.positivePct.toFixed(0)}%</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--production">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Antwortzeit</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{commData.stats.avgResponseHours.toFixed(1)}h</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__stat-card yt-analytics__stat-card--error">
|
||||
<div className="yt-analytics__stat-header">
|
||||
<span className="yt-analytics__stat-label">Eskalationen</span>
|
||||
</div>
|
||||
<div className="yt-analytics__stat-value">{commData.stats.escalationsCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Sentiment</h3>
|
||||
<div className="yt-analytics__sentiment-list">
|
||||
{Object.entries(commData.sentiment).map(([sentiment, count]) => {
|
||||
const percentage = sentimentMax > 0 ? (count / sentimentMax) * 100 : 0
|
||||
return (
|
||||
<div key={sentiment} className="yt-analytics__sentiment-item">
|
||||
<div className="yt-analytics__sentiment-label">
|
||||
{sentiment === 'positive' && 'Positiv'}
|
||||
{sentiment === 'neutral' && 'Neutral'}
|
||||
{sentiment === 'negative' && 'Negativ'}
|
||||
{sentiment === 'question' && 'Frage'}
|
||||
</div>
|
||||
<div className="yt-analytics__sentiment-bar-track">
|
||||
<div
|
||||
className={`yt-analytics__sentiment-bar-fill yt-analytics__sentiment-bar-fill--${sentiment}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="yt-analytics__sentiment-count">{count}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{commData.topTopics && commData.topTopics.length > 0 && (
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Top-Themen</h3>
|
||||
<div className="yt-analytics__topic-list">
|
||||
{commData.topTopics.map((topic, index) => (
|
||||
<div key={index} className="yt-analytics__topic-tag">
|
||||
{topic.topic}
|
||||
<span className="yt-analytics__topic-count">{topic.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commData.recentUnresolved && commData.recentUnresolved.length > 0 && (
|
||||
<div className="yt-analytics__section">
|
||||
<h3 className="yt-analytics__section-title">Neueste unbearbeitete Kommentare</h3>
|
||||
{commData.recentUnresolved.map((comment) => (
|
||||
<div key={comment.id} className="yt-analytics__comment-item">
|
||||
<div className="yt-analytics__comment-header">
|
||||
<div className="yt-analytics__comment-author">
|
||||
{comment.authorName} (@{comment.authorHandle})
|
||||
</div>
|
||||
<div className="yt-analytics__comment-meta">
|
||||
{comment.platform} • {formatDate(comment.publishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="yt-analytics__comment-message">{comment.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="yt-analytics">
|
||||
<div className="yt-analytics__header">
|
||||
<div className="yt-analytics__title-section">
|
||||
<h2>YouTube Analytics</h2>
|
||||
<p>Übersicht über Performance, Pipeline, Ziele und Community</p>
|
||||
</div>
|
||||
<div className="yt-analytics__controls">
|
||||
<select
|
||||
className="yt-analytics__channel-select"
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value)}
|
||||
>
|
||||
<option value="all">Alle Kanäle</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.slug}>
|
||||
{ch.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="yt-analytics__period-select"
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as Period)}
|
||||
>
|
||||
{Object.entries(periodLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="yt-analytics__refresh-btn"
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
aria-label="Aktualisieren"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className={loading ? 'yt-analytics__spinner' : ''}
|
||||
>
|
||||
<path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="yt-analytics__tabs">
|
||||
{tabConfig.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`yt-analytics__tab ${activeTab === tab.key ? 'yt-analytics__tab--active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && !data && (
|
||||
<div className="yt-analytics__loading">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="yt-analytics__spinner"
|
||||
>
|
||||
<path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16" />
|
||||
</svg>
|
||||
<p>Lade Daten...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="yt-analytics__error">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<div className="yt-analytics__content">
|
||||
{activeTab === 'performance' && renderPerformance()}
|
||||
{activeTab === 'pipeline' && renderPipeline()}
|
||||
{activeTab === 'goals' && renderGoals()}
|
||||
{activeTab === 'community' && renderCommunity()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default YouTubeAnalyticsDashboard
|
||||
10
src/components/admin/YouTubeAnalyticsDashboardView.tsx
Normal file
10
src/components/admin/YouTubeAnalyticsDashboardView.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { YouTubeAnalyticsDashboard } from './YouTubeAnalyticsDashboard'
|
||||
|
||||
export const YouTubeAnalyticsDashboardView: React.FC = () => {
|
||||
return <YouTubeAnalyticsDashboard />
|
||||
}
|
||||
|
||||
export default YouTubeAnalyticsDashboardView
|
||||
87
src/components/admin/YouTubeAnalyticsNavLinks.scss
Normal file
87
src/components/admin/YouTubeAnalyticsNavLinks.scss
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
.yt-nav-links {
|
||||
padding: 0 var(--base);
|
||||
margin-top: var(--base);
|
||||
border-top: 1px solid var(--theme-elevation-100);
|
||||
padding-top: var(--base);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--theme-elevation-500);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--theme-elevation-800);
|
||||
font-size: 13px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-elevation-50);
|
||||
color: var(--theme-elevation-900);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--theme-elevation-100);
|
||||
color: var(--theme-text);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-elevation-150);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__link-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__link-label {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
.yt-nav-links {
|
||||
&__link {
|
||||
&:hover {
|
||||
background-color: var(--theme-elevation-100);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--theme-elevation-150);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-elevation-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/components/admin/YouTubeAnalyticsNavLinks.tsx
Normal file
45
src/components/admin/YouTubeAnalyticsNavLinks.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import './YouTubeAnalyticsNavLinks.scss'
|
||||
|
||||
export const YouTubeAnalyticsNavLinks: React.FC = () => {
|
||||
const pathname = usePathname()
|
||||
|
||||
const links = [
|
||||
{
|
||||
href: '/admin/youtube-analytics',
|
||||
label: 'YouTube Analytics',
|
||||
icon: '📈',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="yt-nav-links">
|
||||
<div className="yt-nav-links__header">
|
||||
<span className="yt-nav-links__icon">🎬</span>
|
||||
<span className="yt-nav-links__title">YouTube</span>
|
||||
</div>
|
||||
<nav className="yt-nav-links__list">
|
||||
{links.map((link) => {
|
||||
const isActive = pathname === link.href
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`yt-nav-links__link ${isActive ? 'yt-nav-links__link--active' : ''}`}
|
||||
>
|
||||
<span className="yt-nav-links__link-icon">{link.icon}</span>
|
||||
<span className="yt-nav-links__link-label">{link.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default YouTubeAnalyticsNavLinks
|
||||
|
|
@ -128,14 +128,19 @@ export default buildConfig({
|
|||
admin: {
|
||||
user: Users.slug,
|
||||
components: {
|
||||
// Community Management Nav Links
|
||||
afterNavLinks: ['@/components/admin/CommunityNavLinks#CommunityNavLinks'],
|
||||
// Custom Views - re-enabled after Payload 3.76.1 upgrade (Issue #15241 test)
|
||||
afterNavLinks: [
|
||||
'@/components/admin/CommunityNavLinks#CommunityNavLinks',
|
||||
'@/components/admin/YouTubeAnalyticsNavLinks#YouTubeAnalyticsNavLinks',
|
||||
],
|
||||
views: {
|
||||
TenantDashboard: {
|
||||
Component: '@/components/admin/TenantDashboardView#TenantDashboardView',
|
||||
path: '/tenant-dashboard',
|
||||
},
|
||||
YouTubeAnalyticsDashboard: {
|
||||
Component: '@/components/admin/YouTubeAnalyticsDashboardView#YouTubeAnalyticsDashboardView',
|
||||
path: '/youtube-analytics',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue