Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)

This commit is contained in:
2026-08-11 12:27:44 +02:00
commit d5af006443
304 changed files with 74604 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.env.local
.env.development.local
.env.production.local
*.log
.git
+6
View File
@@ -0,0 +1,6 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}
+30
View File
@@ -0,0 +1,30 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# NEXT_PUBLIC_* variables are inlined into JS bundles at build time.
# Pass the real API URL as a build argument — it cannot be changed at runtime.
ARG NEXT_PUBLIC_API_URL=/api/v1
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# ─────────────────────────────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# next.config.ts has output: 'standalone' — copy only what the standalone server needs
RUN mkdir -p ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]
+63
View File
@@ -0,0 +1,63 @@
# StudioFlow Frontend Implementation Plan
## 1. Directory Structure (Next.js 15 App Router)
```text
frontend/
├── src/
│ ├── app/ # Pages & Routing
│ │ ├── (dashboard)/ # Protected routes shell
│ │ │ ├── videos/ # Video List & Detail Editor
│ │ │ ├── blocks/ # Block Management
│ │ │ ├── collaborators/ # CRM
│ │ │ ├── calendar/ # Planning
│ │ │ └── bulk-jobs/ # Job Monitoring
│ │ ├── (auth)/ # Login & OAuth
│ │ └── layout.tsx # Providers & Root styling
│ ├── components/ # Reusable Components
│ │ ├── ui/ # Base primitives (Button, Input, Card)
│ │ ├── video-table/ # Virtualized Table & Filters
│ │ ├── config-editor/ # D&D Block Editor
│ │ └── shared/ # Sidebar, Header, QuotaIndicator
│ ├── hooks/ # TanStack Query & Logic Hooks
│ ├── lib/ # API Client, Utils, Constants
│ ├── store/ # Zustand (UI State)
│ └── styles/ # Vanilla CSS & Theme
```
## 2. Core Feature Specifications
### 2.1 Video Table (Command Center)
- **Engine:** TanStack Table v8.
- **Virtualization:** `react-window` or `@tanstack/react-virtual` for 1000+ rows.
- **Features:**
- Inline editing for Title/Tags.
- Multi-select for Bulk Actions (Sync, Render, Delete).
- Saved View sidebar for quick filtering (e.g., "Missing CTA").
### 2.2 Video Config Editor
- **Logic:** Manages `VideoConfig` state (blockOrder, overrides, variables).
- **Interaction:** Drag-and-drop sorting for blocks.
- **Live Preview:** Debounced rendering call to backend to show final description.
- **Variables:** Dynamically generated form based on the blocks used in the config.
### 2.3 Quota Dashboard
- **Visual:** Circular progress indicator for daily units.
- **Logic:** Polling every 60s while active. Warning notifications when units < 10%.
## 3. Aesthetic & UI Strategy
- **Theme:** "Dark Studio" (Background: #0f0f0f, Surface: #1e1e1e, Accents: YouTube Red #FF0000).
- **Typography:** Inter or Roboto (standard for YouTube Studio feel).
- **Animations:** Framer Motion for layout transitions and modal entries.
## 4. API Integration
- **Client:** `axios` with interceptors.
- **Error Handling:** Centralized toast notifications for API errors.
- **Auth:** NextAuth.js or custom JWT storage with Google OAuth 2.0.
## 5. Phase 1 Implementation Tasks
1. Initialize Next.js 15 with TS and Vanilla CSS.
2. Set up `api-client.ts` and `QueryProvider`.
3. Create global layout (Sidebar + Header).
4. Implement `VideoTable` with mock data.
5. Implement `ConfigEditor` basic D&D.
+29
View File
@@ -0,0 +1,29 @@
# StudioFlow — Frontend
## Übersicht
Die Weboberfläche von StudioFlow dient zur Verwaltung von YouTube-Metadaten, zur Konfiguration von Videobeschreibungen und zur Überwachung von Bulk-Jobs.
## Technologie-Stack
- **Framework:** Next.js 15 (App Router, TypeScript)
- **Styling:** Vanilla CSS (bevorzugt) oder Shadcn UI / Radix UI für Komponenten
- **State Management:** Zustand (Client State), TanStack Query (Server State)
- **Tabellen:** TanStack Table (virtuelle Listen, Filter, Batch-Select)
- **Icons:** Lucide React
- **Datum:** date-fns
## Kernbereiche (Seiten)
1. **Dashboard:** Quota-Verbrauch, Lint-Fehler, aktive Bulk-Jobs.
2. **Video-Tabelle:** Zentrale Liste mit Filtern, Suche und Bulk-Aktionen.
3. **Video-Config-Editor:** Drag-and-Drop Editor für Block-Reihenfolge und Variablen.
4. **Block-Library:** Verwaltung der Textbausteine.
5. **Kollaboratoren:** Übersicht und Detailseiten für Gast-Creator.
6. **Content Calendar:** Monats-/Wochenansicht der Upload-Planung.
## UI-Komponenten
- `video-table/`: Hochperformante Tabelle für hunderte Datensätze.
- `config-editor/`: Interaktiver Editor für Video-Strukturen.
- `quota-indicator/`: Visuelle Anzeige des täglichen YouTube-Limits.
- `lint-badge/`: Status-Anzeige für Metadaten-Validierung.
## API-Kommunikation
Alle Anfragen erfolgen an den API-Container (Standard: `http://localhost:3001`). Der API-Client sollte in `src/lib/api-client.ts` zentralisiert sein.
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+14
View File
@@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['react-icons'],
images: {
remotePatterns: [
{ protocol: 'https', hostname: '**.ytimg.com' },
{ protocol: 'https', hostname: 'img.youtube.com' },
],
},
};
export default nextConfig;
+5850
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "studioflow-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-table": "^8.20.5",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.454.0",
"next": "^15.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-icons": "^5.6.0",
"tailwind-merge": "^2.5.4",
"zustand": "^5.0.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^8",
"eslint-config-next": "15.0.3",
"typescript": "^5"
}
}
@@ -0,0 +1,18 @@
.layout {
display: flex;
min-height: 100vh;
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
padding: 24px;
min-width: 0;
overflow-x: hidden;
}
@@ -0,0 +1,303 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-5);
max-width: 1200px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.header h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
/* ── Filters ── */
.filters {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.filterSelect {
width: auto;
cursor: pointer;
}
.clearBtn {
font-size: var(--text-xs);
color: var(--color-text-faint);
background: none;
border: none;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
.clearBtn:hover { color: var(--color-text); background: var(--color-surface-offset); }
.total {
margin-left: auto;
font-size: var(--text-xs);
color: var(--color-text-faint);
}
/* ── States ── */
.state {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-8);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.empty {
padding: var(--space-10) 0;
text-align: center;
color: var(--color-text-faint);
font-size: var(--text-sm);
font-style: italic;
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
/* ── Table ── */
.tableWrap {
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.table thead tr {
background: var(--color-surface-offset);
border-bottom: 1px solid var(--color-divider);
}
.table th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--color-text-faint);
white-space: nowrap;
}
.row {
border-bottom: 1px solid var(--color-divider);
transition: background 0.1s;
cursor: default;
}
.row:last-child { border-bottom: none; }
.row:hover { background: var(--color-surface-offset); }
.rowExpanded { background: var(--color-surface-offset); }
.row td {
padding: var(--space-3) var(--space-4);
vertical-align: middle;
}
/* ── Cells ── */
.timeCell {
white-space: nowrap;
color: var(--color-text-muted);
font-size: var(--text-xs);
font-variant-numeric: tabular-nums;
}
.actorCell {
font-size: var(--text-sm);
color: var(--color-text);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entityType {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-sm);
padding: 1px 6px;
}
.entityIdCell { color: var(--color-text-faint); }
.entityId {
font-family: var(--font-mono, monospace);
font-size: 11px;
background: var(--color-surface-offset);
border-radius: var(--radius-sm);
padding: 1px 5px;
}
/* ── Action pills ── */
.actionPill {
display: inline-block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
border-radius: var(--radius-sm);
padding: 2px 7px;
}
.pillCreate {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.pillUpdate {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
}
.pillDelete {
background: color-mix(in srgb, var(--color-error) 15%, transparent);
color: var(--color-error);
}
/* ── Expand ── */
.expandCell { width: 32px; text-align: center; }
.expandBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
border: none;
background: none;
color: var(--color-text-faint);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.expandBtn:hover { background: var(--color-surface-raised); color: var(--color-text); }
/* ── Diff ── */
.diffRow td {
padding: 0 var(--space-4) var(--space-4);
}
.diffEmpty {
font-size: var(--text-xs);
color: var(--color-text-faint);
font-style: italic;
padding: var(--space-2) 0;
}
.diffTable {
width: 100%;
border-collapse: collapse;
font-size: var(--text-xs);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
overflow: hidden;
}
.diffTable th {
background: var(--color-surface-raised);
padding: var(--space-2) var(--space-3);
text-align: left;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--color-text-faint);
font-size: 10px;
border-bottom: 1px solid var(--color-divider);
}
.diffTable tr + tr { border-top: 1px solid var(--color-divider); }
.diffTable td {
padding: var(--space-2) var(--space-3);
vertical-align: top;
font-family: var(--font-mono, monospace);
word-break: break-all;
}
.diffKey {
font-family: inherit;
font-weight: 600;
color: var(--color-text-muted);
white-space: nowrap;
width: 160px;
}
.diffBefore {
color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 6%, transparent);
}
.diffAfter {
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 6%, transparent);
}
.diffNone { color: var(--color-text-faint); font-style: italic; font-family: inherit; }
/* ── Pagination ── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-4);
padding: var(--space-2) 0 var(--space-4);
}
.pageBtn {
font-size: var(--text-sm);
padding: var(--space-2) var(--space-4);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
cursor: pointer;
transition: background 0.1s;
}
.pageBtn:hover:not(:disabled) { background: var(--color-surface-offset); }
.pageBtn:disabled { opacity: 0.4; cursor: not-allowed; }
.pageInfo {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
+228
View File
@@ -0,0 +1,228 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react';
import { fetchAuditLogs, type AuditLog } from '@/lib/api';
import f from '@/components/shared/FormField.module.css';
import styles from './page.module.css';
const ENTITY_TYPES = ['Collaborator', 'DescriptionBlock', 'Template', 'TeamVariable', 'Video', 'VideoConfig', 'SavedView', 'TeamMember'];
const ACTIONS = ['create', 'update', 'delete'];
const ACTION_PILL: Record<string, string> = {
create: styles.pillCreate,
update: styles.pillUpdate,
delete: styles.pillDelete,
};
function relativeTime(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString();
}
function computeDiff(before: Record<string, unknown> | null, after: Record<string, unknown> | null) {
if (!before && !after) return [];
if (!before) return Object.entries(after ?? {}).map(([k, v]) => ({ key: k, before: undefined, after: v }));
if (!after) return Object.entries(before).map(([k, v]) => ({ key: k, before: v, after: undefined }));
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
return [...keys]
.map((k) => ({ key: k, before: before[k], after: after[k] }))
.filter((d) => JSON.stringify(d.before) !== JSON.stringify(d.after));
}
function DiffRow({ log }: { log: AuditLog }) {
const diff = computeDiff(log.beforeJson, log.afterJson);
if (diff.length === 0 && !log.afterJson && !log.beforeJson) return null;
const SKIP = new Set(['updatedAt', 'createdAt']);
const rows = diff.filter((d) => !SKIP.has(d.key));
if (rows.length === 0) {
return (
<div className={styles.diffEmpty}>No tracked field changes.</div>
);
}
return (
<table className={styles.diffTable}>
<thead>
<tr>
<th>Field</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
{rows.map((d) => (
<tr key={d.key}>
<td className={styles.diffKey}>{d.key}</td>
<td className={styles.diffBefore}>{d.before === undefined ? <span className={styles.diffNone}></span> : JSON.stringify(d.before)}</td>
<td className={styles.diffAfter}>{d.after === undefined ? <span className={styles.diffNone}></span> : JSON.stringify(d.after)}</td>
</tr>
))}
</tbody>
</table>
);
}
function LogRow({ log }: { log: AuditLog }) {
const [expanded, setExpanded] = useState(false);
const hasDiff = !!(log.beforeJson || log.afterJson);
const actorLabel = log.actor?.name ?? log.actor?.email ?? log.actorId.slice(0, 8);
return (
<>
<tr className={`${styles.row} ${expanded ? styles.rowExpanded : ''}`} onClick={() => hasDiff && setExpanded((v) => !v)}>
<td className={styles.timeCell}>
<span title={formatDateTime(log.createdAt)}>{relativeTime(log.createdAt)}</span>
</td>
<td className={styles.actorCell}>{actorLabel}</td>
<td>
<span className={styles.entityType}>{log.entityType}</span>
</td>
<td className={styles.entityIdCell}>
<code className={styles.entityId}>{log.entityId.slice(-8)}</code>
</td>
<td>
<span className={`${styles.actionPill} ${ACTION_PILL[log.action] ?? styles.pillUpdate}`}>
{log.action}
</span>
</td>
<td className={styles.expandCell}>
{hasDiff && (
<button className={styles.expandBtn} onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
)}
</td>
</tr>
{expanded && (
<tr className={styles.diffRow}>
<td colSpan={6}>
<DiffRow log={log} />
</td>
</tr>
)}
</>
);
}
const LIMIT = 50;
export default function AuditPage() {
const [page, setPage] = useState(1);
const [entityType, setEntityType] = useState('');
const [action, setAction] = useState('');
const { data, isLoading, isError } = useQuery({
queryKey: ['audit-logs', page, entityType, action],
queryFn: () => fetchAuditLogs({ page, limit: LIMIT, entityType: entityType || undefined, action: action || undefined }),
});
const totalPages = data ? Math.ceil(data.total / LIMIT) : 1;
const handleFilter = (next: { entityType?: string; action?: string }) => {
if (next.entityType !== undefined) setEntityType(next.entityType);
if (next.action !== undefined) setAction(next.action);
setPage(1);
};
return (
<div className={styles.container}>
<header className={styles.header}>
<div>
<span className={styles.eyebrow}>Team Activity</span>
<h1>History</h1>
</div>
</header>
<div className={styles.filters}>
<select
className={`${f.input} ${styles.filterSelect}`}
value={entityType}
onChange={(e) => handleFilter({ entityType: e.target.value })}
>
<option value="">All entity types</option>
{ENTITY_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select
className={`${f.input} ${styles.filterSelect}`}
value={action}
onChange={(e) => handleFilter({ action: e.target.value })}
>
<option value="">All actions</option>
{ACTIONS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
{(entityType || action) && (
<button className={styles.clearBtn} onClick={() => handleFilter({ entityType: '', action: '' })}>
Clear filters
</button>
)}
{data && (
<span className={styles.total}>{data.total.toLocaleString()} events</span>
)}
</div>
{isLoading && (
<div className={styles.state}><Loader2 size={20} className={styles.spin} /> Loading history</div>
)}
{isError && (
<div className={styles.state}><AlertCircle size={18} /> Failed to load audit logs.</div>
)}
{data && data.data.length === 0 && (
<div className={styles.empty}>No activity found{entityType || action ? ' for this filter' : ''}.</div>
)}
{data && data.data.length > 0 && (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Entity Type</th>
<th>Entity ID</th>
<th>Action</th>
<th />
</tr>
</thead>
<tbody>
{data.data.map((log) => <LogRow key={log.id} log={log} />)}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className={styles.pagination}>
<button className={styles.pageBtn} onClick={() => setPage((p) => p - 1)} disabled={page <= 1}>
Prev
</button>
<span className={styles.pageInfo}>Page {page} of {totalPages}</span>
<button className={styles.pageBtn} onClick={() => setPage((p) => p + 1)} disabled={page >= totalPages}>
Next
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,547 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-8);
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.headerLeft h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
.headerRight {
display: flex;
gap: var(--space-3);
}
.controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-6);
}
.searchBar {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
flex: 1;
max-width: 480px;
color: var(--color-text-faint);
}
.searchBar input {
background: none;
border: none;
color: var(--color-text);
outline: none;
flex: 1;
font-size: var(--text-sm);
}
.filterRow {
display: flex;
align-items: center;
gap: var(--space-4);
}
.filterBtn {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: 600;
}
.quickFilters {
display: flex;
gap: var(--space-2);
}
.tag {
font-size: var(--text-xs);
font-weight: 600;
padding: 0.25rem 0.75rem;
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
color: var(--color-text-muted);
cursor: pointer;
}
.tag:hover {
background: var(--color-divider);
color: var(--color-text);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: var(--space-6);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-5);
box-shadow: var(--shadow-sm);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.titleInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.blockName {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text);
}
.blockDesc {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.previewBox {
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
padding: var(--space-4);
}
.preview {
font-family: var(--font-body);
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: pre-wrap;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
}
.cardFooter {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
}
.usage {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.usage strong {
color: var(--color-text-muted);
}
.editBtn {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.count {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: var(--space-3);
padding: 0.1rem 0.5rem;
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
vertical-align: middle;
}
.tagActive {
background: var(--color-primary-highlight);
border-color: var(--color-primary);
color: var(--color-primary);
}
.tags {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.blockTag {
font-size: 10px;
font-weight: 600;
padding: 0.15rem 0.5rem;
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-full);
color: var(--color-text-faint);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.state {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* ─── Block modal 2-column layout ──────────────────────────────────────── */
.modalGrid {
display: grid;
grid-template-columns: 1fr 340px;
gap: var(--space-6);
align-items: start;
}
.modalLeft {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.modalRight {
display: flex;
flex-direction: column;
gap: var(--space-4);
position: sticky;
top: 0;
}
.varDeclSection {
display: flex;
flex-direction: column;
gap: var(--space-3);
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
}
.varDeclTitle {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.varDeclHint {
font-size: var(--text-xs);
color: var(--color-text-faint);
line-height: 1.4;
}
/* Variable reference panel */
.varPanel {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: 0;
padding: var(--space-3);
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
}
.varGroup {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.varGroupLabel {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.varChips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.varChip {
font-size: var(--text-xs);
font-family: var(--font-mono, monospace);
padding: 2px 8px;
border-radius: var(--radius-full, 999px);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-primary);
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
white-space: nowrap;
display: flex;
flex-direction: column;
align-items: flex-start;
border-radius: var(--radius-md);
white-space: normal;
}
.varChipLabel {
font-family: var(--font-sans, sans-serif);
font-size: 10px;
color: var(--color-text-faint);
margin-top: 1px;
}
.varChip:hover {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-color: var(--color-primary);
}
.varChipCollab {
color: var(--color-purple, #a855f7);
border-color: color-mix(in srgb, var(--color-purple, #a855f7) 40%, transparent);
}
.varChipCollab:hover {
background: color-mix(in srgb, var(--color-purple, #a855f7) 10%, transparent);
border-color: var(--color-purple, #a855f7);
}
.trashBtn {
color: var(--color-text-faint);
display: flex;
align-items: center;
transition: color 0.12s;
}
.trashBtn:hover { color: var(--color-error); }
.confirmDelete {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.confirmText { font-size: var(--text-xs); color: var(--color-text-muted); }
.deleteErr { font-size: var(--text-xs); color: var(--color-error); max-width: 200px; }
.confirmYes {
font-size: var(--text-xs);
font-weight: 700;
color: white;
background: var(--color-error);
border-radius: var(--radius-sm);
padding: 2px 8px;
}
.confirmNo {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 1s linear infinite; }
/* Type description below the select */
.typeDesc {
font-size: var(--text-xs);
color: var(--color-text-faint);
line-height: 1.4;
margin-top: var(--space-1);
}
/* Card type hint */
.typeHint {
font-size: var(--text-xs);
color: var(--color-text-faint);
line-height: 1.4;
margin-top: calc(-1 * var(--space-2));
}
/* ─── Condition builder ──────────────────────────────────────────────────── */
.conditionBuilder {
display: flex;
flex-direction: column;
gap: var(--space-2);
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.conditionHeader {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.conditionLabel {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-weight: 600;
}
.combinatorSelect {
padding: 2px var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
cursor: pointer;
}
.conditionEmpty {
font-size: var(--text-xs);
color: var(--color-text-faint);
font-style: italic;
}
.ruleList {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.ruleRow {
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
flex-wrap: wrap;
}
.ruleTypeSelect {
padding: 3px var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
font-size: var(--text-xs);
color: var(--color-text);
cursor: pointer;
}
.ruleInput {
flex: 1;
min-width: 120px;
padding: 3px var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
font-size: var(--text-xs);
color: var(--color-text);
outline: none;
}
.ruleInput:focus { border-color: var(--color-primary); }
.ruleNumInput {
width: 60px;
padding: 3px var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
font-size: var(--text-xs);
color: var(--color-text);
outline: none;
text-align: center;
}
.ruleNumInput:focus { border-color: var(--color-primary); }
.ruleValueLabel {
font-size: var(--text-xs);
color: var(--color-text-faint);
white-space: nowrap;
}
.ruleRemoveBtn {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-sm);
color: var(--color-text-faint);
flex-shrink: 0;
transition: all 0.15s;
}
.ruleRemoveBtn:hover {
background: color-mix(in srgb, var(--color-error), transparent 85%);
color: var(--color-error);
}
.addRuleBtn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
padding: var(--space-1) var(--space-2);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
align-self: flex-start;
transition: all 0.15s;
}
.addRuleBtn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-primary-highlight);
}
@@ -0,0 +1,556 @@
'use client';
import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, ListFilter, Loader2, AlertCircle, Trash2, X } from 'lucide-react';
import {
fetchBlocks, createBlock, updateBlock, deleteBlock, fetchBlockUsage,
fetchTeamVariables, fetchSystemVariables, fetchCampaigns,
type Block, type BlockPayload, type BlockVariableDefinition,
type TeamVariable, type SystemVariable, type Campaign,
type BlockCondition, type ConditionRule, type CollabCountRule,
} from '@/lib/api';
import UsagePanel from '@/components/shared/UsagePanel';
import Modal from '@/components/shared/Modal';
import f from '@/components/shared/FormField.module.css';
import styles from './page.module.css';
const TYPE_META: Record<string, { label: string; desc: string; pill: string }> = {
STATIC: { label: 'Static', pill: 'pill-blue', desc: 'Output verbatim — no token substitution of any kind. Use for boilerplate text that must never be altered.' },
VARIABLE: { label: 'Variable', pill: 'pill-warn', desc: 'Text that changes per video. Variables like {sponsor_name} are filled in when the description is rendered.' },
CONDITIONAL: { label: 'Conditional', pill: 'pill-primary', desc: 'Only included when all (or any) of its conditions are met. Define conditions below.' },
COLLABORATOR:{ label: 'Collaborator', pill: 'pill-purple', desc: 'Repeats once per collaborator linked to the video, filling in {collab.name}, {collab.youtube}, etc.' },
CAMPAIGN: { label: 'Campaign', pill: 'pill-warn', desc: 'Auto-included in every render while the linked campaign\'s date window is active.' },
};
const BLOCK_TYPES = Object.keys(TYPE_META);
const TYPE_FILTERS = ['All', 'Static', 'Variable', 'Global', 'Conditional'] as const;
function TypePill({ type }: { type: string }) {
const meta = TYPE_META[type];
return <span className={`pill ${meta?.pill ?? 'pill-primary'}`}>{meta?.label ?? type}</span>;
}
// ─── Condition builder ────────────────────────────────────────────────────────
const OPERATOR_LABELS: Record<string, string> = {
eq: 'is exactly', gt: 'more than', lt: 'fewer than', gte: 'at least', lte: 'at most',
};
const EMPTY_CONDITION: BlockCondition = { combinator: 'and', rules: [] };
function emptyRule(): ConditionRule {
return { type: 'variable_filled', variable: '' };
}
function ConditionBuilder({
condition,
onChange,
teamVars,
}: {
condition: BlockCondition;
onChange: (c: BlockCondition) => void;
teamVars: TeamVariable[];
}) {
const setCombinator = (v: 'and' | 'or') => onChange({ ...condition, combinator: v });
const addRule = () => onChange({ ...condition, rules: [...condition.rules, emptyRule()] });
const removeRule = (i: number) =>
onChange({ ...condition, rules: condition.rules.filter((_, idx) => idx !== i) });
const updateRule = (i: number, patch: Partial<ConditionRule>) =>
onChange({
...condition,
rules: condition.rules.map((r, idx) =>
idx === i ? { ...r, ...patch } as ConditionRule : r,
),
});
const changeRuleType = (i: number, type: ConditionRule['type']) => {
if (type === 'collab_count') {
updateRule(i, { type, operator: 'eq', value: 1 } as Partial<ConditionRule>);
} else {
updateRule(i, { type, variable: '' } as Partial<ConditionRule>);
}
};
return (
<div className={styles.conditionBuilder}>
<div className={styles.conditionHeader}>
<span className={styles.conditionLabel}>Show this block when</span>
<select
className={styles.combinatorSelect}
value={condition.combinator}
onChange={(e) => setCombinator(e.target.value as 'and' | 'or')}
>
<option value="and">ALL</option>
<option value="or">ANY</option>
</select>
<span className={styles.conditionLabel}>of these conditions are met</span>
</div>
{condition.rules.length === 0 && (
<p className={styles.conditionEmpty}>No conditions block is always included when active.</p>
)}
<div className={styles.ruleList}>
{condition.rules.map((rule, i) => (
<div key={i} className={styles.ruleRow}>
<select
className={styles.ruleTypeSelect}
value={rule.type}
onChange={(e) => changeRuleType(i, e.target.value as ConditionRule['type'])}
>
<option value="variable_filled">Variable</option>
<option value="variable_empty">Variable (empty)</option>
<option value="collab_count">Collaborator count</option>
</select>
{(rule.type === 'variable_filled' || rule.type === 'variable_empty') && (
<>
<input
className={styles.ruleInput}
list={`vars-${i}`}
value={rule.variable}
onChange={(e) => updateRule(i, { variable: e.target.value } as Partial<ConditionRule>)}
placeholder="variable_name"
/>
<datalist id={`vars-${i}`}>
{teamVars.map((v) => <option key={v.id} value={v.name} />)}
</datalist>
<span className={styles.ruleValueLabel}>
{rule.type === 'variable_filled' ? 'is filled' : 'is empty'}
</span>
</>
)}
{rule.type === 'collab_count' && (
<>
<span className={styles.ruleValueLabel}>count is</span>
<select
className={styles.ruleTypeSelect}
value={rule.operator}
onChange={(e) => updateRule(i, { operator: e.target.value as CollabCountRule['operator'] } as Partial<ConditionRule>)}
>
{Object.entries(OPERATOR_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<input
className={styles.ruleNumInput}
type="number"
min={0}
value={rule.value}
onChange={(e) => updateRule(i, { value: Number(e.target.value) })}
/>
</>
)}
<button className={styles.ruleRemoveBtn} onClick={() => removeRule(i)} title="Remove condition">
<X size={13} />
</button>
</div>
))}
</div>
<button className={styles.addRuleBtn} onClick={addRule}>
<Plus size={13} />
Add condition
</button>
</div>
);
}
// ─── Variable reference panel ─────────────────────────────────────────────────
function VarDefEditor({ defs, onChange }: { defs: BlockVariableDefinition[]; onChange: (d: BlockVariableDefinition[]) => void }) {
const add = () => onChange([...defs, { name: '', label: '', description: '', defaultValue: '' }]);
const remove = (i: number) => onChange(defs.filter((_, idx) => idx !== i));
const update = (i: number, key: keyof BlockVariableDefinition, val: string) => {
const next = defs.map((d, idx) => idx === i ? { ...d, [key]: val } : d);
onChange(next);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
{defs.map((d, i) => (
<div key={i} style={{ background: 'var(--color-surface-offset)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 'var(--space-3)', display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<div className={f.row}>
<div className={f.field}>
<label className={f.label}>Placeholder name</label>
<input className={f.input} value={d.name} onChange={(e) => update(i, 'name', e.target.value)} placeholder="sponsor_name" />
</div>
<div className={f.field}>
<label className={f.label}>Display label</label>
<input className={f.input} value={d.label} onChange={(e) => update(i, 'label', e.target.value)} placeholder="Sponsor Name" />
</div>
</div>
<div className={f.row}>
<div className={f.field}>
<label className={f.label}>Description (optional)</label>
<input className={f.input} value={d.description ?? ''} onChange={(e) => update(i, 'description', e.target.value)} placeholder="Shown to editors" />
</div>
<div className={f.field}>
<label className={f.label}>Default value (optional)</label>
<input className={f.input} value={d.defaultValue ?? ''} onChange={(e) => update(i, 'defaultValue', e.target.value)} placeholder="e.g. MegaCorp" />
</div>
</div>
<button style={{ alignSelf: 'flex-end', display: 'flex', alignItems: 'center', gap: '4px', fontSize: 'var(--text-xs)', color: 'var(--color-error)' }} onClick={() => remove(i)}>
<Trash2 size={12} /> Remove
</button>
</div>
))}
<button className="btn btn-secondary" style={{ alignSelf: 'flex-start' }} onClick={add}>
<Plus size={14} /> Declare variable
</button>
</div>
);
}
function VariableReferencePanel({
teamVars,
systemVars,
onInsert,
}: {
teamVars: TeamVariable[];
systemVars: SystemVariable[];
onInsert: (token: string) => void;
}) {
const collabVars = systemVars.filter((v) => v.group === 'collaborator');
const videoVars = systemVars.filter((v) => v.group === 'video');
return (
<div className={styles.varPanel}>
{teamVars.length > 0 && (
<div className={styles.varGroup}>
<span className={styles.varGroupLabel}>Global Variables</span>
<div className={styles.varChips}>
{teamVars.map((v) => (
<button key={v.id} type="button" className={styles.varChip} title={`Current value: ${v.value}`} onClick={() => onInsert(`{${v.name}}`)}>
{`{${v.name}}`}
</button>
))}
</div>
</div>
)}
{videoVars.length > 0 && (
<div className={styles.varGroup}>
<span className={styles.varGroupLabel}>Video Variables</span>
<div className={styles.varChips}>
{videoVars.map((v) => (
<button key={v.token} type="button" className={`${styles.varChip} ${styles.varChipCollab}`} title={`${v.description} — e.g. "${v.example}"`} onClick={() => onInsert(v.placeholder)}>
<span>{v.placeholder}</span>
<span className={styles.varChipLabel}>{v.label}</span>
</button>
))}
</div>
</div>
)}
{collabVars.length > 0 && (
<div className={styles.varGroup}>
<span className={styles.varGroupLabel}>Collaborator Variables</span>
<div className={styles.varChips}>
{collabVars.map((v) => (
<button key={v.token} type="button" className={`${styles.varChip} ${styles.varChipCollab}`} title={`${v.description} — e.g. "${v.example}"`} onClick={() => onInsert(v.placeholder)}>
<span>{v.placeholder}</span>
<span className={styles.varChipLabel}>{v.label}</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
// ─── Block modal ──────────────────────────────────────────────────────────────
const EMPTY_FORM: BlockPayload = {
name: '', type: 'STATIC', content: '', language: 'de',
tags: [], active: true, compact: false, variableDefinitions: [], campaignId: null, condition: null,
};
function BlockModal({ initial, onClose }: { initial?: Block; onClose: () => void }) {
const qc = useQueryClient();
const initForm = (): BlockPayload => {
if (!initial) return EMPTY_FORM;
return {
name: initial.name, type: initial.type, content: initial.content,
language: initial.language, tags: initial.tags, active: initial.active,
compact: initial.compact, variableDefinitions: initial.variableDefinitions ?? [],
campaignId: initial.campaignId ?? null,
condition: initial.type === 'CONDITIONAL' ? (initial.condition ?? EMPTY_CONDITION) : null,
};
};
const [form, setForm] = useState<BlockPayload>(initForm);
const [tagsInput, setTagsInput] = useState(initial?.tags.join(', ') ?? '');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { data: teamVars = [] } = useQuery<TeamVariable[]>({ queryKey: ['team-variables'], queryFn: fetchTeamVariables });
const { data: systemVars = [] } = useQuery<SystemVariable[]>({ queryKey: ['system-variables'], queryFn: fetchSystemVariables });
const { data: campaigns = [] } = useQuery<Campaign[]>({ queryKey: ['campaigns'], queryFn: fetchCampaigns });
const { data: usage, isLoading: usageLoading } = useQuery({
queryKey: ['block-usage', initial?.id],
queryFn: () => fetchBlockUsage(initial!.id),
enabled: !!initial,
});
const mutation = useMutation({
mutationFn: () => {
const payload = { ...form, tags: tagsInput.split(',').map((t) => t.trim()).filter(Boolean) };
return initial ? updateBlock(initial.id, payload) : createBlock(payload);
},
onSuccess: () => { qc.invalidateQueries({ queryKey: ['blocks'] }); onClose(); },
});
const set = (key: keyof BlockPayload, val: unknown) => setForm((p) => ({ ...p, [key]: val }));
const handleTypeChange = (type: string) => {
set('type', type);
if (type === 'CONDITIONAL' && !form.condition) {
set('condition', EMPTY_CONDITION);
} else if (type !== 'CONDITIONAL') {
set('condition', null);
}
};
const insertToken = (token: string) => {
const el = textareaRef.current;
if (!el) return;
const start = el.selectionStart ?? form.content.length;
const end = el.selectionEnd ?? start;
const next = form.content.slice(0, start) + token + form.content.slice(end);
set('content', next);
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
});
};
return (
<Modal title={initial ? 'Edit Block' : 'Create Block'} onClose={onClose} width={1050}>
<div className={styles.modalGrid}>
{/* ── Left column: content editing ── */}
<div className={styles.modalLeft}>
<div className={f.field}>
<label className={f.label}>Name</label>
<input className={f.input} value={form.name} onChange={(e) => set('name', e.target.value)} placeholder="e.g. Sponsor CTA" autoFocus />
</div>
<div className={f.row}>
<div className={f.field}>
<label className={f.label}>Type</label>
<select className={f.select} value={form.type} onChange={(e) => handleTypeChange(e.target.value)}>
{BLOCK_TYPES.map((t) => <option key={t} value={t}>{TYPE_META[t].label}</option>)}
</select>
{form.type && <p className={styles.typeDesc}>{TYPE_META[form.type]?.desc}</p>}
</div>
<div className={f.field}>
<label className={f.label}>Language</label>
<select className={f.select} value={form.language} onChange={(e) => set('language', e.target.value)}>
<option value="de">DE</option>
<option value="en">EN</option>
</select>
</div>
</div>
{form.type === 'CAMPAIGN' && (
<div className={f.field}>
<label className={f.label}>Campaign</label>
<select className={f.select} value={form.campaignId ?? ''} onChange={(e) => set('campaignId', e.target.value || null)}>
<option value=""> select a campaign </option>
{campaigns.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<p className={styles.typeDesc}>This block auto-appears in every render while the campaign&apos;s date window is active.</p>
</div>
)}
{form.type === 'CONDITIONAL' && (
<div className={f.field}>
<label className={f.label}>Conditions</label>
<ConditionBuilder
condition={form.condition ?? EMPTY_CONDITION}
onChange={(c) => set('condition', c)}
teamVars={teamVars}
/>
</div>
)}
<div className={f.field}>
<label className={f.label}>Content</label>
<textarea
ref={textareaRef}
className={f.textarea}
style={{ minHeight: 200 }}
value={form.content}
onChange={(e) => set('content', e.target.value)}
placeholder="Block content… use {variable_name} for placeholders"
/>
</div>
<div className={f.field}>
<label className={f.label}>Tags (comma-separated)</label>
<input className={f.input} value={tagsInput} onChange={(e) => setTagsInput(e.target.value)} placeholder="sponsor, cta, promo" />
</div>
<label className={f.toggle}>
<input type="checkbox" checked={form.active} onChange={(e) => set('active', e.target.checked)} />
Active
</label>
<label className={f.toggle} title="When compact, this block is joined with the previous block using a single newline instead of a blank line">
<input type="checkbox" checked={form.compact ?? false} onChange={(e) => set('compact', e.target.checked)} />
Compact by default (no blank line before this block)
</label>
</div>
{/* ── Right column: variables ── */}
<div className={styles.modalRight}>
<VariableReferencePanel teamVars={teamVars} systemVars={systemVars} onInsert={insertToken} />
<div className={styles.varDeclSection}>
<span className={styles.varDeclTitle}>Variable Declarations</span>
<p className={styles.varDeclHint}>
Declare the <code>{`{placeholders}`}</code> used in this block so editors see labels and descriptions.
</p>
<VarDefEditor defs={form.variableDefinitions ?? []} onChange={(d) => set('variableDefinitions', d)} />
</div>
</div>
</div>
{initial && (
<UsagePanel
isLoading={usageLoading}
groups={[
{ heading: 'Videos', items: (usage?.videos ?? []).map((v) => ({ id: v.id, label: v.title, href: `/videos/${v.id}` })) },
{ heading: 'Templates', items: (usage?.templates ?? []).map((t) => ({ id: t.id, label: t.name, href: '/templates' })) },
]}
/>
)}
{mutation.isError && <p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)' }}>Save failed check the backend logs.</p>}
<div className={f.actions}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => mutation.mutate()} disabled={mutation.isPending || !form.name.trim()}>
{mutation.isPending ? <Loader2 size={14} className={styles.spinner} /> : null}
{initial ? 'Save Changes' : 'Create Block'}
</button>
</div>
</Modal>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function BlocksPage() {
const qc = useQueryClient();
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('All');
const [modal, setModal] = useState<'create' | Block | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const deleteMut = useMutation({
mutationFn: deleteBlock,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['blocks'] }); setConfirmDelete(null); setDeleteError(null); },
onError: (e: unknown) => setDeleteError((e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Delete failed'),
});
const { data: blocks = [], isLoading, isError } = useQuery<Block[]>({
queryKey: ['blocks'],
queryFn: fetchBlocks,
});
const filtered = blocks.filter((b) => {
const matchType = typeFilter === 'All' || b.type === typeFilter.toUpperCase();
const matchSearch = !search || b.name.toLowerCase().includes(search.toLowerCase()) || b.content.toLowerCase().includes(search.toLowerCase());
return matchType && matchSearch;
});
return (
<div className={styles.container}>
<header className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.eyebrow}>Content Components</span>
<h1>Description Blocks{blocks.length > 0 && <span className={styles.count}>{blocks.length}</span>}</h1>
</div>
<div className={styles.headerRight}>
<button className="btn btn-primary" onClick={() => setModal('create')}>
<Plus size={18} />
<span>Create Block</span>
</button>
</div>
</header>
<div className={styles.controls}>
<label className={styles.searchBar}>
<Search size={18} />
<input type="text" placeholder="Search blocks, variables, or types…" value={search} onChange={(e) => setSearch(e.target.value)} />
</label>
<div className={styles.filterRow}>
<button className={styles.filterBtn}><ListFilter size={16} /><span>Filter</span></button>
<div className={styles.quickFilters}>
{TYPE_FILTERS.map((f) => (
<button key={f} className={`${styles.tag} ${typeFilter === f ? styles.tagActive : ''}`} onClick={() => setTypeFilter(f)}>{f}</button>
))}
</div>
</div>
</div>
{isLoading && <div className={styles.state}><Loader2 size={24} className={styles.spinner} /><span>Loading blocks</span></div>}
{isError && <div className={styles.state}><AlertCircle size={20} /><span>Failed to load blocks is the backend running?</span></div>}
{!isLoading && !isError && filtered.length === 0 && (
<div className={styles.state}>
<span>{search || typeFilter !== 'All' ? 'No blocks match your filter.' : 'No blocks yet. Create your first one.'}</span>
</div>
)}
{!isLoading && !isError && filtered.length > 0 && (
<div className={styles.grid}>
{filtered.map((block) => (
<div key={block.id} className={styles.card}>
<div className={styles.cardHeader}>
<div className={styles.titleInfo}>
<h3 className={styles.blockName}>{block.name}</h3>
<p className={styles.blockDesc}>v{block.version} · {block.language.toUpperCase()}{!block.active && ' · Inactive'}</p>
</div>
<TypePill type={block.type} />
</div>
{TYPE_META[block.type] && (
<p className={styles.typeHint}>{TYPE_META[block.type].desc}</p>
)}
<div className={styles.previewBox}>
<pre className={styles.preview}>{block.content}</pre>
</div>
<div className={styles.cardFooter}>
<div className={styles.tags}>
{block.tags.slice(0, 3).map((t) => <span key={t} className={styles.blockTag}>{t}</span>)}
</div>
{confirmDelete === block.id ? (
<div className={styles.confirmDelete}>
{deleteError
? <span className={styles.deleteErr}>{deleteError}</span>
: <span className={styles.confirmText}>Delete?</span>}
<button className={styles.confirmYes} onClick={() => deleteMut.mutate(block.id)} disabled={deleteMut.isPending}>
{deleteMut.isPending ? <Loader2 size={12} className={styles.spinner} /> : 'Yes'}
</button>
<button className={styles.confirmNo} onClick={() => { setConfirmDelete(null); setDeleteError(null); }}>
{deleteError ? 'OK' : 'No'}
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 'var(--space-3)', alignItems: 'center' }}>
<button className={styles.editBtn} onClick={() => setModal(block)}>Edit</button>
<button className={styles.trashBtn} onClick={() => { setConfirmDelete(block.id); setDeleteError(null); }}><Trash2 size={14} /></button>
</div>
)}
</div>
</div>
))}
</div>
)}
{modal !== null && (
<BlockModal initial={modal === 'create' ? undefined : modal} onClose={() => setModal(null)} />
)}
</div>
);
}
@@ -0,0 +1,159 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-8);
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.headerLeft h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
.count {
display: inline-flex;
align-items: center;
margin-left: var(--space-3);
padding: 0.1rem 0.5rem;
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
vertical-align: middle;
}
.tableContainer {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
padding: var(--space-4) var(--space-5);
text-align: left;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-faint);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-divider);
}
.table td {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-divider);
vertical-align: middle;
font-size: var(--text-sm);
}
.table tr:last-child td { border-bottom: none; }
.table tr:hover { background: var(--color-surface-offset); }
.jobType {
display: block;
font-weight: 600;
text-transform: capitalize;
color: var(--color-text);
}
.jobId {
display: block;
font-size: 10px;
font-family: monospace;
color: var(--color-text-faint);
margin-top: 2px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 600;
padding: 0.25rem 0.6rem;
border-radius: var(--radius-full);
}
.statusDone { background: color-mix(in srgb, var(--color-success), transparent 85%); color: var(--color-success); }
.statusRunning { background: color-mix(in srgb, var(--color-blue), transparent 85%); color: var(--color-blue); }
.statusPending { background: var(--color-surface-offset); color: var(--color-text-muted); border: 1px solid var(--color-border); }
.statusFailed { background: color-mix(in srgb, var(--color-error), transparent 85%); color: var(--color-error); }
.statusRolled { background: color-mix(in srgb, var(--color-purple), transparent 85%); color: var(--color-purple); }
.progress {
display: flex;
flex-direction: column;
gap: 4px;
}
.progressBar {
height: 4px;
background: var(--color-divider);
border-radius: var(--radius-full);
width: 120px;
overflow: hidden;
}
.progressFill {
height: 100%;
background: var(--color-primary);
transition: width 0.3s;
}
.progressText {
font-size: 11px;
color: var(--color-text-muted);
}
.errors { color: var(--color-error); }
.duration, .date {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.state, .empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
text-align: center;
}
.emptyIcon { color: var(--color-text-faint); margin-bottom: var(--space-2); }
.emptyHint { color: var(--color-text-faint); font-size: var(--text-xs); max-width: 40ch; }
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
@@ -0,0 +1,118 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Loader2, AlertCircle, Zap, CheckCircle2, XCircle, Clock, RotateCcw } from 'lucide-react';
import { fetchBulkJobs, type BulkJob } from '@/lib/api';
import styles from './page.module.css';
const STATUS_CONFIG: Record<string, { label: string; className: string; icon: React.ReactNode }> = {
DONE: { label: 'Done', className: styles.statusDone, icon: <CheckCircle2 size={14} /> },
RUNNING: { label: 'Running', className: styles.statusRunning, icon: <Loader2 size={14} className={styles.spin} /> },
PENDING: { label: 'Pending', className: styles.statusPending, icon: <Clock size={14} /> },
CONFIRMED: { label: 'Confirmed', className: styles.statusPending, icon: <Clock size={14} /> },
FAILED: { label: 'Failed', className: styles.statusFailed, icon: <XCircle size={14} /> },
ROLLED_BACK: { label: 'Rolled back', className: styles.statusRolled, icon: <RotateCcw size={14} /> },
DRY_RUN: { label: 'Dry run', className: styles.statusPending, icon: <Clock size={14} /> },
};
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status] ?? { label: status, className: styles.statusPending, icon: null };
return (
<span className={`${styles.badge} ${cfg.className}`}>
{cfg.icon} {cfg.label}
</span>
);
}
function formatDuration(start: string, end: string | null) {
if (!end) return '—';
const ms = new Date(end).getTime() - new Date(start).getTime();
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.round(ms / 60000)}m`;
}
export default function BulkJobsPage() {
const { data: jobs = [], isLoading, isError } = useQuery<BulkJob[]>({
queryKey: ['bulk-jobs'],
queryFn: () => fetchBulkJobs(),
refetchInterval: 5000,
});
return (
<div className={styles.container}>
<header className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.eyebrow}>Background Operations</span>
<h1>
Bulk Jobs
{jobs.length > 0 && <span className={styles.count}>{jobs.length}</span>}
</h1>
</div>
</header>
{isLoading && (
<div className={styles.state}>
<Loader2 size={24} className={styles.spin} />
<span>Loading jobs</span>
</div>
)}
{isError && (
<div className={styles.state}>
<AlertCircle size={20} />
<span>Failed to load bulk jobs is the backend running?</span>
</div>
)}
{!isLoading && !isError && jobs.length === 0 && (
<div className={styles.empty}>
<Zap size={40} className={styles.emptyIcon} />
<p>No bulk jobs yet.</p>
<p className={styles.emptyHint}>Bulk jobs appear here when you run batch metadata updates from the Videos page.</p>
</div>
)}
{!isLoading && !isError && jobs.length > 0 && (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
<tr>
<th>Type</th>
<th>Status</th>
<th>Progress</th>
<th>Duration</th>
<th>Started</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
<tr key={job.id}>
<td>
<span className={styles.jobType}>{job.type === 'SYNC_PUSH' ? 'YouTube Push' : job.type.replace(/_/g, ' ')}</span>
<span className={styles.jobId}>{job.id.slice(0, 8)}</span>
</td>
<td><StatusBadge status={job.status} /></td>
<td>
<div className={styles.progress}>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${job.totalCount ? (job.successCount / job.totalCount) * 100 : 0}%` }}
/>
</div>
<span className={styles.progressText}>
{job.successCount}/{job.totalCount}
{job.errorCount > 0 && <span className={styles.errors}> · {job.errorCount} errors</span>}
</span>
</div>
</td>
<td className={styles.duration}>{formatDuration(job.createdAt, job.completedAt)}</td>
<td className={styles.date}>{new Date(job.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,253 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--space-4);
}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.headerLeft h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
.headerRight {
display: flex;
align-items: center;
gap: var(--space-4);
}
.viewToggle {
display: flex;
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.viewBtn,
.viewBtnActive {
padding: 0.4rem 0.875rem;
font-size: var(--text-sm);
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.15s;
color: var(--color-text-muted);
background: transparent;
}
.viewBtnActive {
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
.nav {
display: flex;
align-items: center;
gap: var(--space-3);
}
.navBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: var(--radius-md);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
background: var(--color-surface);
transition: all 0.15s;
}
.navBtn:hover {
background: var(--color-surface-offset);
color: var(--color-text);
}
.monthLabel {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text);
min-width: 160px;
text-align: center;
}
/* Month grid */
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
}
.weekday {
padding: var(--space-3);
text-align: center;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-faint);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-offset);
}
.cell,
.cellEmpty {
min-height: 120px;
padding: var(--space-2) var(--space-3);
border-right: 1px solid var(--color-divider);
border-bottom: 1px solid var(--color-divider);
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.cell:nth-child(7n) { border-right: none; }
.cellEmpty { background: var(--color-surface-offset); opacity: 0.5; }
.today {
background: color-mix(in srgb, var(--color-primary), transparent 94%);
}
.dayNumber {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-muted);
align-self: flex-start;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
}
.today .dayNumber {
background: var(--color-primary);
color: white;
}
.entries {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
}
.chip {
padding: 3px 6px;
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-left: 3px solid var(--color-primary);
cursor: default;
}
.chipTitle {
font-size: 11px;
font-weight: 500;
color: var(--color-text-muted);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.more {
font-size: 10px;
color: var(--color-text-faint);
font-weight: 600;
padding-left: 2px;
}
/* Agenda view */
.agenda {
display: flex;
flex-direction: column;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.agendaRow {
display: flex;
align-items: center;
gap: var(--space-5);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-divider);
}
.agendaRow:last-child { border-bottom: none; }
.agendaRow:hover { background: var(--color-surface-offset); }
.agendaDate {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-muted);
min-width: 60px;
}
.agendaInfo {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.agendaTitle {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.agendaMeta {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.agendaBadges {
display: flex;
gap: var(--space-2);
align-items: center;
}
.state {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 1s linear infinite; }
@@ -0,0 +1,155 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Loader2, AlertCircle } from 'lucide-react';
import apiClient from '@/lib/api-client';
import styles from './page.module.css';
interface CalendarEntry {
videoId: string;
title: string;
date: string | null;
templateName?: string;
lintStatus: 'OK' | 'WARNING' | 'ERROR';
privacyStatus: string;
}
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
function lintColor(status: string) {
if (status === 'ERROR') return 'var(--color-error)';
if (status === 'WARNING') return 'var(--color-warn)';
return 'var(--color-primary)';
}
function privacyPill(status: string) {
if (status === 'PUBLIC') return <span className="pill pill-primary" style={{ fontSize: 10, padding: '1px 5px' }}>Public</span>;
if (status === 'UNLISTED') return <span className="pill pill-blue" style={{ fontSize: 10, padding: '1px 5px' }}>Unlisted</span>;
return <span className="pill" style={{ fontSize: 10, padding: '1px 5px' }}>Private</span>;
}
function formatDateKey(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
type ViewMode = 'month' | 'agenda';
export default function CalendarPage() {
const now = new Date();
const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth()); // 0-indexed
const [view, setView] = useState<ViewMode>('month');
const dateParam = `${year}-${String(month + 1).padStart(2, '0')}`;
const { data: entries = [], isLoading, isError } = useQuery<CalendarEntry[]>({
queryKey: ['calendar', view, dateParam],
queryFn: () =>
apiClient.get<CalendarEntry[]>('/calendar', { params: { view, date: dateParam } }).then((r) => r.data),
});
function prevMonth() {
if (month === 0) { setMonth(11); setYear((y) => y - 1); }
else setMonth((m) => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setYear((y) => y + 1); }
else setMonth((m) => m + 1);
}
// Group entries by day key
const byDay = entries.reduce<Record<string, CalendarEntry[]>>((acc, e) => {
if (!e.date) return acc;
const key = e.date.slice(0, 10);
(acc[key] ??= []).push(e);
return acc;
}, {});
// Build calendar grid
const firstDay = new Date(year, month, 1).getDay(); // 0=Sun
const daysInMonth = new Date(year, month + 1, 0).getDate();
const totalCells = Math.ceil((firstDay + daysInMonth) / 7) * 7;
const cells: (number | null)[] = Array.from({ length: totalCells }, (_, i) => {
const d = i - firstDay + 1;
return d >= 1 && d <= daysInMonth ? d : null;
});
const todayKey = formatDateKey(now);
return (
<div className={styles.container}>
<header className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.eyebrow}>Release Planning</span>
<h1>Content Calendar</h1>
</div>
<div className={styles.headerRight}>
<div className={styles.viewToggle}>
<button className={view === 'month' ? styles.viewBtnActive : styles.viewBtn} onClick={() => setView('month')}>Month</button>
<button className={view === 'agenda' ? styles.viewBtnActive : styles.viewBtn} onClick={() => setView('agenda')}>Agenda</button>
</div>
<div className={styles.nav}>
<button className={styles.navBtn} onClick={prevMonth}><ChevronLeft size={18} /></button>
<span className={styles.monthLabel}>{MONTH_NAMES[month]} {year}</span>
<button className={styles.navBtn} onClick={nextMonth}><ChevronRight size={18} /></button>
</div>
</div>
</header>
{isLoading && <div className={styles.state}><Loader2 size={24} className={styles.spinner} /><span>Loading calendar</span></div>}
{isError && <div className={styles.state}><AlertCircle size={20} /><span>Failed to load calendar is the backend running?</span></div>}
{!isLoading && !isError && view === 'month' && (
<div className={styles.grid}>
{WEEKDAYS.map((d) => <div key={d} className={styles.weekday}>{d}</div>)}
{cells.map((day, i) => {
if (day === null) return <div key={i} className={styles.cellEmpty} />;
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dayEntries = byDay[key] ?? [];
const isToday = key === todayKey;
return (
<div key={i} className={`${styles.cell} ${isToday ? styles.today : ''}`}>
<span className={styles.dayNumber}>{day}</span>
<div className={styles.entries}>
{dayEntries.slice(0, 3).map((e) => (
<div key={e.videoId} className={styles.chip} style={{ borderLeftColor: lintColor(e.lintStatus) }} title={e.title}>
<span className={styles.chipTitle}>{e.title}</span>
</div>
))}
{dayEntries.length > 3 && (
<span className={styles.more}>+{dayEntries.length - 3} more</span>
)}
</div>
</div>
);
})}
</div>
)}
{!isLoading && !isError && view === 'agenda' && (
<div className={styles.agenda}>
{entries.length === 0 && (
<div className={styles.state}><span>No scheduled or published videos this month.</span></div>
)}
{entries.map((e) => (
<div key={e.videoId} className={styles.agendaRow}>
<div className={styles.agendaDate}>
{e.date ? new Date(e.date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) : '—'}
</div>
<div className={styles.agendaInfo}>
<span className={styles.agendaTitle}>{e.title}</span>
{e.templateName && <span className={styles.agendaMeta}>{e.templateName}</span>}
</div>
<div className={styles.agendaBadges}>
{privacyPill(e.privacyStatus)}
</div>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,229 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-8);
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.headerLeft h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
.headerRight { display: flex; gap: var(--space-3); }
.count {
display: inline-flex;
align-items: center;
margin-left: var(--space-3);
padding: 0.1rem 0.5rem;
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
vertical-align: middle;
}
.tableContainer {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
padding: var(--space-4) var(--space-5);
text-align: left;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-faint);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-divider);
}
.table td {
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-divider);
vertical-align: middle;
}
.table tr:last-child td { border-bottom: none; }
.table tr:hover { background: var(--color-surface-offset); }
.nameCell {
display: flex;
align-items: center;
gap: var(--space-3);
}
.avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
background: var(--color-primary-highlight);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: var(--text-sm);
flex-shrink: 0;
}
.name {
display: block;
font-weight: 600;
font-size: var(--text-sm);
color: var(--color-text);
}
.notes {
display: block;
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.handle {
font-size: var(--text-sm);
color: var(--color-primary);
font-weight: 500;
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-sm);
color: var(--color-blue);
}
.empty2 {
color: var(--color-text-faint);
font-size: var(--text-sm);
}
.aliases {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.alias {
font-size: 10px;
font-weight: 600;
padding: 0.15rem 0.5rem;
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-full);
color: var(--color-text-faint);
}
.editBtn {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.state, .empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
text-align: center;
}
.emptyIcon { color: var(--color-text-faint); margin-bottom: var(--space-2); }
.emptyHint { color: var(--color-text-faint); font-size: var(--text-xs); max-width: 36ch; }
.trashBtn {
color: var(--color-text-faint);
display: flex;
align-items: center;
transition: color 0.12s;
}
.trashBtn:hover { color: var(--color-error); }
.confirmDelete {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.confirmText { font-size: var(--text-xs); color: var(--color-text-muted); }
.deleteErr { font-size: var(--text-xs); color: var(--color-error); max-width: 180px; }
.confirmYes {
font-size: var(--text-xs);
font-weight: 700;
color: white;
background: var(--color-error);
border-radius: var(--radius-sm);
padding: 2px 8px;
}
.confirmNo {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
}
.platformBadges {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.platformBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
text-decoration: none;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
a.platformBadge:hover {
background: var(--color-primary-highlight);
border-color: var(--color-primary);
color: var(--color-primary);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 1s linear infinite; }
@@ -0,0 +1,167 @@
'use client';
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Loader2, AlertCircle, Users, Trash2 } from 'lucide-react';
import { FaYoutube } from 'react-icons/fa';
import { SiTwitch, SiInstagram, SiTiktok, SiX, SiBluesky, SiDiscord } from 'react-icons/si';
import { fetchCollaborators, deleteCollaborator, type Collaborator } from '@/lib/api';
import CollaboratorModal, { PLATFORMS, type PlatformKey } from '@/components/shared/CollaboratorModal';
import styles from './page.module.css';
// ─── Platforms cell ───────────────────────────────────────────────────────────
const PLATFORM_ICON: Record<PlatformKey, React.ReactElement> = {
youtubeLink: <FaYoutube size={13} />,
twitchLink: <SiTwitch size={13} />,
instagramLink: <SiInstagram size={13} />,
tiktokLink: <SiTiktok size={13} />,
twitterLink: <SiX size={13} />,
blueskyLink: <SiBluesky size={13} />,
discordHandle: <SiDiscord size={13} />,
};
function PlatformsCell({ c }: { c: Collaborator }) {
const links = PLATFORMS.filter((p) => !!c[p.key]);
if (links.length === 0) return <span className={styles.empty2}></span>;
return (
<div className={styles.platformBadges}>
{links.map((p) => {
const val = c[p.key];
if (p.urlField && val) {
return (
<a key={p.key} href={val} target="_blank" rel="noopener noreferrer" className={styles.platformBadge} title={p.label}>
{PLATFORM_ICON[p.key]}
</a>
);
}
return (
<span key={p.key} className={styles.platformBadge} title={`${p.label}: ${val}`}>
{PLATFORM_ICON[p.key]}
</span>
);
})}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function CollaboratorsPage() {
const qc = useQueryClient();
const [modal, setModal] = useState<'create' | Collaborator | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const deleteMut = useMutation({
mutationFn: deleteCollaborator,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['collaborators'] }); setConfirmDelete(null); setDeleteError(null); },
onError: (e: unknown) => setDeleteError((e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Delete failed'),
});
const { data: collaborators = [], isLoading, isError } = useQuery<Collaborator[]>({
queryKey: ['collaborators'],
queryFn: fetchCollaborators,
});
return (
<div className={styles.container}>
<header className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.eyebrow}>People & Partners</span>
<h1>
Collaborators
{collaborators.length > 0 && <span className={styles.count}>{collaborators.length}</span>}
</h1>
</div>
<div className={styles.headerRight}>
<button className="btn btn-primary" onClick={() => setModal('create')}>
<Plus size={18} />
<span>Add Collaborator</span>
</button>
</div>
</header>
{isLoading && <div className={styles.state}><Loader2 size={24} className={styles.spinner} /><span>Loading collaborators</span></div>}
{isError && <div className={styles.state}><AlertCircle size={20} /><span>Failed to load collaborators is the backend running?</span></div>}
{!isLoading && !isError && collaborators.length === 0 && (
<div className={styles.empty}>
<Users size={40} className={styles.emptyIcon} />
<p>No collaborators yet.</p>
<p className={styles.emptyHint}>Add guest creators and partners to use them in your description blocks.</p>
<button className="btn btn-primary" onClick={() => setModal('create')}>
<Plus size={18} />
<span>Add your first collaborator</span>
</button>
</div>
)}
{!isLoading && !isError && collaborators.length > 0 && (
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Platforms</th>
<th>Aliases</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{collaborators.map((c) => (
<tr key={c.id}>
<td>
<div className={styles.nameCell}>
<div className={styles.avatar}>{c.name.charAt(0).toUpperCase()}</div>
<div>
<span className={styles.name}>{c.name}</span>
{c.notes && <span className={styles.notes}>{c.notes}</span>}
</div>
</div>
</td>
<td>
<PlatformsCell c={c} />
</td>
<td>
<div className={styles.aliases}>
{c.aliases.slice(0, 2).map((a) => <span key={a} className={styles.alias}>{a}</span>)}
{c.aliases.length > 2 && <span className={styles.alias}>+{c.aliases.length - 2}</span>}
</div>
</td>
<td>
{c.active ? <span className="pill pill-primary">Active</span> : <span className="pill">Inactive</span>}
</td>
<td>
{confirmDelete === c.id ? (
<div className={styles.confirmDelete}>
{deleteError
? <span className={styles.deleteErr}>{deleteError}</span>
: <span className={styles.confirmText}>Delete?</span>}
<button className={styles.confirmYes} onClick={() => deleteMut.mutate(c.id)} disabled={deleteMut.isPending}>
{deleteMut.isPending ? <Loader2 size={12} /> : 'Yes'}
</button>
<button className={styles.confirmNo} onClick={() => { setConfirmDelete(null); setDeleteError(null); }}>
{deleteError ? 'OK' : 'No'}
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 'var(--space-3)', alignItems: 'center' }}>
<button className={styles.editBtn} onClick={() => setModal(c)}>Edit</button>
<button className={styles.trashBtn} onClick={() => { setConfirmDelete(c.id); setDeleteError(null); }}><Trash2 size={14} /></button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{modal !== null && (
<CollaboratorModal initial={modal === 'create' ? undefined : modal} onClose={() => setModal(null)} />
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import Sidebar from '@/components/shared/Sidebar';
import Header from '@/components/shared/Header';
import Providers from '@/components/shared/Providers';
import AuthGuard from '@/components/shared/AuthGuard';
import styles from './DashboardLayout.module.css';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<Providers>
<AuthGuard>
<div className={styles.layout}>
<Sidebar />
<div className={styles.main}>
<Header />
<main className={styles.content}>
{children}
</main>
</div>
</div>
</AuthGuard>
</Providers>
);
}
@@ -0,0 +1,401 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-10);
max-width: 1100px;
margin: 0 auto;
}
/* ── Header ── */
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--space-4);
}
.headerLeft {}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.header h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
.headerRight {
display: flex;
align-items: center;
gap: var(--space-3);
}
.rerunResult {
font-size: var(--text-xs);
color: var(--color-success);
}
/* ── Summary cards ── */
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
}
@media (max-width: 680px) {
.cards { grid-template-columns: 1fr; }
}
.card {
padding: var(--space-5);
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
background: var(--color-surface);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.cardLabel {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--color-text-faint);
}
.cardValue {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
line-height: 1;
}
.cardValueError { color: var(--color-error); }
.cardSub {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
/* ── Section ── */
.section {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--space-3);
}
.sectionTitle {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text);
flex: 1;
}
.collapseBtn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-faint);
font-size: var(--text-sm);
padding: 0;
display: flex;
align-items: center;
gap: var(--space-1);
}
.collapseBtn:hover { color: var(--color-text); }
/* ── Rule config panel ── */
.rulesPanel {
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
overflow: hidden;
}
.ruleRow {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-divider);
font-size: var(--text-sm);
}
.ruleRow:last-child { border-bottom: none; }
.ruleRow:nth-child(odd) { background: var(--color-surface-offset); }
.ruleToggle {
flex-shrink: 0;
cursor: pointer;
accent-color: var(--color-primary);
width: 16px;
height: 16px;
}
.ruleLabel {
font-weight: 600;
color: var(--color-text);
min-width: 160px;
}
.ruleLabelDisabled {
color: var(--color-text-faint);
text-decoration: line-through;
}
.ruleDesc {
flex: 1;
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.rulesSaveRow {
display: flex;
align-items: center;
gap: var(--space-3);
padding-top: var(--space-2);
}
.rulesSaved {
font-size: var(--text-xs);
color: var(--color-success);
display: flex;
align-items: center;
gap: var(--space-1);
}
/* ── Severity badge ── */
.sevBadge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
border-radius: var(--radius-sm);
padding: 2px 7px;
}
.sevERROR { background: color-mix(in srgb, var(--color-error) 12%, transparent); color: var(--color-error); }
.sevWARNING { background: color-mix(in srgb, var(--color-warning) 12%, transparent); color: var(--color-warning); }
.sevINFO { background: color-mix(in srgb, var(--color-primary) 12%, transparent); color: var(--color-primary); }
/* ── Filter bar ── */
.filterBar {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.sevPills {
display: flex;
gap: var(--space-1);
}
.sevPill {
font-size: var(--text-xs);
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-full);
border: 1px solid var(--color-divider);
background: var(--color-surface);
cursor: pointer;
color: var(--color-text-muted);
transition: all 0.1s;
}
.sevPill:hover { border-color: var(--color-primary); color: var(--color-primary); }
.sevPillActive {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
color: var(--color-primary);
}
.ruleFilter {
width: auto;
min-width: 160px;
}
.searchInput {
min-width: 200px;
}
.bulkActions {
margin-left: auto;
}
/* ── Table ── */
.tableWrap {
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.table thead tr {
background: var(--color-surface-offset);
border-bottom: 1px solid var(--color-divider);
}
.table th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--color-text-faint);
white-space: nowrap;
}
.table th:first-child { width: 32px; }
.table tbody tr {
border-bottom: 1px solid var(--color-divider);
transition: background 0.1s;
}
.table tbody tr:last-child { border-bottom: none; }
.table tbody tr:hover { background: var(--color-surface-offset); }
.table td {
padding: var(--space-3) var(--space-4);
vertical-align: top;
}
.checkboxCell {
width: 32px;
text-align: center;
vertical-align: middle !important;
}
.checkboxCell input {
cursor: pointer;
accent-color: var(--color-primary);
width: 15px;
height: 15px;
}
.videoTitle {
font-weight: 600;
color: var(--color-primary);
text-decoration: none;
}
.videoTitle:hover { text-decoration: underline; }
.targetChip {
display: inline-block;
font-family: var(--font-mono, monospace);
font-size: 11px;
background: var(--color-surface-offset);
border-radius: var(--radius-sm);
padding: 1px 5px;
color: var(--color-text-faint);
}
.messageCell {
max-width: 320px;
}
.message {
color: var(--color-text);
}
.fixBtn {
background: none;
border: none;
cursor: pointer;
font-size: var(--text-xs);
color: var(--color-primary);
padding: 2px 0;
display: flex;
align-items: center;
gap: 3px;
}
.fixBtn:hover { text-decoration: underline; }
.fixSuggestion {
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
background: var(--color-surface-offset);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-3);
border-left: 2px solid var(--color-primary);
}
.dateCell {
font-size: var(--text-xs);
color: var(--color-text-faint);
white-space: nowrap;
}
.resolveBtn {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-3);
}
/* ── Empty state ── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
padding: var(--space-12) var(--space-8);
color: var(--color-text-faint);
font-size: var(--text-sm);
text-align: center;
}
.emptyIcon {
color: var(--color-success);
opacity: 0.7;
}
.emptyTitle {
font-weight: 600;
color: var(--color-text-muted);
}
/* ── Loading / spinner ── */
.state {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-8);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
@@ -0,0 +1,456 @@
'use client';
import { useState, useMemo, useEffect } from 'react';
import Link from 'next/link';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle, CheckCircle, ChevronDown, ChevronUp,
RefreshCw, Loader2, Info,
} from 'lucide-react';
import { useAuthStore } from '@/store/useAuthStore';
import {
fetchLintResults, resolveLintResult, bulkResolveLintResults,
runLintForTeam, recomputeLintStatus, fetchTeamSettings, updateTeamSettings,
} from '@/lib/api';
import ColumnPicker from '@/components/shared/ColumnPicker';
import { useColumnPreferences } from '@/hooks/useColumnPreferences';
import styles from './page.module.css';
import f from '@/components/shared/FormField.module.css';
const LINTING_COLUMN_DEFS = [
{ id: 'severity', label: 'Severity' },
{ id: 'rule', label: 'Rule' },
{ id: 'video', label: 'Video', required: true },
{ id: 'field', label: 'Field' },
{ id: 'message', label: 'Message' },
{ id: 'date', label: 'Date' },
];
const LINTING_DEFAULTS = {
visible: ['severity', 'rule', 'video', 'field', 'message', 'date'],
order: ['severity', 'rule', 'video', 'field', 'message', 'date'],
};
// ── Static rule catalog ────────────────────────────────────────────────────────
const RULE_CATALOG = [
{ code: 'TITLE_WEAK', severity: 'WARNING', label: 'Weak title', desc: 'Title contains weak/filler words that may hurt click-through rate.' },
{ code: 'TITLE_TOO_LONG', severity: 'WARNING', label: 'Title too long', desc: 'Title exceeds the 100-character YouTube limit.' },
{ code: 'DESC_MISSING_CTA', severity: 'WARNING', label: 'Missing CTA', desc: 'Description has no call-to-action (subscribe, follow, etc.).' },
{ code: 'DESC_MISSING_CHAPTERS', severity: 'WARNING', label: 'Missing chapters', desc: 'Description has no timestamp chapters for navigation.' },
{ code: 'DESC_EMPTY_PLACEHOLDER', severity: 'ERROR', label: 'Unfilled placeholder', desc: 'Description still contains an unfilled template placeholder like [TODO].' },
{ code: 'DESC_DUPLICATE_HASHTAG', severity: 'WARNING', label: 'Duplicate hashtag', desc: 'The same hashtag appears more than once in the description.' },
{ code: 'DESC_REQUIRED_LINK_MISSING', severity: 'ERROR', label: 'Required link missing', desc: 'A required link (e.g. affiliate, sponsor) is absent from the description.' },
{ code: 'DESC_OUTDATED_SPONSOR_COPY', severity: 'ERROR', label: 'Outdated sponsor copy', desc: 'A campaign block with an expired end date is still active in the description.' },
{ code: 'REMOTE_CONFLICT', severity: 'ERROR', label: 'Remote conflict', desc: 'The video was modified on YouTube after the last sync — local and remote diverged.' },
] as const;
type RuleCode = typeof RULE_CATALOG[number]['code'];
// ── Helpers ───────────────────────────────────────────────────────────────────
function SevBadge({ sev }: { sev: string }) {
const cls = sev === 'ERROR' ? styles.sevERROR : sev === 'WARNING' ? styles.sevWARNING : styles.sevINFO;
const Icon = sev === 'ERROR' ? AlertTriangle : sev === 'WARNING' ? AlertTriangle : Info;
return (
<span className={`${styles.sevBadge} ${cls}`}>
<Icon size={11} />
{sev}
</span>
);
}
function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function LintingPage() {
const user = useAuthStore((s) => s.user);
const teamId = user?.teamId ?? '';
const qc = useQueryClient();
const { visible: colVisible, order: colOrder, setColumns } = useColumnPreferences('linting');
const show = (id: string) => colVisible.includes(id);
// ── Queries ──
const { data: results = [], isLoading } = useQuery({
queryKey: ['lintResults'],
queryFn: () => fetchLintResults(),
refetchInterval: 30_000,
});
const { data: settings } = useQuery({
queryKey: ['teamSettings', teamId],
queryFn: () => fetchTeamSettings(teamId),
enabled: !!teamId,
});
// ── Rule config state ──
const [rulesOpen, setRulesOpen] = useState(false);
const [localDisabled, setLocalDisabled] = useState<string[] | null>(null);
const [rulesSaved, setRulesSaved] = useState(false);
const [rulesSaving, setRulesSaving] = useState(false);
const disabledRules = localDisabled ?? settings?.disabledLintRules ?? [];
function toggleRule(code: string) {
const current = disabledRules;
setLocalDisabled(
current.includes(code) ? current.filter((c) => c !== code) : [...current, code],
);
setRulesSaved(false);
}
async function saveRules() {
setRulesSaving(true);
try {
await updateTeamSettings(teamId, { disabledLintRules: disabledRules });
qc.invalidateQueries({ queryKey: ['teamSettings', teamId] });
qc.invalidateQueries({ queryKey: ['lintResults'] });
setRulesSaved(true);
setTimeout(() => setRulesSaved(false), 3000);
} finally {
setRulesSaving(false);
}
}
// ── Re-run lint state ──
const [rerunMsg, setRerunMsg] = useState('');
const [rerunning, setRerunning] = useState(false);
async function handleRerun() {
setRerunning(true);
setRerunMsg('');
try {
const res = await runLintForTeam();
setRerunMsg(`Queued ${res.queued} video${res.queued !== 1 ? 's' : ''}`);
} catch {
setRerunMsg('Failed to queue');
} finally {
setRerunning(false);
}
}
// Silently heal any stale lintStatus badges whenever this page is opened
const { mutate: healLintStatus } = useMutation({ mutationFn: recomputeLintStatus });
useEffect(() => { healLintStatus(); }, []);
// ── Filters ──
const [sevFilter, setSevFilter] = useState<'ALL' | 'ERROR' | 'WARNING'>('ALL');
const [ruleFilter, setRuleFilter] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [expandedFix, setExpandedFix] = useState<Set<string>>(new Set());
const filtered = useMemo(() => {
return results.filter((r) => {
if (sevFilter !== 'ALL' && r.severity !== sevFilter) return false;
if (ruleFilter && r.ruleCode !== ruleFilter) return false;
if (search && !r.video.title.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
}, [results, sevFilter, ruleFilter, search]);
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleAll() {
if (selected.size === filtered.length) {
setSelected(new Set());
} else {
setSelected(new Set(filtered.map((r) => r.id)));
}
}
function toggleFix(id: string) {
setExpandedFix((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
// ── Mutations ──
const resolveOneMutation = useMutation({
mutationFn: resolveLintResult,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['lintResults'] });
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
const bulkResolveMutation = useMutation({
mutationFn: bulkResolveLintResults,
onSuccess: () => {
setSelected(new Set());
qc.invalidateQueries({ queryKey: ['lintResults'] });
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
// ── Summary stats ──
const errorCount = results.filter((r) => r.severity === 'ERROR').length;
const warnCount = results.filter((r) => r.severity === 'WARNING').length;
const videosAffected = new Set(results.map((r) => r.video.id)).size;
return (
<div className={styles.container}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.eyebrow}>Quality</span>
<h1>Metadata Linting</h1>
</div>
<div className={styles.headerRight}>
<button
className="btn btn-secondary"
onClick={handleRerun}
disabled={rerunning}
>
{rerunning
? <><Loader2 size={14} className={styles.spin} /> Running</>
: <><RefreshCw size={14} /> Re-run lint</>}
</button>
{rerunMsg && <span className={styles.rerunResult}>{rerunMsg}</span>}
</div>
</div>
{/* Summary cards */}
<div className={styles.cards}>
<div className={styles.card}>
<span className={styles.cardLabel}>Open issues</span>
<span className={styles.cardValue}>{results.length}</span>
<span className={styles.cardSub}>{errorCount} errors · {warnCount} warnings</span>
</div>
<div className={styles.card}>
<span className={styles.cardLabel}>Videos affected</span>
<span className={styles.cardValue}>{videosAffected}</span>
<span className={styles.cardSub}>unique videos with at least one open issue</span>
</div>
<div className={styles.card}>
<span className={styles.cardLabel}>Errors</span>
<span className={`${styles.cardValue} ${errorCount > 0 ? styles.cardValueError : ''}`}>{errorCount}</span>
<span className={styles.cardSub}>require immediate attention</span>
</div>
</div>
{/* Rule configuration */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Rule Configuration</span>
<button className={styles.collapseBtn} onClick={() => setRulesOpen((o) => !o)}>
{rulesOpen ? <><ChevronUp size={14} /> Collapse</> : <><ChevronDown size={14} /> Expand</>}
</button>
</div>
{rulesOpen && (
<>
<div className={styles.rulesPanel}>
{RULE_CATALOG.map((rule) => {
const enabled = !disabledRules.includes(rule.code);
return (
<div key={rule.code} className={styles.ruleRow}>
<input
type="checkbox"
className={styles.ruleToggle}
checked={enabled}
onChange={() => toggleRule(rule.code)}
/>
<span className={`${styles.ruleLabel} ${!enabled ? styles.ruleLabelDisabled : ''}`}>
{rule.label}
</span>
<SevBadge sev={rule.severity} />
<span className={styles.ruleDesc}>{rule.desc}</span>
</div>
);
})}
</div>
<div className={styles.rulesSaveRow}>
<button
className="btn btn-primary"
onClick={saveRules}
disabled={rulesSaving}
>
{rulesSaving ? 'Saving…' : 'Save rule config'}
</button>
{rulesSaved && (
<span className={styles.rulesSaved}>
<CheckCircle size={13} /> Saved
</span>
)}
</div>
</>
)}
</div>
{/* Issue table */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>Open Issues</span>
<ColumnPicker
columns={LINTING_COLUMN_DEFS}
visible={colVisible}
order={colOrder}
onChange={(v, o) => setColumns(v, o)}
defaultVisible={LINTING_DEFAULTS.visible}
defaultOrder={LINTING_DEFAULTS.order}
/>
</div>
{/* Filter bar */}
<div className={styles.filterBar}>
<div className={styles.sevPills}>
{(['ALL', 'ERROR', 'WARNING'] as const).map((s) => (
<button
key={s}
className={`${styles.sevPill} ${sevFilter === s ? styles.sevPillActive : ''}`}
onClick={() => setSevFilter(s)}
>
{s === 'ALL' ? 'All' : s === 'ERROR' ? 'Errors' : 'Warnings'}
{s !== 'ALL' && (
<> · {results.filter((r) => r.severity === s).length}</>
)}
</button>
))}
</div>
<select
className={`${f.input} ${f.select} ${styles.ruleFilter}`}
value={ruleFilter}
onChange={(e) => setRuleFilter(e.target.value)}
>
<option value="">All rules</option>
{RULE_CATALOG.map((r) => (
<option key={r.code} value={r.code}>{r.label}</option>
))}
</select>
<input
className={`${f.input} ${styles.searchInput}`}
placeholder="Search video title…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{selected.size > 0 && (
<div className={styles.bulkActions}>
<button
className="btn btn-primary"
onClick={() => bulkResolveMutation.mutate([...selected])}
disabled={bulkResolveMutation.isPending}
>
{bulkResolveMutation.isPending
? 'Resolving…'
: `Resolve selected (${selected.size})`}
</button>
</div>
)}
</div>
{isLoading ? (
<div className={styles.state}>
<Loader2 size={16} className={styles.spin} />
Loading issues
</div>
) : filtered.length === 0 ? (
<div className={styles.empty}>
<CheckCircle size={40} className={styles.emptyIcon} />
<span className={styles.emptyTitle}>No open issues</span>
<span>All metadata looks good! Re-run lint to check for new problems.</span>
</div>
) : (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.checkboxCell}>
<input
type="checkbox"
checked={selected.size === filtered.length && filtered.length > 0}
onChange={toggleAll}
/>
</th>
{show('severity') && <th>Severity</th>}
{show('rule') && <th>Rule</th>}
{show('video') && <th>Video</th>}
{show('field') && <th>Field</th>}
{show('message') && <th>Message</th>}
{show('date') && <th>Date</th>}
<th></th>
</tr>
</thead>
<tbody>
{filtered.map((r) => {
const ruleMeta = RULE_CATALOG.find((x) => x.code === r.ruleCode);
const fixOpen = expandedFix.has(r.id);
return (
<tr key={r.id}>
<td className={styles.checkboxCell}>
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={() => toggleSelect(r.id)}
/>
</td>
{show('severity') && <td><SevBadge sev={r.severity} /></td>}
{show('rule') && <td>{ruleMeta?.label ?? r.ruleCode}</td>}
{show('video') && (
<td>
<Link href={`/videos/${r.video.id}`} className={styles.videoTitle}>
{r.video.title}
</Link>
</td>
)}
{show('field') && (
<td>
{r.targetField
? <span className={styles.targetChip}>{r.targetField}</span>
: <span style={{ color: 'var(--color-text-faint)' }}></span>}
</td>
)}
{show('message') && (
<td className={styles.messageCell}>
<div className={styles.message}>{r.message}</div>
{r.fixSuggestion && (
<>
<button className={styles.fixBtn} onClick={() => toggleFix(r.id)}>
{fixOpen
? <><ChevronUp size={11} /> Hide suggestion</>
: <><ChevronDown size={11} /> Show fix suggestion</>}
</button>
{fixOpen && (
<div className={styles.fixSuggestion}>{r.fixSuggestion}</div>
)}
</>
)}
</td>
)}
{show('date') && <td className={styles.dateCell}>{fmtDate(r.createdAt)}</td>}
<td>
<button
className={`btn btn-secondary ${styles.resolveBtn}`}
onClick={() => resolveOneMutation.mutate(r.id)}
disabled={resolveOneMutation.isPending}
>
Resolve
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,230 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-8);
max-width: 1400px;
margin: 0 auto;
}
.pageHeader {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.pageHeader h1 {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
}
.pageSubtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.sectionLabel {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-faint);
margin-bottom: var(--space-3);
}
/* ── Stat row ── */
.statRow {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: var(--space-4);
}
.statCard {
position: relative;
display: flex;
flex-direction: column;
gap: var(--space-1);
cursor: default;
}
.statCardClickable {
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.statCardClickable:hover {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.accent_error {
border-color: color-mix(in srgb, var(--color-error) 50%, transparent) !important;
}
.accent_warn {
border-color: color-mix(in srgb, var(--color-warning) 50%, transparent) !important;
}
.accent_ok {
border-color: color-mix(in srgb, var(--color-success, #22c55e) 50%, transparent) !important;
}
.statTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.statValue {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
line-height: 1;
}
.statIcon {
color: var(--color-text-faint);
margin-top: 2px;
flex-shrink: 0;
}
.statLabel {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.statSub {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.statArrow {
position: absolute;
bottom: var(--space-4);
right: var(--space-4);
color: var(--color-text-faint);
opacity: 0;
transition: opacity 0.15s;
}
.statCardClickable:hover .statArrow {
opacity: 1;
}
/* ── Quota bar ── */
.quotaBar {
height: 4px;
background: var(--color-divider);
border-radius: var(--radius-full);
margin-top: var(--space-2);
overflow: hidden;
}
.quotaFill {
height: 100%;
background: var(--color-primary);
border-radius: var(--radius-full);
transition: width 0.4s ease;
}
.quotaFillWarn { background: var(--color-warning); }
.quotaFillError { background: var(--color-error); }
/* ── 2-col grid ── */
.grid2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: var(--space-6);
}
/* ── Panel internals ── */
.panelHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
}
.panelTitle {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
font-weight: 700;
}
.emptyNote {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
padding: var(--space-2) 0;
}
/* ── Issue rows ── */
.issueRow {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) 0;
border-top: 1px solid var(--color-divider);
cursor: pointer;
transition: color 0.12s;
}
.issueRow:hover .issueTitle {
color: var(--color-primary);
}
.issueTitle {
flex: 1;
font-size: var(--text-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.12s;
}
.issueArrow {
color: var(--color-text-faint);
flex-shrink: 0;
}
.issueDate {
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
flex-shrink: 0;
}
/* ── Bulk job rows ── */
.jobRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
border-top: 1px solid var(--color-divider);
gap: var(--space-3);
}
.jobInfo {
display: flex;
flex-direction: column;
gap: 2px;
}
.jobType {
font-size: var(--text-sm);
font-weight: 500;
text-transform: capitalize;
}
.jobMeta {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 1s linear infinite; }
@@ -0,0 +1,332 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import {
AlertCircle, CheckCircle2, RefreshCw, Loader2, Video,
Users, Layers, FileText, ArrowRight, GitMerge,
Upload, Clock, LayoutTemplate, History,
} from 'lucide-react';
import {
fetchVideoCount, fetchVideos, fetchBlocks, fetchCollaborators,
fetchBulkJobs, fetchQuota, fetchVideoOverviewStats, fetchTemplates,
} from '@/lib/api';
import styles from './page.module.css';
function StatCard({ label, value, sub, href, icon: Icon, accent }: {
label: string;
value: string | number;
sub?: string;
href?: string;
icon?: React.ElementType;
accent?: 'warn' | 'error' | 'ok';
}) {
const router = useRouter();
return (
<div
className={`panel ${styles.statCard} ${accent ? styles[`accent_${accent}`] : ''} ${href ? styles.statCardClickable : ''}`}
onClick={href ? () => router.push(href) : undefined}
role={href ? 'button' : undefined}
tabIndex={href ? 0 : undefined}
onKeyDown={href ? (e) => e.key === 'Enter' && router.push(href!) : undefined}
>
<div className={styles.statTop}>
<div className={styles.statValue}>{value}</div>
{Icon && <Icon size={18} className={styles.statIcon} />}
</div>
<div className={styles.statLabel}>{label}</div>
{sub && <div className={styles.statSub}>{sub}</div>}
{href && <ArrowRight size={13} className={styles.statArrow} />}
</div>
);
}
function QuotaCard() {
const { data, isLoading } = useQuery({
queryKey: ['quota'],
queryFn: fetchQuota,
refetchInterval: 60_000,
});
const pct = data?.percentUsed ?? 0;
const used = data?.used ?? 0;
const limit = data?.limit ?? 10_000;
const resetAt = data?.resetAt ? new Date(data.resetAt) : null;
const accent = pct >= 90 ? 'error' : pct >= 70 ? 'warn' : undefined;
return (
<div className={`panel ${styles.statCard} ${accent ? styles[`accent_${accent}`] : ''}`}>
<div className={styles.statTop}>
<div className={styles.statValue}>{isLoading ? '…' : `${used.toLocaleString()}`}</div>
<RefreshCw size={16} className={styles.statIcon} />
</div>
<div className={styles.statLabel}>YouTube quota today</div>
<div className={styles.quotaBar}>
<div
className={`${styles.quotaFill} ${pct > 80 ? styles.quotaFillWarn : ''} ${pct > 90 ? styles.quotaFillError : ''}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<div className={styles.statSub}>
{isLoading ? 'Loading…' : `${used.toLocaleString()} / ${limit.toLocaleString()} · ${pct}%`}
{resetAt && ` · resets ${resetAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
</div>
</div>
);
}
function formatRelative(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export default function OverviewPage() {
const router = useRouter();
const { data: totalVideos } = useQuery({ queryKey: ['videoCount', 'total'], queryFn: () => fetchVideoCount() });
const { data: publicVideos } = useQuery({ queryKey: ['videoCount', 'public'], queryFn: () => fetchVideoCount({ privacyStatus: 'PUBLIC' }) });
const { data: privateVideos } = useQuery({ queryKey: ['videoCount', 'private'], queryFn: () => fetchVideoCount({ privacyStatus: 'PRIVATE' }) });
const { data: unlistedVideos } = useQuery({ queryKey: ['videoCount', 'unlisted'], queryFn: () => fetchVideoCount({ privacyStatus: 'UNLISTED' }) });
const { data: errorVideos } = useQuery({ queryKey: ['videoCount', 'error'], queryFn: () => fetchVideoCount({ lintStatus: 'ERROR' }) });
const { data: warnVideos } = useQuery({ queryKey: ['videoCount', 'warn'], queryFn: () => fetchVideoCount({ lintStatus: 'WARNING' }) });
const { data: blocks = [] } = useQuery({ queryKey: ['blocks'], queryFn: fetchBlocks });
const { data: collaborators = [] } = useQuery({ queryKey: ['collaborators'], queryFn: fetchCollaborators });
const { data: templates = [] } = useQuery({ queryKey: ['templates'], queryFn: fetchTemplates });
const { data: recentJobs = [] } = useQuery({ queryKey: ['bulk-jobs'], queryFn: () => fetchBulkJobs() });
const { data: overviewStats } = useQuery({
queryKey: ['videoOverviewStats'],
queryFn: fetchVideoOverviewStats,
refetchInterval: 30_000,
});
const { data: lintErrorVideos } = useQuery({
queryKey: ['videos', 'lintErrors'],
queryFn: () => fetchVideos({ lintStatus: 'ERROR', limit: 5 }),
});
const { data: conflictVideos } = useQuery({
queryKey: ['videos', 'conflicts'],
queryFn: () => fetchVideos({ limit: 50 }),
select: (d) => d.items.filter((v) => v.remoteConflict).slice(0, 5),
});
const JOB_STATUS_LABEL: Record<string, string> = {
DONE: 'Done', RUNNING: 'Running', FAILED: 'Failed',
PENDING: 'Pending', CONFIRMED: 'Confirmed', ROLLED_BACK: 'Rolled back',
};
const JOB_STATUS_PILL: Record<string, string> = {
DONE: 'pill-primary', RUNNING: 'pill-blue', FAILED: 'pill-error',
PENDING: '', CONFIRMED: 'pill-blue', ROLLED_BACK: 'pill-warn',
};
const pendingCount = overviewStats?.pendingSync.count ?? 0;
const upcomingCount = overviewStats?.upcomingScheduled.count ?? 0;
return (
<div className={styles.container}>
<header className={styles.pageHeader}>
<h1>Overview</h1>
<p className={styles.pageSubtitle}>Live snapshot of your channel and content health.</p>
</header>
{/* ── Video counts ── */}
<section>
<p className={styles.sectionLabel}>Videos</p>
<div className={styles.statRow}>
<StatCard label="Total videos" value={totalVideos ?? '…'} href="/videos" icon={Video} />
<StatCard label="Public" value={publicVideos ?? '…'} href="/videos?tab=Published" />
<StatCard label="Private" value={privateVideos ?? '…'} href="/videos?privacyStatus=PRIVATE" />
<StatCard label="Unlisted" value={unlistedVideos ?? '…'} href="/videos?privacyStatus=UNLISTED" />
<StatCard label="Lint errors" value={errorVideos ?? '…'} href="/videos?tab=Lint Errors" accent={errorVideos ? 'error' : undefined} />
<StatCard label="Lint warnings" value={warnVideos ?? '…'} href="/videos?lintStatus=WARNING" accent={warnVideos ? 'warn' : undefined} />
</div>
</section>
{/* ── Sync + Library ── */}
<section>
<p className={styles.sectionLabel}>Sync & Library</p>
<div className={styles.statRow}>
<StatCard
label="Pending YouTube push"
value={overviewStats ? pendingCount : '…'}
href="/videos?tab=Push Pending"
icon={Upload}
accent={pendingCount > 0 ? 'warn' : 'ok'}
/>
<StatCard
label="Upcoming scheduled"
value={overviewStats ? upcomingCount : '…'}
href="/calendar"
icon={Clock}
/>
<StatCard label="Templates" value={templates.length} href="/templates" icon={LayoutTemplate} />
<StatCard label="Description blocks" value={blocks.length} href="/blocks" icon={Layers} />
<StatCard label="Active blocks" value={blocks.filter((b) => b.active).length} />
<StatCard label="Collaborators" value={collaborators.length} href="/collaborators" icon={Users} />
<QuotaCard />
</div>
</section>
{/* ── Sync panels ── */}
<div className={styles.grid2}>
{/* Pending push */}
<article className="panel">
<div className={styles.panelHeader}>
<h3 className={styles.panelTitle}>
<Upload size={15} style={{ color: 'var(--color-warn)' }} />
Pending YouTube push
</h3>
{pendingCount > 5 && (
<button className="btn btn-secondary" style={{ fontSize: 'var(--text-xs)' }} onClick={() => router.push('/videos?tab=Push Pending')}>
View all
</button>
)}
</div>
{!overviewStats && <p className={styles.emptyNote}><Loader2 size={14} className={styles.spinner} /> Loading</p>}
{overviewStats?.pendingSync.items.length === 0 && (
<p className={styles.emptyNote}><CheckCircle2 size={14} style={{ color: 'var(--color-success, #22c55e)' }} /> All videos are up to date.</p>
)}
{overviewStats?.pendingSync.items.map((v) => (
<div key={v.id} className={styles.issueRow} onClick={() => router.push(`/videos/${v.id}`)}>
<span className={styles.issueTitle}>{v.title}</span>
<ArrowRight size={13} className={styles.issueArrow} />
</div>
))}
</article>
{/* Recently synced */}
<article className="panel">
<div className={styles.panelHeader}>
<h3 className={styles.panelTitle}>
<History size={15} style={{ color: 'var(--color-primary)' }} />
Recently pushed to YouTube
</h3>
</div>
{!overviewStats && <p className={styles.emptyNote}><Loader2 size={14} className={styles.spinner} /> Loading</p>}
{overviewStats?.recentlySynced.items.length === 0 && (
<p className={styles.emptyNote}>No videos have been pushed yet.</p>
)}
{overviewStats?.recentlySynced.items.map((v) => (
<div key={v.id} className={styles.issueRow} onClick={() => router.push(`/videos/${v.id}`)}>
<span className={styles.issueTitle}>{v.title}</span>
<span className={styles.issueDate}>{formatRelative(v.lastSyncedAt)}</span>
<ArrowRight size={13} className={styles.issueArrow} />
</div>
))}
</article>
{/* Upcoming scheduled */}
<article className="panel">
<div className={styles.panelHeader}>
<h3 className={styles.panelTitle}>
<Clock size={15} style={{ color: 'var(--color-text-muted)' }} />
Upcoming scheduled
</h3>
{upcomingCount > 5 && (
<button className="btn btn-secondary" style={{ fontSize: 'var(--text-xs)' }} onClick={() => router.push('/calendar')}>
View calendar
</button>
)}
</div>
{!overviewStats && <p className={styles.emptyNote}><Loader2 size={14} className={styles.spinner} /> Loading</p>}
{overviewStats?.upcomingScheduled.items.length === 0 && (
<p className={styles.emptyNote}>No upcoming scheduled videos.</p>
)}
{overviewStats?.upcomingScheduled.items.map((v) => (
<div key={v.id} className={styles.issueRow} onClick={() => router.push(`/videos/${v.id}`)}>
<span className={styles.issueTitle}>{v.title}</span>
<span className={styles.issueDate}>{formatDate(v.scheduledAt)}</span>
<ArrowRight size={13} className={styles.issueArrow} />
</div>
))}
</article>
</div>
{/* ── Issues ── */}
<div className={styles.grid2}>
{/* Lint errors */}
<article className="panel">
<div className={styles.panelHeader}>
<h3 className={styles.panelTitle}>
<AlertCircle size={15} style={{ color: 'var(--color-error)' }} />
Videos with lint errors
</h3>
{(errorVideos ?? 0) > 5 && (
<button className="btn btn-secondary" style={{ fontSize: 'var(--text-xs)' }} onClick={() => router.push('/videos?tab=Lint Errors')}>
View all
</button>
)}
</div>
{!lintErrorVideos && <p className={styles.emptyNote}><Loader2 size={14} className={styles.spinner} /> Loading</p>}
{lintErrorVideos?.items.length === 0 && (
<p className={styles.emptyNote}><CheckCircle2 size={14} style={{ color: 'var(--color-success, #22c55e)' }} /> No lint errors.</p>
)}
{lintErrorVideos?.items.map((v) => (
<div key={v.id} className={styles.issueRow} onClick={() => router.push(`/videos/${v.id}`)}>
<span className={styles.issueTitle}>{v.title}</span>
<ArrowRight size={13} className={styles.issueArrow} />
</div>
))}
</article>
{/* Remote conflicts */}
<article className="panel">
<div className={styles.panelHeader}>
<h3 className={styles.panelTitle}>
<GitMerge size={15} style={{ color: 'var(--color-warn)' }} />
Remote conflicts
</h3>
{(conflictVideos?.length ?? 0) > 0 && (
<button className="btn btn-secondary" style={{ fontSize: 'var(--text-xs)' }} onClick={() => router.push('/videos?tab=Conflicts')}>
View all
</button>
)}
</div>
{!conflictVideos && <p className={styles.emptyNote}><Loader2 size={14} className={styles.spinner} /> Loading</p>}
{conflictVideos?.length === 0 && (
<p className={styles.emptyNote}><CheckCircle2 size={14} style={{ color: 'var(--color-success, #22c55e)' }} /> No conflicts.</p>
)}
{conflictVideos?.map((v) => (
<div key={v.id} className={styles.issueRow} onClick={() => router.push(`/videos/${v.id}`)}>
<span className={styles.issueTitle}>{v.title}</span>
<span className="pill pill-warn" style={{ fontSize: 11 }}>Conflict</span>
<ArrowRight size={13} className={styles.issueArrow} />
</div>
))}
</article>
{/* Recent bulk jobs */}
<article className="panel">
<div className={styles.panelHeader}>
<h3 className={styles.panelTitle}>
<FileText size={15} />
Recent bulk jobs
</h3>
</div>
{recentJobs.length === 0 && <p className={styles.emptyNote}>No bulk jobs yet.</p>}
{recentJobs.slice(0, 5).map((job) => (
<div key={job.id} className={styles.jobRow}>
<div className={styles.jobInfo}>
<span className={styles.jobType}>{job.type.replace(/_/g, ' ')}</span>
<span className={styles.jobMeta}>
{job.totalCount} videos · {new Date(job.createdAt).toLocaleDateString('de-DE')}
</span>
</div>
<span className={`pill ${JOB_STATUS_PILL[job.status] ?? ''}`}>{JOB_STATUS_LABEL[job.status] ?? job.status}</span>
</div>
))}
</article>
</div>
</div>
);
}
@@ -0,0 +1,213 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-8);
max-width: 1400px;
margin: 0 auto;
}
.hero {
display: grid;
grid-template-columns: 1fr 340px;
gap: var(--space-6);
}
.heroCopy {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-10);
display: flex;
flex-direction: column;
gap: var(--space-6);
box-shadow: var(--shadow-sm);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--color-primary);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .08em;
}
.heroCopy h2 {
font-family: var(--font-display);
font-size: var(--text-xl);
line-height: 1.2;
font-weight: 700;
max-width: 32ch;
}
.heroCopy p {
color: var(--color-text-muted);
font-size: var(--text-base);
max-width: 60ch;
}
.chipRow {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chip {
padding: 0.4rem 0.8rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.heroSide {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.miniCard {
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.miniCard h3 {
font-size: var(--text-sm);
font-weight: 700;
}
.tagRow {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-2);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-5);
}
.statValue {
font-family: var(--font-display);
font-size: var(--text-2xl);
line-height: 1;
font-weight: 700;
margin-bottom: var(--space-1);
}
.statLabel {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-1);
}
.trend {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.statBar {
height: 6px;
background: var(--color-divider);
border-radius: var(--radius-full);
margin-top: var(--space-4);
overflow: hidden;
}
.statBarFill {
height: 100%;
background: var(--color-primary);
}
.grid2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
gap: var(--space-6);
}
.sectionHead {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-6);
}
.sectionTitle {
font-size: var(--text-lg);
font-weight: 700;
margin-bottom: var(--space-1);
}
.sectionSub {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.builder {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.blockList {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.block {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4);
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.block strong {
display: block;
font-size: var(--text-sm);
margin-bottom: 2px;
}
.block p {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.lintList {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.lintList li {
display: flex;
gap: var(--space-4);
color: var(--color-warning);
}
.lintList strong {
display: block;
font-size: var(--text-sm);
color: var(--color-text);
margin-bottom: 2px;
}
.note {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function DashboardIndex() {
redirect('/overview');
}
@@ -0,0 +1,438 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: 1100px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.headerRight {
display: flex;
align-items: center;
gap: var(--space-3);
}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.header h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
.daySelect {
width: auto;
cursor: pointer;
}
/* ── Summary cards ── */
.summaryRow {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-4);
}
.summaryCard {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.summaryValue {
font-size: var(--text-2xl);
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.summaryLabel {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.summaryMeta {
font-size: var(--text-xs);
color: var(--color-text-faint);
margin-top: var(--space-1);
}
.quotaBar {
height: 4px;
background: var(--color-divider);
border-radius: 2px;
overflow: hidden;
margin-top: var(--space-2);
}
.quotaFill {
height: 100%;
background: var(--color-primary);
border-radius: 2px;
transition: width 0.4s;
}
.quotaFillWarn { background: var(--color-warning); }
.quotaFillError { background: var(--color-error); }
/* ── Section title ── */
.sectionTitle {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-muted);
margin-bottom: var(--space-4);
}
.count {
margin-left: var(--space-2);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-faint);
background: var(--color-surface-offset);
border-radius: var(--radius-full);
padding: 1px 8px;
}
/* ── Breakdown table ── */
.breakdownTable {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.breakdownTable thead tr {
border-bottom: 1px solid var(--color-divider);
}
.breakdownTable th {
padding: var(--space-2) var(--space-3);
text-align: left;
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--color-text-faint);
}
.breakdownRow {
border-bottom: 1px solid var(--color-divider);
}
.breakdownRow:last-child { border-bottom: none; }
.breakdownRow td {
padding: var(--space-3) var(--space-3);
vertical-align: middle;
}
.miniBarTrack {
height: 6px;
min-width: 80px;
background: var(--color-divider);
border-radius: 3px;
overflow: hidden;
}
.miniBarFill {
height: 100%;
background: var(--color-primary);
border-radius: 3px;
}
/* ── Log table ── */
.tableWrap {
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.table thead tr {
background: var(--color-surface-offset);
border-bottom: 1px solid var(--color-divider);
}
.table th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--color-text-faint);
white-space: nowrap;
}
.row {
border-bottom: 1px solid var(--color-divider);
transition: background 0.1s;
}
.row:last-child { border-bottom: none; }
.row:hover { background: var(--color-surface-offset); }
.row td {
padding: var(--space-3) var(--space-4);
vertical-align: middle;
}
/* ── Cells ── */
.timeCell {
white-space: nowrap;
color: var(--color-text-muted);
font-size: var(--text-xs);
font-variant-numeric: tabular-nums;
}
.opLabel {
display: block;
font-size: var(--text-sm);
color: var(--color-text);
}
.opCode {
display: block;
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--color-text-faint);
margin-top: 1px;
}
.numCol {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.barCol {
width: 120px;
padding-left: var(--space-4) !important;
}
.unitsBadge {
font-weight: 600;
color: var(--color-text-muted);
}
.unitsHigh {
color: var(--color-warning);
}
.metaCell {
font-size: var(--text-sm);
color: var(--color-text-muted);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detailCell {
font-size: var(--text-sm);
color: var(--color-text-muted);
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.videoLink {
color: var(--color-primary);
text-decoration: none;
}
.videoLink:hover { text-decoration: underline; }
.none {
color: var(--color-text-faint);
}
/* ── Action groups ── */
.groupList {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.groupBlock {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface);
}
.groupHeader {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.1s;
min-height: 0;
}
.groupHeader:hover {
background: var(--color-surface-offset);
}
.groupChevron {
flex-shrink: 0;
color: var(--color-text-faint);
display: flex;
align-items: center;
}
.groupTime {
flex-shrink: 0;
font-size: var(--text-xs);
color: var(--color-text-faint);
font-variant-numeric: tabular-nums;
min-width: 60px;
}
.groupLabel {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
flex-shrink: 0;
}
.groupDetail {
font-size: var(--text-sm);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
text-decoration: none;
}
a.groupDetail:hover {
color: var(--color-primary);
text-decoration: underline;
}
.groupVideoId {
margin-left: var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-faint);
font-family: monospace;
}
.groupMeta {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--space-3);
margin-left: auto;
}
.groupChannel {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.groupCalls {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.groupUnits {
font-size: var(--text-sm);
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-text-muted);
min-width: 70px;
text-align: right;
}
.groupUnitsHigh {
color: var(--color-warning);
}
.groupEntries {
border-top: 1px solid var(--color-divider);
background: var(--color-bg);
}
.subTable {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.subTable th {
padding: var(--space-2) var(--space-5);
text-align: left;
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--color-text-faint);
border-bottom: 1px solid var(--color-divider);
}
.subRow {
border-bottom: 1px solid var(--color-divider);
}
.subRow:last-child {
border-bottom: none;
}
.subRow td {
padding: var(--space-2) var(--space-5);
vertical-align: middle;
}
/* ── States ── */
.state {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-8);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.empty {
padding: var(--space-10) 0;
text-align: center;
color: var(--color-text-faint);
font-size: var(--text-sm);
font-style: italic;
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
@@ -0,0 +1,436 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, AlertCircle, BarChart2, ChevronDown, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { fetchQuotaHistory, type QuotaLogEntry } from '@/lib/api';
import f from '@/components/shared/FormField.module.css';
import styles from './page.module.css';
const OPERATION_COST: Record<string, string> = {
'videos.update': '50',
'playlistItems.insert': '50',
'playlistItems.delete': '50',
'videos.list': '1',
'playlistItems.list': '1',
'playlists.list': '1',
'channels.list': '1',
};
function relativeTime(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function formatDateTime(iso: string) {
return new Date(iso).toLocaleString();
}
function operationLabel(op: string) {
const labels: Record<string, string> = {
'videos.update': 'Push to YouTube',
'videos.list': 'Fetch video metadata',
'playlistItems.list': 'List playlist items',
'playlistItems.insert': 'Add to playlist',
'playlistItems.delete': 'Remove from playlist',
'playlists.list': 'List playlists',
'channels.list': 'Fetch channel info',
};
return labels[op] ?? op;
}
const DAYS_OPTIONS = [
{ value: 1, label: 'Today' },
{ value: 7, label: 'Last 7 days' },
{ value: 30, label: 'Last 30 days' },
{ value: 90, label: 'Last 90 days' },
];
function groupByOperation(items: QuotaLogEntry[]) {
const map = new Map<string, { count: number; units: number }>();
for (const item of items) {
const existing = map.get(item.operation) ?? { count: 0, units: 0 };
map.set(item.operation, { count: existing.count + 1, units: existing.units + item.units });
}
return Array.from(map.entries())
.map(([op, stats]) => ({ op, ...stats }))
.sort((a, b) => b.units - a.units);
}
// ── Action group clustering ──────────────────────────────────────────────────
const ACTION_TYPE_LABELS: Record<string, string> = {
channel_import: 'Channel Import',
video_sync: 'Push to YouTube',
video_refresh: 'Refresh from YouTube',
playlist_sync: 'Playlist Sync',
playlist_add: 'Add to Playlist',
playlist_remove: 'Remove from Playlist',
};
interface ActionGroup {
actionId: string | null;
channelId: string | null;
channelName: string | null;
startTs: number;
totalUnits: number;
entries: QuotaLogEntry[];
label: string;
detail: string | null;
videoId: string | null;
youtubeVideoId: string | null;
}
function buildLabel(
actionType: string | null,
entries: QuotaLogEntry[],
): { label: string; detail: string | null; videoId: string | null; youtubeVideoId: string | null } {
const label = (actionType && ACTION_TYPE_LABELS[actionType]) ?? operationLabel(entries[0]?.operation ?? '');
if (actionType === 'video_sync' || actionType === 'video_refresh') {
const e = entries.find((e) => e.videoTitle ?? e.videoId);
return {
label,
detail: e?.videoTitle ?? e?.videoId ?? null,
videoId: e?.videoId ?? null,
youtubeVideoId: e?.youtubeVideoId ?? null,
};
}
if (actionType === 'playlist_add' || actionType === 'playlist_remove') {
const e = entries.find((e) => e.entityLabel);
return { label, detail: e?.entityLabel ?? null, videoId: null, youtubeVideoId: null };
}
return { label, detail: null, videoId: null, youtubeVideoId: null };
}
function groupByActionId(items: QuotaLogEntry[]): ActionGroup[] {
if (!items.length) return [];
const map = new Map<string, ActionGroup>();
for (const entry of items) {
const key = entry.actionId ?? `ungrouped:${entry.id}`;
const ts = new Date(entry.createdAt).getTime();
if (!map.has(key)) {
map.set(key, {
actionId: entry.actionId,
channelId: entry.channelId,
channelName: entry.channelName,
startTs: ts,
totalUnits: 0,
entries: [],
label: '',
detail: null,
videoId: null,
youtubeVideoId: null,
});
}
const group = map.get(key)!;
group.totalUnits += entry.units;
group.entries.push(entry);
if (ts < group.startTs) group.startTs = ts;
}
const groups = Array.from(map.values());
for (const group of groups) {
const actionType = group.entries[0]?.actionType ?? null;
const { label, detail, videoId, youtubeVideoId } = buildLabel(actionType, group.entries);
group.label = label;
group.detail = detail;
group.videoId = videoId;
group.youtubeVideoId = youtubeVideoId;
group.entries.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
const sorted = groups.sort((a, b) => b.startTs - a.startTs);
return clusterVideoGroups(sorted);
}
// Merge action groups that share the same videoId and are within 60 seconds of each other.
// This handles the common case where a video push and playlist changes are logged as separate
// actionIds but belong to the same user-initiated save action.
function clusterVideoGroups(groups: ActionGroup[]): ActionGroup[] {
const result: ActionGroup[] = [];
const used = new Set<number>();
for (let i = 0; i < groups.length; i++) {
if (used.has(i)) continue;
const g = groups[i];
if (!g.videoId) {
result.push(g);
continue;
}
const toMerge: ActionGroup[] = [g];
for (let j = i + 1; j < groups.length; j++) {
if (used.has(j)) continue;
const other = groups[j];
if (other.videoId !== g.videoId) continue;
// groups are sorted newest-first so g.startTs >= other.startTs
if (g.startTs - other.startTs > 60_000) continue;
toMerge.push(other);
used.add(j);
}
if (toMerge.length === 1) {
result.push(g);
} else {
const allEntries = toMerge
.flatMap((m) => m.entries)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
const totalUnits = toMerge.reduce((s, m) => s + m.totalUnits, 0);
const startTs = Math.min(...toMerge.map((m) => m.startTs));
// Prefer the video_sync group's label if present; otherwise keep the first
const primary = toMerge.find((m) => m.entries.some((e) => e.actionType === 'video_sync')) ?? toMerge[0];
result.push({ ...primary, entries: allEntries, totalUnits, startTs });
}
}
return result;
}
// ── Components ───────────────────────────────────────────────────────────────
function ActionGroupRow({ group }: { group: ActionGroup }) {
const [open, setOpen] = useState(false);
const startIso = new Date(group.startTs).toISOString();
return (
<div className={styles.groupBlock}>
<button
className={styles.groupHeader}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className={styles.groupChevron}>
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className={styles.groupTime} title={formatDateTime(startIso)}>
{relativeTime(startIso)}
</span>
<span className={styles.groupLabel}>{group.label}</span>
{group.detail && (
group.videoId ? (
<Link
href={`/videos/${group.videoId}`}
className={styles.groupDetail}
onClick={(e) => e.stopPropagation()}
>
{group.detail}
{group.youtubeVideoId && (
<span className={styles.groupVideoId}>{group.youtubeVideoId}</span>
)}
</Link>
) : (
<span className={styles.groupDetail}>{group.detail}</span>
)
)}
<span className={styles.groupMeta}>
{group.channelName && <span className={styles.groupChannel}>{group.channelName}</span>}
<span className={styles.groupCalls}>{group.entries.length} call{group.entries.length !== 1 ? 's' : ''}</span>
<span className={`${styles.groupUnits} ${group.totalUnits >= 50 ? styles.groupUnitsHigh : ''}`}>
{group.totalUnits} units
</span>
</span>
</button>
{open && (
<div className={styles.groupEntries}>
<table className={styles.subTable}>
<thead>
<tr>
<th>Time</th>
<th>Operation</th>
<th>Detail</th>
<th className={styles.numCol}>Units</th>
</tr>
</thead>
<tbody>
{group.entries.map((entry) => (
<tr key={entry.id} className={styles.subRow}>
<td className={styles.timeCell} title={formatDateTime(entry.createdAt)}>
{relativeTime(entry.createdAt)}
</td>
<td>
<span className={styles.opLabel}>{operationLabel(entry.operation)}</span>
<span className={styles.opCode}>{entry.operation}</span>
</td>
<td className={styles.detailCell}>
{entry.videoId ? (
<Link href={`/videos/${entry.videoId}`} className={styles.videoLink}>
{entry.videoTitle ?? entry.videoId}
{entry.youtubeVideoId && (
<span className={styles.groupVideoId}>{entry.youtubeVideoId}</span>
)}
</Link>
) : entry.entityLabel ? (
<span>{entry.entityLabel}</span>
) : (
<span className={styles.none}></span>
)}
</td>
<td className={`${styles.numCol} ${styles.unitsBadge} ${entry.units >= 50 ? styles.unitsHigh : ''}`}>
{entry.units}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function QuotaHistoryPage() {
const [days, setDays] = useState(7);
const { data, isLoading, isError } = useQuery({
queryKey: ['quotaHistory', days],
queryFn: () => fetchQuotaHistory(days),
refetchInterval: 60_000,
});
const summary = data ? groupByOperation(data.items) : [];
const groups = data ? groupByActionId(data.items) : [];
const limit = 9_000;
const pct = data ? Math.round((data.totalUnits / limit) * 100) : 0;
return (
<div className={styles.container}>
<header className={styles.header}>
<div>
<span className={styles.eyebrow}>Logging</span>
<h1>Quota History</h1>
</div>
<div className={styles.headerRight}>
<select
className={`${f.input} ${styles.daySelect}`}
value={days}
onChange={(e) => setDays(Number(e.target.value))}
>
{DAYS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
</header>
{/* Summary cards */}
{data && (
<div className={styles.summaryRow}>
<div className={`panel ${styles.summaryCard}`}>
<div className={styles.summaryValue}>{data.totalUnits.toLocaleString()}</div>
<div className={styles.summaryLabel}>Total units used</div>
<div className={styles.quotaBar}>
<div
className={`${styles.quotaFill} ${pct > 70 ? styles.quotaFillWarn : ''} ${pct > 90 ? styles.quotaFillError : ''}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<div className={styles.summaryMeta}>{pct}% of 9,000 daily limit</div>
</div>
<div className={`panel ${styles.summaryCard}`}>
<div className={styles.summaryValue}>{groups.length.toLocaleString()}</div>
<div className={styles.summaryLabel}>Actions</div>
<div className={styles.summaryMeta}>{data.items.length} API calls total</div>
</div>
{summary[0] && (
<div className={`panel ${styles.summaryCard}`}>
<div className={styles.summaryValue}>{summary[0].units.toLocaleString()}</div>
<div className={styles.summaryLabel}>Highest cost: {operationLabel(summary[0].op)}</div>
<div className={styles.summaryMeta}>{summary[0].count} calls × {OPERATION_COST[summary[0].op] ?? '?'} units</div>
</div>
)}
</div>
)}
{/* Breakdown by operation */}
{summary.length > 0 && (
<section className="panel">
<h2 className={styles.sectionTitle}>
<BarChart2 size={15} />
Usage by operation
</h2>
<table className={styles.breakdownTable}>
<thead>
<tr>
<th>Operation</th>
<th className={styles.numCol}>Calls</th>
<th className={styles.numCol}>Units each</th>
<th className={styles.numCol}>Total units</th>
<th className={styles.barCol}></th>
</tr>
</thead>
<tbody>
{summary.map(({ op, count, units }) => (
<tr key={op} className={styles.breakdownRow}>
<td>
<span className={styles.opLabel}>{operationLabel(op)}</span>
<span className={styles.opCode}>{op}</span>
</td>
<td className={styles.numCol}>{count.toLocaleString()}</td>
<td className={styles.numCol}>{OPERATION_COST[op] ?? '?'}</td>
<td className={styles.numCol}><strong>{units.toLocaleString()}</strong></td>
<td className={styles.barCol}>
<div className={styles.miniBarTrack}>
<div
className={styles.miniBarFill}
style={{ width: `${Math.round((units / data!.totalUnits) * 100)}%` }}
/>
</div>
</td>
</tr>
))}
</tbody>
</table>
</section>
)}
{/* Action groups */}
<section>
<h2 className={styles.sectionTitle} style={{ marginBottom: 'var(--space-3)' }}>
Actions
{data && <span className={styles.count}>{groups.length}</span>}
</h2>
{isLoading && (
<div className={styles.state}>
<Loader2 size={20} className={styles.spin} />
<span>Loading quota history</span>
</div>
)}
{isError && (
<div className={styles.state}>
<AlertCircle size={18} />
<span>Failed to load quota history.</span>
</div>
)}
{data?.items.length === 0 && (
<div className={styles.empty}>No quota usage recorded for this period.</div>
)}
{groups.length > 0 && (
<div className={styles.groupList}>
{groups.map((group, i) => (
<ActionGroupRow key={i} group={group} />
))}
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,566 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-10);
max-width: 900px;
margin: 0 auto;
}
/* ── Header ── */
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.header h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
/* ── State ── */
.state {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-8);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
/* ── Section ── */
.section {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.sectionTitle {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text);
}
.sectionDesc {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: calc(-1 * var(--space-2));
}
/* ── Table ── */
.tableWrap {
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.table thead tr {
background: var(--color-surface-offset);
border-bottom: 1px solid var(--color-divider);
}
.table th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--color-text-faint);
white-space: nowrap;
}
.table tbody tr {
border-bottom: 1px solid var(--color-divider);
}
.table tbody tr:last-child { border-bottom: none; }
.table tbody tr:hover { background: var(--color-surface-offset); }
.table td {
padding: var(--space-3) var(--space-4);
vertical-align: middle;
}
/* ── Member cell ── */
.memberCell {
display: flex;
align-items: center;
gap: var(--space-3);
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 700;
flex-shrink: 0;
}
.memberName {
font-weight: 600;
color: var(--color-text);
font-size: var(--text-sm);
}
.memberEmail {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.youBadge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-radius: var(--radius-sm);
padding: 1px 5px;
}
/* ── Role badges ── */
.roleBadge {
display: inline-block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
border-radius: var(--radius-sm);
padding: 2px 7px;
background: var(--color-surface-offset);
color: var(--color-text-muted);
}
.roleOWNER { background: color-mix(in srgb, var(--color-error) 12%, transparent); color: var(--color-error); }
.roleADMIN { background: color-mix(in srgb, var(--color-primary) 12%, transparent); color: var(--color-primary); }
.roleEDITOR { background: color-mix(in srgb, var(--color-success) 12%, transparent); color: var(--color-success); }
.roleSelect {
width: auto;
cursor: pointer;
}
/* ── Actions cell ── */
.actionsCell {
width: 140px;
text-align: right;
}
.trashBtn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-faint);
display: inline-flex;
align-items: center;
padding: var(--space-1);
border-radius: var(--radius-sm);
transition: color 0.1s;
}
.trashBtn:hover { color: var(--color-error); }
.confirmDelete {
display: flex;
align-items: center;
gap: var(--space-2);
justify-content: flex-end;
}
.confirmText { font-size: var(--text-xs); color: var(--color-text-muted); }
.confirmYes {
font-size: var(--text-xs); font-weight: 700; color: white;
background: var(--color-error); border: none; border-radius: var(--radius-sm);
padding: 2px 8px; cursor: pointer;
}
.confirmNo {
font-size: var(--text-xs); background: none; border: none;
color: var(--color-text-faint); cursor: pointer; padding: 2px 4px;
}
/* ── Invite box ── */
.inviteBox {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px dashed var(--color-divider);
border-radius: var(--radius-lg);
background: var(--color-surface-offset);
}
.inviteTitle {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-muted);
}
.inviteRow {
display: flex;
gap: var(--space-3);
align-items: center;
}
.inviteRoleSelect {
flex-shrink: 0;
}
.inviteError { font-size: var(--text-xs); color: var(--color-error); }
.inviteSuccess { font-size: var(--text-xs); color: var(--color-success); }
.roleHints {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.roleHint {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
/* ── Channels ── */
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-8);
color: var(--color-text-faint);
font-size: var(--text-sm);
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
}
.emptyIcon { opacity: 0.3; }
.channelCell {
display: flex;
align-items: center;
gap: var(--space-3);
}
.channelIcon {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, #ff0000 12%, transparent);
color: #ff0000;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.channelName { font-weight: 600; }
.channelId {
font-family: var(--font-mono, monospace);
font-size: 11px;
background: var(--color-surface-offset);
border-radius: var(--radius-sm);
padding: 1px 5px;
color: var(--color-text-faint);
}
.channelDate {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.channelActionsCell {
text-align: right;
white-space: nowrap;
width: 1%;
}
.refreshBtn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
padding: var(--space-1) var(--space-3);
}
.refreshResult {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-muted);
flex-wrap: wrap;
justify-content: flex-end;
}
.refreshOk {
color: var(--color-success);
flex-shrink: 0;
}
.refreshInfo {
color: var(--color-primary);
}
.refreshWarn {
color: var(--color-warning);
}
.refreshDismiss {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-faint);
font-size: var(--text-sm);
line-height: 1;
padding: 0 2px;
}
.refreshDismiss:hover { color: var(--color-text); }
/* ── Publishing Schedule ── */
.scheduleBox {
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-5);
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
background: var(--color-surface-offset);
}
.scheduleRow {
display: flex;
align-items: center;
gap: var(--space-4);
}
.scheduleLabel {
min-width: 90px;
flex-shrink: 0;
margin: 0;
}
.scheduleValue {
font-size: var(--text-sm);
color: var(--color-text);
}
.tzSelect {
width: auto;
min-width: 220px;
}
/* Slot list */
.slotList {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.slotEmpty {
font-size: var(--text-sm);
color: var(--color-text-faint);
padding: var(--space-2) 0;
}
.slotRow {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
.slotEveryDay {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-radius: var(--radius-sm);
padding: 2px 7px;
}
.slotDays {
display: flex;
gap: 3px;
}
.slotDay {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
color: var(--color-text-faint);
}
.slotDayActive {
background: var(--color-primary);
color: white;
}
.slotTime {
display: flex;
align-items: center;
gap: var(--space-1);
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--color-text);
}
.slotDelete {
margin-left: auto;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-faint);
font-size: var(--text-base);
line-height: 1;
padding: 0 2px;
}
.slotDelete:hover { color: var(--color-error); }
/* Add slot form */
.addSlotForm {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--color-divider);
}
.addSlotRow {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.dayCheckboxes {
display: flex;
align-items: center;
gap: var(--space-1);
flex-wrap: wrap;
}
.everyDayToggle {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
padding: 3px 7px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-divider);
background: var(--color-surface);
white-space: nowrap;
}
.everyDayToggle:hover { border-color: var(--color-primary); }
.dayCheckbox {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 600;
border-radius: var(--radius-sm);
border: 1px solid var(--color-divider);
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
}
.dayCheckbox:hover { border-color: var(--color-primary); color: var(--color-primary); }
.dayCheckboxActive {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.timeInput {
width: auto;
min-width: 110px;
}
/* Save row */
.schedSaveRow {
display: flex;
align-items: center;
gap: var(--space-3);
}
.schedSaved {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--color-success);
}
.schedError {
font-size: var(--text-xs);
color: var(--color-error);
}
.toggleRow {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-text);
}
.toggleDesc {
font-size: var(--text-xs);
color: var(--color-text-faint);
font-weight: 400;
}
@@ -0,0 +1,729 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, AlertCircle, Trash2, Youtube, RefreshCw, CheckCircle2, Plus, Clock } from 'lucide-react';
import {
fetchTeam, inviteTeamMember, updateMemberRole, removeTeamMember, fullRefreshChannel,
purgeDeletedVideos,
fetchTeamSettings, updateTeamSettings,
type TeamMember, type FullRefreshResult, type PurgeResult, type PublishingSlot,
} from '@/lib/api';
import { useAuthStore } from '@/store/useAuthStore';
import f from '@/components/shared/FormField.module.css';
import styles from './page.module.css';
const ROLES = ['OWNER', 'ADMIN', 'EDITOR', 'REVIEWER', 'READONLY'] as const;
type Role = typeof ROLES[number];
const ROLE_PRIORITY: Record<Role, number> = {
OWNER: 5, ADMIN: 4, EDITOR: 3, REVIEWER: 2, READONLY: 1,
};
const ROLE_DESC: Record<Role, string> = {
OWNER: 'Full control, cannot be removed',
ADMIN: 'Manage members, channels, settings',
EDITOR: 'Create and edit content',
REVIEWER: 'View and comment only',
READONLY: 'View only',
};
const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const DAY_LABELS_SHORT = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const TIMEZONES = [
'UTC',
'Europe/London',
'Europe/Berlin',
'Europe/Paris',
'Europe/Madrid',
'Europe/Rome',
'Europe/Amsterdam',
'Europe/Stockholm',
'Europe/Warsaw',
'Europe/Zurich',
'Europe/Vienna',
'Europe/Prague',
'Europe/Helsinki',
'Europe/Lisbon',
'Europe/Athens',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Phoenix',
'America/Toronto',
'America/Vancouver',
'America/Sao_Paulo',
'America/Mexico_City',
'America/Buenos_Aires',
'Asia/Tokyo',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Singapore',
'Asia/Kolkata',
'Asia/Dubai',
'Asia/Istanbul',
'Australia/Sydney',
'Australia/Melbourne',
'Pacific/Auckland',
'Pacific/Honolulu',
'Africa/Johannesburg',
'Africa/Cairo',
];
function RoleBadge({ role }: { role: Role }) {
return <span className={`${styles.roleBadge} ${styles[`role${role}`]}`}>{role}</span>;
}
function SlotDayPills({ days }: { days: number[] }) {
if (days.length === 0) {
return <span className={styles.slotEveryDay}>Every day</span>;
}
return (
<span className={styles.slotDays}>
{DAY_LABELS_SHORT.map((label, i) => (
<span key={i} className={`${styles.slotDay} ${days.includes(i) ? styles.slotDayActive : ''}`}>
{label}
</span>
))}
</span>
);
}
export default function SettingsPage() {
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
const teamId = user?.teamId ?? '';
const myRole = (user?.teamRole ?? 'READONLY') as Role;
const isAdmin = ROLE_PRIORITY[myRole] >= ROLE_PRIORITY.ADMIN;
const { data: team, isLoading, isError } = useQuery({
queryKey: ['team', teamId],
queryFn: () => fetchTeam(teamId),
enabled: !!teamId,
});
const { data: settings } = useQuery({
queryKey: ['teamSettings', teamId],
queryFn: () => fetchTeamSettings(teamId),
enabled: !!teamId,
});
// ── Invite ────────────────────────────────────────────────────────────────
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<Role>('EDITOR');
const [inviteError, setInviteError] = useState<string | null>(null);
const inviteMut = useMutation({
mutationFn: () => inviteTeamMember(teamId, inviteEmail.trim(), inviteRole),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['team', teamId] });
setInviteEmail('');
setInviteError(null);
},
onError: (e: unknown) => setInviteError((e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Invite failed'),
});
// ── Role change ───────────────────────────────────────────────────────────
const roleMut = useMutation({
mutationFn: ({ userId, role }: { userId: string; role: string }) =>
updateMemberRole(teamId, userId, role),
onSuccess: () => qc.invalidateQueries({ queryKey: ['team', teamId] }),
});
// ── Remove member ─────────────────────────────────────────────────────────
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const removeMut = useMutation({
mutationFn: (userId: string) => removeTeamMember(teamId, userId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['team', teamId] });
setConfirmRemove(null);
},
});
// ── Full refresh ──────────────────────────────────────────────────────────
const [refreshResults, setRefreshResults] = useState<Record<string, FullRefreshResult>>({});
const refreshMut = useMutation({
mutationFn: (channelId: string) => fullRefreshChannel(channelId),
onSuccess: (data, channelId) => {
setRefreshResults((prev) => ({ ...prev, [channelId]: data }));
qc.invalidateQueries({ queryKey: ['videos'] });
qc.invalidateQueries({ queryKey: ['playlists'] });
},
});
// ── Purge deleted ────────────────────────────────────────────────────────
const [purgeResults, setPurgeResults] = useState<Record<string, PurgeResult>>({});
const purgeMut = useMutation({
mutationFn: (channelId: string) => purgeDeletedVideos(channelId),
onSuccess: (data, channelId) => {
setPurgeResults((prev) => ({ ...prev, [channelId]: data }));
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
// ── Publishing schedule ───────────────────────────────────────────────────
const [schedTimezone, setSchedTimezone] = useState<string>('');
const [schedSlots, setSchedSlots] = useState<PublishingSlot[] | null>(null);
const [newSlotDays, setNewSlotDays] = useState<number[]>([]);
const [newSlotTime, setNewSlotTime] = useState('12:00');
const [schedSaved, setSchedSaved] = useState(false);
const [showCanvaLink, setShowCanvaLink] = useState<boolean | null>(null);
const [showDeletedVideos, setShowDeletedVideos] = useState<boolean | null>(null);
// ── Conflict detection ────────────────────────────────────────────────────
const [cdEnabled, setCdEnabled] = useState<boolean | null>(null);
const [cdBatchSize, setCdBatchSize] = useState<number | null>(null);
const [cdMinAgeDays, setCdMinAgeDays] = useState<number | null>(null);
const [cdSaved, setCdSaved] = useState(false);
// Initialise local schedule state from server data once loaded
const effectiveTimezone = schedTimezone || settings?.timezone || 'UTC';
const effectiveSlots: PublishingSlot[] = schedSlots ?? settings?.publishingSchedule ?? [];
const effectiveShowCanva = showCanvaLink ?? settings?.showCanvaLink ?? false;
const effectiveShowDeleted = showDeletedVideos ?? settings?.showDeletedVideos ?? false;
const effectiveCdEnabled = cdEnabled ?? settings?.conflictDetectionEnabled ?? false;
const effectiveCdBatchSize = cdBatchSize ?? settings?.conflictDetectionBatchSize ?? 50;
const effectiveCdMinAgeDays = cdMinAgeDays ?? settings?.conflictDetectionMinAgeDays ?? 7;
const schedMut = useMutation({
mutationFn: () => updateTeamSettings(teamId, { timezone: effectiveTimezone, publishingSchedule: effectiveSlots, showCanvaLink: effectiveShowCanva, showDeletedVideos: effectiveShowDeleted }),
onSuccess: (data) => {
qc.setQueryData(['teamSettings', teamId], data);
setSchedTimezone('');
setSchedSlots(null);
setShowCanvaLink(null);
setShowDeletedVideos(null);
setSchedSaved(true);
setTimeout(() => setSchedSaved(false), 3000);
},
});
const cdMut = useMutation({
mutationFn: () => updateTeamSettings(teamId, {
conflictDetectionEnabled: effectiveCdEnabled,
conflictDetectionBatchSize: effectiveCdBatchSize,
conflictDetectionMinAgeDays: effectiveCdMinAgeDays,
}),
onSuccess: (data) => {
qc.setQueryData(['teamSettings', teamId], data);
setCdEnabled(null);
setCdBatchSize(null);
setCdMinAgeDays(null);
setCdSaved(true);
setTimeout(() => setCdSaved(false), 3000);
},
});
function addSlot() {
if (!newSlotTime) return;
const slot: PublishingSlot = { days: newSlotDays, time: newSlotTime };
setSchedSlots([...effectiveSlots, slot]);
setNewSlotDays([]);
setNewSlotTime('12:00');
}
function removeSlot(idx: number) {
setSchedSlots(effectiveSlots.filter((_, i) => i !== idx));
}
function toggleNewDay(day: number) {
setNewSlotDays((prev) =>
prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day],
);
}
// ── Render ────────────────────────────────────────────────────────────────
if (isLoading) return (
<div className={styles.state}><Loader2 size={20} className={styles.spin} /> Loading</div>
);
if (isError || !team) return (
<div className={styles.state}><AlertCircle size={18} /> Failed to load team settings.</div>
);
const editableRoles = ROLES.filter((r) => r !== 'OWNER');
const canChangeRole = (member: TeamMember) =>
isAdmin && member.role !== 'OWNER' && member.userId !== user?.id;
const canRemove = (member: TeamMember) =>
isAdmin && member.role !== 'OWNER' && member.userId !== user?.id;
return (
<div className={styles.container}>
<header className={styles.header}>
<div>
<span className={styles.eyebrow}>Workspace</span>
<h1>{team.name}</h1>
</div>
</header>
{/* ── Members ── */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Team Members</h2>
<p className={styles.sectionDesc}>People with access to this workspace.</p>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Member</th>
<th>Role</th>
{isAdmin && <th />}
</tr>
</thead>
<tbody>
{team.members.map((m) => (
<tr key={m.userId}>
<td>
<div className={styles.memberCell}>
<div className={styles.avatar}>{(m.user.name ?? m.user.email).charAt(0).toUpperCase()}</div>
<div>
<div className={styles.memberName}>{m.user.name ?? '—'}</div>
<div className={styles.memberEmail}>{m.user.email}</div>
</div>
{m.userId === user?.id && <span className={styles.youBadge}>you</span>}
</div>
</td>
<td>
{canChangeRole(m) ? (
<select
className={`${f.input} ${styles.roleSelect}`}
value={m.role}
onChange={(e) => roleMut.mutate({ userId: m.userId, role: e.target.value })}
disabled={roleMut.isPending}
>
{editableRoles.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
) : (
<RoleBadge role={m.role as Role} />
)}
</td>
{isAdmin && (
<td className={styles.actionsCell}>
{canRemove(m) && (
confirmRemove === m.userId ? (
<div className={styles.confirmDelete}>
<span className={styles.confirmText}>Remove?</span>
<button
className={styles.confirmYes}
onClick={() => removeMut.mutate(m.userId)}
disabled={removeMut.isPending}
>
{removeMut.isPending ? <Loader2 size={11} /> : 'Yes'}
</button>
<button className={styles.confirmNo} onClick={() => setConfirmRemove(null)}>No</button>
</div>
) : (
<button className={styles.trashBtn} onClick={() => setConfirmRemove(m.userId)}>
<Trash2 size={14} />
</button>
)
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{isAdmin && (
<div className={styles.inviteBox}>
<h3 className={styles.inviteTitle}>Invite member</h3>
<div className={styles.inviteRow}>
<input
className={f.input}
type="email"
placeholder="email@example.com"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && inviteEmail.trim() && inviteMut.mutate()}
/>
<select
className={`${f.input} ${styles.inviteRoleSelect}`}
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as Role)}
style={{ width: 'auto' }}
>
{editableRoles.map((r) => (
<option key={r} value={r} title={ROLE_DESC[r]}>{r}</option>
))}
</select>
<button
className="btn btn-primary"
onClick={() => inviteMut.mutate()}
disabled={!inviteEmail.trim() || inviteMut.isPending}
>
{inviteMut.isPending ? <Loader2 size={14} /> : null}
Invite
</button>
</div>
{inviteError && <p className={styles.inviteError}>{inviteError}</p>}
{inviteMut.isSuccess && <p className={styles.inviteSuccess}>Invitation sent.</p>}
<div className={styles.roleHints}>
{editableRoles.map((r) => (
<span key={r} className={styles.roleHint}><strong>{r}</strong> {ROLE_DESC[r]}</span>
))}
</div>
</div>
)}
</section>
{/* ── Channels ── */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Connected YouTube Channels</h2>
<p className={styles.sectionDesc}>YouTube channels synced to this workspace.</p>
{team.channels.length === 0 ? (
<div className={styles.empty}>
<Youtube size={32} className={styles.emptyIcon} />
<p>No channels connected yet.</p>
</div>
) : (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Channel</th>
<th>YouTube ID</th>
<th>Connected</th>
{isAdmin && <th />}
</tr>
</thead>
<tbody>
{team.channels.map((ch) => {
const isRefreshing = refreshMut.isPending && refreshMut.variables === ch.id;
const isPurging = purgeMut.isPending && purgeMut.variables === ch.id;
const result = refreshResults[ch.id];
const purgeResult = purgeResults[ch.id];
return (
<tr key={ch.id}>
<td>
<div className={styles.channelCell}>
<div className={styles.channelIcon}><Youtube size={15} /></div>
<span className={styles.channelName}>{ch.name}</span>
</div>
</td>
<td>
<code className={styles.channelId}>{ch.youtubeChannelId}</code>
</td>
<td className={styles.channelDate}>
{new Date(ch.createdAt).toLocaleDateString()}
</td>
{isAdmin && (
<td className={styles.channelActionsCell}>
{result ? (
<div className={styles.refreshResult}>
<CheckCircle2 size={13} className={styles.refreshOk} />
<span>{result.total} videos, {result.playlistsForceSynced} playlists synced</span>
{result.orphansImported > 0 && (
<span className={styles.refreshInfo}>{result.orphansImported} orphaned video{result.orphansImported !== 1 ? 's' : ''} imported</span>
)}
{result.deleted > 0 && (
<span className={styles.refreshWarn} title={result.deletedTitles.join('\n')}>{result.deleted} deleted from YouTube removed</span>
)}
{result.duplicateUploadsEntries > 0 && (
<span className={styles.refreshWarn}>{result.duplicateUploadsEntries} duplicate playlist entries</span>
)}
{result.orphansRejected > 0 && (
<span className={styles.refreshWarn}>{result.orphansRejected} skipped different channel</span>
)}
<button className={styles.refreshDismiss} onClick={() => setRefreshResults((p) => { const n = { ...p }; delete n[ch.id]; return n; })}>×</button>
</div>
) : purgeResult ? (
<div className={styles.refreshResult}>
<CheckCircle2 size={13} className={styles.refreshOk} />
{purgeResult.deleted === 0
? <span>All {purgeResult.checked} videos still on YouTube</span>
: <span className={styles.refreshWarn} title={purgeResult.deletedTitles.join('\n')}>{purgeResult.deleted} deleted video{purgeResult.deleted !== 1 ? 's' : ''} removed</span>
}
<button className={styles.refreshDismiss} onClick={() => setPurgeResults((p) => { const n = { ...p }; delete n[ch.id]; return n; })}>×</button>
</div>
) : (
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<button
className={`btn btn-secondary ${styles.refreshBtn}`}
onClick={() => refreshMut.mutate(ch.id)}
disabled={refreshMut.isPending || purgeMut.isPending}
title="Force-reimport all videos and playlists for this channel"
>
{isRefreshing ? <Loader2 size={13} className={styles.spin} /> : <RefreshCw size={13} />}
Full Refresh
</button>
<button
className={`btn btn-secondary ${styles.refreshBtn}`}
onClick={() => purgeMut.mutate(ch.id)}
disabled={purgeMut.isPending || refreshMut.isPending}
title="Check for videos deleted on YouTube and remove them locally"
>
{isPurging ? <Loader2 size={13} className={styles.spin} /> : <Trash2 size={13} />}
Purge deleted
</button>
</div>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
{/* ── Publishing Schedule ── */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Publishing Schedule</h2>
<p className={styles.sectionDesc}>
Define the time slots when videos are allowed to publish. Used by the "Next free slot" feature in the video editor.
</p>
<div className={styles.scheduleBox}>
{/* Timezone */}
<div className={styles.scheduleRow}>
<label className={`${f.label} ${styles.scheduleLabel}`}>Timezone</label>
{isAdmin ? (
<select
className={`${f.input} ${styles.tzSelect}`}
value={effectiveTimezone}
onChange={(e) => setSchedTimezone(e.target.value)}
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
) : (
<span className={styles.scheduleValue}>{effectiveTimezone}</span>
)}
</div>
{/* Slot list */}
<div className={styles.slotList}>
{effectiveSlots.length === 0 && (
<div className={styles.slotEmpty}>No slots configured yet.</div>
)}
{effectiveSlots.map((slot, idx) => (
<div key={idx} className={styles.slotRow}>
<SlotDayPills days={slot.days} />
<span className={styles.slotTime}>
<Clock size={12} />
{slot.time}
</span>
{isAdmin && (
<button className={styles.slotDelete} onClick={() => removeSlot(idx)} title="Remove slot">
×
</button>
)}
</div>
))}
</div>
{/* Add slot form */}
{isAdmin && (
<div className={styles.addSlotForm}>
<span className={`${f.label} ${styles.scheduleLabel}`}>Add slot</span>
<div className={styles.addSlotRow}>
<div className={styles.dayCheckboxes}>
<label className={styles.everyDayToggle}>
<input
type="checkbox"
checked={newSlotDays.length === 0}
onChange={() => setNewSlotDays([])}
/>
Every day
</label>
{DAY_LABELS.map((label, i) => (
<label key={i} className={`${styles.dayCheckbox} ${newSlotDays.includes(i) ? styles.dayCheckboxActive : ''}`}>
<input
type="checkbox"
checked={newSlotDays.includes(i)}
onChange={() => toggleNewDay(i)}
style={{ display: 'none' }}
/>
{label}
</label>
))}
</div>
<input
className={`${f.input} ${styles.timeInput}`}
type="time"
value={newSlotTime}
onChange={(e) => setNewSlotTime(e.target.value)}
/>
<button
className="btn btn-secondary"
onClick={addSlot}
disabled={!newSlotTime}
>
<Plus size={13} />
Add
</button>
</div>
</div>
)}
{/* Options */}
<div className={styles.scheduleRow}>
{isAdmin ? (
<label className={styles.toggleRow}>
<input
type="checkbox"
checked={effectiveShowCanva}
onChange={(e) => setShowCanvaLink(e.target.checked)}
/>
<span>Show Canva link in video editor</span>
<span className={styles.toggleDesc}>
Adds a link next to YouTube Studio that searches Canva for the video's Game Title.
</span>
</label>
) : (
<span className={styles.scheduleValue}>
Canva link: {effectiveShowCanva ? 'enabled' : 'disabled'}
</span>
)}
</div>
<div className={styles.scheduleRow}>
{isAdmin ? (
<label className={styles.toggleRow}>
<input
type="checkbox"
checked={effectiveShowDeleted}
onChange={(e) => setShowDeletedVideos(e.target.checked)}
/>
<span>Show deleted videos tab</span>
<span className={styles.toggleDesc}>
Reveals a "Deleted" tab in the video list for videos removed from YouTube. They are kept locally for 30 days before being permanently deleted.
</span>
</label>
) : (
<span className={styles.scheduleValue}>
Deleted videos tab: {effectiveShowDeleted ? 'enabled' : 'disabled'}
</span>
)}
</div>
{/* Save */}
{isAdmin && (
<div className={styles.schedSaveRow}>
<button
className="btn btn-primary"
onClick={() => schedMut.mutate()}
disabled={schedMut.isPending}
>
{schedMut.isPending ? <Loader2 size={13} /> : null}
Save schedule
</button>
{schedSaved && (
<span className={styles.schedSaved}>
<CheckCircle2 size={13} /> Saved
</span>
)}
{schedMut.isError && (
<span className={styles.schedError}>Failed to save.</span>
)}
</div>
)}
</div>
</section>
{/* ── Remote Conflict Detection ── */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Remote Conflict Detection</h2>
<p className={styles.sectionDesc}>
Periodically checks YouTube for out-of-band edits to your videos. When a mismatch is found,
the video is flagged with a lint error so you can review the diff and either accept the remote
version or push the local one. Costs 1 quota unit per video checked.
</p>
<div className={styles.scheduleBox}>
<div className={styles.scheduleRow}>
{isAdmin ? (
<label className={styles.toggleRow}>
<input
type="checkbox"
checked={effectiveCdEnabled}
onChange={(e) => setCdEnabled(e.target.checked)}
/>
<span>Enable scheduled conflict detection</span>
<span className={styles.toggleDesc}>
Runs on the schedule configured by your server administrator.
</span>
</label>
) : (
<span className={styles.scheduleValue}>
Conflict detection: {effectiveCdEnabled ? 'enabled' : 'disabled'}
</span>
)}
</div>
{effectiveCdEnabled && (
<>
<div className={styles.scheduleRow}>
<label className={`${f.label} ${styles.scheduleLabel}`}>Videos per run</label>
{isAdmin ? (
<input
className={f.input}
type="number"
min={1}
max={500}
value={effectiveCdBatchSize}
onChange={(e) => setCdBatchSize(Number(e.target.value))}
style={{ width: '8rem' }}
/>
) : (
<span className={styles.scheduleValue}>{effectiveCdBatchSize}</span>
)}
</div>
<div className={styles.scheduleRow}>
<label className={`${f.label} ${styles.scheduleLabel}`}>Only check videos older than (days)</label>
{isAdmin ? (
<input
className={f.input}
type="number"
min={0}
value={effectiveCdMinAgeDays}
onChange={(e) => setCdMinAgeDays(Number(e.target.value))}
style={{ width: '8rem' }}
/>
) : (
<span className={styles.scheduleValue}>{effectiveCdMinAgeDays}</span>
)}
</div>
</>
)}
{isAdmin && (
<div className={styles.schedSaveRow}>
<button
className="btn btn-primary"
onClick={() => cdMut.mutate()}
disabled={cdMut.isPending}
>
{cdMut.isPending ? <Loader2 size={13} /> : null}
Save
</button>
{cdSaved && (
<span className={styles.schedSaved}>
<CheckCircle2 size={13} /> Saved
</span>
)}
{cdMut.isError && (
<span className={styles.schedError}>Failed to save.</span>
)}
</div>
)}
</div>
</section>
</div>
);
}
@@ -0,0 +1,520 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-8);
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.headerLeft h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
.headerRight { display: flex; gap: var(--space-3); }
.count {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: var(--space-3);
padding: 0.1rem 0.5rem;
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
vertical-align: middle;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-5);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-3);
box-shadow: var(--shadow-sm);
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.cardTop {
display: flex;
justify-content: space-between;
align-items: center;
}
.cardIcon {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--color-primary-highlight);
color: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
}
.name {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text);
}
.desc {
font-size: var(--text-xs);
color: var(--color-text-muted);
flex: 1;
}
.cardFooter {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: auto;
padding-top: var(--space-3);
border-top: 1px solid var(--color-divider);
}
.cardFooterRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.deleteErrRow {
font-size: var(--text-xs);
color: var(--color-error);
line-height: 1.4;
}
.version {
font-size: var(--text-xs);
color: var(--color-text-faint);
font-weight: 600;
}
.editBtn {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.state, .empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
text-align: center;
}
.emptyIcon { color: var(--color-text-faint); margin-bottom: var(--space-2); }
.emptyHint { color: var(--color-text-faint); font-size: var(--text-xs); }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 1s linear infinite; }
/* Card meta badges */
.cardMeta {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.metaBadge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
color: var(--color-text-faint);
border: 1px solid var(--color-border);
}
/* Modal tabs */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-4);
}
.tab {
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.15s;
}
.tab:hover { color: var(--color-text); }
.tabActive {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.tabBody {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-height: 280px;
}
.sectionHint {
font-size: var(--text-xs);
color: var(--color-text-faint);
line-height: 1.5;
}
/* Video field list */
.fieldList {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.fieldRow {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
transition: border-color 0.15s;
}
.fieldRowActive {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary), transparent 96%);
}
.fieldToggle {
display: flex;
align-items: center;
gap: var(--space-2);
}
.toggleBtn {
width: 22px;
height: 22px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
color: var(--color-text-faint);
background: var(--color-surface);
flex-shrink: 0;
transition: all 0.15s;
}
.toggleBtn:hover { border-color: var(--color-primary); color: var(--color-primary); }
.toggleBtnOn {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.fieldLabel {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.fieldControl { padding-left: calc(22px + var(--space-2)); }
.tagsEditor {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tagChips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.tagChip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--color-primary-highlight);
border: 1px solid color-mix(in srgb, var(--color-primary), transparent 60%);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-primary);
}
.tagChip button {
display: flex;
align-items: center;
color: var(--color-primary);
opacity: 0.7;
}
.tagChip button:hover { opacity: 1; }
/* Block editor */
.blockEditorLayout {
display: grid;
grid-template-columns: 220px 1fr;
gap: var(--space-4);
align-items: flex-start;
}
.blockLibrary {
display: flex;
flex-direction: column;
gap: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3);
}
.blockConfig {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.panelTitle {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.libraryList {
display: flex;
flex-direction: column;
max-height: 300px;
overflow-y: auto;
margin-top: var(--space-1);
}
.libraryItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-1);
border-top: 1px solid var(--color-divider);
gap: var(--space-2);
}
.libraryItem:hover { background: var(--color-surface-offset); }
.orderedList {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-2);
}
.orderedItem {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.freeTextArea {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: var(--text-xs);
font-family: inherit;
line-height: 1.5;
resize: vertical;
}
.blockIndex {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-faint);
width: 18px;
text-align: center;
flex-shrink: 0;
}
.blockInfo {
flex: 1;
min-width: 0;
}
.itemName {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemType {
font-size: 10px;
color: var(--color-text-faint);
text-transform: uppercase;
}
.blockControls {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.addBtn {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-primary);
background: var(--color-primary-highlight);
transition: all 0.15s;
flex-shrink: 0;
}
.addBtn:hover { background: var(--color-primary); color: white; }
.iconBtn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
color: var(--color-text-muted);
transition: all 0.15s;
}
.iconBtn:hover:not(:disabled) { background: var(--color-surface-offset); color: var(--color-text); }
.iconBtn:disabled { opacity: 0.3; cursor: default; }
.removeBtn:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-error), transparent 85%);
color: var(--color-error);
}
.empty {
font-size: var(--text-xs);
color: var(--color-text-faint);
padding: var(--space-3);
text-align: center;
}
.trashBtn {
color: var(--color-text-faint);
display: flex;
align-items: center;
transition: color 0.12s;
}
.trashBtn:hover { color: var(--color-error); }
.confirmDelete {
display: flex;
align-items: center;
gap: var(--space-2);
white-space: nowrap;
}
.confirmText { font-size: var(--text-xs); color: var(--color-text-muted); }
.deleteErr { font-size: var(--text-xs); color: var(--color-error); }
.confirmYes {
font-size: var(--text-xs);
font-weight: 700;
color: white;
background: var(--color-error);
border-radius: var(--radius-sm);
padding: 2px 8px;
}
.confirmNo {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
}
/* Yes / No toggle */
.yesNoToggle {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.yesNoBtn {
padding: var(--space-1) var(--space-4);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface);
transition: all 0.15s;
}
.yesNoBtn + .yesNoBtn {
border-left: 1px solid var(--color-border);
}
.yesNoBtn:hover:not(.yesNoBtnActive) {
background: var(--color-surface-offset);
color: var(--color-text);
}
.yesNoBtnActive {
background: var(--color-primary);
color: white;
}
@@ -0,0 +1,395 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Loader2, AlertCircle, Layers3, X, Check, Trash2 } from 'lucide-react';
import {
fetchTemplates, fetchBlocks, fetchTeamVariables, fetchSystemVariables,
createTemplate, updateTemplate, deleteTemplate, fetchTemplateUsage, renderTemplatePreview,
type Template, type TemplatePayload, type Block, type TeamVariable, type SystemVariable,
} from '@/lib/api';
import BlockOrderEditor, { type BlockOverride } from '@/components/shared/BlockOrderEditor';
import UsagePanel from '@/components/shared/UsagePanel';
import { YT_CATEGORIES, LANGUAGE_OPTIONS, LICENSE_OPTIONS } from '@/lib/videoFieldOptions';
import Modal from '@/components/shared/Modal';
import f from '@/components/shared/FormField.module.css';
import styles from './page.module.css';
// ─── Video field definitions ──────────────────────────────────────────────────
type FieldDef =
| { key: string; label: string; type: 'text' | 'boolean' | 'tags' }
| { key: string; label: string; type: 'select'; options: { value: string; label: string }[] };
const VIDEO_FIELD_DEFS: FieldDef[] = [
{ key: 'privacyStatus', label: 'Privacy Status', type: 'select', options: [
{ value: 'PUBLIC', label: 'Public' },
{ value: 'PRIVATE', label: 'Private' },
{ value: 'UNLISTED', label: 'Unlisted' },
]},
{ key: 'categoryId', label: 'Category', type: 'select', options: YT_CATEGORIES.map((c) => ({ value: c.id, label: c.name })) },
{ key: 'defaultAudioLanguage', label: 'Video Language', type: 'select', options: LANGUAGE_OPTIONS.filter((l) => l.code).map((l) => ({ value: l.code, label: l.name })) },
{ key: 'defaultLanguage', label: 'Title & Description Language', type: 'select', options: LANGUAGE_OPTIONS.filter((l) => l.code).map((l) => ({ value: l.code, label: l.name })) },
{ key: 'license', label: 'License', type: 'select', options: LICENSE_OPTIONS },
{ key: 'embeddable', label: 'Allow Embedding', type: 'boolean' },
{ key: 'selfDeclaredMadeForKids', label: 'Made for Kids', type: 'boolean' },
{ key: 'gameTitle', label: 'Game Title', type: 'text' },
{ key: 'tags', label: 'Tags', type: 'tags' },
];
// ─── TemplateModal ────────────────────────────────────────────────────────────
type Tab = 'general' | 'videoFields' | 'blocks' | 'usage';
function TemplateModal({ initial, onClose }: { initial?: Template; onClose: () => void }) {
const qc = useQueryClient();
const [tab, setTab] = useState<Tab>('general');
const [name, setName] = useState(initial?.name ?? '');
const [description, setDescription] = useState(initial?.description ?? '');
const [active, setActive] = useState(initial?.active ?? true);
const [videoFields, setVideoFields] = useState<Record<string, unknown>>(initial?.videoFields ?? {});
const [blockOrder, setBlockOrder] = useState<string[]>(initial?.defaultBlocks ?? []);
const [blockOverrides, setBlockOverrides] = useState<Record<string, BlockOverride>>(
(initial?.defaultOverrides ?? {}) as Record<string, BlockOverride>,
);
const [variableValues, setVariableValues] = useState<Record<string, string>>(
(initial?.variables ?? {}) as Record<string, string>,
);
const [tagInput, setTagInput] = useState('');
const [preview, setPreview] = useState<string | null>(null);
const { data: allBlocks = [] } = useQuery<Block[]>({ queryKey: ['blocks'], queryFn: fetchBlocks });
const { data: teamVars = [] } = useQuery<TeamVariable[]>({ queryKey: ['team-variables'], queryFn: fetchTeamVariables });
const { data: systemVars = [] } = useQuery<SystemVariable[]>({ queryKey: ['system-variables'], queryFn: fetchSystemVariables });
const { data: usage, isLoading: usageLoading } = useQuery({
queryKey: ['template-usage', initial?.id],
queryFn: () => fetchTemplateUsage(initial!.id),
enabled: !!initial,
});
const renderMut = useMutation({
mutationFn: () => renderTemplatePreview(initial!.id, variableValues),
onSuccess: (data) => setPreview(data.rendered),
});
const mutation = useMutation({
mutationFn: () => {
const pendingTags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
const resolvedVideoFields = pendingTags.length && 'tags' in videoFields
? { ...videoFields, tags: [...(videoFields.tags as string[] ?? []), ...pendingTags.filter((t) => !(videoFields.tags as string[] ?? []).includes(t))] }
: videoFields;
const payload: TemplatePayload = {
name,
description: description || undefined,
active,
defaultBlocks: blockOrder,
defaultOverrides: Object.keys(blockOverrides).length > 0 ? blockOverrides : {},
rules: initial?.rules ?? {},
variables: variableValues,
videoFields: Object.keys(resolvedVideoFields).length > 0 ? resolvedVideoFields : null,
};
return initial ? updateTemplate(initial.id, payload) : createTemplate(payload);
},
onSuccess: () => { qc.invalidateQueries({ queryKey: ['templates'] }); setTagInput(''); onClose(); },
});
// ── Video field helpers ───────────────────────────────────────────────────
const fieldEnabled = (key: string) => key in videoFields;
const toggleField = (key: string) => {
setVideoFields((prev) => {
const next = { ...prev };
if (key in next) {
delete next[key];
} else {
const def = VIDEO_FIELD_DEFS.find((d) => d.key === key);
if (!def) return next;
if (def.type === 'boolean') next[key] = false;
else if (def.type === 'tags') next[key] = [];
else if (def.type === 'select') next[key] = def.options[0].value;
else next[key] = '';
}
return next;
});
};
const setFieldValue = (key: string, val: unknown) =>
setVideoFields((prev) => ({ ...prev, [key]: val }));
return (
<Modal title={initial ? 'Edit Template' : 'Create Template'} onClose={onClose} width={760}>
{/* Tabs */}
<div className={styles.tabs}>
{((['general', 'videoFields', 'blocks'] as Tab[]).concat(initial ? ['usage' as Tab] : [])).map((t) => (
<button
key={t}
className={`${styles.tab} ${tab === t ? styles.tabActive : ''}`}
onClick={() => setTab(t)}
>
{t === 'general' ? 'General' : t === 'videoFields' ? 'Video Fields' : t === 'blocks' ? 'Description Blocks' : 'Usage'}
</button>
))}
</div>
{/* ── General ── */}
{tab === 'general' && (
<div className={styles.tabBody}>
<div className={f.field}>
<label className={f.label}>Name</label>
<input className={f.input} value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Standard Tutorial" autoFocus />
</div>
<div className={f.field}>
<label className={f.label}>Description</label>
<textarea className={f.textarea} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What is this template for?" />
</div>
<label className={f.toggle}>
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
Active
</label>
</div>
)}
{/* ── Video Fields ── */}
{tab === 'videoFields' && (
<div className={styles.tabBody}>
<p className={styles.sectionHint}>
Enable fields to include them in this template. When the template is applied to a video, only enabled fields will be overridden.
</p>
<div className={styles.fieldList}>
{VIDEO_FIELD_DEFS.map((def) => {
const enabled = fieldEnabled(def.key);
return (
<div key={def.key} className={`${styles.fieldRow} ${enabled ? styles.fieldRowActive : ''}`}>
<div className={styles.fieldToggle}>
<button
className={`${styles.toggleBtn} ${enabled ? styles.toggleBtnOn : ''}`}
onClick={() => toggleField(def.key)}
title={enabled ? 'Remove this field from template' : 'Include this field in template'}
>
{enabled ? <Check size={12} /> : <Plus size={12} />}
</button>
<span className={styles.fieldLabel}>{def.label}</span>
</div>
{enabled && (
<div className={styles.fieldControl}>
{def.type === 'text' && (
<input
className={f.input}
value={(videoFields[def.key] as string) ?? ''}
onChange={(e) => setFieldValue(def.key, e.target.value)}
placeholder={`Enter ${def.label.toLowerCase()}`}
/>
)}
{def.type === 'select' && (
<select className={f.input} value={(videoFields[def.key] as string) ?? ''} onChange={(e) => setFieldValue(def.key, e.target.value)}>
{def.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
)}
{def.type === 'boolean' && (
<div className={styles.yesNoToggle}>
<button
className={`${styles.yesNoBtn} ${!!videoFields[def.key] ? styles.yesNoBtnActive : ''}`}
onClick={() => setFieldValue(def.key, true)}
>Yes</button>
<button
className={`${styles.yesNoBtn} ${!videoFields[def.key] ? styles.yesNoBtnActive : ''}`}
onClick={() => setFieldValue(def.key, false)}
>No</button>
</div>
)}
{def.type === 'tags' && (
<div className={styles.tagsEditor}>
<div className={styles.tagChips}>
{(videoFields[def.key] as string[] ?? []).map((tag: string) => (
<span key={tag} className={styles.tagChip}>
{tag}
<button onClick={() => setFieldValue(def.key, (videoFields[def.key] as string[]).filter((t: string) => t !== tag))}><X size={10} /></button>
</span>
))}
</div>
<input
className={f.input}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const parts = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
const current = (videoFields[def.key] as string[]) ?? [];
const toAdd = parts.filter((t) => !current.includes(t));
if (toAdd.length) setFieldValue(def.key, [...current, ...toAdd]);
setTagInput('');
}
}}
placeholder="Type a tag and press Enter…"
/>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* ── Description Blocks ── */}
{tab === 'blocks' && (
<div className={styles.tabBody}>
<BlockOrderEditor
blockOrder={blockOrder}
blockOverrides={blockOverrides}
variableValues={variableValues}
collaboratorIds={[]}
onBlockOrderChange={setBlockOrder}
onBlockOverridesChange={setBlockOverrides}
onVariableValuesChange={setVariableValues}
onCollaboratorIdsChange={() => {}}
allBlocks={allBlocks}
collaborators={[]}
teamVars={teamVars}
systemVars={systemVars}
onPreview={initial ? () => renderMut.mutate() : undefined}
previewPending={renderMut.isPending}
previewResult={preview}
/>
</div>
)}
{tab === 'usage' && initial && (
<div className={styles.tabBody}>
<UsagePanel
isLoading={usageLoading}
groups={[
{ heading: 'Videos', items: (usage?.videos ?? []).map((v) => ({ id: v.id, label: v.title, href: `/videos/${v.id}` })) },
]}
/>
</div>
)}
{mutation.isError && (
<p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)', marginTop: 'var(--space-2)' }}>
Save failed check the backend logs.
</p>
)}
<div className={f.actions} style={{ marginTop: 'var(--space-4)' }}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => mutation.mutate()} disabled={mutation.isPending || !name.trim()}>
{mutation.isPending ? <Loader2 size={14} /> : null}
{initial ? 'Save Changes' : 'Create Template'}
</button>
</div>
</Modal>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function TemplatesPage() {
const qc = useQueryClient();
const [modal, setModal] = useState<'create' | Template | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const deleteMut = useMutation({
mutationFn: deleteTemplate,
onSuccess: () => { qc.invalidateQueries({ queryKey: ['templates'] }); setConfirmDelete(null); setDeleteError(null); },
onError: (e: unknown) => setDeleteError((e as { response?: { data?: { message?: string } } })?.response?.data?.message ?? 'Delete failed'),
});
const { data: templates = [], isLoading, isError } = useQuery<Template[]>({
queryKey: ['templates'],
queryFn: fetchTemplates,
});
return (
<div className={styles.container}>
<header className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.eyebrow}>Reusable Structures</span>
<h1>
Templates
{templates.length > 0 && <span className={styles.count}>{templates.length}</span>}
</h1>
</div>
<div className={styles.headerRight}>
<button className="btn btn-primary" onClick={() => setModal('create')}>
<Plus size={18} />
<span>Create Template</span>
</button>
</div>
</header>
{isLoading && <div className={styles.state}><Loader2 size={24} className={styles.spinner} /><span>Loading templates</span></div>}
{isError && <div className={styles.state}><AlertCircle size={20} /><span>Failed to load templates is the backend running?</span></div>}
{!isLoading && !isError && templates.length === 0 && (
<div className={styles.empty}>
<Layers3 size={40} className={styles.emptyIcon} />
<p>No templates yet.</p>
<p className={styles.emptyHint}>Templates define default video fields and description block structures.</p>
<button className="btn btn-primary" onClick={() => setModal('create')}>
<Plus size={18} />
<span>Create your first template</span>
</button>
</div>
)}
{!isLoading && !isError && templates.length > 0 && (
<div className={styles.grid}>
{templates.map((t) => (
<div key={t.id} className={styles.card}>
<div className={styles.cardTop}>
<div className={styles.cardIcon}><Layers3 size={20} /></div>
{t.active ? <span className="pill pill-primary">Active</span> : <span className="pill pill-blue">Inactive</span>}
</div>
<h3 className={styles.name}>{t.name}</h3>
{t.description && <p className={styles.desc}>{t.description}</p>}
<div className={styles.cardMeta}>
{t.videoFields && Object.keys(t.videoFields).length > 0 && (
<span className={styles.metaBadge}>{Object.keys(t.videoFields).length} video field{Object.keys(t.videoFields).length !== 1 ? 's' : ''}</span>
)}
{t.defaultBlocks?.length > 0 && (
<span className={styles.metaBadge}>{t.defaultBlocks.length} block{t.defaultBlocks.length !== 1 ? 's' : ''}</span>
)}
</div>
<div className={styles.cardFooter}>
{confirmDelete === t.id && deleteError && (
<div className={styles.deleteErrRow}>{deleteError}</div>
)}
<div className={styles.cardFooterRow}>
<span className={styles.version}>v{t.version}</span>
{confirmDelete === t.id ? (
<div className={styles.confirmDelete}>
{!deleteError && <span className={styles.confirmText}>Delete?</span>}
<button className={styles.confirmYes} onClick={() => deleteMut.mutate(t.id)} disabled={deleteMut.isPending}>
{deleteMut.isPending ? <Loader2 size={12} className={styles.spinner} /> : 'Yes'}
</button>
<button className={styles.confirmNo} onClick={() => { setConfirmDelete(null); setDeleteError(null); }}>
{deleteError ? 'OK' : 'No'}
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 'var(--space-3)', alignItems: 'center' }}>
<button className={styles.editBtn} onClick={() => setModal(t)}>Edit</button>
<button className={styles.trashBtn} onClick={() => { setConfirmDelete(t.id); setDeleteError(null); }}><Trash2 size={14} /></button>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
{modal !== null && (
<TemplateModal initial={modal === 'create' ? undefined : modal} onClose={() => setModal(null)} />
)}
</div>
);
}
@@ -0,0 +1,243 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-6);
max-width: 900px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
}
.title {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
}
.subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: var(--space-1);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.table th {
padding: var(--space-3) var(--space-5);
border-bottom: 1px solid var(--color-divider);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.table td {
padding: var(--space-3) var(--space-5);
border-bottom: 1px solid var(--color-divider);
font-size: var(--text-sm);
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: none;
}
.clickableRow {
cursor: pointer;
}
.clickableRow:hover {
background: var(--color-surface-offset);
}
.newRow td {
background: color-mix(in srgb, var(--color-primary), transparent 95%);
}
.nameCell {
font-weight: 600;
color: var(--color-text);
}
.valueCell {
color: var(--color-text-muted);
max-width: 400px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
font-family: var(--font-mono, monospace);
font-size: var(--text-xs);
background: var(--color-surface-offset);
padding: 2px 6px;
border-radius: var(--radius-sm);
color: var(--color-primary);
border: 1px solid var(--color-border);
}
.actions {
display: flex;
align-items: center;
gap: var(--space-2);
justify-content: flex-end;
}
.deleteBtn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: var(--color-text-faint);
transition: all 0.15s;
}
.deleteBtn:hover {
background: color-mix(in srgb, var(--color-error), transparent 85%);
color: var(--color-error);
}
.loading, .empty {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-4) 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
.hint {
font-size: var(--text-sm);
color: var(--color-text-muted);
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
line-height: 1.6;
}
.hint code {
font-family: var(--font-mono, monospace);
font-size: var(--text-xs);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 1px 5px;
color: var(--color-primary);
}
/* Render settings */
.settingsRow {
display: flex;
gap: var(--space-6);
padding: var(--space-5);
align-items: flex-start;
}
.settingsLabel {
flex: 0 0 260px;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.settingName {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.settingDesc {
font-size: var(--text-xs);
color: var(--color-text-faint);
line-height: 1.5;
}
.settingDesc code {
font-family: var(--font-mono, monospace);
font-size: 10px;
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0 4px;
color: var(--color-primary);
}
.settingsControl {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.dateFormatRow {
display: flex;
gap: var(--space-2);
align-items: center;
}
.formatPresets {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.presetChip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--text-xs);
color: var(--color-text-muted);
background: var(--color-surface);
transition: all 0.15s;
cursor: pointer;
}
.presetChip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.presetActive {
border-color: var(--color-primary);
background: var(--color-primary-highlight);
color: var(--color-primary);
}
.presetPreview {
font-family: var(--font-mono, monospace);
font-size: 10px;
color: var(--color-text-faint);
}
.presetActive .presetPreview {
color: var(--color-primary);
opacity: 0.7;
}
@@ -0,0 +1,304 @@
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Save, Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
import {
fetchTeamVariables, createTeamVariable, updateTeamVariable, deleteTeamVariable, fetchVariableUsage,
fetchTeamSettings, updateTeamSettings,
type TeamVariable,
} from '@/lib/api';
import { useAuthStore } from '@/store/useAuthStore';
import f from '@/components/shared/FormField.module.css';
import styles from './page.module.css';
interface EditRow {
id: string | null; // null = new unsaved row
name: string;
value: string;
}
const DATE_FORMAT_EXAMPLES = [
{ label: 'ISO (default)', value: 'YYYY-MM-DD', preview: '2026-05-24' },
{ label: 'German', value: 'DD.MM.YYYY', preview: '24.05.2026' },
{ label: 'US long', value: 'MMMM D, YYYY', preview: 'May 24, 2026' },
{ label: 'EU long', value: 'D MMMM YYYY', preview: '24 May 2026' },
{ label: 'Short month', value: 'D MMM YYYY', preview: '24 May 2026' },
];
function VariableUsageRow({ id }: { id: string }) {
const { data, isLoading } = useQuery({
queryKey: ['variable-usage', id],
queryFn: () => fetchVariableUsage(id),
});
if (isLoading) return <tr><td colSpan={4} style={{ paddingBottom: 'var(--space-2)', paddingLeft: 'var(--space-5)' }}><span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-faint)' }}>Loading usage</span></td></tr>;
const blocks = data?.blocks ?? [];
return (
<tr>
<td colSpan={4} style={{ paddingBottom: 'var(--space-3)', paddingLeft: 'var(--space-5)', paddingTop: 0 }}>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-faint)', display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap', alignItems: 'center' }}>
{blocks.length === 0
? <span style={{ fontStyle: 'italic' }}>Not referenced in any block.</span>
: <>
<span style={{ fontWeight: 600, color: 'var(--color-text-muted)' }}>Used in:</span>
{blocks.map((b) => (
<Link key={b.id} href="/blocks" style={{ background: 'var(--color-surface-offset)', border: '1px solid var(--color-divider)', borderRadius: 'var(--radius-sm)', padding: '1px 6px', color: 'inherit', textDecoration: 'none' }}
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
>{b.name}</Link>
))}
</>
}
</div>
</td>
</tr>
);
}
export default function VariablesPage() {
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
const teamId = user?.teamId;
const { data: variables = [], isLoading } = useQuery({
queryKey: ['team-variables'],
queryFn: fetchTeamVariables,
});
const { data: teamSettings } = useQuery({
queryKey: ['team-settings', teamId],
queryFn: () => fetchTeamSettings(teamId!),
enabled: !!teamId,
});
const [dateFormatInput, setDateFormatInput] = useState<string>('');
const [dateFormatDirty, setDateFormatDirty] = useState(false);
const settingsMut = useMutation({
mutationFn: (fmt: string | null) => updateTeamSettings(teamId!, { dateFormat: fmt || null }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['team-settings', teamId] });
setDateFormatDirty(false);
},
});
// Sync input with fetched settings (only on first load)
const effectiveDateFormat = dateFormatDirty ? dateFormatInput : (teamSettings?.dateFormat ?? '');
const [newRow, setNewRow] = useState<EditRow | null>(null);
const [editMap, setEditMap] = useState<Record<string, EditRow>>({});
const createMut = useMutation({
mutationFn: (row: EditRow) => createTeamVariable({ name: row.name, value: row.value }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['team-variables'] }); setNewRow(null); },
});
const updateMut = useMutation({
mutationFn: (row: EditRow) => updateTeamVariable(row.id!, { name: row.name, value: row.value }),
onSuccess: (_, row) => {
qc.invalidateQueries({ queryKey: ['team-variables'] });
setEditMap((prev) => { const next = { ...prev }; delete next[row.id!]; return next; });
},
});
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [usageOpenId, setUsageOpenId] = useState<string | null>(null);
const deleteMut = useMutation({
mutationFn: (id: string) => deleteTeamVariable(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['team-variables'] }); setConfirmDelete(null); },
});
const startEdit = (v: TeamVariable) => {
setEditMap((prev) => ({ ...prev, [v.id]: { id: v.id, name: v.name, value: v.value } }));
};
const cancelEdit = (id: string) => {
setEditMap((prev) => { const next = { ...prev }; delete next[id]; return next; });
};
return (
<div className={styles.container}>
<div className={styles.header}>
<div>
<h1 className={styles.title}>Global Variables</h1>
<p className={styles.subtitle}>
Team-wide defaults for description placeholders. Any video can override these per-video.
</p>
</div>
<button className="btn btn-primary" onClick={() => setNewRow({ id: null, name: '', value: '' })}>
<Plus size={16} /> Add Variable
</button>
</div>
<div className={styles.card}>
<table className={styles.table}>
<thead>
<tr>
<th>Variable Name</th>
<th>Value</th>
<th>Usage</th>
<th />
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={4} className={styles.loading}><Loader2 size={16} className={styles.spin} /> Loading</td></tr>
)}
{/* Existing rows */}
{variables.map((v) => {
const editing = editMap[v.id];
return (
<React.Fragment key={v.id}>
<tr onClick={() => !editing && startEdit(v)} className={!editing ? styles.clickableRow : ''}>
{editing ? (
<>
<td><input className={f.input} value={editing.name} onChange={(e) => setEditMap((prev) => ({ ...prev, [v.id]: { ...editing, name: e.target.value } }))} /></td>
<td><input className={f.input} value={editing.value} onChange={(e) => setEditMap((prev) => ({ ...prev, [v.id]: { ...editing, value: e.target.value } }))} /></td>
<td><code className={styles.placeholder}>{`{${editing.name || v.name}}`}</code></td>
<td className={styles.actions}>
<button className="btn btn-primary btn-sm" onClick={(e) => { e.stopPropagation(); updateMut.mutate(editing); }} disabled={updateMut.isPending}>
{updateMut.isPending ? <Loader2 size={13} className={styles.spin} /> : <Save size={13} />}
</button>
<button className="btn btn-ghost btn-sm" onClick={(e) => { e.stopPropagation(); cancelEdit(v.id); }}>Cancel</button>
{confirmDelete === v.id ? (
<>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>Sure?</span>
<button className={styles.deleteBtn} style={{ color: 'var(--color-error)' }} onClick={(e) => { e.stopPropagation(); deleteMut.mutate(v.id); }} disabled={deleteMut.isPending}>
{deleteMut.isPending ? <Loader2 size={13} className={styles.spin} /> : 'Yes'}
</button>
<button className="btn btn-ghost btn-sm" onClick={(e) => { e.stopPropagation(); setConfirmDelete(null); }}>No</button>
</>
) : (
<button className={styles.deleteBtn} onClick={(e) => { e.stopPropagation(); setConfirmDelete(v.id); }}>
<Trash2 size={14} />
</button>
)}
</td>
</>
) : (
<>
<td className={styles.nameCell}>{v.name}</td>
<td className={styles.valueCell}>{v.value}</td>
<td><code className={styles.placeholder}>{`{${v.name}}`}</code></td>
<td className={styles.actions}>
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-faint)' }}
onClick={(e) => { e.stopPropagation(); setUsageOpenId(usageOpenId === v.id ? null : v.id); }}
>
{usageOpenId === v.id ? 'Hide usage' : 'Where used?'}
</button>
{confirmDelete === v.id ? (
<>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>Sure?</span>
<button className={styles.deleteBtn} style={{ color: 'var(--color-error)' }} onClick={(e) => { e.stopPropagation(); deleteMut.mutate(v.id); }} disabled={deleteMut.isPending}>
{deleteMut.isPending ? <Loader2 size={13} className={styles.spin} /> : 'Yes'}
</button>
<button className="btn btn-ghost btn-sm" onClick={(e) => { e.stopPropagation(); setConfirmDelete(null); }}>No</button>
</>
) : (
<button className={styles.deleteBtn} onClick={(e) => { e.stopPropagation(); setConfirmDelete(v.id); }}>
<Trash2 size={14} />
</button>
)}
</td>
</>
)}
</tr>
{usageOpenId === v.id && <VariableUsageRow id={v.id} />}
</React.Fragment>
);
})}
{/* New row */}
{newRow && (
<tr className={styles.newRow}>
<td><input className={f.input} placeholder="variable_name" value={newRow.name} onChange={(e) => setNewRow({ ...newRow, name: e.target.value })} autoFocus /></td>
<td><input className={f.input} placeholder="Default value" value={newRow.value} onChange={(e) => setNewRow({ ...newRow, value: e.target.value })} /></td>
<td><code className={styles.placeholder}>{newRow.name ? `{${newRow.name}}` : '—'}</code></td>
<td className={styles.actions}>
<button className="btn btn-primary btn-sm" onClick={() => createMut.mutate(newRow)} disabled={!newRow.name || createMut.isPending}>
{createMut.isPending ? <Loader2 size={13} className={styles.spin} /> : <Save size={13} />}
Save
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setNewRow(null)}>Cancel</button>
</td>
</tr>
)}
{!isLoading && variables.length === 0 && !newRow && (
<tr>
<td colSpan={4} className={styles.empty}>
<AlertCircle size={16} />
No variables yet. Click &quot;Add Variable&quot; to create one.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className={styles.hint}>
<strong>How it works:</strong> Use <code>{`{variable_name}`}</code> in any block content or free text.
The global value fills in automatically. Videos can override individual variables in the description config.
</div>
{/* ── Team Render Settings ─────────────────────────────────── */}
<div className={styles.header} style={{ marginTop: 'var(--space-6)' }}>
<div>
<h1 className={styles.title}>Render Settings</h1>
<p className={styles.subtitle}>
Team-wide defaults for how the render engine formats values.
</p>
</div>
</div>
<div className={styles.card}>
<div className={styles.settingsRow}>
<div className={styles.settingsLabel}>
<span className={styles.settingName}>Default Date Format</span>
<span className={styles.settingDesc}>
Format used for <code>{'{video.scheduledAt}'}</code>, <code>{'{video.recordingDate}'}</code>, etc.
Override per-token with <code>{'{video.scheduledAt|DD.MM.YYYY}'}</code>.
Falls back to <code>YYYY-MM-DD</code> if not set.
</span>
</div>
<div className={styles.settingsControl}>
<div className={styles.dateFormatRow}>
<input
className={f.input}
value={effectiveDateFormat}
onChange={(e) => { setDateFormatInput(e.target.value); setDateFormatDirty(true); }}
placeholder="YYYY-MM-DD (default)"
style={{ flex: 1 }}
/>
<button
className="btn btn-primary btn-sm"
onClick={() => settingsMut.mutate(effectiveDateFormat || null)}
disabled={!dateFormatDirty || settingsMut.isPending}
>
{settingsMut.isPending ? <Loader2 size={13} className={styles.spin} /> : settingsMut.isSuccess && !dateFormatDirty ? <CheckCircle2 size={13} /> : <Save size={13} />}
Save
</button>
</div>
<div className={styles.formatPresets}>
{DATE_FORMAT_EXAMPLES.map((ex) => (
<button
key={ex.value}
className={`${styles.presetChip} ${effectiveDateFormat === ex.value ? styles.presetActive : ''}`}
onClick={() => { setDateFormatInput(ex.value); setDateFormatDirty(true); }}
title={`Preview: ${ex.preview}`}
>
{ex.label} <span className={styles.presetPreview}>{ex.preview}</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,325 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: var(--space-4);
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 820px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.modalTitle {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.closeBtn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
display: flex;
padding: var(--space-1);
border-radius: var(--radius-sm);
}
.closeBtn:hover { background: var(--color-bg); color: var(--color-text); }
/* ── Scrollable table container ── */
.tableContainer {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.loadingState, .emptyState {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-8);
color: var(--color-text-muted);
justify-content: center;
font-size: var(--text-sm);
}
/* ── Video table ── */
.videoTable {
width: 100%;
border-collapse: collapse;
}
.videoTable thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-surface);
border-bottom: 2px solid var(--color-border);
padding: var(--space-2) var(--space-4);
text-align: left;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.colCheck { width: 44px; }
.colThumb { width: 100px; }
.colMeta { /* grows */ }
.colExpand { width: 44px; }
.row {
border-bottom: 1px solid var(--color-border);
}
.row:hover {
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
}
.row td {
padding: var(--space-3) var(--space-4);
vertical-align: middle;
}
.rowDeselected {
opacity: 0.4;
}
.checkbox {
display: block;
cursor: pointer;
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
.thumb {
display: block;
border-radius: var(--radius-sm);
object-fit: cover;
}
.colMeta td, td.colMeta {
vertical-align: middle;
}
.rowTitle {
display: block;
font-weight: 600;
font-size: var(--text-sm);
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 480px;
margin-bottom: var(--space-1);
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.fieldChip {
font-size: var(--text-xs);
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
white-space: nowrap;
}
.chipFirstSync {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.expandBtn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
display: flex;
padding: var(--space-1);
border-radius: var(--radius-sm);
}
.expandBtn:hover { color: var(--color-text); }
/* ── Diff panel row ── */
.diffRow td {
padding: 0;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.diffCell {
padding: var(--space-3) var(--space-4) !important;
}
/* ── Diff table (nested) ── */
.diffTable {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
table-layout: fixed;
}
.diffTable thead tr {
border-bottom: 1px solid var(--color-border);
}
.diffTable th, .diffTable td {
padding: var(--space-2) var(--space-3);
text-align: left;
vertical-align: top;
}
.diffField {
width: 140px;
color: var(--color-text-muted);
font-weight: 600;
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.diffBefore {
color: var(--color-text-muted);
width: calc(50% - 70px);
}
.diffAfter {
color: var(--color-text);
font-weight: 500;
width: calc(50% - 70px);
}
.diffTable tbody tr:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
.descPre {
font-family: inherit;
font-size: var(--text-xs);
white-space: pre-wrap;
word-break: break-word;
max-height: 180px;
overflow-y: auto;
margin: 0;
padding: var(--space-2);
background: var(--color-surface);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
/* ── Tags diff ── */
.tagsDiff {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tagKept, .tagRemoved, .tagAdded {
font-size: var(--text-xs);
padding: 2px 7px;
border-radius: var(--radius-full);
}
.tagKept {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.tagRemoved {
background: color-mix(in srgb, var(--color-error) 12%, transparent);
border: 1px solid var(--color-error);
color: var(--color-error);
text-decoration: line-through;
}
.tagAdded {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
border: 1px solid var(--color-success);
color: var(--color-success);
}
/* ── Footer ── */
.modalFooter {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
.selectAll {
background: none;
border: none;
padding: 0;
font-size: var(--text-sm);
color: var(--color-primary);
cursor: pointer;
white-space: nowrap;
}
.selectAll:hover {
text-decoration: underline;
}
.footerNote {
flex: 1;
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.footerActions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,246 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { Loader2, X, ChevronDown, ChevronRight } from 'lucide-react';
import {
fetchPushPendingPreview, confirmBulkPush,
type PushPendingPreviewItem,
} from '@/lib/api';
import styles from './PushPendingModal.module.css';
const FIELD_LABELS: Record<string, string> = {
title: 'Title', description: 'Description', privacyStatus: 'Visibility',
tags: 'Tags', categoryId: 'Category', defaultLanguage: 'Language',
defaultAudioLanguage: 'Audio Language', selfDeclaredMadeForKids: 'Made for Kids',
embeddable: 'Embeddable', license: 'License', recordingDate: 'Recording Date',
};
function FieldChip({ field }: { field: string }) {
return <span className={styles.fieldChip}>{FIELD_LABELS[field] ?? field}</span>;
}
function TagsDiff({ before, after }: { before: string[]; after: string[] }) {
const beforeSet = new Set(before);
const afterSet = new Set(after);
const removed = before.filter((t) => !afterSet.has(t));
const added = after.filter((t) => !beforeSet.has(t));
const kept = after.filter((t) => beforeSet.has(t));
return (
<div className={styles.tagsDiff}>
{kept.map((t) => <span key={t} className={styles.tagKept}>{t}</span>)}
{removed.map((t) => <span key={t} className={styles.tagRemoved}>{t}</span>)}
{added.map((t) => <span key={t} className={styles.tagAdded}>{t}</span>)}
</div>
);
}
function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem }) {
const label = FIELD_LABELS[field] ?? field;
const diff = item.diff as any;
const d = diff[field];
if (!d) return null;
if (field === 'tags') {
return (
<tr>
<td className={styles.diffField}>{label}</td>
<td className={styles.diffBefore}>{(d.before as string[]).join(', ') || '—'}</td>
<td className={styles.diffAfter}><TagsDiff before={d.before} after={d.after} /></td>
</tr>
);
}
if (field === 'description') {
return (
<tr>
<td className={styles.diffField}>{label}</td>
<td className={styles.diffBefore}><pre className={styles.descPre}>{d.before ?? '—'}</pre></td>
<td className={styles.diffAfter}><pre className={styles.descPre}>{d.after ?? '—'}</pre></td>
</tr>
);
}
const fmt = (v: any) => {
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
return String(v);
};
return (
<tr>
<td className={styles.diffField}>{label}</td>
<td className={styles.diffBefore}>{fmt(d.before)}</td>
<td className={styles.diffAfter}>{fmt(d.after)}</td>
</tr>
);
}
function VideoRow({
item,
selected,
onToggle,
}: {
item: PushPendingPreviewItem;
selected: boolean;
onToggle: () => void;
}) {
const [expanded, setExpanded] = useState(false);
const thumb = item.thumbnailUrl ?? `https://img.youtube.com/vi/${item.videoId}/mqdefault.jpg`;
return (
<>
<tr className={`${styles.row} ${!selected ? styles.rowDeselected : ''}`}>
<td className={styles.colCheck}>
<input type="checkbox" checked={selected} onChange={onToggle} className={styles.checkbox} />
</td>
<td className={styles.colThumb}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={thumb} alt="" width={80} height={45} className={styles.thumb} />
</td>
<td className={styles.colMeta}>
<span className={styles.rowTitle}>{item.title}</span>
<div className={styles.chips}>
{item.firstSync
? <span className={`${styles.fieldChip} ${styles.chipFirstSync}`}>First sync</span>
: item.changedFields.map((f) => <FieldChip key={f} field={f} />)
}
</div>
</td>
<td className={styles.colExpand}>
<button className={styles.expandBtn} onClick={() => setExpanded((e) => !e)}>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
</td>
</tr>
{expanded && (
<tr className={styles.diffRow}>
<td colSpan={4} className={styles.diffCell}>
<table className={styles.diffTable}>
<thead>
<tr>
<th className={styles.diffField}>Field</th>
<th className={styles.diffBefore}>Before</th>
<th className={styles.diffAfter}>After</th>
</tr>
</thead>
<tbody>
{item.changedFields.map((f) => (
<DiffRow key={f} field={f} item={item} />
))}
</tbody>
</table>
</td>
</tr>
)}
</>
);
}
export default function PushPendingModal({ onClose, sort, order }: { onClose: () => void; sort?: string; order?: string }) {
const router = useRouter();
const { data: items = [], isLoading } = useQuery({
queryKey: ['pushPendingPreview', sort, order],
queryFn: () => fetchPushPendingPreview(sort, order),
staleTime: 30_000,
});
// null = untouched (treat as all selected); Set = explicit selection state
const [selected, setSelected] = useState<Set<string> | null>(null);
const effectiveSelected = useMemo(
() => selected ?? new Set(items.map((i) => i.videoId)),
[items, selected],
);
const allSelected = items.length > 0 && effectiveSelected.size === items.length;
const toggleAll = () => {
if (allSelected) setSelected(new Set());
else setSelected(new Set(items.map((i) => i.videoId)));
};
const toggleOne = (id: string) => {
const next = new Set(effectiveSelected);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelected(next);
};
const confirmMut = useMutation({
mutationFn: () => confirmBulkPush([...effectiveSelected]),
onSuccess: () => {
setSelected(null);
onClose();
router.push('/bulk-jobs');
},
});
const selectedCount = effectiveSelected.size;
return (
<div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Push Pending Videos</h2>
<button className={styles.closeBtn} onClick={onClose}><X size={18} /></button>
</div>
<div className={styles.tableContainer}>
{isLoading && (
<div className={styles.loadingState}>
<Loader2 size={20} className={styles.spinner} />
<span>Loading pending changes</span>
</div>
)}
{!isLoading && items.length === 0 && (
<div className={styles.emptyState}>No pending videos found.</div>
)}
{!isLoading && items.length > 0 && (
<table className={styles.videoTable}>
<thead>
<tr>
<th className={styles.colCheck}></th>
<th className={styles.colThumb}></th>
<th className={styles.colMeta}>Video</th>
<th className={styles.colExpand}></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<VideoRow
key={item.videoId}
item={item}
selected={effectiveSelected.has(item.videoId)}
onToggle={() => toggleOne(item.videoId)}
/>
))}
</tbody>
</table>
)}
</div>
{!isLoading && items.length > 0 && (
<div className={styles.modalFooter}>
<button className={styles.selectAll} onClick={toggleAll}>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
<span className={styles.footerNote}>
Videos are pushed in order, respecting YouTube quota limits.
</span>
<div className={styles.footerActions}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button
className="btn btn-primary"
disabled={selectedCount === 0 || confirmMut.isPending}
onClick={() => confirmMut.mutate()}
>
{confirmMut.isPending ? <Loader2 size={14} className={styles.spinner} /> : null}
Push {selectedCount} video{selectedCount !== 1 ? 's' : ''}
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,730 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-6);
width: 100%;
min-width: 0;
}
.backBtn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
transition: color 0.15s;
align-self: flex-start;
}
.backBtn:hover {
color: var(--color-text);
}
/* Combined video header card */
.videoHeader {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--space-4) var(--space-5);
display: flex;
align-items: flex-start;
gap: var(--space-5);
min-width: 0;
}
.headerThumb {
width: 160px;
flex-shrink: 0;
border-radius: var(--radius-md);
overflow: hidden;
aspect-ratio: 16 / 9;
background: var(--color-surface-offset);
align-self: flex-start;
}
.headerThumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.headerBody {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.headerTitle {
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.headerMeta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.headerMetaItem {
display: inline-flex;
align-items: center;
gap: 4px;
}
.headerMetaDot {
color: var(--color-text-faint);
}
.headerLinks {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.headerLink {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--color-text-faint);
transition: color 0.15s;
}
.headerLink:hover {
color: var(--color-primary);
}
.lintBadgeRow {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.lintBadgeBtn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);
border-radius: var(--radius-full);
padding: 2px 8px;
cursor: pointer;
transition: background 0.15s;
}
.lintBadgeBtn:hover {
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
}
.lintExpandList {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.lintExpandItem {
display: flex;
align-items: flex-start;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
line-height: 1.4;
}
.lintCleanBadge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-success, #22c55e);
}
.headerActions {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-2);
}
.headerBtns {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
justify-content: flex-end;
}
.formCard {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-5);
box-shadow: var(--shadow-sm);
}
.sectionTitle {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.syncPending {
display: flex;
align-items: center;
gap: 4px;
color: var(--color-warning);
font-weight: 600;
}
.syncClean {
display: flex;
align-items: center;
gap: 4px;
color: var(--color-success, #22c55e);
font-weight: 600;
}
.syncTime {
color: var(--color-text-faint);
}
.diffToggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-faint);
background: none;
border: none;
padding: 0;
cursor: pointer;
margin-left: auto;
transition: color 0.15s;
}
.diffToggle:hover {
color: var(--color-text-muted);
}
.diffPanel {
display: flex;
flex-direction: column;
gap: var(--space-4);
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-top: var(--space-2);
}
.diffPanelCol {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.diffPanelLabel {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.diffPanelText {
font-family: var(--font-body);
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
margin: 0;
max-height: 300px;
overflow-y: auto;
}
.diffPanelDivider {
background: var(--color-divider);
width: 1px;
}
.successBanner {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-success, #22c55e), transparent 88%);
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e), transparent 70%);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-success, #22c55e);
}
.errorBanner {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-error), transparent 88%);
border: 1px solid color-mix(in srgb, var(--color-error), transparent 70%);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-error);
}
.descPreview {
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
padding: var(--space-4);
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--color-text-muted);
white-space: pre-wrap;
line-height: 1.6;
max-height: 400px;
overflow-y: auto;
}
.state {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* Two independent flex columns — no row-height coupling */
.twoCol {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
align-items: start;
min-width: 0;
}
.col {
display: flex;
flex-direction: column;
gap: var(--space-6);
min-width: 0;
}
.colEmpty {
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
}
.colEmptyHint {
font-size: var(--text-sm);
color: var(--color-text-faint);
pointer-events: none;
}
/* Toggle / checkbox row */
.toggleRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) 0;
font-size: var(--text-sm);
}
.toggleLabel {
display: flex;
flex-direction: column;
gap: 2px;
}
.toggleHint {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.badgeYes {
font-size: var(--text-xs);
font-weight: 600;
padding: 2px 8px;
border-radius: var(--radius-full, 999px);
background: color-mix(in srgb, var(--color-error) 12%, transparent);
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
white-space: nowrap;
flex-shrink: 0;
}
.badgeNo {
font-size: var(--text-xs);
font-weight: 600;
padding: 2px 8px;
border-radius: var(--radius-full, 999px);
background: var(--color-surface-offset);
color: var(--color-text-faint);
border: 1px solid var(--color-border);
white-space: nowrap;
flex-shrink: 0;
}
/* Playlist panel */
/* ── Playlist chips (current playlists) ── */
.playlistChips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.playlistChip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 4px 10px 4px 12px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-text);
max-width: 260px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlistChipRemove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
padding: 0;
transition: background 0.1s, color 0.1s;
}
.playlistChipRemove:hover { background: color-mix(in srgb, var(--color-error) 15%, transparent); color: var(--color-error); }
.playlistChipRemove:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Playlist search dropdown ── */
.playlistDropdownWrap {
position: relative;
}
.playlistSearchLabel {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text-muted);
cursor: text;
transition: border-color 0.15s;
}
.playlistSearchLabel:focus-within {
border-color: var(--color-primary);
color: var(--color-text);
}
.playlistSearchInput {
flex: 1;
border: none;
background: none;
outline: none;
font-size: var(--text-sm);
color: var(--color-text);
}
.playlistSearchInput::placeholder { color: var(--color-text-faint); }
.playlistDropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
z-index: 50;
max-height: 240px;
overflow-y: auto;
}
.playlistDropdownItem {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
background: none;
text-align: left;
cursor: pointer;
gap: var(--space-3);
transition: background 0.1s;
}
.playlistDropdownItem:hover { background: var(--color-primary-highlight); }
.playlistDropdownItem:disabled { opacity: 0.5; cursor: not-allowed; }
.playlistDropdownItem + .playlistDropdownItem { border-top: 1px solid var(--color-divider); }
.playlistDropdownName {
font-size: var(--text-sm);
color: var(--color-text);
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlistDropdownCount {
font-size: var(--text-xs);
color: var(--color-text-faint);
flex-shrink: 0;
}
.playlistDropdownEmpty {
padding: var(--space-4) var(--space-3);
font-size: var(--text-sm);
color: var(--color-text-faint);
text-align: center;
font-style: italic;
}
.emptyHint {
font-size: var(--text-sm);
color: var(--color-text-faint);
text-align: center;
padding: var(--space-4);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 1s linear infinite; }
/* Apply template modal */
.applyModal {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.applyField {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.applyLabel {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.applySelect {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
outline: none;
}
.applySelect:focus { border-color: var(--color-primary); }
.applyOptions {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.applyCheckRow {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
}
.applyCheckRow input[type="checkbox"] {
margin-top: 2px;
flex-shrink: 0;
accent-color: var(--color-primary);
}
.applyOptLabel {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.applyOptHint {
font-size: var(--text-xs);
color: var(--color-text-faint);
margin-top: 2px;
line-height: 1.4;
}
.applyHint {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
/* Diff table */
.diffTable {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
font-size: var(--text-xs);
}
.diffHeader {
display: grid;
grid-template-columns: 150px 1fr 1fr;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-offset);
font-weight: 700;
color: var(--color-text-faint);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--color-border);
}
.diffRow {
display: grid;
grid-template-columns: 150px 1fr 1fr;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-divider);
align-items: center;
}
.diffRow:last-child { border-bottom: none; }
.diffRowChanged {
background: color-mix(in srgb, var(--color-warning, #f59e0b), transparent 93%);
}
.diffKey {
font-weight: 600;
color: var(--color-text);
}
.diffCurrent {
color: var(--color-text-muted);
word-break: break-word;
}
.diffNew {
color: var(--color-primary);
font-weight: 600;
word-break: break-word;
}
/* Tag chips (video editor) */
.tagChips {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
min-height: 0;
}
.tagChips:empty { margin-bottom: 0; }
.tagChip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: var(--radius-full);
background: var(--color-primary-highlight);
border: 1px solid color-mix(in srgb, var(--color-primary), transparent 60%);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-primary);
}
.tagChip button {
display: flex;
align-items: center;
color: var(--color-primary);
opacity: 0.7;
transition: opacity 0.1s;
}
.tagChip button:hover { opacity: 1; }
/* ── Scheduling ── */
.scheduleInputRow {
display: flex;
gap: var(--space-2);
align-items: center;
}
.scheduleInputRow input {
flex: 1;
min-width: 0;
}
.nextSlotBtn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
padding: var(--space-1) var(--space-3);
white-space: nowrap;
flex-shrink: 0;
}
.slotMsg {
display: block;
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--color-text-muted);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,175 @@
.container {
display: flex;
flex-direction: column;
gap: var(--space-8);
max-width: 1400px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.eyebrow {
display: block;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
text-transform: uppercase;
letter-spacing: .08em;
margin-bottom: var(--space-1);
}
.headerLeft h1 {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
line-height: 1.1;
}
.headerRight {
display: flex;
gap: var(--space-3);
}
.filterBar {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--color-divider);
padding-bottom: 2px;
}
.viewTabs {
display: flex;
gap: var(--space-4);
}
.tab, .tabActive {
padding: var(--space-3) 0;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-faint);
position: relative;
transition: color 0.2s;
}
.tab:hover {
color: var(--color-text);
}
.tabActive {
color: var(--color-primary);
}
.tabActive::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--color-primary);
}
.tabAdd {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
color: var(--color-text-faint);
background: none;
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
margin-left: var(--space-1);
}
.tabAdd:hover { color: var(--color-primary); border-color: var(--color-primary); }
.filterBtn {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: 600;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
}
.filterBtn:hover {
background: var(--color-surface-offset);
}
.searchBar {
display: flex;
align-items: center;
gap: var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.4rem 0.75rem;
color: var(--color-text-faint);
}
.searchBar input {
background: none;
border: none;
outline: none;
color: var(--color-text);
font-size: var(--text-sm);
width: 200px;
}
.count {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: var(--space-3);
padding: 0.1rem 0.5rem;
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
vertical-align: middle;
}
.state {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) 0;
}
.pageInfo {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.pageButtons {
display: flex;
align-items: center;
gap: var(--space-2);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}
@@ -0,0 +1,451 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams, useRouter } from 'next/navigation';
import { Download, Loader2, AlertCircle, RefreshCw, CheckCircle2, X, Plus } from 'lucide-react';
import { type SortingState, type ColumnVisibilityState } from '@tanstack/react-table';
import VideoTable, { VideoRow, VIDEO_COLUMN_DEFS } from '@/components/video-table/VideoTable';
import ColumnPicker from '@/components/shared/ColumnPicker';
import SavedViewManager from '@/components/shared/SavedViewManager';
import {
fetchVideos, triggerChannelImport, fetchMyChannels, exportCsv, fetchTeamSettings,
fetchSavedViewTabs, type Video, type VideosQuery,
} from '@/lib/api';
import { useColumnPreferences } from '@/hooks/useColumnPreferences';
import PushPendingModal from './PushPendingModal';
import { useAuthStore } from '@/store/useAuthStore';
import styles from './page.module.css';
const DEFAULTS = {
visible: ['youtubeVideoId', 'title', 'privacyStatus', 'publishedAt', 'lintStatus', 'playlists', 'syncStatus'],
order: ['youtubeVideoId', 'title', 'privacyStatus', 'publishedAt', 'lintStatus', 'playlists', 'syncStatus'],
};
const COLUMN_TO_FIELD: Record<string, string> = {
title: 'title',
privacyStatus: 'privacyStatus',
publishedAt: 'publishedAt',
lintStatus: 'lintStatus',
syncStatus: 'lastSyncedAt',
categoryId: 'categoryId',
defaultLanguage: 'defaultLanguage',
scheduledAt: 'scheduledAt',
embeddable: 'embeddable',
license: 'license',
selfDeclaredMadeForKids: 'selfDeclaredMadeForKids',
recordingDate: 'recordingDate',
updatedAt: 'updatedAt',
remoteConflict: 'remoteConflict',
};
// ─── System tabs ──────────────────────────────────────────────────────────────
interface SystemTab {
id: string;
label: string;
query: Partial<VideosQuery>;
conditional?: string;
}
const SYSTEM_TABS: SystemTab[] = [
{ id: 'all', label: 'All Content', query: {} },
{ id: 'published', label: 'Published', query: { privacyStatus: 'PUBLIC' } },
{ id: 'private', label: 'Private', query: { privacyStatus: 'PRIVATE', notScheduled: true } },
{ id: 'scheduled', label: 'Scheduled', query: { scheduled: true } },
{ id: 'conflicts', label: 'Conflicts', query: { remoteConflict: true } },
{ id: 'pushPending', label: 'Push Pending', query: { pendingSync: true } },
{ id: 'lintErrors', label: 'Lint Issues', query: { hasLintIssues: true } },
{ id: 'deleted', label: 'Deleted', query: { deletedOnYouTube: true }, conditional: 'showDeletedVideos' },
];
// ─── Video mapping ─────────────────────────────────────────────────────────────
function toVideoRow(v: Video): VideoRow {
const syncStatus: VideoRow['syncStatus'] = v.remoteConflict
? 'conflict'
: v.hasPendingChanges
? 'pending'
: 'synced';
return {
id: v.id,
youtubeVideoId: v.youtubeVideoId,
thumbnailUrl: v.thumbnailUrl ?? null,
title: v.title,
description: v.renderedDescription ?? '',
channelId: v.channelId,
publishedAt: v.publishedAt ?? v.createdAt,
scheduledAt: v.scheduledAt ?? null,
privacyStatus: v.privacyStatus,
lintStatus: v.lintStatus,
syncStatus,
lastSyncedAt: v.lastSyncedAt,
youtubeDeletedAt: v.youtubeDeletedAt ?? null,
playlists: (v.playlists ?? []).map((vp) => vp.playlist),
tags: v.tags,
categoryId: v.categoryId ?? null,
defaultLanguage: v.defaultLanguage ?? null,
embeddable: v.embeddable,
license: v.license ?? null,
selfDeclaredMadeForKids: v.selfDeclaredMadeForKids,
recordingDate: v.recordingDate ?? null,
updatedAt: v.updatedAt,
remoteConflict: v.remoteConflict,
};
}
// ─── Filter keys that live in URL params ──────────────────────────────────────
const EXTRA_FILTER_KEYS = [
'tagsSearch', 'categoryId', 'defaultLanguage', 'embeddable', 'license', 'selfDeclaredMadeForKids',
] as const;
function parseExtraFilters(searchParams: URLSearchParams): Partial<VideosQuery> {
const result: Partial<VideosQuery> = {};
for (const k of EXTRA_FILTER_KEYS) {
const v = searchParams.get(k);
if (v !== null) {
if (k === 'embeddable' || k === 'selfDeclaredMadeForKids') {
(result as any)[k] = v === 'true';
} else {
(result as any)[k] = v;
}
}
}
return result;
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function VideosPage() {
const searchParams = useSearchParams();
const router = useRouter();
const [selectedChannelId, setSelectedChannelId] = useState<string>('');
const [page, setPage] = useState(1);
const [sorting, setSorting] = useState<SortingState>([{ id: 'publishedAt', desc: true }]);
const [pushModalOpen, setPushModalOpen] = useState(false);
const [viewManagerOpen, setViewManagerOpen] = useState(false);
const LIMIT = 50;
const user = useAuthStore((s) => s.user);
const queryClient = useQueryClient();
const { visible, order, setColumns } = useColumnPreferences('videos');
const columnVisibility = useMemo<ColumnVisibilityState>(() => {
const allIds = VIDEO_COLUMN_DEFS.map((c) => c.id);
return Object.fromEntries(allIds.map((id) => [id, visible.includes(id)]));
}, [visible]);
// ── Active tab ─────────────────────────────────────────────────────────────
const tabParam = searchParams.get('tab') ?? 'all';
const { data: teamSettings } = useQuery({
queryKey: ['teamSettings', user?.teamId],
queryFn: () => fetchTeamSettings(user!.teamId),
enabled: !!user?.teamId,
staleTime: 60_000,
});
const { data: savedViewTabs = [] } = useQuery({
queryKey: ['savedViewTabs'],
queryFn: fetchSavedViewTabs,
staleTime: 60_000,
enabled: !!user?.teamId,
});
const visibleSystemTabs = SYSTEM_TABS.filter((t) => {
if (t.conditional === 'showDeletedVideos') return teamSettings?.showDeletedVideos;
return true;
});
// Find the active tab's base query
const activeSystemTab = visibleSystemTabs.find((t) => t.id === tabParam);
const activeSavedView = savedViewTabs.find((v) => v.id === tabParam);
const baseQuery: Partial<VideosQuery> = activeSystemTab?.query
?? (activeSavedView?.queryJson as Partial<VideosQuery> ?? {});
const setActiveTab = useCallback((tabId: string) => {
const params = new URLSearchParams();
params.set('tab', tabId);
router.replace(`/videos?${params.toString()}`);
setPage(1);
}, [router]);
// ── Extra filters from URL ─────────────────────────────────────────────────
const extraFilters = useMemo(() => parseExtraFilters(searchParams), [searchParams]);
const handleFiltersChange = useCallback((patch: Partial<VideosQuery>) => {
const params = new URLSearchParams(searchParams.toString());
// Update only the extra filter keys in the URL
for (const [k, v] of Object.entries(patch)) {
if (v === undefined || v === null || v === '') {
params.delete(k);
} else {
params.set(k, String(v));
}
}
// Also handle search/privacyStatus/lintStatus/pendingSync/remoteConflict as URL params
router.replace(`/videos?${params.toString()}`);
setPage(1);
}, [searchParams, router]);
const clearExtraFilters = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
for (const k of EXTRA_FILTER_KEYS) params.delete(k);
params.delete('pendingSync'); params.delete('remoteConflict');
params.delete('lintStatus'); params.delete('privacyStatus'); params.delete('search');
router.replace(`/videos?${params.toString()}`);
}, [searchParams, router]);
// Build combined filters for API (base tab query + extra overlays)
const urlSearch = searchParams.get('search') ?? undefined;
const urlLintStatus = searchParams.get('lintStatus') ?? undefined;
const urlPrivacyStatus = searchParams.get('privacyStatus') ?? undefined;
const urlPendingSync = searchParams.get('pendingSync') === 'true' ? true : undefined;
const urlRemoteConflict = searchParams.get('remoteConflict') === 'true' ? true : undefined;
const combinedFilters: Partial<VideosQuery> = {
...baseQuery,
...extraFilters,
...(urlSearch ? { search: urlSearch } : {}),
...(urlLintStatus ? { lintStatus: urlLintStatus } : {}),
...(urlPrivacyStatus ? { privacyStatus: urlPrivacyStatus } : {}),
...(urlPendingSync ? { pendingSync: true } : {}),
...(urlRemoteConflict ? { remoteConflict: true } : {}),
};
// A unified "display" filters object for the filter panel — merges tab base + overlays
const displayFilters: Partial<VideosQuery> = {
search: urlSearch,
lintStatus: urlLintStatus ?? (baseQuery.lintStatus as string | undefined),
privacyStatus: urlPrivacyStatus ?? (baseQuery.privacyStatus as string | undefined),
pendingSync: urlPendingSync ?? (baseQuery.pendingSync as boolean | undefined),
remoteConflict: urlRemoteConflict ?? (baseQuery.remoteConflict as boolean | undefined),
...extraFilters,
};
const sortField = sorting[0] ? (COLUMN_TO_FIELD[sorting[0].id] ?? sorting[0].id) : 'publishedAt';
const sortOrder = sorting[0] ? (sorting[0].desc ? 'desc' : 'asc') : 'desc';
// Reset page on filter/sort changes
useEffect(() => { setPage(1); }, [tabParam, sortField, sortOrder]);
const { data, isLoading, isError } = useQuery({
queryKey: ['videos', tabParam, combinedFilters, page, sortField, sortOrder],
queryFn: () => fetchVideos({ ...combinedFilters, limit: LIMIT, page, sort: sortField, order: sortOrder }),
placeholderData: (prev) => prev,
});
const { data: channels = [] } = useQuery({
queryKey: ['channels', user?.teamId],
queryFn: () => fetchMyChannels(user!.teamId),
enabled: !!user?.teamId,
staleTime: 60_000,
});
const importMutation = useMutation({
mutationFn: () => triggerChannelImport(selectedChannelId || channels[0]?.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['videos'] }),
});
const rows = (data?.items ?? []).map(toVideoRow);
const totalPages = data ? Math.ceil(data.total / LIMIT) : 1;
const activeChannelId = selectedChannelId || channels[0]?.id;
const isPushPendingTab = tabParam === 'pushPending';
// ── Column picker state ────────────────────────────────────────────────────
const currentColumnsJson = useMemo(() => ({ visible, order }), [visible, order]);
const currentSortJson = useMemo(() => sorting.length ? { id: sorting[0].id, desc: sorting[0].desc } : null, [sorting]);
return (
<div className={styles.container}>
<header className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.eyebrow}>Inventory Management</span>
<h1>Channel Videos{data && <span className={styles.count}>{data.total}</span>}</h1>
</div>
<div className={styles.headerRight}>
<ColumnPicker
columns={VIDEO_COLUMN_DEFS}
visible={visible}
order={order}
onChange={(v, o) => setColumns(v, o)}
defaultVisible={DEFAULTS.visible}
defaultOrder={DEFAULTS.order}
/>
<button className="btn btn-secondary" onClick={() => exportCsv()}>
<Download size={18} />
<span>Export CSV</span>
</button>
{channels.length > 1 && (
<select
value={selectedChannelId}
onChange={(e) => setSelectedChannelId(e.target.value)}
className="btn btn-secondary"
style={{ cursor: 'pointer' }}
>
{channels.map((ch) => (
<option key={ch.id} value={ch.id}>{ch.name}</option>
))}
</select>
)}
<button
className="btn btn-secondary"
onClick={() => importMutation.mutate()}
disabled={importMutation.isPending || !activeChannelId}
title="Pull all videos from the selected YouTube channel"
>
{importMutation.isPending
? <Loader2 size={18} className={styles.spinner} />
: importMutation.isSuccess
? <CheckCircle2 size={18} />
: <RefreshCw size={18} />}
<span>
{importMutation.isPending
? 'Importing…'
: importMutation.isSuccess
? `Done (${importMutation.data?.created} new, ${importMutation.data?.updated} updated${(importMutation.data?.deleted ?? 0) > 0 ? `, ${importMutation.data!.deleted} deleted` : ''})`
: 'Import from YouTube'}
</span>
</button>
</div>
</header>
<div className={styles.filterBar}>
<div className={styles.viewTabs}>
{visibleSystemTabs.map((tab) => (
<button
key={tab.id}
className={tabParam === tab.id ? styles.tabActive : styles.tab}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
{savedViewTabs.map((view) => (
<button
key={view.id}
className={tabParam === view.id ? styles.tabActive : styles.tab}
onClick={() => setActiveTab(view.id)}
>
{view.name}
</button>
))}
<button
className={styles.tabAdd}
onClick={() => setViewManagerOpen(true)}
title="Manage saved views"
>
<Plus size={14} />
</button>
</div>
{isPushPendingTab && data && data.total > 0 && (
<button className="btn btn-primary" onClick={() => setPushModalOpen(true)}>
Push pending
</button>
)}
</div>
{pushModalOpen && <PushPendingModal onClose={() => setPushModalOpen(false)} sort={sortField} order={sortOrder} />}
{viewManagerOpen && (
<SavedViewManager
onClose={() => setViewManagerOpen(false)}
currentFilters={combinedFilters}
currentColumnsJson={currentColumnsJson}
currentSortJson={currentSortJson}
/>
)}
{isLoading && (
<div className={styles.state}>
<Loader2 size={24} className={styles.spinner} />
<span>Loading videos</span>
</div>
)}
{isError && (
<div className={styles.state}>
<AlertCircle size={20} />
<span>Failed to load videos is the backend running?</span>
</div>
)}
{!isLoading && !isError && rows.length === 0 && (
<div className={styles.state}>
<span>No videos found.</span>
<button
className="btn btn-primary"
onClick={() => importMutation.mutate()}
disabled={importMutation.isPending || !activeChannelId}
>
{importMutation.isPending ? <Loader2 size={18} className={styles.spinner} /> : <RefreshCw size={18} />}
<span>{importMutation.isPending ? 'Importing…' : 'Import from YouTube'}</span>
</button>
</div>
)}
{!isLoading && !isError && rows.length > 0 && (
<>
<VideoTable
data={rows}
sorting={sorting}
onSortingChange={setSorting}
columnVisibility={columnVisibility}
onColumnVisibilityChange={(v) => {
const nextVisible = VIDEO_COLUMN_DEFS.filter((c) => v[c.id] !== false).map((c) => c.id);
setColumns(nextVisible, order);
}}
columnOrder={order}
filters={displayFilters}
onFiltersChange={handleFiltersChange}
/>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}>
Page {page} of {totalPages} · {data!.total} videos
</span>
<div className={styles.pageButtons}>
<button
className="btn btn-secondary"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
const p = totalPages <= 7 ? i + 1 : (() => {
if (i === 0) return 1;
if (i === 6) return totalPages;
if (page <= 4) return i + 1;
if (page >= totalPages - 3) return totalPages - 6 + i;
return page - 2 + i;
})();
return (
<button
key={p}
className={`btn ${page === p ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setPage(p)}
style={{ minWidth: 36 }}
>
{p}
</button>
);
})}
<button
className="btn btn-secondary"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,49 @@
'use client';
import { useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/useAuthStore';
import apiClient from '@/lib/api-client';
export default function CallbackHandler() {
const searchParams = useSearchParams();
const router = useRouter();
const setAuth = useAuthStore((s) => s.setAuth);
useEffect(() => {
const token = searchParams.get('token');
if (!token) {
router.replace('/login');
return;
}
apiClient
.get('/auth/me', { headers: { Authorization: `Bearer ${token}` } })
.then(({ data }) => {
setAuth(token, data);
const secure = location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `sf_session=1; path=/; SameSite=Lax${secure}`;
router.replace('/');
})
.catch(() => {
router.replace('/login');
});
}, [searchParams, router, setAuth]);
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--font-body)',
color: 'var(--color-text-muted)',
fontSize: 'var(--text-sm)',
background: 'var(--color-bg)',
}}
>
Signing you in
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from 'react';
import CallbackHandler from './CallbackHandler';
export default function AuthCallbackPage() {
return (
<Suspense>
<CallbackHandler />
</Suspense>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import "@/styles/globals.css";
export const metadata: Metadata = {
title: "StudioFlow | YouTube Upload Manager",
description: "Centralized metadata management for YouTube creators",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
{children}
</body>
</html>
);
}
+95
View File
@@ -0,0 +1,95 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
padding: var(--space-6);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: var(--space-12) var(--space-10);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
max-width: 400px;
width: 100%;
}
.logo {
width: 56px;
height: 56px;
border-radius: var(--radius-lg);
background: var(--color-primary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-2);
}
.logoMark {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: white;
letter-spacing: -0.02em;
}
.title {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text);
text-align: center;
}
.subtitle {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-align: center;
max-width: 280px;
}
.googleBtn {
display: inline-flex;
align-items: center;
gap: var(--space-3);
padding: 0.875rem 1.5rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
text-decoration: none;
transition: all 0.2s;
width: 100%;
justify-content: center;
margin-top: var(--space-4);
box-shadow: var(--shadow-sm);
}
.googleBtn:hover {
background: var(--color-surface-offset);
border-color: var(--color-text-faint);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.googleIcon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.disclaimer {
font-size: var(--text-xs);
color: var(--color-text-faint);
text-align: center;
margin-top: var(--space-2);
}
+39
View File
@@ -0,0 +1,39 @@
import styles from './Login.module.css';
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1';
export default function LoginPage() {
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.logo}>
<span className={styles.logoMark}>SF</span>
</div>
<h1 className={styles.title}>StudioFlow</h1>
<p className={styles.subtitle}>Sign in to manage your YouTube studio</p>
<a href={`${API_URL}/auth/google`} className={styles.googleBtn}>
<svg viewBox="0 0 24 24" className={styles.googleIcon} aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Sign in with Google
</a>
<p className={styles.disclaimer}>Access is restricted to authorized accounts only.</p>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/overview');
}
@@ -0,0 +1,25 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/store/useAuthStore';
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const router = useRouter();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (mounted && !token) {
router.replace('/login');
}
}, [mounted, token, router]);
if (!mounted || !token) return null;
return <>{children}</>;
}
@@ -0,0 +1,608 @@
.root {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
.editorLayout {
display: grid;
grid-template-columns: 220px 1fr;
gap: var(--space-4);
align-items: flex-start;
}
/* Library */
.library {
display: flex;
flex-direction: column;
gap: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.libraryHeader {
padding: var(--space-3) var(--space-3) 0;
}
.panelTitle {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.searchWrap {
position: relative;
padding: var(--space-2) var(--space-3);
}
.searchIcon {
position: absolute;
left: calc(var(--space-3) + 8px);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-faint);
pointer-events: none;
}
.searchInput {
width: 100%;
padding: var(--space-1) var(--space-2) var(--space-1) var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
font-size: var(--text-xs);
color: var(--color-text);
outline: none;
}
.searchInput:focus { border-color: var(--color-primary); }
.libraryList {
max-height: 340px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.libraryItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--color-divider);
gap: var(--space-2);
}
.libraryItem:hover { background: var(--color-surface-offset); }
.libraryItemInfo {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.libraryItemName {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.libraryItemType {
font-size: 10px;
color: var(--color-text-faint);
text-transform: uppercase;
}
.addBtn {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-primary);
background: var(--color-primary-highlight);
transition: all 0.15s;
}
.addBtn:hover:not(:disabled) { background: var(--color-primary); color: white; }
.addBtn:disabled { opacity: 0.35; cursor: default; }
.libraryFooter {
padding: var(--space-2) var(--space-3) var(--space-3);
border-top: 1px solid var(--color-divider);
}
.freeTextBtn {
display: flex;
align-items: center;
gap: var(--space-1);
width: 100%;
padding: var(--space-2);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
justify-content: center;
transition: all 0.15s;
}
.freeTextBtn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-primary-highlight);
}
/* Config panel */
.configPanel {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.blockList {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.blockItem {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
transition: opacity 0.15s;
}
.blockItem.inactive { opacity: 0.5; }
.freeTextItem { align-items: flex-start; flex-wrap: wrap; }
.blockIndex {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-faint);
width: 18px;
text-align: center;
flex-shrink: 0;
}
.blockInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.blockName {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.blockType {
font-size: 10px;
color: var(--color-text-faint);
text-transform: uppercase;
}
.blockNameRow {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.blockContentWrap {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-top: var(--space-2);
width: 100%;
}
.freeTextArea {
width: 100%;
margin-top: var(--space-1);
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
font-size: var(--text-xs);
color: var(--color-text);
font-family: var(--font-body);
line-height: 1.5;
resize: vertical;
outline: none;
}
.freeTextArea:focus { border-color: var(--color-primary); }
/* Controls */
.blockControls {
display: flex;
align-items: center;
gap: var(--space-1);
flex-shrink: 0;
}
.toggle {
position: relative;
display: inline-block;
width: 30px;
height: 17px;
flex-shrink: 0;
cursor: pointer;
}
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggleSlider {
position: absolute;
inset: 0;
background: var(--color-border);
border-radius: 999px;
transition: background 0.2s;
}
.toggleSlider::before {
content: '';
position: absolute;
width: 11px;
height: 11px;
border-radius: 50%;
background: white;
top: 3px;
left: 3px;
transition: transform 0.2s;
}
.toggle input:checked + .toggleSlider { background: var(--color-primary); }
.toggle input:checked + .toggleSlider::before { transform: translateX(13px); }
.iconBtn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
flex-shrink: 0;
transition: all 0.15s;
}
.iconBtn:hover:not(:disabled) { background: var(--color-surface-offset); color: var(--color-text); }
.iconBtn:disabled { opacity: 0.3; cursor: default; }
.removeBtn:hover:not(:disabled) { background: color-mix(in srgb, var(--color-error), transparent 85%); color: var(--color-error); }
.compactActive { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent); }
.resetContentBtn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
color: var(--color-text-faint);
align-self: flex-start;
padding: 2px 6px;
border-radius: var(--radius-sm);
transition: all 0.15s;
}
.resetContentBtn:hover {
color: var(--color-warning, #f59e0b);
background: color-mix(in srgb, var(--color-warning, #f59e0b), transparent 90%);
}
/* Badges */
.overriddenBadge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-warning, #f59e0b), transparent 85%);
color: var(--color-warning, #f59e0b);
border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b), transparent 70%);
}
.globalBadge {
margin-left: var(--space-2);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-primary), transparent 85%);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary), transparent 70%);
}
.overrideBadge {
margin-left: var(--space-2);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-warning, #f59e0b), transparent 85%);
color: var(--color-warning, #f59e0b);
border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b), transparent 70%);
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.sectionRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.sectionTitle {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.variableGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
}
.varDesc {
font-size: var(--text-xs);
color: var(--color-text-faint);
margin-top: -2px;
}
/* Collaborators */
.collaboratorChips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.collaboratorChip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 4px 6px 4px 10px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-text);
max-width: 260px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chipHandle { font-weight: 400; color: var(--color-text-faint); font-size: 10px; margin-left: 2px; }
.collaboratorChipRemove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
padding: 0;
transition: background 0.1s, color 0.1s;
}
.collaboratorChipRemove:hover { background: color-mix(in srgb, var(--color-error) 15%, transparent); color: var(--color-error); }
.collabEmpty {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
/* Collaborator search dropdown */
.collabDropdownWrap {
position: relative;
}
.collabSearchLabel {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg);
color: var(--color-text-muted);
cursor: text;
transition: border-color 0.15s;
}
.collabSearchLabel:focus-within {
border-color: var(--color-primary);
color: var(--color-text);
}
.collabSearchInput {
flex: 1;
border: none;
background: none;
outline: none;
font-size: var(--text-sm);
color: var(--color-text);
}
.collabSearchInput::placeholder { color: var(--color-text-faint); }
.collabDropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
z-index: 50;
max-height: 240px;
overflow-y: auto;
}
.collabDropdownItem {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
background: none;
text-align: left;
cursor: pointer;
gap: var(--space-3);
transition: background 0.1s;
}
.collabDropdownItem:hover { background: var(--color-primary-highlight); }
.collabDropdownItem + .collabDropdownItem { border-top: 1px solid var(--color-divider); }
.collabDropdownName {
font-size: var(--text-sm);
color: var(--color-text);
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collabDropdownHandle {
font-size: var(--text-xs);
color: var(--color-text-faint);
flex-shrink: 0;
}
.collabDropdownEmpty {
padding: var(--space-4) var(--space-3);
font-size: var(--text-sm);
color: var(--color-text-faint);
text-align: center;
font-style: italic;
}
/* Reference panel */
.refToggle {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
transition: color 0.15s;
}
.refToggle:hover { color: var(--color-primary); }
.refPanel {
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.refTitle { font-size: var(--text-xs); font-weight: 700; color: var(--color-text-muted); }
.refList { display: flex; flex-direction: column; gap: 4px; }
.refRow {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.refMeta { display: flex; flex-direction: column; gap: 2px; }
.refDesc { font-size: var(--text-xs); color: var(--color-text-faint); }
.refCode {
font-family: var(--font-mono, monospace);
font-size: var(--text-xs);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 1px 6px;
color: var(--color-primary);
min-width: 160px;
}
.refLabel { font-size: var(--text-xs); color: var(--color-text-muted); }
.refNote {
font-size: var(--text-xs);
color: var(--color-text-faint);
border-top: 1px solid var(--color-divider);
padding-top: var(--space-2);
margin-top: var(--space-1);
line-height: 1.5;
white-space: pre-line;
}
/* Actions & preview */
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.previewBox {
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
padding: var(--space-4);
font-size: var(--text-sm);
color: var(--color-text-muted);
white-space: pre-wrap;
line-height: 1.6;
max-height: 500px;
overflow-y: auto;
font-family: var(--font-body);
}
.empty {
font-size: var(--text-xs);
color: var(--color-text-faint);
padding: var(--space-3);
text-align: center;
}
@@ -0,0 +1,550 @@
'use client';
import { useState, useMemo, useRef, useEffect } from 'react';
import {
Plus, Trash2, ChevronUp, ChevronDown, RefreshCw,
Search, Loader2, Type, Info, AlignJustify, FileText, RotateCcw, X,
} from 'lucide-react';
import type { Block, Collaborator, TeamVariable, SystemVariable } from '@/lib/api';
import CollaboratorModal from '@/components/shared/CollaboratorModal';
import f from '@/components/shared/FormField.module.css';
import styles from './BlockOrderEditor.module.css';
export interface BlockOverride {
content?: string;
active?: boolean;
compact?: boolean;
}
export interface BlockOrderEditorState {
blockOrder: string[];
blockOverrides: Record<string, BlockOverride>;
variableValues: Record<string, string>;
collaboratorIds: string[];
}
interface Props extends BlockOrderEditorState {
onBlockOrderChange: (v: string[]) => void;
onBlockOverridesChange: (v: Record<string, BlockOverride>) => void;
onVariableValuesChange: (v: Record<string, string>) => void;
onCollaboratorIdsChange: (v: string[]) => void;
allBlocks: Block[];
collaborators: Collaborator[];
teamVars: TeamVariable[];
systemVars: SystemVariable[];
onPreview?: () => void;
previewPending?: boolean;
previewResult?: string | null;
currentDescription?: string | null;
currentDescriptionLabel?: string;
}
function extractPlaceholders(content: string): string[] {
const matches = content.matchAll(/\{([a-zA-Z_@][a-zA-Z0-9_.]*)(?:\|[^}]*)?\}/g);
return [...new Set([...matches].map((m) => m[1]))];
}
export function isFreeText(id: string) { return id.startsWith('freetext:'); }
export function newFreeTextId() { return `freetext:${Date.now()}-${Math.random().toString(36).slice(2)}`; }
export default function BlockOrderEditor({
blockOrder,
blockOverrides,
variableValues,
collaboratorIds,
onBlockOrderChange,
onBlockOverridesChange,
onVariableValuesChange,
onCollaboratorIdsChange,
allBlocks,
collaborators,
teamVars,
systemVars,
onPreview,
previewPending = false,
previewResult = null,
currentDescription,
currentDescriptionLabel = 'Current Description',
}: Props) {
const [blockSearch, setBlockSearch] = useState('');
const [expandedBlocks, setExpandedBlocks] = useState<Set<string>>(new Set());
const [showCollabRef, setShowCollabRef] = useState(false);
const [showNewCollabModal, setShowNewCollabModal] = useState(false);
const [collaboratorSearch, setCollaboratorSearch] = useState('');
const [collaboratorDropdownOpen, setCollaboratorDropdownOpen] = useState(false);
const collaboratorDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!collaboratorDropdownOpen) return;
const handler = (e: MouseEvent) => {
if (collaboratorDropdownRef.current && !collaboratorDropdownRef.current.contains(e.target as Node)) {
setCollaboratorDropdownOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [collaboratorDropdownOpen]);
const teamVarMap = useMemo(
() => Object.fromEntries(teamVars.map((v) => [v.name, v.value])),
[teamVars],
);
const orderedBlocks = useMemo(
() => blockOrder.map((id) => allBlocks.find((b) => b.id === id)).filter(Boolean) as Block[],
[blockOrder, allBlocks],
);
const systemTokens = useMemo(() => new Set(systemVars.map((v) => v.token)), [systemVars]);
const variables = useMemo(() => {
const placeholders = new Set<string>();
for (const block of orderedBlocks) {
if (blockOverrides[block.id]?.active !== false) {
extractPlaceholders(blockOverrides[block.id]?.content ?? block.content)
.forEach((p) => { if (!systemTokens.has(p)) placeholders.add(p); });
}
}
for (const id of blockOrder) {
if (isFreeText(id) && blockOverrides[id]?.active !== false) {
extractPlaceholders(blockOverrides[id]?.content ?? '')
.forEach((p) => { if (!systemTokens.has(p)) placeholders.add(p); });
}
}
return [...placeholders];
}, [orderedBlocks, blockOverrides, blockOrder, systemTokens]);
const blockVarDefs = useMemo(() => {
const map: Record<string, { label: string; description?: string; defaultValue?: string }> = {};
for (const block of orderedBlocks) {
for (const def of (block.variableDefinitions ?? [])) {
if (!map[def.name]) map[def.name] = def;
}
}
return map;
}, [orderedBlocks]);
const filteredBlocks = allBlocks.filter(
(b) => b.active && b.name.toLowerCase().includes(blockSearch.toLowerCase()),
);
const activeCollaborators = collaborators.filter((c) => c.active);
const selectedCollaborators = activeCollaborators.filter((c) => collaboratorIds.includes(c.id));
const availableCollaborators = activeCollaborators.filter(
(c) => !collaboratorIds.includes(c.id) && c.name.toLowerCase().includes(collaboratorSearch.toLowerCase()),
);
// ── Mutations ────────────────────────────────────────────────────────────────
const addBlock = (block: Block) => {
if (blockOrder.includes(block.id)) return;
const newVals: Record<string, string> = {};
for (const def of (block.variableDefinitions ?? [])) {
if (def.defaultValue && !variableValues[def.name]) newVals[def.name] = def.defaultValue;
}
if (Object.keys(newVals).length) onVariableValuesChange({ ...newVals, ...variableValues });
onBlockOrderChange([...blockOrder, block.id]);
};
const addFreeText = () => {
const id = newFreeTextId();
onBlockOrderChange([...blockOrder, id]);
onBlockOverridesChange({ ...blockOverrides, [id]: { content: '', active: true } });
};
const removeEntry = (id: string) => {
onBlockOrderChange(blockOrder.filter((b) => b !== id));
};
const moveEntry = (index: number, dir: -1 | 1) => {
const next = [...blockOrder];
const target = index + dir;
if (target < 0 || target >= next.length) return;
[next[index], next[target]] = [next[target], next[index]];
onBlockOrderChange(next);
};
const toggleEntry = (id: string, active: boolean) => {
onBlockOverridesChange({ ...blockOverrides, [id]: { ...blockOverrides[id], active } });
};
const toggleCompact = (id: string, blockDefault = false) => {
const effective = blockOverrides[id]?.compact ?? blockDefault;
onBlockOverridesChange({ ...blockOverrides, [id]: { ...blockOverrides[id], compact: !effective } });
};
const toggleExpanded = (id: string) => {
setExpandedBlocks((prev) => {
const next = new Set(prev);
if (next.has(id)) { next.delete(id); } else { next.add(id); }
return next;
});
};
const setBlockContent = (id: string, content: string) => {
onBlockOverridesChange({ ...blockOverrides, [id]: { ...blockOverrides[id], content } });
};
const resetBlockContent = (id: string) => {
const override = { ...(blockOverrides[id] ?? {}) } as BlockOverride;
delete override.content;
onBlockOverridesChange({ ...blockOverrides, [id]: override });
};
return (
<div className={styles.root}>
<div className={styles.editorLayout}>
{/* Left: Block library */}
<div className={styles.library}>
<div className={styles.libraryHeader}>
<span className={styles.panelTitle}>Block Library</span>
</div>
<div className={styles.searchWrap}>
<Search size={13} className={styles.searchIcon} />
<input
className={styles.searchInput}
placeholder="Search blocks…"
value={blockSearch}
onChange={(e) => setBlockSearch(e.target.value)}
/>
</div>
<div className={styles.libraryList}>
{filteredBlocks.length === 0 && <p className={styles.empty}>No blocks found.</p>}
{filteredBlocks.map((block) => {
const inConfig = blockOrder.includes(block.id);
return (
<div key={block.id} className={styles.libraryItem}>
<div className={styles.libraryItemInfo}>
<span className={styles.libraryItemName}>{block.name}</span>
<span className={styles.libraryItemType}>{block.type}</span>
</div>
<button
className={styles.addBtn}
onClick={() => addBlock(block)}
disabled={inConfig}
title={inConfig ? 'Already added' : 'Add to config'}
>
<Plus size={14} />
</button>
</div>
);
})}
</div>
<div className={styles.libraryFooter}>
<button className={styles.freeTextBtn} onClick={addFreeText}>
<Type size={13} />
Add free text
</button>
</div>
</div>
{/* Right: Ordered block list */}
<div className={styles.configPanel}>
<span className={styles.panelTitle}>Block Order</span>
{blockOrder.length === 0 && (
<p className={styles.empty}>Add blocks from the library or insert free text.</p>
)}
<div className={styles.blockList}>
{blockOrder.map((id, i) => {
const isActive = blockOverrides[id]?.active !== false;
const isFT = isFreeText(id);
if (isFT) {
return (
<div key={id} className={`${styles.blockItem} ${styles.freeTextItem} ${!isActive ? styles.inactive : ''}`}>
<span className={styles.blockIndex}>{i + 1}</span>
<div className={styles.blockInfo}>
<span className={styles.blockType}>Free Text</span>
<textarea
className={styles.freeTextArea}
value={blockOverrides[id]?.content ?? ''}
onChange={(e) => setBlockContent(id, e.target.value)}
placeholder="Enter free text… use {variable_name} for variables"
rows={3}
/>
</div>
<div className={styles.blockControls}>
<label className={styles.toggle} title={isActive ? 'Deactivate' : 'Activate'}>
<input type="checkbox" checked={isActive} onChange={(e) => toggleEntry(id, e.target.checked)} />
<span className={styles.toggleSlider} />
</label>
{i > 0 && (
<button
className={`${styles.iconBtn} ${blockOverrides[id]?.compact ? styles.compactActive : ''}`}
onClick={() => toggleCompact(id)}
title={blockOverrides[id]?.compact ? 'Compact on — click to restore blank line' : 'Remove blank line before this block'}
>
<AlignJustify size={14} />
</button>
)}
<button className={styles.iconBtn} onClick={() => moveEntry(i, -1)} disabled={i === 0} title="Move up"><ChevronUp size={14} /></button>
<button className={styles.iconBtn} onClick={() => moveEntry(i, 1)} disabled={i === blockOrder.length - 1} title="Move down"><ChevronDown size={14} /></button>
<button className={`${styles.iconBtn} ${styles.removeBtn}`} onClick={() => removeEntry(id)} title="Remove"><Trash2 size={14} /></button>
</div>
</div>
);
}
const block = allBlocks.find((b) => b.id === id);
if (!block) return null;
const isExpanded = expandedBlocks.has(id);
const hasContentOverride = blockOverrides[id]?.content !== undefined;
const displayContent = hasContentOverride ? blockOverrides[id]!.content! : block.content;
const effectiveCompact = blockOverrides[id]?.compact ?? block.compact;
return (
<div key={id} className={`${styles.blockItem} ${styles.freeTextItem} ${!isActive ? styles.inactive : ''}`}>
<span className={styles.blockIndex}>{i + 1}</span>
<div className={styles.blockInfo}>
<div className={styles.blockNameRow}>
<span className={styles.blockName}>{block.name}</span>
{hasContentOverride && <span className={styles.overriddenBadge}>overridden</span>}
<span className={styles.blockType}>{block.type}</span>
</div>
{isExpanded && (
<div className={styles.blockContentWrap}>
<textarea
className={styles.freeTextArea}
value={displayContent}
onChange={(e) => setBlockContent(id, e.target.value)}
rows={Math.max(3, displayContent.split('\n').length)}
/>
{hasContentOverride && (
<button className={styles.resetContentBtn} onClick={() => resetBlockContent(id)} title="Reset to block default">
<RotateCcw size={11} /> Reset to default
</button>
)}
</div>
)}
</div>
<div className={styles.blockControls}>
<button
className={`${styles.iconBtn} ${isExpanded ? styles.compactActive : ''}`}
onClick={() => toggleExpanded(id)}
title={isExpanded ? 'Hide content' : 'Show / edit content'}
>
<FileText size={14} />
</button>
<label className={styles.toggle} title={isActive ? 'Deactivate' : 'Activate'}>
<input type="checkbox" checked={isActive} onChange={(e) => toggleEntry(id, e.target.checked)} />
<span className={styles.toggleSlider} />
</label>
{i > 0 && (
<button
className={`${styles.iconBtn} ${effectiveCompact ? styles.compactActive : ''}`}
onClick={() => toggleCompact(id, block.compact)}
title={effectiveCompact ? 'Compact on — click to restore blank line' : 'Remove blank line before this block'}
>
<AlignJustify size={14} />
</button>
)}
<button className={styles.iconBtn} onClick={() => moveEntry(i, -1)} disabled={i === 0} title="Move up"><ChevronUp size={14} /></button>
<button className={styles.iconBtn} onClick={() => moveEntry(i, 1)} disabled={i === blockOrder.length - 1} title="Move down"><ChevronDown size={14} /></button>
<button className={`${styles.iconBtn} ${styles.removeBtn}`} onClick={() => removeEntry(id)} title="Remove"><Trash2 size={14} /></button>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Variables */}
{variables.length > 0 && (
<div className={styles.section}>
<span className={styles.sectionTitle}>Variables</span>
<div className={styles.variableGrid}>
{variables.map((v) => {
const def = blockVarDefs[v];
const globalVal = teamVarMap[v];
const hasGlobal = globalVal !== undefined;
const hasOverride = variableValues[v] !== undefined && variableValues[v] !== '';
return (
<div key={v} className={f.field}>
<label className={f.label}>
{def?.label ?? v}
{hasGlobal && !hasOverride && (
<span className={styles.globalBadge} title="Using global team value">global</span>
)}
{hasGlobal && hasOverride && (
<span className={styles.overrideBadge} title="Overriding global value">override</span>
)}
</label>
{def?.description && <span className={styles.varDesc}>{def.description}</span>}
<input
className={f.input}
value={variableValues[v] ?? ''}
onChange={(e) => onVariableValuesChange({ ...variableValues, [v]: e.target.value })}
placeholder={hasGlobal ? `Global: ${globalVal}` : (def?.defaultValue ?? `Value for ${v}`)}
/>
</div>
);
})}
</div>
</div>
)}
{/* Collaborators */}
<div className={styles.section}>
<div className={styles.sectionRow}>
<span className={styles.sectionTitle}>Collaborators</span>
<div style={{ display: 'flex', gap: 'var(--space-2)', alignItems: 'center' }}>
<button className={styles.refToggle} onClick={() => setShowCollabRef((v) => !v)} title="Show available placeholders">
<Info size={13} />
{showCollabRef ? 'Hide' : 'Show'} placeholders
</button>
<button className={styles.refToggle} onClick={() => setShowNewCollabModal(true)} title="Create a new collaborator">
<Plus size={13} />
New collaborator
</button>
</div>
</div>
{showCollabRef && (
<div className={styles.refPanel}>
<p className={styles.refTitle}>Collaborator variables</p>
<div className={styles.refList}>
{systemVars.filter((v) => v.group === 'collaborator').map((v) => (
<div key={v.token} className={styles.refRow}>
<code className={styles.refCode}>{v.placeholder}</code>
<div className={styles.refMeta}>
<span className={styles.refLabel}>{v.label}</span>
<span className={styles.refDesc}>{v.description}</span>
</div>
</div>
))}
</div>
<p className={styles.refTitle} style={{ marginTop: 'var(--space-3)' }}>Video variables</p>
<div className={styles.refList}>
{systemVars.filter((v) => v.group === 'video').map((v) => (
<div key={v.token} className={styles.refRow}>
<code className={styles.refCode}>{v.placeholder}</code>
<div className={styles.refMeta}>
<span className={styles.refLabel}>{v.label}</span>
<span className={styles.refDesc}>{v.description}</span>
</div>
</div>
))}
</div>
<p className={styles.refNote}>
COLLABORATOR-type blocks repeat once per selected collaborator. Other blocks use the first selected collaborator.{'\n\n'}
Date variables accept an optional format suffix: {'{video.scheduledAt|DD.MM.YYYY}'}. Tokens: YYYY, YY, MMMM, MMM, MM, M, DD, D.
</p>
</div>
)}
{selectedCollaborators.length > 0 ? (
<div className={styles.collaboratorChips}>
{selectedCollaborators.map((c) => (
<span key={c.id} className={styles.collaboratorChip}>
{c.name}
{c.youtubeLink && (
<span className={styles.chipHandle}>
@{c.youtubeLink.replace('https://www.youtube.com/@', '')}
</span>
)}
<button
className={styles.collaboratorChipRemove}
onClick={() => onCollaboratorIdsChange(collaboratorIds.filter((id) => id !== c.id))}
title="Remove collaborator"
>
<X size={11} />
</button>
</span>
))}
</div>
) : (
<p className={styles.collabEmpty}>No collaborators selected.</p>
)}
{activeCollaborators.length > 0 ? (
<div className={styles.collabDropdownWrap} ref={collaboratorDropdownRef}>
<label className={styles.collabSearchLabel}>
<Search size={13} />
<input
type="text"
placeholder="Add a collaborator…"
className={styles.collabSearchInput}
value={collaboratorSearch}
onChange={(e) => { setCollaboratorSearch(e.target.value); setCollaboratorDropdownOpen(true); }}
onFocus={() => setCollaboratorDropdownOpen(true)}
/>
</label>
{collaboratorDropdownOpen && (
<div className={styles.collabDropdown}>
{availableCollaborators.length === 0 ? (
<div className={styles.collabDropdownEmpty}>
{collaboratorSearch ? 'No matching collaborators.' : 'All collaborators already added.'}
</div>
) : (
availableCollaborators.map((c) => (
<button
key={c.id}
className={styles.collabDropdownItem}
onMouseDown={(e) => {
e.preventDefault();
onCollaboratorIdsChange([...collaboratorIds, c.id]);
setCollaboratorSearch('');
setCollaboratorDropdownOpen(false);
}}
>
<span className={styles.collabDropdownName}>{c.name}</span>
{c.youtubeLink && (
<span className={styles.collabDropdownHandle}>
@{c.youtubeLink.replace('https://www.youtube.com/@', '')}
</span>
)}
</button>
))
)}
</div>
)}
</div>
) : (
<p className={styles.collabEmpty}>No collaborators yet. Use &quot;New collaborator&quot; above to add one.</p>
)}
</div>
{showNewCollabModal && (
<CollaboratorModal
onClose={() => setShowNewCollabModal(false)}
onCreated={(id) => {
onCollaboratorIdsChange([...collaboratorIds, id]);
setShowNewCollabModal(false);
}}
/>
)}
{/* Preview */}
{onPreview && (
<div className={styles.actions}>
<button className="btn btn-secondary" onClick={onPreview} disabled={previewPending}>
{previewPending ? <Loader2 size={15} className={styles.spin} /> : <RefreshCw size={15} />}
Render Preview
</button>
</div>
)}
{currentDescription ? (
<div className={styles.section}>
<span className={styles.sectionTitle}>{currentDescriptionLabel}</span>
<pre className={styles.previewBox}>{currentDescription}</pre>
</div>
) : (
onPreview && (
<div className={styles.section}>
<span className={styles.sectionTitle}>Current Description</span>
<p className={styles.empty}>No description yet. Use &quot;Render Preview&quot; to generate one from your blocks.</p>
</div>
)
)}
{previewResult !== null && (
<div className={styles.section}>
<span className={styles.sectionTitle}>Rendered Preview</span>
<pre className={styles.previewBox}>{previewResult}</pre>
</div>
)}
</div>
);
}
@@ -0,0 +1,218 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import {
fetchCollaboratorVideos, createCollaborator, updateCollaborator,
type Collaborator, type CollaboratorPayload,
} from '@/lib/api';
import UsagePanel from './UsagePanel';
import Modal from './Modal';
import f from './FormField.module.css';
export const PLATFORMS = [
{ key: 'youtubeLink', prefix: 'https://www.youtube.com/@', display: 'youtube.com/@', label: 'YouTube', urlField: true },
{ key: 'twitchLink', prefix: 'https://twitch.tv/', display: 'twitch.tv/', label: 'Twitch', urlField: true },
{ key: 'instagramLink', prefix: 'https://instagram.com/', display: 'instagram.com/', label: 'Instagram', urlField: true },
{ key: 'tiktokLink', prefix: 'https://tiktok.com/@', display: 'tiktok.com/@', label: 'TikTok', urlField: true },
{ key: 'twitterLink', prefix: 'https://x.com/', display: 'x.com/', label: 'Twitter / X', urlField: true },
{ key: 'blueskyLink', prefix: 'https://bsky.app/profile/', display: 'bsky.app/profile/', label: 'Bluesky', urlField: true },
{ key: 'discordHandle', prefix: '', display: '', label: 'Discord', urlField: false },
] as const;
export type PlatformKey = typeof PLATFORMS[number]['key'];
export function toUsername(url: string | null | undefined, prefix: string): string {
if (!url) return '';
return prefix ? url.replace(prefix, '').replace(/^\/+/, '') : url;
}
export function toValue(username: string, prefix: string): string {
if (!username.trim()) return '';
return prefix ? prefix + username.trim().replace(/^\/+/, '') : username.trim();
}
const EMPTY_FORM: CollaboratorPayload = {
name: '',
youtubeLink: '', twitchLink: '', instagramLink: '', tiktokLink: '',
twitterLink: '', blueskyLink: '', discordHandle: '',
aliases: [], notes: '', active: true,
};
interface Props {
initial?: Collaborator;
onClose: () => void;
/** Called after a successful create, with the new collaborator id */
onCreated?: (id: string) => void;
}
export default function CollaboratorModal({ initial, onClose, onCreated }: Props) {
const qc = useQueryClient();
const [form, setForm] = useState<CollaboratorPayload>(
initial
? {
name: initial.name,
youtubeLink: initial.youtubeLink ?? '',
twitchLink: initial.twitchLink ?? '',
instagramLink: initial.instagramLink ?? '',
tiktokLink: initial.tiktokLink ?? '',
twitterLink: initial.twitterLink ?? '',
blueskyLink: initial.blueskyLink ?? '',
discordHandle: initial.discordHandle ?? '',
aliases: initial.aliases,
notes: initial.notes ?? '',
active: initial.active,
}
: EMPTY_FORM,
);
const [aliasInput, setAliasInput] = useState(initial?.aliases.join(', ') ?? '');
const { data: usageVideos = [], isLoading: usageLoading } = useQuery({
queryKey: ['collaborator-videos', initial?.id],
queryFn: () => fetchCollaboratorVideos(initial!.id),
enabled: !!initial,
});
const [usernames, setUsernames] = useState<Record<PlatformKey, string>>(() => {
const src = initial ?? {} as Collaborator;
return Object.fromEntries(
PLATFORMS.map((p) => [p.key, toUsername(src[p.key as keyof Collaborator] as string | null, p.prefix)])
) as Record<PlatformKey, string>;
});
const setUsername = (key: PlatformKey, val: string) =>
setUsernames((prev) => ({ ...prev, [key]: val }));
const mutation = useMutation({
mutationFn: () => {
const platformValues = Object.fromEntries(
PLATFORMS.map((p) => [p.key, toValue(usernames[p.key], p.prefix)])
);
const payload: CollaboratorPayload = {
...form,
...platformValues,
aliases: aliasInput.split(',').map((a) => a.trim()).filter(Boolean),
};
return initial ? updateCollaborator(initial.id, payload) : createCollaborator(payload);
},
onSuccess: (saved) => {
qc.invalidateQueries({ queryKey: ['collaborators'] });
if (!initial && onCreated) onCreated((saved as Collaborator).id);
onClose();
},
});
const set = (key: keyof CollaboratorPayload, val: unknown) =>
setForm((p) => ({ ...p, [key]: val }));
return (
<Modal title={initial ? 'Edit Collaborator' : 'Add Collaborator'} onClose={onClose} width={600}>
<div className={f.field}>
<label className={f.label}>Name</label>
<input
className={f.input}
value={form.name}
onChange={(e) => set('name', e.target.value)}
placeholder="Full name or channel name"
autoFocus
/>
</div>
{([
PLATFORMS.slice(0, 2),
PLATFORMS.slice(2, 4),
PLATFORMS.slice(4, 6),
PLATFORMS.slice(6, 7),
] as const).map((row, ri) => (
<div key={ri} className={f.row}>
{row.map((p) => (
<div key={p.key} className={f.field}>
<label className={f.label}>
{p.label}
{p.key === 'discordHandle' && (
<span style={{ fontWeight: 400, color: 'var(--color-text-faint)' }}> (handle)</span>
)}
</label>
{p.display ? (
<div className={f.inputPrefix}>
<span className={f.prefix}>{p.display}</span>
<input
className={f.input}
value={usernames[p.key]}
onChange={(e) =>
setUsername(
p.key,
e.target.value.replace(
new RegExp(`^https?://(www\\.)?${p.display.replace(/\//g, '\\/')}`, 'i'),
'',
),
)
}
placeholder="username"
/>
</div>
) : (
<input
className={f.input}
value={usernames[p.key]}
onChange={(e) => setUsername(p.key, e.target.value)}
placeholder="username"
/>
)}
</div>
))}
</div>
))}
<div className={f.field}>
<label className={f.label}>Aliases (comma-separated)</label>
<input
className={f.input}
value={aliasInput}
onChange={(e) => setAliasInput(e.target.value)}
placeholder="alias1, alias2"
/>
</div>
<div className={f.field}>
<label className={f.label}>Notes</label>
<textarea
className={f.textarea}
value={form.notes ?? ''}
onChange={(e) => set('notes', e.target.value)}
placeholder="Optional notes about this collaborator"
/>
</div>
<label className={f.toggle}>
<input type="checkbox" checked={form.active} onChange={(e) => set('active', e.target.checked)} />
Active
</label>
{initial && (
<UsagePanel
isLoading={usageLoading}
groups={[
{
heading: 'Videos',
items: usageVideos.map((v) => ({ id: v.id, label: v.title, href: `/videos/${v.id}` })),
},
]}
/>
)}
{mutation.isError && (
<p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)' }}>
Save failed check the backend logs.
</p>
)}
<div className={f.actions}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button
className="btn btn-primary"
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !form.name.trim()}
>
{mutation.isPending ? <Loader2 size={14} /> : null}
{initial ? 'Save Changes' : 'Add Collaborator'}
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,112 @@
.root {
position: relative;
display: inline-flex;
}
.trigger {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 5px var(--space-3);
font-size: var(--text-sm);
font-weight: 500;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
}
.trigger:hover { background: var(--color-bg); color: var(--color-text); }
.triggerActive { color: var(--color-primary); border-color: var(--color-primary); }
.badge {
font-size: var(--text-xs);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
border-radius: var(--radius-full);
padding: 1px 6px;
}
.popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 50;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
width: 240px;
overflow: hidden;
}
.popoverHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border);
}
.popoverTitle {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.resetBtn {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
padding: 2px 4px;
border-radius: var(--radius-sm);
}
.resetBtn:hover { color: var(--color-text); background: var(--color-bg); }
.list {
padding: var(--space-2) 0;
max-height: 360px;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 5px var(--space-3);
user-select: none;
}
.item:hover { background: var(--color-bg); }
.grip {
cursor: grab;
color: var(--color-text-faint);
display: flex;
align-items: center;
flex-shrink: 0;
}
.grip:active { cursor: grabbing; }
.label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
flex: 1;
}
.label input:disabled { opacity: 0.4; cursor: not-allowed; }
.checkbox {
accent-color: var(--color-primary);
width: 14px;
height: 14px;
flex-shrink: 0;
}
@@ -0,0 +1,148 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,
useSensor, useSensors, type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy,
useSortable, arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Columns, GripVertical, RotateCcw } from 'lucide-react';
import styles from './ColumnPicker.module.css';
export interface ColumnDef {
id: string;
label: string;
/** Columns that cannot be hidden */
required?: boolean;
}
interface Props {
columns: ColumnDef[];
visible: string[];
order: string[];
onChange: (visible: string[], order: string[]) => void;
defaultVisible: string[];
defaultOrder: string[];
}
function SortableItem({
col, isVisible, onToggle,
}: {
col: ColumnDef;
isVisible: boolean;
onToggle: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style} className={styles.item}>
<span className={styles.grip} {...attributes} {...listeners}>
<GripVertical size={14} />
</span>
<label className={styles.label}>
<input
type="checkbox"
checked={isVisible}
onChange={onToggle}
disabled={col.required}
className={styles.checkbox}
/>
{col.label}
</label>
</div>
);
}
export default function ColumnPicker({ columns, visible, order, onChange, defaultVisible, defaultOrder }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Ordered list of column defs — columns not in order go at end
const orderedCols = [
...order.map((id) => columns.find((c) => c.id === id)).filter(Boolean) as ColumnDef[],
...columns.filter((c) => !order.includes(c.id)),
];
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = orderedCols.findIndex((c) => c.id === active.id);
const newIndex = orderedCols.findIndex((c) => c.id === over.id);
const newOrder = arrayMove(orderedCols, oldIndex, newIndex).map((c) => c.id);
onChange(visible, newOrder);
};
const toggleVisible = (id: string) => {
const col = columns.find((c) => c.id === id);
if (col?.required) return;
const next = visible.includes(id) ? visible.filter((v) => v !== id) : [...visible, id];
onChange(next, order);
};
const reset = () => onChange(defaultVisible, defaultOrder);
const hiddenCount = columns.filter((c) => !visible.includes(c.id)).length;
return (
<div className={styles.root} ref={ref}>
<button
className={`${styles.trigger} ${hiddenCount > 0 ? styles.triggerActive : ''}`}
onClick={() => setOpen((o) => !o)}
title="Choose columns"
>
<Columns size={15} />
<span>Columns</span>
{hiddenCount > 0 && <span className={styles.badge}>{hiddenCount} hidden</span>}
</button>
{open && (
<div className={styles.popover}>
<div className={styles.popoverHeader}>
<span className={styles.popoverTitle}>Columns</span>
<button className={styles.resetBtn} onClick={reset} title="Reset to defaults">
<RotateCcw size={13} />
Reset
</button>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={orderedCols.map((c) => c.id)} strategy={verticalListSortingStrategy}>
<div className={styles.list}>
{orderedCols.map((col) => (
<SortableItem
key={col.id}
col={col}
isVisible={visible.includes(col.id)}
onToggle={() => toggleVisible(col.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
</div>
);
}
@@ -0,0 +1,111 @@
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.label {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.input,
.textarea,
.select {
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.625rem 0.875rem;
font-size: var(--text-sm);
color: var(--color-text);
outline: none;
transition: border-color 0.15s;
width: 100%;
}
.input:focus,
.textarea:focus,
.select:focus {
border-color: var(--color-primary);
}
.textarea {
resize: vertical;
min-height: 80px;
font-family: var(--font-body);
line-height: 1.5;
}
.select {
cursor: pointer;
}
.toggle {
display: flex;
align-items: center;
gap: var(--space-3);
cursor: pointer;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.toggle input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
cursor: pointer;
}
.row {
display: flex;
gap: var(--space-4);
}
.row .field { flex: 1; }
.inputPrefix {
display: flex;
align-items: stretch;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
transition: border-color 0.15s;
}
.inputPrefix:focus-within {
border-color: var(--color-primary);
}
.prefix {
display: flex;
align-items: center;
padding: 0 0.625rem;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
font-size: var(--text-sm);
color: var(--color-text-faint);
white-space: nowrap;
user-select: none;
}
.inputPrefix .input {
border: none;
border-radius: 0;
flex: 1;
}
.inputPrefix .input:focus {
border-color: transparent;
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--color-divider);
margin-top: var(--space-2);
}
@@ -0,0 +1,286 @@
.header {
height: var(--header-height);
background: color-mix(in oklab, var(--color-bg) 86%, transparent);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-6);
position: sticky;
top: 0;
z-index: 100;
}
.left {
display: flex;
align-items: center;
gap: var(--space-4);
flex: 1;
}
.menuBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: var(--radius-md);
color: var(--color-text-muted);
}
.menuBtn:hover {
background: var(--color-surface-offset);
}
.search {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: 0.75rem 1rem;
max-width: 520px;
width: 100%;
color: var(--color-text-muted);
}
.search input {
border: none;
background: transparent;
outline: none;
width: 100%;
color: var(--color-text);
font-size: var(--text-sm);
}
.right {
display: flex;
align-items: center;
gap: var(--space-3);
}
.btnPrimary {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
background: var(--color-primary);
color: white;
box-shadow: var(--shadow-sm);
transition: all 0.2s;
}
.btnPrimary:hover {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btnSecondary {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
transition: all 0.2s;
}
.btnSecondary:hover {
background: var(--color-surface-offset);
}
.userArea {
display: flex;
align-items: center;
gap: var(--space-2);
padding-left: var(--space-3);
border-left: 1px solid var(--color-border);
}
.teamBadge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0.2rem 0.5rem;
background: color-mix(in srgb, var(--color-primary), transparent 88%);
color: var(--color-primary);
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.userName {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-muted);
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logoutBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: var(--radius-md);
color: var(--color-text-muted);
transition: all 0.2s;
}
.logoutBtn:hover {
background: var(--color-surface-offset);
color: var(--color-error);
}
/* ── Sync status indicator ── */
.syncWrap {
position: relative;
}
.syncBtn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
color: var(--color-text-faint);
transition: background 0.15s, color 0.15s;
}
.syncBtn:hover {
background: var(--color-surface-offset);
color: var(--color-text-muted);
}
.syncBtnActive {
color: var(--color-primary);
}
.syncBtnFailed {
color: var(--color-error);
}
.syncBadge {
position: absolute;
top: 3px;
right: 3px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--color-warning);
color: white;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
pointer-events: none;
}
@keyframes spin { to { transform: rotate(360deg); } }
.syncSpinner {
animation: spin 1s linear infinite;
}
.syncDropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 320px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg, 0 8px 24px rgba(0,0,0,0.12));
z-index: 200;
overflow: hidden;
}
.syncDropdownTitle {
padding: var(--space-3) var(--space-4);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-faint);
border-bottom: 1px solid var(--color-divider);
}
.syncEmpty {
padding: var(--space-4);
font-size: var(--text-sm);
color: var(--color-text-muted);
text-align: center;
}
.syncSection {
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-divider);
}
.syncSection:last-child {
border-bottom: none;
}
.syncSectionLabel {
padding: var(--space-1) var(--space-4);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-faint);
margin-bottom: var(--space-1);
}
.syncRow {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
}
.syncRowInfo {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.syncRowTitle {
flex: 1;
font-size: var(--text-sm);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.syncRowSub {
font-size: var(--text-xs);
color: var(--color-error);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.syncRowTime {
font-size: var(--text-xs);
color: var(--color-text-faint);
white-space: nowrap;
flex-shrink: 0;
}
+382
View File
@@ -0,0 +1,382 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { Menu, Search, Sun, Moon, Eye, LogOut, Loader2, CloudUpload, CheckCircle2, XCircle, Clock, Loader } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useUIStore } from '@/store/useUIStore';
import { useAuthStore } from '@/store/useAuthStore';
import { useTheme } from '@/hooks/useTheme';
import apiClient from '@/lib/api-client';
import { fetchSavedViews, fetchTemplates, bulkPreview, bulkApply, fetchSyncQueueStatus, type BulkActionType, type BulkPreviewResponse } from '@/lib/api';
import Modal from './Modal';
import f from './FormField.module.css';
import styles from './Header.module.css';
// ─── Bulk Action Modal ────────────────────────────────────────────────────────
const ACTION_LABELS: Record<BulkActionType, string> = {
SET_PRIVACY: 'Set Privacy Status',
SET_TEMPLATE: 'Assign Template',
ADD_TAGS: 'Add Tags',
REMOVE_TAGS: 'Remove Tags',
SEARCH_REPLACE_TITLE: 'Search & Replace in Title',
};
function BulkModal({ onClose }: { onClose: () => void }) {
const [step, setStep] = useState<'configure' | 'preview'>('configure');
const [actionType, setActionType] = useState<BulkActionType>('SET_PRIVACY');
const [targetType, setTargetType] = useState<'all' | 'view'>('all');
const [savedViewId, setSavedViewId] = useState('');
const [payload, setPayload] = useState<Record<string, string>>({});
const [preview, setPreview] = useState<BulkPreviewResponse | null>(null);
const { data: savedViews = [] } = useQuery({ queryKey: ['saved-views'], queryFn: fetchSavedViews });
const { data: templates = [] } = useQuery({ queryKey: ['templates'], queryFn: fetchTemplates });
const buildPayload = () => {
if ((actionType === 'ADD_TAGS' || actionType === 'REMOVE_TAGS') && typeof payload.tags === 'string') {
return { ...payload, tags: payload.tags.split(',').map((t) => t.trim()).filter(Boolean) };
}
return payload;
};
const previewMut = useMutation({
mutationFn: () => bulkPreview({
type: actionType,
payload: buildPayload(),
savedViewId: targetType === 'view' ? savedViewId : undefined,
}),
onSuccess: (data) => { setPreview(data); setStep('preview'); },
});
const applyMut = useMutation({
mutationFn: () => bulkApply({
type: actionType,
payload: buildPayload(),
savedViewId: targetType === 'view' ? savedViewId : undefined,
}),
onSuccess: onClose,
});
const setP = (key: string, val: string) => setPayload((p) => ({ ...p, [key]: val }));
return (
<Modal title="Bulk Change" onClose={onClose} width={640}>
{step === 'configure' && (
<>
<div className={f.field}>
<label className={f.label}>Action</label>
<select className={f.select} value={actionType} onChange={(e) => { setActionType(e.target.value as BulkActionType); setPayload({}); }}>
{(Object.keys(ACTION_LABELS) as BulkActionType[]).map((k) => (
<option key={k} value={k}>{ACTION_LABELS[k]}</option>
))}
</select>
</div>
{actionType === 'SET_PRIVACY' && (
<div className={f.field}>
<label className={f.label}>Privacy Status</label>
<select className={f.select} value={payload.privacyStatus ?? 'PUBLIC'} onChange={(e) => setP('privacyStatus', e.target.value)}>
<option value="PUBLIC">Public</option>
<option value="PRIVATE">Private</option>
<option value="UNLISTED">Unlisted</option>
</select>
</div>
)}
{actionType === 'SET_TEMPLATE' && (
<div className={f.field}>
<label className={f.label}>Template</label>
<select className={f.select} value={payload.templateId ?? ''} onChange={(e) => setP('templateId', e.target.value)}>
<option value=""> select </option>
{templates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
)}
{(actionType === 'ADD_TAGS' || actionType === 'REMOVE_TAGS') && (
<div className={f.field}>
<label className={f.label}>Tags (comma-separated)</label>
<input className={f.input} value={payload.tags ?? ''} onChange={(e) => setP('tags', e.target.value)} placeholder="tag1, tag2, tag3" />
</div>
)}
{actionType === 'SEARCH_REPLACE_TITLE' && (
<div className={f.row}>
<div className={f.field}>
<label className={f.label}>Search</label>
<input className={f.input} value={payload.search ?? ''} onChange={(e) => setP('search', e.target.value)} placeholder="text to find" />
</div>
<div className={f.field}>
<label className={f.label}>Replace with</label>
<input className={f.input} value={payload.replace ?? ''} onChange={(e) => setP('replace', e.target.value)} placeholder="replacement" />
</div>
</div>
)}
<div className={f.field}>
<label className={f.label}>Target Videos</label>
<select className={f.select} value={targetType} onChange={(e) => setTargetType(e.target.value as 'all' | 'view')}>
<option value="all">All videos in team</option>
<option value="view">Saved view</option>
</select>
</div>
{targetType === 'view' && (
<div className={f.field}>
<label className={f.label}>Saved View</label>
<select className={f.select} value={savedViewId} onChange={(e) => setSavedViewId(e.target.value)}>
<option value=""> select </option>
{savedViews.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
)}
{previewMut.isError && <p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)' }}>Preview failed check the backend logs.</p>}
<div className={f.actions}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => previewMut.mutate()} disabled={previewMut.isPending}>
{previewMut.isPending ? <Loader2 size={14} /> : <Eye size={14} />}
Preview Changes
</button>
</div>
</>
)}
{step === 'preview' && preview && (
<>
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-muted)' }}>
<strong>{preview.count}</strong> video{preview.count !== 1 ? 's' : ''} will be affected by <strong>{ACTION_LABELS[preview.type]}</strong>.
</p>
<div style={{ maxHeight: 320, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--text-xs)' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)', background: 'var(--color-surface-offset)' }}>
<th style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--color-text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Video</th>
<th style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--color-text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Before</th>
<th style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--color-text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>After</th>
</tr>
</thead>
<tbody>
{preview.previews.slice(0, 50).map((p) => (
<tr key={p.videoId} style={{ borderBottom: '1px solid var(--color-divider)' }}>
<td style={{ padding: '8px 12px', color: 'var(--color-text-muted)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{p.before.title}
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-text-faint)' }}>
{preview.type === 'SET_PRIVACY' && p.before.privacyStatus}
{preview.type === 'ADD_TAGS' && (p.before.tags ?? []).join(', ')}
{preview.type === 'REMOVE_TAGS' && (p.before.tags ?? []).join(', ')}
{preview.type === 'SEARCH_REPLACE_TITLE' && p.before.title}
{preview.type === 'SET_TEMPLATE' && (p.before.templateId ?? '—')}
</td>
<td style={{ padding: '8px 12px', color: 'var(--color-primary)', fontWeight: 600 }}>
{preview.type === 'SET_PRIVACY' && p.after.privacyStatus}
{preview.type === 'ADD_TAGS' && (p.after.tags ?? []).join(', ')}
{preview.type === 'REMOVE_TAGS' && (p.after.tags ?? []).join(', ')}
{preview.type === 'SEARCH_REPLACE_TITLE' && p.after.title}
{preview.type === 'SET_TEMPLATE' && (p.after.templateId ?? '—')}
</td>
</tr>
))}
</tbody>
</table>
{preview.previews.length > 50 && (
<p style={{ padding: '8px 12px', color: 'var(--color-text-faint)', fontSize: 'var(--text-xs)' }}>
+ {preview.previews.length - 50} more videos not shown
</p>
)}
</div>
{applyMut.isError && <p style={{ color: 'var(--color-error)', fontSize: 'var(--text-xs)' }}>Apply failed check the backend logs.</p>}
<div className={f.actions}>
<button className="btn btn-secondary" onClick={() => setStep('configure')}>Back</button>
<button className="btn btn-primary" onClick={() => applyMut.mutate()} disabled={applyMut.isPending}>
{applyMut.isPending ? <Loader2 size={14} /> : null}
Apply to {preview.count} Video{preview.count !== 1 ? 's' : ''}
</button>
</div>
</>
)}
</Modal>
);
}
// ─── Sync Status Indicator ────────────────────────────────────────────────────
function SyncStatusIndicator() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { data } = useQuery({
queryKey: ['syncQueueStatus'],
queryFn: fetchSyncQueueStatus,
refetchInterval: (query) => {
const d = query.state.data;
if (d?.active.length) return 3_000;
if (d?.waiting.length) return 8_000;
return 30_000;
},
});
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const activeCount = data?.active.length ?? 0;
const waitingCount = data?.waiting.length ?? 0;
const pendingCount = activeCount + waitingCount;
const hasFailures = (data?.recentFailed.length ?? 0) > 0;
function formatRelative(iso: string | null) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
return `${Math.floor(mins / 60)}h ago`;
}
return (
<div className={styles.syncWrap} ref={ref}>
<button
className={`${styles.syncBtn} ${pendingCount > 0 ? styles.syncBtnActive : ''} ${hasFailures && pendingCount === 0 ? styles.syncBtnFailed : ''}`}
onClick={() => setOpen((o) => !o)}
title="YouTube sync queue"
>
{activeCount > 0
? <Loader size={18} className={styles.syncSpinner} />
: <CloudUpload size={18} />}
{pendingCount > 0 && <span className={styles.syncBadge}>{pendingCount}</span>}
</button>
{open && (
<div className={styles.syncDropdown}>
<p className={styles.syncDropdownTitle}>YouTube sync queue</p>
{activeCount === 0 && waitingCount === 0 && data?.recentFailed.length === 0 && data?.recentCompleted.length === 0 && (
<p className={styles.syncEmpty}>Queue is empty.</p>
)}
{activeCount > 0 && (
<div className={styles.syncSection}>
<p className={styles.syncSectionLabel}>Processing</p>
{data!.active.map((j) => (
<div key={j.jobId} className={styles.syncRow}>
<Loader size={13} className={styles.syncSpinner} style={{ color: 'var(--color-primary)', flexShrink: 0 }} />
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
</div>
))}
</div>
)}
{waitingCount > 0 && (
<div className={styles.syncSection}>
<p className={styles.syncSectionLabel}>Queued</p>
{data!.waiting.map((j) => (
<div key={j.jobId} className={styles.syncRow}>
<Clock size={13} style={{ color: 'var(--color-text-faint)', flexShrink: 0 }} />
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
</div>
))}
</div>
)}
{(data?.recentFailed.length ?? 0) > 0 && (
<div className={styles.syncSection}>
<p className={styles.syncSectionLabel}>Failed</p>
{data!.recentFailed.map((j) => (
<div key={j.jobId} className={styles.syncRow}>
<XCircle size={13} style={{ color: 'var(--color-error)', flexShrink: 0 }} />
<div className={styles.syncRowInfo}>
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
{j.failedReason && <span className={styles.syncRowSub}>{j.failedReason}</span>}
</div>
<span className={styles.syncRowTime}>{formatRelative(j.failedAt)}</span>
</div>
))}
</div>
)}
{(data?.recentCompleted.length ?? 0) > 0 && (
<div className={styles.syncSection}>
<p className={styles.syncSectionLabel}>Completed</p>
{data!.recentCompleted.map((j) => (
<div key={j.jobId} className={styles.syncRow}>
<CheckCircle2 size={13} style={{ color: 'var(--color-success, #22c55e)', flexShrink: 0 }} />
<span className={styles.syncRowTitle}>{j.videoTitle}</span>
<span className={styles.syncRowTime}>{formatRelative(j.completedAt)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
// ─── Header ───────────────────────────────────────────────────────────────────
export default function Header() {
const { toggleSidebar } = useUIStore();
const { theme, toggleTheme } = useTheme();
const { user, clearAuth } = useAuthStore();
const router = useRouter();
const [bulkOpen, setBulkOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
async function handleLogout() {
try { await apiClient.post('/auth/logout'); } catch {}
clearAuth();
document.cookie = 'sf_session=; path=/; max-age=0';
router.replace('/login');
}
function handleSearch(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
const q = (e.target as HTMLInputElement).value.trim();
if (q) router.push(`/videos?search=${encodeURIComponent(q)}`);
}
}
return (
<>
<header className={styles.header}>
<div className={styles.left}>
<button onClick={toggleSidebar} className={styles.menuBtn}>
<Menu size={20} />
</button>
<label className={styles.search}>
<Search size={18} />
<input ref={searchRef} type="text" placeholder="Search videos… (Enter)" onKeyDown={handleSearch} />
</label>
</div>
<div className={styles.right}>
<SyncStatusIndicator />
<button className={styles.btnSecondary} onClick={toggleTheme}>
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
<span>Theme</span>
</button>
<button className={styles.btnSecondary} onClick={() => setBulkOpen(true)}>
<Eye size={18} />
<span>Bulk change</span>
</button>
{user && (
<div className={styles.userArea}>
<span className={styles.teamBadge}>{user.teamRole}</span>
<span className={styles.userName}>{user.name}</span>
<button onClick={handleLogout} className={styles.logoutBtn} title="Sign out">
<LogOut size={16} />
</button>
</div>
)}
</div>
</header>
{bulkOpen && <BulkModal onClose={() => setBulkOpen(false)} />}
</>
);
}
@@ -0,0 +1,62 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--space-6);
}
.dialog {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
width: 100%;
display: flex;
flex-direction: column;
max-height: 90vh;
overflow: hidden;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.title {
font-family: var(--font-display);
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text);
}
.closeBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: var(--radius-md);
color: var(--color-text-muted);
transition: all 0.15s;
}
.closeBtn:hover {
background: var(--color-surface-offset);
color: var(--color-text);
}
.body {
padding: var(--space-6);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import styles from './Modal.module.css';
interface ModalProps {
title: string;
onClose: () => void;
children: React.ReactNode;
width?: number;
}
export default function Modal({ title, onClose, children, width = 520 }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
return (
<div
className={styles.overlay}
ref={overlayRef}
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
>
<div className={styles.dialog} style={{ maxWidth: width }}>
<div className={styles.header}>
<h2 className={styles.title}>{title}</h2>
<button onClick={onClose} className={styles.closeBtn}><X size={18} /></button>
</div>
<div className={styles.body}>{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,21 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
retry: 1,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
@@ -0,0 +1,35 @@
.value {
font-family: var(--font-display);
font-size: var(--text-2xl);
line-height: 1;
font-weight: 700;
margin-bottom: var(--space-1);
}
.label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
margin-bottom: var(--space-4);
}
.bar {
height: 6px;
background: var(--color-divider);
border-radius: var(--radius-full);
overflow: hidden;
}
.fill {
height: 100%;
background: var(--color-primary);
transition: width 0.4s ease;
}
.fillWarn { background: var(--color-warning); }
.detail {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin-top: var(--space-2);
}
@@ -0,0 +1,33 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { fetchQuota } from '@/lib/api';
import styles from './QuotaWidget.module.css';
export default function QuotaWidget() {
const { data, isLoading } = useQuery({
queryKey: ['quota'],
queryFn: fetchQuota,
refetchInterval: 60_000,
});
const pct = data?.percentUsed ?? 0;
const used = data?.used ?? '—';
const limit = data?.limit ?? 10_000;
return (
<div className="panel">
<div className={styles.value}>{isLoading ? '…' : `${pct}%`}</div>
<div className={styles.label}>YouTube Quota</div>
<div className={styles.bar}>
<div
className={`${styles.fill} ${pct > 80 ? styles.fillWarn : ''}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<div className={styles.detail}>
{isLoading ? 'Loading…' : `${used.toLocaleString()} / ${limit.toLocaleString()} units`}
</div>
</div>
);
}
@@ -0,0 +1,196 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: var(--space-4);
}
.modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 560px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.title {
font-size: var(--text-lg);
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.closeBtn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
display: flex;
padding: var(--space-1);
border-radius: var(--radius-sm);
}
.closeBtn:hover { background: var(--color-bg); color: var(--color-text); }
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-4) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.loading {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.newViewBtn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
background: none;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
color: var(--color-primary);
cursor: pointer;
width: 100%;
justify-content: center;
}
.newViewBtn:hover { background: color-mix(in srgb, var(--color-primary) 5%, transparent); }
.createForm {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
background: var(--color-bg);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.nameInput, .descInput {
width: 100%;
padding: 8px var(--space-3);
font-size: var(--text-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
}
.nameInput:focus, .descInput:focus { outline: none; border-color: var(--color-primary); }
.pinLabel {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
}
.pinLabel input { accent-color: var(--color-primary); }
.createActions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.sectionTitle {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-2);
}
.viewRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
background: var(--color-surface);
}
.viewRow:last-child { margin-bottom: 0; }
.viewMeta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.viewName {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.viewDesc {
font-size: var(--text-xs);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
.viewActions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.iconBtn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
display: flex;
padding: 5px;
border-radius: var(--radius-sm);
}
.iconBtn:hover { color: var(--color-text); background: var(--color-bg); }
.iconBtn:disabled { opacity: 0.3; cursor: not-allowed; }
.iconBtnDanger:hover { color: var(--color-error); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
.empty {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-align: center;
padding: var(--space-6) 0;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@@ -0,0 +1,171 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Plus, Pin, PinOff, Trash2, ChevronUp, ChevronDown, Loader2 } from 'lucide-react';
import {
fetchSavedViews, createSavedView, updateSavedView, deleteSavedView,
type SavedView, type VideosQuery, type UserPreferences,
} from '@/lib/api';
import styles from './SavedViewManager.module.css';
interface Props {
onClose: () => void;
currentFilters: Partial<VideosQuery>;
currentColumnsJson: Record<string, unknown>;
currentSortJson: Record<string, unknown> | null;
}
export default function SavedViewManager({ onClose, currentFilters, currentColumnsJson, currentSortJson }: Props) {
const qc = useQueryClient();
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [pinNew, setPinNew] = useState(false);
const [creating, setCreating] = useState(false);
const { data: views = [], isLoading } = useQuery({
queryKey: ['savedViews'],
queryFn: fetchSavedViews,
staleTime: 30_000,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['savedViews'] });
qc.invalidateQueries({ queryKey: ['savedViewTabs'] });
};
const createMut = useMutation({
mutationFn: () => createSavedView({
name: newName.trim(),
description: newDesc.trim() || undefined,
queryJson: currentFilters as Record<string, unknown>,
columnsJson: currentColumnsJson,
sortJson: currentSortJson,
pinnedAsTab: pinNew,
tabOrder: pinNew ? (views.filter((v) => v.pinnedAsTab).length) : undefined,
}),
onSuccess: () => { invalidate(); setNewName(''); setNewDesc(''); setPinNew(false); setCreating(false); },
});
const patchMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateSavedView>[1] }) =>
updateSavedView(id, data),
onSuccess: invalidate,
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteSavedView(id),
onSuccess: invalidate,
});
const pinnedViews = views.filter((v) => v.pinnedAsTab).sort((a, b) => (a.tabOrder ?? 99) - (b.tabOrder ?? 99));
const unpinnedViews = views.filter((v) => !v.pinnedAsTab).sort((a, b) => a.name.localeCompare(b.name));
const moveTab = (view: SavedView, dir: -1 | 1) => {
const idx = pinnedViews.findIndex((v) => v.id === view.id);
const swapIdx = idx + dir;
if (swapIdx < 0 || swapIdx >= pinnedViews.length) return;
const swap = pinnedViews[swapIdx];
Promise.all([
patchMut.mutateAsync({ id: view.id, data: { tabOrder: swapIdx } }),
patchMut.mutateAsync({ id: swap.id, data: { tabOrder: idx } }),
]);
};
return (
<div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className={styles.modal}>
<div className={styles.header}>
<h2 className={styles.title}>Saved Views</h2>
<button className={styles.closeBtn} onClick={onClose}><X size={18} /></button>
</div>
<div className={styles.body}>
{/* Create new view */}
{creating ? (
<div className={styles.createForm}>
<input
className={styles.nameInput}
placeholder="View name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
autoFocus
/>
<input
className={styles.descInput}
placeholder="Description (optional)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
<label className={styles.pinLabel}>
<input type="checkbox" checked={pinNew} onChange={(e) => setPinNew(e.target.checked)} />
Pin as tab
</label>
<div className={styles.createActions}>
<button className="btn btn-secondary" onClick={() => setCreating(false)}>Cancel</button>
<button
className="btn btn-primary"
disabled={!newName.trim() || createMut.isPending}
onClick={() => createMut.mutate()}
>
{createMut.isPending ? <Loader2 size={14} className={styles.spin} /> : null}
Save view
</button>
</div>
</div>
) : (
<button className={styles.newViewBtn} onClick={() => setCreating(true)}>
<Plus size={14} /> Save current filters as view
</button>
)}
{isLoading && <div className={styles.loading}><Loader2 size={18} className={styles.spin} /> Loading</div>}
{/* Pinned as tabs */}
{pinnedViews.length > 0 && (
<section>
<div className={styles.sectionTitle}>Pinned Tabs</div>
{pinnedViews.map((view, idx) => (
<div key={view.id} className={styles.viewRow}>
<div className={styles.viewMeta}>
<span className={styles.viewName}>{view.name}</span>
{view.description && <span className={styles.viewDesc}>{view.description}</span>}
</div>
<div className={styles.viewActions}>
<button title="Move up" className={styles.iconBtn} disabled={idx === 0} onClick={() => moveTab(view, -1)}><ChevronUp size={14} /></button>
<button title="Move down" className={styles.iconBtn} disabled={idx === pinnedViews.length - 1} onClick={() => moveTab(view, 1)}><ChevronDown size={14} /></button>
<button title="Unpin" className={styles.iconBtn} onClick={() => patchMut.mutate({ id: view.id, data: { pinnedAsTab: false, tabOrder: null } })}><PinOff size={14} /></button>
<button title="Delete" className={`${styles.iconBtn} ${styles.iconBtnDanger}`} onClick={() => { if (confirm(`Delete "${view.name}"?`)) deleteMut.mutate(view.id); }}><Trash2 size={14} /></button>
</div>
</div>
))}
</section>
)}
{/* All other views */}
{unpinnedViews.length > 0 && (
<section>
<div className={styles.sectionTitle}>All Views</div>
{unpinnedViews.map((view) => (
<div key={view.id} className={styles.viewRow}>
<div className={styles.viewMeta}>
<span className={styles.viewName}>{view.name}</span>
{view.description && <span className={styles.viewDesc}>{view.description}</span>}
</div>
<div className={styles.viewActions}>
<button title="Pin as tab" className={styles.iconBtn} onClick={() => patchMut.mutate({ id: view.id, data: { pinnedAsTab: true, tabOrder: pinnedViews.length } })}><Pin size={14} /></button>
<button title="Delete" className={`${styles.iconBtn} ${styles.iconBtnDanger}`} onClick={() => { if (confirm(`Delete "${view.name}"?`)) deleteMut.mutate(view.id); }}><Trash2 size={14} /></button>
</div>
</div>
))}
</section>
)}
{!isLoading && views.length === 0 && !creating && (
<p className={styles.empty}>No saved views yet. Save your current filters to create one.</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,156 @@
.sidebar {
width: var(--sidebar-width);
height: 100vh;
background-color: var(--color-surface);
border-right: 1px solid var(--color-border);
position: sticky;
top: 0;
flex-shrink: 0;
transition: width 0.2s ease;
/* overflow visible so the edge button can protrude outside */
overflow: visible;
}
.collapsed {
width: var(--sidebar-width-collapsed);
}
/* inner scroll container — carries the padding and scrolls */
.inner {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--space-6);
overflow-y: auto;
overflow-x: hidden;
}
.collapsed .inner {
padding: var(--space-4) var(--space-2);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-10);
}
.collapsed .brand {
margin-bottom: var(--space-6);
justify-content: center;
}
.logo {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(135deg, var(--color-primary), var(--color-blue));
display: grid;
place-items: center;
color: white;
box-shadow: var(--shadow-md);
flex-shrink: 0;
}
.brandName {
font-family: var(--font-display);
font-size: var(--text-lg);
line-height: 1.1;
font-weight: 700;
}
.brandTagline {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.nav {
display: flex;
flex-direction: column;
gap: var(--space-6);
flex: 1;
}
.collapsed .nav {
gap: var(--space-4);
}
.groupTitle {
font-size: var(--text-xs);
text-transform: uppercase;
letter-spacing: .08em;
color: var(--color-text-faint);
margin-bottom: var(--space-3);
font-weight: 600;
}
.list {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.link {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 0.8rem 0.9rem;
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-muted);
transition: all 0.15s;
}
.collapsed .link {
justify-content: center;
padding: 0.7rem;
gap: 0;
}
.link:hover {
background-color: var(--color-primary-highlight);
color: var(--color-text);
}
.active {
background-color: var(--color-primary-highlight);
color: var(--color-text);
font-weight: 600;
}
.link svg {
color: inherit;
flex-shrink: 0;
}
/* edge toggle button — sits on the right border, hidden until hover */
.collapseBtn {
position: absolute;
right: -12px;
top: 72px;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background-color: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-faint);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background-color 0.15s, border-color 0.15s;
z-index: 10;
}
.sidebar:hover .collapseBtn {
opacity: 1;
}
.collapseBtn:hover {
background-color: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
+107
View File
@@ -0,0 +1,107 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Film,
Layers3,
FileStack,
CalendarRange,
Users,
Filter,
WandSparkles,
History,
Settings,
ArrowUpDown,
Import,
Braces,
BarChart2,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { clsx } from 'clsx';
import { useUIStore } from '@/store/useUIStore';
import styles from './Sidebar.module.css';
const navItems = [
{ label: 'Overview', href: '/overview', icon: LayoutDashboard, group: 'WORKSPACE' },
{ label: 'Videos', href: '/videos', icon: Film, group: 'WORKSPACE' },
{ label: 'Description Blocks', href: '/blocks', icon: Layers3, group: 'WORKSPACE' },
{ label: 'Templates', href: '/templates', icon: FileStack, group: 'WORKSPACE' },
{ label: 'Global Variables', href: '/variables', icon: Braces, group: 'WORKSPACE' },
{ label: 'Content Calendar', href: '/calendar', icon: CalendarRange, group: 'WORKSPACE' },
{ label: 'Saved Views', href: '/saved-views', icon: Filter, group: 'OPERATIONS' },
{ label: 'Metadata Linting', href: '/linting', icon: WandSparkles, group: 'OPERATIONS' },
{ label: 'Bulk Jobs', href: '/bulk-jobs', icon: ArrowUpDown, group: 'OPERATIONS' },
{ label: 'Import / Export', href: '/io', icon: Import, group: 'OPERATIONS' },
{ label: 'Collaborators', href: '/collaborators', icon: Users, group: 'PEOPLE' },
{ label: 'Settings', href: '/settings', icon: Settings, group: 'PEOPLE' },
{ label: 'Change History', href: '/audit', icon: History, group: 'LOGGING' },
{ label: 'Quota History', href: '/quota-history', icon: BarChart2, group: 'LOGGING' },
];
export default function Sidebar() {
const pathname = usePathname();
const { sidebarCollapsed, toggleSidebar } = useUIStore();
const groups = Array.from(new Set(navItems.map(item => item.group)));
return (
<aside className={clsx(styles.sidebar, sidebarCollapsed && styles.collapsed)}>
<div className={styles.inner}>
<div className={styles.brand}>
<div className={styles.logo}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7.5c0-1.1.9-2 2-2h8.4c.4 0 .8.1 1.1.3l3 1.7c.9.5 1.5 1.5 1.5 2.5v4c0 1.1-.6 2-1.5 2.5l-3 1.7c-.3.2-.7.3-1.1.3H6c-1.1 0-2-.9-2-2v-9Z"></path>
<path d="m10 9 5 3-5 3V9Z"></path>
</svg>
</div>
{!sidebarCollapsed && (
<div>
<h1 className={styles.brandName}>StudioFlow</h1>
<p className={styles.brandTagline}>YouTube upload manager planner</p>
</div>
)}
</div>
<nav className={styles.nav}>
{groups.map(group => (
<div key={group} className={styles.group}>
{!sidebarCollapsed && <h3 className={styles.groupTitle}>{group}</h3>}
<ul className={styles.list}>
{navItems.filter(item => item.group === group).map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
return (
<li key={item.href}>
<Link
href={item.href}
className={clsx(styles.link, isActive && styles.active)}
title={sidebarCollapsed ? item.label : undefined}
>
<Icon size={18} />
{!sidebarCollapsed && <span>{item.label}</span>}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
</div>
<button
className={styles.collapseBtn}
onClick={toggleSidebar}
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{sidebarCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
</button>
</aside>
);
}
@@ -0,0 +1,49 @@
/* Floating pill that appears centered at the top of each section on hover */
.topBar {
position: absolute;
top: -13px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 2px;
height: 22px;
padding: 0 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
box-shadow: var(--shadow-sm);
opacity: 0;
transition: opacity 0.15s;
z-index: 2;
white-space: nowrap;
}
div:hover > .topBar {
opacity: 1;
}
.handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 18px;
color: var(--color-text-faint);
cursor: grab;
border-radius: var(--radius-sm);
background: transparent;
border: none;
padding: 0;
transition: color 0.15s, background 0.15s;
}
.handle:active {
cursor: grabbing;
}
.handle:hover {
color: var(--color-text-muted);
background: var(--color-surface-offset);
}
@@ -0,0 +1,41 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripHorizontal } from 'lucide-react';
import styles from './SortableSection.module.css';
interface Props {
id: string;
children: React.ReactNode;
}
export default function SortableSection({ id, children }: Props) {
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.35 : 1,
position: 'relative',
}}
>
<div className={styles.topBar}>
<button
ref={setActivatorNodeRef}
className={styles.handle}
{...attributes}
{...listeners}
title="Drag to reorder"
aria-label="Drag to reorder section"
>
<GripHorizontal size={14} />
</button>
</div>
{children}
</div>
);
}
@@ -0,0 +1,80 @@
.panel {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
margin-top: var(--space-2);
}
.header {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.loading {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.empty {
font-size: var(--text-xs);
color: var(--color-text-faint);
font-style: italic;
}
.group {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.groupLabel {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.list {
display: flex;
flex-direction: column;
gap: 2px;
list-style: none;
padding: 0;
margin: 0;
}
.item {
font-size: var(--text-xs);
color: var(--color-text-muted);
padding: 2px var(--space-2);
border-left: 2px solid var(--color-border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemLink {
color: inherit;
text-decoration: none;
}
.itemLink:hover {
text-decoration: underline;
color: var(--color-accent);
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
@@ -0,0 +1,54 @@
'use client';
import Link from 'next/link';
import { Loader2, Info } from 'lucide-react';
import styles from './UsagePanel.module.css';
export interface UsageItem {
id: string;
label: string;
href?: string;
}
export interface UsageGroup {
heading: string;
items: UsageItem[];
}
interface UsagePanelProps {
groups: UsageGroup[];
isLoading: boolean;
}
export default function UsagePanel({ groups, isLoading }: UsagePanelProps) {
const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0);
return (
<div className={styles.panel}>
<div className={styles.header}>
<Info size={13} />
<span>Used in</span>
</div>
{isLoading ? (
<div className={styles.loading}><Loader2 size={13} className={styles.spin} /> Checking usage</div>
) : totalItems === 0 ? (
<p className={styles.empty}>Not used anywhere safe to delete.</p>
) : (
groups.filter((g) => g.items.length > 0).map((g) => (
<div key={g.heading} className={styles.group}>
<span className={styles.groupLabel}>{g.heading}</span>
<ul className={styles.list}>
{g.items.map((item) => (
<li key={item.id} className={styles.item}>
{item.href
? <Link href={item.href} className={styles.itemLink}>{item.label}</Link>
: item.label}
</li>
))}
</ul>
</div>
))
)}
</div>
);
}
@@ -0,0 +1,621 @@
.root {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.heading {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.loading {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-4) 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 1s linear infinite; }
/* Editor two-panel layout */
.editorLayout {
display: grid;
grid-template-columns: 220px 1fr;
gap: var(--space-4);
align-items: flex-start;
}
/* Block library */
.library {
display: flex;
flex-direction: column;
gap: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.libraryHeader {
padding: var(--space-3) var(--space-3) 0;
}
.panelTitle {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.searchWrap {
position: relative;
padding: var(--space-2) var(--space-3);
}
.searchIcon {
position: absolute;
left: calc(var(--space-3) + 8px);
top: 50%;
transform: translateY(-50%);
color: var(--color-text-faint);
pointer-events: none;
}
.searchInput {
width: 100%;
padding: var(--space-1) var(--space-2) var(--space-1) var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
font-size: var(--text-xs);
color: var(--color-text);
outline: none;
}
.searchInput:focus {
border-color: var(--color-primary);
}
.libraryList {
max-height: 340px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.libraryItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--color-divider);
gap: var(--space-2);
}
.libraryItem:hover {
background: var(--color-surface-offset);
}
.libraryItemInfo {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.libraryItemName {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.libraryItemType {
font-size: 10px;
color: var(--color-text-faint);
text-transform: uppercase;
}
.addBtn {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-primary);
background: var(--color-primary-highlight);
transition: all 0.15s;
}
.addBtn:hover:not(:disabled) {
background: var(--color-primary);
color: white;
}
.addBtn:disabled {
opacity: 0.35;
cursor: default;
}
/* Config panel */
.configPanel {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.blockList {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.blockItem {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
transition: opacity 0.15s;
}
.blockItem.inactive {
opacity: 0.5;
}
.blockIndex {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-faint);
width: 18px;
text-align: center;
flex-shrink: 0;
}
.blockInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.blockName {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.blockType {
font-size: 10px;
color: var(--color-text-faint);
text-transform: uppercase;
}
/* Toggle switch */
.toggle {
position: relative;
display: inline-block;
width: 30px;
height: 17px;
flex-shrink: 0;
cursor: pointer;
}
.toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggleSlider {
position: absolute;
inset: 0;
background: var(--color-border);
border-radius: 999px;
transition: background 0.2s;
}
.toggleSlider::before {
content: '';
position: absolute;
width: 11px;
height: 11px;
border-radius: 50%;
background: white;
top: 3px;
left: 3px;
transition: transform 0.2s;
}
.toggle input:checked + .toggleSlider {
background: var(--color-primary);
}
.toggle input:checked + .toggleSlider::before {
transform: translateX(13px);
}
.iconBtn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
flex-shrink: 0;
transition: all 0.15s;
}
.iconBtn:hover:not(:disabled) {
background: var(--color-surface-offset);
color: var(--color-text);
}
.iconBtn:disabled {
opacity: 0.3;
cursor: default;
}
.removeBtn:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-error), transparent 85%);
color: var(--color-error);
}
.compactActive {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.sectionRow {
display: flex;
align-items: center;
justify-content: space-between;
}
.sectionTitle {
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-faint);
}
.variableGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-3);
}
/* Variable metadata */
.globalBadge {
margin-left: var(--space-2);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-primary), transparent 85%);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary), transparent 70%);
}
.overrideBadge {
margin-left: var(--space-2);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-warning, #f59e0b), transparent 85%);
color: var(--color-warning, #f59e0b);
border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b), transparent 70%);
}
.varDesc {
font-size: var(--text-xs);
color: var(--color-text-faint);
margin-top: -2px;
}
/* Collaborator placeholder reference */
.refToggle {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
transition: color 0.15s;
}
.refToggle:hover {
color: var(--color-primary);
}
.refPanel {
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.refTitle {
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-muted);
}
.refList {
display: flex;
flex-direction: column;
gap: 4px;
}
.refRow {
display: flex;
align-items: flex-start;
gap: var(--space-3);
}
.refMeta {
display: flex;
flex-direction: column;
gap: 2px;
}
.refDesc {
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.refCode {
font-family: var(--font-mono, monospace);
font-size: var(--text-xs);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 1px 6px;
color: var(--color-primary);
min-width: 160px;
}
.refLabel {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.refNote {
font-size: var(--text-xs);
color: var(--color-text-faint);
border-top: 1px solid var(--color-divider);
padding-top: var(--space-2);
margin-top: var(--space-1);
line-height: 1.5;
}
/* Collaborators */
.collaboratorList {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.collaboratorChip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface);
cursor: pointer;
transition: all 0.15s;
}
.collaboratorChip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.collaboratorChip.chipSelected {
background: var(--color-primary-highlight);
border-color: var(--color-primary);
color: var(--color-primary);
}
.chipHandle {
font-weight: 400;
color: var(--color-text-faint);
font-size: 10px;
}
/* Actions */
.actions {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
/* Banners */
.successBanner {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: color-mix(in srgb, var(--color-success, #22c55e), transparent 88%);
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e), transparent 70%);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-success, #22c55e);
}
.errorBanner {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: color-mix(in srgb, var(--color-error), transparent 88%);
border: 1px solid color-mix(in srgb, var(--color-error), transparent 70%);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-error);
}
.libraryFooter {
padding: var(--space-2) var(--space-3) var(--space-3);
border-top: 1px solid var(--color-divider);
}
.freeTextBtn {
display: flex;
align-items: center;
gap: var(--space-1);
width: 100%;
padding: var(--space-2) var(--space-2);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
justify-content: center;
transition: all 0.15s;
}
.freeTextBtn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-primary-highlight);
}
.freeTextItem {
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-2);
}
.blockControls {
display: flex;
align-items: center;
gap: var(--space-1);
flex-shrink: 0;
}
.freeTextArea {
width: 100%;
margin-top: var(--space-1);
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-offset);
font-size: var(--text-xs);
color: var(--color-text);
font-family: var(--font-body);
line-height: 1.5;
resize: vertical;
outline: none;
}
.freeTextArea:focus {
border-color: var(--color-primary);
}
.empty {
font-size: var(--text-xs);
color: var(--color-text-faint);
padding: var(--space-3);
text-align: center;
}
.blockNameRow {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.overriddenBadge {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-warning, #f59e0b), transparent 85%);
color: var(--color-warning, #f59e0b);
border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b), transparent 70%);
}
.blockContentWrap {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-top: var(--space-2);
width: 100%;
}
.resetContentBtn {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 600;
color: var(--color-text-faint);
align-self: flex-start;
padding: 2px 6px;
border-radius: var(--radius-sm);
transition: all 0.15s;
}
.resetContentBtn:hover {
color: var(--color-warning, #f59e0b);
background: color-mix(in srgb, var(--color-warning, #f59e0b), transparent 90%);
}
.previewBox {
background: var(--color-surface-offset);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
padding: var(--space-4);
font-size: var(--text-sm);
color: var(--color-text-muted);
white-space: pre-wrap;
line-height: 1.6;
max-height: 500px;
overflow-y: auto;
font-family: var(--font-body);
}
@@ -0,0 +1,141 @@
'use client';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import {
fetchBlocks, fetchTemplates, fetchCollaborators, fetchTeamVariables, fetchSystemVariables,
fetchVideoConfig, upsertVideoConfig, renderVideoConfigPreview,
type UpsertVideoConfigDto, type SystemVariable,
} from '@/lib/api';
import BlockOrderEditor, { type BlockOverride } from '@/components/shared/BlockOrderEditor';
import f from '@/components/shared/FormField.module.css';
import styles from './VideoConfigEditor.module.css';
export interface VideoConfigEditorHandle {
save: () => Promise<void>;
isDirty: () => boolean;
getCollaboratorIds: () => string[];
}
interface Props {
videoId: string;
initialCollaboratorIds?: string[];
currentDescription?: string | null;
currentDescriptionLabel?: string;
onDirtyChange?: (dirty: boolean) => void;
}
const VideoConfigEditor = forwardRef<VideoConfigEditorHandle, Props>(function VideoConfigEditor(
{ videoId, initialCollaboratorIds = [], currentDescription, currentDescriptionLabel = 'Current Description', onDirtyChange },
ref,
) {
const { data: config, isLoading: configLoading } = useQuery({
queryKey: ['video-config', videoId],
queryFn: () => fetchVideoConfig(videoId),
});
const { data: allBlocks = [] } = useQuery({ queryKey: ['blocks'], queryFn: fetchBlocks });
const { data: templates = [] } = useQuery({ queryKey: ['templates'], queryFn: fetchTemplates });
const { data: collaborators = [] } = useQuery({ queryKey: ['collaborators'], queryFn: fetchCollaborators });
const { data: teamVars = [] } = useQuery({ queryKey: ['team-variables'], queryFn: fetchTeamVariables });
const { data: systemVars = [] } = useQuery<SystemVariable[]>({ queryKey: ['system-variables'], queryFn: fetchSystemVariables });
const [templateId, setTemplateId] = useState<string>('');
const [blockOrder, setBlockOrder] = useState<string[]>([]);
const [blockOverrides, setBlockOverrides] = useState<Record<string, BlockOverride>>({});
const [variableValues, setVariableValues] = useState<Record<string, string>>({});
const [collaboratorIds, setCollaboratorIds] = useState<string[]>([]);
const [preview, setPreview] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (!config) return;
setTemplateId(config.templateId ?? '');
setBlockOrder(config.blockOrder ?? []);
setBlockOverrides((config.blockOverrides as Record<string, BlockOverride>) ?? {});
setVariableValues((config.variableValues as Record<string, string>) ?? {});
setDirty(false);
onDirtyChange?.(false);
}, [config, onDirtyChange]);
useEffect(() => {
setCollaboratorIds(initialCollaboratorIds);
}, [initialCollaboratorIds]);
const toDto = (): UpsertVideoConfigDto => ({
templateId: templateId || undefined,
blockOrder,
blockOverrides,
variableValues,
collaboratorIds,
});
const saveMut = useMutation({
mutationFn: () => upsertVideoConfig(videoId, { templateId: templateId || undefined, blockOrder, blockOverrides, variableValues, autoRender: true }),
onSuccess: () => { setDirty(false); onDirtyChange?.(false); },
});
useImperativeHandle(ref, () => ({
save: () => saveMut.mutateAsync().then(() => {}),
isDirty: () => dirty,
getCollaboratorIds: () => collaboratorIds,
}), [dirty, saveMut, collaboratorIds]);
const renderMut = useMutation({
mutationFn: () => renderVideoConfigPreview(videoId, toDto()),
onSuccess: (data) => setPreview(data.rendered),
});
const mark = () => { setDirty(true); onDirtyChange?.(true); };
if (configLoading) {
return (
<div className={styles.loading}>
<Loader2 size={20} className={styles.spin} />
<span>Loading config</span>
</div>
);
}
return (
<div className={styles.root}>
<h2 className={styles.heading}>Description Config</h2>
<div className={f.field}>
<label className={f.label}>Template</label>
<select
className={f.select}
value={templateId}
onChange={(e) => { setTemplateId(e.target.value); mark(); }}
>
<option value="">(no template)</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<BlockOrderEditor
blockOrder={blockOrder}
blockOverrides={blockOverrides}
variableValues={variableValues}
collaboratorIds={collaboratorIds}
onBlockOrderChange={(v) => { setBlockOrder(v); mark(); }}
onBlockOverridesChange={(v) => { setBlockOverrides(v); mark(); }}
onVariableValuesChange={(v) => { setVariableValues(v); mark(); }}
onCollaboratorIdsChange={(v) => { setCollaboratorIds(v); mark(); }}
allBlocks={allBlocks}
collaborators={collaborators}
teamVars={teamVars}
systemVars={systemVars}
onPreview={() => renderMut.mutate()}
previewPending={renderMut.isPending}
previewResult={preview}
currentDescription={currentDescription}
currentDescriptionLabel={currentDescriptionLabel}
/>
</div>
);
});
export default VideoConfigEditor;
@@ -0,0 +1,356 @@
.tableContainer {
width: 100%;
overflow-x: auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.table th {
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--color-divider);
color: var(--color-text-faint);
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.thSortable {
cursor: pointer;
transition: color 0.15s;
}
.thSortable:hover {
color: var(--color-text);
}
.thInner {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.sortIcon {
flex-shrink: 0;
color: var(--color-primary);
}
.sortIconInactive {
color: var(--color-text-faint);
opacity: 0.5;
}
.table td {
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-divider);
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: none;
}
.table tr:hover {
background-color: var(--color-surface-offset);
}
.clickableRow {
position: relative;
cursor: pointer;
}
.rowOverlayLink {
position: absolute;
inset: 0;
z-index: 1;
}
.thumbnail {
width: 140px;
aspect-ratio: 16 / 9;
background-color: var(--color-bg);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.videoInfo {
display: flex;
flex-direction: column;
gap: 2px;
max-width: 400px;
}
.titleRow {
display: flex;
align-items: center;
gap: var(--space-2);
}
.titleText {
font-weight: 700;
font-size: var(--text-sm);
color: var(--color-text);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.descriptionText {
font-size: var(--text-xs);
color: var(--color-text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.metaRow {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: 4px;
}
.metaItem {
font-size: 11px;
font-weight: 600;
color: var(--color-text-faint);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.dot {
color: var(--color-text-faint);
}
.dateCell {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.syncBadge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 600;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-full);
}
.syncOk {
background: color-mix(in srgb, var(--color-success, #22c55e), transparent 85%);
color: var(--color-success, #22c55e);
}
.syncPending {
background: color-mix(in srgb, var(--color-primary, #6366f1), transparent 85%);
color: var(--color-primary, #6366f1);
}
.syncConflict {
background: color-mix(in srgb, var(--color-warning), transparent 85%);
color: var(--color-warning);
}
/* Privacy badges */
.privacyBadge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
font-weight: 600;
padding: 2px 8px;
border-radius: var(--radius-full, 999px);
white-space: nowrap;
}
.privacyPublic {
background: color-mix(in srgb, var(--color-success, #22c55e) 12%, transparent);
color: var(--color-success, #22c55e);
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 30%, transparent);
}
.privacyPrivate {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
}
.privacyUnlisted {
background: var(--color-surface-offset);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.privacyScheduled {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.playlistsCell {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 180px;
}
.playlistTag {
display: block;
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-text-muted);
background: var(--color-surface-offset);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 1px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlistsEmpty {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--color-text-faint);
}
.actionBtn {
color: var(--color-text-faint);
padding: 8px;
border-radius: var(--radius-md);
transition: all 0.2s;
}
.actionBtn:hover {
background-color: var(--color-primary-highlight);
color: var(--color-primary);
}
/* ── Optional column cells ── */
.cellText {
font-size: var(--text-sm);
color: var(--color-text);
}
.cellMuted {
font-size: var(--text-sm);
color: var(--color-text-faint);
}
.tagsCell {
display: flex;
flex-wrap: wrap;
gap: 3px;
max-width: 200px;
}
.tagChip {
font-size: var(--text-xs);
padding: 1px 6px;
border-radius: var(--radius-full);
background: var(--color-surface-offset, var(--color-bg));
border: 1px solid var(--color-border);
color: var(--color-text-muted);
white-space: nowrap;
}
/* ── Toolbar + filter panel ── */
.toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) 0 var(--space-2);
}
.filterToggle {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px var(--space-3);
font-size: var(--text-sm);
font-weight: 500;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.filterToggle:hover { background: var(--color-bg); color: var(--color-text); }
.filterToggleActive { border-color: var(--color-primary); color: var(--color-primary); }
.filterToggleHasActive { border-color: var(--color-primary); color: var(--color-primary); }
.filterBadge {
font-size: var(--text-xs);
background: var(--color-primary);
color: white;
border-radius: var(--radius-full);
padding: 0 5px;
min-width: 16px;
text-align: center;
}
.clearFiltersBtn {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
font-size: var(--text-sm);
color: var(--color-text-muted);
cursor: pointer;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.clearFiltersBtn:hover { color: var(--color-error); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
.filterPanel {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.filterInput, .filterSelect {
height: 32px;
padding: 0 var(--space-3);
font-size: var(--text-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
min-width: 140px;
}
.filterInput:focus, .filterSelect:focus {
outline: none;
border-color: var(--color-primary);
}
@@ -0,0 +1,649 @@
'use client';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import {
useReactTable,
getCoreRowModel,
flexRender,
createColumnHelper,
type SortingState,
type OnChangeFn,
type ColumnVisibilityState,
} from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import {
Clock, AlertTriangle, CheckCircle2, RefreshCw,
ChevronUp, ChevronDown, ChevronsUpDown,
Globe, Lock, EyeOff, CalendarClock, ListVideo,
SlidersHorizontal, X,
} from 'lucide-react';
import type { VideosQuery } from '@/lib/api';
import styles from './VideoTable.module.css';
export interface VideoRow {
id: string;
youtubeVideoId: string;
thumbnailUrl: string | null;
title: string;
description: string;
channelId: string;
publishedAt: string;
scheduledAt: string | null;
privacyStatus: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
lintStatus: 'OK' | 'WARNING' | 'ERROR';
syncStatus: 'synced' | 'pending' | 'conflict';
lastSyncedAt: string | null;
youtubeDeletedAt: string | null;
playlists: { id: string; title: string }[];
// Optional columns
tags?: string[];
categoryId?: string | null;
defaultLanguage?: string | null;
embeddable?: boolean;
license?: string | null;
selfDeclaredMadeForKids?: boolean;
recordingDate?: string | null;
updatedAt?: string;
remoteConflict?: boolean;
}
/** @deprecated use VideoRow */
export type VideoData = VideoRow;
// ─── YouTube categories ────────────────────────────────────────────────────────
const YT_CATEGORIES: Record<string, string> = {
'1': 'Film & Animation', '2': 'Autos & Vehicles', '10': 'Music',
'15': 'Pets & Animals', '17': 'Sports', '18': 'Short Movies',
'19': 'Travel & Events', '20': 'Gaming', '21': 'Videoblogging',
'22': 'People & Blogs', '23': 'Comedy', '24': 'Entertainment',
'25': 'News & Politics', '26': 'Howto & Style', '27': 'Education',
'28': 'Science & Technology', '29': 'Nonprofits & Activism',
};
// ─── Language options ──────────────────────────────────────────────────────────
const LANGUAGES = [
{ code: 'en', label: 'English' }, { code: 'de', label: 'German' },
{ code: 'fr', label: 'French' }, { code: 'es', label: 'Spanish' },
{ code: 'it', label: 'Italian' }, { code: 'pt', label: 'Portuguese' },
{ code: 'nl', label: 'Dutch' }, { code: 'pl', label: 'Polish' },
{ code: 'ru', label: 'Russian' }, { code: 'ja', label: 'Japanese' },
{ code: 'ko', label: 'Korean' }, { code: 'zh', label: 'Chinese' },
{ code: 'ar', label: 'Arabic' }, { code: 'hi', label: 'Hindi' },
{ code: 'tr', label: 'Turkish' }, { code: 'sv', label: 'Swedish' },
{ code: 'da', label: 'Danish' }, { code: 'fi', label: 'Finnish' },
{ code: 'no', label: 'Norwegian' }, { code: 'cs', label: 'Czech' },
{ code: 'hu', label: 'Hungarian' }, { code: 'ro', label: 'Romanian' },
{ code: 'uk', label: 'Ukrainian' }, { code: 'id', label: 'Indonesian' },
{ code: 'th', label: 'Thai' }, { code: 'vi', label: 'Vietnamese' },
];
// ─── Column definitions ────────────────────────────────────────────────────────
export const VIDEO_COLUMN_DEFS: { id: string; label: string; required?: boolean }[] = [
{ id: 'youtubeVideoId', label: 'Thumbnail', required: true },
{ id: 'title', label: 'Title', required: true },
{ id: 'privacyStatus', label: 'Visibility' },
{ id: 'publishedAt', label: 'Publish Date' },
{ id: 'lintStatus', label: 'Lint' },
{ id: 'playlists', label: 'Playlists' },
{ id: 'syncStatus', label: 'Sync' },
{ id: 'tags', label: 'Tags' },
{ id: 'categoryId', label: 'Category' },
{ id: 'defaultLanguage', label: 'Language' },
{ id: 'scheduledAt', label: 'Scheduled At' },
{ id: 'embeddable', label: 'Embeddable' },
{ id: 'license', label: 'License' },
{ id: 'selfDeclaredMadeForKids', label: 'Made for Kids' },
{ id: 'recordingDate', label: 'Recording Date' },
{ id: 'updatedAt', label: 'Last Modified' },
{ id: 'remoteConflict', label: 'Conflict' },
];
const columnHelper = createColumnHelper<VideoRow>();
function SortIcon({ sorted }: { sorted: false | 'asc' | 'desc' }) {
if (sorted === 'asc') return <ChevronUp size={12} className={styles.sortIcon} />;
if (sorted === 'desc') return <ChevronDown size={12} className={styles.sortIcon} />;
return <ChevronsUpDown size={12} className={`${styles.sortIcon} ${styles.sortIconInactive}`} />;
}
function PrivacyBadge({ status, scheduledAt }: { status: VideoRow['privacyStatus']; scheduledAt: string | null }) {
if (status === 'PUBLIC') return (
<span className={`${styles.privacyBadge} ${styles.privacyPublic}`}>
<Globe size={11} /> Public
</span>
);
if (status === 'PRIVATE') {
const isScheduled = scheduledAt && new Date(scheduledAt) > new Date();
if (isScheduled) return (
<span className={`${styles.privacyBadge} ${styles.privacyScheduled}`}>
<CalendarClock size={11} /> Scheduled
</span>
);
return (
<span className={`${styles.privacyBadge} ${styles.privacyPrivate}`}>
<Lock size={11} /> Private
</span>
);
}
return (
<span className={`${styles.privacyBadge} ${styles.privacyUnlisted}`}>
<EyeOff size={11} /> Unlisted
</span>
);
}
const ALL_COLUMNS = [
columnHelper.accessor('youtubeVideoId', {
header: '',
enableSorting: false,
cell: (info) => (
<div className={styles.thumbnail}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={info.row.original.thumbnailUrl ?? `https://img.youtube.com/vi/${info.getValue()}/mqdefault.jpg`}
alt="Thumbnail"
width={120}
height={68}
style={{ width: '100%', height: 'auto' }}
/>
</div>
),
}),
columnHelper.accessor('title', {
header: 'Title',
enableSorting: true,
cell: (info) => (
<div className={styles.videoInfo}>
<div className={styles.titleRow}>
<span className={styles.titleText}>{info.getValue()}</span>
{info.row.original.youtubeDeletedAt && (
<span className="pill pill-warn" style={{ fontSize: 'var(--text-xs)', whiteSpace: 'nowrap' }}>Deleted on YouTube</span>
)}
</div>
<span className={styles.descriptionText}>{info.row.original.description}</span>
<div className={styles.metaRow}>
<span className={styles.metaItem}>{info.row.original.youtubeVideoId}</span>
</div>
</div>
),
}),
columnHelper.accessor('privacyStatus', {
header: 'Visibility',
enableSorting: true,
cell: (info) => <PrivacyBadge status={info.getValue()} scheduledAt={info.row.original.scheduledAt} />,
}),
columnHelper.accessor(
(row) => row.scheduledAt ?? row.publishedAt,
{
id: 'publishedAt',
header: 'Publish Date',
enableSorting: true,
cell: (info) => {
const row = info.row.original;
const isScheduled = row.scheduledAt && new Date(row.scheduledAt) > new Date();
const date = isScheduled ? row.scheduledAt! : row.publishedAt;
return (
<div className={styles.dateCell}>
{isScheduled ? <CalendarClock size={13} /> : <Clock size={13} />}
<span>{new Date(date).toLocaleDateString()}</span>
</div>
);
},
}
),
columnHelper.accessor('lintStatus', {
header: 'Lint',
enableSorting: true,
cell: (info) => {
const s = info.getValue();
if (s === 'ERROR') return <span className="pill pill-warn">Error</span>;
if (s === 'WARNING') return <span className="pill pill-purple">Warning</span>;
return <span className="pill pill-primary">Clean</span>;
},
}),
columnHelper.accessor('playlists', {
header: 'Playlists',
enableSorting: false,
cell: (info) => {
const playlists = info.getValue();
if (!playlists?.length) return <span className={styles.playlistsEmpty}><ListVideo size={12} /> </span>;
return (
<div className={styles.playlistsCell}>
{playlists.map((pl) => (
<span key={pl.id} className={styles.playlistTag} title={pl.title}>{pl.title}</span>
))}
</div>
);
},
}),
columnHelper.accessor('syncStatus', {
header: 'Sync',
enableSorting: true,
cell: (info) => {
const s = info.getValue();
const lastSynced = info.row.original.lastSyncedAt;
if (s === 'conflict') return (
<span className={`${styles.syncBadge} ${styles.syncConflict}`} title="YouTube changed this video independently — review before pushing">
<AlertTriangle size={12} /> Conflict
</span>
);
if (s === 'pending') return (
<span className={`${styles.syncBadge} ${styles.syncPending}`} title="Local changes not yet pushed to YouTube">
<RefreshCw size={12} /> Push pending
</span>
);
return (
<span className={`${styles.syncBadge} ${styles.syncOk}`} title={lastSynced ? `Last pushed ${new Date(lastSynced).toLocaleString()}` : 'In sync with YouTube'}>
<CheckCircle2 size={12} /> In sync
</span>
);
},
}),
// ── Optional columns ──────────────────────────────────────────────────────
columnHelper.accessor('tags', {
id: 'tags',
header: 'Tags',
enableSorting: false,
cell: (info) => {
const tags = info.getValue() ?? [];
if (!tags.length) return <span className={styles.cellMuted}></span>;
return (
<div className={styles.tagsCell}>
{tags.slice(0, 4).map((t) => <span key={t} className={styles.tagChip}>{t}</span>)}
{tags.length > 4 && <span className={styles.cellMuted}>+{tags.length - 4}</span>}
</div>
);
},
}),
columnHelper.accessor('categoryId', {
id: 'categoryId',
header: 'Category',
enableSorting: true,
cell: (info) => {
const id = info.getValue();
return <span className={styles.cellText}>{id ? (YT_CATEGORIES[id] ?? id) : '—'}</span>;
},
}),
columnHelper.accessor('defaultLanguage', {
id: 'defaultLanguage',
header: 'Language',
enableSorting: true,
cell: (info) => {
const code = info.getValue();
if (!code) return <span className={styles.cellMuted}></span>;
const lang = LANGUAGES.find((l) => l.code === code);
return <span className={styles.cellText}>{lang?.label ?? code}</span>;
},
}),
columnHelper.accessor('scheduledAt', {
id: 'scheduledAt',
header: 'Scheduled At',
enableSorting: true,
cell: (info) => {
const v = info.getValue();
return v ? <span className={styles.cellText}>{new Date(v).toLocaleDateString()}</span> : <span className={styles.cellMuted}></span>;
},
}),
columnHelper.accessor('embeddable', {
id: 'embeddable',
header: 'Embeddable',
enableSorting: true,
cell: (info) => {
const v = info.getValue();
return v === undefined ? <span className={styles.cellMuted}></span> : <span className={styles.cellText}>{v ? 'Yes' : 'No'}</span>;
},
}),
columnHelper.accessor('license', {
id: 'license',
header: 'License',
enableSorting: true,
cell: (info) => {
const v = info.getValue();
if (!v) return <span className={styles.cellMuted}></span>;
return <span className={styles.cellText}>{v === 'creativeCommon' ? 'CC' : 'YouTube'}</span>;
},
}),
columnHelper.accessor('selfDeclaredMadeForKids', {
id: 'selfDeclaredMadeForKids',
header: 'Made for Kids',
enableSorting: true,
cell: (info) => {
const v = info.getValue();
return v === undefined ? <span className={styles.cellMuted}></span> : <span className={styles.cellText}>{v ? 'Yes' : 'No'}</span>;
},
}),
columnHelper.accessor('recordingDate', {
id: 'recordingDate',
header: 'Recording Date',
enableSorting: true,
cell: (info) => {
const v = info.getValue();
return v ? <span className={styles.cellText}>{new Date(v).toLocaleDateString()}</span> : <span className={styles.cellMuted}></span>;
},
}),
columnHelper.accessor('updatedAt', {
id: 'updatedAt',
header: 'Last Modified',
enableSorting: true,
cell: (info) => {
const v = info.getValue();
return v ? <span className={styles.cellText}>{new Date(v).toLocaleDateString()}</span> : <span className={styles.cellMuted}></span>;
},
}),
columnHelper.accessor('remoteConflict', {
id: 'remoteConflict',
header: 'Conflict',
enableSorting: true,
cell: (info) => {
const v = info.getValue();
if (!v) return <span className={styles.cellMuted}>No</span>;
return <span className={`${styles.syncBadge} ${styles.syncConflict}`}><AlertTriangle size={12} /> Yes</span>;
},
}),
];
// ─── Filter config per column ─────────────────────────────────────────────────
interface FilterField {
key: keyof VideosQuery;
type: 'text' | 'select';
placeholder?: string;
options?: { value: string; label: string }[];
}
const COLUMN_FILTERS: Partial<Record<string, FilterField>> = {
title: { key: 'search', type: 'text', placeholder: 'Search title…' },
privacyStatus: {
key: 'privacyStatus', type: 'select',
options: [
{ value: '', label: 'All' },
{ value: 'PUBLIC', label: 'Public' },
{ value: 'PRIVATE', label: 'Private' },
{ value: 'UNLISTED', label: 'Unlisted' },
],
},
lintStatus: {
key: 'lintStatus', type: 'select',
options: [
{ value: '', label: 'All' },
{ value: 'OK', label: 'Clean' },
{ value: 'WARNING', label: 'Warning' },
{ value: 'ERROR', label: 'Error' },
],
},
syncStatus: {
key: 'pendingSync', type: 'select',
options: [
{ value: '', label: 'All' },
{ value: 'pending', label: 'Push Pending' },
{ value: 'conflict', label: 'Conflict' },
],
},
tags: { key: 'tagsSearch', type: 'text', placeholder: 'Filter by tag…' },
categoryId: {
key: 'categoryId', type: 'select',
options: [
{ value: '', label: 'All' },
...Object.entries(YT_CATEGORIES).map(([v, label]) => ({ value: v, label })),
],
},
defaultLanguage: {
key: 'defaultLanguage', type: 'select',
options: [
{ value: '', label: 'All' },
...LANGUAGES.map((l) => ({ value: l.code, label: l.label })),
],
},
embeddable: {
key: 'embeddable', type: 'select',
options: [{ value: '', label: 'All' }, { value: 'true', label: 'Yes' }, { value: 'false', label: 'No' }],
},
license: {
key: 'license', type: 'select',
options: [{ value: '', label: 'All' }, { value: 'youtube', label: 'YouTube' }, { value: 'creativeCommon', label: 'Creative Commons' }],
},
selfDeclaredMadeForKids: {
key: 'selfDeclaredMadeForKids', type: 'select',
options: [{ value: '', label: 'All' }, { value: 'true', label: 'Yes' }, { value: 'false', label: 'No' }],
},
remoteConflict: {
key: 'remoteConflict', type: 'select',
options: [{ value: '', label: 'All' }, { value: 'true', label: 'Yes' }, { value: 'false', label: 'No' }],
},
};
// ─── FilterPanel ──────────────────────────────────────────────────────────────
function FilterPanel({
visibleColumnIds,
filters,
onFiltersChange,
}: {
visibleColumnIds: string[];
filters: Partial<VideosQuery>;
onFiltersChange: (patch: Partial<VideosQuery>) => void;
}) {
const [titleValue, setTitleValue] = useState((filters.search as string) ?? '');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setTitleValue((filters.search as string) ?? '');
}, [filters.search]);
const handleText = useCallback((key: keyof VideosQuery, value: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
setTitleValue(value);
debounceRef.current = setTimeout(() => {
onFiltersChange({ [key]: value || undefined });
}, 300);
}, [onFiltersChange]);
const handleSelect = useCallback((key: keyof VideosQuery, rawValue: string) => {
if (key === 'pendingSync') {
if (rawValue === 'pending') onFiltersChange({ pendingSync: true, remoteConflict: undefined });
else if (rawValue === 'conflict') onFiltersChange({ pendingSync: undefined, remoteConflict: true });
else onFiltersChange({ pendingSync: undefined, remoteConflict: undefined });
return;
}
if (key === 'embeddable' || key === 'selfDeclaredMadeForKids' || key === 'remoteConflict') {
onFiltersChange({ [key]: rawValue === '' ? undefined : rawValue === 'true' });
return;
}
onFiltersChange({ [key]: rawValue || undefined });
}, [onFiltersChange]);
const filterableColumns = visibleColumnIds.filter((id) => COLUMN_FILTERS[id]);
if (!filterableColumns.length) return null;
const getSelectValue = (key: keyof VideosQuery): string => {
if (key === 'pendingSync') {
if (filters.pendingSync) return 'pending';
if (filters.remoteConflict) return 'conflict';
return '';
}
const v = filters[key];
if (v === undefined || v === null) return '';
return String(v);
};
return (
<div className={styles.filterPanel}>
{filterableColumns.map((colId) => {
const cfg = COLUMN_FILTERS[colId]!;
if (cfg.type === 'text') {
return (
<input
key={colId}
className={styles.filterInput}
placeholder={cfg.placeholder}
value={colId === 'title' ? titleValue : String(filters[cfg.key] ?? '')}
onChange={(e) => handleText(cfg.key, e.target.value)}
/>
);
}
return (
<select
key={colId}
className={styles.filterSelect}
value={getSelectValue(cfg.key)}
onChange={(e) => handleSelect(cfg.key, e.target.value)}
>
{cfg.options!.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
);
})}
</div>
);
}
// ─── VideoTable ───────────────────────────────────────────────────────────────
const ACTIVE_FILTER_KEYS: (keyof VideosQuery)[] = [
'search', 'lintStatus', 'privacyStatus', 'pendingSync', 'remoteConflict',
'tagsSearch', 'categoryId', 'defaultLanguage', 'embeddable', 'license', 'selfDeclaredMadeForKids',
];
export default function VideoTable({
data,
sorting,
onSortingChange,
columnVisibility,
onColumnVisibilityChange,
columnOrder,
filters = {},
onFiltersChange,
}: {
data: VideoRow[];
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
columnVisibility?: ColumnVisibilityState;
onColumnVisibilityChange?: (v: ColumnVisibilityState) => void;
columnOrder?: string[];
filters?: Partial<VideosQuery>;
onFiltersChange?: (patch: Partial<VideosQuery>) => void;
}) {
const router = useRouter();
const [filterOpen, setFilterOpen] = useState(false);
const activeFilterCount = ACTIVE_FILTER_KEYS.filter((k) => filters[k] !== undefined && filters[k] !== '').length;
const table = useReactTable({
data,
columns: ALL_COLUMNS,
state: {
sorting,
columnVisibility: columnVisibility ?? {},
columnOrder: columnOrder ?? [],
},
onSortingChange,
onColumnVisibilityChange: onColumnVisibilityChange as any,
onColumnOrderChange: undefined,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
});
const visibleColumnIds = useMemo(
() => table.getVisibleLeafColumns().map((c) => c.id),
// eslint-disable-next-line react-hooks/exhaustive-deps
[columnVisibility, columnOrder],
);
const handleFilterChange = useCallback(
(patch: Partial<VideosQuery>) => onFiltersChange?.(patch),
[onFiltersChange],
);
const clearFilters = () => {
const clear: Partial<VideosQuery> = {};
ACTIVE_FILTER_KEYS.forEach((k) => { (clear as any)[k] = undefined; });
onFiltersChange?.(clear);
};
return (
<div>
{onFiltersChange && (
<div className={styles.toolbar}>
<button
className={`${styles.filterToggle} ${filterOpen ? styles.filterToggleActive : ''} ${activeFilterCount > 0 ? styles.filterToggleHasActive : ''}`}
onClick={() => setFilterOpen((o) => !o)}
>
<SlidersHorizontal size={14} />
Filter
{activeFilterCount > 0 && (
<span className={styles.filterBadge}>{activeFilterCount}</span>
)}
</button>
{activeFilterCount > 0 && (
<button className={styles.clearFiltersBtn} onClick={clearFilters}>
<X size={12} /> Clear filters
</button>
)}
</div>
)}
{filterOpen && onFiltersChange && (
<FilterPanel
visibleColumnIds={visibleColumnIds}
filters={filters}
onFiltersChange={handleFilterChange}
/>
)}
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const canSort = header.column.getCanSort();
const sorted = header.column.getIsSorted();
return (
<th
key={header.id}
className={canSort ? styles.thSortable : ''}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
style={{ userSelect: canSort ? 'none' : undefined }}
>
{header.isPlaceholder ? null : (
<span className={styles.thInner}>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort && <SortIcon sorted={sorted} />}
</span>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className={styles.clickableRow}>
{row.getVisibleCells().map((cell, cellIndex) => (
<td key={cell.id}>
{cellIndex === 0 && (
<a
href={`/videos/${row.original.id}`}
className={styles.rowOverlayLink}
onClick={(e) => { e.preventDefault(); router.push(`/videos/${row.original.id}`); }}
tabIndex={-1}
aria-hidden="true"
/>
)}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,47 @@
'use client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { fetchPreferences, patchPreferences } from '@/lib/api';
import { useCallback } from 'react';
export type TableKey = 'videos' | 'linting';
const DEFAULTS: Record<TableKey, { visible: string[]; order: string[] }> = {
videos: {
visible: ['youtubeVideoId', 'title', 'privacyStatus', 'publishedAt', 'lintStatus', 'playlists', 'syncStatus'],
order: ['youtubeVideoId', 'title', 'privacyStatus', 'publishedAt', 'lintStatus', 'playlists', 'syncStatus'],
},
linting: {
visible: ['title', 'ruleCode', 'severity', 'message', 'status'],
order: ['title', 'ruleCode', 'severity', 'message', 'status'],
},
};
export function useColumnPreferences(tableKey: TableKey) {
const qc = useQueryClient();
const { data: prefs } = useQuery({
queryKey: ['preferences'],
queryFn: fetchPreferences,
staleTime: 5 * 60_000,
});
const saved = prefs?.tableColumns?.[tableKey];
const visible = saved?.visible ?? DEFAULTS[tableKey].visible;
const order = saved?.order ?? DEFAULTS[tableKey].order;
const setColumns = useCallback(
async (nextVisible: string[], nextOrder: string[]) => {
const patch = {
tableColumns: {
...prefs?.tableColumns,
[tableKey]: { visible: nextVisible, order: nextOrder },
},
};
qc.setQueryData(['preferences'], (old: any) => ({ ...old, ...patch }));
await patchPreferences(patch);
},
[prefs, tableKey, qc],
);
return { visible, order, setColumns };
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { useEffect, useState } from 'react';
export function useTheme() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
const initialTheme = savedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
setTheme(initialTheme);
document.documentElement.setAttribute('data-theme', initialTheme);
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
};
return { theme, toggleTheme };
}
+72
View File
@@ -0,0 +1,72 @@
import axios from 'axios';
import { useAuthStore } from '@/store/useAuthStore';
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1',
headers: { 'Content-Type': 'application/json' },
withCredentials: true,
});
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
let isRefreshing = false;
let pendingQueue: Array<(token: string) => void> = [];
function drainQueue(token: string) {
pendingQueue.forEach((cb) => cb(token));
pendingQueue = [];
}
apiClient.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config as typeof error.config & { _retry?: boolean };
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
original._retry = true;
if (isRefreshing) {
return new Promise((resolve) => {
pendingQueue.push((token) => {
original.headers.Authorization = `Bearer ${token}`;
resolve(apiClient(original));
});
});
}
isRefreshing = true;
try {
const { data } = await apiClient.post<{ accessToken: string }>('/auth/refresh');
const newToken = data.accessToken;
const { user, setAuth } = useAuthStore.getState();
if (user) setAuth(newToken, user);
const secure = typeof window !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
if (typeof document !== 'undefined') {
document.cookie = `sf_session=1; path=/; SameSite=Lax${secure}`;
}
drainQueue(newToken);
original.headers.Authorization = `Bearer ${newToken}`;
return apiClient(original);
} catch {
useAuthStore.getState().clearAuth();
if (typeof document !== 'undefined') {
document.cookie = 'sf_session=; path=/; max-age=0';
}
if (typeof window !== 'undefined') window.location.href = '/login';
return Promise.reject(error);
} finally {
isRefreshing = false;
}
},
);
export default apiClient;
+822
View File
@@ -0,0 +1,822 @@
import apiClient from './api-client';
// ─── Videos ───────────────────────────────────────────────────────────────────
export interface Video {
id: string;
youtubeVideoId: string;
channelId: string;
title: string;
youtubeDescription: string | null;
renderedDescription: string | null;
tags: string[];
privacyStatus: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
publishedAt: string | null;
scheduledAt: string | null;
lintStatus: 'OK' | 'WARNING' | 'ERROR';
thumbnailUrl: string | null;
lastSyncedAt: string | null;
remoteConflict: boolean;
hasPendingChanges: boolean;
youtubeDeletedAt: string | null;
createdAt: string;
updatedAt?: string;
playlists: { playlist: { id: string; title: string } }[];
// Optional metadata fields
categoryId?: string | null;
defaultLanguage?: string | null;
embeddable?: boolean;
license?: string | null;
selfDeclaredMadeForKids?: boolean;
recordingDate?: string | null;
}
export interface VideoPage {
total: number;
page: number;
limit: number;
items: Video[];
}
export interface VideosQuery {
search?: string;
lintStatus?: string;
hasLintIssues?: boolean;
privacyStatus?: string;
scheduled?: boolean;
notScheduled?: boolean;
remoteConflict?: boolean;
pendingSync?: boolean;
deletedOnYouTube?: boolean;
tagsSearch?: string;
categoryId?: string;
defaultLanguage?: string;
embeddable?: boolean;
license?: string;
selfDeclaredMadeForKids?: boolean;
page?: number;
limit?: number;
sort?: string;
order?: 'asc' | 'desc';
}
export const fetchVideos = (query: VideosQuery = {}) =>
apiClient.get<VideoPage>('/videos', { params: query }).then((r) => r.data);
export const fetchVideoCount = (query: VideosQuery = {}) =>
apiClient.get<VideoPage>('/videos', { params: { ...query, limit: 1 } }).then((r) => r.data.total);
export interface VideoOverviewStats {
pendingSync: { count: number; items: { id: string; title: string }[] };
recentlySynced: { items: { id: string; title: string; lastSyncedAt: string }[] };
upcomingScheduled: { count: number; items: { id: string; title: string; scheduledAt: string }[] };
}
export const fetchVideoOverviewStats = () =>
apiClient.get<VideoOverviewStats>('/videos/stats/overview').then((r) => r.data);
export interface YoutubeSnapshot {
title: string;
tags: string[];
categoryId: string | null;
privacyStatus: string;
defaultLanguage: string | null;
defaultAudioLanguage: string | null;
selfDeclaredMadeForKids: boolean;
embeddable: boolean;
license: string;
recordingDate: string | null;
}
export interface VideoDetail extends Video {
tags: string[];
categoryId: string | null;
templateId: string | null;
renderedDescription: string | null;
thumbnailUrl: string | null;
collaboratorIds: string[];
lintResults: { severity: string; ruleCode: string; message: string }[];
config: { blockOrder: string[]; variableValues: Record<string, string> } | null;
// Extended metadata
madeForKids: boolean;
selfDeclaredMadeForKids: boolean;
containsPaidPromotion: boolean;
ageRestricted: boolean;
embeddable: boolean;
license: string;
defaultLanguage: string | null;
defaultAudioLanguage: string | null;
recordingDate: string | null;
gameTitle: string | null;
youtubeSnapshot: YoutubeSnapshot | null;
}
export const fetchVideo = (id: string) =>
apiClient.get<VideoDetail>(`/videos/${id}`).then((r) => r.data);
export interface VideoUpdate {
title?: string;
tags?: string[];
privacyStatus?: 'PUBLIC' | 'PRIVATE' | 'UNLISTED';
scheduledAt?: string;
categoryId?: string;
templateId?: string;
collaboratorIds?: string[];
// Extended metadata (madeForKids, ageRestricted, containsPaidPromotion are read-only from YouTube)
selfDeclaredMadeForKids?: boolean;
embeddable?: boolean;
license?: string;
defaultLanguage?: string;
defaultAudioLanguage?: string;
recordingDate?: string;
gameTitle?: string;
}
export const updateVideo = (id: string, data: VideoUpdate) =>
apiClient.patch<VideoDetail>(`/videos/${id}`, data).then((r) => r.data);
// ─── User Preferences ─────────────────────────────────────────────────────────
export const fetchPreferences = () =>
apiClient.get<UserPreferences>('/auth/me/preferences').then((r) => r.data);
export const patchPreferences = (patch: Partial<UserPreferences>) =>
apiClient.patch<UserPreferences>('/auth/me/preferences', patch).then((r) => r.data);
// ─── System Variables ─────────────────────────────────────────────────────────
export interface SystemVariable {
token: string;
placeholder: string;
label: string;
description: string;
example: string;
group: 'collaborator' | 'video';
}
export const fetchSystemVariables = () =>
apiClient.get<SystemVariable[]>('/system-variables').then((r) => r.data);
// ─── Team Variables ───────────────────────────────────────────────────────────
export interface TeamVariable {
id: string;
name: string;
value: string;
createdAt: string;
}
export const fetchTeamVariables = () =>
apiClient.get<TeamVariable[]>('/team-variables').then((r) => r.data);
export const createTeamVariable = (data: { name: string; value: string }) =>
apiClient.post<TeamVariable>('/team-variables', data).then((r) => r.data);
export const updateTeamVariable = (id: string, data: { name?: string; value?: string }) =>
apiClient.patch<TeamVariable>(`/team-variables/${id}`, data).then((r) => r.data);
export const deleteTeamVariable = (id: string) =>
apiClient.delete(`/team-variables/${id}`).then((r) => r.data);
export const fetchVariableUsage = (id: string) =>
apiClient.get<{ blocks: { id: string; name: string }[] }>(`/team-variables/${id}/usage`).then((r) => r.data);
// ─── Blocks ───────────────────────────────────────────────────────────────────
export interface BlockVariableDefinition {
name: string;
label: string;
description?: string;
defaultValue?: string;
}
export type ConditionRuleType = 'variable_filled' | 'variable_empty' | 'collab_count';
export interface VariableFilledRule { type: 'variable_filled'; variable: string }
export interface VariableEmptyRule { type: 'variable_empty'; variable: string }
export interface CollabCountRule { type: 'collab_count'; operator: 'eq' | 'gt' | 'lt' | 'gte' | 'lte'; value: number }
export type ConditionRule = VariableFilledRule | VariableEmptyRule | CollabCountRule;
export interface BlockCondition {
combinator: 'and' | 'or';
rules: ConditionRule[];
}
export interface Block {
id: string;
name: string;
type: string;
content: string;
language: string;
version: number;
active: boolean;
compact: boolean;
tags: string[];
variableDefinitions: BlockVariableDefinition[];
campaignId: string | null;
condition: BlockCondition | null;
createdAt: string;
}
export interface Campaign {
id: string;
name: string;
startAt: string;
endAt: string | null;
status: string;
}
export const fetchCampaigns = () =>
apiClient.get<Campaign[]>('/campaigns').then((r) => r.data);
export const fetchBlocks = () =>
apiClient.get<Block[]>('/blocks').then((r) => r.data);
// ─── Templates ────────────────────────────────────────────────────────────────
export interface Template {
id: string;
name: string;
description: string | null;
defaultBlocks: string[];
defaultOverrides: Record<string, { content?: string; active?: boolean; compact?: boolean }>;
rules: Record<string, unknown>;
variables: Record<string, string>;
videoFields: Record<string, unknown> | null;
version: number;
active: boolean;
createdAt: string;
}
export interface ApplyTemplateOptions {
applyVideoFields: boolean;
applyDescriptionConfig: boolean;
}
export interface ApplyTemplateResult {
appliedFields: string[];
}
export const fetchTemplates = () =>
apiClient.get<Template[]>('/templates').then((r) => r.data);
export const fetchTemplate = (id: string) =>
apiClient.get<Template>(`/templates/${id}`).then((r) => r.data);
export const applyTemplate = (templateId: string, videoId: string, options: ApplyTemplateOptions) =>
apiClient.post<ApplyTemplateResult>(`/templates/${templateId}/apply/${videoId}`, options).then((r) => r.data);
// ─── Collaborators ────────────────────────────────────────────────────────────
export interface Collaborator {
id: string;
name: string;
youtubeLink: string | null;
twitchLink: string | null;
instagramLink: string | null;
tiktokLink: string | null;
twitterLink: string | null;
blueskyLink: string | null;
discordHandle: string | null;
aliases: string[];
active: boolean;
notes: string;
createdAt: string;
}
export const fetchCollaborators = () =>
apiClient.get<Collaborator[]>('/collaborators').then((r) => r.data);
// ─── Bulk Jobs ────────────────────────────────────────────────────────────────
export interface BulkJob {
id: string;
type: string;
status: string;
totalCount: number;
successCount: number;
errorCount: number;
createdAt: string;
completedAt: string | null;
}
export const fetchBulkJobs = (status?: string) =>
apiClient.get<BulkJob[]>('/bulk-jobs', { params: status ? { status } : {} }).then((r) => r.data);
export interface PushPendingFieldDiff { before: string | null; after: string | null }
export interface PushPendingTagsDiff { before: string[]; after: string[] }
export interface PushPendingBoolDiff { before: boolean; after: boolean }
export interface PushPendingPreviewItem {
videoId: string;
title: string;
thumbnailUrl: string | null;
firstSync: boolean;
changedFields: string[];
diff: {
title?: PushPendingFieldDiff;
description?: PushPendingFieldDiff;
privacyStatus?: PushPendingFieldDiff;
tags?: PushPendingTagsDiff;
categoryId?: PushPendingFieldDiff;
defaultLanguage?: PushPendingFieldDiff;
defaultAudioLanguage?: PushPendingFieldDiff;
selfDeclaredMadeForKids?: PushPendingBoolDiff;
embeddable?: PushPendingBoolDiff;
license?: PushPendingFieldDiff;
recordingDate?: PushPendingFieldDiff;
};
}
export const fetchPushPendingPreview = (sort = 'publishedAt', order = 'desc') =>
apiClient.get<PushPendingPreviewItem[]>('/bulk-jobs/push-pending/preview', { params: { sort, order } }).then((r) => r.data);
export const confirmBulkPush = (videoIds: string[]) =>
apiClient.post<{ bulkJobId: string; count: number }>('/bulk-jobs/push-pending', { videoIds }).then((r) => r.data);
// ─── Quota ────────────────────────────────────────────────────────────────────
export interface QuotaStatus {
used: number;
remaining: number;
limit: number;
resetAt: string;
percentUsed: number;
}
export const fetchQuota = () =>
apiClient.get<QuotaStatus>('/quota/today').then((r) => r.data);
export interface QuotaLogEntry {
id: string;
operation: string;
units: number;
channelId: string | null;
channelName: string | null;
videoId: string | null;
videoTitle: string | null;
youtubeVideoId: string | null;
entityId: string | null;
entityLabel: string | null;
actionId: string | null;
actionType: string | null;
createdAt: string;
}
export interface QuotaHistory {
items: QuotaLogEntry[];
totalUnits: number;
}
export const fetchQuotaHistory = (days = 7) =>
apiClient.get<QuotaHistory>('/quota/history', { params: { days } }).then((r) => r.data);
// ─── Channel Import ───────────────────────────────────────────────────────────
export interface ChannelImportResult {
total: number;
created: number;
updated: number;
deleted: number;
deletedTitles: string[];
}
export const triggerChannelImport = (channelId: string) =>
apiClient.post<ChannelImportResult>('/youtube-sync/channel-import', { channelId }).then((r) => r.data);
export interface SyncQueueJob {
jobId: string;
videoId: string;
videoTitle: string;
addedAt: string | null;
}
export interface SyncQueueStatus {
active: SyncQueueJob[];
waiting: SyncQueueJob[];
recentFailed: (SyncQueueJob & { failedReason: string | null; failedAt: string | null })[];
recentCompleted: (SyncQueueJob & { completedAt: string | null })[];
}
export const fetchSyncQueueStatus = () =>
apiClient.get<SyncQueueStatus>('/youtube-sync/queue-status').then((r) => r.data);
// ─── Teams / Channels ─────────────────────────────────────────────────────────
export interface TeamChannel {
id: string;
name: string;
youtubeChannelId: string;
}
export const fetchMyChannels = (teamId: string) =>
apiClient.get<TeamChannel[]>(`/teams/${teamId}/channels`).then((r) => r.data);
// ─── Team settings ────────────────────────────────────────────────────────────
export interface PublishingSlot {
days: number[]; // 0=Sun … 6=Sat; empty array = every day
time: string; // "HH:MM" 24h
}
export interface TeamSettings {
dateFormat: string | null;
timezone: string;
publishingSchedule: PublishingSlot[] | null;
showCanvaLink: boolean;
disabledLintRules: string[];
showDeletedVideos: boolean;
conflictDetectionEnabled: boolean;
conflictDetectionBatchSize: number;
conflictDetectionMinAgeDays: number;
}
export const fetchTeamSettings = (teamId: string) =>
apiClient.get<TeamSettings>(`/teams/${teamId}/settings`).then((r) => r.data);
export const updateTeamSettings = (teamId: string, data: Partial<TeamSettings>) =>
apiClient.patch<TeamSettings>(`/teams/${teamId}/settings`, data).then((r) => r.data);
export const fetchNextPublishSlot = (teamId: string, channelId: string) =>
apiClient.get<{ slot: string | null }>(`/teams/${teamId}/next-publish-slot?channelId=${channelId}`)
.then((r) => r.data);
// ─── Block mutations ──────────────────────────────────────────────────────────
export interface BlockPayload {
name: string;
type: string;
content: string;
language: string;
tags: string[];
active: boolean;
compact?: boolean;
variableDefinitions?: BlockVariableDefinition[];
campaignId?: string | null;
condition?: BlockCondition | null;
}
export const createBlock = (data: BlockPayload) =>
apiClient.post<Block>('/blocks', data).then((r) => r.data);
export const updateBlock = (id: string, data: Partial<BlockPayload>) =>
apiClient.patch<Block>(`/blocks/${id}`, data).then((r) => r.data);
export const deleteBlock = (id: string) =>
apiClient.delete(`/blocks/${id}`).then((r) => r.data);
export const fetchBlockUsage = (id: string) =>
apiClient.get<{ videos: { id: string; title: string }[]; templates: { id: string; name: string }[] }>(`/blocks/${id}/usage`).then((r) => r.data);
// ─── Template mutations ───────────────────────────────────────────────────────
export interface TemplatePayload {
name: string;
description?: string;
active?: boolean;
defaultBlocks?: string[];
defaultOverrides?: Record<string, { content?: string; active?: boolean; compact?: boolean }>;
rules?: Record<string, unknown>;
variables?: Record<string, string>;
videoFields?: Record<string, unknown> | null;
}
export const createTemplate = (data: TemplatePayload) =>
apiClient.post<Template>('/templates', data).then((r) => r.data);
export const updateTemplate = (id: string, data: Partial<TemplatePayload>) =>
apiClient.patch<Template>(`/templates/${id}`, data).then((r) => r.data);
export const deleteTemplate = (id: string) =>
apiClient.delete(`/templates/${id}`).then((r) => r.data);
export const fetchTemplateUsage = (id: string) =>
apiClient.get<{ videos: { id: string; title: string }[] }>(`/templates/${id}/usage`).then((r) => r.data);
export const renderTemplatePreview = (id: string, variableValues: Record<string, string>) =>
apiClient.post<{ rendered: string }>(`/templates/${id}/render-preview`, { variableValues }).then((r) => r.data);
export const fetchCollaboratorVideos = (id: string) =>
apiClient.get<{ id: string; title: string; publishedAt: string | null }[]>(`/collaborators/${id}/videos`).then((r) => r.data);
// ─── Collaborator mutations ───────────────────────────────────────────────────
export interface CollaboratorPayload {
name: string;
youtubeLink?: string;
twitchLink?: string;
instagramLink?: string;
tiktokLink?: string;
twitterLink?: string;
blueskyLink?: string;
discordHandle?: string;
aliases: string[];
notes?: string;
active: boolean;
}
export const createCollaborator = (data: CollaboratorPayload) =>
apiClient.post<Collaborator>('/collaborators', data).then((r) => r.data);
export const updateCollaborator = (id: string, data: Partial<CollaboratorPayload>) =>
apiClient.patch<Collaborator>(`/collaborators/${id}`, data).then((r) => r.data);
export const deleteCollaborator = (id: string) =>
apiClient.delete(`/collaborators/${id}`).then((r) => r.data);
// ─── Video Configs ────────────────────────────────────────────────────────────
export interface VideoConfig {
id: string;
videoId: string;
templateId: string | null;
blockOrder: string[];
blockOverrides: Record<string, { content?: string; active?: boolean; compact?: boolean }>;
variableValues: Record<string, string>;
version: number;
}
export interface UpsertVideoConfigDto {
templateId?: string;
blockOrder: string[];
blockOverrides: Record<string, { content?: string; active?: boolean; compact?: boolean }>;
variableValues: Record<string, string>;
collaboratorIds?: string[];
autoRender?: boolean;
}
export const fetchVideoConfig = (videoId: string) =>
apiClient.get<VideoConfig>(`/video-configs/${videoId}`).then((r) => r.data);
export const upsertVideoConfig = (videoId: string, dto: UpsertVideoConfigDto) =>
apiClient.put<VideoConfig>(`/video-configs/${videoId}`, dto).then((r) => r.data);
export interface RenderPreviewResult {
rendered: string;
}
export const renderVideoConfigPreview = (videoId: string, dto: UpsertVideoConfigDto) =>
apiClient.post<RenderPreviewResult>(`/video-configs/${videoId}/render-preview`, dto).then((r) => r.data);
export const syncVideo = (videoId: string) =>
apiClient.post(`/videos/${videoId}/sync`).then((r) => r.data);
export const renderVideoDescription = (videoId: string) =>
apiClient.post<RenderPreviewResult>(`/videos/${videoId}/render`).then((r) => r.data);
export const refreshVideoFromYouTube = (videoId: string) =>
apiClient.post<VideoDetail>(`/videos/${videoId}/refresh`).then((r) => r.data);
// ─── Playlists ────────────────────────────────────────────────────────────────
export interface Playlist {
id: string;
channelId: string;
youtubePlaylistId: string;
title: string;
description: string | null;
itemCount: number;
privacyStatus: string;
}
export const fetchChannelPlaylists = (channelId: string) =>
apiClient.get<Playlist[]>(`/playlists/channel/${channelId}`).then((r) => r.data);
export const syncChannelPlaylists = (channelId: string) =>
apiClient.post<Playlist[]>(`/playlists/channel/${channelId}/sync`).then((r) => r.data);
export const fetchVideoPlaylists = (videoId: string) =>
apiClient.get<Playlist[]>(`/playlists/video/${videoId}`).then((r) => r.data);
export const addVideoToPlaylist = (videoId: string, playlistId: string) =>
apiClient.post(`/playlists/video/${videoId}/add/${playlistId}`).then((r) => r.data);
export const removeVideoFromPlaylist = (videoId: string, playlistId: string) =>
apiClient.delete(`/playlists/video/${videoId}/remove/${playlistId}`).then((r) => r.data);
// ─── Exports ──────────────────────────────────────────────────────────────────
export const exportCsv = async (videoIds?: string[], savedViewId?: string): Promise<void> => {
const res = await apiClient.post('/exports/csv', { videoIds, savedViewId }, { responseType: 'blob' });
const url = URL.createObjectURL(res.data as Blob);
const a = document.createElement('a');
a.href = url;
a.download = `studioflow-export-${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
};
// ─── Saved Views ──────────────────────────────────────────────────────────────
export interface SavedView {
id: string;
name: string;
description?: string | null;
isGlobal: boolean;
queryJson: Record<string, unknown>;
columnsJson: Record<string, unknown>;
sortJson?: Record<string, unknown> | null;
pinnedAsTab: boolean;
tabOrder?: number | null;
createdAt: string;
updatedAt: string;
}
export interface UserPreferences {
tableColumns?: {
videos?: { visible: string[]; order: string[] };
linting?: { visible: string[]; order: string[] };
};
videoTabs?: {
systemOrder?: string[];
hiddenSystem?: string[];
};
}
export const fetchSavedViews = () =>
apiClient.get<SavedView[]>('/saved-views').then((r) => r.data);
export const fetchSavedViewTabs = () =>
apiClient.get<SavedView[]>('/saved-views/tabs').then((r) => r.data);
export const createSavedView = (data: {
name: string;
description?: string;
queryJson: Record<string, unknown>;
columnsJson: Record<string, unknown>;
sortJson?: Record<string, unknown> | null;
pinnedAsTab?: boolean;
tabOrder?: number;
}) => apiClient.post<SavedView>('/saved-views', data).then((r) => r.data);
export const updateSavedView = (id: string, data: Partial<{
name: string;
description: string;
queryJson: Record<string, unknown>;
columnsJson: Record<string, unknown>;
sortJson: Record<string, unknown> | null;
pinnedAsTab: boolean;
tabOrder: number | null;
}>) => apiClient.patch(`/saved-views/${id}`, data).then((r) => r.data);
export const deleteSavedView = (id: string) =>
apiClient.delete(`/saved-views/${id}`).then((r) => r.data);
// ─── Bulk operations ──────────────────────────────────────────────────────────
export type BulkActionType =
| 'SET_PRIVACY'
| 'SET_TEMPLATE'
| 'ADD_TAGS'
| 'REMOVE_TAGS'
| 'SEARCH_REPLACE_TITLE';
export interface BulkPreviewRequest {
type: BulkActionType;
payload: Record<string, unknown>;
videoIds?: string[];
savedViewId?: string;
}
export interface BulkPreviewItem {
videoId: string;
before: { id: string; title: string; tags: string[]; privacyStatus: string; templateId: string | null };
after: { id: string; title: string; tags: string[]; privacyStatus: string; templateId: string | null };
}
export interface BulkPreviewResponse {
type: BulkActionType;
count: number;
previews: BulkPreviewItem[];
}
export const bulkPreview = (dto: BulkPreviewRequest) =>
apiClient.post<BulkPreviewResponse>('/videos/bulk/preview', dto).then((r) => r.data);
export const bulkApply = (dto: BulkPreviewRequest) =>
apiClient.post<{ bulkJobId: string; count: number }>('/videos/bulk/apply', dto).then((r) => r.data);
// ─── Teams ────────────────────────────────────────────────────────────────────
export interface TeamMember {
userId: string;
role: 'OWNER' | 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'READONLY';
createdAt: string;
user: { id: string; email: string; name: string | null };
}
export interface TeamChannel {
id: string;
name: string;
youtubeChannelId: string;
uploadsPlaylistId: string | null;
connectedBy: string | null;
createdAt: string;
}
export interface TeamDetail {
id: string;
name: string;
members: TeamMember[];
channels: TeamChannel[];
}
export const fetchTeam = (teamId: string) =>
apiClient.get<TeamDetail>(`/teams/${teamId}`).then((r) => r.data);
export const inviteTeamMember = (teamId: string, email: string, role: string) =>
apiClient.post(`/teams/${teamId}/members`, { email, role }).then((r) => r.data);
export const updateMemberRole = (teamId: string, userId: string, role: string) =>
apiClient.patch(`/teams/${teamId}/members/${userId}`, { role }).then((r) => r.data);
export const removeTeamMember = (teamId: string, userId: string) =>
apiClient.delete(`/teams/${teamId}/members/${userId}`).then((r) => r.data);
export interface FullRefreshResult {
total: number;
created: number;
updated: number;
duplicateUploadsEntries: number;
notFoundOnYouTube: string[];
playlistsForceSynced: number;
orphansImported: number;
orphansRejected: number;
deleted: number;
deletedTitles: string[];
}
export const fullRefreshChannel = (channelId: string) =>
apiClient.post<FullRefreshResult>('/youtube-sync/channel-full-refresh', { channelId }).then((r) => r.data);
export interface PurgeResult {
checked: number;
deleted: number;
deletedTitles: string[];
}
export const purgeDeletedVideos = (channelId: string) =>
apiClient.post<PurgeResult>('/youtube-sync/channel-purge-deleted', { channelId }).then((r) => r.data);
// ─── Audit Logs ───────────────────────────────────────────────────────────────
export interface AuditActor {
id: string;
name: string | null;
email: string;
}
export interface AuditLog {
id: string;
actorId: string;
actor: AuditActor | null;
entityType: string;
entityId: string;
action: string;
beforeJson: Record<string, unknown> | null;
afterJson: Record<string, unknown> | null;
requestId: string | null;
createdAt: string;
}
export interface AuditLogPage {
data: AuditLog[];
total: number;
page: number;
limit: number;
}
export const fetchAuditLogs = (params: { page?: number; limit?: number; entityType?: string; action?: string }) =>
apiClient.get<AuditLogPage>('/audit-logs', { params }).then((r) => r.data);
// ─── Linting ──────────────────────────────────────────────────────────────────
export interface LintResultEntry {
id: string;
ruleCode: string;
severity: 'ERROR' | 'WARNING' | 'INFO';
targetField: string | null;
message: string;
fixSuggestion: string | null;
createdAt: string;
video: { id: string; title: string };
}
export const fetchLintResults = (params?: { severity?: string; ruleCode?: string }) =>
apiClient.get<LintResultEntry[]>('/lint/results', { params }).then((r) => r.data);
export const resolveLintResult = (id: string) =>
apiClient.patch(`/lint/results/${id}/resolve`).then((r) => r.data);
export const bulkResolveLintResults = (ids: string[]) =>
apiClient.post('/lint/results/bulk-resolve', { ids }).then((r) => r.data);
export const runLintForChannel = (channelId: string) =>
apiClient.post<{ queued: number }>(`/lint/channel/${channelId}`).then((r) => r.data);
export const runLintForTeam = () =>
apiClient.post<{ queued: number }>('/lint/team').then((r) => r.data);
export const recomputeLintStatus = () =>
apiClient.post<{ checked: number; updated: number }>('/lint/team/recompute-status').then((r) => r.data);
+50
View File
@@ -0,0 +1,50 @@
export const YT_CATEGORIES: { id: string; name: string }[] = [
{ id: '1', name: 'Film & Animation' },
{ id: '2', name: 'Autos & Vehicles' },
{ id: '10', name: 'Music' },
{ id: '15', name: 'Pets & Animals' },
{ id: '17', name: 'Sports' },
{ id: '18', name: 'Short Movies' },
{ id: '19', name: 'Travel & Events' },
{ id: '20', name: 'Gaming' },
{ id: '21', name: 'Videoblogging' },
{ id: '22', name: 'People & Blogs' },
{ id: '23', name: 'Comedy' },
{ id: '24', name: 'Entertainment' },
{ id: '25', name: 'News & Politics' },
{ id: '26', name: 'Howto & Style' },
{ id: '27', name: 'Education' },
{ id: '28', name: 'Science & Technology' },
{ id: '29', name: 'Nonprofits & Activism' },
];
export const LANGUAGE_OPTIONS: { code: string; name: string }[] = [
{ code: '', name: '— None —' },
{ code: 'en', name: 'English' },
{ code: 'de', name: 'German' },
{ code: 'fr', name: 'French' },
{ code: 'es', name: 'Spanish' },
{ code: 'pt', name: 'Portuguese' },
{ code: 'it', name: 'Italian' },
{ code: 'nl', name: 'Dutch' },
{ code: 'ru', name: 'Russian' },
{ code: 'ja', name: 'Japanese' },
{ code: 'ko', name: 'Korean' },
{ code: 'zh', name: 'Chinese' },
{ code: 'ar', name: 'Arabic' },
{ code: 'hi', name: 'Hindi' },
{ code: 'tr', name: 'Turkish' },
{ code: 'pl', name: 'Polish' },
{ code: 'sv', name: 'Swedish' },
{ code: 'da', name: 'Danish' },
{ code: 'fi', name: 'Finnish' },
{ code: 'nb', name: 'Norwegian' },
];
export const CATEGORY_MAP = Object.fromEntries(YT_CATEGORIES.map((c) => [c.id, c.name]));
export const LANGUAGE_MAP = Object.fromEntries(LANGUAGE_OPTIONS.filter((l) => l.code).map((l) => [l.code, l.name]));
export const LICENSE_OPTIONS: { value: string; label: string }[] = [
{ value: 'youtube', label: 'Standard YouTube License' },
{ value: 'creativeCommon', label: 'Creative Commons — Attribution' },
];
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PUBLIC_PATHS = ['/login', '/auth/callback'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
const hasSession = request.cookies.has('sf_session');
if (!isPublic && !hasSession) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (pathname === '/login' && hasSession) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
+30
View File
@@ -0,0 +1,30 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface AuthUser {
id: string;
email: string;
name: string;
isAppAdmin: boolean;
teamId: string;
teamRole: string;
}
interface AuthState {
token: string | null;
user: AuthUser | null;
setAuth: (token: string, user: AuthUser) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setAuth: (token, user) => set({ token, user }),
clearAuth: () => set({ token: null, user: null }),
}),
{ name: 'sf-auth' },
),
);
+21
View File
@@ -0,0 +1,21 @@
'use client';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UIState {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
}
export const useUIStore = create<UIState>()(
persist(
(set) => ({
sidebarCollapsed: false,
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
}),
{ name: 'ui-store' },
),
);
+207
View File
@@ -0,0 +1,207 @@
@import url('https://api.fontshare.com/v2/css?f[]=general-sans@400,500,600,700&f[]=cabinet-grotesk@500,700&display=swap');
:root {
/* Light Theme Tokens */
--text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
--text-sm: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
--text-base: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
--text-lg: clamp(1.125rem, 1rem + 0.75vw, 1.5rem);
--text-xl: clamp(1.5rem, 1.2rem + 1.25vw, 2.25rem);
--text-2xl: clamp(2rem, 1.2rem + 2.5vw, 3.5rem);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--color-bg: #f7f6f2;
--color-surface: #f9f8f5;
--color-surface-2: #fbfbf9;
--color-surface-offset: #f3f0ec;
--color-border: #d4d1ca;
--color-divider: #dcd9d5;
--color-text: #28251d;
--color-text-muted: #66645d;
--color-text-faint: #9f9c94;
--color-text-inverse: #f9f8f4;
--color-primary: #01696f;
--color-primary-hover: #0c4e54;
--color-primary-highlight: #cedcd8;
--color-success: #437a22;
--color-warning: #964219;
--color-error: #a12c7b;
--color-blue: #006494;
--color-purple: #7a39bb;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
--shadow-sm: 0 1px 2px oklch(0.2 0.01 80 / 0.06);
--shadow-md: 0 4px 12px oklch(0.2 0.01 80 / 0.08);
--shadow-lg: 0 12px 32px oklch(0.2 0.01 80 / 0.12);
--font-body: 'General Sans', Inter, sans-serif;
--font-display: 'Cabinet Grotesk', Inter, sans-serif;
--sidebar-width: 280px;
--sidebar-width-collapsed: 64px;
--header-height: 72px;
}
[data-theme="dark"] {
--color-bg: #171614;
--color-surface: #1c1b19;
--color-surface-2: #201f1d;
--color-surface-offset: #22211f;
--color-border: #393836;
--color-divider: #262523;
--color-text: #cdccca;
--color-text-muted: #9d9c99;
--color-text-faint: #666560;
--color-text-inverse: #171614;
--color-primary: #4f98a3;
--color-primary-hover: #227f8b;
--color-primary-highlight: #313b3b;
--color-success: #6daa45;
--color-warning: #bb653b;
--color-error: #d163a7;
--color-blue: #5591c7;
--color-purple: #a86fdf;
--shadow-sm: 0 1px 2px oklch(0 0 0 / 0.2);
--shadow-md: 0 4px 12px oklch(0 0 0 / 0.3);
--shadow-lg: 0 12px 32px oklch(0 0 0 / 0.4);
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-font-smoothing: antialiased;
scroll-behavior: smooth;
}
body {
min-height: 100vh;
font-family: var(--font-body);
font-size: var(--text-base);
line-height: 1.6;
background: var(--color-bg);
color: var(--color-text);
transition: background-color 0.3s, color 0.3s;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
border: none;
background: none;
}
a {
color: inherit;
text-decoration: none;
}
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 3px;
border-radius: var(--radius-sm);
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg);
}
::-webkit-scrollbar-thumb {
background: var(--color-divider);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border);
}
/* Utility Classes based on Concept */
.panel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
box-shadow: var(--shadow-sm);
}
.pill {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.pill-primary { background: var(--color-primary-highlight); color: var(--color-primary); }
.pill-blue { background: color-mix(in srgb, var(--color-blue), transparent 85%); color: var(--color-blue); }
.pill-warn { background: color-mix(in srgb, var(--color-warning), transparent 85%); color: var(--color-warning); }
.pill-purple { background: color-mix(in srgb, var(--color-purple), transparent 85%); color: var(--color-purple); }
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0.75rem 1.25rem;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-primary);
color: white;
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--color-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.btn-secondary:hover {
background: var(--color-surface-offset);
border-color: var(--color-text-faint);
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}