Compare commits

...
2 Commits
Author SHA1 Message Date
devil d6ff99e8a4 feat(infrastructure): pull pre-built images from Gitea registry
migrate/api/worker/frontend now reference
git.devils.zone/devil/youtube-studio-flow-{backend,frontend}:\${IMAGE_TAG}
instead of building on the deploy host. Adds scripts/build-and-push.sh
to build+tag (git short SHA + latest) and push both images from a dev
machine, and updates infrastructure/README.md with the pull-based
deploy sequence.
2026-08-11 14:18:30 +02:00
devil b51c79e889 fix(frontend): resolve build-blocking lint and type errors
next build runs ESLint + full type-check and fails on errors (not just
warnings) - the production build was broken. Fixes: unused imports/vars,
any-typed diff/preference lookups replaced with the actual union/Record
types, ternary-as-statement flagged by no-unused-expressions, unescaped
JSX entities, a UserPreferences interface missing two fields the video
editor already reads/writes at runtime, and an import of
ColumnVisibilityState which @tanstack/react-table does not export
(the real name is VisibilityState).
2026-08-11 14:18:26 +02:00
13 changed files with 101 additions and 52 deletions
@@ -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.' },
] as const;
type RuleCode = typeof RULE_CATALOG[number]['code'];
// ── Helpers ───────────────────────────────────────────────────────────────────
function SevBadge({ sev }: { sev: string }) {
@@ -156,7 +154,7 @@ export default function LintingPage() {
function toggleSelect(id: string) {
setSelected((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;
});
}
@@ -172,7 +170,7 @@ export default function LintingPage() {
function toggleFix(id: string) {
setExpandedFix((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;
});
}
@@ -483,7 +483,7 @@ export default function SettingsPage() {
<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.
Define the time slots when videos are allowed to publish. Used by the &quot;Next free slot&quot; feature in the video editor.
</p>
<div className={styles.scheduleBox}>
@@ -581,7 +581,7 @@ export default function SettingsPage() {
/>
<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.
Adds a link next to YouTube Studio that searches Canva for the video&apos;s Game Title.
</span>
</label>
) : (
@@ -601,7 +601,7 @@ export default function SettingsPage() {
/>
<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.
Reveals a &quot;Deleted&quot; tab in the video list for videos removed from YouTube. They are kept locally for 30 days before being permanently deleted.
</span>
</label>
) : (
@@ -38,8 +38,7 @@ function TagsDiff({ before, after }: { before: string[]; after: string[] }) {
function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem }) {
const label = FIELD_LABELS[field] ?? field;
const diff = item.diff as any;
const d = diff[field];
const d = item.diff[field as keyof typeof item.diff];
if (!d) return null;
if (field === 'tags') {
@@ -47,7 +46,7 @@ function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem
<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>
<td className={styles.diffAfter}><TagsDiff before={d.before as string[]} after={d.after as string[]} /></td>
</tr>
);
}
@@ -60,7 +59,7 @@ function DiffRow({ field, item }: { field: string; item: PushPendingPreviewItem
</tr>
);
}
const fmt = (v: any) => {
const fmt = (v: string | number | boolean | string[] | null | undefined) => {
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
return String(v);
@@ -13,7 +13,7 @@ import { SortableContext, verticalListSortingStrategy, arrayMove } from '@dnd-ki
import {
ArrowLeft, ExternalLink, Loader2, AlertCircle, CheckCircle2,
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,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
@@ -34,7 +34,7 @@ import SortableSection from '@/components/shared/SortableSection';
import f from '@/components/shared/FormField.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 {
const d = new Date(utcIso);
+5 -13
View File
@@ -3,8 +3,8 @@
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 { Download, Loader2, AlertCircle, RefreshCw, CheckCircle2, Plus } from 'lucide-react';
import { type SortingState, type VisibilityState } 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';
@@ -107,9 +107,9 @@ function parseExtraFilters(searchParams: URLSearchParams): Partial<VideosQuery>
const v = searchParams.get(k);
if (v !== null) {
if (k === 'embeddable' || k === 'selfDeclaredMadeForKids') {
(result as any)[k] = v === 'true';
(result as Record<string, string | boolean>)[k] = v === 'true';
} 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 columnVisibility = useMemo<ColumnVisibilityState>(() => {
const columnVisibility = useMemo<VisibilityState>(() => {
const allIds = VIDEO_COLUMN_DEFS.map((c) => c.id);
return Object.fromEntries(allIds.map((id) => [id, visible.includes(id)]));
}, [visible]);
@@ -193,14 +193,6 @@ export default function VideosPage() {
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;
@@ -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 {
fetchSavedViews, createSavedView, updateSavedView, deleteSavedView,
type SavedView, type VideosQuery, type UserPreferences,
type SavedView, type VideosQuery,
} from '@/lib/api';
import styles from './SavedViewManager.module.css';
@@ -8,7 +8,8 @@ import {
createColumnHelper,
type SortingState,
type OnChangeFn,
type ColumnVisibilityState,
type VisibilityState,
type Updater,
} from '@tanstack/react-table';
import { useRouter } from 'next/navigation';
import {
@@ -522,8 +523,8 @@ export default function VideoTable({
data: VideoRow[];
sorting: SortingState;
onSortingChange: OnChangeFn<SortingState>;
columnVisibility?: ColumnVisibilityState;
onColumnVisibilityChange?: (v: ColumnVisibilityState) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: (v: VisibilityState) => void;
columnOrder?: string[];
filters?: Partial<VideosQuery>;
onFiltersChange?: (patch: Partial<VideosQuery>) => void;
@@ -542,7 +543,10 @@ export default function VideoTable({
columnOrder: columnOrder ?? [],
},
onSortingChange,
onColumnVisibilityChange: onColumnVisibilityChange as any,
onColumnVisibilityChange: ((updater: Updater<VisibilityState>) => {
const next = typeof updater === 'function' ? updater(columnVisibility ?? {}) : updater;
onColumnVisibilityChange?.(next);
}) as OnChangeFn<VisibilityState>,
onColumnOrderChange: undefined,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
@@ -561,7 +565,7 @@ export default function VideoTable({
const clearFilters = () => {
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);
};
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
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';
export type TableKey = 'videos' | 'linting';
@@ -37,7 +37,7 @@ export function useColumnPreferences(tableKey: TableKey) {
[tableKey]: { visible: nextVisible, order: nextOrder },
},
};
qc.setQueryData(['preferences'], (old: any) => ({ ...old, ...patch }));
qc.setQueryData(['preferences'], (old: UserPreferences | undefined) => ({ ...old, ...patch }));
await patchPreferences(patch);
},
[prefs, tableKey, qc],
+2
View File
@@ -630,6 +630,8 @@ export interface UserPreferences {
systemOrder?: string[];
hiddenSystem?: string[];
};
videoEditLeftCol?: string[];
videoEditRightCol?: string[];
}
export const fetchSavedViews = () =>
+6
View File
@@ -2,6 +2,12 @@
# The public domain Traefik routes to this app (no https://, no trailing slash).
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_USER=studioflow
POSTGRES_PASSWORD=change_me_strong_password
+16 -4
View File
@@ -10,11 +10,23 @@ Dieses Verzeichnis enthält die Konfiguration für die lokale Entwicklung und da
- **postgres:** PostgreSQL 16 (Port 5432)
- **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`
2. Werte in `.env` anpassen (besonders Google API Keys).
3. Container starten: `docker compose up -d`
4. Datenbank migrieren: `docker compose run --rm migrate`
2. Werte in `.env` anpassen (Secrets, Domain, Google API Keys, `IMAGE_TAG`).
3. Am Server einloggen: `docker login git.devils.zone`
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 46 wiederholen. Das externe Docker-Netzwerk `traefik-network`
muss vorher existieren (`docker network create traefik-network`), falls Traefik das nicht
bereits selbst anlegt.
## Daten-Persistenz
- PostgreSQL-Daten liegen im Volume `postgres_data`.
+7 -14
View File
@@ -2,9 +2,7 @@ services:
# Runs database migrations once before the API and worker start.
migrate:
build:
context: ../backend
dockerfile: Dockerfile
image: git.devils.zone/devil/youtube-studio-flow-backend:${IMAGE_TAG:-latest}
command: npx prisma migrate deploy
restart: "no"
environment:
@@ -16,9 +14,7 @@ services:
- app-network
api:
build:
context: ../backend
dockerfile: Dockerfile
image: git.devils.zone/devil/youtube-studio-flow-backend:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
NODE_ENV: production
@@ -57,9 +53,7 @@ services:
- traefik-network
worker:
build:
context: ../backend
dockerfile: Dockerfile
image: git.devils.zone/devil/youtube-studio-flow-backend:${IMAGE_TAG:-latest}
restart: unless-stopped
command: node dist/worker.js
environment:
@@ -84,11 +78,10 @@ services:
- app-network
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
# NEXT_PUBLIC_API_URL defaults to /api/v1 in the Dockerfile — no override needed.
# The relative path works because Traefik serves both frontend and API on the same domain.
# NEXT_PUBLIC_API_URL is baked in at build time (scripts/build-and-push.sh uses the
# Dockerfile default, /api/v1) — the relative path works because Traefik serves both
# frontend and API on the same domain.
image: git.devils.zone/devil/youtube-studio-flow-frontend:${IMAGE_TAG:-latest}
restart: unless-stopped
labels:
- "traefik.enable=true"
+43
View File
@@ -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"