Initial commit: YouTube Studio Flow (backend, frontend, infrastructure, docs)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.production.local
|
||||
*.log
|
||||
.git
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"next/typescript"
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
Generated
+5850
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 "Add Variable" 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from 'react';
|
||||
import CallbackHandler from './CallbackHandler';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<CallbackHandler />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "New collaborator" 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 "Render Preview" 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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).*)'],
|
||||
};
|
||||
@@ -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' },
|
||||
),
|
||||
);
|
||||
@@ -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' },
|
||||
),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user