diff --git a/.gitea/workflows/main.yaml b/.gitea/workflows/main.yaml index 7370abe..bc394d2 100644 --- a/.gitea/workflows/main.yaml +++ b/.gitea/workflows/main.yaml @@ -70,4 +70,7 @@ jobs: run: bun docker:api - name: Build and Push App - run: bun docker:app \ No newline at end of file + run: bun docker:app + + - name: Build and Push Page + run: bun docker:page \ No newline at end of file diff --git a/apps/api/src/changelog/byId.ts b/apps/api/src/changelog/byId.ts index eed24b8..e0a64cf 100644 --- a/apps/api/src/changelog/byId.ts +++ b/apps/api/src/changelog/byId.ts @@ -32,6 +32,11 @@ export const func = async ({ userId, id }: { userId: string; id: string }) => { const result = await db.query.changelog.findFirst({ where: and(eq(changelog.userId, userId), eq(changelog.id, id)), with: { + pages: { + with: { + page: true, + }, + }, versions: { orderBy: (changelog_version, { desc }) => [ desc(changelog_version.createdAt), diff --git a/apps/api/src/changelog/update.ts b/apps/api/src/changelog/update.ts index c5e2619..74c7212 100644 --- a/apps/api/src/changelog/update.ts +++ b/apps/api/src/changelog/update.ts @@ -10,6 +10,7 @@ import { HTTPException } from 'hono/http-exception' export const route = createRoute({ method: 'put', path: '/:id', + tags: ['changelog'], request: { body: { content: { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f1217b7..efd42d8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,7 +8,9 @@ import changelog from './changelog' import version from './changelog/version' import user from './user' +import pageApi from './page' import { authentication } from './utils/authentication' +import { handleError, handleZodError } from './utils/errors' import { startup } from './utils/startup' type User = z.infer @@ -17,20 +19,24 @@ export type Variables = { user: User } -export const app = new OpenAPIHono<{ Variables: Variables }>() +export const app = new OpenAPIHono<{ Variables: Variables }>({ + defaultHook: handleZodError, +}) -app.use( - '*', - sentry({ - dsn: 'https://1d7428bbab0a305078cf4aa380721aa2@o4508167321354240.ingest.de.sentry.io/4508167323648080', - }), -) +// app.use( +// '*', +// sentry({ +// dsn: 'https://1d7428bbab0a305078cf4aa380721aa2@o4508167321354240.ingest.de.sentry.io/4508167323648080', +// }), +// ) +app.onError(handleError) app.use('*', cors()) app.use('/v1/*', authentication) app.route('/v1/user', user) app.route('/v1/changelog', changelog) app.route('/v1/changelog/version', version) +app.route('/v1/page', pageApi) app.doc('/openapi.json', { openapi: '3.0.0', diff --git a/apps/api/src/page/byId.ts b/apps/api/src/page/byId.ts new file mode 100644 index 0000000..b05754a --- /dev/null +++ b/apps/api/src/page/byId.ts @@ -0,0 +1,65 @@ +import { db, page } from '@boring.tools/database' +import { PageByIdParams, PageOutput } from '@boring.tools/schema' +import { createRoute } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' + +import { verifyAuthentication } from '../utils/authentication' +import type { pageApi } from './index' + +const route = createRoute({ + method: 'get', + tags: ['page'], + description: 'Get a page by id', + path: '/:id', + request: { + params: PageByIdParams, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: PageOutput, + }, + }, + description: 'Return changelog by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const registerPageById = (api: typeof pageApi) => { + return api.openapi(route, async (c) => { + const userId = verifyAuthentication(c) + const { id } = c.req.valid('param') + + const result = await db.query.page.findFirst({ + where: and(eq(page.id, id), eq(page.userId, userId)), + with: { + changelogs: { + with: { + changelog: true, + }, + }, + }, + }) + + if (!result) { + throw new HTTPException(404, { message: 'Not Found' }) + } + + const { changelogs, ...rest } = result + + const mappedResult = { + ...rest, + changelogs: changelogs.map((log) => log.changelog), + } + + return c.json(mappedResult, 200) + }) +} diff --git a/apps/api/src/page/create.ts b/apps/api/src/page/create.ts new file mode 100644 index 0000000..cba5a3e --- /dev/null +++ b/apps/api/src/page/create.ts @@ -0,0 +1,69 @@ +import { changelogs_to_pages, db, page } from '@boring.tools/database' +import { PageCreateInput, PageOutput } from '@boring.tools/schema' +import { createRoute, type z } from '@hono/zod-openapi' +import { HTTPException } from 'hono/http-exception' + +import { verifyAuthentication } from '../utils/authentication' +import type { pageApi } from './index' + +const route = createRoute({ + method: 'post', + tags: ['page'], + description: 'Create a page', + path: '/', + request: { + body: { + content: { + 'application/json': { schema: PageCreateInput }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: PageOutput, + }, + }, + description: 'Return changelog by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const registerPageCreate = (api: typeof pageApi) => { + return api.openapi(route, async (c) => { + const userId = verifyAuthentication(c) + + const { changelogIds, ...rest }: z.infer = + await c.req.json() + + const [result] = await db + .insert(page) + .values({ + ...rest, + userId: userId, + }) + .returning() + + // TODO: implement transaction + if (changelogIds) { + await db.insert(changelogs_to_pages).values( + changelogIds.map((changelogId) => ({ + changelogId, + pageId: result.id, + })), + ) + } + if (!result) { + throw new HTTPException(404, { message: 'Not Found' }) + } + + return c.json(result, 200) + }) +} diff --git a/apps/api/src/page/delete.ts b/apps/api/src/page/delete.ts new file mode 100644 index 0000000..4133972 --- /dev/null +++ b/apps/api/src/page/delete.ts @@ -0,0 +1,48 @@ +import { db, page } from '@boring.tools/database' +import { GeneralOutput, PageByIdParams } from '@boring.tools/schema' +import { createRoute } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' +import type { pageApi } from '.' +import { verifyAuthentication } from '../utils/authentication' + +const route = createRoute({ + method: 'delete', + path: '/:id', + request: { + params: PageByIdParams, + }, + + responses: { + 200: { + content: { + 'application/json': { + schema: GeneralOutput, + }, + }, + description: 'Removes a changelog by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const registerPageDelete = (api: typeof pageApi) => { + return api.openapi(route, async (c) => { + const userId = verifyAuthentication(c) + const { id } = c.req.valid('param') + const result = await db + .delete(page) + .where(and(eq(page.userId, userId), eq(page.id, id))) + .returning() + if (!result) { + throw new HTTPException(404, { message: 'Not Found' }) + } + + return c.json(result, 200) + }) +} diff --git a/apps/api/src/page/index.ts b/apps/api/src/page/index.ts new file mode 100644 index 0000000..d22fda4 --- /dev/null +++ b/apps/api/src/page/index.ts @@ -0,0 +1,24 @@ +import { OpenAPIHono } from '@hono/zod-openapi' +import type { Variables } from '..' +import type { ContextModule } from '../utils/sentry' +import { registerPageById } from './byId' +import { registerPageCreate } from './create' +import { registerPageDelete } from './delete' +import { registerPageList } from './list' +import { registerPagePublic } from './public' +import { registerPageUpdate } from './update' + +export const pageApi = new OpenAPIHono<{ Variables: Variables }>() + +const module: ContextModule = { + name: 'page', +} + +registerPageById(pageApi) +registerPageCreate(pageApi) +registerPageList(pageApi) +registerPagePublic(pageApi) +registerPageDelete(pageApi) +registerPageUpdate(pageApi) + +export default pageApi diff --git a/apps/api/src/page/list.ts b/apps/api/src/page/list.ts new file mode 100644 index 0000000..deb6126 --- /dev/null +++ b/apps/api/src/page/list.ts @@ -0,0 +1,47 @@ +import { db, page } from '@boring.tools/database' +import { createRoute } from '@hono/zod-openapi' +import { and, eq } from 'drizzle-orm' + +import { PageListOutput } from '@boring.tools/schema' +import { HTTPException } from 'hono/http-exception' +import { verifyAuthentication } from '../utils/authentication' +import type { pageApi } from './index' + +const route = createRoute({ + method: 'get', + tags: ['page'], + description: 'Get a page list', + path: '/', + responses: { + 200: { + content: { + 'application/json': { + schema: PageListOutput, + }, + }, + description: 'Return changelog by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const registerPageList = (api: typeof pageApi) => { + return api.openapi(route, async (c) => { + const userId = verifyAuthentication(c) + + const result = await db.query.page.findMany({ + where: and(eq(page.userId, userId)), + }) + + if (!result) { + throw new HTTPException(404, { message: 'Not Found' }) + } + + return c.json(result, 200) + }) +} diff --git a/apps/api/src/page/public.ts b/apps/api/src/page/public.ts new file mode 100644 index 0000000..acf7a2d --- /dev/null +++ b/apps/api/src/page/public.ts @@ -0,0 +1,86 @@ +import { changelog_version, db, page } from '@boring.tools/database' +import { createRoute } from '@hono/zod-openapi' +import { eq } from 'drizzle-orm' + +import { PagePublicOutput, PagePublicParams } from '@boring.tools/schema' +import { HTTPException } from 'hono/http-exception' +import type { pageApi } from './index' + +const route = createRoute({ + method: 'get', + tags: ['page'], + description: 'Get a page', + path: '/:id/public', + request: { + params: PagePublicParams, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: PagePublicOutput, + }, + }, + description: 'Return changelog by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const registerPagePublic = (api: typeof pageApi) => { + return api.openapi(route, async (c) => { + const { id } = c.req.valid('param') + + const result = await db.query.page.findFirst({ + where: eq(page.id, id), + columns: { + title: true, + description: true, + icon: true, + }, + with: { + changelogs: { + with: { + changelog: { + columns: { + title: true, + description: true, + }, + with: { + versions: { + where: eq(changelog_version.status, 'published'), + orderBy: (changelog_version, { desc }) => [ + desc(changelog_version.createdAt), + ], + columns: { + markdown: true, + version: true, + releasedAt: true, + }, + }, + }, + }, + }, + }, + }, + }) + + if (!result) { + throw new HTTPException(404, { message: 'Not Found' }) + } + + const { changelogs, ...rest } = result + + const mappedResult = { + ...rest, + changelogs: changelogs.map((log) => log.changelog), + } + + return c.json(mappedResult, 200) + }) +} diff --git a/apps/api/src/page/update.ts b/apps/api/src/page/update.ts new file mode 100644 index 0000000..28aa7b7 --- /dev/null +++ b/apps/api/src/page/update.ts @@ -0,0 +1,88 @@ +import { changelogs_to_pages, db, page } from '@boring.tools/database' +import { createRoute, type z } from '@hono/zod-openapi' + +import { + PageOutput, + PageUpdateInput, + PageUpdateParams, +} from '@boring.tools/schema' +import { and, eq } from 'drizzle-orm' +import { HTTPException } from 'hono/http-exception' +import { verifyAuthentication } from '../utils/authentication' +import type { pageApi } from './index' + +const route = createRoute({ + method: 'put', + tags: ['page'], + description: 'Update a page', + path: '/:id', + request: { + params: PageUpdateParams, + body: { + content: { + 'application/json': { schema: PageUpdateInput }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: PageOutput, + }, + }, + description: 'Return changelog by id', + }, + 400: { + description: 'Bad Request', + }, + 500: { + description: 'Internal Server Error', + }, + }, +}) + +export const registerPageUpdate = (api: typeof pageApi) => { + return api.openapi(route, async (c) => { + const userId = verifyAuthentication(c) + const { id } = c.req.valid('param') + + const { changelogIds, ...rest }: z.infer = + await c.req.json() + + const [result] = await db + .update(page) + .set({ + ...rest, + userId, + }) + .where(and(eq(page.userId, userId), eq(page.id, id))) + .returning() + + // TODO: implement transaction + if (changelogIds) { + if (changelogIds.length === 0) { + await db + .delete(changelogs_to_pages) + .where(eq(changelogs_to_pages.pageId, result.id)) + } + if (changelogIds?.length >= 1) { + await db + .delete(changelogs_to_pages) + .where(eq(changelogs_to_pages.pageId, result.id)) + await db.insert(changelogs_to_pages).values( + changelogIds.map((changelogId) => ({ + changelogId, + pageId: result.id, + })), + ) + } + } + + if (!result) { + throw new HTTPException(404, { message: 'Not Found' }) + } + + return c.json(result, 200) + }) +} diff --git a/apps/api/src/user/get.ts b/apps/api/src/user/get.ts index ab00dea..52cc980 100644 --- a/apps/api/src/user/get.ts +++ b/apps/api/src/user/get.ts @@ -1,11 +1,13 @@ -import { type UserSelect, db, user as userDb } from '@boring.tools/database' +import { db, user as userDb } from '@boring.tools/database' import { UserOutput } from '@boring.tools/schema' import { createRoute } from '@hono/zod-openapi' import { eq } from 'drizzle-orm' +import type { userApi } from '.' -export const route = createRoute({ +const route = createRoute({ method: 'get', path: '/', + tags: ['user'], responses: { 200: { content: { @@ -22,19 +24,17 @@ export const route = createRoute({ }, }) -export const func = async ({ user }: { user: UserSelect }) => { - const result = await db.query.user.findFirst({ - where: eq(userDb.id, user.id), +export const registerUserGet = (api: typeof userApi) => { + return api.openapi(route, async (c) => { + const user = c.get('user') + const result = await db.query.user.findFirst({ + where: eq(userDb.id, user.id), + }) + + if (!result) { + throw new Error('User not found') + } + + return c.json(result, 200) }) - - if (!result) { - throw new Error('User not found') - } - - return result -} - -export default { - route, - func, } diff --git a/apps/api/src/user/index.ts b/apps/api/src/user/index.ts index fd5d4e6..016e8e8 100644 --- a/apps/api/src/user/index.ts +++ b/apps/api/src/user/index.ts @@ -1,48 +1,16 @@ -import { logger } from '@boring.tools/logger' import { OpenAPIHono } from '@hono/zod-openapi' -import { Webhook } from 'svix' import type { Variables } from '..' -import { type ContextModule, captureSentry } from '../utils/sentry' -import get from './get' -import webhook from './webhook' +import type { ContextModule } from '../utils/sentry' +import { registerUserGet } from './get' +import { registerUserWebhook } from './webhook' -const app = new OpenAPIHono<{ Variables: Variables }>() +export const userApi = new OpenAPIHono<{ Variables: Variables }>() const module: ContextModule = { name: 'user', } -app.openapi(get.route, async (c) => { - const user = c.get('user') - try { - const result = await get.func({ user }) - return c.json(result, 201) - } catch (error) { - return captureSentry({ - c, - error, - module, - user, - }) - } -}) +registerUserGet(userApi) +registerUserWebhook(userApi) -app.openapi(webhook.route, async (c) => { - try { - const wh = new Webhook(import.meta.env.CLERK_WEBHOOK_SECRET as string) - const payload = await c.req.json() - const headers = c.req.header() - const verifiedPayload = wh.verify(JSON.stringify(payload), headers) - const result = await webhook.func({ payload: verifiedPayload }) - logger.info('Clerk Webhook', result) - return c.json(result, 200) - } catch (error) { - return captureSentry({ - c, - error, - module, - }) - } -}) - -export default app +export default userApi diff --git a/apps/api/src/user/webhook.ts b/apps/api/src/user/webhook.ts index 871dce7..7c8243a 100644 --- a/apps/api/src/user/webhook.ts +++ b/apps/api/src/user/webhook.ts @@ -3,10 +3,13 @@ import { logger } from '@boring.tools/logger' import { UserOutput, UserWebhookInput } from '@boring.tools/schema' import { createRoute, type z } from '@hono/zod-openapi' import { HTTPException } from 'hono/http-exception' +import { Webhook } from 'svix' +import type userApi from '.' -export const route = createRoute({ +const route = createRoute({ method: 'post', path: '/webhook', + tags: ['user'], request: { body: { content: { @@ -59,20 +62,20 @@ const userCreate = async ({ } } -export const func = async ({ - payload, -}: { - payload: z.infer -}) => { - switch (payload.type) { - case 'user.created': - return userCreate({ payload }) - default: - throw new HTTPException(404, { message: 'Webhook type not supported' }) - } -} - -export default { - route, - func, +export const registerUserWebhook = (api: typeof userApi) => { + return api.openapi(route, async (c) => { + const wh = new Webhook(import.meta.env.CLERK_WEBHOOK_SECRET as string) + const payload = await c.req.json() + const headers = c.req.header() + const verifiedPayload = wh.verify(JSON.stringify(payload), headers) + switch (verifiedPayload.type) { + case 'user.created': { + const result = await userCreate({ payload: verifiedPayload }) + logger.info('Clerk Webhook', result) + return c.json(result, 200) + } + default: + throw new HTTPException(404, { message: 'Webhook type not supported' }) + } + }) } diff --git a/apps/api/src/utils/errors/base-error.ts b/apps/api/src/utils/errors/base-error.ts new file mode 100644 index 0000000..1315a6e --- /dev/null +++ b/apps/api/src/utils/errors/base-error.ts @@ -0,0 +1,46 @@ +import type { ErrorCode } from '.' + +type ErrorContext = Record + +export abstract class BaseError< + TContext extends ErrorContext = ErrorContext, +> extends Error { + public abstract readonly name: string + /** + * A distinct code for the error type used to differentiate between different types of errors. + * Used to build the URL for the error documentation. + * @example 'UNAUTHENTICATED' | 'INTERNAL_SERVER_ERROR' + */ + public abstract readonly code?: ErrorCode + public readonly cause?: BaseError + /** + * Additional context to help understand the error. + * @example { url: 'https://example.com/api', method: 'GET', statusCode: 401 } + */ + public readonly context?: TContext + + constructor(opts: { + message: string + cause?: BaseError + context?: TContext + }) { + super(opts.message) + this.cause = opts.cause + this.context = opts.context + + // TODO: add logger here! + } + + public toString(): string { + return `${this.name}(${this.code}): ${ + this.message + } - caused by ${this.cause?.toString()} - with context ${JSON.stringify( + this.context, + )}` + } + + // get docs(): string { + // if (!this.code) return "https://example.com/docs/errors" + // return `https://example.com/docs/errors/${this.code}`; + // } +} diff --git a/apps/api/src/utils/errors/http-error.ts b/apps/api/src/utils/errors/http-error.ts new file mode 100644 index 0000000..e81352f --- /dev/null +++ b/apps/api/src/utils/errors/http-error.ts @@ -0,0 +1,35 @@ +import { type ErrorCode, statusToCode } from '.' +import { BaseError } from './base-error' + +type Context = { + url?: string + method?: string + statusCode?: number +} + +export class HttpError extends BaseError { + public readonly name = HttpError.name + public readonly code: ErrorCode + + constructor(opts: { + code: ErrorCode + message: string + cause?: BaseError + context?: Context + }) { + super(opts) + this.code = opts.code + } + + public static fromRequest(request: Request, response: Response) { + return new HttpError({ + code: statusToCode(response.status), + message: response.statusText, // can be overriden with { ...res, statusText: 'Custom message' } + context: { + url: request.url, + method: request.method, + statusCode: response.status, + }, + }) + } +} diff --git a/apps/api/src/utils/errors/index.ts b/apps/api/src/utils/errors/index.ts new file mode 100644 index 0000000..f86c0c3 --- /dev/null +++ b/apps/api/src/utils/errors/index.ts @@ -0,0 +1,120 @@ +import type { Context } from 'hono' +import { HTTPException } from 'hono/http-exception' +import { ZodError, z } from 'zod' +import { SchemaError } from './schema-error' + +export const ErrorCodeEnum = z.enum([ + 'BAD_REQUEST', + 'FORBIDDEN', + 'INTERNAL_SERVER_ERROR', + 'USAGE_EXCEEDED', + 'DISABLED', + 'CONFLICT', + 'NOT_FOUND', + 'NOT_UNIQUE', + 'UNAUTHORIZED', + 'METHOD_NOT_ALLOWED', + 'UNPROCESSABLE_ENTITY', +]) + +export type ErrorCode = z.infer + +export function statusToCode(status: number): ErrorCode { + switch (status) { + case 400: + return 'BAD_REQUEST' + case 401: + return 'UNAUTHORIZED' + case 403: + return 'FORBIDDEN' + case 404: + return 'NOT_FOUND' + case 405: + return 'METHOD_NOT_ALLOWED' + case 409: + return 'METHOD_NOT_ALLOWED' + case 422: + return 'UNPROCESSABLE_ENTITY' + case 500: + return 'INTERNAL_SERVER_ERROR' + default: + return 'INTERNAL_SERVER_ERROR' + } +} + +export type ErrorSchema = z.infer> + +export function createErrorSchema(code: ErrorCode) { + return z.object({ + code: ErrorCodeEnum.openapi({ + example: code, + description: 'The error code related to the status code.', + }), + message: z.string().openapi({ + description: 'A human readable message describing the issue.', + example: "Missing required field 'name'.", + }), + docs: z.string().openapi({ + description: 'A link to the documentation for the error.', + example: `https://docs.openstatus.dev/api-references/errors/code/${code}`, + }), + }) +} + +export function handleError(err: Error, c: Context): Response { + if (err instanceof ZodError) { + const error = SchemaError.fromZod(err, c) + return c.json( + { + code: 'BAD_REQUEST', + message: error.message, + docs: 'https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST', + }, + { status: 400 }, + ) + } + if (err instanceof HTTPException) { + const code = statusToCode(err.status) + return c.json( + { + code: code, + message: err.message, + docs: `https://docs.openstatus.dev/api-references/errors/code/${code}`, + }, + { status: err.status }, + ) + } + return c.json( + { + code: 'INTERNAL_SERVER_ERROR', + message: err.message ?? 'Something went wrong', + docs: 'https://docs.openstatus.dev/api-references/errors/code/INTERNAL_SERVER_ERROR', + }, + + { status: 500 }, + ) +} +export function handleZodError( + result: + | { + success: true + data: unknown + } + | { + success: false + error: ZodError + }, + c: Context, +) { + if (!result.success) { + const error = SchemaError.fromZod(result.error, c) + return c.json>>( + { + code: 'BAD_REQUEST', + docs: 'https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST', + message: error.message, + }, + { status: 400 }, + ) + } +} diff --git a/apps/api/src/utils/errors/schema-error.ts b/apps/api/src/utils/errors/schema-error.ts new file mode 100644 index 0000000..e819e8e --- /dev/null +++ b/apps/api/src/utils/errors/schema-error.ts @@ -0,0 +1,30 @@ +import type { ZodError } from 'zod' + +import type { ErrorCode } from '.' +import { BaseError } from './base-error' +import { parseZodErrorIssues } from './utils' + +type Context = { raw: unknown } + +export class SchemaError extends BaseError { + public readonly name = SchemaError.name + public readonly code: ErrorCode + + constructor(opts: { + code: ErrorCode + message: string + cause?: BaseError + context?: Context + }) { + super(opts) + this.code = opts.code + } + + static fromZod(e: ZodError, raw: unknown): SchemaError { + return new SchemaError({ + code: 'UNPROCESSABLE_ENTITY', + message: parseZodErrorIssues(e.issues), + context: { raw: JSON.stringify(raw) }, + }) + } +} diff --git a/apps/api/src/utils/errors/utils.ts b/apps/api/src/utils/errors/utils.ts new file mode 100644 index 0000000..ce45a5b --- /dev/null +++ b/apps/api/src/utils/errors/utils.ts @@ -0,0 +1,66 @@ +import type { ZodIssue } from 'zod' +import type { ErrorCode } from '.' + +export function statusToCode(status: number): ErrorCode { + switch (status) { + case 400: + return 'BAD_REQUEST' + case 401: + return 'UNAUTHORIZED' + case 403: + return 'FORBIDDEN' + case 404: + return 'NOT_FOUND' + case 405: + return 'METHOD_NOT_ALLOWED' + case 409: + return 'METHOD_NOT_ALLOWED' + case 422: + return 'UNPROCESSABLE_ENTITY' + case 500: + return 'INTERNAL_SERVER_ERROR' + default: + return 'INTERNAL_SERVER_ERROR' + } +} + +export function codeToStatus(code: ErrorCode): number { + switch (code) { + case 'BAD_REQUEST': + return 400 + case 'UNAUTHORIZED': + return 401 + case 'FORBIDDEN': + return 403 + case 'NOT_FOUND': + return 404 + case 'METHOD_NOT_ALLOWED': + return 405 + case 'CONFLICT': + return 409 + case 'UNPROCESSABLE_ENTITY': + return 422 + case 'INTERNAL_SERVER_ERROR': + return 500 + default: + return 500 + } +} + +// Props to cal.com: https://github.com/calcom/cal.com/blob/5d325495a9c30c5a9d89fc2adfa620b8fde9346e/packages/lib/server/getServerErrorFromUnknown.ts#L17 +export function parseZodErrorIssues(issues: ZodIssue[]): string { + return issues + .map((i) => + i.code === 'invalid_union' + ? i.unionErrors.map((ue) => parseZodErrorIssues(ue.issues)).join('; ') + : i.code === 'unrecognized_keys' + ? i.message + : `${i.path.length ? `${i.code} in '${i.path}': ` : ''}${i.message}`, + ) + .join('; ') +} + +export function redactError(err: TError) { + if (!(err instanceof Error)) return err + console.error(`Type of Error: ${err.constructor}`) +} diff --git a/apps/app/src/components/Page/Delete.tsx b/apps/app/src/components/Page/Delete.tsx new file mode 100644 index 0000000..3956725 --- /dev/null +++ b/apps/app/src/components/Page/Delete.tsx @@ -0,0 +1,72 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Button, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@boring.tools/ui' +import { useNavigate } from '@tanstack/react-router' +import { Trash2Icon } from 'lucide-react' +import { useState } from 'react' +import { usePageDelete } from '../../hooks/usePage' + +export const PageDelete = ({ id }: { id: string }) => { + const remove = usePageDelete() + const navigate = useNavigate({ from: `/page/${id}` }) + const [isOpen, setIsOpen] = useState(false) + + const removeChangelog = () => { + remove.mutate( + { id }, + { + onSuccess: () => { + setIsOpen(false) + navigate({ to: '/page' }) + }, + }, + ) + } + return ( + + + + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your + page and remove your data from our servers. + + + + setIsOpen(false)}> + Cancel + + + + + + + + +

Remove

+
+
+ ) +} diff --git a/apps/app/src/components/Sidebar.tsx b/apps/app/src/components/Sidebar.tsx index 8a0f133..fa580d0 100644 --- a/apps/app/src/components/Sidebar.tsx +++ b/apps/app/src/components/Sidebar.tsx @@ -1,37 +1,19 @@ -import { ChevronRightIcon, FileStackIcon } from 'lucide-react' - import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, Sidebar as SidebarComp, SidebarContent, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, - SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, } from '@boring.tools/ui' import { Link } from '@tanstack/react-router' -import { useChangelogList } from '../hooks/useChangelog' +import { SidebarChangelog } from './SidebarChangelog' +import { SidebarPage } from './SidebarPage' import { SidebarUser } from './SidebarUser' -const items = [ - { - title: 'Changelog', - url: '/changelog', - icon: FileStackIcon, - isActive: true, - }, -] - export function Sidebar() { - const { data, error } = useChangelogList() return ( @@ -53,47 +35,8 @@ export function Sidebar() { - {items.map((item) => ( - - - - - - {item.title} - - - - - - - Toggle - - - - - {!error && - data?.map((changelog) => ( - - - - {changelog.title} - - - - ))} - - - - - ))} + + diff --git a/apps/app/src/components/SidebarChangelog.tsx b/apps/app/src/components/SidebarChangelog.tsx new file mode 100644 index 0000000..76c8afc --- /dev/null +++ b/apps/app/src/components/SidebarChangelog.tsx @@ -0,0 +1,76 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from '@boring.tools/ui' +import { Link } from '@tanstack/react-router' +import { ChevronRightIcon, FileStackIcon, PlusIcon } from 'lucide-react' +import { useChangelogList } from '../hooks/useChangelog' + +export const SidebarChangelog = () => { + const { data, error } = useChangelogList() + + return ( + + + + + + Changelog + + + + + + + Toggle + + + + + {!error && + data?.map((changelog) => ( + + + + {changelog.title} + + + + ))} + + + + + + + New changelog + + + + + + + + + ) +} diff --git a/apps/app/src/components/SidebarPage.tsx b/apps/app/src/components/SidebarPage.tsx new file mode 100644 index 0000000..ca3b2fb --- /dev/null +++ b/apps/app/src/components/SidebarPage.tsx @@ -0,0 +1,73 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from '@boring.tools/ui' +import { Link } from '@tanstack/react-router' +import { ChevronRightIcon, NotebookTextIcon, PlusIcon } from 'lucide-react' +import { usePageList } from '../hooks/usePage' + +export const SidebarPage = () => { + const { data, error } = usePageList() + + return ( + + + + + + Page + + + + + + + Toggle + + + + + {!error && + data?.map((page) => ( + + + + {page.title} + + + + ))} + + + + + + + New page + + + + + + + + + ) +} diff --git a/apps/app/src/hooks/usePage.ts b/apps/app/src/hooks/usePage.ts new file mode 100644 index 0000000..e0e1981 --- /dev/null +++ b/apps/app/src/hooks/usePage.ts @@ -0,0 +1,196 @@ +import type { + PageByIdOutput, + PageCreateInput, + PageListOutput, + PageOutput, + PageUpdateInput, +} from '@boring.tools/schema' +import { useAuth } from '@clerk/clerk-react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { z } from 'zod' +import { queryFetch } from '../utils/queryFetch' + +type Page = z.infer +type PageList = z.infer +type PageById = z.infer +type PageCreate = z.infer +type PageUpdate = z.infer + +export const usePageList = () => { + const { getToken } = useAuth() + return useQuery({ + queryKey: ['pageList'], + queryFn: async (): Promise> => + await queryFetch({ + path: 'page', + method: 'get', + token: await getToken(), + }), + }) +} + +export const usePageById = ({ id }: { id: string }) => { + const { getToken } = useAuth() + + return useQuery({ + queryKey: ['pageById', id], + queryFn: async (): Promise> => + await queryFetch({ + path: `page/${id}`, + method: 'get', + token: await getToken(), + }), + }) +} + +export const usePageCreate = () => { + const { getToken } = useAuth() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: PageCreate): Promise> => + await queryFetch({ + path: 'page', + data: payload, + method: 'post', + token: await getToken(), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pageList'] }) + }, + }) +} + +export const usePageDelete = () => { + const { getToken } = useAuth() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id }: { id: string }): Promise> => + await queryFetch({ + path: `page/${id}`, + method: 'delete', + token: await getToken(), + }), + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: ['pageList'], + }) + queryClient.invalidateQueries({ + queryKey: ['pageById', data.id], + }) + }, + }) +} + +export const usePageUpdate = () => { + const { getToken } = useAuth() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + id, + payload, + }: { + id: string + payload: PageUpdate + }): Promise> => + await queryFetch({ + path: `page/${id}`, + data: payload, + method: 'put', + token: await getToken(), + }), + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: ['pageById', data.id], + }) + queryClient.invalidateQueries({ + queryKey: ['pageList'], + }) + }, + }) +} +/* + + +export const useChangelogVersionCreate = () => { + const { getToken } = useAuth() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (payload: VersionCreate): Promise> => + await queryFetch({ + path: 'changelog/version', + data: payload, + method: 'post', + token: await getToken(), + }), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['changelogList'] }) + queryClient.invalidateQueries({ + queryKey: ['changelogById', data.changelogId], + }) + }, + }) +} + +export const useChangelogVersionById = ({ id }: { id: string }) => { + const { getToken } = useAuth() + + return useQuery({ + queryKey: ['changelogVersionById', id], + queryFn: async (): Promise> => + await queryFetch({ + path: `changelog/version/${id}`, + method: 'get', + token: await getToken(), + }), + }) +} + +export const useChangelogVersionUpdate = () => { + const { getToken } = useAuth() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + id, + payload, + }: { + id: string + payload: VersionUpdate + }): Promise> => + await queryFetch({ + path: `changelog/version/${id}`, + data: payload, + method: 'put', + token: await getToken(), + }), + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: ['changelogById', data.id], + }) + }, + }) +} + +export const useChangelogVersionRemove = () => { + const { getToken } = useAuth() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ id }: { id: string }): Promise> => + await queryFetch({ + path: `changelog/version/${id}`, + method: 'delete', + token: await getToken(), + }), + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: ['changelogList', 'changelogById', data.id], + }) + }, + }) +} + */ diff --git a/apps/app/src/routeTree.gen.ts b/apps/app/src/routeTree.gen.ts index a9260a2..c0e14ce 100644 --- a/apps/app/src/routeTree.gen.ts +++ b/apps/app/src/routeTree.gen.ts @@ -19,10 +19,15 @@ import { Route as ChangelogIdVersionVersionIdImport } from './routes/changelog.$ const IndexLazyImport = createFileRoute('/')() const UserIndexLazyImport = createFileRoute('/user/')() +const PageIndexLazyImport = createFileRoute('/page/')() const ChangelogIndexLazyImport = createFileRoute('/changelog/')() +const PageCreateLazyImport = createFileRoute('/page/create')() +const PageIdLazyImport = createFileRoute('/page/$id')() const ChangelogCreateLazyImport = createFileRoute('/changelog/create')() const ChangelogIdLazyImport = createFileRoute('/changelog/$id')() +const PageIdIndexLazyImport = createFileRoute('/page/$id/')() const ChangelogIdIndexLazyImport = createFileRoute('/changelog/$id/')() +const PageIdEditLazyImport = createFileRoute('/page/$id/edit')() const ChangelogIdVersionCreateLazyImport = createFileRoute( '/changelog/$id/versionCreate', )() @@ -40,6 +45,11 @@ const UserIndexLazyRoute = UserIndexLazyImport.update({ getParentRoute: () => rootRoute, } as any).lazy(() => import('./routes/user/index.lazy').then((d) => d.Route)) +const PageIndexLazyRoute = PageIndexLazyImport.update({ + path: '/page/', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/page.index.lazy').then((d) => d.Route)) + const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({ path: '/changelog/', getParentRoute: () => rootRoute, @@ -47,6 +57,16 @@ const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({ import('./routes/changelog.index.lazy').then((d) => d.Route), ) +const PageCreateLazyRoute = PageCreateLazyImport.update({ + path: '/page/create', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/page.create.lazy').then((d) => d.Route)) + +const PageIdLazyRoute = PageIdLazyImport.update({ + path: '/page/$id', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/page.$id.lazy').then((d) => d.Route)) + const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({ path: '/changelog/create', getParentRoute: () => rootRoute, @@ -59,6 +79,13 @@ const ChangelogIdLazyRoute = ChangelogIdLazyImport.update({ getParentRoute: () => rootRoute, } as any).lazy(() => import('./routes/changelog.$id.lazy').then((d) => d.Route)) +const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({ + path: '/', + getParentRoute: () => PageIdLazyRoute, +} as any).lazy(() => + import('./routes/page.$id.index.lazy').then((d) => d.Route), +) + const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({ path: '/', getParentRoute: () => ChangelogIdLazyRoute, @@ -66,6 +93,11 @@ const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({ import('./routes/changelog.$id.index.lazy').then((d) => d.Route), ) +const PageIdEditLazyRoute = PageIdEditLazyImport.update({ + path: '/edit', + getParentRoute: () => PageIdLazyRoute, +} as any).lazy(() => import('./routes/page.$id.edit.lazy').then((d) => d.Route)) + const ChangelogIdVersionCreateLazyRoute = ChangelogIdVersionCreateLazyImport.update({ path: '/versionCreate', @@ -112,6 +144,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChangelogCreateLazyImport parentRoute: typeof rootRoute } + '/page/$id': { + id: '/page/$id' + path: '/page/$id' + fullPath: '/page/$id' + preLoaderRoute: typeof PageIdLazyImport + parentRoute: typeof rootRoute + } + '/page/create': { + id: '/page/create' + path: '/page/create' + fullPath: '/page/create' + preLoaderRoute: typeof PageCreateLazyImport + parentRoute: typeof rootRoute + } '/changelog/': { id: '/changelog/' path: '/changelog' @@ -119,6 +165,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChangelogIndexLazyImport parentRoute: typeof rootRoute } + '/page/': { + id: '/page/' + path: '/page' + fullPath: '/page' + preLoaderRoute: typeof PageIndexLazyImport + parentRoute: typeof rootRoute + } '/user/': { id: '/user/' path: '/user' @@ -140,6 +193,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChangelogIdVersionCreateLazyImport parentRoute: typeof ChangelogIdLazyImport } + '/page/$id/edit': { + id: '/page/$id/edit' + path: '/edit' + fullPath: '/page/$id/edit' + preLoaderRoute: typeof PageIdEditLazyImport + parentRoute: typeof PageIdLazyImport + } '/changelog/$id/': { id: '/changelog/$id/' path: '/' @@ -147,6 +207,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChangelogIdIndexLazyImport parentRoute: typeof ChangelogIdLazyImport } + '/page/$id/': { + id: '/page/$id/' + path: '/' + fullPath: '/page/$id/' + preLoaderRoute: typeof PageIdIndexLazyImport + parentRoute: typeof PageIdLazyImport + } '/changelog/$id/version/$versionId': { id: '/changelog/$id/version/$versionId' path: '/version/$versionId' @@ -177,26 +244,49 @@ const ChangelogIdLazyRouteWithChildren = ChangelogIdLazyRoute._addFileChildren( ChangelogIdLazyRouteChildren, ) +interface PageIdLazyRouteChildren { + PageIdEditLazyRoute: typeof PageIdEditLazyRoute + PageIdIndexLazyRoute: typeof PageIdIndexLazyRoute +} + +const PageIdLazyRouteChildren: PageIdLazyRouteChildren = { + PageIdEditLazyRoute: PageIdEditLazyRoute, + PageIdIndexLazyRoute: PageIdIndexLazyRoute, +} + +const PageIdLazyRouteWithChildren = PageIdLazyRoute._addFileChildren( + PageIdLazyRouteChildren, +) + export interface FileRoutesByFullPath { '/': typeof IndexLazyRoute '/changelog/$id': typeof ChangelogIdLazyRouteWithChildren '/changelog/create': typeof ChangelogCreateLazyRoute + '/page/$id': typeof PageIdLazyRouteWithChildren + '/page/create': typeof PageCreateLazyRoute '/changelog': typeof ChangelogIndexLazyRoute + '/page': typeof PageIndexLazyRoute '/user': typeof UserIndexLazyRoute '/changelog/$id/edit': typeof ChangelogIdEditLazyRoute '/changelog/$id/versionCreate': typeof ChangelogIdVersionCreateLazyRoute + '/page/$id/edit': typeof PageIdEditLazyRoute '/changelog/$id/': typeof ChangelogIdIndexLazyRoute + '/page/$id/': typeof PageIdIndexLazyRoute '/changelog/$id/version/$versionId': typeof ChangelogIdVersionVersionIdRoute } export interface FileRoutesByTo { '/': typeof IndexLazyRoute '/changelog/create': typeof ChangelogCreateLazyRoute + '/page/create': typeof PageCreateLazyRoute '/changelog': typeof ChangelogIndexLazyRoute + '/page': typeof PageIndexLazyRoute '/user': typeof UserIndexLazyRoute '/changelog/$id/edit': typeof ChangelogIdEditLazyRoute '/changelog/$id/versionCreate': typeof ChangelogIdVersionCreateLazyRoute + '/page/$id/edit': typeof PageIdEditLazyRoute '/changelog/$id': typeof ChangelogIdIndexLazyRoute + '/page/$id': typeof PageIdIndexLazyRoute '/changelog/$id/version/$versionId': typeof ChangelogIdVersionVersionIdRoute } @@ -205,11 +295,16 @@ export interface FileRoutesById { '/': typeof IndexLazyRoute '/changelog/$id': typeof ChangelogIdLazyRouteWithChildren '/changelog/create': typeof ChangelogCreateLazyRoute + '/page/$id': typeof PageIdLazyRouteWithChildren + '/page/create': typeof PageCreateLazyRoute '/changelog/': typeof ChangelogIndexLazyRoute + '/page/': typeof PageIndexLazyRoute '/user/': typeof UserIndexLazyRoute '/changelog/$id/edit': typeof ChangelogIdEditLazyRoute '/changelog/$id/versionCreate': typeof ChangelogIdVersionCreateLazyRoute + '/page/$id/edit': typeof PageIdEditLazyRoute '/changelog/$id/': typeof ChangelogIdIndexLazyRoute + '/page/$id/': typeof PageIdIndexLazyRoute '/changelog/$id/version/$versionId': typeof ChangelogIdVersionVersionIdRoute } @@ -219,32 +314,46 @@ export interface FileRouteTypes { | '/' | '/changelog/$id' | '/changelog/create' + | '/page/$id' + | '/page/create' | '/changelog' + | '/page' | '/user' | '/changelog/$id/edit' | '/changelog/$id/versionCreate' + | '/page/$id/edit' | '/changelog/$id/' + | '/page/$id/' | '/changelog/$id/version/$versionId' fileRoutesByTo: FileRoutesByTo to: | '/' | '/changelog/create' + | '/page/create' | '/changelog' + | '/page' | '/user' | '/changelog/$id/edit' | '/changelog/$id/versionCreate' + | '/page/$id/edit' | '/changelog/$id' + | '/page/$id' | '/changelog/$id/version/$versionId' id: | '__root__' | '/' | '/changelog/$id' | '/changelog/create' + | '/page/$id' + | '/page/create' | '/changelog/' + | '/page/' | '/user/' | '/changelog/$id/edit' | '/changelog/$id/versionCreate' + | '/page/$id/edit' | '/changelog/$id/' + | '/page/$id/' | '/changelog/$id/version/$versionId' fileRoutesById: FileRoutesById } @@ -253,7 +362,10 @@ export interface RootRouteChildren { IndexLazyRoute: typeof IndexLazyRoute ChangelogIdLazyRoute: typeof ChangelogIdLazyRouteWithChildren ChangelogCreateLazyRoute: typeof ChangelogCreateLazyRoute + PageIdLazyRoute: typeof PageIdLazyRouteWithChildren + PageCreateLazyRoute: typeof PageCreateLazyRoute ChangelogIndexLazyRoute: typeof ChangelogIndexLazyRoute + PageIndexLazyRoute: typeof PageIndexLazyRoute UserIndexLazyRoute: typeof UserIndexLazyRoute } @@ -261,7 +373,10 @@ const rootRouteChildren: RootRouteChildren = { IndexLazyRoute: IndexLazyRoute, ChangelogIdLazyRoute: ChangelogIdLazyRouteWithChildren, ChangelogCreateLazyRoute: ChangelogCreateLazyRoute, + PageIdLazyRoute: PageIdLazyRouteWithChildren, + PageCreateLazyRoute: PageCreateLazyRoute, ChangelogIndexLazyRoute: ChangelogIndexLazyRoute, + PageIndexLazyRoute: PageIndexLazyRoute, UserIndexLazyRoute: UserIndexLazyRoute, } @@ -280,7 +395,10 @@ export const routeTree = rootRoute "/", "/changelog/$id", "/changelog/create", + "/page/$id", + "/page/create", "/changelog/", + "/page/", "/user/" ] }, @@ -299,9 +417,22 @@ export const routeTree = rootRoute "/changelog/create": { "filePath": "changelog.create.lazy.tsx" }, + "/page/$id": { + "filePath": "page.$id.lazy.tsx", + "children": [ + "/page/$id/edit", + "/page/$id/" + ] + }, + "/page/create": { + "filePath": "page.create.lazy.tsx" + }, "/changelog/": { "filePath": "changelog.index.lazy.tsx" }, + "/page/": { + "filePath": "page.index.lazy.tsx" + }, "/user/": { "filePath": "user/index.lazy.tsx" }, @@ -313,10 +444,18 @@ export const routeTree = rootRoute "filePath": "changelog.$id.versionCreate.lazy.tsx", "parent": "/changelog/$id" }, + "/page/$id/edit": { + "filePath": "page.$id.edit.lazy.tsx", + "parent": "/page/$id" + }, "/changelog/$id/": { "filePath": "changelog.$id.index.lazy.tsx", "parent": "/changelog/$id" }, + "/page/$id/": { + "filePath": "page.$id.index.lazy.tsx", + "parent": "/page/$id" + }, "/changelog/$id/version/$versionId": { "filePath": "changelog.$id.version.$versionId.tsx", "parent": "/changelog/$id" diff --git a/apps/app/src/routes/changelog.$id.lazy.tsx b/apps/app/src/routes/changelog.$id.lazy.tsx index 48f6837..6094fe5 100644 --- a/apps/app/src/routes/changelog.$id.lazy.tsx +++ b/apps/app/src/routes/changelog.$id.lazy.tsx @@ -13,7 +13,7 @@ import { useChangelogById } from '../hooks/useChangelog' const Component = () => { const { id } = Route.useParams() const { data, error, isPending, refetch } = useChangelogById({ id }) - + console.log(data) if (error) { return (
diff --git a/apps/app/src/routes/page.$id.edit.lazy.tsx b/apps/app/src/routes/page.$id.edit.lazy.tsx new file mode 100644 index 0000000..b02b02b --- /dev/null +++ b/apps/app/src/routes/page.$id.edit.lazy.tsx @@ -0,0 +1,192 @@ +import { PageUpdateInput } from '@boring.tools/schema' +import { + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Textarea, + cn, +} from '@boring.tools/ui' +import { zodResolver } from '@hookform/resolvers/zod' +import { createLazyFileRoute } from '@tanstack/react-router' +import { useNavigate } from '@tanstack/react-router' +import { Check, ChevronsUpDown } from 'lucide-react' +import { useForm } from 'react-hook-form' +import type { z } from 'zod' +import { useChangelogList } from '../hooks/useChangelog' +import { usePageById, usePageUpdate } from '../hooks/usePage' + +const Component = () => { + const { id } = Route.useParams() + const navigate = useNavigate({ from: `/page/${id}/edit` }) + const page = usePageById({ id }) + const changelogList = useChangelogList() + const pageUpdate = usePageUpdate() + const form = useForm>({ + resolver: zodResolver(PageUpdateInput), + defaultValues: { + ...page.data, + changelogIds: page.data?.changelogs.map((log) => log.id), + }, + }) + const onSubmit = (values: z.infer) => { + pageUpdate.mutate( + { id, payload: values }, + { + onSuccess(data) { + navigate({ to: '/page/$id', params: { id: data.id } }) + }, + }, + ) + } + + return ( + <> +
+

Edit page

+ +
+ + ( + + Title + + + {' '} + + + )} + /> + + ( + + Description + +