Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c6556a0
feat: add NavigationPane component with icon support and correspondin…
timosville May 13, 2026
442bf0e
feat: refactor story edit page to use tabbed navigation and modular c…
timosville May 13, 2026
2493845
feat: add content classification panel with story, section, and chapt…
timosville May 13, 2026
cf975d6
feat: add visibility select field to story edit details component
timosville May 13, 2026
3163a38
wip: add section-panel
timosville May 13, 2026
17f2206
feat: enhance section panel field with improved icon sizes and toggle…
timosville May 13, 2026
860fae5
feat: update icon component with new SVG paths for improved visual re…
timosville May 13, 2026
f2a2d8b
feat: integrate resources section into story edit component with new …
timosville May 13, 2026
0b4b9fa
feat: enhance story edit components with tab icons and improved secti…
timosville May 13, 2026
5acabf3
feat: introduce sectionPanel widget to enhance story editing with str…
timosville May 13, 2026
79533c4
ops: merge
JannieT May 27, 2026
aade5b1
Merge branch 'sco-2435-standard-story-templates' into sco-2195-widget…
JannieT May 27, 2026
5953c5f
Merge branch 'sco-2435-standard-story-templates' into sco-2195-widget…
JannieT May 27, 2026
08594c8
refactor: export components temporarily needed in client
JannieT May 27, 2026
cba91e9
increment: field widget enrichment
JannieT May 28, 2026
1d2e602
increment: string and markdown fields can specify placeholder text
JannieT May 28, 2026
528b530
feat: slugify
JannieT May 28, 2026
d59a6fe
Merge branch 'main' into sco-2195-widgets-and-screens-for-editing-sto…
JannieT Jun 2, 2026
78d9d2b
Merge branch 'main' into sco-2195-widgets-and-screens-for-editing-sto…
JannieT Jun 2, 2026
8325c1d
refactor: moved tags from stories to localisation table
JannieT Jun 3, 2026
043881d
feat: story store route
JannieT Jun 3, 2026
e441f2d
fix: linting on sories_service
JannieT Jun 3, 2026
ed6c026
increment: export pill button
JannieT Jun 3, 2026
d6413c4
increment: export exceptions
JannieT Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/backend/exceptions/story_delete_exception.ts
Original file line number Diff line number Diff line change
@@ -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(' | ');
}
}
1 change: 0 additions & 1 deletion src/backend/factories/story_factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/backend/factories/story_localisation_factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
9 changes: 3 additions & 6 deletions src/backend/models/story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ export default class Story extends BaseModel {
@column({ isPrimary: true })
declare id: number;

@column()
declare tags: string;

@column()
declare chapterLimit: number;

Expand All @@ -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;
Expand All @@ -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<typeof StoryLocalisation>;
Expand Down
12 changes: 8 additions & 4 deletions src/backend/models/story_localisation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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<StoryLocalisation> = {
title: '',
coverImage: '',
description: '',
tags: '',
sections: [],
resources: [],
};
22 changes: 12 additions & 10 deletions src/backend/services/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>): object => {
const result: Record<string, unknown> = {};
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) {
Expand All @@ -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, '');
};
144 changes: 123 additions & 21 deletions src/backend/services/story_service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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) {}
Expand All @@ -26,6 +32,93 @@ export class StoryService {
return { storyId: Number.parseInt(ctx.params.storyId), locale: ctx.params.locale };
}

public async blockingPublishMessages(story: Story): Promise<string[]> {
// 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<string[]> {
// 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<StoryCreateProps | undefined> {
// 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>('providers')!,
};
}

public async uniqueSlug(title: string): Promise<string> {
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<StoryEditProps | undefined> {
const params = this.paramsFromPath(ctx);
if (!params) return undefined;
Expand All @@ -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,
Expand All @@ -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>('providers')!,
};
}
Expand Down Expand Up @@ -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,
};
});
Expand Down Expand Up @@ -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,
Expand All @@ -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 ?? [],
};
Expand Down
5 changes: 5 additions & 0 deletions src/backend/stubs/inertia/middleware.stub
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading