diff --git a/src/backend/exceptions/story_delete_exception.ts b/src/backend/exceptions/story_delete_exception.ts new file mode 100644 index 00000000..c0ddad74 --- /dev/null +++ b/src/backend/exceptions/story_delete_exception.ts @@ -0,0 +1,17 @@ +import { Exception } from '@adonisjs/core/exceptions'; + +export default class StoryDeleteException extends Exception { + static status = 500; + static code = 'E_STORY_DELETE_BLOCKED'; + + readonly messages: string[]; + + constructor(messages: string[]) { + super(messages.join('\n')); + this.messages = messages; + } + + public get reasons(): string { + return this.messages.join(' | '); + } +} diff --git a/src/backend/factories/story_factory.ts b/src/backend/factories/story_factory.ts index ea7ed9ab..28825c2d 100644 --- a/src/backend/factories/story_factory.ts +++ b/src/backend/factories/story_factory.ts @@ -5,7 +5,6 @@ import { StoryLocalisationFactory } from './story_localisation_factory.js'; export const StoryFactory = factory .define(Story, async ({ faker }) => { return { - tags: faker.lorem.words(3).split(' ').join(','), chapterLimit: 10, storyType: 'Story', chapterType: 'Chapter', diff --git a/src/backend/factories/story_localisation_factory.ts b/src/backend/factories/story_localisation_factory.ts index a44043e5..a212f077 100644 --- a/src/backend/factories/story_localisation_factory.ts +++ b/src/backend/factories/story_localisation_factory.ts @@ -10,6 +10,7 @@ export const StoryLocalisationFactory = factory title: faker.lorem.sentence(), coverImage: faker.image.url(), description: faker.lorem.paragraph(), + tags: faker.lorem.words(3).split(' ').join(','), sections: [ { id: faker.string.uuid(), diff --git a/src/backend/index.ts b/src/backend/index.ts index 270a6026..6bd64a96 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -3,6 +3,7 @@ export * from '../types.js'; export { configure } from './configure.js'; export { stubsRoot } from './stubs/main.js'; export * from './define_config.js'; +export { default as StoryDeleteException } from './exceptions/story_delete_exception.js'; export { default as Activity } from './models/activity.js'; export { default as Invitation } from './models/invitation.js'; export { default as Chapter } from './models/chapter.js'; diff --git a/src/backend/models/story.ts b/src/backend/models/story.ts index eabecd6e..6d3e0ec7 100644 --- a/src/backend/models/story.ts +++ b/src/backend/models/story.ts @@ -7,9 +7,6 @@ export default class Story extends BaseModel { @column({ isPrimary: true }) declare id: number; - @column() - declare tags: string; - @column() declare chapterLimit: number; @@ -20,7 +17,7 @@ export default class Story extends BaseModel { declare chapterType: string; @column() - declare sectionType: string; + declare sectionType: string | null; @column() declare visibility: string; @@ -38,10 +35,10 @@ export default class Story extends BaseModel { declare isPublished: boolean; @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime; + declare createdAt: DateTime | null; @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime; + declare updatedAt: DateTime | null; @hasMany(() => StoryLocalisation) declare localisations: HasMany; diff --git a/src/backend/models/story_localisation.ts b/src/backend/models/story_localisation.ts index 01ebb1fc..ee8c8c4b 100644 --- a/src/backend/models/story_localisation.ts +++ b/src/backend/models/story_localisation.ts @@ -24,10 +24,13 @@ export default class StoryLocalisation extends BaseModel { declare title: string; @column() - declare coverImage: string; + declare coverImage: string | null; @column() - declare description: string; + declare description: string | null; + + @column() + declare tags: string | null; @column({ prepare: (value) => prepareJsonColumn(value), @@ -45,16 +48,17 @@ export default class StoryLocalisation extends BaseModel { declare updatedBy: number | null; @column.dateTime({ autoCreate: true }) - declare createdAt: DateTime; + declare createdAt: DateTime | null; @column.dateTime({ autoCreate: true, autoUpdate: true }) - declare updatedAt: DateTime; + declare updatedAt: DateTime | null; } export const emptyTranslation: Partial = { title: '', coverImage: '', description: '', + tags: '', sections: [], resources: [], }; diff --git a/src/backend/services/helpers.ts b/src/backend/services/helpers.ts index 2a377b54..5705fcfe 100644 --- a/src/backend/services/helpers.ts +++ b/src/backend/services/helpers.ts @@ -4,16 +4,6 @@ export { standardAudienceKeys, } from '../../shared/audience_helpers.js'; -// The model store on the client requires that the error messages -// each have a "bundle" prefix -export const bundledErrors = (plain: Record): object => { - const result: Record = {}; - for (const key in plain) { - result[`bundle.${key}`] = plain[key]; - } - return result; -}; - export const getCredentialsFrom = (key: string): any => { const credentialsBase64 = process.env[key] || ''; if (!credentialsBase64) { @@ -28,3 +18,15 @@ export const getCredentialsFrom = (key: string): any => { throw new Error(`${key} environment variable is not a valid encoded JSON string.`); } }; + +export const slugify = (text: string): string => { + return text + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); +}; diff --git a/src/backend/services/story_service.ts b/src/backend/services/story_service.ts index 964015b8..a93f6859 100644 --- a/src/backend/services/story_service.ts +++ b/src/backend/services/story_service.ts @@ -1,8 +1,11 @@ import config from '@adonisjs/core/services/config'; import { errors, HttpContext } from '@adonisjs/core/http'; +import db from '@adonisjs/lucid/services/db'; +import StoryDeleteException from '../exceptions/story_delete_exception.js'; +import Chapter from '../models/chapter.js'; import Index from '../models/index.js'; import Story from '../models/story.js'; -import { emptyTranslation } from '../models/story_localisation.js'; +import StoryLocalisation, { emptyTranslation } from '../models/story_localisation.js'; import type { CmsConfig, FieldSpec, @@ -11,7 +14,10 @@ import type { StorySpec, StoryVersion, Providers, + StoryCreateProps, } from '../../types.js'; +import Draft from '../models/draft.js'; +import { slugify } from './helpers.js'; export class StoryService { public constructor(protected readonly config: CmsConfig) {} @@ -26,6 +32,93 @@ export class StoryService { return { storyId: Number.parseInt(ctx.params.storyId), locale: ctx.params.locale }; } + public async blockingPublishMessages(story: Story): Promise { + // return ['Story not found.', 'Story is published.', 'Story has content.']; + const collected: string[] = []; + // source language chapter count + const sourceChapters = await Chapter.query() + .where('storyId', story.id) + .where('locale', this.config.languages[0].locale); + const missingChapters = story.chapterLimit - (sourceChapters?.length ?? 0); + if (missingChapters > 0) + collected.push( + `You need to add ${missingChapters} more ${story.chapterType}s to your ${story.storyType}.`, + ); + + return collected; + } + + async blockingDeleteMessages(storyId: number): Promise { + // return ['Story not found.', 'Story is published.', 'Story has content.']; + const collected: string[] = []; + const story = await Story.query().where('id', storyId).first(); + if (!story) collected.push('Story not found.'); + if (story?.isPublished) collected.push('Story is published.'); + + const chapters = await Chapter.query().where('storyId', storyId); + if (chapters.length > 0) collected.push('Story has published chapters.'); + + return collected; + } + + public async delete(storyId: number) { + const messages = await this.blockingDeleteMessages(storyId); + if (messages.length > 0) throw new StoryDeleteException(messages); + // cascade delete drafts, chapters and index + await db.transaction(async (trx) => { + await Draft.query({ client: trx }).where('storyId', storyId).delete(); + await Chapter.query({ client: trx }).where('storyId', storyId).delete(); + await Index.query({ client: trx }).where('storyId', storyId).delete(); + await StoryLocalisation.query({ client: trx }).where('storyId', storyId).delete(); + await Story.query({ client: trx }).where('id', storyId).delete(); + }); + } + + public async createProps(ctx: HttpContext): Promise { + // ready? + if (!this.config.storyTemplates.length) return undefined; + const params = this.paramsFromPath(ctx); + if (params?.locale !== this.config.languages[0].locale) return undefined; + + // set + const template = this.config.storyTemplates[0].id; + + const firstLocalisation = await StoryLocalisation.query() + .where('locale', this.config.languages[0].locale) + .first(); + + const target = firstLocalisation + ? { + ...emptyTranslation, + coverImage: firstLocalisation?.coverImage, + } + : emptyTranslation; + + // go! + return { + model: { + chapterLimit: 10, + storyType: 'Story', + chapterType: 'Chapter', + sectionType: null, + visibility: 'public', + slug: null, + template, + ...this.localisationFields(target), + }, + templates: this.config.storyTemplates, + providers: config.get('providers')!, + }; + } + + public async uniqueSlug(title: string): Promise { + const slug = slugify(title); + const story = await Story.query().where('slug', slug).first(); + if (story) + return this.uniqueSlug(title + '-' + Math.random().toString(36).substring(2, 15)); + return slug; + } + public async editProps(ctx: HttpContext): Promise { const params = this.paramsFromPath(ctx); if (!params) return undefined; @@ -47,14 +140,23 @@ export class StoryService { const target = story.localisations.find((localisation) => localisation.locale === locale) ?? emptyTranslation; - const source = - story.localisations.find((localisation) => localisation.locale === sourceLocale) ?? - emptyTranslation; + + let sourceSection = {}; + if (locale !== sourceLocale) { + const source = + story.localisations.find( + (localisation) => localisation.locale === sourceLocale, + ) ?? emptyTranslation; + sourceSection = { + source: this.localisationFields(source), + }; + + target.coverImage = source.coverImage; + } return { model: { id: story.id, - tags: story.tags ?? null, chapterLimit: story.chapterLimit, storyType: story.storyType, chapterType: story.chapterType, @@ -63,12 +165,10 @@ export class StoryService { slug: story.slug, template: story.template, isPublished: story.isPublished, - createdAt: story.createdAt.toISO()!, - updatedAt: story.updatedAt.toISO()!, ...this.localisationFields(target), }, - source: this.localisationFields(source), - isNew: hasNoContent, + ...sourceSection, + hasNoContent, providers: config.get('providers')!, }; } @@ -136,12 +236,12 @@ export class StoryService { return { id: story.id, name: local.title, - description: local.description, - coverImage: local.coverImage, + description: local.description ?? '', + coverImage: local.coverImage ?? '', chapterLimit: story.chapterLimit, isPublished: story.isPublished, - createdAt: story.createdAt.toISO()!, - updatedAt: story.updatedAt.toISO()!, + createdAt: story.createdAt?.toISO() ?? '', + updatedAt: story.updatedAt?.toISO() ?? '', draftCount: index?.draftsList.length ?? 0, }; }); @@ -194,12 +294,12 @@ export class StoryService { return this.specFrom(story); } - protected specFrom(story: Story): StorySpec { + public specFrom(story: Story): StorySpec { const localisation = story.localisations[0] ?? emptyTranslation; return { id: story.id, name: localisation.title, - coverImage: localisation.coverImage, + coverImage: localisation.coverImage ?? '', chapterLimit: story.chapterLimit, chapterType: story.chapterType, storyType: story.storyType, @@ -210,19 +310,21 @@ export class StoryService { } private localisationFields(local: { - title?: string; - coverImage?: string; - description?: string; - sections?: StoryEditProps['model']['sections']; - resources?: string[]; + title?: string | null; + coverImage?: string | null; + description?: string | null; + tags?: string | null; + sections?: StoryEditProps['model']['sections'] | null; + resources?: string[] | null; }): Pick< StoryEditProps['model'], - 'title' | 'coverImage' | 'description' | 'sections' | 'resources' + 'title' | 'coverImage' | 'description' | 'tags' | 'sections' | 'resources' > { return { title: local.title ?? '', coverImage: local.coverImage ?? '', description: local.description ?? '', + tags: local.tags ?? null, sections: local.sections ?? [], resources: local.resources ?? [], }; diff --git a/src/backend/stubs/inertia/middleware.stub b/src/backend/stubs/inertia/middleware.stub index eadc8b83..8e16a6f7 100644 --- a/src/backend/stubs/inertia/middleware.stub +++ b/src/backend/stubs/inertia/middleware.stub @@ -34,8 +34,13 @@ export default class InertiaMiddleware extends BaseInertiaMiddleware { */ return { ...cmsShared, + + // these errors triggers the router onError callback errors: ctx.inertia.always(getTrimmedValidationErrors(ctx)), + + // these messages are received in the router onSuccess callback flash: ctx.inertia.always({ + // resolve as follows: const flashError = (page.props as { flash?: { error?: string } }).flash?.error; error: error, }), // the rest of the shared props are injected by CmsService.sharedProps() diff --git a/src/backend/stubs/migrations/stories.stub b/src/backend/stubs/migrations/stories.stub index e25aff2e..33e13160 100644 --- a/src/backend/stubs/migrations/stories.stub +++ b/src/backend/stubs/migrations/stories.stub @@ -20,8 +20,6 @@ export default class extends BaseSchema { .notNullable() .comment('unique handle for referencing the story'); - table.string('tags').nullable().comment('comma-separated list of tags'); - table .integer('chapter_limit') .notNullable() @@ -73,7 +71,7 @@ export default class extends BaseSchema { }); // --------------------------------------- - // story_meta + // story_localisations // --------------------------------------- this.schema.createTable(this.localTableName, (table) => { table.increments('id'); @@ -88,6 +86,8 @@ export default class extends BaseSchema { table.text('description').nullable(); + table.string('tags').nullable().comment('comma-separated list of tags'); + table .jsonb('sections') .notNullable() diff --git a/src/backend/stubs/routes/stories.stub b/src/backend/stubs/routes/stories.stub index 7e626f89..47561509 100644 --- a/src/backend/stubs/routes/stories.stub +++ b/src/backend/stubs/routes/stories.stub @@ -11,6 +11,7 @@ export default () => { // stories router.get(':locale/story', [StoriesController, 'index']); router.get(':locale/story/create', [StoriesController, 'create']); + router.post(':locale/story', [StoriesController, 'store']); router.get(':locale/story/:storyId/edit', [StoriesController, 'edit']); router.delete(':locale/story/:storyId', [StoriesController, 'delete']); router.post(':locale/story/:storyId', [StoriesController, 'update']); diff --git a/src/backend/stubs/tests/helpers/cms_mock.stub b/src/backend/stubs/tests/helpers/cms_mock.stub index 0d61fbdb..0391ad22 100644 --- a/src/backend/stubs/tests/helpers/cms_mock.stub +++ b/src/backend/stubs/tests/helpers/cms_mock.stub @@ -13,6 +13,7 @@ export const testCmsConfig: CmsConfig = { helpUrl: 'https://example.com/help', hasAppPreview: false, microcopySource: '', + supportEmail: 'support@example.com', languages: [ { locale: 'en', diff --git a/src/frontend/fields/markdown-field.story.vue b/src/frontend/fields/markdown-field.story.vue index 1e92653e..8fa41c9a 100644 --- a/src/frontend/fields/markdown-field.story.vue +++ b/src/frontend/fields/markdown-field.story.vue @@ -78,6 +78,12 @@ + + + + diff --git a/src/frontend/fields/markdown-field.vue b/src/frontend/fields/markdown-field.vue index a93f4af9..77731006 100644 --- a/src/frontend/fields/markdown-field.vue +++ b/src/frontend/fields/markdown-field.vue @@ -138,6 +138,7 @@ onMounted(async () => { spellChecker: false, nativeSpellcheck: false, status: false, + placeholder: field.value.placeholderText, // @ts-expect-error toolbar is not typed toolbar: toolbar.value, }); diff --git a/src/frontend/fields/number-field.vue b/src/frontend/fields/number-field.vue index ccaef207..18407dd3 100644 --- a/src/frontend/fields/number-field.vue +++ b/src/frontend/fields/number-field.vue @@ -16,7 +16,7 @@ v-model="modelValue" type="number" :readonly="props.isReadOnly" - class="w-24 input-field" + class="input-field w-24" :class="{ 'border-error': hasError, 'text-gray-600': props.isReadOnly, @@ -24,7 +24,7 @@ }" @input="update" /> -

Please enter a number

+

{{ errors[0] }}

diff --git a/src/frontend/fields/panel-field.story.vue b/src/frontend/fields/panel-field.story.vue index dbac5d57..7090228a 100644 --- a/src/frontend/fields/panel-field.story.vue +++ b/src/frontend/fields/panel-field.story.vue @@ -31,6 +31,11 @@ + + + + + @@ -86,6 +91,11 @@ const noLabel = { label: '', }; +const withBackgroundColor = { + ...spec, + backgroundColor: 'indigo-50', +}; + const isRowWithNoLabel = { label: '', name: 'note', diff --git a/src/frontend/fields/panel-field.vue b/src/frontend/fields/panel-field.vue index 98aa91eb..5b5b1fcf 100644 --- a/src/frontend/fields/panel-field.vue +++ b/src/frontend/fields/panel-field.vue @@ -1,7 +1,8 @@ + + diff --git a/src/frontend/fields/section-panel-field.vue b/src/frontend/fields/section-panel-field.vue new file mode 100644 index 00000000..9561e483 --- /dev/null +++ b/src/frontend/fields/section-panel-field.vue @@ -0,0 +1,260 @@ + + + diff --git a/src/frontend/fields/string-field.story.vue b/src/frontend/fields/string-field.story.vue index 8160d12c..28f99f6d 100644 --- a/src/frontend/fields/string-field.story.vue +++ b/src/frontend/fields/string-field.story.vue @@ -20,6 +20,12 @@ + + + + diff --git a/src/frontend/fields/string-field.vue b/src/frontend/fields/string-field.vue index 5e5ee1d6..9bcc569a 100644 --- a/src/frontend/fields/string-field.vue +++ b/src/frontend/fields/string-field.vue @@ -20,6 +20,7 @@ :readonly="props.isReadOnly" autocomplete="given-name" :value="modelValue" + :placeholder="field.placeholderText" class="input-field" :class="{ 'border-error': hasError, diff --git a/src/frontend/fields/widget-fields.ts b/src/frontend/fields/widget-fields.ts index 2667fda8..5ab35941 100644 --- a/src/frontend/fields/widget-fields.ts +++ b/src/frontend/fields/widget-fields.ts @@ -17,6 +17,7 @@ import DateField from './date-field.vue'; import DateRangeField from './date-range-field.vue'; import TagField from './tag-field.vue'; import RegionField from './region-field.vue'; +import SectionPanelField from './section-panel-field.vue'; export const widgetField = (widget: string) => { const up = widget[0].toUpperCase() + widget.substring(1); @@ -62,6 +63,8 @@ export const widgetField = (widget: string) => { return TagField; case 'RegionField': return RegionField; + case 'SectionPanelField': + return SectionPanelField; default: return NullField; } diff --git a/src/frontend/index.story.md b/src/frontend/index.story.md index 6e13810d..40809e39 100644 --- a/src/frontend/index.story.md +++ b/src/frontend/index.story.md @@ -19,7 +19,7 @@ Following are the widgets that are currently implemented: [string](#string), [number](#number), [markdown](#markdown), [image](#image), [audio](#audio), [boolean](#boolean), [select](#select), [object](#object), -[panel](#panel), [list](#list), [scripture](#scripture), +[panel](#panel), [list](#list), [sectionPanel](#sectionpanel), [scripture](#scripture), [scriptureReference](#scripturereference), [date](#date), [dateRange](#daterange), [region](#region) @@ -27,8 +27,22 @@ Following are the widgets that are currently implemented: ## string -Suitable for short, single line plain text strings. It has only the common properties and -renders a [StringField](#) +Suitable for short, single line plain text strings and renders a [StringField](#). It has +one optional special property: + +- `placeholderText` an optional string displayed as placeholder text in the input when the + field is empty + +Example: + +```ts +{ + label: 'Title', + name: 'title', + widget: 'string', + placeholderText: 'Enter a title', +}, +``` ## number @@ -74,6 +88,8 @@ these optional extra properties: minimal height possible. - `noMarkup` a boolean value which is `false` by default. `noMarkup` sets the widget to not allow any markup and only display the text as plain text. +- `placeholderText` an optional string displayed as placeholder text in the editor when the + field is empty - `toolbar` accepts an array of strings `['bold', 'italic', 'strikethrough', 'heading', 'heading-smaller', 'heading-bigger', 'heading-1', 'heading-2', 'heading-3', 'code', 'quote', 'unordered-list', 'ordered-list', 'clean-block', 'link', 'image', 'upload-image', 'table', 'horizontal-rule', 'preview', 'side-by-side', 'fullscreen', 'guide','undo', 'redo'` representing the formatting buttons to display. @@ -112,7 +128,7 @@ Remove the toolbar by passing an empty toolbar array. Example: name: 'excerpt', widget: 'markdown', minimal: true, - toobar: [] + toolbar: [] }, ``` @@ -138,7 +154,7 @@ the component to the `toolbar` Example: name: 'excerpt', widget: 'markdown', minimal: true, - toobar: ['footnote'] + toolbar: ['footnote'] }, ``` @@ -398,10 +414,11 @@ example: ## panel Suitable to group several primitive fields visually together. -Has two special properties +Has these special properties: - `isRow` Optional boolean to specify that the panel item should be rendered side by side and not vertically aligned. +- `backgroundColor` Optional string value that accepts a Tailwind color. Defaults to `white` - `fields` which is a required list with primitive fields that should be grouped together visually. Note: The same visual effect can be achieved by wrapping primitive fields in an `object` @@ -432,6 +449,34 @@ example: }, ``` +With a custom background color: + +```ts +{ + widget: 'panel', + backgroundColor: 'indigo-50', + fields: [ + { + label: 'Title', + name: 'title', + widget: 'string', + }, + ], +}, +``` + +Note: You will have to whitelist any custom background colors in the project's +`tailwind.config.cjs` file. For example, for the custom 'indigo-50' value used in the +example above, you will have to include these variants: + +```js +module.exports = { + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + safelist: ['bg-indigo-50', 'indigo-50'], 👈 + // rest of the Tailwind config +}; +``` + For horizontal layouts ```ts @@ -491,13 +536,59 @@ example: }, { label: 'Passage', - name: 'paxssage', + name: 'passage', widget: 'scripture', }, ] } ``` +## sectionPanel + +A repeating editor that renders a [SectionPanelField](#). Each item is a **card**: drag +handle, optional row title, expand/collapse, and optional delete. Nested `fields` render +inside the expanded body. Use this when editors should skim many groups as a stack of +panels rather than a single long list. + +Specialised keys: + +- `fields`: required array of field specs for one row (same idea as [list](#list)). +- `index`: optional dotted path into one row’s data (e.g. `scripture.reference`) used as + the **collapsed row label**. If omitted, the **first** field in `fields` supplies the + title string. +- `isFlexible`: optional boolean; when `true`, row add/remove/reorder stays enabled in + translation workflows where list-like widgets are normally read-only. + +example (scripture + commentary rows): + +```ts +{ + label: 'Section', + name: 'sections', + widget: 'sectionPanel', + index: 'scripture.reference', + fields: [ + { label: 'Scripture', name: 'scripture', widget: 'scripture' }, + { label: 'Commentary', name: 'commentary', widget: 'markdown' }, + ], +} +``` + +example (title-driven rows, no `index`): + +```ts +{ + label: 'Resource', + name: 'resources', + widget: 'sectionPanel', + fields: [ + { label: 'Title', name: 'title', widget: 'string' }, + { label: 'URL', name: 'url', widget: 'string' }, + { label: 'Summary', name: 'summary', widget: 'markdown' }, + ], +} +``` + ## scripture A compound widget that renders a [ScriptureField](#). It has a Bible reference field that diff --git a/src/frontend/index.ts b/src/frontend/index.ts index 8838a0f1..76c24b59 100644 --- a/src/frontend/index.ts +++ b/src/frontend/index.ts @@ -23,6 +23,7 @@ import ImageField from './fields/image-field.vue'; import IndexCard from './stories/components/index-card.vue'; import IndexFilter from './shared/index-filter.vue'; import LabelButton from './shared/label-button.vue'; +import PillButton from './shared/pill-button.vue'; import LanguagesEdit from './settings/languages/languages-edit.vue'; import SettingsIndex from './settings/settings-index.vue'; import ListField from './fields/list-field.vue'; @@ -58,11 +59,15 @@ import UiPage from './ui/ui-page.vue'; import UsersIndex from './team/users-index.vue'; import VideoField from './fields/video-field.vue'; +import ContentSidebar from './shared/content-sidebar.vue'; +import NavigationPane from './shared/navigation-pane.vue'; +import SectionPanelField from './fields/section-panel-field.vue'; + export { createInertiaApp, usePage, useForm, router } from '@inertiajs/vue3'; export * from './store/index'; -export { commonProps, helpScoutWidget } from './shared/helpers'; +export { commonProps, helpScoutWidget, formatDate } from './shared/helpers'; export { ActionButton, @@ -72,8 +77,6 @@ export { AudienceIndex, AudioField, BooleanField, - InvitationsEdit, - InvitationsIndex, ChapterPreview, ContentHeader, ContextMenu, @@ -83,15 +86,17 @@ export { DateRangeField, DraftIndex, DropDown, + DropEdit, ForgotPassword, Icon, IconButton, ImageField, IndexCard, IndexFilter, + InvitationsEdit, + InvitationsIndex, LabelButton, LanguagesEdit, - SettingsIndex, ListField, ListSwitcher, Login, @@ -105,16 +110,17 @@ export { PagesIndex, PanelField, Pagination, + PillButton, PublicLayout, RegionField, ResetPassword, ScriptureField, SelectField, + SettingsIndex, StatusTag, StoryEdit, StoryGallery, StoryIndex, - DropEdit, StreamGallery, StreamIndex, StringField, @@ -124,4 +130,10 @@ export { UiPage, UsersIndex, VideoField, + // --------------- + // TODO: remove once landed + // --------------- + ContentSidebar, + NavigationPane, + SectionPanelField, }; diff --git a/src/frontend/shared/icon.vue b/src/frontend/shared/icon.vue index dd720265..e37d22ab 100644 --- a/src/frontend/shared/icon.vue +++ b/src/frontend/shared/icon.vue @@ -182,7 +182,7 @@ > @@ -322,6 +322,7 @@ fill="currentColor" /> + - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/src/frontend/shared/navigation-pane.story.vue b/src/frontend/shared/navigation-pane.story.vue new file mode 100644 index 00000000..db589b58 --- /dev/null +++ b/src/frontend/shared/navigation-pane.story.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/frontend/shared/navigation-pane.vue b/src/frontend/shared/navigation-pane.vue new file mode 100644 index 00000000..b6c864a3 --- /dev/null +++ b/src/frontend/shared/navigation-pane.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/frontend/stories/components/story-edit-details.vue b/src/frontend/stories/components/story-edit-details.vue new file mode 100644 index 00000000..b4298ebd --- /dev/null +++ b/src/frontend/stories/components/story-edit-details.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/frontend/stories/components/story-edit-resources.vue b/src/frontend/stories/components/story-edit-resources.vue new file mode 100644 index 00000000..bb257cd9 --- /dev/null +++ b/src/frontend/stories/components/story-edit-resources.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/frontend/stories/components/story-edit-sections.vue b/src/frontend/stories/components/story-edit-sections.vue new file mode 100644 index 00000000..fd7fd113 --- /dev/null +++ b/src/frontend/stories/components/story-edit-sections.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/frontend/stories/draft-index.vue b/src/frontend/stories/draft-index.vue index 21cfddc0..7f89e659 100644 --- a/src/frontend/stories/draft-index.vue +++ b/src/frontend/stories/draft-index.vue @@ -195,6 +195,8 @@ const metaChapter = computed( () => `${padZero(props.draft.number)} of ${padZero(props.story.chapterLimit)}`, ); +// TODO: this might leak memory, because pinia does not auto-unsubscribe +// outside an effect scope like inside onMounted or outside setup onMounted(() => { model.$subscribe(() => { if (isSettingErrors) { diff --git a/src/frontend/stories/story-edit.story.vue b/src/frontend/stories/story-edit.story.vue index 0948cbf1..39da2e46 100644 --- a/src/frontend/stories/story-edit.story.vue +++ b/src/frontend/stories/story-edit.story.vue @@ -30,7 +30,7 @@ + diff --git a/src/types.ts b/src/types.ts index d458f410..009dfdea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,9 +51,11 @@ export interface FieldSpec { noMarkup?: boolean; toolbar?: string[]; description?: string; + placeholderText?: string; extensions?: string[]; maxSize?: number; tintColor?: string; + backgroundColor?: string; labelOrder?: string; folder?: string; collectionId?: string; @@ -261,11 +263,32 @@ export interface JournaStoryEditProps { isNew: boolean; } +export interface StoryCreateProps { + model: { + title: string; + coverImage: string; + description: string; + chapterLimit: number; + tags: string | null; + storyType: string; + chapterType: string; + sectionType: string | null; + visibility: string; + slug: string | null; + template: string; + }; + templates: BundleTemplate[]; + providers: Providers; +} + export interface StoryEditProps { model: { id: number; - tags: string | null; + title: string; + coverImage: string; + description: string; chapterLimit: number; + tags: string | null; storyType: string; chapterType: string; sectionType: string | null; @@ -273,22 +296,17 @@ export interface StoryEditProps { slug: string; template: string; isPublished: boolean; - createdAt: string; - updatedAt: string; - title: string; - coverImage: string; - description: string; sections: StorySection[]; resources: string[]; }; - source: { + source?: { title: string; coverImage: string; description: string; sections: StorySection[]; resources: string[]; }; - isNew: boolean; + hasNoContent: boolean; providers: Providers; } @@ -458,6 +476,13 @@ export interface TabItem { count: number; } +/** Tabs for `navigation-pane` (icon + label). */ +export interface NavigationPaneTab { + label: string; + /** Registered `Icon` name; omitted for label-only tabs. */ + icon?: string; +} + export interface StatMetric { name: string; stat: number; diff --git a/tailwind.config.js b/tailwind.config.js index 864f1ca7..773989d7 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -7,7 +7,14 @@ export default { content: [ "./src/frontend/**/*.{vue,js,ts,jsx,tsx}", ], - safelist: ['bg-green-400', 'green-400', 'focus:ring-green-400', 'cursor-default'], + safelist: [ + 'bg-green-400', + 'green-400', + 'focus:ring-green-400', + 'cursor-default', + 'bg-indigo-50', + 'indigo-50', + ], theme: { extend: { colors: { diff --git a/tests/unit/slugify.spec.ts b/tests/unit/slugify.spec.ts new file mode 100644 index 00000000..936bd1b9 --- /dev/null +++ b/tests/unit/slugify.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; + +import { slugify } from '../../src/backend/services/helpers.js'; + +test.describe('slugify', () => { + test('lowercases and hyphenates simple titles', () => { + expect(slugify('My Cool Story!')).toBe('my-cool-story'); + expect(slugify('Genesis Devotional')).toBe('genesis-devotional'); + }); + + test('collapses whitespace and underscores to a single hyphen', () => { + expect(slugify('My Cool Story')).toBe('my-cool-story'); + expect(slugify('hello_world')).toBe('hello-world'); + expect(slugify('hello\tworld')).toBe('hello-world'); + }); + + test('transliterates accented characters', () => { + expect(slugify('Café Stories')).toBe('cafe-stories'); + expect(slugify('Naïve')).toBe('naive'); + }); + + test('trims leading and trailing whitespace and punctuation', () => { + expect(slugify(' Hello ')).toBe('hello'); + expect(slugify('---Hello---')).toBe('hello'); + }); + + test('preserves existing hyphens and numbers', () => { + expect(slugify('pre-existing')).toBe('pre-existing'); + expect(slugify('Chapter 1')).toBe('chapter-1'); + }); + + test('returns an empty string when nothing slug-worthy remains', () => { + expect(slugify('')).toBe(''); + expect(slugify('!!!')).toBe(''); + expect(slugify(' ')).toBe(''); + }); +});