diff --git a/apps/website/src/lib/enums.ts b/apps/website/src/lib/enums.ts index 5c62ffd0b..1fe573901 100644 --- a/apps/website/src/lib/enums.ts +++ b/apps/website/src/lib/enums.ts @@ -127,6 +127,7 @@ export const PostState = { DRAFT: 'DRAFT', EPHEMERAL: 'EPHEMERAL', PUBLISHED: 'PUBLISHED', + TEMPLATE: 'TEMPLATE', } as const; export const PostSynchronizationKind = { diff --git a/apps/website/src/lib/server/graphql/schemas/post.ts b/apps/website/src/lib/server/graphql/schemas/post.ts index 2ee869391..72acf950f 100644 --- a/apps/website/src/lib/server/graphql/schemas/post.ts +++ b/apps/website/src/lib/server/graphql/schemas/post.ts @@ -28,6 +28,7 @@ import { BookmarkGroups, database, inArray, + notInArray, PostComments, PostContentSnapshots, PostContentStates, @@ -71,7 +72,7 @@ import { useFirstRow, useFirstRowOrThrow, } from '$lib/server/utils'; -import { base36To10, createEmptyTiptapDocumentNode } from '$lib/utils'; +import { base36To10, createEmptyTiptapDocumentNode, getMetadataFromTiptapDocument } from '$lib/utils'; import { PublishPostInputSchema } from '$lib/validations/post'; import { builder } from '../builder'; import { pubsub } from '../pubsub'; @@ -1003,6 +1004,8 @@ const CreatePostInput = builder.inputType('CreatePostInput', { fields: (t) => ({ spaceId: t.id({ required: false }), collectionId: t.id({ required: false }), + isTemplate: t.boolean({ defaultValue: false }), + usingTemplate: t.id({ required: false }), }), }); @@ -1279,23 +1282,49 @@ builder.mutationFields((t) => ({ } return await database.transaction(async (tx) => { + const publishOptions = input.usingTemplate + ? await database + .select({ + visibility: Posts.visibility, + discloseStats: Posts.discloseStats, + receiveFeedback: Posts.receiveFeedback, + receivePatronage: Posts.receivePatronage, + receiveTagContribution: Posts.receiveTagContribution, + protectContent: Posts.protectContent, + }) + .from(Posts) + .where(and(eq(Posts.id, input.usingTemplate))) + .then(useFirstRowOrThrow(new NotFoundError())) + : ({ + visibility: 'PUBLIC', + discloseStats: true, + receiveFeedback: true, + receivePatronage: true, + receiveTagContribution: true, + protectContent: true, + } as const); + const [post] = await tx .insert(Posts) .values({ permalink, userId: context.session.userId, spaceId: input.spaceId, - state: 'EPHEMERAL', - visibility: 'PUBLIC', - discloseStats: true, - receiveFeedback: true, - receivePatronage: true, - receiveTagContribution: true, - protectContent: true, + state: input.isTemplate ? 'TEMPLATE' : 'EPHEMERAL', + ...publishOptions, }) .returning({ id: Posts.id }); - const node = createEmptyTiptapDocumentNode(); + const metadata = input.usingTemplate + ? await tx + .select({ content: PostContentStates.content }) + .from(PostContentStates) + .where(eq(PostContentStates.postId, input.usingTemplate)) + .then((rows) => getMetadataFromTiptapDocument(rows[0].content)) + : null; + + const node = metadata?.doc ?? createEmptyTiptapDocumentNode(); + const doc = prosemirrorToYDoc(node, 'content'); const update = Y.encodeStateAsUpdateV2(doc); const vector = Y.encodeStateVector(doc); @@ -1307,10 +1336,10 @@ builder.mutationFields((t) => ({ vector, upToSeq: 0n, content: node.toJSON(), - text: '', - characters: 0, - images: 0, - files: 0, + text: metadata?.text ?? '', + characters: metadata?.characters ?? 0, + images: metadata?.images ?? 0, + files: metadata?.files ?? 0, }); await tx.insert(PostContentSnapshots).values({ @@ -1350,7 +1379,7 @@ builder.mutationFields((t) => ({ .where( and( eq(Posts.id, input.postId), - ne(Posts.state, 'DELETED'), + notInArray(Posts.state, ['DELETED', 'TEMPLATE']), or(eq(Spaces.state, 'ACTIVE'), isNull(Spaces.id)), ), ); @@ -1519,7 +1548,9 @@ builder.mutationFields((t) => ({ .select({ userId: Posts.userId, space: { id: Spaces.id } }) .from(Posts) .innerJoin(Spaces, eq(Spaces.id, Posts.spaceId)) - .where(and(eq(Posts.id, input.postId), eq(Posts.state, 'PUBLISHED'), eq(Spaces.state, 'ACTIVE'))); + .where( + and(eq(Posts.id, input.postId), inArray(Posts.state, ['PUBLISHED', 'TEMPLATE']), eq(Spaces.state, 'ACTIVE')), + ); if (posts.length === 0) { throw new NotFoundError(); @@ -1565,7 +1596,9 @@ builder.mutationFields((t) => ({ .select({ userId: Posts.userId, space: { id: Spaces.id } }) .from(Posts) .innerJoin(Spaces, eq(Spaces.id, Posts.spaceId)) - .where(and(eq(Posts.id, input.postId), eq(Posts.state, 'PUBLISHED'), eq(Spaces.state, 'ACTIVE'))); + .where( + and(eq(Posts.id, input.postId), inArray(Posts.state, ['PUBLISHED', 'TEMPLATE']), eq(Spaces.state, 'ACTIVE')), + ); if (posts.length === 0) { throw new NotFoundError(); diff --git a/apps/website/src/lib/server/graphql/schemas/template.ts b/apps/website/src/lib/server/graphql/schemas/template.ts new file mode 100644 index 000000000..0d6962ee2 --- /dev/null +++ b/apps/website/src/lib/server/graphql/schemas/template.ts @@ -0,0 +1,22 @@ +import { and, eq } from 'drizzle-orm'; +import { database, Posts } from '$lib/server/database'; +import { builder } from '../builder'; +import { Post } from './post'; + +builder.inputType('CreateTemplateBasedPostInput', { + fields: (t) => ({ + templateId: t.id(), + }), +}); + +builder.queryFields((t) => ({ + templatePosts: t.withAuth({ user: true }).field({ + type: [Post], + resolve: async (_, __, context) => { + return await database + .select() + .from(Posts) + .where(and(eq(Posts.state, 'TEMPLATE'), eq(Posts.userId, context.session.userId))); + }, + }), +})); diff --git a/apps/website/src/lib/utils/tiptap.ts b/apps/website/src/lib/utils/tiptap.ts index ec24209a3..5cd2b1e8a 100644 --- a/apps/website/src/lib/utils/tiptap.ts +++ b/apps/website/src/lib/utils/tiptap.ts @@ -36,6 +36,7 @@ export const getMetadataFromTiptapDocument = (content: JSONContent) => { }); return { + doc, text, characters, images,