diff --git a/src/backend/configure.ts b/src/backend/configure.ts index 28b2500a..6f2295ef 100644 --- a/src/backend/configure.ts +++ b/src/backend/configure.ts @@ -152,6 +152,7 @@ export async function configure(command: Configure) { await codemods.makeUsingStub(stubsRoot, 'resources/layout.stub', {}); await codemods.makeUsingStub(stubsRoot, 'resources/views/preview.stub', {}); + await codemods.makeUsingStub(stubsRoot, 'resources/views/preview_devotion.stub', {}); await codemods.makeUsingStub(stubsRoot, 'resources/views/scripture.stub', {}); await codemods.makeUsingStub(stubsRoot, 'commands/migrate.stub', {}); diff --git a/src/backend/index.ts b/src/backend/index.ts index 0ca3236b..f845d0c7 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -54,6 +54,17 @@ export * from './validators/invitation.js'; export * from './validators/bundle.js'; export * from './validators/chapter.js'; export * from './validators/course.js'; +export * from './validators/devotion_draft.js'; +export { + createDevotionDraftBundle, + normalizeDevotionDraftBundle, +} from '../shared/devotion_draft.js'; +export { resolvePreviewBundle } from '../shared/preview_bundle.js'; +export { cloneBlocksStructure } from '../shared/block_structure.js'; +export { + getPreviousDevotionChapterBlocks, + type StoryChapterSpecifier, +} from '../shared/previous_chapter_blocks.js'; export { default as DropValidator } from './validators/drop.js'; export { default as PageValidator } from './validators/page.js'; export * from './validators/user.js'; diff --git a/src/backend/services/draft_service.ts b/src/backend/services/draft_service.ts index 46163318..12a88a06 100644 --- a/src/backend/services/draft_service.ts +++ b/src/backend/services/draft_service.ts @@ -2,6 +2,8 @@ import Chapter from '../models/chapter.js'; import type { FieldMap, FieldSpec, StorySpec, StoryVersion, JSON } from '../../types'; import { BundleService } from './bundle_service.js'; import { CmsService } from './cms_service.js'; +import { createDevotionDraftBundle } from '../../shared/devotion_draft.js'; +import { isDevotionTemplate } from '../../shared/story_helpers.js'; export class DraftService { public story: StorySpec; @@ -25,6 +27,10 @@ export class DraftService { ): Promise | null> { // is this the source language? if (version.locale === this.cms.sourceLocale) { + if (isDevotionTemplate(this.story.template)) { + return JSON.stringify(createDevotionDraftBundle(number)); + } + const bundleService = new BundleService(this.story.fields); return bundleService.defaultBundle; } diff --git a/src/backend/services/helpers.ts b/src/backend/services/helpers.ts index cdc4e7bb..0c564e76 100644 --- a/src/backend/services/helpers.ts +++ b/src/backend/services/helpers.ts @@ -8,6 +8,8 @@ export { canPublishStory, canPublishStoryMetadata, canPublishStoryReady, + DEVOTION_TEMPLATE_ID, + isDevotionTemplate, missingPublishedChapters, storyDetailsBlockedMessages, publishBlockedMessage, diff --git a/src/backend/services/story_service.ts b/src/backend/services/story_service.ts index ac3bb652..0420d32f 100644 --- a/src/backend/services/story_service.ts +++ b/src/backend/services/story_service.ts @@ -429,6 +429,7 @@ export class StoryService { return { id: story.id, name: localisation?.title ?? '', + template: story.template, coverImage: localisation?.coverImage ?? '', chapterLimit: story.chapterLimit, chapterType: story.chapterType ?? '', diff --git a/src/backend/stubs/controllers/chapters_controller.stub b/src/backend/stubs/controllers/chapters_controller.stub index 561847ec..4138ec70 100644 --- a/src/backend/stubs/controllers/chapters_controller.stub +++ b/src/backend/stubs/controllers/chapters_controller.stub @@ -10,7 +10,10 @@ import { type AddStatus, IndexService, Chapter, + Draft, + Story, StoryService, + resolvePreviewBundle, } from '@story-cms/kit'; import cms from '#services/cms'; @@ -47,8 +50,15 @@ export default class ChaptersController { }; const chapter = await Chapter.query().where(specifier).firstOrFail(); - const bundleView = await edge.render({{ '`preview_${story.id}`' }}, { - bundle: chapter.bundle, + const draft = await Draft.query().where(specifier).first(); + const storyRecord = await Story.findOrFail(story.id); + const bundle = resolvePreviewBundle({ + chapter, + draft, + template: storyRecord.template, + }); + const bundleView = await edge.render(`preview_${storyRecord.template}`, { + bundle, marked: marked, number: chapter.number, }); @@ -56,8 +66,8 @@ export default class ChaptersController { const props: PreviewProps = { chapter: chapter.meta, bundleView: bundleView, - title: chapter.index.title, - bundle: chapter.bundle, + title: (typeof bundle.title === 'string' ? bundle.title : '') || chapter.index.title, + bundle, story, }; diff --git a/src/backend/stubs/controllers/drafts_controller.stub b/src/backend/stubs/controllers/drafts_controller.stub index ad42d259..87e4446b 100644 --- a/src/backend/stubs/controllers/drafts_controller.stub +++ b/src/backend/stubs/controllers/drafts_controller.stub @@ -10,7 +10,12 @@ import { Draft, DraftService, IndexService, + ResourceService, StoryService, + isDevotionTemplate, + normalizeDevotionDraftBundle, + getPreviousDevotionChapterBlocks, + type DevotionDraftEditProps, type DraftEditProps, } from '@story-cms/kit'; import cms from '#services/cms'; @@ -33,12 +38,19 @@ export default class DraftsController { const bundle = await service.getDraftBundle(version, number); if (bundle === null) return ctx.response.redirect('/'); - await Draft.create({ + const draft = await Draft.create({ ...version, number, bundle, }); + if ( + version.locale === cms.sourceLocale && + isDevotionTemplate(story.template) + ) { + ctx.session.flash('newDevotionDraftId', draft.id); + } + await Activity.create({ userId: ctx.auth.user?.id, locale: version.locale, @@ -100,6 +112,44 @@ export default class DraftsController { const isTranslation = version.locale !== cms.sourceLocale; if (!isTranslation) { + if (isDevotionTemplate(story.template)) { + const bundle = normalizeDevotionDraftBundle(draft.bundle, draft.number); + const resourceService = new ResourceService(); + const [availableResources, resources] = await Promise.all([ + resourceService.listForLocale(version.locale), + resourceService.hydrate(bundle.resources), + ]); + const createdDraftId = ctx.session.flashMessages.get('newDevotionDraftId'); + const previousChapterBlocks = + draft.number > 1 + ? await getPreviousDevotionChapterBlocks( + { ...specifier, number: draft.number }, + async (spec) => { + const previousDraft = await Draft.query().where(spec).first(); + if (previousDraft) { + return previousDraft.bundle; + } + + const previousChapter = await Chapter.query().where(spec).first(); + return previousChapter?.bundle ?? null; + }, + ) + : []; + const devotionData: DevotionDraftEditProps = { + ...data, + bundle: { + ...bundle, + resources, + }, + availableResources, + isCreate: Number(createdDraftId) === draft.id, + previousChapterBlocks, + }; + + // @ts-expect-error Inertia page name + return ctx.inertia.render('DevotionDraftEdit', devotionData); + } + // @ts-expect-error Inertia page name return ctx.inertia.render('DraftIndex', data); } diff --git a/src/backend/stubs/resources/views/preview_devotion.stub b/src/backend/stubs/resources/views/preview_devotion.stub new file mode 100644 index 00000000..ddb0f66c --- /dev/null +++ b/src/backend/stubs/resources/views/preview_devotion.stub @@ -0,0 +1,92 @@ +{{{ + exports({ to: app.makePath('resources/views/preview_devotion.edge') }) +}}} +
+ @if(bundle.number) +
Number
+
\{\{ bundle.number \}\}
+ @end + + @if(bundle.title) +
Title
+
\{\{ bundle.title \}\}
+ @end + + @if(bundle.description) +
Description
+
\{\{\{ marked.parse(bundle.description) \}\}\}
+ @end + + @if(bundle.coverImage) +
Cover Image
+ \{\{ bundle.title \}\} + @end + + @if(bundle.devotionAudio && bundle.devotionAudio.url) +
Devotion Audio
+ + @end + + @if(bundle.blocks) + @each(block in bundle.blocks) + @if(!block.visibility || !block.visibility.hidden) +
+ @if(block.blockName) +
\{\{ block.blockName \}\}
+ @end + + @if(block.kind === 'title') + @if(block.title) +
\{\{ block.title \}\}
+ @end + @if(block.subtitle) +
\{\{ block.subtitle \}\}
+ @end + @if(block.coverImage) + \{\{ block.title \}\} + @end + @end + + @if(block.kind === 'scripture') + @if(block.displayName) +
\{\{ block.displayName \}\}
+ @end + @if(block.scripture) + @!component('components/scripture', {passage: block.scripture}) + @end + @end + + @if(!block.kind || block.kind === 'content') + @if(block.displayName) +
\{\{ block.displayName \}\}
+ @end + @if(block.content) +
\{\{\{ marked.parse(block.content) \}\}\}
+ @end + @if(block.items) + @each(item in block.items) + @if(item.kind === 'image' && item.imageUrl) +
+ +
+ @end + @if(item.kind === 'video' && item.video && item.video.url) +
+ @!component('components/video', {url: item.video.url, librayId: librayId}) +
+ @end + @if(item.kind === 'scripture' && item.scripture) +
+ @!component('components/scripture', {passage: item.scripture}) +
+ @end + @endeach + @end + @end +
+ @end + @endeach + @end +
diff --git a/src/backend/stubs/validators/story_validator.stub b/src/backend/stubs/validators/story_validator.stub index 8e29cf98..06c0f025 100644 --- a/src/backend/stubs/validators/story_validator.stub +++ b/src/backend/stubs/validators/story_validator.stub @@ -2,7 +2,12 @@ exports({ to: app.makePath('app/validators/story_validator.ts') }) }}} import vine, { SimpleMessagesProvider } from '@vinejs/vine'; -import type { StorySpec, ValidatorType } from '@story-cms/kit'; +import { + DevotionDraftValidator, + isDevotionTemplate, + type StorySpec, + type ValidatorType, +} from '@story-cms/kit'; // to save us from setting vine.string().trim().minLength(1) everywhere vine.convertEmptyStringsToNull = true; @@ -41,6 +46,10 @@ export default class Story1 implements ValidatorType { } export const storyValidator = (story: StorySpec): ValidatorType => { + if (isDevotionTemplate(story.template)) { + return new DevotionDraftValidator(); + } + switch (story.id) { case 1: return new Story1(); diff --git a/src/backend/validators/devotion_draft.ts b/src/backend/validators/devotion_draft.ts new file mode 100644 index 00000000..b0f6699b --- /dev/null +++ b/src/backend/validators/devotion_draft.ts @@ -0,0 +1,162 @@ +import vine, { SimpleMessagesProvider } from '@vinejs/vine'; +import type { FieldContext } from '@vinejs/vine/types'; +import type { ValidatorType } from '../../types.js'; +import audioRule from './audio_rule.js'; +import videoRule from './video_rule.js'; + +const requiredString = () => vine.string().trim().minLength(1); + +const visibilitySchema = vine.object({ + presenter: vine.boolean({ strict: true }), + personal: vine.boolean({ strict: true }), + inNavigation: vine.boolean({ strict: true }), + hidden: vine.boolean({ strict: true }), +}); + +const scriptureSchema = vine.object({ + reference: requiredString(), + verse: requiredString(), +}); + +const imageItemSchema = vine.object({ + id: requiredString(), + kind: vine.literal('image'), + imageUrl: vine.string().trim().url({ require_protocol: true }), +}); + +const videoItemSchema = vine.object({ + id: requiredString(), + kind: vine.literal('video'), + video: vine + .object({ + url: vine.string().nullable(), + }) + .use(videoRule(null)), +}); + +const scriptureItemSchema = vine.object({ + id: requiredString(), + kind: vine.literal('scripture'), + scripture: scriptureSchema, +}); + +const itemSchema = vine.union([ + vine.union.if((value) => value.kind === 'image', imageItemSchema), + vine.union.if((value) => value.kind === 'video', videoItemSchema), + vine.union.if((value) => value.kind === 'scripture', scriptureItemSchema), + vine.union.else( + vine.object({ + id: requiredString(), + kind: vine.enum(['image', 'video', 'scripture'] as const), + }), + ), +]); + +const blockBase = { + id: requiredString(), + blockName: requiredString(), + visibility: visibilitySchema, +}; + +const contentOrItemRule = vine.createRule( + (value: unknown, _options: undefined, field: FieldContext) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return; + + const block = value as Record; + const hasContent = typeof block.content === 'string' && block.content.trim().length > 0; + const hasItems = Array.isArray(block.items) && block.items.length > 0; + if (!hasContent && !hasItems) { + field.report( + 'A content block must have text or at least one media or scripture item', + 'contentOrItem', + field, + ); + } + }, +); + +const contentBlockSchema = vine + .object({ + ...blockBase, + kind: vine.literal('content'), + displayName: requiredString(), + blockRole: requiredString(), + style: requiredString(), + content: vine.string().optional(), + items: vine.array(itemSchema).optional(), + leadersNotes: vine.string().optional(), + showLeadersNotes: vine.boolean({ strict: true }).optional(), + }) + .bail(false) + .use(contentOrItemRule()); + +const titleBlockSchema = vine.object({ + ...blockBase, + kind: vine.literal('title'), + title: requiredString(), + subtitle: vine.string().optional(), + coverImage: vine.string().optional(), +}); + +const scriptureBlockSchema = vine.object({ + ...blockBase, + kind: vine.literal('scripture'), + displayName: requiredString(), + scripture: scriptureSchema, + leadersNotes: vine.string().optional(), + showLeadersNotes: vine.boolean({ strict: true }).optional(), +}); + +const blockSchema = vine.union([ + vine.union.if((value) => value.kind === 'content', contentBlockSchema), + vine.union.if((value) => value.kind === 'title', titleBlockSchema), + vine.union.if((value) => value.kind === 'scripture', scriptureBlockSchema), + vine.union.else( + vine.object({ + ...blockBase, + kind: vine.enum(['content', 'title', 'scripture'] as const), + }), + ), +]); + +const devotionAudioSchema = vine + .object({ + url: vine.string().nullable(), + length: vine.number().nullable(), + }) + .use(audioRule({ canBeEmpty: true })) + .optional(); + +export const devotionDraftErrorMessages = new SimpleMessagesProvider({ + 'bundle.number.required': 'The devotion must have a number', + 'bundle.number.minLength': 'The devotion must have a number', + 'bundle.title.required': 'The devotion must have a title', + 'bundle.title.minLength': 'The devotion must have a title', + 'bundle.blocks.required': 'The devotion must have at least one block', + 'bundle.blocks.minLength': 'The devotion must have at least one block', + 'bundle.blocks.*.id.required': 'Every block must have an ID', + 'bundle.blocks.*.id.minLength': 'Every block must have an ID', + 'bundle.blocks.*.blockName.required': 'Every block must have a name', + 'bundle.blocks.*.blockName.minLength': 'Every block must have a name', + 'bundle.resources.*.uuid': 'Invalid resource', +}); + +export class DevotionDraftValidator implements ValidatorType { + validate(data: any): Promise { + vine.messagesProvider = devotionDraftErrorMessages; + + const schema = vine.create({ + bundle: vine.object({ + number: requiredString(), + title: requiredString(), + description: vine.string().optional(), + coverImage: vine.string().optional(), + devotionAudio: devotionAudioSchema, + blocks: vine.array(blockSchema).minLength(1), + resources: vine.array(vine.string().uuid()).optional(), + }), + }); + + return schema.validate(data); + } +} diff --git a/src/frontend/dashboard/action-card.story.vue b/src/frontend/dashboard/action-card.story.vue index fc01894b..c7294477 100644 --- a/src/frontend/dashboard/action-card.story.vue +++ b/src/frontend/dashboard/action-card.story.vue @@ -31,8 +31,8 @@ :icon="FileText" title="New Page" description="Engage your audience morning, noon, and night. Create healthy daily rhythms with content that reaches them throughout the day." - @action="handleAction" disabled + @action="handleAction" /> diff --git a/src/frontend/fields/attachments/attachment-field.vue b/src/frontend/fields/attachments/attachment-field.vue index a573203a..2e2de99e 100644 --- a/src/frontend/fields/attachments/attachment-field.vue +++ b/src/frontend/fields/attachments/attachment-field.vue @@ -3,6 +3,7 @@
+
+
+ + + +
+ + +
+ + + diff --git a/src/frontend/stories/components/blocks/add-leaders-notes-button.vue b/src/frontend/stories/components/blocks/add-leaders-notes-button.vue new file mode 100644 index 00000000..fe4201c8 --- /dev/null +++ b/src/frontend/stories/components/blocks/add-leaders-notes-button.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/frontend/stories/components/blocks/block-card-shell.vue b/src/frontend/stories/components/blocks/block-card-shell.vue new file mode 100644 index 00000000..3b6c106d --- /dev/null +++ b/src/frontend/stories/components/blocks/block-card-shell.vue @@ -0,0 +1,120 @@ + + + diff --git a/src/frontend/stories/components/blocks/block-empty-state.vue b/src/frontend/stories/components/blocks/block-empty-state.vue new file mode 100644 index 00000000..62c42539 --- /dev/null +++ b/src/frontend/stories/components/blocks/block-empty-state.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/frontend/stories/components/blocks/block-field-errors.ts b/src/frontend/stories/components/blocks/block-field-errors.ts new file mode 100644 index 00000000..c0058039 --- /dev/null +++ b/src/frontend/stories/components/blocks/block-field-errors.ts @@ -0,0 +1,48 @@ +type ValidationErrors = Record; + +function normalizeMessages(messages: string | string[] | undefined): string[] { + if (!messages) return []; + return Array.isArray(messages) ? messages : [messages]; +} + +function blockPrefix(blockIndex: number): string { + return `bundle.blocks.${blockIndex}`; +} + +export function blockFieldErrorMessages( + errors: ValidationErrors, + blockIndex: number, + field: string, +): string[] { + return normalizeMessages(errors[`${blockPrefix(blockIndex)}.${field}`]); +} + +export function blockItemFieldErrorMessages( + errors: ValidationErrors, + blockIndex: number, + itemIndex: number, + field: string, +): string[] { + return normalizeMessages( + errors[`${blockPrefix(blockIndex)}.items.${itemIndex}.${field}`], + ); +} + +export function blockHasError(errors: ValidationErrors, blockIndex: number): boolean { + const prefix = blockPrefix(blockIndex); + return Object.keys(errors).some((key) => key === prefix || key.startsWith(`${prefix}.`)); +} + +export function blockLevelErrorMessages( + errors: ValidationErrors, + blockIndex: number, +): string[] { + return normalizeMessages(errors[blockPrefix(blockIndex)]); +} + +export function blocksArrayErrorMessages(errors: ValidationErrors): string[] { + return [ + ...normalizeMessages(errors['bundle.blocks']), + ...normalizeMessages(errors['bundle.blocks.minLength']), + ]; +} diff --git a/src/frontend/stories/components/blocks/block-image-field.vue b/src/frontend/stories/components/blocks/block-image-field.vue new file mode 100644 index 00000000..3d6ad8b4 --- /dev/null +++ b/src/frontend/stories/components/blocks/block-image-field.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/frontend/stories/components/blocks/block-rich-text-editor.vue b/src/frontend/stories/components/blocks/block-rich-text-editor.vue new file mode 100644 index 00000000..79486dfd --- /dev/null +++ b/src/frontend/stories/components/blocks/block-rich-text-editor.vue @@ -0,0 +1,96 @@ +