Compare commits
10
Commits
5a1f9a2e50
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49cfe2d8e0 | ||
|
|
0ac1ca3b08 | ||
|
|
b8c4cebad5 | ||
|
|
baf827df29 | ||
|
|
ba409ca53b | ||
|
|
811c14ee73 | ||
|
|
ba8c7185a7 | ||
|
|
b122ab4ac0 | ||
|
|
d6ff99e8a4 | ||
|
|
b51c79e889 |
@@ -3,6 +3,7 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
backend/dist/
|
backend/dist/
|
||||||
|
backend/tsconfig.tsbuildinfo
|
||||||
frontend/.next/
|
frontend/.next/
|
||||||
frontend/tsconfig.tsbuildinfo
|
frontend/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
.env
|
.env
|
||||||
.env.development
|
.env.development
|
||||||
.env.production
|
.env.production
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "BulkJobItem" DROP CONSTRAINT "BulkJobItem_videoId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "LintResult" DROP CONSTRAINT "LintResult_videoId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "VideoConfig" DROP CONSTRAINT "VideoConfig_videoId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "VideoPlaylist" DROP CONSTRAINT "VideoPlaylist_videoId_fkey";
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VideoConfig" ADD CONSTRAINT "VideoConfig_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LintResult" ADD CONSTRAINT "LintResult_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "BulkJobItem" ADD CONSTRAINT "BulkJobItem_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VideoPlaylist" ADD CONSTRAINT "VideoPlaylist_videoId_fkey" FOREIGN KEY ("videoId") REFERENCES "Video"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -174,7 +174,7 @@ enum LintStatus {
|
|||||||
model VideoConfig {
|
model VideoConfig {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
videoId String @unique
|
videoId String @unique
|
||||||
video Video @relation(fields: [videoId], references: [id])
|
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||||
templateId String?
|
templateId String?
|
||||||
blockOrder Json
|
blockOrder Json
|
||||||
blockOverrides Json
|
blockOverrides Json
|
||||||
@@ -312,7 +312,7 @@ model SavedView {
|
|||||||
model LintResult {
|
model LintResult {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
videoId String
|
videoId String
|
||||||
video Video @relation(fields: [videoId], references: [id])
|
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||||
ruleCode String
|
ruleCode String
|
||||||
severity LintSeverity
|
severity LintSeverity
|
||||||
targetField String?
|
targetField String?
|
||||||
@@ -369,7 +369,7 @@ model BulkJobItem {
|
|||||||
bulkJobId String
|
bulkJobId String
|
||||||
bulkJob BulkJob @relation(fields: [bulkJobId], references: [id])
|
bulkJob BulkJob @relation(fields: [bulkJobId], references: [id])
|
||||||
videoId String
|
videoId String
|
||||||
video Video @relation(fields: [videoId], references: [id])
|
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||||
beforeSnapshot Json?
|
beforeSnapshot Json?
|
||||||
afterSnapshot Json?
|
afterSnapshot Json?
|
||||||
status String @default("pending")
|
status String @default("pending")
|
||||||
@@ -415,7 +415,7 @@ model VideoPlaylist {
|
|||||||
videoId String
|
videoId String
|
||||||
playlistId String
|
playlistId String
|
||||||
position Int?
|
position Int?
|
||||||
video Video @relation(fields: [videoId], references: [id])
|
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||||
playlist Playlist @relation(fields: [playlistId], references: [id])
|
playlist Playlist @relation(fields: [playlistId], references: [id])
|
||||||
|
|
||||||
@@id([videoId, playlistId])
|
@@id([videoId, playlistId])
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export class AuthService {
|
|||||||
youtubeChannelId,
|
youtubeChannelId,
|
||||||
name: channelName,
|
name: channelName,
|
||||||
uploadsPlaylistId,
|
uploadsPlaylistId,
|
||||||
|
supplementalVideoIds: [],
|
||||||
youtubeAccessToken: this.encrypt(accessToken),
|
youtubeAccessToken: this.encrypt(accessToken),
|
||||||
youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined,
|
youtubeRefreshToken: refreshToken ? this.encrypt(refreshToken) : undefined,
|
||||||
youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
|
youtubeTokenExpiry: new Date(Date.now() + 3600 * 1000),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"target": "ES2021",
|
"target": "ES2021",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -17,5 +18,6 @@
|
|||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"forceConsistentCasingInFileNames": false,
|
"forceConsistentCasingInFileNames": false,
|
||||||
"noFallthroughCasesInSwitch": false
|
"noFallthroughCasesInSwitch": false
|
||||||
}
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "prisma"]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ The production setup is a single-host Docker Compose deployment using **Traefik*
|
|||||||
| `postgres` | postgres:16-alpine | Database — bound to `127.0.0.1:5432` (not public) |
|
| `postgres` | postgres:16-alpine | Database — bound to `127.0.0.1:5432` (not public) |
|
||||||
| `redis` | redis:7-alpine | BullMQ queues — `noeviction` policy, AOF persistence |
|
| `redis` | redis:7-alpine | BullMQ queues — `noeviction` policy, AOF persistence |
|
||||||
|
|
||||||
**SSL / routing**: Traefik handles TLS termination with automatic Let's Encrypt certificates. Both API (`/api` prefix) and frontend run on the same domain — Traefik routes by path prefix. The `traefik-network` external network must exist before deploy.
|
**SSL / routing**: Traefik handles TLS termination with automatic Let's Encrypt certificates. Both API (`/api` prefix) and frontend run on the same domain — Traefik routes by path prefix. The `proxy` external network (the host's existing Traefik network) must exist before deploy.
|
||||||
|
|
||||||
**Build**: Multi-stage Dockerfile (`node:22-alpine`). Builder compiles TypeScript; runner installs prod-only deps. The Prisma CLI is copied from builder stage so the `migrate` service can run schema migrations.
|
**Build**: Multi-stage Dockerfile (`node:22-alpine`). Builder compiles TypeScript; runner installs prod-only deps. The Prisma CLI is copied from builder stage so the `migrate` service can run schema migrations.
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ const RULE_CATALOG = [
|
|||||||
{ code: 'REMOTE_CONFLICT', severity: 'ERROR', label: 'Remote conflict', desc: 'The video was modified on YouTube after the last sync — local and remote diverged.' },
|
{ 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;
|
] as const;
|
||||||
|
|
||||||
type RuleCode = typeof RULE_CATALOG[number]['code'];
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SevBadge({ sev }: { sev: string }) {
|
function SevBadge({ sev }: { sev: string }) {
|
||||||
@@ -156,7 +154,7 @@ export default function LintingPage() {
|
|||||||
function toggleSelect(id: string) {
|
function toggleSelect(id: string) {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.has(id) ? next.delete(id) : next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -172,7 +170,7 @@ export default function LintingPage() {
|
|||||||
function toggleFix(id: string) {
|
function toggleFix(id: string) {
|
||||||
setExpandedFix((prev) => {
|
setExpandedFix((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.has(id) ? next.delete(id) : next.add(id);
|
if (next.has(id)) next.delete(id); else next.add(id);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,7 +483,7 @@ export default function SettingsPage() {
|
|||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Publishing Schedule</h2>
|
<h2 className={styles.sectionTitle}>Publishing Schedule</h2>
|
||||||
<p className={styles.sectionDesc}>
|
<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.
|
Define the time slots when videos are allowed to publish. Used by the "Next free slot" feature in the video editor.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={styles.scheduleBox}>
|
<div className={styles.scheduleBox}>
|
||||||
@@ -581,7 +581,7 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
<span>Show Canva link in video editor</span>
|
<span>Show Canva link in video editor</span>
|
||||||
<span className={styles.toggleDesc}>
|
<span className={styles.toggleDesc}>
|
||||||
Adds a link next to YouTube Studio that searches Canva for the video's Game Title.
|
Adds a link next to YouTube Studio that searches Canva for the video's Game Title.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
@@ -601,7 +601,7 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
<span>Show deleted videos tab</span>
|
<span>Show deleted videos tab</span>
|
||||||
<span className={styles.toggleDesc}>
|
<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.
|
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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ function TagsDiff({ before, after }: { before: string[]; after: string[] }) {
|
|||||||
|
|
||||||
function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem }) {
|
function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem }) {
|
||||||
const label = FIELD_LABELS[field] ?? field;
|
const label = FIELD_LABELS[field] ?? field;
|
||||||
const diff = item.diff as any;
|
const d = item.diff[field as keyof typeof item.diff];
|
||||||
const d = diff[field];
|
|
||||||
if (!d) return null;
|
if (!d) return null;
|
||||||
|
|
||||||
if (field === 'tags') {
|
if (field === 'tags') {
|
||||||
@@ -47,7 +46,7 @@ function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem
|
|||||||
<tr>
|
<tr>
|
||||||
<td className={styles.diffField}>{label}</td>
|
<td className={styles.diffField}>{label}</td>
|
||||||
<td className={styles.diffBefore}>{(d.before as string[]).join(', ') || '—'}</td>
|
<td className={styles.diffBefore}>{(d.before as string[]).join(', ') || '—'}</td>
|
||||||
<td className={styles.diffAfter}><TagsDiff before={d.before} after={d.after} /></td>
|
<td className={styles.diffAfter}><TagsDiff before={d.before as string[]} after={d.after as string[]} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -60,7 +59,7 @@ function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const fmt = (v: any) => {
|
const fmt = (v: string | number | boolean | string[] | null | undefined) => {
|
||||||
if (v === null || v === undefined) return '—';
|
if (v === null || v === undefined) return '—';
|
||||||
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
|
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
|
||||||
return String(v);
|
return String(v);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-ki
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, ExternalLink, Loader2, AlertCircle, CheckCircle2,
|
ArrowLeft, ExternalLink, Loader2, AlertCircle, CheckCircle2,
|
||||||
Save, Tag, Shield, Calendar, Hash, RefreshCw, RotateCcw,
|
Save, Tag, Shield, Calendar, Hash, RefreshCw, RotateCcw,
|
||||||
Plus, Minus, Layers3, X, Upload, CloudOff, Eye, EyeOff, Search, CalendarPlus,
|
Layers3, X, Upload, CloudOff, Eye, EyeOff, Search, CalendarPlus,
|
||||||
ChevronDown, ChevronUp,
|
ChevronDown, ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
@@ -34,7 +34,7 @@ import SortableSection from '@/components/shared/SortableSection';
|
|||||||
import f from '@/components/shared/FormField.module.css';
|
import f from '@/components/shared/FormField.module.css';
|
||||||
import styles from './page.module.css';
|
import styles from './page.module.css';
|
||||||
|
|
||||||
const PRIVACY_LABELS = { PUBLIC: 'Public', PRIVATE: 'Private', UNLISTED: 'Unlisted' } as const;
|
const PRIVACY_LABELS: Record<string, string> = { PUBLIC: 'Public', PRIVATE: 'Private', UNLISTED: 'Unlisted' };
|
||||||
|
|
||||||
function utcToLocalInput(utcIso: string): string {
|
function utcToLocalInput(utcIso: string): string {
|
||||||
const d = new Date(utcIso);
|
const d = new Date(utcIso);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { Download, Loader2, AlertCircle, RefreshCw, CheckCircle2, X, Plus } from 'lucide-react';
|
import { Download, Loader2, AlertCircle, RefreshCw, CheckCircle2, Plus } from 'lucide-react';
|
||||||
import { type SortingState, type ColumnVisibilityState } from '@tanstack/react-table';
|
import { type SortingState, type VisibilityState } from '@tanstack/react-table';
|
||||||
import VideoTable, { VideoRow, VIDEO_COLUMN_DEFS } from '@/components/video-table/VideoTable';
|
import VideoTable, { VideoRow, VIDEO_COLUMN_DEFS } from '@/components/video-table/VideoTable';
|
||||||
import ColumnPicker from '@/components/shared/ColumnPicker';
|
import ColumnPicker from '@/components/shared/ColumnPicker';
|
||||||
import SavedViewManager from '@/components/shared/SavedViewManager';
|
import SavedViewManager from '@/components/shared/SavedViewManager';
|
||||||
@@ -107,9 +107,9 @@ function parseExtraFilters(searchParams: URLSearchParams): Partial<VideosQuery>
|
|||||||
const v = searchParams.get(k);
|
const v = searchParams.get(k);
|
||||||
if (v !== null) {
|
if (v !== null) {
|
||||||
if (k === 'embeddable' || k === 'selfDeclaredMadeForKids') {
|
if (k === 'embeddable' || k === 'selfDeclaredMadeForKids') {
|
||||||
(result as any)[k] = v === 'true';
|
(result as Record<string, string | boolean>)[k] = v === 'true';
|
||||||
} else {
|
} else {
|
||||||
(result as any)[k] = v;
|
(result as Record<string, string | boolean>)[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ export default function VideosPage() {
|
|||||||
|
|
||||||
const { visible, order, setColumns } = useColumnPreferences('videos');
|
const { visible, order, setColumns } = useColumnPreferences('videos');
|
||||||
|
|
||||||
const columnVisibility = useMemo<ColumnVisibilityState>(() => {
|
const columnVisibility = useMemo<VisibilityState>(() => {
|
||||||
const allIds = VIDEO_COLUMN_DEFS.map((c) => c.id);
|
const allIds = VIDEO_COLUMN_DEFS.map((c) => c.id);
|
||||||
return Object.fromEntries(allIds.map((id) => [id, visible.includes(id)]));
|
return Object.fromEntries(allIds.map((id) => [id, visible.includes(id)]));
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
@@ -193,14 +193,6 @@ export default function VideosPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}, [searchParams, router]);
|
}, [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)
|
// Build combined filters for API (base tab query + extra overlays)
|
||||||
const urlSearch = searchParams.get('search') ?? undefined;
|
const urlSearch = searchParams.get('search') ?? undefined;
|
||||||
const urlLintStatus = searchParams.get('lintStatus') ?? undefined;
|
const urlLintStatus = searchParams.get('lintStatus') ?? undefined;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { X, Plus, Pin, PinOff, Trash2, ChevronUp, ChevronDown, Loader2 } from 'lucide-react';
|
import { X, Plus, Pin, PinOff, Trash2, ChevronUp, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
fetchSavedViews, createSavedView, updateSavedView, deleteSavedView,
|
fetchSavedViews, createSavedView, updateSavedView, deleteSavedView,
|
||||||
type SavedView, type VideosQuery, type UserPreferences,
|
type SavedView, type VideosQuery,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import styles from './SavedViewManager.module.css';
|
import styles from './SavedViewManager.module.css';
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
type OnChangeFn,
|
type OnChangeFn,
|
||||||
type ColumnVisibilityState,
|
type VisibilityState,
|
||||||
|
type Updater,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -522,8 +523,8 @@ export default function VideoTable({
|
|||||||
data: VideoRow[];
|
data: VideoRow[];
|
||||||
sorting: SortingState;
|
sorting: SortingState;
|
||||||
onSortingChange: OnChangeFn<SortingState>;
|
onSortingChange: OnChangeFn<SortingState>;
|
||||||
columnVisibility?: ColumnVisibilityState;
|
columnVisibility?: VisibilityState;
|
||||||
onColumnVisibilityChange?: (v: ColumnVisibilityState) => void;
|
onColumnVisibilityChange?: (v: VisibilityState) => void;
|
||||||
columnOrder?: string[];
|
columnOrder?: string[];
|
||||||
filters?: Partial<VideosQuery>;
|
filters?: Partial<VideosQuery>;
|
||||||
onFiltersChange?: (patch: Partial<VideosQuery>) => void;
|
onFiltersChange?: (patch: Partial<VideosQuery>) => void;
|
||||||
@@ -542,7 +543,10 @@ export default function VideoTable({
|
|||||||
columnOrder: columnOrder ?? [],
|
columnOrder: columnOrder ?? [],
|
||||||
},
|
},
|
||||||
onSortingChange,
|
onSortingChange,
|
||||||
onColumnVisibilityChange: onColumnVisibilityChange as any,
|
onColumnVisibilityChange: ((updater: Updater<VisibilityState>) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(columnVisibility ?? {}) : updater;
|
||||||
|
onColumnVisibilityChange?.(next);
|
||||||
|
}) as OnChangeFn<VisibilityState>,
|
||||||
onColumnOrderChange: undefined,
|
onColumnOrderChange: undefined,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
manualSorting: true,
|
manualSorting: true,
|
||||||
@@ -561,7 +565,7 @@ export default function VideoTable({
|
|||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
const clear: Partial<VideosQuery> = {};
|
const clear: Partial<VideosQuery> = {};
|
||||||
ACTIVE_FILTER_KEYS.forEach((k) => { (clear as any)[k] = undefined; });
|
ACTIVE_FILTER_KEYS.forEach((k) => { (clear as Record<string, undefined>)[k] = undefined; });
|
||||||
onFiltersChange?.(clear);
|
onFiltersChange?.(clear);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { fetchPreferences, patchPreferences } from '@/lib/api';
|
import { fetchPreferences, patchPreferences, type UserPreferences } from '@/lib/api';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
export type TableKey = 'videos' | 'linting';
|
export type TableKey = 'videos' | 'linting';
|
||||||
@@ -37,7 +37,7 @@ export function useColumnPreferences(tableKey: TableKey) {
|
|||||||
[tableKey]: { visible: nextVisible, order: nextOrder },
|
[tableKey]: { visible: nextVisible, order: nextOrder },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
qc.setQueryData(['preferences'], (old: any) => ({ ...old, ...patch }));
|
qc.setQueryData(['preferences'], (old: UserPreferences | undefined) => ({ ...old, ...patch }));
|
||||||
await patchPreferences(patch);
|
await patchPreferences(patch);
|
||||||
},
|
},
|
||||||
[prefs, tableKey, qc],
|
[prefs, tableKey, qc],
|
||||||
|
|||||||
@@ -630,6 +630,8 @@ export interface UserPreferences {
|
|||||||
systemOrder?: string[];
|
systemOrder?: string[];
|
||||||
hiddenSystem?: string[];
|
hiddenSystem?: string[];
|
||||||
};
|
};
|
||||||
|
videoEditLeftCol?: string[];
|
||||||
|
videoEditRightCol?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchSavedViews = () =>
|
export const fetchSavedViews = () =>
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
# The public domain Traefik routes to this app (no https://, no trailing slash).
|
# The public domain Traefik routes to this app (no https://, no trailing slash).
|
||||||
DOMAIN=yourdomain.com
|
DOMAIN=yourdomain.com
|
||||||
|
|
||||||
|
# ─── Image tag ────────────────────────────────────────────────────────────────
|
||||||
|
# Which tag to pull from git.devils.zone/devil/youtube-studio-flow-{backend,frontend}.
|
||||||
|
# Pin to a git short SHA or release tag for reproducible deploys; `latest` tracks
|
||||||
|
# whatever scripts/build-and-push.sh last pushed.
|
||||||
|
IMAGE_TAG=latest
|
||||||
|
|
||||||
# ─── Postgres ─────────────────────────────────────────────────────────────────
|
# ─── Postgres ─────────────────────────────────────────────────────────────────
|
||||||
POSTGRES_USER=studioflow
|
POSTGRES_USER=studioflow
|
||||||
POSTGRES_PASSWORD=change_me_strong_password
|
POSTGRES_PASSWORD=change_me_strong_password
|
||||||
|
|||||||
@@ -10,11 +10,23 @@ Dieses Verzeichnis enthält die Konfiguration für die lokale Entwicklung und da
|
|||||||
- **postgres:** PostgreSQL 16 (Port 5432)
|
- **postgres:** PostgreSQL 16 (Port 5432)
|
||||||
- **redis:** Redis 7 für Caching und Queues (Port 6379)
|
- **redis:** Redis 7 für Caching und Queues (Port 6379)
|
||||||
|
|
||||||
## Schnellstart
|
## Schnellstart (Server-Deployment)
|
||||||
|
|
||||||
|
Images werden **nicht** auf dem Server gebaut — sie kommen fertig aus der Gitea Container
|
||||||
|
Registry (`git.devils.zone/devil/youtube-studio-flow-{backend,frontend}`), gebaut per
|
||||||
|
`scripts/build-and-push.sh` auf der Dev-Maschine.
|
||||||
|
|
||||||
1. `.env.example` kopieren: `cp .env.example .env`
|
1. `.env.example` kopieren: `cp .env.example .env`
|
||||||
2. Werte in `.env` anpassen (besonders Google API Keys).
|
2. Werte in `.env` anpassen (Secrets, Domain, Google API Keys, `IMAGE_TAG`).
|
||||||
3. Container starten: `docker compose up -d`
|
3. Am Server einloggen: `docker login git.devils.zone`
|
||||||
4. Datenbank migrieren: `docker compose run --rm migrate`
|
4. Images ziehen: `docker compose pull`
|
||||||
|
5. Datenbank migrieren: `docker compose run --rm migrate`
|
||||||
|
6. Container starten: `docker compose up -d`
|
||||||
|
|
||||||
|
Für ein Update auf eine neue Version: `IMAGE_TAG` in `.env` anpassen (oder `latest`
|
||||||
|
belassen), dann Schritte 4–6 wiederholen. Das externe Docker-Netzwerk `proxy`
|
||||||
|
muss vorher existieren (`docker network create proxy`), falls Traefik das nicht
|
||||||
|
bereits selbst anlegt.
|
||||||
|
|
||||||
## Daten-Persistenz
|
## Daten-Persistenz
|
||||||
- PostgreSQL-Daten liegen im Volume `postgres_data`.
|
- PostgreSQL-Daten liegen im Volume `postgres_data`.
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ services:
|
|||||||
|
|
||||||
# Runs database migrations once before the API and worker start.
|
# Runs database migrations once before the API and worker start.
|
||||||
migrate:
|
migrate:
|
||||||
build:
|
image: git.devils.zone/devil/youtube-studio-flow-backend:${IMAGE_TAG:-latest}
|
||||||
context: ../backend
|
pull_policy: always
|
||||||
dockerfile: Dockerfile
|
|
||||||
command: npx prisma migrate deploy
|
command: npx prisma migrate deploy
|
||||||
restart: "no"
|
restart: "no"
|
||||||
environment:
|
environment:
|
||||||
@@ -16,9 +15,8 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
image: git.devils.zone/devil/youtube-studio-flow-backend:${IMAGE_TAG:-latest}
|
||||||
context: ../backend
|
pull_policy: always
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
@@ -48,18 +46,17 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/health || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/api/v1/health || exit 1"]
|
||||||
interval: 15s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 10
|
||||||
start_period: 30s
|
start_period: 60s
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
- traefik-network
|
- proxy
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build:
|
image: git.devils.zone/devil/youtube-studio-flow-backend:${IMAGE_TAG:-latest}
|
||||||
context: ../backend
|
pull_policy: always
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: node dist/worker.js
|
command: node dist/worker.js
|
||||||
environment:
|
environment:
|
||||||
@@ -84,11 +81,11 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
# NEXT_PUBLIC_API_URL is baked in at build time (scripts/build-and-push.sh uses the
|
||||||
context: ../frontend
|
# Dockerfile default, /api/v1) — the relative path works because Traefik serves both
|
||||||
dockerfile: Dockerfile
|
# frontend and API on the same domain.
|
||||||
# NEXT_PUBLIC_API_URL defaults to /api/v1 in the Dockerfile — no override needed.
|
image: git.devils.zone/devil/youtube-studio-flow-frontend:${IMAGE_TAG:-latest}
|
||||||
# The relative path works because Traefik serves both frontend and API on the same domain.
|
pull_policy: always
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
@@ -98,11 +95,14 @@ services:
|
|||||||
- "traefik.http.routers.studioflow-frontend.priority=1"
|
- "traefik.http.routers.studioflow-frontend.priority=1"
|
||||||
- "traefik.http.services.studioflow-frontend.loadbalancer.server.port=3000"
|
- "traefik.http.services.studioflow-frontend.loadbalancer.server.port=3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
# service_started, not service_healthy: a slow/unhealthy api shouldn't block the
|
||||||
|
# whole `compose up` (and Portainer tearing down what it created) - the frontend
|
||||||
|
# itself doesn't need api to be ready at container-start time.
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
- traefik-network
|
- proxy
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -149,5 +149,5 @@ volumes:
|
|||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
traefik-network:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the backend and frontend images and pushes them to the Gitea
|
||||||
|
# container registry at git.devils.zone. Requires `docker login
|
||||||
|
# git.devils.zone` to already be done (run that yourself, out of band, so
|
||||||
|
# the token never ends up in a Claude transcript).
|
||||||
|
#
|
||||||
|
# Usage: scripts/build-and-push.sh [tag]
|
||||||
|
# tag Extra tag to push alongside the git short SHA and `latest`.
|
||||||
|
# Useful for a release, e.g. scripts/build-and-push.sh v0.1.0
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="git.devils.zone"
|
||||||
|
OWNER="devil"
|
||||||
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
EXTRA_TAG="${1:-}"
|
||||||
|
|
||||||
|
BACKEND_IMAGE="${REGISTRY}/${OWNER}/youtube-studio-flow-backend"
|
||||||
|
FRONTEND_IMAGE="${REGISTRY}/${OWNER}/youtube-studio-flow-frontend"
|
||||||
|
|
||||||
|
echo "==> Building backend (${BACKEND_IMAGE}:${SHA})"
|
||||||
|
docker build -t "${BACKEND_IMAGE}:${SHA}" -t "${BACKEND_IMAGE}:latest" ./backend
|
||||||
|
|
||||||
|
echo "==> Building frontend (${FRONTEND_IMAGE}:${SHA})"
|
||||||
|
docker build -t "${FRONTEND_IMAGE}:${SHA}" -t "${FRONTEND_IMAGE}:latest" ./frontend
|
||||||
|
|
||||||
|
if [ -n "$EXTRA_TAG" ]; then
|
||||||
|
docker tag "${BACKEND_IMAGE}:${SHA}" "${BACKEND_IMAGE}:${EXTRA_TAG}"
|
||||||
|
docker tag "${FRONTEND_IMAGE}:${SHA}" "${FRONTEND_IMAGE}:${EXTRA_TAG}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Pushing backend"
|
||||||
|
docker push "${BACKEND_IMAGE}:${SHA}"
|
||||||
|
docker push "${BACKEND_IMAGE}:latest"
|
||||||
|
[ -n "$EXTRA_TAG" ] && docker push "${BACKEND_IMAGE}:${EXTRA_TAG}"
|
||||||
|
|
||||||
|
echo "==> Pushing frontend"
|
||||||
|
docker push "${FRONTEND_IMAGE}:${SHA}"
|
||||||
|
docker push "${FRONTEND_IMAGE}:latest"
|
||||||
|
[ -n "$EXTRA_TAG" ] && docker push "${FRONTEND_IMAGE}:${EXTRA_TAG}"
|
||||||
|
|
||||||
|
echo "==> Done. Pushed tags: ${SHA}, latest${EXTRA_TAG:+, $EXTRA_TAG}"
|
||||||
|
echo " On the server: docker compose pull && docker compose run --rm migrate && docker compose up -d"
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-time migration: dump the local dev Postgres and restore it into the
|
||||||
|
# server's Postgres over SSH, replacing whatever is there.
|
||||||
|
#
|
||||||
|
# DESTRUCTIVE on the target: drops and recreates the remote database.
|
||||||
|
#
|
||||||
|
# Fill in the placeholders below (or export them as env vars before running),
|
||||||
|
# then run from the project root: bash scripts/migrate-local-db-to-server.sh
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - Local Postgres running: cd infrastructure && docker compose up -d postgres redis
|
||||||
|
# - SSH access to the server
|
||||||
|
# - The remote postgres container's name (find it with `docker ps` on the
|
||||||
|
# server, or check the container list in Portainer for your stack - it'll
|
||||||
|
# be something like <stack-name>-postgres-1)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Git Bash/MSYS auto-translates POSIX-looking path args (e.g. /tmp/foo) into
|
||||||
|
# Windows paths before handing them to docker.exe - but these paths are meant
|
||||||
|
# to be interpreted inside the Linux container, not on the Windows host.
|
||||||
|
export MSYS_NO_PATHCONV=1
|
||||||
|
|
||||||
|
# ── Fill these in ──────────────────────────────────────────────────────────
|
||||||
|
SSH_USER="${SSH_USER:-dummyuser}"
|
||||||
|
SSH_HOST="${SSH_HOST:-dummy.server.example}"
|
||||||
|
SSH_PORT="${SSH_PORT:-22}"
|
||||||
|
REMOTE_POSTGRES_CONTAINER="${REMOTE_POSTGRES_CONTAINER:-studioflow-postgres-1}"
|
||||||
|
REMOTE_POSTGRES_USER="${REMOTE_POSTGRES_USER:-studioflow}"
|
||||||
|
REMOTE_POSTGRES_DB="${REMOTE_POSTGRES_DB:-studioflow}"
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
LOCAL_DB_USER="studioflow"
|
||||||
|
LOCAL_DB_NAME="studioflow"
|
||||||
|
DUMP_FILE="studioflow_migration_$(date +%Y%m%d_%H%M%S).dump"
|
||||||
|
|
||||||
|
echo "==> Finding local Postgres container"
|
||||||
|
LOCAL_PG_CONTAINER=$(docker ps --format '{{.Names}}' | grep -i postgres | head -1)
|
||||||
|
if [ -z "$LOCAL_PG_CONTAINER" ]; then
|
||||||
|
echo "No running local postgres container found. Start it first:"
|
||||||
|
echo " cd infrastructure && docker compose up -d postgres redis"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " Using: $LOCAL_PG_CONTAINER"
|
||||||
|
|
||||||
|
echo "==> Dumping local database ($LOCAL_DB_NAME)"
|
||||||
|
docker exec "$LOCAL_PG_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" -F c -f "/tmp/$DUMP_FILE"
|
||||||
|
docker cp "${LOCAL_PG_CONTAINER}:/tmp/$DUMP_FILE" "./$DUMP_FILE"
|
||||||
|
docker exec "$LOCAL_PG_CONTAINER" rm "/tmp/$DUMP_FILE"
|
||||||
|
echo " Dump size: $(du -h "./$DUMP_FILE" | cut -f1)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "About to overwrite the database on ${SSH_HOST} (container: ${REMOTE_POSTGRES_CONTAINER})."
|
||||||
|
read -p "Type YES to continue: " CONFIRM
|
||||||
|
if [ "$CONFIRM" != "YES" ]; then
|
||||||
|
echo "Aborted. Local dump kept at ./$DUMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Copying dump to server"
|
||||||
|
scp -P "$SSH_PORT" "./$DUMP_FILE" "${SSH_USER}@${SSH_HOST}:/tmp/$DUMP_FILE"
|
||||||
|
|
||||||
|
echo "==> Restoring on server"
|
||||||
|
ssh -p "$SSH_PORT" "${SSH_USER}@${SSH_HOST}" bash -s <<EOF
|
||||||
|
set -euo pipefail
|
||||||
|
echo " Copying dump into container..."
|
||||||
|
docker cp "/tmp/$DUMP_FILE" "${REMOTE_POSTGRES_CONTAINER}:/tmp/$DUMP_FILE"
|
||||||
|
|
||||||
|
echo " Terminating existing connections..."
|
||||||
|
docker exec "$REMOTE_POSTGRES_CONTAINER" psql -U "$REMOTE_POSTGRES_USER" -d postgres -c \
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$REMOTE_POSTGRES_DB';" >/dev/null
|
||||||
|
|
||||||
|
echo " Dropping and recreating database..."
|
||||||
|
docker exec "$REMOTE_POSTGRES_CONTAINER" psql -U "$REMOTE_POSTGRES_USER" -d postgres -c \
|
||||||
|
"DROP DATABASE IF EXISTS $REMOTE_POSTGRES_DB;" >/dev/null
|
||||||
|
docker exec "$REMOTE_POSTGRES_CONTAINER" psql -U "$REMOTE_POSTGRES_USER" -d postgres -c \
|
||||||
|
"CREATE DATABASE $REMOTE_POSTGRES_DB OWNER $REMOTE_POSTGRES_USER;" >/dev/null
|
||||||
|
|
||||||
|
echo " Restoring data..."
|
||||||
|
docker exec "$REMOTE_POSTGRES_CONTAINER" pg_restore -U "$REMOTE_POSTGRES_USER" -d "$REMOTE_POSTGRES_DB" --no-owner --no-privileges "/tmp/$DUMP_FILE"
|
||||||
|
|
||||||
|
docker exec "$REMOTE_POSTGRES_CONTAINER" rm "/tmp/$DUMP_FILE"
|
||||||
|
rm "/tmp/$DUMP_FILE"
|
||||||
|
echo " Done."
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "==> Migration complete."
|
||||||
|
echo " Restart api/worker in Portainer (or they'll self-heal on next DB query)."
|
||||||
|
echo " Local dump kept at ./$DUMP_FILE - delete it once you've confirmed the server looks right."
|
||||||
Reference in New Issue
Block a user