diff --git a/.env b/.env new file mode 100644 index 0000000..8c76213 --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +# Sanity CMS Configuration +# Get your project ID from https://sanity.io/manage +PUBLIC_SANITY_PROJECT_ID=p49jotqu +PUBLIC_SANITY_DATASET=production + +# Only needed when running the migration script (scripts/migrate-to-sanity.mjs) +# Create a write token at https://sanity.io/manage → API → Tokens +# Revoke after migration is complete. +SANITY_API_TOKEN=skQ3tzSF8tp5ETeDq26zlgYAoxv50L4D37uITONtQ0efe4wHxjH64NrauljoyucMGy0F58fOXmUbQXh7C0glLFcD0PUd0tVRwzifuuLYvlZmX0MKOo6aZB2U2P5ISydRNzKd0sPGyMjxRo47psGb3wCtzT0Xcv8ByMVOlqnn35bc6uDj7Afl diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 87f808e..fe016e5 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -39,16 +39,6 @@ jobs: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}:${{ steps.tag.outputs.sha }} ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}:latest - - name: Build and push backend image - uses: docker/build-push-action@v5 - with: - context: . - target: backend - push: true - tags: | - ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}-tina:${{ steps.tag.outputs.sha }} - ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}-tina:latest - - name: Update image tags in gitops repo env: CI_TOKEN: ${{ secrets.CI_TOKEN }} @@ -63,10 +53,7 @@ jobs: sed -i \ "s|image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}:.*|image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}:${SHA}|" \ "apps/${{ env.APP }}/deployment.yaml" - sed -i \ - "s|image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}-tina:.*|image: ${{ env.REGISTRY }}/${{ env.ORG }}/${{ env.APP }}-tina:${SHA}|" \ - "apps/${{ env.APP }}/tina-backend-deployment.yaml" - git add "apps/${{ env.APP }}/deployment.yaml" "apps/${{ env.APP }}/tina-backend-deployment.yaml" + git add "apps/${{ env.APP }}/deployment.yaml" git diff --cached --quiet && echo "No changes, skipping commit." && exit 0 git commit -m "ci: update ${{ env.APP }} to ${SHA}" git push origin main diff --git a/sanity.config.ts b/sanity.config.ts new file mode 100644 index 0000000..ef2d2b2 --- /dev/null +++ b/sanity.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'sanity' +import { structureTool } from 'sanity/structure' +import { visionTool } from '@sanity/vision' +import { schemaTypes } from './src/sanity/schemas' + +export default defineConfig({ + projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID, + dataset: import.meta.env.PUBLIC_SANITY_DATASET ?? 'production', + plugins: [ + structureTool({ + structure: (S) => + S.list() + .title('Content') + .items([ + S.listItem() + .title('Theater Homepage') + .child( + S.document() + .schemaType('theaterHomepage') + .documentId('theaterHomepage') + ), + S.divider(), + S.documentTypeListItem('post').title('Blog Posts'), + S.documentTypeListItem('page').title('Pages'), + ]), + }), + visionTool(), + ], + schema: { types: schemaTypes }, +}) diff --git a/scripts/migrate-to-sanity.mjs b/scripts/migrate-to-sanity.mjs new file mode 100644 index 0000000..b6addf3 --- /dev/null +++ b/scripts/migrate-to-sanity.mjs @@ -0,0 +1,257 @@ +/** + * One-shot migration script: TinaCMS MDX files → Sanity documents + * + * Prerequisites: + * npm install --save-dev gray-matter @portabletext/markdown + * + * Usage: + * npm run migrate + * + * Revoke SANITY_API_TOKEN after migration is complete. + */ + +import { createClient } from '@sanity/client' +import { createReadStream, readdirSync, readFileSync, existsSync } from 'fs' +import { join, basename } from 'path' +import matter from 'gray-matter' +import { markdownToPortableText } from '@portabletext/markdown' + +const PROJECT_ID = process.env.PUBLIC_SANITY_PROJECT_ID +const TOKEN = process.env.SANITY_API_TOKEN +const DATASET = process.env.PUBLIC_SANITY_DATASET ?? 'production' + +if (!PROJECT_ID || !TOKEN) { + console.error('Missing PUBLIC_SANITY_PROJECT_ID or SANITY_API_TOKEN environment variables.') + process.exit(1) +} + +const client = createClient({ + projectId: PROJECT_ID, + dataset: DATASET, + apiVersion: '2024-01-01', + token: TOKEN, + useCdn: false, +}) + +function randomKey() { + return Math.random().toString(36).slice(2, 10) +} + +function emptyPortableText() { + return [] +} + +function stringToPortableText(text) { + if (!text || text === '') return emptyPortableText() + return [ + { + _type: 'block', + _key: randomKey(), + style: 'normal', + markDefs: [], + children: [{ _type: 'span', _key: randomKey(), text, marks: [] }], + }, + ] +} + +// Upload a local image file to Sanity and return a Sanity image reference +async function uploadImageFile(localPath, label) { + if (!existsSync(localPath)) { + console.warn(` Skipping missing image: ${localPath}`) + return null + } + console.log(` Uploading image: ${label}`) + const asset = await client.assets.upload('image', createReadStream(localPath), { + filename: basename(localPath), + }) + return { _type: 'image', asset: { _type: 'reference', _ref: asset._id } } +} + +// Map /images/filename.ext → public/images/filename.ext (relative to project root) +function localPathForImageUrl(imageUrl) { + if (!imageUrl || !imageUrl.startsWith('/images/')) return null + return join('public', imageUrl) +} + +// --------------------------------------------------------------------------- +// Migrate theaterHomepage singleton +// --------------------------------------------------------------------------- +async function migrateTheaterHomepage() { + console.log('\n--- Migrating theaterHomepage ---') + const raw = readFileSync('content/theater/homepage.mdx', 'utf-8') + const { data: fm } = matter(raw) + + // Pre-upload all organization logos + const orgs = (fm.team?.organizations ?? []) + const uploadedLogos = {} + for (const org of orgs) { + if (org.logo && !uploadedLogos[org.logo]) { + const localPath = localPathForImageUrl(org.logo) + if (localPath) { + uploadedLogos[org.logo] = await uploadImageFile(localPath, org.logo) + } + } + } + + const doc = { + _type: 'theaterHomepage', + _id: 'theaterHomepage', + title: fm.title, + hero: fm.hero ?? {}, + projekt: { + title_de: fm.projekt?.title_de ?? '', + title_en: fm.projekt?.title_en ?? '', + content_de: stringToPortableText(fm.projekt?.content_de), + content_en: stringToPortableText(fm.projekt?.content_en), + }, + team: { + title_de: fm.team?.title_de ?? '', + title_en: fm.team?.title_en ?? '', + content_de: stringToPortableText(fm.team?.content_de), + content_en: stringToPortableText(fm.team?.content_en), + organizations: orgs.map((org) => ({ + _key: randomKey(), + name: org.name, + role_de: org.role_de, + role_en: org.role_en, + url: org.url, + ...(org.logo && uploadedLogos[org.logo] ? { logo: uploadedLogos[org.logo] } : {}), + })), + }, + stueck: { + title_de: fm.stueck?.title_de ?? '', + title_en: fm.stueck?.title_en ?? '', + content_de: stringToPortableText(fm.stueck?.content_de), + content_en: stringToPortableText(fm.stueck?.content_en), + }, + mitwirkung: { + title_de: fm.mitwirkung?.title_de ?? '', + title_en: fm.mitwirkung?.title_en ?? '', + content_de: stringToPortableText(fm.mitwirkung?.content_de), + content_en: stringToPortableText(fm.mitwirkung?.content_en), + director_name: fm.mitwirkung?.director_name, + director_email: fm.mitwirkung?.director_email, + director_phone: fm.mitwirkung?.director_phone, + }, + termine: { + title_de: fm.termine?.title_de ?? '', + title_en: fm.termine?.title_en ?? '', + events: (fm.termine?.events ?? []).map((e) => ({ ...e, _key: randomKey() })), + }, + presse: { + title_de: fm.presse?.title_de ?? '', + title_en: fm.presse?.title_en ?? '', + content_de: stringToPortableText(fm.presse?.content_de), + content_en: stringToPortableText(fm.presse?.content_en), + }, + gallery: { + title_de: fm.gallery?.title_de ?? '', + title_en: fm.gallery?.title_en ?? '', + images: [], + }, + tickets: { + title_de: fm.tickets?.title_de ?? '', + title_en: fm.tickets?.title_en ?? '', + content_de: stringToPortableText(fm.tickets?.content_de), + content_en: stringToPortableText(fm.tickets?.content_en), + ...(fm.tickets?.ticket_url ? { ticket_url: fm.tickets.ticket_url } : {}), + }, + } + + await client.createOrReplace(doc) + console.log(' ✓ theaterHomepage created/replaced') +} + +// --------------------------------------------------------------------------- +// Migrate blog posts +// --------------------------------------------------------------------------- +async function migratePosts() { + console.log('\n--- Migrating blog posts ---') + const blogDir = 'content/blog' + if (!existsSync(blogDir)) { + console.log(' No blog directory found, skipping.') + return + } + const files = readdirSync(blogDir).filter((f) => f.endsWith('.mdx')) + + for (const file of files) { + const slug = file.replace('.mdx', '') + const raw = readFileSync(join(blogDir, file), 'utf-8') + const { data: fm, content: body } = matter(raw) + + const portableTextBody = body.trim() + ? markdownToPortableText(body) + : [] + + // Upload cover image if present and exists locally + let coverImage + if (fm.coverImage) { + const localPath = localPathForImageUrl(fm.coverImage) + if (localPath) { + coverImage = await uploadImageFile(localPath, fm.coverImage) + } + } + + const doc = { + _type: 'post', + _id: `post-${slug}`, + title: fm.title, + slug: { _type: 'slug', current: slug }, + date: fm.date, + excerpt: fm.excerpt, + body: portableTextBody, + ...(coverImage ? { coverImage } : {}), + } + + await client.createOrReplace(doc) + console.log(` ✓ Post migrated: ${slug}`) + } +} + +// --------------------------------------------------------------------------- +// Migrate static pages +// --------------------------------------------------------------------------- +async function migratePages() { + console.log('\n--- Migrating pages ---') + const pagesDir = 'content/pages' + if (!existsSync(pagesDir)) { + console.log(' No pages directory found, skipping.') + return + } + const files = readdirSync(pagesDir).filter((f) => f.endsWith('.mdx')) + + for (const file of files) { + const slug = file.replace('.mdx', '') + const raw = readFileSync(join(pagesDir, file), 'utf-8') + const { data: fm, content: body } = matter(raw) + + const portableTextBody = body.trim() + ? markdownToPortableText(body) + : [] + + const doc = { + _type: 'page', + _id: `page-${slug}`, + title: fm.title, + slug: { _type: 'slug', current: slug }, + description: fm.description, + body: portableTextBody, + } + + await client.createOrReplace(doc) + console.log(` ✓ Page migrated: ${slug}`) + } +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- +try { + await migrateTheaterHomepage() + await migratePosts() + await migratePages() + console.log('\n✓ Migration complete.') +} catch (err) { + console.error('\n✗ Migration failed:', err) + process.exit(1) +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..da596b7 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/src/sanity/client.ts b/src/sanity/client.ts new file mode 100644 index 0000000..decd8a7 --- /dev/null +++ b/src/sanity/client.ts @@ -0,0 +1 @@ +export { sanityClient } from 'sanity:client' diff --git a/src/sanity/imageUrl.ts b/src/sanity/imageUrl.ts new file mode 100644 index 0000000..7cb4d27 --- /dev/null +++ b/src/sanity/imageUrl.ts @@ -0,0 +1,11 @@ +import { createImageUrlBuilder } from '@sanity/image-url' + +const builder = createImageUrlBuilder({ + projectId: import.meta.env.PUBLIC_SANITY_PROJECT_ID, + dataset: import.meta.env.PUBLIC_SANITY_DATASET ?? 'production', +}) + +export function urlFor(source: any): string { + if (!source) return '' + return builder.image(source).url() +} diff --git a/src/sanity/portableText.ts b/src/sanity/portableText.ts new file mode 100644 index 0000000..b6d09fa --- /dev/null +++ b/src/sanity/portableText.ts @@ -0,0 +1,6 @@ +import { toHTML } from '@portabletext/to-html' + +export function renderPortableText(blocks: any[]): string { + if (!blocks?.length) return '' + return toHTML(blocks) +} diff --git a/src/sanity/queries.ts b/src/sanity/queries.ts new file mode 100644 index 0000000..15586ca --- /dev/null +++ b/src/sanity/queries.ts @@ -0,0 +1,34 @@ +export const theaterHomepageQuery = `*[_type == "theaterHomepage" && _id == "theaterHomepage"][0]` + +export const allPostsQuery = ` + *[_type == "post"] | order(date desc) { + _id, + title, + "slug": slug.current, + date, + excerpt, + coverImage, + body + } +` + +export const postBySlugQuery = ` + *[_type == "post" && slug.current == $slug][0] { + _id, + title, + "slug": slug.current, + date, + excerpt, + coverImage, + body + } +` + +export const pageBySlugQuery = ` + *[_type == "page" && slug.current == $slug][0] { + _id, + title, + description, + body + } +` diff --git a/src/sanity/schemas/index.ts b/src/sanity/schemas/index.ts new file mode 100644 index 0000000..9d6ca6f --- /dev/null +++ b/src/sanity/schemas/index.ts @@ -0,0 +1,5 @@ +import { postSchema } from './post' +import { pageSchema } from './page' +import { theaterHomepageSchema } from './theaterHomepage' + +export const schemaTypes = [postSchema, pageSchema, theaterHomepageSchema] diff --git a/src/sanity/schemas/page.ts b/src/sanity/schemas/page.ts new file mode 100644 index 0000000..f5c3188 --- /dev/null +++ b/src/sanity/schemas/page.ts @@ -0,0 +1,33 @@ +import { defineType, defineField } from 'sanity' + +export const pageSchema = defineType({ + name: 'page', + title: 'Pages', + type: 'document', + fields: [ + defineField({ + name: 'title', + title: 'Title', + type: 'string', + validation: (r) => r.required(), + }), + defineField({ + name: 'slug', + title: 'Slug', + type: 'slug', + options: { source: 'title' }, + }), + defineField({ + name: 'description', + title: 'Description', + type: 'string', + }), + defineField({ + name: 'body', + title: 'Content', + type: 'array', + of: [{ type: 'block' }], + validation: (r) => r.required(), + }), + ], +}) diff --git a/src/sanity/schemas/post.ts b/src/sanity/schemas/post.ts new file mode 100644 index 0000000..b0da0c0 --- /dev/null +++ b/src/sanity/schemas/post.ts @@ -0,0 +1,47 @@ +import { defineType, defineField } from 'sanity' + +export const postSchema = defineType({ + name: 'post', + title: 'Blog Posts', + type: 'document', + fields: [ + defineField({ + name: 'title', + title: 'Title', + type: 'string', + validation: (r) => r.required(), + }), + defineField({ + name: 'slug', + title: 'Slug', + type: 'slug', + options: { source: 'title' }, + validation: (r) => r.required(), + }), + defineField({ + name: 'date', + title: 'Publication Date', + type: 'datetime', + validation: (r) => r.required(), + }), + defineField({ + name: 'excerpt', + title: 'Excerpt', + type: 'string', + validation: (r) => r.required(), + }), + defineField({ + name: 'coverImage', + title: 'Cover Image', + type: 'image', + options: { hotspot: true }, + }), + defineField({ + name: 'body', + title: 'Body', + type: 'array', + of: [{ type: 'block' }, { type: 'image' }], + validation: (r) => r.required(), + }), + ], +}) diff --git a/src/sanity/schemas/theaterHomepage.ts b/src/sanity/schemas/theaterHomepage.ts new file mode 100644 index 0000000..3a37c65 --- /dev/null +++ b/src/sanity/schemas/theaterHomepage.ts @@ -0,0 +1,173 @@ +import { defineType, defineField, defineArrayMember } from 'sanity' + +export const theaterHomepageSchema = defineType({ + name: 'theaterHomepage', + title: 'Theater Homepage', + type: 'document', + // Singleton: disable create/delete — only update and publish + __experimental_actions: ['update', 'publish'], + fields: [ + defineField({ + name: 'title', + title: 'Page Title', + type: 'string', + }), + + // Hero Section + defineField({ + name: 'hero', + title: 'Hero Section', + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Title (German)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'title_en', title: 'Title (English)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'subtitle_de', title: 'Subtitle (German)', type: 'text' }), + defineField({ name: 'subtitle_en', title: 'Subtitle (English)', type: 'text' }), + ], + }), + + // Section 1: Das Projekt / The Project + defineField({ + name: 'projekt', + title: 'Section 1: Das Projekt / The Project', + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Title (German)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'title_en', title: 'Title (English)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'content_de', title: 'Content (German)', type: 'array', of: [{ type: 'block' }] }), + defineField({ name: 'content_en', title: 'Content (English)', type: 'array', of: [{ type: 'block' }] }), + ], + }), + + // Section 2: Wer steckt dahinter / Who's Behind It + defineField({ + name: 'team', + title: "Section 2: Wer steckt dahinter / Who's Behind It", + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Title (German)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'title_en', title: 'Title (English)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'content_de', title: 'Content (German)', type: 'array', of: [{ type: 'block' }] }), + defineField({ name: 'content_en', title: 'Content (English)', type: 'array', of: [{ type: 'block' }] }), + defineField({ + name: 'organizations', + title: 'Organizations', + type: 'array', + of: [ + defineArrayMember({ + type: 'object', + fields: [ + defineField({ name: 'name', title: 'Name', type: 'string' }), + defineField({ name: 'role_de', title: 'Role (German)', type: 'string' }), + defineField({ name: 'role_en', title: 'Role (English)', type: 'string' }), + defineField({ name: 'logo', title: 'Logo', type: 'image', options: { hotspot: false } }), + defineField({ name: 'url', title: 'Website URL', type: 'url' }), + ], + }), + ], + }), + ], + }), + + // Section 3: Das Stück / The Play + defineField({ + name: 'stueck', + title: 'Section 3: Das Stück / The Play', + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Title (German)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'title_en', title: 'Title (English)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'content_de', title: 'Content (German)', type: 'array', of: [{ type: 'block' }] }), + defineField({ name: 'content_en', title: 'Content (English)', type: 'array', of: [{ type: 'block' }] }), + ], + }), + + // Section 4: Mitwirkung / Participation + defineField({ + name: 'mitwirkung', + title: 'Section 4: Mitwirkung / Participation', + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Title (German)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'title_en', title: 'Title (English)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'content_de', title: 'Content (German)', type: 'array', of: [{ type: 'block' }] }), + defineField({ name: 'content_en', title: 'Content (English)', type: 'array', of: [{ type: 'block' }] }), + defineField({ name: 'director_name', title: 'Director Name', type: 'string' }), + defineField({ name: 'director_email', title: 'Director Email', type: 'string' }), + defineField({ name: 'director_phone', title: 'Director Phone', type: 'string' }), + ], + }), + + // Section 5: Termine / Schedule + defineField({ + name: 'termine', + title: 'Section 5: Termine / Schedule', + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Title (German)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'title_en', title: 'Title (English)', type: 'string', validation: (r) => r.required() }), + defineField({ + name: 'events', + title: 'Events', + type: 'array', + of: [ + defineArrayMember({ + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Event Title (German)', type: 'string' }), + defineField({ name: 'title_en', title: 'Event Title (English)', type: 'string' }), + defineField({ name: 'date', title: 'Date / Period', type: 'string' }), + defineField({ name: 'description_de', title: 'Description (German)', type: 'text' }), + defineField({ name: 'description_en', title: 'Description (English)', type: 'text' }), + ], + }), + ], + }), + ], + }), + + // Section 6: Presse / Press + defineField({ + name: 'presse', + title: 'Section 6: Presse / Press', + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Title (German)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'title_en', title: 'Title (English)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'content_de', title: 'Content (German)', type: 'array', of: [{ type: 'block' }] }), + defineField({ name: 'content_en', title: 'Content (English)', type: 'array', of: [{ type: 'block' }] }), + ], + }), + + // Section 7: Foto Galerie / Photo Gallery + defineField({ + name: 'gallery', + title: 'Section 7: Foto Galerie / Photo Gallery', + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Title (German)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'title_en', title: 'Title (English)', type: 'string', validation: (r) => r.required() }), + defineField({ + name: 'images', + title: 'Gallery Images', + type: 'array', + of: [{ type: 'image' }], + }), + ], + }), + + // Section 8: Tickets + defineField({ + name: 'tickets', + title: 'Section 8: Tickets', + type: 'object', + fields: [ + defineField({ name: 'title_de', title: 'Title (German)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'title_en', title: 'Title (English)', type: 'string', validation: (r) => r.required() }), + defineField({ name: 'content_de', title: 'Content (German)', type: 'array', of: [{ type: 'block' }] }), + defineField({ name: 'content_en', title: 'Content (English)', type: 'array', of: [{ type: 'block' }] }), + defineField({ name: 'ticket_url', title: 'Ticket Purchase URL', type: 'url' }), + ], + }), + ], +})