From 9839d1ba7ceec56dc0938efe7bb7e6f0310466c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 12 Jan 2026 14:49:44 +0000 Subject: [PATCH] Checkpoint before follow-up message Co-authored-by: dennis --- .gitea/workflows/dev-deploy.yml | 42 +++++----- .gitea/workflows/production-deploy.yml | 2 +- GITEA_VARIABLES_SETUP.md | 103 ++++++++----------------- README.md | 4 +- app/api/projects/export/route.ts | 31 +++++++- app/api/projects/import/route.ts | 102 +++++++++++++++++++++++- components/ImportExport.tsx | 10 +-- docker-compose.testing.yml | 95 +++++++++++++++++++++++ docs/CMS_GUIDE.md | 20 +++++ docs/TESTING_AND_DEPLOYMENT.md | 52 +++++++++++++ 10 files changed, 358 insertions(+), 103 deletions(-) create mode 100644 docker-compose.testing.yml create mode 100644 docs/CMS_GUIDE.md create mode 100644 docs/TESTING_AND_DEPLOYMENT.md diff --git a/.gitea/workflows/dev-deploy.yml b/.gitea/workflows/dev-deploy.yml index d31ebf9..25e7efc 100644 --- a/.gitea/workflows/dev-deploy.yml +++ b/.gitea/workflows/dev-deploy.yml @@ -1,16 +1,16 @@ -name: Dev Deployment (Zero Downtime) +name: Testing Deployment (Zero Downtime) on: push: - branches: [ dev ] + branches: [ testing ] env: NODE_VERSION: '20' DOCKER_IMAGE: portfolio-app - IMAGE_TAG: staging + IMAGE_TAG: testing jobs: - deploy-dev: + deploy-testing: runs-on: ubuntu-latest steps: - name: Checkout code @@ -38,7 +38,7 @@ jobs: - name: Build Docker image run: | - echo "🏗️ Building dev Docker image with BuildKit cache..." + echo "🏗️ Building testing Docker image with BuildKit cache..." DOCKER_BUILDKIT=1 docker build \ --cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ --cache-from ${{ env.DOCKER_IMAGE }}:latest \ @@ -46,12 +46,12 @@ jobs: . echo "✅ Docker image built successfully" - - name: Zero-Downtime Dev Deployment + - name: Zero-Downtime Testing Deployment run: | - echo "🚀 Starting zero-downtime dev deployment..." + echo "🚀 Starting zero-downtime testing deployment..." - COMPOSE_FILE="docker-compose.staging.yml" - CONTAINER_NAME="portfolio-app-staging" + COMPOSE_FILE="docker-compose.testing.yml" + CONTAINER_NAME="portfolio-app-testing" HEALTH_PORT="3002" # Backup current container ID if running @@ -59,7 +59,7 @@ jobs: # Start new container with updated image echo "🆕 Starting new dev container..." - docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-staging + docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio-testing # Wait for new container to be healthy echo "⏳ Waiting for new container to be healthy..." @@ -84,8 +84,8 @@ jobs: # Verify new container is working if ! curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then - echo "⚠️ New dev container health check failed, but continuing (non-blocking)..." - docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-staging + echo "⚠️ New testing container health check failed, but continuing (non-blocking)..." + docker compose -f $COMPOSE_FILE logs --tail=50 portfolio-testing fi # Remove old container if it exists and is different @@ -98,11 +98,11 @@ jobs: fi fi - echo "✅ Dev deployment completed!" + echo "✅ Testing deployment completed!" env: - NODE_ENV: staging + NODE_ENV: production LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }} - NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }} + NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_TESTING || 'https://testing.dk0.dev' }} MY_EMAIL: ${{ vars.MY_EMAIL }} MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} MY_PASSWORD: ${{ secrets.MY_PASSWORD }} @@ -111,19 +111,19 @@ jobs: N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} - - name: Dev Health Check + - name: Testing Health Check run: | - echo "🔍 Running dev health checks..." + echo "🔍 Running testing health checks..." for i in {1..20}; do if curl -f http://localhost:3002/api/health && curl -f http://localhost:3002/ > /dev/null; then - echo "✅ Dev is fully operational!" + echo "✅ Testing is fully operational!" exit 0 fi - echo "⏳ Waiting for dev... ($i/20)" + echo "⏳ Waiting for testing... ($i/20)" sleep 3 done - echo "⚠️ Dev health check failed, but continuing (non-blocking)..." - docker compose -f docker-compose.staging.yml logs --tail=50 + echo "⚠️ Testing health check failed, but continuing (non-blocking)..." + docker compose -f docker-compose.testing.yml logs --tail=50 - name: Cleanup run: | diff --git a/.gitea/workflows/production-deploy.yml b/.gitea/workflows/production-deploy.yml index 4312c17..f3b4d40 100644 --- a/.gitea/workflows/production-deploy.yml +++ b/.gitea/workflows/production-deploy.yml @@ -196,7 +196,7 @@ jobs: env: NODE_ENV: production LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }} - NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }} + NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} MY_EMAIL: ${{ vars.MY_EMAIL }} MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} MY_PASSWORD: ${{ secrets.MY_PASSWORD }} diff --git a/GITEA_VARIABLES_SETUP.md b/GITEA_VARIABLES_SETUP.md index ff25bcd..83104b4 100644 --- a/GITEA_VARIABLES_SETUP.md +++ b/GITEA_VARIABLES_SETUP.md @@ -10,12 +10,12 @@ In Gitea kannst du **Variables** (öffentlich) und **Secrets** (verschlüsselt) 2. Klicke auf **Settings** (Einstellungen) 3. Klicke auf **Variables** oder **Secrets** im linken Menü -## 🔑 Variablen für Production Branch +## 🔑 Variablen für Production Branch (`production` → `dk0.dev`) Für den `production` Branch brauchst du: ### Variables (öffentlich sichtbar): -- `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev` +- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev` - `MY_EMAIL` = `contact@dk0.dev` (oder deine Email) - `MY_INFO_EMAIL` = `info@dk0.dev` (oder deine Info-Email) - `LOG_LEVEL` = `info` @@ -29,10 +29,10 @@ Für den `production` Branch brauchst du: ## 🧪 Variablen für Dev Branch -Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten: +Für den `testing` Branch brauchst du die **gleichen** Variablen, aber mit anderen Werten: ### Variables: -- `NEXT_PUBLIC_BASE_URL` = `https://dev.dk0.dev` ⚠️ **WICHTIG: Andere URL!** +- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev` ⚠️ **WICHTIG: Andere URL!** - `MY_EMAIL` = `contact@dk0.dev` (kann gleich sein) - `MY_INFO_EMAIL` = `info@dk0.dev` (kann gleich sein) - `LOG_LEVEL` = `debug` (für Dev mehr Logging) @@ -41,7 +41,7 @@ Für den `dev` Branch brauchst du die **gleichen** Variablen, aber mit anderen W ### Secrets: - `MY_PASSWORD` = Dein Email-Passwort (kann gleich sein) - `MY_INFO_PASSWORD` = Dein Info-Email-Passwort (kann gleich sein) -- `ADMIN_BASIC_AUTH` = `admin:staging_password` (kann anders sein) +- `ADMIN_BASIC_AUTH` = `admin:testing_password` (kann anders sein) - `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional) ## ✅ Lösung: Automatische Branch-Erkennung @@ -54,38 +54,27 @@ Die Workflows triggern auf unterschiedlichen Branches und verwenden automatisch **Production Workflow** (`.gitea/workflows/production-deploy.yml`): - Triggert nur auf `production` Branch -- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dk0.dev` +- Verwendet: `NEXT_PUBLIC_BASE_URL_PRODUCTION` (wenn gesetzt) oder Default: `https://dk0.dev` -**Dev Workflow** (`.gitea/workflows/dev-deploy.yml`): -- Triggert nur auf `dev` Branch -- Verwendet: `NEXT_PUBLIC_BASE_URL` (wenn gesetzt) oder Default: `https://dev.dk0.dev` +**Testing Workflow** (`.gitea/workflows/dev-deploy.yml`): +- Triggert nur auf `testing` Branch +- Verwendet: `NEXT_PUBLIC_BASE_URL_TESTING` (wenn gesetzt) oder Default: `https://testing.dk0.dev` **Das bedeutet:** -- Du setzt **eine** Variable `NEXT_PUBLIC_BASE_URL` in Gitea -- **Production Branch** → verwendet diese Variable (oder Default `https://dk0.dev`) -- **Dev Branch** → verwendet diese Variable (oder Default `https://dev.dk0.dev`) +- Du setzt **zwei** Variablen in Gitea (empfohlen, weil Gitea nicht branch-spezifisch scoped): + - `NEXT_PUBLIC_BASE_URL_PRODUCTION` + - `NEXT_PUBLIC_BASE_URL_TESTING` ### ⚠️ WICHTIG: - -Da beide Workflows die **gleiche Variable** verwenden, aber unterschiedliche Defaults haben: - -**Option 1: Variable NICHT setzen (Empfohlen)** -- Production verwendet automatisch: `https://dk0.dev` -- Dev verwendet automatisch: `https://dev.dk0.dev` -- ✅ Funktioniert perfekt ohne Konfiguration! - -**Option 2: Variable setzen** -- Wenn du `NEXT_PUBLIC_BASE_URL` = `https://dk0.dev` setzt -- Dann verwendet **beide** Branches diese URL (nicht ideal für Dev) -- ⚠️ Nicht empfohlen, da Dev dann die Production-URL verwendet +Gitea kann Variablen/Secrets nicht pro Branch trennen. Darum nutzen wir **separate Variablennamen** für Production und Testing. ## ✅ Empfohlene Konfiguration -### ⭐ Einfachste Lösung: NICHTS setzen! +### ⭐ Einfachste Lösung: nur URLs setzen (optional) Die Workflows haben bereits die richtigen Defaults: - **Production Branch** → automatisch `https://dk0.dev` -- **Dev Branch** → automatisch `https://dev.dk0.dev` +- **Testing Branch** → automatisch `https://testing.dk0.dev` Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch! @@ -102,8 +91,9 @@ Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch! - `ADMIN_BASIC_AUTH` = `admin:dein_passwort` - `N8N_SECRET_TOKEN` = Dein n8n Token (optional) -**⚠️ NICHT setzen:** -- `NEXT_PUBLIC_BASE_URL` - Lass diese Variable leer, damit jeder Branch seinen eigenen Default verwendet! +**Optional setzen:** +- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev` +- `NEXT_PUBLIC_BASE_URL_TESTING` = `https://testing.dk0.dev` ## 📝 Schritt-für-Schritt Anleitung @@ -116,11 +106,17 @@ https://git.dk0.dev/denshooter/portfolio/settings ### 3. Für Variables (öffentlich): - Klicke auf **"New Variable"** -- **Name:** `NEXT_PUBLIC_BASE_URL` -- **Value:** `https://dk0.dev` (für Production) +- **Name:** `NEXT_PUBLIC_BASE_URL_PRODUCTION` +- **Value:** `https://dk0.dev` - **Protect:** ✅ (optional, schützt vor Änderungen) - Klicke **"Add Variable"** +- Klicke auf **"New Variable"** +- **Name:** `NEXT_PUBLIC_BASE_URL_TESTING` +- **Value:** `https://testing.dk0.dev` +- **Protect:** ✅ (optional) +- Klicke **"Add Variable"** + ### 4. Für Secrets (verschlüsselt): - Klicke auf **"New Secret"** - **Name:** `MY_PASSWORD` @@ -133,53 +129,22 @@ Die Workflows verwenden diese einfache Logik: ```yaml # Production Workflow (triggert nur auf production branch) -NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dk0.dev' }} +NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} -# Dev Workflow (triggert nur auf dev branch) -NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL || 'https://dev.dk0.dev' }} +# Testing Workflow (triggert nur auf testing branch) +NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_TESTING || 'https://testing.dk0.dev' }} ``` **Das bedeutet:** - Jeder Workflow hat seinen **eigenen Default** -- Wenn `NEXT_PUBLIC_BASE_URL` in Gitea gesetzt ist, wird diese verwendet +- Wenn die jeweilige Variable (`NEXT_PUBLIC_BASE_URL_PRODUCTION` / `NEXT_PUBLIC_BASE_URL_TESTING`) in Gitea gesetzt ist, wird diese verwendet - Wenn **nicht** gesetzt, verwendet jeder Branch seinen eigenen Default **⭐ Beste Lösung:** -- **NICHT** `NEXT_PUBLIC_BASE_URL` in Gitea setzen -- Dann verwendet Production automatisch `https://dk0.dev` -- Und Dev verwendet automatisch `https://dev.dk0.dev` -- ✅ Perfekt getrennt, ohne Konfiguration! +- Setze `NEXT_PUBLIC_BASE_URL_PRODUCTION` und `NEXT_PUBLIC_BASE_URL_TESTING` (oder lass beide weg und nutze Defaults). ## 🎯 Best Practice -1. **Production:** Setze alle Variablen explizit in Gitea -2. **Dev:** Nutze die Defaults im Workflow (oder setze separate Variablen) -3. **Secrets:** Immer in Gitea Secrets setzen, nie in Code! - -## 🔍 Prüfen ob Variablen gesetzt sind - -In den Workflow-Logs siehst du: -``` -📝 Using Gitea Variables and Secrets: - - NEXT_PUBLIC_BASE_URL: https://dk0.dev -``` - -Wenn eine Variable fehlt, wird der Default verwendet. - -## ⚙️ Alternative: Environment-spezifische Variablen - -Falls du separate Variablen für Dev und Production willst, können wir die Workflows anpassen: - -```yaml -# Production -NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} - -# Dev -NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }} -``` - -Dann könntest du setzen: -- `NEXT_PUBLIC_BASE_URL_PRODUCTION` = `https://dk0.dev` -- `NEXT_PUBLIC_BASE_URL_DEV` = `https://dev.dk0.dev` - -Soll ich die Workflows entsprechend anpassen? +1. **Production (`production`)**: `docker-compose.production.yml` → Port 3000 → NPM Host `dk0.dev` +2. **Testing (`testing`)**: `docker-compose.testing.yml` → Port 3002 → NPM Host `testing.dk0.dev` +3. **Secrets**: immer als Gitea Secrets, nie im Code diff --git a/README.md b/README.md index 17babc9..2e9a1cd 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,10 @@ npm run start # Production Server ## 📖 Dokumentation - [Development Setup](DEV-SETUP.md) - Detaillierte Setup-Anleitung -- [Deployment Guide](DEPLOYMENT.md) - Production Deployment +- [Deployment Setup](DEPLOYMENT_SETUP.md) - Production Deployment - [Analytics](ANALYTICS.md) - Analytics und Performance +- [CMS Guide](docs/CMS_GUIDE.md) - Inhalte/Sprachen pflegen (Rich Text) +- [Testing & Deployment](docs/TESTING_AND_DEPLOYMENT.md) - Branches → Container → Domains ## 🔗 Links diff --git a/app/api/projects/export/route.ts b/app/api/projects/export/route.ts index 510b921..6645b4e 100644 --- a/app/api/projects/export/route.ts +++ b/app/api/projects/export/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { projectService } from '@/lib/prisma'; +import { prisma, projectService } from '@/lib/prisma'; import { requireSessionAuth } from '@/lib/auth'; export async function GET(request: NextRequest) { @@ -9,16 +9,39 @@ export async function GET(request: NextRequest) { const authError = requireSessionAuth(request); if (authError) return authError; - // Get all projects with full data - const projectsResult = await projectService.getAllProjects(); + // Projects (with translations) + const projectsResult = await projectService.getAllProjects({ limit: 10000 }); const projects = projectsResult.projects || projectsResult; + const projectIds = projects.map((p: { id: number }) => p.id); + + const projectTranslations = await prisma.projectTranslation.findMany({ + where: { projectId: { in: projectIds } }, + orderBy: [{ projectId: 'asc' }, { locale: 'asc' }], + }); + + // CMS content pages (with translations) + const contentPages = await prisma.contentPage.findMany({ + orderBy: { key: 'asc' }, + include: { + translations: { + orderBy: { locale: 'asc' }, + }, + }, + }); + + const siteSettings = await prisma.siteSettings.findUnique({ where: { id: 1 } }); // Format for export const exportData = { - version: '1.0', + version: '2.0', exportDate: new Date().toISOString(), + siteSettings, + contentPages, + projectTranslations, projects: projects.map(project => ({ id: project.id, + slug: (project as unknown as { slug?: string }).slug, + defaultLocale: (project as unknown as { defaultLocale?: string }).defaultLocale, title: project.title, description: project.description, content: project.content, diff --git a/app/api/projects/import/route.ts b/app/api/projects/import/route.ts index 08f2859..33dfef6 100644 --- a/app/api/projects/import/route.ts +++ b/app/api/projects/import/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { projectService } from '@/lib/prisma'; +import { prisma, projectService } from '@/lib/prisma'; import { requireSessionAuth } from '@/lib/auth'; export async function POST(request: NextRequest) { @@ -25,10 +25,65 @@ export async function POST(request: NextRequest) { errors: [] as string[] }; + // Import SiteSettings (optional) + if (body.siteSettings && typeof body.siteSettings === 'object') { + try { + await prisma.siteSettings.upsert({ + where: { id: 1 }, + create: { id: 1, ...(body.siteSettings as Record) } as any, + update: { ...(body.siteSettings as Record) } as any, + }); + } catch { + // non-blocking + } + } + + // Import CMS content pages (optional) + if (Array.isArray(body.contentPages)) { + for (const page of body.contentPages) { + try { + if (!page?.key) continue; + const upserted = await prisma.contentPage.upsert({ + where: { key: page.key }, + create: { key: page.key, status: page.status || 'PUBLISHED' }, + update: { status: page.status || 'PUBLISHED' }, + }); + + if (Array.isArray(page.translations)) { + for (const tr of page.translations) { + if (!tr?.locale || !tr?.content) continue; + await prisma.contentPageTranslation.upsert({ + where: { pageId_locale: { pageId: upserted.id, locale: tr.locale } }, + create: { + pageId: upserted.id, + locale: tr.locale, + title: tr.title || null, + slug: tr.slug || null, + content: tr.content, + metaDescription: tr.metaDescription || null, + keywords: tr.keywords || null, + } as any, + update: { + title: tr.title || null, + slug: tr.slug || null, + content: tr.content, + metaDescription: tr.metaDescription || null, + keywords: tr.keywords || null, + } as any, + }); + } + } + } catch (error) { + results.errors.push(`Failed to import content page "${page?.key}": ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + } + // Preload existing titles once (avoid O(n^2) DB reads during import) const existingProjectsResult = await projectService.getAllProjects({ limit: 10000 }); const existingProjects = existingProjectsResult.projects || existingProjectsResult; const existingTitles = new Set(existingProjects.map(p => p.title)); + const existingSlugs = new Set(existingProjects.map(p => (p as unknown as { slug?: string }).slug).filter(Boolean)); // Process each project for (const projectData of body.projects) { @@ -43,7 +98,9 @@ export async function POST(request: NextRequest) { } // Create new project - await projectService.createProject({ + const created = await projectService.createProject({ + slug: projectData.slug, + defaultLocale: projectData.defaultLocale || 'en', title: projectData.title, description: projectData.description, content: projectData.content, @@ -76,8 +133,49 @@ export async function POST(request: NextRequest) { } }); + // Import translations (optional, from export v2) + if (Array.isArray(body.projectTranslations)) { + for (const tr of body.projectTranslations) { + if (!tr?.projectId || !tr?.locale) continue; + // Map translation to created project by original slug/title when possible. + // We match by slug if available in exported project list; otherwise by title. + const exportedProject = body.projects.find((p: any) => p.id === tr.projectId); + const exportedSlug = exportedProject?.slug; + const matches = + (exportedSlug && (created as any).slug === exportedSlug) || + (!!exportedProject?.title && (created as any).title === exportedProject.title); + if (!matches) continue; + + if (!tr.title || !tr.description) continue; + await prisma.projectTranslation.upsert({ + where: { projectId_locale: { projectId: (created as any).id, locale: tr.locale } }, + create: { + projectId: (created as any).id, + locale: tr.locale, + title: tr.title, + description: tr.description, + content: tr.content || null, + metaDescription: tr.metaDescription || null, + keywords: tr.keywords || null, + ogImage: tr.ogImage || null, + schema: tr.schema || null, + } as any, + update: { + title: tr.title, + description: tr.description, + content: tr.content || null, + metaDescription: tr.metaDescription || null, + keywords: tr.keywords || null, + ogImage: tr.ogImage || null, + schema: tr.schema || null, + } as any, + }); + } + } + results.imported++; existingTitles.add(projectData.title); + if (projectData.slug) existingSlugs.add(projectData.slug); } catch (error) { results.skipped++; results.errors.push(`Failed to import "${projectData.title}": ${error instanceof Error ? error.message : 'Unknown error'}`); diff --git a/components/ImportExport.tsx b/components/ImportExport.tsx index d31e5fb..f354a4f 100644 --- a/components/ImportExport.tsx +++ b/components/ImportExport.tsx @@ -36,7 +36,7 @@ export default function ImportExport() { const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `portfolio-projects-${new Date().toISOString().split('T')[0]}.json`; + a.download = `portfolio-backup-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); @@ -119,9 +119,9 @@ export default function ImportExport() {
{/* Export Section */}
-

Export Projekte

+

Backup Export (Projekte + CMS)

- Alle Projekte als JSON-Datei herunterladen + Vollständiges Backup als JSON herunterladen (inkl. CMS Inhalte und Übersetzungen)