Compare commits

...

81 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
714da23d04 feat(app): sidebar loading state
All checks were successful
Build and Push Docker Image / tests (push) Successful in 54s
Build and Push Docker Image / build (push) Successful in 3m55s
2024-11-10 22:37:23 +01:00
d461acc3f4 fix(app): build error
All checks were successful
Build and Push Docker Image / tests (push) Successful in 52s
Build and Push Docker Image / build (push) Successful in 4m23s
2024-11-10 20:45:05 +01:00
70b98acb4e style(app): improve version update
Some checks failed
Build and Push Docker Image / tests (push) Successful in 1m10s
Build and Push Docker Image / build (push) Failing after 1m29s
2024-11-10 20:37:57 +01:00
b7f0713d6f feat: un/assigned commits on version update 2024-11-10 20:06:10 +01:00
d6cb69ec3b feat(api): return commits on version by id request 2024-11-10 07:27:00 +01:00
9422eaed7e feat(app): reset form on load 2024-11-10 07:26:20 +01:00
e98709d854 feat(api): sort db entries by createdAt date 2024-11-09 21:31:30 +01:00
a73d99afcd fix(app) : build error
All checks were successful
Build and Push Docker Image / tests (push) Successful in 51s
Build and Push Docker Image / build (push) Successful in 4m37s
2024-11-06 22:45:18 +01:00
f2b952a8e9 fix: changelog tests
Some checks failed
Build and Push Docker Image / tests (push) Successful in 1m5s
Build and Push Docker Image / build (push) Failing after 1m11s
2024-11-06 22:40:00 +01:00
f161d6b468 feat(api): complete refactor of openapi routes
Some checks failed
Build and Push Docker Image / tests (push) Failing after 1m4s
Build and Push Docker Image / build (push) Has been skipped
2024-11-06 22:20:10 +01:00
95e00816c4 feat(api): more refactor 2024-11-06 22:04:20 +01:00
bfc8ae2f21 feat: refactor zod validations and openapi errors 2024-11-06 21:33:57 +01:00
34bc012ceb chore: add migration files
All checks were successful
Build and Push Docker Image / tests (push) Successful in 47s
Build and Push Docker Image / build (push) Successful in 3m52s
2024-11-05 23:52:41 +01:00
32a84e2011 feat(api): try cors middleware on changelog routes
All checks were successful
Build and Push Docker Image / tests (push) Successful in 57s
Build and Push Docker Image / build (push) Successful in 3m56s
2024-11-05 23:06:32 +01:00
34db8e9b8d ci: add redis url env var for dev/test
All checks were successful
Build and Push Docker Image / tests (push) Successful in 58s
Build and Push Docker Image / build (push) Successful in 4m4s
2024-11-05 16:05:43 +01:00
68e6967cee ci: use redis credentials only on prod env
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Build and Push Docker Image / tests (push) Has been cancelled
2024-11-05 16:02:40 +01:00
94cf57cfda ci: redis credentials only on production
Some checks failed
Build and Push Docker Image / build (push) Blocked by required conditions
Build and Push Docker Image / tests (push) Has been cancelled
2024-11-05 15:59:03 +01:00
15425cdc32 ci: remove redis start command
Some checks failed
Build and Push Docker Image / build (push) Blocked by required conditions
Build and Push Docker Image / tests (push) Has been cancelled
2024-11-05 15:56:42 +01:00
501e75b420 ci: add port and debug log
Some checks failed
Build and Push Docker Image / build (push) Blocked by required conditions
Build and Push Docker Image / tests (push) Has been cancelled
2024-11-05 15:51:13 +01:00
f1ed0d5f6c ci: add redis for testing
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Build and Push Docker Image / tests (push) Has been cancelled
2024-11-05 15:40:06 +01:00
8657228e02 feat(api): add redis cache for public page
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Build and Push Docker Image / tests (push) Has been cancelled
2024-11-05 15:33:27 +01:00
52829bd127 fix(api): page create 2024-11-05 00:11:32 +01:00
8edfd1c7ad fix(api): changelog tests 2024-11-05 00:07:15 +01:00
a7ee7bf981 feat: refactor user id to own id and save clerk id as providerId 2024-11-04 23:29:00 +01:00
a8392312c1 feat(db): update drizzle version 2024-11-04 20:53:22 +01:00
abf009a045 feat(website): add more content for changelog 2024-11-04 20:04:22 +01:00
000a76d28d feat(app): changelog version number editable
All checks were successful
Build and Push Docker Image / tests (push) Successful in 2m13s
Build and Push Docker Image / build (push) Successful in 5m21s
2024-11-02 20:08:04 +01:00
37be3bc1f5 fix(cli): cli arguments
All checks were successful
Build and Push Docker Image / tests (push) Successful in 1m50s
Build and Push Docker Image / build (push) Successful in 5m9s
2024-11-02 19:58:04 +01:00
3e67d18749 chore: fix build errors
All checks were successful
Build and Push Docker Image / tests (push) Successful in 39s
Build and Push Docker Image / build (push) Successful in 4m38s
2024-11-01 21:45:18 +01:00
1be0facd64 feat(app): changelog version auto create
Some checks failed
Build and Push Docker Image / tests (push) Successful in 39s
Build and Push Docker Image / build (push) Failing after 1m8s
2024-11-01 21:15:46 +01:00
ede09bcf04 feat(page): render changelog markdown 2024-11-01 21:14:28 +01:00
d867dc4b8a feat(database): extend changelog schema with: isConventional boolean 2024-11-01 21:13:06 +01:00
a63f5dea75 feat(api): changelog version auto create 2024-11-01 21:11:50 +01:00
9d1f96a5f5 style(app): refactor cli page
All checks were successful
Build and Push Docker Image / tests (push) Successful in 44s
Build and Push Docker Image / build (push) Successful in 4m27s
2024-10-31 22:54:04 +01:00
e50f499c08 feat(app): update cli download paths
All checks were successful
Build and Push Docker Image / tests (push) Successful in 51s
Build and Push Docker Image / build (push) Successful in 3m52s
2024-10-31 22:17:21 +01:00
47bcd228e7 ci(cli): update build output name 2024-10-31 22:15:34 +01:00
649819c777 feat(cli): implement commander lib 2024-10-31 22:12:25 +01:00
19fa2121d9 fix(app): add all commits button
All checks were successful
Build and Push Docker Image / tests (push) Successful in 49s
Build and Push Docker Image / build (push) Successful in 3m57s
2024-10-31 21:27:48 +01:00
e193bb7b5c feat(api): release commits on version delete 2024-10-31 21:27:33 +01:00
badcb2ac2f style(app): more responsive
All checks were successful
Build and Push Docker Image / tests (push) Successful in 31s
Build and Push Docker Image / build (push) Successful in 4m17s
2024-10-31 20:56:40 +01:00
c4e55490cc chore(cli): refactor
All checks were successful
Build and Push Docker Image / tests (push) Successful in 45s
Build and Push Docker Image / build (push) Successful in 4m4s
2024-10-31 20:36:32 +01:00
4faa61159e chore(db): add migration files 2024-10-31 20:36:13 +01:00
c6afa94e1e fix(api): changelog commit upload 2024-10-31 20:35:52 +01:00
81baf571f9 feat: add dashboard statistics
All checks were successful
Build and Push Docker Image / tests (push) Successful in 46s
Build and Push Docker Image / build (push) Successful in 4m4s
2024-10-31 14:58:06 +01:00
af2d0ec4aa fix(app): build errors
All checks were successful
Build and Push Docker Image / tests (push) Successful in 40s
Build and Push Docker Image / build (push) Successful in 3m47s
2024-10-30 23:17:11 +01:00
d72f5c2111 feat(app): implement cli page with download and example
Some checks failed
Build and Push Docker Image / tests (push) Successful in 1m0s
Build and Push Docker Image / build (push) Failing after 1m1s
2024-10-30 23:02:15 +01:00
d2d65027f7 feat(cli): add build and upload step
All checks were successful
Build and Push Docker Image / tests (push) Successful in 52s
Build and Push Docker Image / build (push) Successful in 4m18s
2024-10-30 21:22:00 +01:00
91aeb0793c fix: correct access token delete text 2024-10-30 20:30:01 +01:00
90232feb1e feat: access tokens (create, list, delete)
All checks were successful
Build and Push Docker Image / tests (push) Successful in 54s
Build and Push Docker Image / build (push) Successful in 3m30s
2024-10-30 20:20:35 +01:00
415bba96f0 feat(api): add openapi tags 2024-10-29 22:08:10 +01:00
ab0286fd71 fix: build errors
All checks were successful
Build and Push Docker Image / tests (push) Successful in 37s
Build and Push Docker Image / build (push) Successful in 3m39s
2024-10-29 21:29:54 +01:00
625b463287 feat: add version create with commit select
Some checks failed
Build and Push Docker Image / tests (push) Successful in 47s
Build and Push Docker Image / build (push) Failing after 1m1s
2024-10-29 21:25:21 +01:00
5467e78596 feat: add changelog commit schema
All checks were successful
Build and Push Docker Image / tests (push) Successful in 47s
Build and Push Docker Image / build (push) Successful in 3m39s
2024-10-26 15:12:43 +02:00
106b3c0545 feat: cli commit upload
Some checks failed
Build and Push Docker Image / tests (push) Failing after 41s
Build and Push Docker Image / build (push) Has been skipped
2024-10-26 14:30:15 +02:00
1b18ac3f3a bruno: update commit create
All checks were successful
Build and Push Docker Image / tests (push) Successful in 54s
Build and Push Docker Image / build (push) Successful in 3m16s
2024-10-25 19:50:43 +02:00
4a4eea5e91 wip: cli git parsing 2024-10-25 19:50:21 +02:00
a42342b99d chore(schema): optional field 2024-10-25 19:48:33 +02:00
b182329146 wip: cli and changelog commit upload
All checks were successful
Build and Push Docker Image / tests (push) Successful in 56s
Build and Push Docker Image / build (push) Successful in 3m31s
2024-10-25 16:31:59 +02:00
a14cbd4437 feat(app): add page indes route
All checks were successful
Build and Push Docker Image / tests (push) Successful in 54s
Build and Push Docker Image / build (push) Successful in 3m53s
2024-10-25 00:02:32 +02:00
dbef7867ca chore: fix app build errors
All checks were successful
Build and Push Docker Image / tests (push) Successful in 43s
Build and Push Docker Image / build (push) Successful in 4m7s
2024-10-24 23:41:38 +02:00
fb2e1eee17 ci: fix test error
Some checks failed
Build and Push Docker Image / tests (push) Successful in 36s
Build and Push Docker Image / build (push) Failing after 1m9s
2024-10-24 23:30:52 +02:00
169 changed files with 6887 additions and 2654 deletions

View File

@ -52,6 +52,16 @@ jobs:
# environment: production
# sourcemaps: ./build/app/
- name: Upload CLI to R2
uses: shallwefootball/s3-upload-action@master
with:
aws_key_id: ${{ secrets.R2_ACCESS_ID }}
aws_secret_access_key: ${{ secrets.R2_ACCESS_SECRET }}
aws_bucket: ${{ secrets.R2_BUCKET }}
endpoint: ${{ secrets.R2_URL }}
source_dir: "build/cli"
destination_dir: "cli"
- name: Set docker chmod (temp solution)
run: sudo chmod 666 /var/run/docker.sock

View File

@ -6,6 +6,12 @@ jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis:7.4.1-alpine
# command: redis-server --save 20 1 --loglevel warning --requirepass development
ports:
- 6379:6379
postgres:
image: postgres:17-alpine
@ -44,4 +50,6 @@ jobs:
run: bun test:api
env:
NODE_ENV: test
POSTGRES_URL: postgres://postgres:postgres@postgres:5432/postgres
POSTGRES_URL: postgres://postgres:postgres@postgres:5432/postgres
REDIS_URL: redis://redis:6379
REDIS_PASSWORD: development

View File

@ -1 +0,0 @@
POSTGRES_URL=postgres://postgres:postgres@localhost:5432/postgres

View File

@ -2,7 +2,7 @@
"name": "@boring.tools/api",
"scripts": {
"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"
},
"dependencies": {
@ -13,9 +13,12 @@
"@hono/sentry": "^1.2.0",
"@hono/zod-openapi": "^0.16.2",
"@scalar/hono-api-reference": "^0.5.149",
"convert-gitmoji": "^0.1.5",
"hono": "^4.6.3",
"redis": "^4.7.0",
"semver": "^7.6.3",
"svix": "^1.36.0"
"svix": "^1.36.0",
"ts-pattern": "^5.5.0"
},
"devDependencies": {
"@types/bun": "latest",

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

@ -0,0 +1,56 @@
import crypto from 'node:crypto'
import { access_token, db } from '@boring.tools/database'
import { AccessTokenCreateInput, AccessTokenOutput } from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import { HTTPException } from 'hono/http-exception'
import type { accessTokenApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
export const route = createRoute({
method: 'post',
path: '/',
tags: ['access-token'],
request: {
body: {
content: {
'application/json': { schema: AccessTokenCreateInput },
},
},
},
responses: {
201: {
content: {
'application/json': { schema: AccessTokenOutput },
},
description: 'Commits created',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerAccessTokenCreate = (api: typeof accessTokenApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const data: z.infer<typeof AccessTokenCreateInput> = await c.req.json()
const token = crypto.randomBytes(20).toString('hex')
const [result] = await db
.insert(access_token)
.values({
...data,
userId,
token: `bt_${token}`,
})
.returning()
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(AccessTokenOutput.parse(result), 201)
})
}

View File

@ -0,0 +1,43 @@
import { access_token, db } from '@boring.tools/database'
import { GeneralOutput } from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import type { accessTokenApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
export const route = createRoute({
method: 'delete',
path: '/:id',
tags: ['access-token'],
responses: {
200: {
content: {
'application/json': { schema: GeneralOutput },
},
description: 'Removes a access token by id',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerAccessTokenDelete = (api: typeof accessTokenApi) => {
return api.openapi(route, async (c) => {
const id = c.req.param('id')
const userId = await verifyAuthentication(c)
const [result] = await db
.delete(access_token)
.where(and(eq(access_token.userId, userId), eq(access_token.id, id)))
.returning()
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(GeneralOutput.parse({ message: 'Access token deleted' }), 200)
})
}

View File

@ -0,0 +1,16 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import type { Variables } from '..'
import type { ContextModule } from '../utils/sentry'
import { registerAccessTokenCreate } from './create'
import { registerAccessTokenDelete } from './delete'
import { registerAccessTokenList } from './list'
export const accessTokenApi = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'access-token',
}
registerAccessTokenCreate(accessTokenApi)
registerAccessTokenList(accessTokenApi)
registerAccessTokenDelete(accessTokenApi)

View File

@ -0,0 +1,48 @@
import { changelog, db } from '@boring.tools/database'
import { AccessTokenListOutput } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
import { desc, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import type { accessTokenApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
const route = createRoute({
method: 'get',
path: '/',
tags: ['access-token'],
responses: {
200: {
content: {
'application/json': {
schema: AccessTokenListOutput,
},
},
description: 'Return version by id',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerAccessTokenList = (api: typeof accessTokenApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const result = await db.query.access_token.findMany({
where: eq(changelog.userId, userId),
orderBy: () => desc(changelog.createdAt),
})
if (!result) {
throw new HTTPException(404, { message: 'Access Tokens not found' })
}
const mappedData = result.map((at) => ({
...at,
token: at.token.substring(0, 10),
}))
return c.json(AccessTokenListOutput.parse(mappedData), 200)
})
}

View File

@ -4,9 +4,14 @@ import { createRoute } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import type { changelogApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
export const route = createRoute({
method: 'get',
path: '/:id',
tags: ['changelog'],
request: {
params: ChangelogByIdParams,
},
@ -19,39 +24,35 @@ export const route = createRoute({
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
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,
export const registerChangelogById = (api: typeof changelogApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const { id } = c.req.valid('param')
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),
],
},
},
versions: {
orderBy: (changelog_version, { desc }) => [
desc(changelog_version.createdAt),
],
},
},
})
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
return c.json(ChangelogOutput.parse(result), 200)
})
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
return result
}
export default {
route,
func,
}

View File

@ -12,18 +12,23 @@ import { eq } from 'drizzle-orm'
import { fetch } from '../utils/testing/fetch'
describe('Changelog', () => {
let testAccessToken: AccessTokenOutput
let testAccessToken: z.infer<typeof AccessTokenOutput>
let testChangelog: z.infer<typeof ChangelogOutput>
beforeAll(async () => {
await db
const createdUser = await db
.insert(user)
.values({ email: 'changelog@test.local', id: 'test_000' })
.values({ email: 'changelog@test.local', providerId: 'test_000' })
.returning()
const tAccessToken = await db
.insert(access_token)
.values({ token: 'test123', userId: 'test_000', name: 'testtoken' })
.values({
token: 'test123',
userId: createdUser[0].id,
name: 'testtoken',
})
.returning()
testAccessToken = tAccessToken[0]
testAccessToken = tAccessToken[0] as z.infer<typeof AccessTokenOutput>
})
afterAll(async () => {
@ -35,6 +40,8 @@ describe('Changelog', () => {
const payload: z.infer<typeof ChangelogCreateInput> = {
title: 'changelog',
description: 'description',
isSemver: true,
isConventional: true,
}
const res = await fetch(
@ -43,10 +50,10 @@ describe('Changelog', () => {
method: 'POST',
body: payload,
},
testAccessToken.token,
testAccessToken.token as string,
)
const json: z.infer<typeof ChangelogCreateOutput> = await res.json()
const json = (await res.json()) as z.infer<typeof ChangelogCreateOutput>
testChangelog = json
expect(res.status).toBe(201)
@ -61,7 +68,7 @@ describe('Changelog', () => {
path: `/v1/changelog/${testChangelog.id}`,
method: 'GET',
},
testAccessToken.token,
testAccessToken.token as string,
)
expect(res.status).toBe(200)
@ -73,7 +80,7 @@ describe('Changelog', () => {
path: '/v1/changelog/635f4aa7-79fc-4d6b-af7d-6731999cc8bb',
method: 'GET',
},
testAccessToken.token,
testAccessToken.token as string,
)
expect(res.status).toBe(404)
@ -85,12 +92,12 @@ describe('Changelog', () => {
path: '/v1/changelog/some',
method: 'GET',
},
testAccessToken.token,
testAccessToken.token as string,
)
expect(res.status).toBe(400)
const json = await res.json()
const json = (await res.json()) as { success: boolean }
expect(json.success).toBeFalse()
})
})
@ -102,12 +109,12 @@ describe('Changelog', () => {
path: '/v1/changelog',
method: 'GET',
},
testAccessToken.token,
testAccessToken.token as string,
)
expect(res.status).toBe(200)
const json: z.infer<typeof ChangelogListOutput> = await res.json()
const json = (await res.json()) as z.infer<typeof ChangelogListOutput>
expect(json).toHaveLength(1)
})
})
@ -119,7 +126,7 @@ describe('Changelog', () => {
path: `/v1/changelog/${testChangelog.id}`,
method: 'DELETE',
},
testAccessToken.token,
testAccessToken.token as string,
)
expect(res.status).toBe(200)
@ -131,7 +138,7 @@ describe('Changelog', () => {
path: `/v1/changelog/${testChangelog.id}`,
method: 'DELETE',
},
testAccessToken.token,
testAccessToken.token as string,
)
expect(res.status).toBe(404)

View File

@ -0,0 +1,67 @@
import { changelog, changelog_commit, db } from '@boring.tools/database'
import { CommitCreateInput, CommitCreateOutput } from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import type { changelogCommitApi } from '.'
import { verifyAuthentication } from '../../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../../utils/openapi'
export const route = createRoute({
method: 'post',
path: '/',
tags: ['commit'],
request: {
body: {
content: {
'application/json': { schema: CommitCreateInput },
},
},
},
responses: {
201: {
content: {
'application/json': { schema: CommitCreateOutput },
},
description: 'Commits created',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerCommitCreate = (api: typeof changelogCommitApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const data: z.infer<typeof CommitCreateInput> = await c.req.json()
const changelogResult = await db.query.changelog.findFirst({
where: and(
eq(changelog.id, data[0].changelogId),
eq(changelog.userId, userId),
),
})
if (!changelogResult) {
throw new HTTPException(404, { message: 'Not Found' })
}
const mappedData = data.map((entry) => ({
...entry,
createdAt: new Date(entry.author.date),
}))
const [result] = await db
.insert(changelog_commit)
.values(mappedData)
.onConflictDoNothing()
.returning()
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(CommitCreateOutput.parse(result), 201)
})
}

View File

@ -0,0 +1,15 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import type { Variables } from '../..'
import type { ContextModule } from '../../utils/sentry'
import { registerCommitCreate } from './create'
import { registerCommitList } from './list'
export const changelogCommitApi = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'changelog',
sub_module: 'version',
}
registerCommitCreate(changelogCommitApi)
registerCommitList(changelogCommitApi)

View File

@ -0,0 +1,83 @@
import { changelog, changelog_commit, db } from '@boring.tools/database'
import { CommitListOutput, CommitListParams } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
import { and, eq, isNotNull, isNull } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import { P, match } from 'ts-pattern'
import type { changelogCommitApi } from '.'
import { verifyAuthentication } from '../../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../../utils/openapi'
const route = createRoute({
method: 'get',
path: '/',
tags: ['commit'],
request: {
query: CommitListParams,
},
responses: {
200: {
content: {
'application/json': {
schema: CommitListOutput,
},
},
description: 'Return version by id',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerCommitList = (api: typeof changelogCommitApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const { changelogId, limit, offset, hasVersion } = c.req.valid('query')
const result = await db.query.changelog.findFirst({
where: and(eq(changelog.userId, userId), eq(changelog.id, changelogId)),
})
if (!result) {
throw new HTTPException(404, { message: 'Changelog not found' })
}
const where = match({ changelogId, hasVersion })
.with(
{
changelogId: P.select('changelogId'),
hasVersion: P.when((hasVersion) => hasVersion === true),
},
({ changelogId }) =>
and(
eq(changelog_commit.changelogId, changelogId),
isNotNull(changelog_commit.versionId),
),
)
.with(
{
changelogId: P.select('changelogId'),
hasVersion: P.when((hasVersion) => hasVersion === false),
},
({ changelogId }) =>
and(
eq(changelog_commit.changelogId, changelogId),
isNull(changelog_commit.versionId),
),
)
.otherwise(() => eq(changelog_commit.changelogId, changelogId))
const commits = await db.query.changelog_commit.findMany({
where,
limit: Number(limit) ?? 10,
offset: Number(offset) ?? 0,
orderBy: (_, { desc }) => [desc(changelog_commit.createdAt)],
})
if (!commits) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(CommitListOutput.parse(commits), 200)
})
}

View File

@ -5,9 +5,14 @@ import {
} from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import type { changelogApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
export const route = createRoute({
method: 'post',
path: '/',
tags: ['changelog'],
request: {
body: {
content: {
@ -22,32 +27,24 @@ export const route = createRoute({
},
description: 'Return created changelog',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const func = async ({
userId,
payload,
}: {
userId: string
payload: z.infer<typeof ChangelogCreateInput>
}) => {
return await db
.insert(changelog)
.values({
...payload,
userId: userId,
})
.returning()
}
export const registerChangelogCreate = (api: typeof changelogApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const payload: z.infer<typeof ChangelogCreateInput> = await c.req.json()
export default {
route,
func,
const [result] = await db
.insert(changelog)
.values({
...payload,
userId: userId,
})
.returning()
return c.json(ChangelogCreateOutput.parse(result), 201)
})
}

View File

@ -4,9 +4,14 @@ import { createRoute } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import type { changelogApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
export const route = createRoute({
method: 'delete',
path: '/:id',
tags: ['changelog'],
responses: {
200: {
content: {
@ -16,29 +21,25 @@ export const route = createRoute({
},
description: 'Removes a changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const func = async ({ userId, id }: { userId: string; id: string }) => {
const result = await db
.delete(changelog)
.where(and(eq(changelog.userId, userId), eq(changelog.id, id)))
.returning()
export const registerChangelogDelete = async (api: typeof changelogApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const id = c.req.param('id')
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
const [result] = await db
.delete(changelog)
.where(and(eq(changelog.userId, userId), eq(changelog.id, id)))
.returning()
return result
}
export default {
route,
func,
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
return c.json(GeneralOutput.parse(result), 200)
})
}

View File

@ -1,130 +1,28 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import { cors } from 'hono/cors'
import type { Variables } from '..'
import { verifyAuthentication } from '../utils/authentication'
import { type ContextModule, captureSentry } from '../utils/sentry'
import ById from './byId'
import Create from './create'
import Delete from './delete'
import List from './list'
import Update from './update'
import type { ContextModule } from '../utils/sentry'
import { registerChangelogById } from './byId'
import { changelogCommitApi } from './commit'
import { registerChangelogCreate } from './create'
import { registerChangelogDelete } from './delete'
import { registerChangelogList } from './list'
import { registerChangelogUpdate } from './update'
import version from './version'
const app = new OpenAPIHono<{ Variables: Variables }>()
export const changelogApi = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'changelog',
}
changelogApi.use('*', cors())
changelogApi.route('/commit', changelogCommitApi)
changelogApi.route('/version', version)
app.openapi(ById.route, async (c) => {
const userId = verifyAuthentication(c)
try {
const id = c.req.param('id')
const result = await ById.func({ userId, id })
return c.json(result, 200)
} catch (error) {
return captureSentry({
c,
error,
module,
user: {
id: userId,
},
})
}
})
registerChangelogById(changelogApi)
registerChangelogCreate(changelogApi)
registerChangelogDelete(changelogApi)
registerChangelogUpdate(changelogApi)
registerChangelogList(changelogApi)
app.openapi(List.route, async (c) => {
const userId = verifyAuthentication(c)
try {
const result = await List.func({ userId })
return c.json(result, 200)
} catch (error) {
return captureSentry({
c,
error,
module,
user: {
id: userId,
},
})
}
})
app.openapi(Create.route, async (c) => {
const userId = verifyAuthentication(c)
try {
const [result] = await Create.func({
userId,
payload: await c.req.json(),
})
return c.json(result, 201)
} catch (error) {
return captureSentry({
c,
error,
module,
user: {
id: userId,
},
})
}
})
app.openapi(Delete.route, async (c) => {
const userId = verifyAuthentication(c)
try {
const id = c.req.param('id')
const result = await Delete.func({ userId, id })
if (result.length === 0) {
return c.json({ message: 'Changelog not found' }, 404)
}
return c.json({ message: 'Changelog removed' })
} catch (error) {
return captureSentry({
c,
error,
module,
user: {
id: userId,
},
})
}
})
app.openapi(Update.route, async (c) => {
const userId = verifyAuthentication(c)
try {
const id = c.req.param('id')
if (!id) {
return c.json({ message: 'Changelog not found' }, 404)
}
const result = await Update.func({
userId,
payload: await c.req.json(),
id,
})
if (result.length === 0) {
return c.json({ message: 'Changelog not found' }, 404)
}
return c.json(result)
} catch (error) {
return captureSentry({
c,
error,
module,
user: {
id: userId,
},
})
}
})
export default app
export default changelogApi

View File

@ -3,9 +3,14 @@ import { ChangelogListOutput } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm'
import type { changelogApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
export const route = createRoute({
method: 'get',
path: '/',
tags: ['changelog'],
responses: {
200: {
content: {
@ -15,40 +20,37 @@ export const route = createRoute({
},
description: 'Return changelogs for current user',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const func = async ({ userId }: { userId: string }) => {
const result = await db.query.changelog.findMany({
where: eq(changelog.userId, userId),
with: {
versions: true,
commits: {
columns: { id: true },
},
},
orderBy: (changelog, { asc }) => [asc(changelog.createdAt)],
})
export const registerChangelogList = (api: typeof changelogApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
return result.map((changelog) => {
const { versions, commits, ...rest } = changelog
return {
...rest,
computed: {
versionCount: versions.length,
commitCount: commits.length,
const result = await db.query.changelog.findMany({
where: eq(changelog.userId, userId),
with: {
versions: true,
commits: {
columns: { id: true },
},
},
}
orderBy: (changelog, { asc }) => [asc(changelog.createdAt)],
})
const mappedData = result.map((changelog) => {
const { versions, commits, ...rest } = changelog
return {
...rest,
computed: {
versionCount: versions.length,
commitCount: commits.length,
},
}
})
return c.json(ChangelogListOutput.parse(mappedData), 200)
})
}
export default {
route,
func,
}

View File

@ -7,6 +7,11 @@ import { createRoute, type z } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import type { changelogApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
import { redis } from '../utils/redis'
export const route = createRoute({
method: 'put',
path: '/:id',
@ -25,40 +30,32 @@ export const route = createRoute({
},
description: 'Return updated changelog',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const func = async ({
userId,
payload,
id,
}: {
userId: string
payload: z.infer<typeof ChangelogUpdateInput>
id: string
}) => {
const [result] = await db
.update(changelog)
.set({
...payload,
})
.where(and(eq(changelog.id, id), eq(changelog.userId, userId)))
.returning()
export const registerChangelogUpdate = (api: typeof changelogApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const id = c.req.param('id')
const payload: z.infer<typeof ChangelogUpdateInput> = await c.req.json()
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
const [result] = await db
.update(changelog)
.set({
...payload,
})
.where(and(eq(changelog.id, id), eq(changelog.userId, userId)))
.returning()
return result
}
export default {
route,
func,
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
if (result.pageId) {
redis.del(result.pageId)
}
return c.json(ChangelogUpdateOutput.parse(result), 200)
})
}

View File

@ -1,11 +1,22 @@
import { changelog, changelog_version, db } from '@boring.tools/database'
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import { VersionByIdParams, VersionOutput } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import type changelogVersionApi from '.'
import { verifyAuthentication } from '../../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../../utils/openapi'
export const byId = createRoute({
method: 'get',
path: '/:id',
tags: ['version'],
request: {
params: VersionByIdParams,
},
@ -18,49 +29,46 @@ export const byId = createRoute({
},
description: 'Return version by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const byIdFunc = async ({
userId,
id,
}: {
userId: string
id: string
}) => {
const versionResult = await db.query.changelog_version.findFirst({
where: eq(changelog_version.id, id),
with: {
commits: true,
},
export const registerVersionById = (api: typeof changelogVersionApi) => {
return api.openapi(byId, async (c) => {
const userId = await verifyAuthentication(c)
const { id } = c.req.valid('param')
const versionResult = await db.query.changelog_version.findFirst({
where: eq(changelog_version.id, id),
with: {
commits: {
orderBy: () => desc(changelog_commit.createdAt),
},
},
})
if (!versionResult) {
throw new HTTPException(404, { message: 'Not Found' })
}
if (!versionResult.changelogId) {
throw new HTTPException(404, { message: 'Not Found' })
}
const changelogResult = await db.query.changelog.findMany({
where: and(eq(changelog.userId, userId)),
columns: {
id: true,
},
})
const changelogIds = changelogResult.map((cl) => cl.id)
if (!changelogIds.includes(versionResult.changelogId)) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(VersionOutput.parse(versionResult), 200)
})
if (!versionResult) {
return null
}
if (!versionResult.changelogId) {
return null
}
const changelogResult = await db.query.changelog.findMany({
where: and(eq(changelog.userId, userId)),
columns: {
id: true,
},
})
const changelogIds = changelogResult.map((cl) => cl.id)
if (!changelogIds.includes(versionResult.changelogId)) {
return null
}
return versionResult
}

View File

@ -1,13 +1,24 @@
import { changelog, changelog_version, db } from '@boring.tools/database'
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import { VersionCreateInput, VersionCreateOutput } from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import semver from 'semver'
export const create = createRoute({
import type changelogVersionApi from '.'
import { verifyAuthentication } from '../../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../../utils/openapi'
import { redis } from '../../utils/redis'
export const route = createRoute({
method: 'post',
path: '/',
tags: ['version'],
request: {
body: {
content: {
@ -22,67 +33,71 @@ export const create = createRoute({
},
description: 'Version created',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const createFunc = async ({
userId,
payload,
}: {
userId: string
payload: z.infer<typeof VersionCreateInput>
}) => {
const formattedVersion = semver.coerce(payload.version)
const validVersion = semver.valid(formattedVersion)
export const registerVersionCreate = (api: typeof changelogVersionApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const payload: z.infer<typeof VersionCreateInput> = await c.req.json()
const changelogResult = await db.query.changelog.findFirst({
where: and(
eq(changelog.userId, userId),
eq(changelog.id, payload.changelogId),
),
const changelogResult = await db.query.changelog.findFirst({
where: and(
eq(changelog.userId, userId),
eq(changelog.id, payload.changelogId),
),
with: {
versions: {
where: and(
eq(changelog_version.changelogId, payload.changelogId),
eq(changelog_version.version, payload.version),
),
},
},
})
if (!changelogResult) {
throw new HTTPException(404, {
message: 'Changelog not found',
})
}
if (changelogResult.versions.length) {
throw new HTTPException(409, {
message: 'Version exists already',
})
}
const formattedVersion = semver.coerce(payload.version)
const validVersion = semver.valid(formattedVersion)
if (validVersion === null) {
throw new HTTPException(409, {
message: 'Version is not semver compatible',
})
}
const [versionCreateResult] = await db
.insert(changelog_version)
.values({
changelogId: payload.changelogId,
version: validVersion,
status: payload.status,
markdown: payload.markdown,
})
.returning()
if (changelogResult.pageId) {
redis.del(changelogResult.pageId)
}
await db
.update(changelog_commit)
.set({ versionId: versionCreateResult.id })
.where(inArray(changelog_commit.id, payload.commitIds))
return c.json(VersionCreateOutput.parse(versionCreateResult), 201)
})
if (!changelogResult) {
throw new HTTPException(404, {
message: 'changelog not found',
})
}
if (validVersion === null) {
throw new HTTPException(409, {
message: 'Version is not semver compatible',
})
}
// Check if a version with the same version already exists
const versionResult = await db.query.changelog_version.findFirst({
where: and(
eq(changelog_version.changelogId, payload.changelogId),
eq(changelog_version.version, validVersion),
),
})
if (versionResult) {
throw new HTTPException(409, {
message: 'Version exists already',
})
}
const [versionCreateResult] = await db
.insert(changelog_version)
.values({
changelogId: payload.changelogId,
version: validVersion,
status: payload.status,
markdown: payload.markdown,
})
.returning()
return versionCreateResult
}

View File

@ -0,0 +1,176 @@
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import {
VersionCreateAutoInput,
VersionCreateOutput,
} from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import { format } from 'date-fns'
import { and, eq, isNull } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import semver from 'semver'
import type { changelogVersionApi } from '.'
import { verifyAuthentication } from '../../utils/authentication'
import { commitsToMarkdown } from '../../utils/git/commitsToMarkdown'
import { openApiErrorResponses, openApiSecurity } from '../../utils/openapi'
import { redis } from '../../utils/redis'
export const route = createRoute({
method: 'post',
path: '/auto',
tags: ['commit'],
request: {
body: {
content: {
'application/json': { schema: VersionCreateAutoInput },
},
},
},
responses: {
201: {
content: {
'application/json': { schema: VersionCreateOutput },
},
description: 'Commits created',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
const getVersion = (version: string) => {
const isValid = semver.valid(semver.coerce(version))
if (isValid) {
return isValid
}
return format(new Date(), 'dd.MM.yy')
}
const getNextVersion = ({
version,
isSemver,
}: { version: string; isSemver: boolean }): string => {
if (isSemver) {
if (version === '') {
return '1.0.0'
}
const isValid = semver.valid(semver.coerce(version))
if (isValid) {
const nextVersion = semver.inc(isValid, 'patch')
if (!nextVersion) {
throw new Error('Incorrect semver')
}
return nextVersion
}
}
return format(new Date(), 'dd.MM.yy')
}
export const registerVersionCreateAuto = (api: typeof changelogVersionApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const data: z.infer<typeof VersionCreateAutoInput> = await c.req.json()
const changelogResult = await db.query.changelog.findFirst({
where: and(
eq(changelog.id, data.changelogId),
eq(changelog.userId, userId),
),
with: {
versions: {
columns: {
version: true,
},
orderBy: (_, { asc }) => asc(changelog_version.createdAt),
limit: 1,
},
},
})
if (!changelogResult) {
throw new HTTPException(404, {
message: 'Version could not be created. Changelog not found.',
})
}
if (!changelogResult.isConventional) {
throw new HTTPException(500, {
message: 'Auto generating only works with conventional commits.',
})
}
const commits = await db.query.changelog_commit.findMany({
where: and(
isNull(changelog_commit.versionId),
eq(changelog_commit.changelogId, data.changelogId),
),
})
// @ts-ignore
const markdown = await commitsToMarkdown(commits)
// If no version exists, create the first one
if (changelogResult?.versions.length === 0) {
// If the changelog follows semver starts with version 1.0.0
const inputVersion = changelog.isSemver
? semver.valid(semver.coerce(data.version))
: data.version
const [versionCreateResult] = await db
.insert(changelog_version)
.values({
changelogId: changelogResult.id,
version:
inputVersion ??
getNextVersion({
version: '',
isSemver: changelogResult.isSemver ?? true,
}),
status: 'draft',
markdown,
})
.returning()
await db
.update(changelog_commit)
.set({ versionId: versionCreateResult.id })
.where(isNull(changelog_commit.versionId))
if (changelogResult.pageId) {
redis.del(changelogResult.pageId)
}
return c.json(versionCreateResult, 201)
}
const [versionCreateResult] = await db
.insert(changelog_version)
.values({
changelogId: changelogResult.id,
version: data.version
? getVersion(data.version)
: getNextVersion({
version: changelogResult.versions[0].version,
isSemver: changelogResult.isSemver ?? true,
}),
status: 'draft',
markdown,
})
.returning()
await db
.update(changelog_commit)
.set({ versionId: versionCreateResult.id })
.where(isNull(changelog_commit.versionId))
if (changelogResult.pageId) {
redis.del(changelogResult.pageId)
}
return c.json(VersionCreateOutput.parse(versionCreateResult), 201)
})
}

View File

@ -1,12 +1,22 @@
import { changelog, changelog_version, db } from '@boring.tools/database'
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import { GeneralOutput } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import type changelogVersionApi from '.'
import { verifyAuthentication } from '../../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../../utils/openapi'
import { redis } from '../../utils/redis'
export const remove = createRoute({
export const route = createRoute({
method: 'delete',
path: '/:id',
tags: ['version'],
responses: {
200: {
content: {
@ -16,48 +26,55 @@ export const remove = createRoute({
},
description: 'Removes a version by id',
},
404: {
content: {
'application/json': {
schema: GeneralOutput,
},
},
description: 'Version not found',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const removeFunc = async ({
userId,
id,
}: {
userId: string
id: string
}) => {
const changelogResult = await db.query.changelog.findMany({
where: and(eq(changelog.userId, userId)),
with: {
versions: {
where: eq(changelog_version.id, id),
export const registerVersionDelete = async (
api: typeof changelogVersionApi,
) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const id = c.req.param('id')
const changelogResult = await db.query.changelog.findMany({
where: and(eq(changelog.userId, userId)),
with: {
versions: {
where: eq(changelog_version.id, id),
},
},
},
})
const findChangelog = changelogResult.find((change) =>
change.versions.find((ver) => ver.id === id),
)
if (!findChangelog?.versions.length) {
throw new HTTPException(404, {
message: 'Version not found',
})
}
return db
.delete(changelog_version)
.where(and(eq(changelog_version.id, id)))
.returning()
if (!changelogResult) {
throw new HTTPException(404, { message: 'Not Found' })
}
const findChangelog = changelogResult.find((change) =>
change.versions.find((ver) => ver.id === id),
)
if (!findChangelog?.versions.length) {
throw new HTTPException(404, {
message: 'Version not found',
})
}
await db
.update(changelog_commit)
.set({ versionId: null })
.where(eq(changelog_commit.versionId, id))
if (findChangelog.pageId) {
redis.del(findChangelog.pageId)
}
await db
.delete(changelog_version)
.where(and(eq(changelog_version.id, id)))
.returning()
return c.json(GeneralOutput.parse({ message: 'Version deleted' }), 200)
})
}

View File

@ -1,126 +1,24 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import type { Variables } from '../..'
import { verifyAuthentication } from '../../utils/authentication'
import { type ContextModule, captureSentry } from '../../utils/sentry'
import { byId, byIdFunc } from './byId'
import { create, createFunc } from './create'
import { remove, removeFunc } from './delete'
import { update, updateFunc } from './update'
const app = new OpenAPIHono<{ Variables: Variables }>()
import type { Variables } from '../..'
import type { ContextModule } from '../../utils/sentry'
import { registerVersionById } from './byId'
import { registerVersionCreate } from './create'
import { registerVersionCreateAuto } from './createAuto'
import { registerVersionDelete } from './delete'
import { registerVersionUpdate } from './update'
export const changelogVersionApi = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'changelog',
sub_module: 'version',
}
app.openapi(create, async (c) => {
const userId = verifyAuthentication(c)
try {
const payload = await c.req.json()
const result = await createFunc({ userId, payload })
registerVersionCreateAuto(changelogVersionApi)
registerVersionById(changelogVersionApi)
registerVersionCreate(changelogVersionApi)
registerVersionDelete(changelogVersionApi)
registerVersionUpdate(changelogVersionApi)
if (!result) {
return c.json({ message: 'Version not created' }, 400)
}
return c.json(result, 201)
} catch (error) {
return captureSentry({
c,
error,
module,
user: {
id: userId,
},
})
}
})
app.openapi(byId, async (c) => {
const userId = verifyAuthentication(c)
try {
const id = c.req.param('id')
const result = await byIdFunc({ userId, id })
if (!result) {
return c.json({ message: 'Version not found' }, 404)
}
// Ensure all required properties are present and non-null
return c.json(
{
...result,
changelogId: result.changelogId || '',
version: result.version || '',
status: result.status || 'draft',
releasedAt: result.releasedAt,
commits: result.commits || [],
markdown: result.markdown || '',
},
200,
)
} catch (error) {
return captureSentry({
c,
error,
module,
user: {
id: userId,
},
})
}
})
app.openapi(update, async (c) => {
const userId = verifyAuthentication(c)
try {
const id = c.req.param('id')
if (!id) {
return c.json({ message: 'Version not found' }, 404)
}
const result = await updateFunc({
userId,
payload: await c.req.json(),
id,
})
return c.json(result)
} catch (error) {
return captureSentry({
c,
error,
module,
user: {
id: userId,
},
})
}
})
app.openapi(remove, async (c) => {
const userId = verifyAuthentication(c)
try {
const id = c.req.param('id')
const result = await removeFunc({ userId, id })
if (result.length === 0) {
return c.json({ message: 'Version not found' }, 404)
}
return c.json({ message: 'Version removed' })
} catch (error) {
return captureSentry({
c,
error,
module,
user: {
id: userId,
},
})
}
})
export default app
export default changelogVersionApi

View File

@ -1,12 +1,23 @@
import { changelog, changelog_version, db } from '@boring.tools/database'
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import { VersionUpdateInput, VersionUpdateOutput } from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { and, eq, inArray, notInArray } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
export const update = createRoute({
import type changelogVersionApi from '.'
import { verifyAuthentication } from '../../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../../utils/openapi'
import { redis } from '../../utils/redis'
export const route = createRoute({
method: 'put',
path: '/:id',
tags: ['version'],
request: {
body: {
content: {
@ -21,52 +32,69 @@ export const update = createRoute({
},
description: 'Return updated version',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const updateFunc = async ({
userId,
payload,
id,
}: {
userId: string
payload: z.infer<typeof VersionUpdateInput>
id: string
}) => {
const changelogResult = await db.query.changelog.findMany({
where: and(eq(changelog.userId, userId)),
with: {
versions: {
where: eq(changelog_version.id, id),
export const registerVersionUpdate = (api: typeof changelogVersionApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const id = c.req.param('id')
const payload: z.infer<typeof VersionUpdateInput> = await c.req.json()
const changelogResult = await db.query.changelog.findMany({
where: and(eq(changelog.userId, userId)),
with: {
versions: {
where: eq(changelog_version.id, id),
},
},
},
})
if (!changelogResult) {
throw new HTTPException(404, {
message: 'Version not found',
})
}
const findChangelog = changelogResult.find((change) =>
change.versions.find((ver) => ver.id === id),
)
if (!findChangelog?.versions.length) {
throw new HTTPException(404, {
message: 'Version not found',
})
}
const [versionUpdateResult] = await db
.update(changelog_version)
.set({
version: payload.version,
status: payload.status,
markdown: payload.markdown,
releasedAt: payload.releasedAt ? new Date(payload.releasedAt) : null,
})
.where(and(eq(changelog_version.id, id)))
.returning()
if (payload.commitIds) {
await db
.update(changelog_commit)
.set({ versionId: null })
.where(notInArray(changelog_commit.id, payload.commitIds))
await db
.update(changelog_commit)
.set({ versionId: versionUpdateResult.id })
.where(inArray(changelog_commit.id, payload.commitIds))
}
if (findChangelog.pageId) {
redis.del(findChangelog.pageId)
}
return c.json(VersionUpdateOutput.parse(versionUpdateResult), 200)
})
const findChangelog = changelogResult.find((change) =>
change.versions.find((ver) => ver.id === id),
)
if (!findChangelog?.versions.length) {
throw new HTTPException(404, {
message: 'Version not found',
})
}
const [versionUpdateResult] = await db
.update(changelog_version)
.set({
status: payload.status,
markdown: payload.markdown,
releasedAt: payload.releasedAt ? new Date(payload.releasedAt) : null,
})
.where(and(eq(changelog_version.id, id)))
.returning()
return versionUpdateResult
}

View File

@ -1,16 +1,19 @@
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 { apiReference } from '@scalar/hono-api-reference'
import { cors } from 'hono/cors'
import { requestId } from 'hono/request-id'
import changelog from './changelog'
import version from './changelog/version'
import user from './user'
import { accessTokenApi } from './access-token'
import pageApi from './page'
import statisticApi from './statistic'
import userApi from './user'
import { authentication } from './utils/authentication'
import { handleError, handleZodError } from './utils/errors'
import { logger } from './utils/logger'
import { startup } from './utils/startup'
type User = z.infer<typeof UserOutput>
@ -21,6 +24,7 @@ export type Variables = {
export const app = new OpenAPIHono<{ Variables: Variables }>({
defaultHook: handleZodError,
strict: false,
})
// app.use(
@ -29,14 +33,26 @@ export const app = new OpenAPIHono<{ Variables: Variables }>({
// dsn: 'https://1d7428bbab0a305078cf4aa380721aa2@o4508167321354240.ingest.de.sentry.io/4508167323648080',
// }),
// )
app.onError(handleError)
app.use('*', cors())
app.use('/v1/*', authentication)
app.use('*', requestId())
app.use(logger())
app.openAPIRegistry.registerComponent('securitySchemes', 'AccessToken', {
type: 'http',
scheme: 'bearer',
})
app.openAPIRegistry.registerComponent('securitySchemes', 'Clerk', {
type: 'http',
scheme: 'bearer',
})
app.route('/v1/user', user)
app.route('/v1/user', userApi)
app.route('/v1/changelog', changelog)
app.route('/v1/changelog/version', version)
app.route('/v1/page', pageApi)
app.route('/v1/access-token', accessTokenApi)
app.route('/v1/statistic', statisticApi)
app.doc('/openapi.json', {
openapi: '3.0.0',

View File

@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
import type { pageApi } from './index'
const route = createRoute({
@ -24,18 +25,14 @@ const route = createRoute({
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerPageById = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const userId = await verifyAuthentication(c)
const { id } = c.req.valid('param')
const result = await db.query.page.findFirst({
@ -60,6 +57,6 @@ export const registerPageById = (api: typeof pageApi) => {
changelogs: changelogs.map((log) => log.changelog),
}
return c.json(mappedResult, 200)
return c.json(PageOutput.parse(mappedResult), 200)
})
}

View File

@ -4,6 +4,7 @@ import { createRoute, type z } from '@hono/zod-openapi'
import { HTTPException } from 'hono/http-exception'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
import type { pageApi } from './index'
const route = createRoute({
@ -19,7 +20,7 @@ const route = createRoute({
},
},
responses: {
200: {
201: {
content: {
'application/json': {
schema: PageOutput,
@ -27,18 +28,14 @@ const route = createRoute({
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerPageCreate = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const userId = await verifyAuthentication(c)
const { changelogIds, ...rest }: z.infer<typeof PageCreateInput> =
await c.req.json()
@ -51,8 +48,12 @@ export const registerPageCreate = (api: typeof pageApi) => {
})
.returning()
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
// TODO: implement transaction
if (changelogIds) {
if (changelogIds.length > 0) {
await db.insert(changelogs_to_pages).values(
changelogIds.map((changelogId) => ({
changelogId,
@ -60,10 +61,7 @@ export const registerPageCreate = (api: typeof pageApi) => {
})),
)
}
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(result, 200)
return c.json(PageOutput.parse(result), 201)
})
}

View File

@ -3,11 +3,14 @@ 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'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
const route = createRoute({
method: 'delete',
tags: ['page'],
path: '/:id',
request: {
params: PageByIdParams,
@ -22,27 +25,25 @@ const route = createRoute({
},
description: 'Removes a changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerPageDelete = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const userId = await verifyAuthentication(c)
const { id } = c.req.valid('param')
const result = await db
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)
return c.json({}, 200)
})
}

View File

@ -1,5 +1,7 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import { timing } from 'hono/timing'
import type { Variables } from '..'
import { handleZodError } from '../utils/errors'
import type { ContextModule } from '../utils/sentry'
import { registerPageById } from './byId'
import { registerPageCreate } from './create'
@ -8,8 +10,10 @@ import { registerPageList } from './list'
import { registerPagePublic } from './public'
import { registerPageUpdate } from './update'
export const pageApi = new OpenAPIHono<{ Variables: Variables }>()
export const pageApi = new OpenAPIHono<{ Variables: Variables }>({
defaultHook: handleZodError,
})
pageApi.use('*', timing())
const module: ContextModule = {
name: 'page',
}

View File

@ -1,16 +1,17 @@
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 { createRoute } from '@hono/zod-openapi'
import { and, desc, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
import type { pageApi } from './index'
const route = createRoute({
method: 'get',
tags: ['page'],
description: 'Get a page list',
description: 'Get a list of pages',
path: '/',
responses: {
200: {
@ -21,27 +22,24 @@ const route = createRoute({
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerPageList = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const userId = await verifyAuthentication(c)
const result = await db.query.page.findMany({
where: and(eq(page.userId, userId)),
orderBy: () => desc(page.createdAt),
})
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(result, 200)
return c.json(PageListOutput.parse(result), 200)
})
}

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

@ -1,15 +1,18 @@
import { changelog_version, db, page } from '@boring.tools/database'
import { PagePublicOutput, PagePublicParams } from '@boring.tools/schema'
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 { endTime, startTime } from 'hono/timing'
import { openApiErrorResponses } from '../utils/openapi'
import { redis } from '../utils/redis'
import type { pageApi } from './index'
const route = createRoute({
method: 'get',
tags: ['page'],
description: 'Get a page',
description: 'Get a page by id for public view',
path: '/:id/public',
request: {
params: PagePublicParams,
@ -21,20 +24,24 @@ const route = createRoute({
schema: PagePublicOutput,
},
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
description: 'Get a page by id for public view',
},
...openApiErrorResponses,
},
})
export const registerPagePublic = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const { id } = c.req.valid('param')
const cache = await redis.get(id)
if (cache) {
c.header('Cache-Control', 'public, max-age=86400')
c.header('X-Cache', 'HIT')
return c.json(JSON.parse(cache), 200)
}
startTime(c, 'database')
const result = await db.query.page.findFirst({
where: eq(page.id, id),
@ -70,6 +77,8 @@ export const registerPagePublic = (api: typeof pageApi) => {
},
})
endTime(c, 'database')
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
@ -81,6 +90,8 @@ export const registerPagePublic = (api: typeof pageApi) => {
changelogs: changelogs.map((log) => log.changelog),
}
return c.json(mappedResult, 200)
redis.set(id, JSON.stringify(mappedResult), { EX: 60 })
const asd = PagePublicOutput.parse(mappedResult)
return c.json(asd, 200)
})
}

View File

@ -9,6 +9,8 @@ import {
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
import { redis } from '../utils/redis'
import type { pageApi } from './index'
const route = createRoute({
@ -33,18 +35,14 @@ const route = createRoute({
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerPageUpdate = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const userId = await verifyAuthentication(c)
const { id } = c.req.valid('param')
const { changelogIds, ...rest }: z.infer<typeof PageUpdateInput> =
@ -59,6 +57,10 @@ export const registerPageUpdate = (api: typeof pageApi) => {
.where(and(eq(page.userId, userId), eq(page.id, id)))
.returning()
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
// TODO: implement transaction
if (changelogIds) {
if (changelogIds.length === 0) {
@ -79,10 +81,8 @@ export const registerPageUpdate = (api: typeof pageApi) => {
}
}
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
redis.del(id)
return c.json(result, 200)
return c.json(PageOutput.parse(result), 200)
})
}

View File

@ -0,0 +1,111 @@
import { changelog, db, page } from '@boring.tools/database'
import { StatisticOutput } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm'
import type { statisticApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
const route = createRoute({
method: 'get',
path: '/',
tags: ['statistic'],
responses: {
200: {
content: {
'application/json': { schema: StatisticOutput },
},
description: 'Return user',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerStatisticGet = (api: typeof statisticApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const pageResult = await db.query.page.findMany({
where: eq(page.userId, userId),
})
const result = await db.query.changelog.findMany({
where: eq(changelog.userId, userId),
with: {
commits: {
columns: {
id: true,
versionId: true,
},
},
versions: {
columns: {
id: true,
status: true,
},
},
},
})
const changelog_total = result.length
const version_total = result.reduce(
(acc, log) => acc + log.versions.length,
0,
)
const version_published = result.reduce(
(acc, log) =>
acc +
log.versions.filter((version) => version.status === 'published').length,
0,
)
const version_review = result.reduce(
(acc, log) =>
acc +
log.versions.filter((version) => version.status === 'review').length,
0,
)
const version_draft = result.reduce(
(acc, log) =>
acc +
log.versions.filter((version) => version.status === 'draft').length,
0,
)
const commit_total = result.reduce(
(acc, log) => acc + log.commits.length,
0,
)
const commit_unassigned = result.reduce(
(acc, log) =>
acc + log.commits.filter((commit) => !commit.versionId).length,
0,
)
const commit_assigned = result.reduce(
(acc, log) =>
acc + log.commits.filter((commit) => commit.versionId).length,
0,
)
const mappedData = {
changelog: {
total: changelog_total,
versions: {
total: version_total,
published: version_published,
review: version_review,
draft: version_draft,
},
commits: {
total: commit_total,
unassigned: commit_unassigned,
assigned: commit_assigned,
},
},
page: {
total: pageResult.length,
},
}
return c.json(StatisticOutput.parse(mappedData), 200)
})
}

View File

@ -0,0 +1,14 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import type { Variables } from '..'
import type { ContextModule } from '../utils/sentry'
import { registerStatisticGet } from './get'
export const statisticApi = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'statistic',
}
registerStatisticGet(statisticApi)
export default statisticApi

View File

@ -2,7 +2,10 @@ 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 '.'
import { verifyAuthentication } from '../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
const route = createRoute({
method: 'get',
@ -15,26 +18,22 @@ const route = createRoute({
},
description: 'Return user',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
export const registerUserGet = (api: typeof userApi) => {
return api.openapi(route, async (c) => {
const user = c.get('user')
const userId = await verifyAuthentication(c)
const result = await db.query.user.findFirst({
where: eq(userDb.id, user.id),
where: eq(userDb.id, userId),
})
if (!result) {
throw new Error('User not found')
}
return c.json(result, 200)
return c.json(UserOutput.parse(result), 200)
})
}

View File

@ -4,7 +4,9 @@ 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 '.'
import { openApiErrorResponses, openApiSecurity } from '../utils/openapi'
const route = createRoute({
method: 'post',
@ -18,19 +20,12 @@ const route = createRoute({
},
},
responses: {
200: {
content: {
'application/json': { schema: UserOutput },
},
204: {
description: 'Return success',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
const userCreate = async ({
@ -39,7 +34,7 @@ const userCreate = async ({
payload: z.infer<typeof UserWebhookInput>
}) => {
const data = {
id: payload.data.id,
providerId: payload.data.id,
name: `${payload.data.first_name} ${payload.data.last_name}`,
email: payload.data.email_addresses[0].email_address,
}
@ -50,7 +45,7 @@ const userCreate = async ({
...data,
})
.onConflictDoUpdate({
target: user.id,
target: user.providerId,
set: data,
})
@ -72,7 +67,11 @@ export const registerUserWebhook = (api: typeof userApi) => {
case 'user.created': {
const result = await userCreate({ payload: verifiedPayload })
logger.info('Clerk Webhook', result)
return c.json(result, 200)
if (result) {
return c.json({}, 204)
}
return c.json({}, 404)
}
default:
throw new HTTPException(404, { message: 'Webhook type not supported' })

View File

@ -1,4 +1,5 @@
import { access_token, db } 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 { eq } from 'drizzle-orm'
import type { Context, Next } from 'hono'
@ -24,6 +25,11 @@ const generatedToken = async (c: Context, next: Next) => {
throw new HTTPException(401, { message: 'Unauthorized' })
}
await db
.update(access_token)
.set({ lastUsedOn: new Date() })
.where(eq(access_token.id, accessTokenResult.id))
c.set('user', accessTokenResult.user)
await next()
@ -31,7 +37,7 @@ const generatedToken = async (c: Context, next: Next) => {
export const authentication = some(generatedToken, clerkMiddleware())
export const verifyAuthentication = (c: Context) => {
export const verifyAuthentication = async (c: Context) => {
const auth = getAuth(c)
if (!auth?.userId) {
const accessTokenUser = c.get('user')
@ -40,5 +46,16 @@ export const verifyAuthentication = (c: Context) => {
}
return accessTokenUser.id
}
return auth.userId
const [userEntry] = await db
.select()
.from(user)
.where(eq(user.providerId, auth.userId))
if (!userEntry) {
logger.error('User not found - Unauthorized', { providerId: auth.userId })
throw new HTTPException(401, { message: 'Unauthorized' })
}
return userEntry.id
}

View File

@ -0,0 +1,26 @@
import { type RouteConfig, createRoute as route } from '@hono/zod-openapi'
import { openApiErrorResponses } from './openapi'
type CreateRoute = {
method: 'get' | 'post' | 'put' | 'delete'
tags: string[]
description: string
path: string
responses: RouteConfig['responses']
request?: RouteConfig['request']
}
export const createRoute = (input: CreateRoute) =>
route({
...input,
responses: {
...input.responses,
...openApiErrorResponses,
},
security: [
{
Clerk: [],
AccessToken: [],
},
],
})

View File

@ -0,0 +1,113 @@
import type { CommitOutput } from '@boring.tools/schema'
import type { z } from '@hono/zod-openapi'
import { convert } from 'convert-gitmoji'
import { type GitCommit, parseCommits } from './parseCommit'
export const config = {
types: {
feat: { title: '🚀 Enhancements', semver: 'minor' },
perf: { title: '🔥 Performance', semver: 'patch' },
fix: { title: '🩹 Fixes', semver: 'patch' },
refactor: { title: '💅 Refactors', semver: 'patch' },
docs: { title: '📖 Documentation', semver: 'patch' },
build: { title: '📦 Build', semver: 'patch' },
types: { title: '🌊 Types', semver: 'patch' },
chore: { title: '🏡 Chore' },
examples: { title: '🏀 Examples' },
test: { title: '✅ Tests' },
style: { title: '🎨 Styles' },
ci: { title: '🤖 CI' },
},
}
type Commit = z.infer<typeof CommitOutput>
export const commitsToMarkdown = async (commits: Commit[]) => {
const parsedCommits = await parseCommits(commits)
const typeGroups: Record<string, GitCommit[]> = groupBy(parsedCommits, 'type')
const markdown: string[] = []
const breakingChanges = []
for (const type in config.types) {
const group = typeGroups[type]
if (!group || group.length === 0) {
continue
}
if (type in config.types) {
markdown.push(
'',
`### ${config.types[type as keyof typeof config.types].title}`,
'',
)
for (const commit of group.reverse()) {
const line = formatCommit(commit)
markdown.push(line)
if (commit.isBreaking) {
breakingChanges.push(line)
}
}
}
}
if (breakingChanges.length > 0) {
markdown.push('', '#### ⚠️ Breaking Changes', '', ...breakingChanges)
}
const _authors = new Map<string, { email: Set<string>; github?: string }>()
for (const commit of commits) {
if (!commit.author) {
continue
}
const name = formatName(commit.author.name)
if (!name || name.includes('[bot]')) {
continue
}
if (_authors.has(name)) {
const entry = _authors.get(name)
entry?.email.add(commit.author.email)
} else {
_authors.set(name, { email: new Set([commit.author.email]) })
}
}
const authors = [..._authors.entries()].map((e) => ({ name: e[0], ...e[1] }))
if (authors.length > 0) {
markdown.push(
'',
'### ' + '❤️ Contributors',
'',
...authors.map((i) => {
return `- ${i.name} ${[...i.email]}`
}),
)
}
return convert(markdown.join('\n').trim(), true)
}
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
function groupBy(items: any[], key: string): Record<string, any[]> {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
const groups: Record<string, any[]> = {}
for (const item of items) {
groups[item[key]] = groups[item[key]] || []
groups[item[key]].push(item)
}
return groups
}
function formatCommit(commit: GitCommit) {
return `- ${commit.scope ? `**${commit.scope.trim()}:** ` : ''}${
commit.isBreaking ? '⚠️ ' : ''
}${commit.description}`
}
function formatName(name = '') {
return name
.split(' ')
.map((p) => p.trim())
.join(' ')
}

View File

@ -0,0 +1,73 @@
import type { CommitOutput } from '@boring.tools/schema'
import type { z } from '@hono/zod-openapi'
export interface GitCommitAuthor {
name: string
email: string
}
export interface GitCommit {
description: string
type: string
scope: string
authors: GitCommitAuthor[]
isBreaking: boolean
}
type Commit = z.infer<typeof CommitOutput>
export function parseCommits(commits: Commit[]): GitCommit[] {
return commits.map((commit) => parseGitCommit(commit)).filter(Boolean)
}
// https://www.conventionalcommits.org/en/v1.0.0/
// https://regex101.com/r/FSfNvA/1
const ConventionalCommitRegex =
/(?<emoji>:.+:|(\uD83C[\uDF00-\uDFFF])|(\uD83D[\uDC00-\uDE4F\uDE80-\uDEFF])|[\u2600-\u2B55])?( *)?(?<type>[a-z]+)(\((?<scope>.+)\))?(?<breaking>!)?: (?<description>.+)/i
const CoAuthoredByRegex = /co-authored-by:\s*(?<name>.+)(<(?<email>.+)>)/gim
export function parseGitCommit(commit: Commit): GitCommit | null {
const match = commit.subject.match(ConventionalCommitRegex)
if (!match) {
return {
...commit,
authors: [],
description: '',
type: 'none',
scope: 'none',
isBreaking: false,
}
}
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const type = match.groups?.['type'] ?? ''
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const scope = match.groups?.['scope'] ?? ''
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const isBreaking = Boolean(match.groups?.['breaking'])
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
const description = match.groups?.['description'] ?? ''
// Find all authors
const authors: GitCommitAuthor[] = [commit.author]
if (commit?.body) {
for (const match of commit.body.matchAll(CoAuthoredByRegex)) {
authors.push({
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
name: (match.groups?.['name'] ?? '').trim(),
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
email: (match.groups?.['email'] ?? '').trim(),
})
}
}
return {
authors,
description,
type,
scope,
isBreaking,
}
}

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

@ -0,0 +1,68 @@
import { createErrorSchema } from './errors'
export const openApiSecurity = {
security: [
{
Clerk: [],
AccessToken: [],
},
],
}
export const openApiErrorResponses = {
400: {
description:
'The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing).',
content: {
'application/json': {
schema: createErrorSchema('BAD_REQUEST').openapi('ErrBadRequest'),
},
},
},
401: {
description:
'The client must authenticate itself to get the requested response.',
content: {
'application/json': {
schema: createErrorSchema('UNAUTHORIZED').openapi('ErrUnauthorized'),
},
},
},
403: {
description:
'The client does not have the necessary permissions to access the resource.',
content: {
'application/json': {
schema: createErrorSchema('FORBIDDEN').openapi('ErrForbidden'),
},
},
},
404: {
description: "The server can't find the requested resource.",
content: {
'application/json': {
schema: createErrorSchema('NOT_FOUND').openapi('ErrNotFound'),
},
},
},
409: {
description:
'The request could not be completed due to a conflict mainly due to unique constraints.',
content: {
'application/json': {
schema: createErrorSchema('CONFLICT').openapi('ErrConflict'),
},
},
},
500: {
description:
"The server has encountered a situation it doesn't know how to handle.",
content: {
'application/json': {
schema: createErrorSchema('INTERNAL_SERVER_ERROR').openapi(
'ErrInternalServerError',
),
},
},
},
}

View File

@ -0,0 +1,17 @@
import { createClient } from 'redis'
const getRedisOptions = () => {
if (import.meta.env.NODE_ENV === 'production') {
return {
password: import.meta.env.REDIS_PASSWORD,
url: import.meta.env.REDIS_URL,
}
}
return {
url: import.meta.env.REDIS_URL,
}
}
export const redis = createClient(getRedisOptions())
redis.on('error', (err) => console.log('Redis Client Error', err))
await redis.connect()

View File

@ -5,6 +5,8 @@ import { logger } from '@boring.tools/logger'
declare module 'bun' {
interface Env {
POSTGRES_URL: string
REDIS_PASSWORD: string
REDIS_URL: string
CLERK_WEBHOOK_SECRET: string
CLERK_SECRET_KEY: string
CLERK_PUBLISHABLE_KEY: string
@ -12,7 +14,7 @@ declare module 'bun' {
}
}
const TEST_VARIABLES = ['POSTGRES_URL']
const TEST_VARIABLES = ['POSTGRES_URL', 'REDIS_URL', 'REDIS_PASSWORD']
const DEVELOPMENT_VARIABLES = [
...TEST_VARIABLES,

View File

@ -20,12 +20,14 @@
"@tailwindcss/typography": "^0.5.15",
"@tanstack/react-query": "^5.59.0",
"@tanstack/react-router": "^1.58.15",
"@tanstack/react-table": "^8.20.5",
"axios": "^1.7.7",
"lucide-react": "^0.446.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"zod": "^3.23.8"
},
"devDependencies": {

12
apps/app/public/apple.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="80px" height="80px" viewBox="-1.5 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-102.000000, -7439.000000)" fill="#71717a">
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M57.5708873,7282.19296 C58.2999598,7281.34797 58.7914012,7280.17098 58.6569121,7279 C57.6062792,7279.04 56.3352055,7279.67099 55.5818643,7280.51498 C54.905374,7281.26397 54.3148354,7282.46095 54.4735932,7283.60894 C55.6455696,7283.69593 56.8418148,7283.03894 57.5708873,7282.19296 M60.1989864,7289.62485 C60.2283111,7292.65181 62.9696641,7293.65879 63,7293.67179 C62.9777537,7293.74279 62.562152,7295.10677 61.5560117,7296.51675 C60.6853718,7297.73474 59.7823735,7298.94772 58.3596204,7298.97372 C56.9621472,7298.99872 56.5121648,7298.17973 54.9134635,7298.17973 C53.3157735,7298.17973 52.8162425,7298.94772 51.4935978,7298.99872 C50.1203933,7299.04772 49.0738052,7297.68074 48.197098,7296.46676 C46.4032359,7293.98379 45.0330649,7289.44985 46.8734421,7286.3899 C47.7875635,7284.87092 49.4206455,7283.90793 51.1942837,7283.88393 C52.5422083,7283.85893 53.8153044,7284.75292 54.6394294,7284.75292 C55.4635543,7284.75292 57.0106846,7283.67793 58.6366882,7283.83593 C59.3172232,7283.86293 61.2283842,7284.09893 62.4549652,7285.8199 C62.355868,7285.8789 60.1747177,7287.09489 60.1989864,7289.62485" id="apple-[#173]">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="80px" height="80px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-60.000000, -7439.000000)" fill="#71717a">
<g transform="translate(56.000000, 160.000000)">
<path d="M13.1458647,7289.43426 C13.1508772,7291.43316 13.1568922,7294.82929 13.1619048,7297.46884 C16.7759398,7297.95757 20.3899749,7298.4613 23.997995,7299 C23.997995,7295.84873 24.002005,7292.71146 23.997995,7289.71311 C20.3809524,7289.71311 16.7649123,7289.43426 13.1458647,7289.43426 M4,7289.43526 L4,7296.22153 C6.72581454,7296.58933 9.45162907,7296.94113 12.1724311,7297.34291 C12.1774436,7294.71736 12.1704261,7292.0908 12.1704261,7289.46524 C9.44661654,7289.47024 6.72380952,7289.42627 4,7289.43526 M4,7281.84344 L4,7288.61071 C6.72581454,7288.61771 9.45162907,7288.57673 12.1774436,7288.57973 C12.1754386,7285.96017 12.1754386,7283.34361 12.1724311,7280.72405 C9.44461153,7281.06486 6.71679198,7281.42567 4,7281.84344 M24,7288.47179 C20.3879699,7288.48578 16.7759398,7288.54075 13.1619048,7288.55175 C13.1598997,7285.88921 13.1598997,7283.22967 13.1619048,7280.56914 C16.7689223,7280.01844 20.3839599,7279.50072 23.997995,7279 C24,7282.15826 23.997995,7285.31353 24,7288.47179">
</path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,72 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@boring.tools/ui'
import { Trash2Icon } from 'lucide-react'
import { useState } from 'react'
import { useAccessTokenDelete } from '../../hooks/useAccessToken'
export const AccessTokenDelete = ({ id }: { id: string }) => {
const accessTokenDelete = useAccessTokenDelete()
const [isOpen, setIsOpen] = useState(false)
const removeChangelog = () => {
accessTokenDelete.mutate(
{ id },
{
onSuccess: () => {
setIsOpen(false)
},
},
)
}
return (
<Tooltip>
<AlertDialog open={isOpen}>
<AlertDialogTrigger asChild>
<TooltipTrigger asChild>
<Button
variant={'ghost-destructive'}
onClick={() => setIsOpen(true)}
>
<Trash2Icon strokeWidth={1.5} />
</Button>
</TooltipTrigger>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
access token and remove your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setIsOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={removeChangelog} variant={'destructive'}>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TooltipContent>
<p>Delete access token</p>
</TooltipContent>
</Tooltip>
)
}

View File

@ -0,0 +1,36 @@
import type { AccessTokenOutput } from '@boring.tools/schema'
import type { ColumnDef } from '@tanstack/react-table'
import { format } from 'date-fns'
import type { z } from 'zod'
import { AccessTokenDelete } from '../Delete'
export const AccessTokenColumns: ColumnDef<
z.infer<typeof AccessTokenOutput>
>[] = [
{
accessorKey: 'name',
header: 'Name',
},
{
accessorKey: 'lastUsedOn',
header: 'Last seen',
accessorFn: (row) => {
if (!row.lastUsedOn) {
return 'Never'
}
return format(new Date(row.lastUsedOn), 'HH:mm dd.MM.yyyy')
},
},
{
accessorKey: 'token',
header: 'Token',
accessorFn: (row) => `${row.token}...`,
},
{
accessorKey: 'id',
header: '',
size: 20,
maxSize: 20,
cell: (props) => <AccessTokenDelete id={props.row.original.id} />,
},
]

View File

@ -0,0 +1,86 @@
'use client'
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@boring.tools/ui'
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}

View File

@ -0,0 +1,44 @@
import { Card, CardContent, CardHeader, CardTitle } from '@boring.tools/ui'
import { useParams } from '@tanstack/react-router'
import { format } from 'date-fns'
import { useChangelogCommitList } from '../../hooks/useChangelog'
export const ChangelogCommitList = () => {
const { id } = useParams({ from: '/changelog/$id' })
const { data } = useChangelogCommitList({ id, limit: 50 })
if (data) {
return (
<Card className="w-full max-w-screen-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Commits ({data.length})</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-1">
{data?.map((commit) => {
return (
<div
className="hover:bg-muted py-1 px-2 rounded transition flex gap-2 items-center"
key={commit.id}
>
<span className="font-mono font-bold text-muted-foreground">
{commit.commit}
</span>
<p className="w-full">{commit.subject}</p>
<span className="text-xs">
{format(new Date(commit.author.date), 'dd.MM.yyyy')}
</span>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
return <div className="flex flex-col gap-5">Not found </div>
}

View File

@ -0,0 +1,29 @@
import { Link, useParams } from '@tanstack/react-router'
import { HandIcon, WorkflowIcon } from 'lucide-react'
export const ChangelogVersionCreateStep01 = ({
nextStep,
}: { nextStep: () => void }) => {
const { id } = useParams({ from: '/changelog/$id' })
return (
<div className="flex gap-10 mt-3">
<button
type="button"
className="flex-col hover:border-accent border rounded border-muted p-5 flex items-center justify-center w-full"
onClick={nextStep}
>
<WorkflowIcon />
Automatic
</button>
<Link
className="flex-col hover:border-accent border rounded border-muted p-5 flex items-center justify-center w-full"
to="/changelog/$id/versionCreate"
params={{ id }}
>
<HandIcon />
Manual
</Link>
</div>
)
}

View File

@ -0,0 +1,74 @@
import { VersionCreateAutoInput } from '@boring.tools/schema'
import {
Button,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@boring.tools/ui'
import { zodResolver } from '@hookform/resolvers/zod'
import { useNavigate, useParams } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import type { z } from 'zod'
import { useChangelogVersionCreateAuto } from '../../../../hooks/useChangelog'
export const ChangelogVersionCreateStep02 = () => {
const { id } = useParams({ from: '/changelog/$id' })
const navigate = useNavigate({ from: `/changelog/${id}` })
const autoVersion = useChangelogVersionCreateAuto()
const form = useForm<z.infer<typeof VersionCreateAutoInput>>({
resolver: zodResolver(VersionCreateAutoInput),
defaultValues: {
changelogId: id,
version: '',
},
})
const onSubmit = (values: z.infer<typeof VersionCreateAutoInput>) => {
autoVersion.mutate(values, {
onSuccess(data) {
navigate({
to: '/changelog/$id/version/$versionId',
params: { id, versionId: data.id },
})
},
})
}
return (
<div className="flex gap-10 mt-3">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 w-full"
>
<FormField
control={form.control}
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<FormControl>
<Input placeholder="v1.0.1" {...field} autoFocus />
</FormControl>{' '}
<FormDescription>
Leave blank for auto generating.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-5 mt-5 w-full justify-end items-end md:col-span-6">
<Button type="submit">Create</Button>
</div>
</form>
</Form>
</div>
)
}

View File

@ -0,0 +1,45 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@boring.tools/ui'
import { PlusCircleIcon } from 'lucide-react'
import { useEffect, useState } from 'react'
import { ChangelogVersionCreateStep01 } from './Step01'
import { ChangelogVersionCreateStep02 } from './Step02'
export const ChangelogVersionCreate = () => {
const [isOpen, setIsOpen] = useState(false)
const [step, setStep] = useState(1)
const nextStep = () => setStep((prev) => prev + 1)
useEffect(() => {
if (!isOpen) {
setStep(1)
}
}, [isOpen])
return (
<Dialog open={isOpen} onOpenChange={(state) => setIsOpen(state)}>
<DialogTrigger>
<PlusCircleIcon />
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>How would you like to create your version?</DialogTitle>
<DialogDescription>
You can create your version manually. You have to make every entry
yourself. However, if you want to create your changelog attachment
from your commit messages, select the automatic option.
</DialogDescription>
</DialogHeader>
{step === 1 && <ChangelogVersionCreateStep01 nextStep={nextStep} />}
{step === 2 && <ChangelogVersionCreateStep02 />}
</DialogContent>
</Dialog>
)
}

View File

@ -38,7 +38,7 @@ export const ChangelogVersionDelete = ({
)
}
return (
<Alert className="mt-10 max-w-screen-md" variant={'destructive'}>
<Alert className="mt-10 w-full" variant={'destructive'}>
<TriangleAlertIcon className="h-4 w-4" />
<AlertTitle>Danger Zone</AlertTitle>
<AlertDescription className="inline-flex flex-col gap-3">

View File

@ -0,0 +1,46 @@
import { Card, CardContent, CardHeader, CardTitle } from '@boring.tools/ui'
import { Link, useParams } from '@tanstack/react-router'
import { useChangelogById } from '../../hooks/useChangelog'
import { ChangelogVersionCreate } from './Version/Create'
import { VersionStatus } from './VersionStatus'
export const ChangelogVersionList = () => {
const { id } = useParams({ from: '/changelog/$id' })
const { data } = useChangelogById({ id })
if (data) {
return (
<Card className="w-full max-w-screen-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Versions ({data.versions?.length})</CardTitle>
<ChangelogVersionCreate />
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-1">
{data.versions?.map((version) => {
return (
<Link
className="hover:bg-muted py-1 px-2 rounded transition flex gap-2 items-center"
to="/changelog/$id/version/$versionId"
params={{
id,
versionId: version.id,
}}
key={version.id}
>
<VersionStatus status={version.status} />
{version.version}
</Link>
)
})}
</div>
</CardContent>
</Card>
)
}
return <div className="flex flex-col gap-5">Not found</div>
}

View File

@ -1,97 +0,0 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@boring.tools/ui'
import { Link } from '@tanstack/react-router'
import { BellIcon } from 'lucide-react'
import { NavigationRoutes } from '../utils/navigation-routes'
export const Navigation = () => {
return (
<div className="hidden border-r bg-muted/40 md:block">
<div className="flex h-full max-h-screen flex-col gap-2">
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
<Link to="/" className="flex items-center gap-2 font-semibold">
<span className="">boring.tools</span>
</Link>
<Button variant="outline" size="icon" className="ml-auto h-8 w-8">
<BellIcon className="h-4 w-4" />
<span className="sr-only">Toggle notifications</span>
</Button>
</div>
<div className="flex-1">
<nav className="grid items-start px-2 text-sm font-medium lg:px-4 gap-2">
{NavigationRoutes.map((route) => {
if (!route.childrens) {
return (
<Link
key={route.name}
to={route.to}
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary"
activeProps={{ className: 'bg-muted text-primary' }}
>
<route.icon className="h-4 w-4" />
{route.name}
</Link>
)
}
return (
<Accordion
type="single"
collapsible
key={route.name}
defaultValue="changelog"
>
<AccordionItem value="changelog">
<AccordionTrigger>Changelog</AccordionTrigger>
<AccordionContent className="gap-2 flex flex-col">
{route.childrens.map((childRoute) => (
<Link
key={childRoute.name}
to={childRoute.to}
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary"
activeProps={{ className: 'bg-muted text-primary' }}
activeOptions={{ exact: true }}
>
<childRoute.icon className="h-4 w-4" />
{childRoute.name}
</Link>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
)
})}
</nav>
</div>
<div className="mt-auto p-4">
<Card>
<CardHeader className="p-2 pt-0 md:p-4">
<CardTitle>More Infos</CardTitle>
<CardDescription>
If you want more information about boring.tools, visit our
documentation!
</CardDescription>
</CardHeader>
<CardContent className="p-2 pt-0 md:p-4 md:pt-0">
<a href="https://boring.tools">
<Button size="sm" className="w-full">
Documentation
</Button>
</a>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@ -1,102 +0,0 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Sheet,
SheetContent,
SheetTrigger,
} from '@boring.tools/ui'
import { Link } from '@tanstack/react-router'
import { MenuIcon } from 'lucide-react'
import { NavigationRoutes } from '../utils/navigation-routes'
export const NavigationMobile = () => {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0 md:hidden">
<MenuIcon className="h-5 w-5" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="flex flex-col">
<nav className="grid gap-2 text-lg font-medium">
<Link
to="/"
className="flex items-center gap-2 text-lg font-semibold"
>
<span className="sr-only">boring.tools</span>
</Link>
{NavigationRoutes.map((route) => {
if (!route.childrens) {
return (
<Link
key={route.name}
to={route.to}
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary"
activeProps={{ className: 'bg-muted text-primary' }}
activeOptions={{ exact: true }}
>
<route.icon className="h-4 w-4" />
{route.name}
</Link>
)
}
return (
<Accordion
type="single"
collapsible
key={route.name}
defaultValue="changelog"
>
<AccordionItem value="changelog">
<AccordionTrigger>Changelog</AccordionTrigger>
<AccordionContent className="gap-2 flex flex-col">
{route.childrens.map((childRoute) => (
<Link
key={childRoute.name}
to={childRoute.to}
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-all hover:text-primary"
activeProps={{ className: 'bg-muted text-primary' }}
activeOptions={{ exact: true }}
>
<childRoute.icon className="h-4 w-4" />
{childRoute.name}
</Link>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
)
})}
</nav>
<div className="mt-auto">
<Card>
<CardHeader className="p-2 pt-0 md:p-4">
<CardTitle>More Infos</CardTitle>
<CardDescription>
If you want more information about boring.tools, visit our
documentation!
</CardDescription>
</CardHeader>
<CardContent className="p-2 pt-0 md:p-4 md:pt-0">
<a href="https://boring.tools">
<Button size="sm" className="w-full">
Documentation
</Button>
</a>
</CardContent>
</Card>
</div>
</SheetContent>
</Sheet>
)
}

View File

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

View File

@ -34,10 +34,8 @@ export function Sidebar() {
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
<SidebarChangelog />
<SidebarPage />
</SidebarMenu>
<SidebarChangelog />
<SidebarPage />
</SidebarGroup>
</SidebarContent>
<SidebarFooter>

View File

@ -2,6 +2,7 @@ import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
@ -9,68 +10,91 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@boring.tools/ui'
import { Link } from '@tanstack/react-router'
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRightIcon, FileStackIcon, PlusIcon } from 'lucide-react'
import { useEffect } from 'react'
import { useLocalStorage } from 'usehooks-ts'
import { useChangelogList } from '../hooks/useChangelog'
export const SidebarChangelog = () => {
const { data, error } = useChangelogList()
const location = useLocation()
const [value, setValue] = useLocalStorage('sidebar-changelog-open', false)
const { data, error, isLoading } = useChangelogList()
useEffect(() => {
const firstElement = location.href.split('/')[1]
if (firstElement === 'changelog') {
setValue(true)
}
}, [location, setValue])
return (
<Collapsible asChild>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Changelog">
<Link
to="/changelog"
activeProps={{ className: 'bg-sidebar-accent' }}
>
<FileStackIcon />
<span>Changelog</span>
</Link>
</SidebarMenuButton>
<SidebarMenu>
<Collapsible asChild open={value} onOpenChange={() => setValue(!value)}>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Changelog">
<Link
to="/changelog"
activeProps={{ className: 'bg-sidebar-accent' }}
>
<FileStackIcon />
<span>Changelog</span>
</Link>
</SidebarMenuButton>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRightIcon />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{!error &&
data?.map((changelog) => (
<SidebarMenuSubItem key={changelog.id}>
<SidebarMenuSubButton asChild>
<Link
to={`/changelog/${changelog.id}`}
activeProps={{
className: 'bg-sidebar-primary',
}}
>
<span>{changelog.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRightIcon />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{isLoading && !data && (
<div className="flex flex-col gap-1 animate-pulse">
<SidebarMenuSubItem>
<div className="w-[100px] h-[20px] bg-border rounded" />
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<div className="w-[130px] h-[20px] bg-border rounded" />
</SidebarMenuSubItem>
</div>
)}
{!error &&
data?.map((changelog) => (
<SidebarMenuSubItem key={changelog.id}>
<SidebarMenuSubButton asChild>
<Link
to={`/changelog/${changelog.id}`}
activeProps={{
className: 'bg-sidebar-primary',
}}
>
<span>{changelog.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
<SidebarMenuSubItem className="opacity-60">
<SidebarMenuSubButton asChild>
<Link
to="/changelog/create"
activeProps={{
className: 'bg-sidebar-primary',
}}
>
<span className="flex items-center gap-1">
<PlusIcon className="w-3 h-3" />
New changelog
</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<SidebarMenuSubItem className="opacity-60">
<SidebarMenuSubButton asChild>
<Link
to="/changelog/create"
activeProps={{
className: 'bg-sidebar-primary',
}}
>
<span className="flex items-center gap-1">
<PlusIcon className="w-3 h-3" />
New changelog
</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
)
}

View File

@ -2,6 +2,7 @@ import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
@ -9,65 +10,88 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@boring.tools/ui'
import { Link } from '@tanstack/react-router'
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRightIcon, NotebookTextIcon, PlusIcon } from 'lucide-react'
import { useEffect } from 'react'
import { useLocalStorage } from 'usehooks-ts'
import { usePageList } from '../hooks/usePage'
export const SidebarPage = () => {
const { data, error } = usePageList()
const location = useLocation()
const [value, setValue] = useLocalStorage('sidebar-page-open', false)
const { data, error, isLoading } = usePageList()
useEffect(() => {
const firstElement = location.href.split('/')[1]
if (firstElement === 'page') {
setValue(true)
}
}, [location, setValue])
return (
<Collapsible asChild>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Page">
<Link to="/page" activeProps={{ className: 'bg-sidebar-accent' }}>
<NotebookTextIcon />
<span>Page</span>
</Link>
</SidebarMenuButton>
<SidebarMenu>
<Collapsible asChild open={value} onOpenChange={() => setValue(!value)}>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Page">
<Link to="/page" activeProps={{ className: 'bg-sidebar-accent' }}>
<NotebookTextIcon />
<span>Page</span>
</Link>
</SidebarMenuButton>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRightIcon />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{!error &&
data?.map((page) => (
<SidebarMenuSubItem key={page.id}>
<SidebarMenuSubButton asChild>
<Link
to={`/page/${page?.id}`}
activeProps={{
className: 'bg-sidebar-primary',
}}
>
<span>{page.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRightIcon />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{isLoading && !data && (
<div className="flex flex-col gap-1 animate-pulse">
<SidebarMenuSubItem>
<div className="w-[100px] h-[20px] bg-border rounded" />
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<div className="w-[130px] h-[20px] bg-border rounded" />
</SidebarMenuSubItem>
</div>
)}
{!error &&
data?.map((page) => (
<SidebarMenuSubItem key={page.id}>
<SidebarMenuSubButton asChild>
<Link
to={`/page/${page?.id}`}
activeProps={{
className: 'bg-sidebar-primary',
}}
>
<span>{page.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
<SidebarMenuSubItem className="opacity-60">
<SidebarMenuSubButton asChild>
<Link
to="/page/create"
activeProps={{
className: 'bg-sidebar-primary',
}}
>
<span className="flex items-center gap-1">
<PlusIcon className="w-3 h-3" />
New page
</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<SidebarMenuSubItem className="opacity-60">
<SidebarMenuSubButton asChild>
<Link
to="/page/create"
activeProps={{
className: 'bg-sidebar-primary',
}}
>
<span className="flex items-center gap-1">
<PlusIcon className="w-3 h-3" />
New page
</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</SidebarMenu>
)
}

View File

@ -1,4 +1,4 @@
import { ChevronsUpDown } from 'lucide-react'
import { ChevronsUpDown, KeyRoundIcon, TerminalIcon } from 'lucide-react'
import {
Avatar,
@ -14,6 +14,7 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
useSidebar,
} from '@boring.tools/ui'
import { SignOutButton, useUser } from '@clerk/clerk-react'
@ -25,6 +26,26 @@ export function SidebarUser() {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Access Tokens">
<Link
to="/access-tokens"
activeProps={{ className: 'bg-sidebar-accent' }}
>
<KeyRoundIcon />
<span>Access Tokens</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Access Tokens">
<Link to="/cli" activeProps={{ className: 'bg-sidebar-accent' }}>
<TerminalIcon />
<span>CLI</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarSeparator />
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@ -0,0 +1,67 @@
import type {
AccessTokenCreateInput,
AccessTokenListOutput,
AccessTokenOutput,
} 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 AccessToken = z.infer<typeof AccessTokenOutput>
type AccessTokenList = z.infer<typeof AccessTokenListOutput>
type AccessTokenCreate = z.infer<typeof AccessTokenCreateInput>
export const useAccessTokenList = () => {
const { getToken } = useAuth()
return useQuery({
queryKey: ['accessTokenList'],
queryFn: async (): Promise<AccessTokenList> =>
await queryFetch({
path: 'access-token',
method: 'get',
token: await getToken(),
}),
})
}
export const useAccessTokenCreate = () => {
const { getToken } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (
payload: AccessTokenCreate,
): Promise<Readonly<AccessToken>> =>
await queryFetch({
path: 'access-token',
data: payload,
method: 'post',
token: await getToken(),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accessTokenList'] })
},
})
}
export const useAccessTokenDelete = () => {
const { getToken } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
id,
}: { id: string }): Promise<Readonly<AccessToken>> =>
await queryFetch({
path: `access-token/${id}`,
method: 'delete',
token: await getToken(),
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['accessTokenList'],
})
},
})
}

View File

@ -2,6 +2,8 @@ import type {
ChangelogCreateInput,
ChangelogOutput,
ChangelogUpdateInput,
CommitOutput,
VersionCreateAutoInput,
VersionCreateInput,
VersionOutput,
VersionUpdateInput,
@ -17,8 +19,11 @@ type ChangelogUpdate = z.infer<typeof ChangelogUpdateInput>
type Version = z.infer<typeof VersionOutput>
type VersionCreate = z.infer<typeof VersionCreateInput>
type VersionCreateAuto = z.infer<typeof VersionCreateAutoInput>
type VersionUpdate = z.infer<typeof VersionUpdateInput>
type Commit = z.infer<typeof CommitOutput>
export const useChangelogList = () => {
const { getToken } = useAuth()
return useQuery({
@ -32,6 +37,23 @@ export const useChangelogList = () => {
})
}
export const useChangelogCommitList = ({
id,
limit,
offset,
}: { id: string; limit?: number; offset?: number }) => {
const { getToken } = useAuth()
return useQuery({
queryKey: ['changelogCommitList'],
queryFn: async (): Promise<ReadonlyArray<Commit>> =>
await queryFetch({
path: `changelog/commit?changelogId=${id}&limit=${limit}&offset=${offset}`,
method: 'get',
token: await getToken(),
}),
})
}
export const useChangelogById = ({ id }: { id: string }) => {
const { getToken } = useAuth()
@ -135,6 +157,29 @@ export const useChangelogVersionCreate = () => {
})
}
export const useChangelogVersionCreateAuto = () => {
const { getToken } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (
payload: VersionCreateAuto,
): Promise<Readonly<Version>> =>
await queryFetch({
path: 'changelog/version/auto',
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()

View File

@ -0,0 +1,20 @@
import type { StatisticOutput } 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 Statistic = z.infer<typeof StatisticOutput>
export const useStatistic = () => {
const { getToken } = useAuth()
return useQuery({
queryKey: ['statistic'],
queryFn: async (): Promise<Readonly<Statistic>> =>
await queryFetch({
path: 'statistic',
method: 'get',
token: await getToken(),
}),
})
}

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 */
// @ts-nocheck
// 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'
@ -17,14 +17,17 @@ import { Route as ChangelogIdVersionVersionIdImport } from './routes/changelog.$
// Create Virtual Routes
const CliLazyImport = createFileRoute('/cli')()
const IndexLazyImport = createFileRoute('/')()
const UserIndexLazyImport = createFileRoute('/user/')()
const PageIndexLazyImport = createFileRoute('/page/')()
const ChangelogIndexLazyImport = createFileRoute('/changelog/')()
const AccessTokensIndexLazyImport = createFileRoute('/access-tokens/')()
const PageCreateLazyImport = createFileRoute('/page/create')()
const PageIdLazyImport = createFileRoute('/page/$id')()
const ChangelogCreateLazyImport = createFileRoute('/changelog/create')()
const ChangelogIdLazyImport = createFileRoute('/changelog/$id')()
const AccessTokensNewLazyImport = createFileRoute('/access-tokens/new')()
const PageIdIndexLazyImport = createFileRoute('/page/$id/')()
const ChangelogIdIndexLazyImport = createFileRoute('/changelog/$id/')()
const PageIdEditLazyImport = createFileRoute('/page/$id/edit')()
@ -35,39 +38,60 @@ const ChangelogIdEditLazyImport = createFileRoute('/changelog/$id/edit')()
// Create/Update Routes
const CliLazyRoute = CliLazyImport.update({
id: '/cli',
path: '/cli',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/cli.lazy').then((d) => d.Route))
const IndexLazyRoute = IndexLazyImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
const UserIndexLazyRoute = UserIndexLazyImport.update({
id: '/user/',
path: '/user/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/user/index.lazy').then((d) => d.Route))
const PageIndexLazyRoute = PageIndexLazyImport.update({
id: '/page/',
path: '/page/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.index.lazy').then((d) => d.Route))
const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({
id: '/changelog/',
path: '/changelog/',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/changelog.index.lazy').then((d) => d.Route),
)
const AccessTokensIndexLazyRoute = AccessTokensIndexLazyImport.update({
id: '/access-tokens/',
path: '/access-tokens/',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/access-tokens.index.lazy').then((d) => d.Route),
)
const PageCreateLazyRoute = PageCreateLazyImport.update({
id: '/page/create',
path: '/page/create',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.create.lazy').then((d) => d.Route))
const PageIdLazyRoute = PageIdLazyImport.update({
id: '/page/$id',
path: '/page/$id',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.$id.lazy').then((d) => d.Route))
const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({
id: '/changelog/create',
path: '/changelog/create',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
@ -75,11 +99,21 @@ const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({
)
const ChangelogIdLazyRoute = ChangelogIdLazyImport.update({
id: '/changelog/$id',
path: '/changelog/$id',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/changelog.$id.lazy').then((d) => d.Route))
const AccessTokensNewLazyRoute = AccessTokensNewLazyImport.update({
id: '/access-tokens/new',
path: '/access-tokens/new',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/access-tokens.new.lazy').then((d) => d.Route),
)
const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({
id: '/',
path: '/',
getParentRoute: () => PageIdLazyRoute,
} as any).lazy(() =>
@ -87,6 +121,7 @@ const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({
)
const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({
id: '/',
path: '/',
getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() =>
@ -94,12 +129,14 @@ const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({
)
const PageIdEditLazyRoute = PageIdEditLazyImport.update({
id: '/edit',
path: '/edit',
getParentRoute: () => PageIdLazyRoute,
} as any).lazy(() => import('./routes/page.$id.edit.lazy').then((d) => d.Route))
const ChangelogIdVersionCreateLazyRoute =
ChangelogIdVersionCreateLazyImport.update({
id: '/versionCreate',
path: '/versionCreate',
getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() =>
@ -107,6 +144,7 @@ const ChangelogIdVersionCreateLazyRoute =
)
const ChangelogIdEditLazyRoute = ChangelogIdEditLazyImport.update({
id: '/edit',
path: '/edit',
getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() =>
@ -115,6 +153,7 @@ const ChangelogIdEditLazyRoute = ChangelogIdEditLazyImport.update({
const ChangelogIdVersionVersionIdRoute =
ChangelogIdVersionVersionIdImport.update({
id: '/version/$versionId',
path: '/version/$versionId',
getParentRoute: () => ChangelogIdLazyRoute,
} as any)
@ -130,6 +169,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexLazyImport
parentRoute: typeof rootRoute
}
'/cli': {
id: '/cli'
path: '/cli'
fullPath: '/cli'
preLoaderRoute: typeof CliLazyImport
parentRoute: typeof rootRoute
}
'/access-tokens/new': {
id: '/access-tokens/new'
path: '/access-tokens/new'
fullPath: '/access-tokens/new'
preLoaderRoute: typeof AccessTokensNewLazyImport
parentRoute: typeof rootRoute
}
'/changelog/$id': {
id: '/changelog/$id'
path: '/changelog/$id'
@ -158,6 +211,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PageCreateLazyImport
parentRoute: typeof rootRoute
}
'/access-tokens/': {
id: '/access-tokens/'
path: '/access-tokens'
fullPath: '/access-tokens'
preLoaderRoute: typeof AccessTokensIndexLazyImport
parentRoute: typeof rootRoute
}
'/changelog/': {
id: '/changelog/'
path: '/changelog'
@ -260,10 +320,13 @@ const PageIdLazyRouteWithChildren = PageIdLazyRoute._addFileChildren(
export interface FileRoutesByFullPath {
'/': typeof IndexLazyRoute
'/cli': typeof CliLazyRoute
'/access-tokens/new': typeof AccessTokensNewLazyRoute
'/changelog/$id': typeof ChangelogIdLazyRouteWithChildren
'/changelog/create': typeof ChangelogCreateLazyRoute
'/page/$id': typeof PageIdLazyRouteWithChildren
'/page/create': typeof PageCreateLazyRoute
'/access-tokens': typeof AccessTokensIndexLazyRoute
'/changelog': typeof ChangelogIndexLazyRoute
'/page': typeof PageIndexLazyRoute
'/user': typeof UserIndexLazyRoute
@ -277,8 +340,11 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/cli': typeof CliLazyRoute
'/access-tokens/new': typeof AccessTokensNewLazyRoute
'/changelog/create': typeof ChangelogCreateLazyRoute
'/page/create': typeof PageCreateLazyRoute
'/access-tokens': typeof AccessTokensIndexLazyRoute
'/changelog': typeof ChangelogIndexLazyRoute
'/page': typeof PageIndexLazyRoute
'/user': typeof UserIndexLazyRoute
@ -293,10 +359,13 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexLazyRoute
'/cli': typeof CliLazyRoute
'/access-tokens/new': typeof AccessTokensNewLazyRoute
'/changelog/$id': typeof ChangelogIdLazyRouteWithChildren
'/changelog/create': typeof ChangelogCreateLazyRoute
'/page/$id': typeof PageIdLazyRouteWithChildren
'/page/create': typeof PageCreateLazyRoute
'/access-tokens/': typeof AccessTokensIndexLazyRoute
'/changelog/': typeof ChangelogIndexLazyRoute
'/page/': typeof PageIndexLazyRoute
'/user/': typeof UserIndexLazyRoute
@ -312,10 +381,13 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/cli'
| '/access-tokens/new'
| '/changelog/$id'
| '/changelog/create'
| '/page/$id'
| '/page/create'
| '/access-tokens'
| '/changelog'
| '/page'
| '/user'
@ -328,8 +400,11 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/cli'
| '/access-tokens/new'
| '/changelog/create'
| '/page/create'
| '/access-tokens'
| '/changelog'
| '/page'
| '/user'
@ -342,10 +417,13 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/cli'
| '/access-tokens/new'
| '/changelog/$id'
| '/changelog/create'
| '/page/$id'
| '/page/create'
| '/access-tokens/'
| '/changelog/'
| '/page/'
| '/user/'
@ -360,10 +438,13 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexLazyRoute: typeof IndexLazyRoute
CliLazyRoute: typeof CliLazyRoute
AccessTokensNewLazyRoute: typeof AccessTokensNewLazyRoute
ChangelogIdLazyRoute: typeof ChangelogIdLazyRouteWithChildren
ChangelogCreateLazyRoute: typeof ChangelogCreateLazyRoute
PageIdLazyRoute: typeof PageIdLazyRouteWithChildren
PageCreateLazyRoute: typeof PageCreateLazyRoute
AccessTokensIndexLazyRoute: typeof AccessTokensIndexLazyRoute
ChangelogIndexLazyRoute: typeof ChangelogIndexLazyRoute
PageIndexLazyRoute: typeof PageIndexLazyRoute
UserIndexLazyRoute: typeof UserIndexLazyRoute
@ -371,10 +452,13 @@ export interface RootRouteChildren {
const rootRouteChildren: RootRouteChildren = {
IndexLazyRoute: IndexLazyRoute,
CliLazyRoute: CliLazyRoute,
AccessTokensNewLazyRoute: AccessTokensNewLazyRoute,
ChangelogIdLazyRoute: ChangelogIdLazyRouteWithChildren,
ChangelogCreateLazyRoute: ChangelogCreateLazyRoute,
PageIdLazyRoute: PageIdLazyRouteWithChildren,
PageCreateLazyRoute: PageCreateLazyRoute,
AccessTokensIndexLazyRoute: AccessTokensIndexLazyRoute,
ChangelogIndexLazyRoute: ChangelogIndexLazyRoute,
PageIndexLazyRoute: PageIndexLazyRoute,
UserIndexLazyRoute: UserIndexLazyRoute,
@ -384,8 +468,6 @@ export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* prettier-ignore-end */
/* ROUTE_MANIFEST_START
{
"routes": {
@ -393,10 +475,13 @@ export const routeTree = rootRoute
"filePath": "__root.tsx",
"children": [
"/",
"/cli",
"/access-tokens/new",
"/changelog/$id",
"/changelog/create",
"/page/$id",
"/page/create",
"/access-tokens/",
"/changelog/",
"/page/",
"/user/"
@ -405,6 +490,12 @@ export const routeTree = rootRoute
"/": {
"filePath": "index.lazy.tsx"
},
"/cli": {
"filePath": "cli.lazy.tsx"
},
"/access-tokens/new": {
"filePath": "access-tokens.new.lazy.tsx"
},
"/changelog/$id": {
"filePath": "changelog.$id.lazy.tsx",
"children": [
@ -427,6 +518,9 @@ export const routeTree = rootRoute
"/page/create": {
"filePath": "page.create.lazy.tsx"
},
"/access-tokens/": {
"filePath": "access-tokens.index.lazy.tsx"
},
"/changelog/": {
"filePath": "changelog.index.lazy.tsx"
},

View File

@ -0,0 +1,36 @@
import { Button } from '@boring.tools/ui'
import { Link, createLazyFileRoute } from '@tanstack/react-router'
import { AccessTokenColumns } from '../components/AccessToken/Table/Columns'
import { DataTable } from '../components/AccessToken/Table/DataTable'
import { PageWrapper } from '../components/PageWrapper'
import { useAccessTokenList } from '../hooks/useAccessToken'
const Component = () => {
const { data, isPending } = useAccessTokenList()
return (
<PageWrapper
breadcrumbs={[
{
name: 'Access tokens',
to: '/access-tokens',
},
]}
>
<div className="flex w-full gap-5 justify-between items-start md:items-center flex-col md:flex-row">
<h1 className="text-3xl">Access Tokens</h1>
<Button asChild>
<Link to="/access-tokens/new">Generate new token</Link>
</Button>
</div>
{data && !isPending && (
<DataTable data={data} columns={AccessTokenColumns} />
)}
</PageWrapper>
)
}
export const Route = createLazyFileRoute('/access-tokens/')({
component: Component,
})

View File

@ -0,0 +1,136 @@
import { AccessTokenCreateInput } from '@boring.tools/schema'
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@boring.tools/ui'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLazyFileRoute, useRouter } from '@tanstack/react-router'
import { AlertCircle, CopyIcon } from 'lucide-react'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useCopyToClipboard } from 'usehooks-ts'
import type { z } from 'zod'
import { PageWrapper } from '../components/PageWrapper'
import { useAccessTokenCreate } from '../hooks/useAccessToken'
const Component = () => {
const router = useRouter()
const [, copy] = useCopyToClipboard()
const [token, setToken] = useState<null | string>(null)
const accessTokenCreate = useAccessTokenCreate()
const form = useForm<z.infer<typeof AccessTokenCreateInput>>({
resolver: zodResolver(AccessTokenCreateInput),
defaultValues: {
name: '',
},
})
const onSubmit = (values: z.infer<typeof AccessTokenCreateInput>) => {
accessTokenCreate.mutate(values, {
onSuccess(data) {
if (data.token) {
setToken(data.token)
}
},
})
}
return (
<PageWrapper
breadcrumbs={[
{
name: 'Access tokens',
to: '/access-tokens',
},
{
name: 'New',
to: '/access-tokens/new',
},
]}
>
<div className="flex w-full gap-5 justify-between items-center">
<h1 className="text-3xl">New access token</h1>
</div>
{token && (
<div className="flex flex-col gap-3 w-full max-w-screen-md">
<h2 className="text-xl">Your token</h2>
<pre className="bg-muted text-xl p-3 rounded text-center flex justify-between items-center">
{token}
<Button
onClick={() => copy(token)}
size={'icon'}
variant={'outline'}
>
<CopyIcon className="w-4 h-4" />
</Button>
</pre>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Reminder</AlertTitle>
<AlertDescription>
Your token is only visible this time. Please notify it securely.
If you forget it, you have to create a new token.
</AlertDescription>
</Alert>
<div className="flex items-center justify-end">
<Button
variant={'ghost'}
type="button"
onClick={() => router.history.back()}
>
Back
</Button>
</div>
</div>
)}
{!token && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="CLI Token" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<div className="w-full flex items-center justify-end gap-5">
<Button
variant={'ghost'}
type="button"
onClick={() => router.history.back()}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</div>
</form>
</Form>
)}
</PageWrapper>
)
}
export const Route = createLazyFileRoute('/access-tokens/new')({
component: Component,
})

View File

@ -1,5 +1,9 @@
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox,
Form,
FormControl,
@ -58,66 +62,118 @@ const Component = () => {
<Form {...form}>
<form
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
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My changelog" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the changelog..."
{...field}
<div className="flex gap-10 w-full max-w-screen-lg">
<Card className="w-full">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 w-full">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
placeholder="My changelog"
{...field}
autoFocus
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<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}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
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>
</FormItem>
)}
/>
<div className="flex gap-5">
</CardContent>
</Card>
<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
type="button"
variant={'ghost'}
@ -127,7 +183,8 @@ const Component = () => {
>
Cancel
</Button>
<Button type="submit">Update</Button>
<Button type="submit">Save</Button>
</div>
</form>
</Form>

View File

@ -1,58 +1,12 @@
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from '@boring.tools/ui'
import { Link, createLazyFileRoute } from '@tanstack/react-router'
import { PlusCircleIcon } from 'lucide-react'
import { VersionStatus } from '../components/Changelog/VersionStatus'
import { useChangelogById } from '../hooks/useChangelog'
import { createLazyFileRoute } from '@tanstack/react-router'
import { ChangelogCommitList } from '../components/Changelog/CommitList'
import { ChangelogVersionList } from '../components/Changelog/VersionList'
const Component = () => {
const { id } = Route.useParams()
const { data, isPending } = useChangelogById({ id })
return (
<div className="flex flex-col gap-5">
{!isPending && data && (
<div>
<Card className="w-full max-w-screen-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Versions ({data.versions?.length})</CardTitle>
<Link to="/changelog/$id/versionCreate" params={{ id }}>
<Button variant={'ghost'} size={'icon'}>
<PlusCircleIcon strokeWidth={1.5} className="w-5 h-5" />
</Button>
</Link>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-1">
{data.versions?.map((version) => {
return (
<Link
className="hover:bg-muted py-1 px-2 rounded transition flex gap-2 items-center"
to="/changelog/$id/version/$versionId"
params={{
id,
versionId: version.id,
}}
key={version.id}
>
<VersionStatus status={version.status} />
{version.version}
</Link>
)
})}
</div>
</CardContent>
</Card>
</div>
)}
<div className="flex gap-5 flex-wrap">
<ChangelogVersionList />
<ChangelogCommitList />
</div>
)
}

View File

@ -13,7 +13,6 @@ import { useChangelogById } from '../hooks/useChangelog'
const Component = () => {
const { id } = Route.useParams()
const { data, error, isPending, refetch } = useChangelogById({ id })
console.log(data)
if (error) {
return (
<div className="flex items-center justify-center mt-32 flex-col">
@ -65,16 +64,7 @@ const Component = () => {
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={'ghost'}>
<Globe2Icon strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Public Page</p>
</TooltipContent>
</Tooltip> */}
*/}
<Tooltip>
<TooltipTrigger asChild>

View File

@ -2,21 +2,30 @@ import { VersionUpdateInput } from '@boring.tools/schema'
import {
Button,
Calendar,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Tabs,
TabsContent,
cn,
} from '@boring.tools/ui'
import { zodResolver } from '@hookform/resolvers/zod'
@ -37,17 +46,19 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import type { z } from 'zod'
import {
useChangelogCommitList,
useChangelogVersionById,
useChangelogVersionUpdate,
} from '../hooks/useChangelog'
import '@mdxeditor/editor/style.css'
import { format } from 'date-fns'
import { CalendarIcon } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import { ChangelogVersionDelete } from '../components/Changelog/VersionDelete'
import { VersionStatus } from '../components/Changelog/VersionStatus'
const Component = () => {
const [activeTab, setActiveTab] = useState('assigned')
const { id, versionId } = Route.useParams()
const mdxEditorRef = useRef<MDXEditorMethods>(null)
const navigate = useNavigate({ from: `/changelog/${id}/versionCreate` })
@ -55,6 +66,8 @@ const Component = () => {
const { data, error, isPending, refetch } = useChangelogVersionById({
id: versionId,
})
const commitResult = useChangelogCommitList({ id })
const form = useForm<z.infer<typeof VersionUpdateInput>>({
resolver: zodResolver(VersionUpdateInput),
defaultValues: data,
@ -74,9 +87,12 @@ const Component = () => {
useEffect(() => {
if (data) {
mdxEditorRef.current?.setMarkdown(data.markdown)
form.reset(data)
form.reset({
...data,
commitIds: data.commits?.map((commit) => commit.id),
})
}
}, [data, form.reset])
}, [data, form])
if (error) {
return (
@ -93,152 +109,332 @@ const Component = () => {
<div className="flex flex-col gap-5">
<Separator />
{!isPending && data && (
<div>
<h1 className="text-xl mb-2">Version: {data.version}</h1>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
>
<FormField
control={form.control}
name="markdown"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<MDXEditor
className="dark-theme"
contentEditableClassName="prose dark:prose-invert max-w-none"
markdown={''}
ref={mdxEditorRef}
onChange={field.onChange}
onBlur={field.onBlur}
plugins={[
headingsPlugin(),
listsPlugin(),
thematicBreakPlugin(),
quotePlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<BlockTypeSelect />
<BoldItalicUnderlineToggles />
<ListsToggle />
<UndoRedo />
</>
),
}),
]}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-5 items-center">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex gap-4 w-full flex-col max-w-screen-2xl"
>
<div className="flex gap-5 w-full">
<Card className="w-full h-[700px]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Details</CardTitle>
</div>
</CardHeader>
<CardContent className="gap-3 flex flex-col">
<FormField
control={form.control}
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select your version status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex gap-2 items-center">
<VersionStatus status={'draft'} />
<span>Draft</span>
</div>
</SelectItem>
<SelectItem value="review">
<div className="flex gap-2 items-center">
<VersionStatus status={'review'} />
<span>Review</span>
</div>
</SelectItem>
<SelectItem value="published">
<div className="flex gap-2 items-center">
<VersionStatus status={'published'} />
<span>Published</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Input placeholder="v1.0.1" {...field} />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="releasedAt"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="mb-2">Released at</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={'outline'}
size={'lg'}
className={cn(
'w-[240px] pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
<div className="flex gap-5 items-center">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select your version status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex gap-2 items-center">
<VersionStatus status={'draft'} />
<span>Draft</span>
</div>
</SelectItem>
<SelectItem value="review">
<div className="flex gap-2 items-center">
<VersionStatus status={'review'} />
<span>Review</span>
</div>
</SelectItem>
<SelectItem value="published">
<div className="flex gap-2 items-center">
<VersionStatus status={'published'} />
<span>Published</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="releasedAt"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="mb-2">Released at</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={'outline'}
size={'lg'}
className={cn(
'w-[240px] pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"
align="start"
>
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value as Date}
onSelect={(date) => field.onChange(date)}
weekStartsOn={1}
<Calendar
mode="single"
selected={field.value as Date}
onSelect={(date) => field.onChange(date)}
weekStartsOn={1}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="markdown"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<MDXEditor
className="dark-theme"
contentEditableClassName="prose dark:prose-invert max-w-none max-h-[390px] overflow-scroll"
markdown={''}
ref={mdxEditorRef}
onChange={field.onChange}
onBlur={field.onBlur}
plugins={[
headingsPlugin(),
listsPlugin(),
thematicBreakPlugin(),
quotePlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<BlockTypeSelect />
<BoldItalicUnderlineToggles />
<ListsToggle />
<UndoRedo />
</>
),
}),
]}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card className="w-full max-w-screen-sm h-[700px]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Commits ({data.commits?.length})</CardTitle>
<div className="flex gap-5">
<Button
type="button"
variant={'ghost'}
onClick={() =>
navigate({ to: '/changelog/$id', params: { id } })
}
>
Cancel
</Button>
<Button type="submit">Update</Button>
</div>
</form>
</Form>
<div className="flex gap-2">
<Button
type="button"
variant={activeTab === 'assigned' ? 'outline' : 'ghost'}
size={'sm'}
onClick={() => setActiveTab('assigned')}
>
Assigend
</Button>
<Button
type="button"
variant={
activeTab === 'unassigned' ? 'outline' : 'ghost'
}
size={'sm'}
onClick={() => setActiveTab('unassigned')}
>
Unassigend
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<Tabs value={activeTab} className="w-full">
<TabsContent value="assigned">
<ScrollArea className="w-full h-[580px]">
<div className="flex flex-col gap-2">
{data?.commits?.map((commit) => {
return (
<FormField
key={commit.id}
control={form.control}
name={'commitIds'}
render={({ field }) => {
return (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
value={commit.id}
checked={field.value?.includes(
commit.id,
)}
onCheckedChange={() => {
const exist = field.value?.includes(
commit.id,
)
if (exist) {
return field.onChange(
field.value?.filter(
(value) =>
value !== commit.id,
),
)
}
return field.onChange([
...(field.value as string[]),
commit.id,
])
}}
/>
</FormControl>
<div className="space-y-1 leading-none w-full">
<FormLabel className="flex gap-2 w-full">
<span className="text-muted-foreground font-mono">
{commit.commit}{' '}
</span>
<span className="w-full">
{commit.subject}
</span>
<span>
{format(
new Date(commit.commiter.date),
'dd.MM.',
)}
</span>
</FormLabel>
</div>
</FormItem>
)
}}
/>
)
})}
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="unassigned">
<ScrollArea className="w-full h-[350px]">
<div className="flex flex-col gap-2">
{commitResult.data?.map((commit) => {
return (
<FormField
key={commit.id}
control={form.control}
name={'commitIds'}
render={({ field }) => {
return (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
value={commit.id}
checked={field.value?.includes(
commit.id,
)}
onCheckedChange={() => {
const exist = field.value?.includes(
commit.id,
)
if (exist) {
return field.onChange(
field.value?.filter(
(value) =>
value !== commit.id,
),
)
}
return field.onChange([
...(field.value as string[]),
commit.id,
])
}}
/>
</FormControl>
<div className="space-y-1 leading-none w-full">
<FormLabel className="flex gap-2 w-full">
<span className="text-muted-foreground font-mono">
{commit.commit}
</span>
<span className="w-full">
{commit.subject}
</span>
<span>
{format(
new Date(commit.commiter.date),
'dd.MM.',
)}
</span>
</FormLabel>
</div>
</FormItem>
)
}}
/>
)
})}
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</div>
<div className="flex gap-5 w-full items-end justify-end">
<Button
type="button"
variant={'ghost'}
onClick={() =>
navigate({ to: '/changelog/$id', params: { id } })
}
>
Cancel
</Button>
<Button type="submit">Update</Button>
</div>
<ChangelogVersionDelete id={id} versionId={versionId} />
</div>
<ChangelogVersionDelete id={id} versionId={versionId} />
</form>
</Form>
)}
</div>
)

View File

@ -2,6 +2,11 @@ import { VersionCreateInput } from '@boring.tools/schema'
import {
Button,
Calendar,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox,
Form,
FormControl,
FormField,
@ -12,12 +17,12 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
cn,
} from '@boring.tools/ui'
import { zodResolver } from '@hookform/resolvers/zod'
@ -37,7 +42,10 @@ import { createLazyFileRoute } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import type { z } from 'zod'
import { useChangelogVersionCreate } from '../hooks/useChangelog'
import {
useChangelogCommitList,
useChangelogVersionCreate,
} from '../hooks/useChangelog'
import '@mdxeditor/editor/style.css'
import { format } from 'date-fns'
import { CalendarIcon } from 'lucide-react'
@ -46,7 +54,9 @@ import { VersionStatus } from '../components/Changelog/VersionStatus'
const Component = () => {
const { id } = Route.useParams()
const navigate = useNavigate({ from: `/changelog/${id}/versionCreate` })
const changelogCommit = useChangelogCommitList({ id })
const versionCreate = useChangelogVersionCreate()
const { data } = useChangelogCommitList({ id })
const form = useForm<z.infer<typeof VersionCreateInput>>({
resolver: zodResolver(VersionCreateInput),
defaultValues: {
@ -54,9 +64,18 @@ const Component = () => {
version: '',
markdown: '',
status: 'draft',
commitIds: [],
},
})
const selectAllCommits = () => {
const commitIds = data?.map((commit) => commit.id)
if (!commitIds) {
return form.setValue('commitIds', [])
}
form.setValue('commitIds', commitIds)
}
const onSubmit = (values: z.infer<typeof VersionCreateInput>) => {
versionCreate.mutate(values, {
onSuccess(data) {
@ -66,149 +85,213 @@ const Component = () => {
}
return (
<div className="flex flex-col gap-5">
<Separator />
<h1 className="text-xl mb-2">New version</h1>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
>
<FormField
control={form.control}
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<FormControl>
<Input placeholder="v1.0.1" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="markdown"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<MDXEditor
className="dark-theme"
contentEditableClassName="prose dark:prose-invert max-w-none"
markdown={field.value}
plugins={[
headingsPlugin(),
listsPlugin(),
thematicBreakPlugin(),
quotePlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<BlockTypeSelect />
<BoldItalicUnderlineToggles />
<ListsToggle />
<UndoRedo />
</>
),
}),
]}
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-5 items-center">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full">
<h1 className="text-2xl">New version</h1>
<div className="grid md:grid-cols-6 gap-5 w-full md:max-w-screen-xl grid-flow-row grid-cols-1">
<Card className="md:col-span-4 col-span-1">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<FormField
control={form.control}
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>Version</FormLabel>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select your version status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex gap-2 items-center">
<VersionStatus status={'draft'} />
<span>Draft</span>
</div>
</SelectItem>
<SelectItem value="review">
<div className="flex gap-2 items-center">
<VersionStatus status={'review'} />
<span>Review</span>
</div>
</SelectItem>
<SelectItem value="published">
<div className="flex gap-2 items-center">
<VersionStatus status={'published'} />
<span>Published</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Input placeholder="v1.0.1" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="releasedAt"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="mb-2">Released at</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={'outline'}
size={'lg'}
className={cn(
'w-[240px] pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value as Date}
onSelect={(date) => field.onChange(date)}
weekStartsOn={1}
<div className="flex gap-5 md:items-center flex-col md:flex-row">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select your version status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex gap-2 items-center">
<VersionStatus status={'draft'} />
<span>Draft</span>
</div>
</SelectItem>
<SelectItem value="review">
<div className="flex gap-2 items-center">
<VersionStatus status={'review'} />
<span>Review</span>
</div>
</SelectItem>
<SelectItem value="published">
<div className="flex gap-2 items-center">
<VersionStatus status={'published'} />
<span>Published</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="releasedAt"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="mb-2">Released at</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={'outline'}
size={'lg'}
className={cn(
'w-[240px] pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? (
format(field.value, 'PPP')
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value as Date}
onSelect={(date) => field.onChange(date)}
weekStartsOn={1}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="markdown"
render={({ field }) => (
<FormItem>
<FormLabel>Changes</FormLabel>
<FormControl>
<MDXEditor
className="dark-theme h-56"
contentEditableClassName="prose dark:prose-invert max-w-none"
markdown={field.value}
plugins={[
headingsPlugin(),
listsPlugin(),
thematicBreakPlugin(),
quotePlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<BlockTypeSelect />
<BoldItalicUnderlineToggles />
<ListsToggle />
<UndoRedo />
</>
),
}),
]}
{...field}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<div className="flex gap-5">
<Card className="md:col-span-2 col-span-1">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Associated commits</CardTitle>
<Button
variant={'ghost'}
size={'sm'}
onClick={selectAllCommits}
type="button"
>
Add all commits
</Button>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="w-full h-[350px]">
<div className="flex flex-col gap-2">
{changelogCommit.data?.map((commit) => {
return (
<FormField
key={commit.id}
control={form.control}
name={'commitIds'}
render={({ field }) => {
return (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md ">
<FormControl>
<Checkbox
value={commit.id}
checked={field.value?.includes(commit.id)}
onCheckedChange={() => {
const exist = field.value.includes(
commit.id,
)
if (exist) {
return field.onChange(
field.value.filter(
(value) => value !== commit.id,
),
)
}
return field.onChange([
...field.value,
commit.id,
])
}}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>{commit.subject}</FormLabel>
</div>
</FormItem>
)
}}
/>
)
})}
</div>
</ScrollArea>
</CardContent>
</Card>
<div className="flex gap-5 mt-5 w-full justify-end items-end md:col-span-6">
<Button
type="button"
variant={'ghost'}
@ -218,9 +301,9 @@ const Component = () => {
</Button>
<Button type="submit">Create</Button>
</div>
</form>
</Form>
</div>
</div>
</form>
</Form>
)
}

View File

@ -1,6 +1,10 @@
import { ChangelogCreateInput } from '@boring.tools/schema'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox,
Form,
FormControl,
@ -29,6 +33,7 @@ const Component = () => {
title: '',
description: '',
isSemver: true,
isConventional: true,
},
})
@ -52,70 +57,131 @@ const Component = () => {
>
<div className="flex flex-col gap-5">
<h1 className="text-3xl">New changelog</h1>
<Form {...form}>
<form
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
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My changelog" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the changelog..."
{...field}
<div className="flex gap-10 w-full max-w-screen-lg">
<Card className="w-full">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 w-full">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
placeholder="My changelog"
{...field}
autoFocus
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<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}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
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>
</FormItem>
)}
/>
<Button type="submit">Create</Button>
</CardContent>
</Card>
<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
type="button"
variant={'ghost'}
onClick={() => navigate({ to: '/changelog' })}
>
Cancel
</Button>
<Button type="submit">Create</Button>
</div>
</form>
</Form>
</div>

View File

@ -26,47 +26,45 @@ const Component = () => {
return (
<PageWrapper breadcrumbs={[{ name: 'Changelog', to: '/changelog' }]}>
<>
<div className="flex flex-col gap-5">
<h1 className="text-3xl">Changelog</h1>
<div className="flex flex-col gap-5">
<h1 className="text-3xl">Changelog</h1>
<div className="flex gap-10 w-full">
{!isPending &&
data &&
data.map((changelog) => {
return (
<Link
to="/changelog/$id"
params={{ id: changelog.id }}
key={changelog.id}
>
<Card className="max-w-56 min-w-56 w-full h-36 hover:border-emerald-700 transition">
<CardHeader className="flex items-center justify-center">
<CardTitle>{changelog.title}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center flex-col">
<span>Versions: {changelog.computed.versionCount}</span>
<div className="flex gap-10 w-full flex-wrap">
{!isPending &&
data &&
data.map((changelog) => {
return (
<Link
to="/changelog/$id"
params={{ id: changelog.id }}
key={changelog.id}
>
<Card className="max-w-56 min-w-56 w-full h-36 hover:border-emerald-700 transition">
<CardHeader className="flex items-center justify-center">
<CardTitle>{changelog.title}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center flex-col">
<span>Versions: {changelog.computed?.versionCount}</span>
<span>Commits: {changelog.computed.commitCount}</span>
</CardContent>
</Card>
</Link>
)
})}
<span>Commits: {changelog.computed?.commitCount}</span>
</CardContent>
</Card>
</Link>
)
})}
<Link to="/changelog/create">
<Card className="max-w-56 min-w-56 w-full h-36 hover:border-emerald-700 transition">
<CardHeader className="flex items-center justify-center">
<CardTitle>New Changelog</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center">
<PlusCircleIcon strokeWidth={1.5} className="w-10 h-10" />
</CardContent>
</Card>
</Link>
</div>
<Link to="/changelog/create">
<Card className="max-w-56 min-w-56 w-full h-36 hover:border-emerald-700 transition">
<CardHeader className="flex items-center justify-center">
<CardTitle>New Changelog</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center">
<PlusCircleIcon strokeWidth={1.5} className="w-10 h-10" />
</CardContent>
</Card>
</Link>
</div>
</>
</div>
</PageWrapper>
)
}

View File

@ -0,0 +1,121 @@
import { Button, Card, CardContent, CardHeader, cn } from '@boring.tools/ui'
import { Link, createLazyFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { PageWrapper } from '../components/PageWrapper'
const Platforms = [
{
name: 'Linux',
arch: 'x86',
svg: '/linux.svg',
path: '/cli/linux/bt-cli',
filename: 'bt-cli',
},
{
name: 'Apple Intel',
arch: 'Intel',
svg: '/apple.svg',
path: '/cli/mac-intel/bt-cli',
filename: 'bt-cli',
},
{
name: 'Apple ARM',
arch: 'ARM',
svg: '/apple.svg',
path: '/cli/mac-arm/bt-cli',
filename: 'bt-cli',
},
{
name: 'Windows',
arch: 'x86',
svg: '/windows.svg',
path: '/cli/windows/bt-cli.exe',
filename: 'bt-cli.exe',
},
]
const Component = () => {
const [activePlatform, setPlatform] = useState('Linux')
const getPlatform = Platforms.find((p) => p.name === activePlatform)
return (
<PageWrapper breadcrumbs={[{ name: 'CLI', to: '/cli' }]}>
<div className="flex flex-col gap-5 w-full md:max-w-screen-lg">
<h1 className="text-3xl">CLI</h1>
<p className="text-muted-foreground">
With our CLI you can upload your commits for your changelog in just a
few seconds.
</p>
<h2 className="text-xl">Platform</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-10">
{Platforms.map((platform) => {
return (
// <a
// href={`https://cdn.boring.tools${platform.path}`}
// key={`${platform.arch}-${platform.name}`}
// download={platform.filename}
// >
<Card
className={cn('hover:border-accent transition', {
'border-accent': platform.name === activePlatform,
})}
key={`${platform.arch}-${platform.name}`}
onClick={() => setPlatform(platform.name)}
>
<CardHeader>
<img
src={platform.svg}
alt={platform.name}
className="h-10 md:h-20"
/>
</CardHeader>
<CardContent className="text-center">
{platform.arch}
</CardContent>
</Card>
// </a>
)
})}
</div>
<h2 className="text-xl">Download</h2>
<Button asChild variant={'outline'}>
<a
href={`https://cdn.boring.tools${getPlatform?.path}`}
download={getPlatform?.filename}
>
https://cdn.boring.tools{getPlatform?.path}
</a>
</Button>
<h2 className="text-xl">WGET</h2>
<pre className="bg-muted text-xs md:text-xl p-3 rounded text-center flex justify-between items-center">
wget https://cdn.boring.tools{getPlatform?.path}
</pre>
<h2 className="text-xl">Usage</h2>
<pre className="bg-muted text-xs md:text-xl p-3 rounded text-center flex justify-between items-center">
{getPlatform?.filename} --help
</pre>
<p className="text-muted-foreground">
Alternatively, you can use an .env file:
</p>
<pre className="bg-muted text-xs md:text-xl p-3 rounded text-center flex justify-between items-center">
BT_CHANGELOG_ID=...
<br />
BT_ACCESS_TOKEN=bt_...
</pre>
<p className="text-muted-foreground">
If you have not yet created an Access Token, you can do so{' '}
<Link to="/access-tokens/new" className="text-accent font-bold">
here
</Link>
.
</p>
</div>
</PageWrapper>
)
}
export const Route = createLazyFileRoute('/cli')({
component: Component,
})

View File

@ -1,13 +1,71 @@
import { Card, CardContent, CardHeader, CardTitle } from '@boring.tools/ui'
import { useUser } from '@clerk/clerk-react'
import { createLazyFileRoute } from '@tanstack/react-router'
import { PageWrapper } from '../components/PageWrapper'
import { useStatistic } from '../hooks/useStatistic'
export const Route = createLazyFileRoute('/')({
component: Index,
})
function Index() {
const Component = () => {
const { data } = useStatistic()
const user = useUser()
return (
<div className="p-2">
<h3>Welcome Home!</h3>
</div>
<PageWrapper
breadcrumbs={[
{
name: 'Dashboard',
to: '/',
},
]}
>
<h1 className="text-3xl">Welcome back, {user.user?.fullName}</h1>
<div className="grid w-full max-w-screen-md gap-10 grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Changelogs</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold">
{data?.changelog.total}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Versions</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold">
{data?.changelog.versions.total}
<div className="text-xs text-muted-foreground tracking-normal flex gap-3 flex-wrap">
<span>{data?.changelog.versions.published} Published</span>
<span>{data?.changelog.versions.review} Review</span>
<span>{data?.changelog.versions.draft} Draft</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Commits</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold">
{data?.changelog.commits.total}
<div className="text-xs text-muted-foreground tracking-normal flex gap-3 flex-wrap">
<span>{data?.changelog.commits.assigned} Assigned</span>
<span>{data?.changelog.commits.unassigned} Unassigned</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pages</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold">
{data?.page.total}
</CardContent>
</Card>
</div>
</PageWrapper>
)
}
export const Route = createLazyFileRoute('/')({
component: Component,
})

View File

@ -1,6 +1,10 @@
import { PageUpdateInput } from '@boring.tools/schema'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Command,
CommandEmpty,
CommandGroup,
@ -40,7 +44,7 @@ const Component = () => {
resolver: zodResolver(PageUpdateInput),
defaultValues: {
...page.data,
changelogIds: page.data?.changelogs.map((log) => log.id),
changelogIds: page.data?.changelogs?.map((log) => log.id),
},
})
const onSubmit = (values: z.infer<typeof PageUpdateInput>) => {
@ -57,120 +61,147 @@ const Component = () => {
return (
<>
<div className="flex flex-col gap-5">
<h1 className="text-3xl">Edit page</h1>
<Form {...form}>
<form
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
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-10 w-full">
<Card className="w-full">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the page..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the page..."
{...field}
value={field.value ?? ''}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<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',
)}
>
{field.value.length === 1 &&
changelogList.data?.find((changelog) =>
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]
}
form.setValue('changelogIds', getIds())
}}
<Card className="w-full">
<CardHeader>
<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
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>
)}
/>
<div className="flex gap-5">
{field?.value?.length === 1 &&
changelogList.data?.find((changelog) =>
field.value?.includes(changelog.id),
)?.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
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'}
@ -178,7 +209,7 @@ const Component = () => {
>
Cancel
</Button>
<Button type="submit">Update</Button>
<Button type="submit">Save</Button>
</div>
</form>
</Form>

View File

@ -6,7 +6,7 @@ import {
CardTitle,
} from '@boring.tools/ui'
import { Link, createLazyFileRoute } from '@tanstack/react-router'
import { CircleMinusIcon, PlusCircleIcon } from 'lucide-react'
import { CircleMinusIcon } from 'lucide-react'
import { usePageById, usePageUpdate } from '../hooks/usePage'
const Component = () => {
@ -31,12 +31,14 @@ const Component = () => {
<Card className="w-full max-w-screen-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Changelogs ({data.changelogs?.length})</CardTitle>
<CardTitle>
Changelogs ({data.changelogs?.length ?? 0})
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-1">
{data.changelogs.map((changelog) => {
{data.changelogs?.map((changelog) => {
return (
<div className="flex gap-3" key={changelog.id}>
<Link

View File

@ -1,6 +1,10 @@
import { PageCreateInput } from '@boring.tools/schema'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Command,
CommandEmpty,
CommandGroup,
@ -69,115 +73,146 @@ const Component = () => {
<Form {...form}>
<form
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
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-10 w-full">
<Card className="w-full">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the page..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<CardContent className="flex flex-col gap-5">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<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',
)}
>
{field.value.length === 1 &&
changelogList.data?.find((changelog) =>
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
}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the page..."
{...field}
value={field.value ?? ''}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
return [...field.value, changelog.id]
}
form.setValue('changelogIds', getIds())
}}
<Card className="w-full">
<CardHeader>
<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
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>
)}
/>
<Button type="submit">Create</Button>
{field.value.length === 1 &&
changelogList.data?.find((changelog) =>
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]
}
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>
</div>

View File

@ -1,10 +1,51 @@
import { Card, CardContent, CardHeader, CardTitle } from '@boring.tools/ui'
import { createLazyFileRoute } from '@tanstack/react-router'
//import { usePageById, usePageList } from '../hooks/usePage'
import { Link } from '@tanstack/react-router'
import { PlusCircleIcon } from 'lucide-react'
import { PageWrapper } from '../components/PageWrapper'
import { usePageList } from '../hooks/usePage'
const Component = () => {
//const { data, error } = usePageList()
const { data, isPending } = usePageList()
return <div>some</div>
return (
<PageWrapper
breadcrumbs={[
{
name: 'Page',
to: '/page',
},
]}
>
<h1 className="text-3xl">Page</h1>
<div className="flex gap-10 w-full flex-wrap">
{!isPending &&
data &&
data.map((page) => {
return (
<Link to="/page/$id" params={{ id: page.id }} key={page.id}>
<Card className="max-w-56 min-w-56 w-full h-36 hover:border-emerald-700 transition">
<CardHeader className="flex items-center justify-center">
<CardTitle>{page.title}</CardTitle>
</CardHeader>
</Card>
</Link>
)
})}
<Link to="/page/create">
<Card className="max-w-56 min-w-56 w-full h-36 hover:border-emerald-700 transition">
<CardHeader className="flex items-center justify-center">
<CardTitle>New page</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center">
<PlusCircleIcon strokeWidth={1.5} className="w-10 h-10" />
</CardContent>
</Card>
</Link>
</div>
</PageWrapper>
)
}
export const Route = createLazyFileRoute('/page/')({

View File

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

View File

@ -1 +1 @@
{"root":["./src/main.tsx","./src/routeTree.gen.ts","./src/vite-env.d.ts","./src/components/Layout.tsx","./src/components/Navigation.tsx","./src/components/NavigationMobile.tsx","./src/components/PageWrapper.tsx","./src/components/Sidebar.tsx","./src/components/SidebarChangelog.tsx","./src/components/SidebarPage.tsx","./src/components/SidebarUser.tsx","./src/components/Changelog/Delete.tsx","./src/components/Changelog/VersionDelete.tsx","./src/components/Changelog/VersionStatus.tsx","./src/components/Page/Delete.tsx","./src/hooks/useChangelog.ts","./src/hooks/usePage.ts","./src/routes/__root.tsx","./src/routes/changelog.$id.edit.lazy.tsx","./src/routes/changelog.$id.index.lazy.tsx","./src/routes/changelog.$id.lazy.tsx","./src/routes/changelog.$id.version.$versionId.tsx","./src/routes/changelog.$id.versionCreate.lazy.tsx","./src/routes/changelog.create.lazy.tsx","./src/routes/changelog.index.lazy.tsx","./src/routes/index.lazy.tsx","./src/routes/page.$id.edit.lazy.tsx","./src/routes/page.$id.index.lazy.tsx","./src/routes/page.$id.lazy.tsx","./src/routes/page.create.lazy.tsx","./src/routes/page.index.lazy.tsx","./src/routes/user/index.lazy.tsx","./src/utils/navigation-routes.ts","./src/utils/queryFetch.ts"],"version":"5.6.2"}
{"root":["./src/main.tsx","./src/routeTree.gen.ts","./src/vite-env.d.ts","./src/components/Layout.tsx","./src/components/PageWrapper.tsx","./src/components/Sidebar.tsx","./src/components/SidebarChangelog.tsx","./src/components/SidebarPage.tsx","./src/components/SidebarUser.tsx","./src/components/AccessToken/Delete.tsx","./src/components/AccessToken/Table/Columns.tsx","./src/components/AccessToken/Table/DataTable.tsx","./src/components/Changelog/CommitList.tsx","./src/components/Changelog/Delete.tsx","./src/components/Changelog/VersionDelete.tsx","./src/components/Changelog/VersionList.tsx","./src/components/Changelog/VersionStatus.tsx","./src/components/Changelog/Version/Create/Step01.tsx","./src/components/Changelog/Version/Create/Step02.tsx","./src/components/Changelog/Version/Create/index.tsx","./src/components/Page/Delete.tsx","./src/hooks/useAccessToken.ts","./src/hooks/useChangelog.ts","./src/hooks/usePage.ts","./src/hooks/useStatistic.ts","./src/routes/__root.tsx","./src/routes/access-tokens.index.lazy.tsx","./src/routes/access-tokens.new.lazy.tsx","./src/routes/changelog.$id.edit.lazy.tsx","./src/routes/changelog.$id.index.lazy.tsx","./src/routes/changelog.$id.lazy.tsx","./src/routes/changelog.$id.version.$versionId.tsx","./src/routes/changelog.$id.versionCreate.lazy.tsx","./src/routes/changelog.create.lazy.tsx","./src/routes/changelog.index.lazy.tsx","./src/routes/cli.lazy.tsx","./src/routes/index.lazy.tsx","./src/routes/page.$id.edit.lazy.tsx","./src/routes/page.$id.index.lazy.tsx","./src/routes/page.$id.lazy.tsx","./src/routes/page.create.lazy.tsx","./src/routes/page.index.lazy.tsx","./src/routes/user/index.lazy.tsx","./src/utils/navigation-routes.ts","./src/utils/queryFetch.ts"],"version":"5.6.3"}

175
apps/cli/.gitignore vendored Normal file
View File

@ -0,0 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
apps/cli/README.md Normal file
View File

@ -0,0 +1,15 @@
# cli
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run src/index.ts
```
This project was created using `bun init` in bun v1.1.21. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

22
apps/cli/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "@boring.tools/cli",
"module": "src/index.ts",
"type": "module",
"scripts": {
"build:linux": "bun build --compile --target=bun-linux-x64-modern ./src/index.ts --outfile ../../build/cli/linux/bt-cli",
"build:mac:arm": "bun build --compile --target=bun-darwin-arm64 ./src/index.ts --outfile ../../build/cli/mac-arm/bt-cli",
"build:mac:intel": "bun build --compile --target=bun-darwin-x64 ./src/index.ts --outfile ../../build/cli/mac-intel/bt-cli",
"build:win": "bun build --compile --target=bun-windows-x64-modern ./src/index.ts --outfile ../../build/cli/windows/bt-cli",
"build": "bun run build:linux && bun run build:mac:arm && bun run build:mac:intel && bun run build:win"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@commander-js/extra-typings": "^12.1.0",
"commander": "^12.1.0"
}
}

21
apps/cli/src/index.ts Normal file
View File

@ -0,0 +1,21 @@
#! /usr/bin/env bun
import { program } from '@commander-js/extra-typings'
import { upload_commits } from './upload_commits'
import type { Arguments } from './utils/arguments'
const ENV_CHANGELOG_ID = Bun.env.BT_CHANGELOG_ID
const ENV_ACCESS_TOKEN = Bun.env.BT_ACCESS_TOKEN
program.name('bt-cli').description('boring.tools CLI').version('0.8.0')
const commit = program.command('commit').description('Commits')
commit
.command('upload')
.description('Upload commit messages')
.requiredOption('--changelogId <id>', 'Changelog Id', ENV_CHANGELOG_ID)
.requiredOption('--accessToken <string>', 'Access Token', ENV_ACCESS_TOKEN)
.action((data) => {
upload_commits(data as Arguments)
})
program.parse()

View File

@ -0,0 +1,49 @@
import type { Arguments } from './utils/arguments'
import { fetchAPI } from './utils/fetch_api'
import { git_log } from './utils/git_log'
const getLastCommitHash = async (args: Arguments) => {
const result = await fetchAPI(
`/v1/changelog/commit?changelogId=${args.changelogId}&limit=1`,
{},
args.accessToken,
)
if (!result) {
return ''
}
if (Array.isArray(result)) {
if (result.length === 0) {
return ''
}
return result[0].commit
}
return ''
}
export const upload_commits = async (arguemnts: Arguments) => {
const hash = await getLastCommitHash(arguemnts)
const commits = await git_log(hash)
if (commits.length === 0) {
console.info('No new commits found')
return
}
console.info(`Pushing ${commits.length} commits`)
const mappedCommits = commits.map((commit) => ({
...commit,
changelogId: arguemnts.changelogId,
}))
await fetchAPI(
'/v1/changelog/commit',
{
method: 'POST',
body: JSON.stringify(mappedCommits),
},
arguemnts.accessToken,
)
}

View File

@ -0,0 +1,8 @@
import { z } from 'zod'
const schema = z.object({
accessToken: z.string().startsWith('bt_'),
changelogId: z.string(),
})
export type Arguments = z.infer<typeof schema>

View File

@ -0,0 +1,27 @@
const getAPIUrl = () => {
if (Bun.env.NODE_ENV === 'development') {
return 'http://localhost:3000'
}
return 'https://api.boring.tools'
}
export const fetchAPI = async (
url: string,
options: RequestInit,
token: string,
) => {
const response = await fetch(`${getAPIUrl()}${url}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
...options,
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return response.json()
}

View File

@ -0,0 +1,20 @@
const GITFORMAT =
'--pretty=format:{"commit": "%h", "parent": "%p", "refs": "%D", "subject": "%s", "author": { "name": "%aN", "email": "%aE", "date": "%ad" }, "commiter": { "name": "%cN", "email": "%cE", "date": "%cd" }},'
export const git_log = async (
from: string | undefined,
to = 'HEAD',
): Promise<Array<Record<string, unknown>>> => {
// https://git-scm.com/docs/pretty-formats
const process = Bun.spawn([
'git',
'log',
`${from ? `${from}...` : ''}${to}`,
GITFORMAT,
'--date=iso',
])
const output = await new Response(process.stdout).text()
const jsonString = `[${output.slice(0, -1)}]`
return JSON.parse(jsonString)
}

27
apps/cli/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@ -21,6 +21,7 @@
"@types/react-dom": "^18.3.1",
"astro": "^4.16.7",
"date-fns": "^4.1.0",
"marked": "^14.1.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",

View File

@ -3,6 +3,7 @@ import type { PageByIdOutput } from '@boring.tools/schema'
import { Separator } from '@boring.tools/ui'
import type { z } from 'astro/zod'
import { format } from 'date-fns'
import { marked } from 'marked'
type PageById = z.infer<typeof PageByIdOutput>
const url = import.meta.env.DEV
@ -29,9 +30,9 @@ const data: PageById = await response.json()
<p class="prose text-sm">{data.description}</p>
</div>
{data.changelogs.length >= 2 && <div class="flex flex-col">
{data.changelogs?.length >= 2 && <div class="flex flex-col">
<h2 class="prose prose-xl">Changelogs</h2>
{data.changelogs.map((changelog) => {
{data.changelogs?.map((changelog) => {
if (changelog.versions && changelog.versions?.length < 1) {
return null
}
@ -55,9 +56,7 @@ const data: PageById = await response.json()
}
</div>
<p>
{version.markdown}
</p>
<div set:html={marked.parse(version.markdown ?? "")} />
</div>
)
})}
@ -66,7 +65,7 @@ const data: PageById = await response.json()
</div>
})}
</div>}
{data.changelogs.length === 1 && <div>
{data.changelogs?.length === 1 && <div>
<h2 class="uppercase text-sm prose tracking-widest">Changelog</h2>
{data.changelogs.map((changelog) => {
if (changelog.versions && changelog.versions?.length < 1) {
@ -89,10 +88,7 @@ const data: PageById = await response.json()
</p>
}
</div>
<p>
{version.markdown}
</p>
<div class="page" set:html={marked.parse(version.markdown ?? "")} />
</div>
)
})}

View File

@ -25,8 +25,18 @@ export default defineConfig({
favicon: '/favicon.svg',
sidebar: [
{
label: 'Guides',
items: [{ label: 'Getting started', slug: 'guides/getting-started' }],
label: 'Getting Started',
items: [
{ label: 'Introduction', slug: 'docs' },
{ label: 'Motivation', slug: 'docs/motivation' },
],
},
{
label: 'Changelog',
items: [
{ label: 'Getting started', slug: 'docs/changelog' },
{ label: 'Features', slug: 'docs/changelog/features' },
],
},
{ label: 'API', link: 'https://api.boring.tools' },
{ label: 'Status', link: 'https://status.boring.tools' },

Some files were not shown because too many files have changed in this diff Show More