Compare commits

...

20 Commits

Author SHA1 Message Date
fc001b4dfb ci: update grafana dashboard
All checks were successful
Build and Push Docker Image / tests (push) Successful in 39s
Build and Push Docker Image / build (push) Successful in 4m16s
2024-11-12 23:18:17 +01:00
235c7c1ef4 feat(logger): get service name via env var, hide logs on tests
All checks were successful
Build and Push Docker Image / tests (push) Successful in 1m4s
Build and Push Docker Image / build (push) Successful in 4m15s
2024-11-12 23:08:44 +01:00
0d0c4c8469 chore: add missing validation 2024-11-12 23:07:19 +01:00
9847fdb70c chore(api): fix build command
Some checks failed
Build and Push Docker Image / tests (push) Successful in 41s
Build and Push Docker Image / build (push) Failing after 1m1s
2024-11-12 22:05:29 +01:00
82558ff06a chore(app): fix build errors 2024-11-12 22:05:11 +01:00
32e5d3bb8d feat(api): add log on authentication error
Some checks failed
Build and Push Docker Image / tests (push) Successful in 49s
Build and Push Docker Image / build (push) Failing after 1m6s
2024-11-12 21:56:49 +01:00
6c5f7c08b5 ci: extend grafana logs dashboard 2024-11-12 21:56:28 +01:00
0787122c25 ci: refactor docker-compose files 2024-11-12 21:34:56 +01:00
8309d3a92c chore(api): fix logger issue 2024-11-12 21:34:32 +01:00
f284f74734 feat(api): improve logging with loki and grafana 2024-11-12 21:08:40 +01:00
33cfe91461 chore: remove test cli 2024-11-12 21:08:10 +01:00
7f6a0f36e9 feat(api): add page tests
Some checks failed
Build and Push Docker Image / tests (push) Successful in 1m9s
Build and Push Docker Image / build (push) Failing after 1m15s
2024-11-12 18:51:30 +01:00
377942fe3c feat(api): add access-token tests 2024-11-12 17:33:20 +01:00
4046c31fbd ci: update docker-compose.yaml
All checks were successful
Build and Push Docker Image / tests (push) Successful in 54s
Build and Push Docker Image / build (push) Successful in 4m50s
2024-11-12 16:17:16 +01:00
fc125519b9 fix(api): user routes 2024-11-12 16:16:55 +01:00
e8d790d799 chore(api): remove console.logs 2024-11-12 16:16:31 +01:00
618a2f67a4 ci: update biome settings 2024-11-12 16:16:08 +01:00
5ae746346f feat(app): improve clerk auth 2024-11-12 16:15:33 +01:00
154df360ac feat(app): refactor page create/update forms
All checks were successful
Build and Push Docker Image / tests (push) Successful in 57s
Build and Push Docker Image / build (push) Successful in 4m30s
2024-11-11 21:58:38 +01:00
83b615d989 feat(app): refactor changelog create/update forms 2024-11-11 21:23:52 +01:00
38 changed files with 1683 additions and 481 deletions

View File

@ -2,7 +2,7 @@
"name": "@boring.tools/api", "name": "@boring.tools/api",
"scripts": { "scripts": {
"dev": "bun run --hot src/index.ts", "dev": "bun run --hot src/index.ts",
"build": "bun build --entrypoints ./src/index.ts --outdir ../../build/api --target bun --splitting --sourcemap=linked", "build": "bun build --entrypoints ./src/index.ts --outdir ../../build/api --target bun --splitting",
"test": "bun test --preload ./src/index.ts" "test": "bun test --preload ./src/index.ts"
}, },
"dependencies": { "dependencies": {

View File

@ -0,0 +1,151 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
import { access_token, db, user } from '@boring.tools/database'
import type {
AccessTokenCreateInput,
AccessTokenListOutput,
AccessTokenOutput,
ChangelogCreateInput,
ChangelogCreateOutput,
ChangelogListOutput,
ChangelogOutput,
UserOutput,
} from '@boring.tools/schema'
import type { z } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm'
import { fetch } from '../utils/testing/fetch'
describe('AccessToken', () => {
let testUser: z.infer<typeof UserOutput>
let testAccessToken: z.infer<typeof AccessTokenOutput>
let createdAccessToken: z.infer<typeof AccessTokenOutput>
let testChangelog: z.infer<typeof ChangelogOutput>
beforeAll(async () => {
const createdUser = await db
.insert(user)
.values({ email: 'access_token@test.local', providerId: 'test_000' })
.returning()
const tAccessToken = await db
.insert(access_token)
.values({
token: '1234567890',
userId: createdUser[0].id,
name: 'testtoken',
})
.returning()
testAccessToken = tAccessToken[0] as z.infer<typeof AccessTokenOutput>
testUser = createdUser[0] as z.infer<typeof UserOutput>
})
afterAll(async () => {
await db.delete(user).where(eq(user.email, 'access_token@test.local'))
})
describe('Create', () => {
test('Success', async () => {
const payload: z.infer<typeof AccessTokenCreateInput> = {
name: 'Test Token',
}
const res = await fetch(
{
path: '/v1/access-token',
method: 'POST',
body: payload,
},
testAccessToken.token as string,
)
const json = (await res.json()) as z.infer<typeof AccessTokenOutput>
createdAccessToken = json
expect(res.status).toBe(201)
})
})
// describe('By Id', () => {
// test('Success', async () => {
// const res = await fetch(
// {
// path: `/v1/changelog/${testChangelog.id}`,
// method: 'GET',
// },
// testAccessToken.token as string,
// )
// expect(res.status).toBe(200)
// })
// test('Not Found', async () => {
// const res = await fetch(
// {
// path: '/v1/changelog/635f4aa7-79fc-4d6b-af7d-6731999cc8bb',
// method: 'GET',
// },
// testAccessToken.token as string,
// )
// expect(res.status).toBe(404)
// })
// test('Invalid Id', async () => {
// const res = await fetch(
// {
// path: '/v1/changelog/some',
// method: 'GET',
// },
// testAccessToken.token as string,
// )
// expect(res.status).toBe(400)
// const json = (await res.json()) as { success: boolean }
// expect(json.success).toBeFalse()
// })
// })
describe('List', () => {
test('Success', async () => {
const res = await fetch(
{
path: '/v1/access-token',
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
const json = (await res.json()) as z.infer<typeof AccessTokenListOutput>
// Check if token is redacted
expect(json[0].token).toHaveLength(10)
expect(json).toHaveLength(2)
})
})
describe('Remove', () => {
test('Success', async () => {
const res = await fetch(
{
path: `/v1/access-token/${createdAccessToken.id}`,
method: 'DELETE',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
test('Not found', async () => {
const res = await fetch(
{
path: `/v1/access-token/${createdAccessToken.id}`,
method: 'DELETE',
},
testAccessToken.token as string,
)
expect(res.status).toBe(404)
})
})
})

View File

@ -29,7 +29,7 @@ export const registerAccessTokenDelete = (api: typeof accessTokenApi) => {
const id = c.req.param('id') const id = c.req.param('id')
const userId = await verifyAuthentication(c) const userId = await verifyAuthentication(c)
const result = await db const [result] = await db
.delete(access_token) .delete(access_token)
.where(and(eq(access_token.userId, userId), eq(access_token.id, id))) .where(and(eq(access_token.userId, userId), eq(access_token.id, id)))
.returning() .returning()

View File

@ -1,17 +1,19 @@
import type { UserOutput } from '@boring.tools/schema' import type { UserOutput } from '@boring.tools/schema'
import { sentry } from '@hono/sentry' // import { sentry } from '@hono/sentry'
import { OpenAPIHono, type z } from '@hono/zod-openapi' import { OpenAPIHono, type z } from '@hono/zod-openapi'
import { apiReference } from '@scalar/hono-api-reference' import { apiReference } from '@scalar/hono-api-reference'
import { cors } from 'hono/cors' import { cors } from 'hono/cors'
import { requestId } from 'hono/request-id'
import changelog from './changelog' import changelog from './changelog'
import user from './user'
import { accessTokenApi } from './access-token' import { accessTokenApi } from './access-token'
import pageApi from './page' import pageApi from './page'
import statisticApi from './statistic' import statisticApi from './statistic'
import userApi from './user'
import { authentication } from './utils/authentication' import { authentication } from './utils/authentication'
import { handleError, handleZodError } from './utils/errors' import { handleError, handleZodError } from './utils/errors'
import { logger } from './utils/logger'
import { startup } from './utils/startup' import { startup } from './utils/startup'
type User = z.infer<typeof UserOutput> type User = z.infer<typeof UserOutput>
@ -31,9 +33,12 @@ export const app = new OpenAPIHono<{ Variables: Variables }>({
// dsn: 'https://1d7428bbab0a305078cf4aa380721aa2@o4508167321354240.ingest.de.sentry.io/4508167323648080', // dsn: 'https://1d7428bbab0a305078cf4aa380721aa2@o4508167321354240.ingest.de.sentry.io/4508167323648080',
// }), // }),
// ) // )
app.onError(handleError) app.onError(handleError)
app.use('*', cors()) app.use('*', cors())
app.use('/v1/*', authentication) app.use('/v1/*', authentication)
app.use('*', requestId())
app.use(logger())
app.openAPIRegistry.registerComponent('securitySchemes', 'AccessToken', { app.openAPIRegistry.registerComponent('securitySchemes', 'AccessToken', {
type: 'http', type: 'http',
scheme: 'bearer', scheme: 'bearer',
@ -43,7 +48,7 @@ app.openAPIRegistry.registerComponent('securitySchemes', 'Clerk', {
scheme: 'bearer', scheme: 'bearer',
}) })
app.route('/v1/user', user) app.route('/v1/user', userApi)
app.route('/v1/changelog', changelog) app.route('/v1/changelog', changelog)
app.route('/v1/page', pageApi) app.route('/v1/page', pageApi)
app.route('/v1/access-token', accessTokenApi) app.route('/v1/access-token', accessTokenApi)

View File

@ -20,7 +20,7 @@ const route = createRoute({
}, },
}, },
responses: { responses: {
200: { 201: {
content: { content: {
'application/json': { 'application/json': {
schema: PageOutput, schema: PageOutput,
@ -62,6 +62,6 @@ export const registerPageCreate = (api: typeof pageApi) => {
) )
} }
return c.json(PageOutput.parse(result), 200) return c.json(PageOutput.parse(result), 201)
}) })
} }

View File

@ -35,7 +35,7 @@ export const registerPageDelete = (api: typeof pageApi) => {
const userId = await verifyAuthentication(c) const userId = await verifyAuthentication(c)
const { id } = c.req.valid('param') const { id } = c.req.valid('param')
const result = await db const [result] = await db
.delete(page) .delete(page)
.where(and(eq(page.userId, userId), eq(page.id, id))) .where(and(eq(page.userId, userId), eq(page.id, id)))
.returning() .returning()

View File

@ -0,0 +1,171 @@
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
import { access_token, db, user } from '@boring.tools/database'
import type {
AccessTokenCreateInput,
AccessTokenListOutput,
AccessTokenOutput,
ChangelogCreateInput,
ChangelogCreateOutput,
ChangelogListOutput,
ChangelogOutput,
PageCreateInput,
PageListOutput,
PageOutput,
PageUpdateInput,
UserOutput,
} from '@boring.tools/schema'
import type { z } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm'
import { fetch } from '../utils/testing/fetch'
describe('Page', () => {
let testUser: z.infer<typeof UserOutput>
let testAccessToken: z.infer<typeof AccessTokenOutput>
let createdAccessToken: z.infer<typeof AccessTokenOutput>
let testPage: z.infer<typeof PageOutput>
beforeAll(async () => {
const createdUser = await db
.insert(user)
.values({ email: 'page@test.local', providerId: 'test_000' })
.returning()
const tAccessToken = await db
.insert(access_token)
.values({
token: '1234567890',
userId: createdUser[0].id,
name: 'testtoken',
})
.returning()
testAccessToken = tAccessToken[0] as z.infer<typeof AccessTokenOutput>
testUser = createdUser[0] as z.infer<typeof UserOutput>
})
afterAll(async () => {
await db.delete(user).where(eq(user.email, 'page@test.local'))
})
describe('Create', () => {
test('Success', async () => {
const payload: z.infer<typeof PageCreateInput> = {
title: 'Test Page',
changelogIds: [],
}
const res = await fetch(
{
path: '/v1/page',
method: 'POST',
body: payload,
},
testAccessToken.token as string,
)
const json = (await res.json()) as z.infer<typeof PageOutput>
testPage = json
expect(res.status).toBe(201)
})
})
describe('By Id', () => {
test('Success', async () => {
const res = await fetch(
{
path: `/v1/page/${testPage.id}`,
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
test('Not Found', async () => {
const res = await fetch(
{
path: '/v1/page/635f4aa7-79fc-4d6b-af7d-6731999cc8bb',
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(404)
})
})
describe('Update', () => {
test('Success', async () => {
const update: z.infer<typeof PageUpdateInput> = {
title: 'Test Update',
}
const res = await fetch(
{
path: `/v1/page/${testPage.id}`,
method: 'PUT',
body: update,
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
})
describe('Public', () => {
test('Success', async () => {
const res = await fetch(
{
path: `/v1/page/${testPage.id}/public`,
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
})
describe('List', () => {
test('Success', async () => {
const res = await fetch(
{
path: '/v1/page',
method: 'GET',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
const json = (await res.json()) as z.infer<typeof PageListOutput>
// Check if token is redacted
expect(json).toHaveLength(1)
})
})
describe('Remove', () => {
test('Success', async () => {
const res = await fetch(
{
path: `/v1/page/${testPage.id}`,
method: 'DELETE',
},
testAccessToken.token as string,
)
expect(res.status).toBe(200)
})
test('Not found', async () => {
const res = await fetch(
{
path: `/v1/page/${testPage.id}`,
method: 'DELETE',
},
testAccessToken.token as string,
)
expect(res.status).toBe(404)
})
})
})

View File

@ -91,6 +91,7 @@ export const registerPagePublic = (api: typeof pageApi) => {
} }
redis.set(id, JSON.stringify(mappedResult), { EX: 60 }) redis.set(id, JSON.stringify(mappedResult), { EX: 60 })
return c.json(PagePublicOutput.parse(mappedResult), 200) const asd = PagePublicOutput.parse(mappedResult)
return c.json(asd, 200)
}) })
} }

View File

@ -4,6 +4,7 @@ import { createRoute } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type { userApi } from '.' import type { userApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi' import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
const route = createRoute({ const route = createRoute({
@ -24,9 +25,9 @@ const route = createRoute({
export const registerUserGet = (api: typeof userApi) => { export const registerUserGet = (api: typeof userApi) => {
return api.openapi(route, async (c) => { return api.openapi(route, async (c) => {
const user = c.get('user') const userId = await verifyAuthentication(c)
const result = await db.query.user.findFirst({ const result = await db.query.user.findFirst({
where: eq(userDb.id, user.id), where: eq(userDb.id, userId),
}) })
if (!result) { if (!result) {

View File

@ -20,10 +20,7 @@ const route = createRoute({
}, },
}, },
responses: { responses: {
200: { 204: {
content: {
'application/json': { schema: UserOutput },
},
description: 'Return success', description: 'Return success',
}, },
...openApiErrorResponses, ...openApiErrorResponses,
@ -70,7 +67,11 @@ export const registerUserWebhook = (api: typeof userApi) => {
case 'user.created': { case 'user.created': {
const result = await userCreate({ payload: verifiedPayload }) const result = await userCreate({ payload: verifiedPayload })
logger.info('Clerk Webhook', result) logger.info('Clerk Webhook', result)
return c.json(UserOutput.parse(result), 200) if (result) {
return c.json({}, 204)
}
return c.json({}, 404)
} }
default: default:
throw new HTTPException(404, { message: 'Webhook type not supported' }) throw new HTTPException(404, { message: 'Webhook type not supported' })

View File

@ -1,4 +1,5 @@
import { access_token, db, user } from '@boring.tools/database' import { access_token, db, user } from '@boring.tools/database'
import { logger } from '@boring.tools/logger'
import { clerkMiddleware, getAuth } from '@hono/clerk-auth' import { clerkMiddleware, getAuth } from '@hono/clerk-auth'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import type { Context, Next } from 'hono' import type { Context, Next } from 'hono'
@ -52,8 +53,9 @@ export const verifyAuthentication = async (c: Context) => {
.where(eq(user.providerId, auth.userId)) .where(eq(user.providerId, auth.userId))
if (!userEntry) { if (!userEntry) {
logger.error('User not found - Unauthorized', { providerId: auth.userId })
throw new HTTPException(401, { message: 'Unauthorized' }) throw new HTTPException(401, { message: 'Unauthorized' })
} }
// console.log(userEntry)
return userEntry.id return userEntry.id
} }

View File

@ -106,7 +106,6 @@ export function handleZodError(
}, },
c: Context, c: Context,
) { ) {
console.log('asdasasdas')
if (!result.success) { if (!result.success) {
const error = SchemaError.fromZod(result.error, c) const error = SchemaError.fromZod(result.error, c)
return c.json<z.infer<ReturnType<typeof createErrorSchema>>>( return c.json<z.infer<ReturnType<typeof createErrorSchema>>>(

View File

@ -0,0 +1,61 @@
import { db, user } from '@boring.tools/database'
import { logger as log } from '@boring.tools/logger'
import { eq } from 'drizzle-orm'
import type { MiddlewareHandler } from 'hono'
import { getPath } from 'hono/utils/url'
export const logger = (): MiddlewareHandler => {
return async function logga(c, next) {
const { method } = c.req
const clerkUser = c.get('clerkAuth')
const requestId = c.get('requestId')
const [dbUser] = await db
.select({ id: user.id, providerId: user.providerId })
.from(user)
.where(eq(user.providerId, clerkUser?.userId))
const path = getPath(c.req.raw)
log.info('Incoming', {
direction: 'in',
method,
path,
userId: dbUser?.id,
requestId,
})
await next()
if (c.res.status <= 399) {
log.info('Outgoing', {
direction: 'out',
method,
path,
status: c.res.status,
userId: dbUser?.id,
requestId,
})
}
if (c.res.status >= 400 && c.res.status < 499) {
log.warn('Outgoing', {
direction: 'out',
method,
path,
status: c.res.status,
userId: dbUser?.id,
requestId,
})
}
if (c.res.status >= 500) {
log.error('Outgoing', {
direction: 'out',
method,
path,
status: c.res.status,
userId: dbUser?.id,
requestId,
})
}
}
}

View File

@ -8,7 +8,11 @@ import {
Separator, Separator,
SidebarTrigger, SidebarTrigger,
} from '@boring.tools/ui' } from '@boring.tools/ui'
import { useAuth } from '@clerk/clerk-react'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useEffect } from 'react'
import { useUser } from '../hooks/useUser'
type Breadcrumbs = { type Breadcrumbs = {
name: string name: string
@ -19,6 +23,15 @@ export const PageWrapper = ({
children, children,
breadcrumbs, breadcrumbs,
}: { children: React.ReactNode; breadcrumbs?: Breadcrumbs[] }) => { }: { children: React.ReactNode; breadcrumbs?: Breadcrumbs[] }) => {
const { error } = useUser()
const { signOut } = useAuth()
useEffect(() => {
if (error) {
signOut()
}
}, [error, signOut])
return ( return (
<> <>
<header className="flex h-16 shrink-0 items-center gap-2"> <header className="flex h-16 shrink-0 items-center gap-2">

View File

@ -0,0 +1,21 @@
import type { UserOutput } from '@boring.tools/schema'
import { useAuth } from '@clerk/clerk-react'
import { useQuery } from '@tanstack/react-query'
import type { z } from 'zod'
import { queryFetch } from '../utils/queryFetch'
type User = z.infer<typeof UserOutput>
export const useUser = () => {
const { getToken } = useAuth()
return useQuery({
queryKey: ['user'],
queryFn: async (): Promise<Readonly<User>> =>
await queryFetch({
path: 'user',
method: 'get',
token: await getToken(),
}),
retry: false,
})
}

View File

@ -1,12 +1,12 @@
/* prettier-ignore-start */
/* eslint-disable */ /* eslint-disable */
// @ts-nocheck // @ts-nocheck
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
// This file is auto-generated by TanStack Router // This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
@ -39,26 +39,31 @@ const ChangelogIdEditLazyImport = createFileRoute('/changelog/$id/edit')()
// Create/Update Routes // Create/Update Routes
const CliLazyRoute = CliLazyImport.update({ const CliLazyRoute = CliLazyImport.update({
id: '/cli',
path: '/cli', path: '/cli',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/cli.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/cli.lazy').then((d) => d.Route))
const IndexLazyRoute = IndexLazyImport.update({ const IndexLazyRoute = IndexLazyImport.update({
id: '/',
path: '/', path: '/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
const UserIndexLazyRoute = UserIndexLazyImport.update({ const UserIndexLazyRoute = UserIndexLazyImport.update({
id: '/user/',
path: '/user/', path: '/user/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/user/index.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/user/index.lazy').then((d) => d.Route))
const PageIndexLazyRoute = PageIndexLazyImport.update({ const PageIndexLazyRoute = PageIndexLazyImport.update({
id: '/page/',
path: '/page/', path: '/page/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.index.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/page.index.lazy').then((d) => d.Route))
const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({ const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({
id: '/changelog/',
path: '/changelog/', path: '/changelog/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => } as any).lazy(() =>
@ -66,6 +71,7 @@ const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({
) )
const AccessTokensIndexLazyRoute = AccessTokensIndexLazyImport.update({ const AccessTokensIndexLazyRoute = AccessTokensIndexLazyImport.update({
id: '/access-tokens/',
path: '/access-tokens/', path: '/access-tokens/',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => } as any).lazy(() =>
@ -73,16 +79,19 @@ const AccessTokensIndexLazyRoute = AccessTokensIndexLazyImport.update({
) )
const PageCreateLazyRoute = PageCreateLazyImport.update({ const PageCreateLazyRoute = PageCreateLazyImport.update({
id: '/page/create',
path: '/page/create', path: '/page/create',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.create.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/page.create.lazy').then((d) => d.Route))
const PageIdLazyRoute = PageIdLazyImport.update({ const PageIdLazyRoute = PageIdLazyImport.update({
id: '/page/$id',
path: '/page/$id', path: '/page/$id',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.$id.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/page.$id.lazy').then((d) => d.Route))
const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({ const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({
id: '/changelog/create',
path: '/changelog/create', path: '/changelog/create',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => } as any).lazy(() =>
@ -90,11 +99,13 @@ const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({
) )
const ChangelogIdLazyRoute = ChangelogIdLazyImport.update({ const ChangelogIdLazyRoute = ChangelogIdLazyImport.update({
id: '/changelog/$id',
path: '/changelog/$id', path: '/changelog/$id',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/changelog.$id.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/changelog.$id.lazy').then((d) => d.Route))
const AccessTokensNewLazyRoute = AccessTokensNewLazyImport.update({ const AccessTokensNewLazyRoute = AccessTokensNewLazyImport.update({
id: '/access-tokens/new',
path: '/access-tokens/new', path: '/access-tokens/new',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any).lazy(() => } as any).lazy(() =>
@ -102,6 +113,7 @@ const AccessTokensNewLazyRoute = AccessTokensNewLazyImport.update({
) )
const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({ const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({
id: '/',
path: '/', path: '/',
getParentRoute: () => PageIdLazyRoute, getParentRoute: () => PageIdLazyRoute,
} as any).lazy(() => } as any).lazy(() =>
@ -109,6 +121,7 @@ const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({
) )
const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({ const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({
id: '/',
path: '/', path: '/',
getParentRoute: () => ChangelogIdLazyRoute, getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() => } as any).lazy(() =>
@ -116,12 +129,14 @@ const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({
) )
const PageIdEditLazyRoute = PageIdEditLazyImport.update({ const PageIdEditLazyRoute = PageIdEditLazyImport.update({
id: '/edit',
path: '/edit', path: '/edit',
getParentRoute: () => PageIdLazyRoute, getParentRoute: () => PageIdLazyRoute,
} as any).lazy(() => import('./routes/page.$id.edit.lazy').then((d) => d.Route)) } as any).lazy(() => import('./routes/page.$id.edit.lazy').then((d) => d.Route))
const ChangelogIdVersionCreateLazyRoute = const ChangelogIdVersionCreateLazyRoute =
ChangelogIdVersionCreateLazyImport.update({ ChangelogIdVersionCreateLazyImport.update({
id: '/versionCreate',
path: '/versionCreate', path: '/versionCreate',
getParentRoute: () => ChangelogIdLazyRoute, getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() => } as any).lazy(() =>
@ -129,6 +144,7 @@ const ChangelogIdVersionCreateLazyRoute =
) )
const ChangelogIdEditLazyRoute = ChangelogIdEditLazyImport.update({ const ChangelogIdEditLazyRoute = ChangelogIdEditLazyImport.update({
id: '/edit',
path: '/edit', path: '/edit',
getParentRoute: () => ChangelogIdLazyRoute, getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() => } as any).lazy(() =>
@ -137,6 +153,7 @@ const ChangelogIdEditLazyRoute = ChangelogIdEditLazyImport.update({
const ChangelogIdVersionVersionIdRoute = const ChangelogIdVersionVersionIdRoute =
ChangelogIdVersionVersionIdImport.update({ ChangelogIdVersionVersionIdImport.update({
id: '/version/$versionId',
path: '/version/$versionId', path: '/version/$versionId',
getParentRoute: () => ChangelogIdLazyRoute, getParentRoute: () => ChangelogIdLazyRoute,
} as any) } as any)
@ -451,8 +468,6 @@ export const routeTree = rootRoute
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>() ._addFileTypes<FileRouteTypes>()
/* prettier-ignore-end */
/* ROUTE_MANIFEST_START /* ROUTE_MANIFEST_START
{ {
"routes": { "routes": {

View File

@ -1,5 +1,9 @@
import { import {
Button, Button,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox, Checkbox,
Form, Form,
FormControl, FormControl,
@ -58,66 +62,118 @@ const Component = () => {
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md" className="flex flex-col gap-10 w-full max-w-screen-lg"
> >
<FormField <div className="flex gap-10 w-full max-w-screen-lg">
control={form.control} <Card className="w-full">
name="title" <CardHeader>
render={({ field }) => ( <CardTitle>Details</CardTitle>
<FormItem> </CardHeader>
<FormLabel>Title</FormLabel> <CardContent>
<FormControl> <div className="flex flex-col gap-3 w-full">
<Input placeholder="My changelog" {...field} autoFocus /> <FormField
</FormControl>{' '} control={form.control}
<FormMessage /> name="title"
</FormItem> render={({ field }) => (
)} <FormItem>
/> <FormLabel>Title</FormLabel>
<FormControl>
<FormField <Input
control={form.control} placeholder="My changelog"
name="description" {...field}
render={({ field }) => ( autoFocus
<FormItem> />
<FormLabel>Description</FormLabel> </FormControl>{' '}
<FormControl> <FormMessage />
<Textarea </FormItem>
placeholder="Some details about the changelog..." )}
{...field}
/> />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="isSemver" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md "> <FormItem>
<FormControl> <FormLabel>Description</FormLabel>
<Checkbox <FormControl>
checked={field.value} <Textarea
onCheckedChange={field.onChange} placeholder="Some details about the changelog..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/> />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Semver</FormLabel>
<FormDescription>
If this changelog is following the{' '}
<a
href="https://semver.org/lang/de/"
className="text-emerald-700"
>
semantic versioning?
</a>
</FormDescription>
</div> </div>
</FormItem> </CardContent>
)} </Card>
/>
<div className="flex gap-5"> <Card className="w-full">
<CardHeader>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full flex flex-col gap-5">
<FormField
control={form.control}
name="isSemver"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Semver</FormLabel>
<FormDescription>
If this changelog is following the{' '}
<a
href="https://semver.org/lang/de/"
className="text-emerald-700"
>
semantic versioning?
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isConventional"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Conventional Commits</FormLabel>
<FormDescription>
If this changelog is using{' '}
<a
href="https://www.conventionalcommits.org/en/v1.0.0/"
className="text-emerald-700"
>
conventional commits
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
<div className="flex items-end justify-end gap-5">
<Button <Button
type="button" type="button"
variant={'ghost'} variant={'ghost'}
@ -127,7 +183,8 @@ const Component = () => {
> >
Cancel Cancel
</Button> </Button>
<Button type="submit">Update</Button>
<Button type="submit">Save</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@ -1,6 +1,10 @@
import { ChangelogCreateInput } from '@boring.tools/schema' import { ChangelogCreateInput } from '@boring.tools/schema'
import { import {
Button, Button,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox, Checkbox,
Form, Form,
FormControl, FormControl,
@ -53,97 +57,131 @@ const Component = () => {
> >
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<h1 className="text-3xl">New changelog</h1> <h1 className="text-3xl">New changelog</h1>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md" className="flex flex-col gap-10 w-full max-w-screen-lg"
> >
<FormField <div className="flex gap-10 w-full max-w-screen-lg">
control={form.control} <Card className="w-full">
name="title" <CardHeader>
render={({ field }) => ( <CardTitle>Details</CardTitle>
<FormItem> </CardHeader>
<FormLabel>Title</FormLabel> <CardContent>
<FormControl> <div className="flex flex-col gap-3 w-full">
<Input placeholder="My changelog" {...field} autoFocus /> <FormField
</FormControl>{' '} control={form.control}
<FormMessage /> name="title"
</FormItem> render={({ field }) => (
)} <FormItem>
/> <FormLabel>Title</FormLabel>
<FormControl>
<FormField <Input
control={form.control} placeholder="My changelog"
name="description" {...field}
render={({ field }) => ( autoFocus
<FormItem> />
<FormLabel>Description</FormLabel> </FormControl>{' '}
<FormControl> <FormMessage />
<Textarea </FormItem>
placeholder="Some details about the changelog..." )}
{...field}
/> />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="isSemver" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md "> <FormItem>
<FormControl> <FormLabel>Description</FormLabel>
<Checkbox <FormControl>
checked={field.value} <Textarea
onCheckedChange={field.onChange} placeholder="Some details about the changelog..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/> />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Semver</FormLabel>
<FormDescription>
If this changelog is following the{' '}
<a
href="https://semver.org/lang/de/"
className="text-emerald-700"
>
semantic versioning?
</a>
</FormDescription>
</div> </div>
</FormItem> </CardContent>
)} </Card>
/>
<FormField <Card className="w-full">
control={form.control} <CardHeader>
name="isConventional" <CardTitle>Options</CardTitle>
render={({ field }) => ( </CardHeader>
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md "> <CardContent>
<FormControl> <div className="w-full flex flex-col gap-5">
<Checkbox <FormField
checked={field.value} control={form.control}
onCheckedChange={field.onChange} name="isSemver"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Semver</FormLabel>
<FormDescription>
If this changelog is following the{' '}
<a
href="https://semver.org/lang/de/"
className="text-emerald-700"
>
semantic versioning?
</a>
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isConventional"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Conventional Commits</FormLabel>
<FormDescription>
If this changelog is using{' '}
<a
href="https://www.conventionalcommits.org/en/v1.0.0/"
className="text-emerald-700"
>
conventional commits
</a>
</FormDescription>
</div>
</FormItem>
)}
/> />
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Using Conventional Commits</FormLabel>
<FormDescription>
If this changelog is using{' '}
<a
href="https://www.conventionalcommits.org/en/v1.0.0/"
className="text-emerald-700"
>
conventional commits
</a>
</FormDescription>
</div> </div>
</FormItem> </CardContent>
)} </Card>
/> </div>
<Button type="submit">Create</Button>
<div className="flex items-end justify-end gap-5">
<Button
type="button"
variant={'ghost'}
onClick={() => navigate({ to: '/changelog' })}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</div>
</form> </form>
</Form> </Form>
</div> </div>

View File

@ -1,6 +1,10 @@
import { PageUpdateInput } from '@boring.tools/schema' import { PageUpdateInput } from '@boring.tools/schema'
import { import {
Button, Button,
Card,
CardContent,
CardHeader,
CardTitle,
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
@ -57,129 +61,147 @@ const Component = () => {
return ( return (
<> <>
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<h1 className="text-3xl">Edit page</h1>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md" className="flex flex-col gap-10 w-full max-w-screen-lg"
> >
<FormField <div className="flex gap-10 w-full">
control={form.control} <Card className="w-full">
name="title" <CardHeader>
render={({ field }) => ( <CardTitle>Details</CardTitle>
<FormItem> </CardHeader>
<FormLabel>Title</FormLabel> <CardContent className="flex flex-col gap-5">
<FormControl> <FormField
<Input placeholder="My page" {...field} autoFocus /> control={form.control}
</FormControl>{' '} name="title"
<FormMessage /> render={({ field }) => (
</FormItem> <FormItem>
)} <FormLabel>Title</FormLabel>
/> <FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Some details about the page..." placeholder="Some details about the page..."
{...field} {...field}
/> value={field.value ?? ''}
</FormControl>{' '} />
<FormMessage /> </FormControl>{' '}
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
</CardContent>
</Card>
<FormField <Card className="w-full">
control={form.control} <CardHeader>
name="changelogIds" <CardTitle>Options</CardTitle>
render={({ field }) => ( </CardHeader>
<FormItem className="flex flex-col"> <CardContent className="flex flex-col gap-5">
<FormLabel>Changelogs</FormLabel> <FormField
<Popover> control={form.control}
<PopoverTrigger asChild> name="changelogIds"
<FormControl> render={({ field }) => (
<Button <FormItem className="flex flex-col">
variant="outline" <FormLabel>Changelogs</FormLabel>
role="combobox" <Popover>
className={cn( <PopoverTrigger asChild>
'w-[200px] justify-between', <FormControl>
!field.value && 'text-muted-foreground', <Button
)} variant="outline"
> role="combobox"
{field?.value?.length === 1 && className={cn(
changelogList.data?.find((changelog) => 'w-[200px] justify-between',
field.value?.includes(changelog.id), !field.value && 'text-muted-foreground',
)?.title} )}
{field?.value &&
field.value.length <= 0 &&
'No changelog selected'}
{field?.value &&
field.value.length > 1 &&
`${field?.value?.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search changelogs..." />
<CommandList>
<CommandEmpty>No changelog found.</CommandEmpty>
<CommandGroup>
{changelogList.data?.map((changelog) => (
<CommandItem
value={changelog.title}
key={changelog.id}
onSelect={() => {
const getIds = () => {
if (!field.value) {
return [changelog.id]
}
if (field.value?.includes(changelog.id)) {
return field.value.filter(
(id) => id !== changelog.id,
)
}
return [
...(field?.value as string[]),
changelog.id,
]
}
form.setValue('changelogIds', getIds())
}}
> >
<Check {field?.value?.length === 1 &&
className={cn( changelogList.data?.find((changelog) =>
'mr-2 h-4 w-4', field.value?.includes(changelog.id),
field.value?.includes(changelog.id) )?.title}
? 'opacity-100' {field?.value &&
: 'opacity-0', field.value.length <= 0 &&
)} 'No changelog selected'}
/> {field?.value &&
{changelog.title} field.value.length > 1 &&
</CommandItem> `${field?.value?.length} selected`}
))} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</CommandGroup> </Button>
</CommandList> </FormControl>
</Command> </PopoverTrigger>
</PopoverContent> <PopoverContent className="w-[200px] p-0">
</Popover> <Command>
<FormDescription> <CommandInput placeholder="Search changelogs..." />
This changelogs are shown on this page. <CommandList>
</FormDescription> <CommandEmpty>No changelog found.</CommandEmpty>
<FormMessage /> <CommandGroup>
</FormItem> {changelogList.data?.map((changelog) => (
)} <CommandItem
/> value={changelog.title}
<div className="flex gap-5"> key={changelog.id}
onSelect={() => {
const getIds = () => {
if (!field.value) {
return [changelog.id]
}
if (
field.value?.includes(changelog.id)
) {
return field.value.filter(
(id) => id !== changelog.id,
)
}
return [
...(field?.value as string[]),
changelog.id,
]
}
form.setValue('changelogIds', getIds())
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
field.value?.includes(changelog.id)
? 'opacity-100'
: 'opacity-0',
)}
/>
{changelog.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This changelogs are shown on this page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
<div className="flex items-end justify-end gap-5">
<Button <Button
type="button" type="button"
variant={'ghost'} variant={'ghost'}
@ -187,7 +209,7 @@ const Component = () => {
> >
Cancel Cancel
</Button> </Button>
<Button type="submit">Update</Button> <Button type="submit">Save</Button>
</div> </div>
</form> </form>
</Form> </Form>

View File

@ -1,6 +1,10 @@
import { PageCreateInput } from '@boring.tools/schema' import { PageCreateInput } from '@boring.tools/schema'
import { import {
Button, Button,
Card,
CardContent,
CardHeader,
CardTitle,
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
@ -69,115 +73,146 @@ const Component = () => {
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md" className="flex flex-col gap-10 w-full max-w-screen-lg"
> >
<FormField <div className="flex gap-10 w-full">
control={form.control} <Card className="w-full">
name="title" <CardHeader>
render={({ field }) => ( <CardTitle>Details</CardTitle>
<FormItem> </CardHeader>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField <CardContent className="flex flex-col gap-5">
control={form.control} <FormField
name="description" control={form.control}
render={({ field }) => ( name="title"
<FormItem> render={({ field }) => (
<FormLabel>Description</FormLabel> <FormItem>
<FormControl> <FormLabel>Title</FormLabel>
<Textarea <FormControl>
placeholder="Some details about the page..." <Input placeholder="My page" {...field} autoFocus />
{...field} </FormControl>{' '}
/> <FormMessage />
</FormControl>{' '} </FormItem>
<FormMessage /> )}
</FormItem> />
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="changelogIds" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem>
<FormLabel>Changelogs</FormLabel> <FormLabel>Description</FormLabel>
<Popover> <FormControl>
<PopoverTrigger asChild> <Textarea
<FormControl> placeholder="Some details about the page..."
<Button {...field}
variant="outline" value={field.value ?? ''}
role="combobox" />
className={cn( </FormControl>{' '}
'w-[200px] justify-between', <FormMessage />
!field.value && 'text-muted-foreground', </FormItem>
)} )}
> />
{field.value.length === 1 && </CardContent>
changelogList.data?.find((changelog) => </Card>
field.value?.includes(changelog.id),
)?.title}
{field.value.length <= 0 && 'No changelog selected'}
{field.value.length > 1 &&
`${field.value.length} selected`}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search changelogs..." />
<CommandList>
<CommandEmpty>No changelog found.</CommandEmpty>
<CommandGroup>
{changelogList.data?.map((changelog) => (
<CommandItem
value={changelog.title}
key={changelog.id}
onSelect={() => {
const getIds = () => {
if (field.value.includes(changelog.id)) {
const asd = field.value.filter(
(id) => id !== changelog.id,
)
return asd
}
return [...field.value, changelog.id] <Card className="w-full">
} <CardHeader>
form.setValue('changelogIds', getIds()) <CardTitle>Options</CardTitle>
}} </CardHeader>
<CardContent className="flex flex-col gap-5">
<FormField
control={form.control}
name="changelogIds"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Changelogs</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
'w-[200px] justify-between',
!field.value && 'text-muted-foreground',
)}
> >
<Check {field.value.length === 1 &&
className={cn( changelogList.data?.find((changelog) =>
'mr-2 h-4 w-4', field.value?.includes(changelog.id),
field.value.includes(changelog.id) )?.title}
? 'opacity-100' {field.value.length <= 0 &&
: 'opacity-0', 'No changelog selected'}
)} {field.value.length > 1 &&
/> `${field.value.length} selected`}
{changelog.title} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</CommandItem> </Button>
))} </FormControl>
</CommandGroup> </PopoverTrigger>
</CommandList> <PopoverContent className="w-[200px] p-0">
</Command> <Command>
</PopoverContent> <CommandInput placeholder="Search changelogs..." />
</Popover> <CommandList>
<FormDescription> <CommandEmpty>No changelog found.</CommandEmpty>
This changelogs are shown on this page. <CommandGroup>
</FormDescription> {changelogList.data?.map((changelog) => (
<FormMessage /> <CommandItem
</FormItem> value={changelog.title}
)} key={changelog.id}
/> onSelect={() => {
<Button type="submit">Create</Button> const getIds = () => {
if (
field.value.includes(changelog.id)
) {
const asd = field.value.filter(
(id) => id !== changelog.id,
)
return asd
}
return [...field.value, changelog.id]
}
form.setValue('changelogIds', getIds())
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
field.value.includes(changelog.id)
? 'opacity-100'
: 'opacity-0',
)}
/>
{changelog.title}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This changelogs are shown on this page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
<div className="flex items-end justify-end gap-5">
<Button
type="button"
variant={'ghost'}
onClick={() => navigate({ to: '/page' })}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</div>
</form> </form>
</Form> </Form>
</div> </div>

View File

@ -12,13 +12,17 @@ const url = import.meta.env.PROD
: 'http://localhost:3000' : 'http://localhost:3000'
export const queryFetch = async ({ path, method, data, token }: Fetch) => { export const queryFetch = async ({ path, method, data, token }: Fetch) => {
const response = await axios({ try {
method, const response = await axios({
url: `${url}/v1/${path}`, method,
data, url: `${url}/v1/${path}`,
headers: { data,
Authorization: `Bearer ${token}`, headers: {
}, Authorization: `Bearer ${token}`,
}) },
return response.data })
return response.data
} catch (error) {
throw new Error('Somethind went wrong.')
}
} }

View File

@ -24,7 +24,6 @@
"formatter": { "formatter": {
"jsxQuoteStyle": "double", "jsxQuoteStyle": "double",
"quoteProperties": "asNeeded", "quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "asNeeded", "semicolons": "asNeeded",
"arrowParentheses": "always", "arrowParentheses": "always",
"bracketSpacing": true, "bracketSpacing": true,

BIN
bt-cli

Binary file not shown.

BIN
bun.lockb

Binary file not shown.

View File

@ -0,0 +1,57 @@
services:
postgres:
image: postgres:17-alpine
container_name: boring_services_postgres
ports:
- 5432:5432
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
- POSTGRES_DB=postgres
cache:
container_name: boring_services_redis
image: redis:7.4.1-alpine
ports:
- '6379:6379'
command: redis-server --save 20 1 --loglevel warning --requirepass development
loki:
image: grafana/loki:latest
container_name: boring_services_loki
ports:
- "9100:3100"
command: -config.file=/etc/loki/local-config.yaml
grafana:
image: grafana/grafana:latest
container_name: boring_services_grafana
environment:
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
volumes:
- ./grafana/dashboard.yaml:/etc/grafana/provisioning/dashboards/main.yaml
- ./grafana/dashboards:/var/lib/grafana/dashboards
- ./grafana/datasources:/etc/grafana/provisioning/datasources
# entrypoint:
# - sh
# - -euc
# - |
# mkdir -p /etc/grafana/provisioning/datasources
# cat <<EOF > /etc/grafana/provisioning/datasources/ds.yaml
# apiVersion: 1
# datasources:
# - name: Loki
# type: loki
# access: proxy
# orgId: 1
# url: http://loki:3100
# basicAuth: false
# isDefault: true
# version: 1
# editable: false
# EOF
# /run.sh
ports:
- "9000:3000"

View File

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: "Dashboard provider"
orgId: 1
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true

View File

@ -0,0 +1,546 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 1,
"links": [],
"panels": [
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 6,
"panels": [],
"title": "Service: API",
"type": "row"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "points",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "smooth",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": 5000,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "#73BF69",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 1
},
"id": 11,
"options": {
"legend": {
"calcs": ["sum"],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | status <= 399 [$__auto]))",
"legendFormat": "<= 300",
"queryType": "range",
"refId": "A"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "count(rate({service=\"api\"} | json | status >= 400 and status <= 499 [$__auto]))",
"hide": false,
"legendFormat": "400 - 499",
"queryType": "range",
"refId": "B"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | status >= 500 [$__auto]))",
"hide": false,
"legendFormat": ">= 500",
"queryType": "range",
"refId": "C"
}
],
"title": "Requests",
"type": "timeseries"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 0,
"y": 8
},
"id": 10,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | status <= 399 [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "<= 399",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "yellow",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 4,
"y": 8
},
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "code",
"expr": "count(rate({service=\"api\"} | json | status >= 400 and status <= 499 [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "400 - 499",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 8,
"y": 8
},
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "code",
"expr": "count(rate({service=\"api\"} | json | status >= 500 [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "400 - 499",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "blue",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 0,
"y": 16
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | level = `info` [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Info",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "yellow",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 4,
"y": 16
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | level = `warn` [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Warning",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 4,
"x": 8,
"y": 16
},
"id": 9,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": ["sum"],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "count(rate({service=\"api\"} | json | level = `error` [$__auto]))",
"queryType": "range",
"refId": "A"
}
],
"title": "Error",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"fieldConfig": {
"defaults": {},
"overrides": []
},
"gridPos": {
"h": 17,
"w": 24,
"x": 0,
"y": 24
},
"id": 5,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"pluginVersion": "11.3.0",
"targets": [
{
"editorMode": "builder",
"expr": "{service=\"api\"} | json",
"queryType": "range",
"refId": "A"
}
],
"title": "Logs",
"type": "logs"
}
],
"preload": false,
"schemaVersion": 40,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Logging",
"uid": "be3r0x46iwlq8f",
"version": 2,
"weekStart": "monday"
}

View File

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: "Dashboard provider"
orgId: 1
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true

View File

@ -0,0 +1,11 @@
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
orgId: 1
url: http://loki:3100
basicAuth: false
isDefault: true
version: 1
editable: false

View File

@ -1,58 +0,0 @@
services:
postgres:
image: postgres:17-alpine
container_name: boring_postgres
ports:
- 5432:5432
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
- POSTGRES_DB=postgres
cache:
container_name: boring_redis
image: redis:7.4.1-alpine
restart: always
ports:
- '6379:6379'
# command: redis-server --save 20 1 --loglevel warning --requirepass development
loki:
image: grafana/loki:latest
ports:
- "9100:3100"
command: -config.file=/etc/loki/local-config.yaml
promtail:
image: grafana/promtail:latest
volumes:
- /var/log:/var/log
command: -config.file=/etc/promtail/config.yml
grafana:
environment:
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
entrypoint:
- sh
- -euc
- |
mkdir -p /etc/grafana/provisioning/datasources
cat <<EOF > /etc/grafana/provisioning/datasources/ds.yaml
apiVersion: 1
datasources:
- name: Loki
type: loki
access: proxy
orgId: 1
url: http://loki:3100
basicAuth: false
isDefault: true
version: 1
editable: false
EOF
/run.sh
image: grafana/grafana:latest
ports:
- "9000:3000"

View File

@ -2,5 +2,5 @@ pre-commit:
commands: commands:
check: check:
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,astro}" glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc,astro}"
run: bunx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files} run: /run/current-system/sw/bin/biome check --apply --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
stage_fixed: true stage_fixed: true

View File

@ -6,6 +6,9 @@
"check:apps": "bunx biome check --write --config-path ./biome.json ./apps", "check:apps": "bunx biome check --write --config-path ./biome.json ./apps",
"check:packages": "bunx biome check --write --config-path ./biome.json ./packages", "check:packages": "bunx biome check --write --config-path ./biome.json ./packages",
"dev": "bun --filter '*' dev", "dev": "bun --filter '*' dev",
"dev:docker:up": "docker compose -f ci/development/docker-compose.yaml up -d",
"dev:docker:down": "docker compose -f ci/development/docker-compose.yaml down",
"dev:docker:stop": "docker compose -f ci/development/docker-compose.yaml stop",
"build": "bun --filter '*' build", "build": "bun --filter '*' build",
"db:generate": "bun --filter '@boring.tools/database' db:generate", "db:generate": "bun --filter '@boring.tools/database' db:generate",
"db:migrate": "bun --filter '@boring.tools/database' db:migrate", "db:migrate": "bun --filter '@boring.tools/database' db:migrate",
@ -26,12 +29,9 @@
"docker:page": "bun docker:page:build && bun docker:page:push" "docker:page": "bun docker:page:build && bun docker:page:push"
}, },
"packageManager": "bun@1.1.29", "packageManager": "bun@1.1.29",
"workspaces": [ "workspaces": ["apps/*", "packages/*"],
"apps/*",
"packages/*"
],
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.8.3", "@biomejs/biome": "1.9.4",
"lefthook": "^1.7.15" "lefthook": "^1.7.15"
} }
} }

View File

@ -12,6 +12,7 @@
"@logtail/node": "^0.5.0", "@logtail/node": "^0.5.0",
"@logtail/winston": "^0.5.0", "@logtail/winston": "^0.5.0",
"winston": "^3.14.2", "winston": "^3.14.2",
"winston-console-format": "^1.0.8",
"winston-loki": "^6.1.3" "winston-loki": "^6.1.3"
} }
} }

View File

@ -1,20 +1,43 @@
import { Logtail } from '@logtail/node' import { Logtail } from '@logtail/node'
import { LogtailTransport } from '@logtail/winston' import { LogtailTransport } from '@logtail/winston'
import winston from 'winston' import { createLogger, format, transports } from 'winston'
import { consoleFormat } from 'winston-console-format'
import LokiTransport from 'winston-loki' import LokiTransport from 'winston-loki'
// Create a Winston logger - passing in the Logtail transport // Create a Winston logger - passing in the Logtail transport
export const logger = winston.createLogger({ export const logger = createLogger({
format: winston.format.json(), format: format.combine(
format.timestamp(),
format.ms(),
format.errors({ stack: true }),
format.splat(),
format.json(),
),
transports: [ transports: [
new winston.transports.Console({ new transports.Console({
format: winston.format.json(), silent: import.meta.env.NODE_ENV === 'test',
format: format.combine(
format.colorize({ all: true }),
format.padLevels(),
consoleFormat({
showMeta: true,
metaStrip: ['timestamp', 'service'],
inspectOptions: {
depth: Number.POSITIVE_INFINITY,
colors: true,
maxArrayLength: Number.POSITIVE_INFINITY,
breakLength: 120,
compact: Number.POSITIVE_INFINITY,
},
}),
),
}), }),
new LokiTransport({ new LokiTransport({
silent: import.meta.env.NODE_ENV === 'test',
host: 'http://localhost:9100', host: 'http://localhost:9100',
labels: { app: 'api' },
json: true, json: true,
format: winston.format.json(), labels: { service: import.meta.env.SERVICE_NAME ?? 'unknown' },
format: format.json(),
replaceTimestamp: true, replaceTimestamp: true,
onConnectionError: (err) => console.error(err), onConnectionError: (err) => console.error(err),
}), }),

View File

@ -7,8 +7,8 @@ export const PageOutput = z
example: '', example: '',
}), }),
title: z.string(), title: z.string(),
description: z.string().optional(), description: z.string().optional().nullable(),
icon: z.string(), icon: z.string().optional().nullable(),
changelogs: z.array(ChangelogOutput).optional(), changelogs: z.array(ChangelogOutput).optional(),
}) })
.openapi('Page') .openapi('Page')

View File

@ -5,10 +5,10 @@ export const PageCreateInput = z
title: z.string().min(3).openapi({ title: z.string().min(3).openapi({
example: 'My page', example: 'My page',
}), }),
description: z.string().optional().openapi({ description: z.string().optional().nullable().openapi({
example: '', example: '',
}), }),
icon: z.string().optional().openapi({ icon: z.string().optional().nullable().openapi({
example: 'base64...', example: 'base64...',
}), }),
changelogIds: z.array(z.string().uuid()), changelogIds: z.array(z.string().uuid()),

View File

@ -2,21 +2,23 @@ import { z } from '@hono/zod-openapi'
export const PagePublicOutput = z.object({ export const PagePublicOutput = z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string().nullable(),
icon: z.string(), icon: z.string(),
changelogs: z.array( changelogs: z
z.object({ .array(
title: z.string(), z.object({
description: z.string(), title: z.string(),
versions: z.array( description: z.string(),
z.object({ versions: z.array(
markdown: z.string(), z.object({
version: z.string(), markdown: z.string(),
releasedAt: z.date(), version: z.string(),
}), releasedAt: z.date(),
), }),
}), ),
), }),
)
.optional(),
}) })
export const PagePublicParams = z.object({ export const PagePublicParams = z.object({

View File

@ -7,10 +7,10 @@ export const PageUpdateInput = z
title: z.string().min(3).optional().openapi({ title: z.string().min(3).optional().openapi({
example: 'My page', example: 'My page',
}), }),
description: z.string().optional().openapi({ description: z.string().nullable().optional().openapi({
example: '', example: '',
}), }),
icon: z.string().optional().openapi({ icon: z.string().optional().nullable().openapi({
example: 'base64...', example: 'base64...',
}), }),
changelogIds: z.array(z.string().uuid()).optional(), changelogIds: z.array(z.string().uuid()).optional(),