Compare commits

..

No commits in common. "main" and "feature/changelog-public-page" have entirely different histories.

169 changed files with 2658 additions and 6891 deletions

View File

@ -52,16 +52,6 @@ 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,12 +6,6 @@ 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
@ -50,6 +44,4 @@ jobs:
run: bun test:api
env:
NODE_ENV: test
POSTGRES_URL: postgres://postgres:postgres@postgres:5432/postgres
REDIS_URL: redis://redis:6379
REDIS_PASSWORD: development
POSTGRES_URL: postgres://postgres:postgres@postgres:5432/postgres

1
apps/api/.env.example Normal file
View File

@ -0,0 +1 @@
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",
"build": "bun build --entrypoints ./src/index.ts --outdir ../../build/api --target bun --splitting --sourcemap=linked",
"test": "bun test --preload ./src/index.ts"
},
"dependencies": {
@ -13,12 +13,9 @@
"@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",
"ts-pattern": "^5.5.0"
"svix": "^1.36.0"
},
"devDependencies": {
"@types/bun": "latest",

View File

@ -1,151 +0,0 @@
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

@ -1,56 +0,0 @@
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

@ -1,43 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,48 +0,0 @@
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,14 +4,9 @@ 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,
},
@ -24,35 +19,39 @@ export const route = createRoute({
},
description: 'Return changelog by id',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
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),
],
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,
},
},
})
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
return c.json(ChangelogOutput.parse(result), 200)
versions: {
orderBy: (changelog_version, { desc }) => [
desc(changelog_version.createdAt),
],
},
},
})
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
return result
}
export default {
route,
func,
}

View File

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

View File

@ -1,67 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,83 +0,0 @@
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,14 +5,9 @@ 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: {
@ -27,24 +22,32 @@ export const route = createRoute({
},
description: 'Return created changelog',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
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()
const [result] = await db
.insert(changelog)
.values({
...payload,
userId: userId,
})
.returning()
return c.json(ChangelogCreateOutput.parse(result), 201)
})
export const func = async ({
userId,
payload,
}: {
userId: string
payload: z.infer<typeof ChangelogCreateInput>
}) => {
return await db
.insert(changelog)
.values({
...payload,
userId: userId,
})
.returning()
}
export default {
route,
func,
}

View File

@ -4,14 +4,9 @@ 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: {
@ -21,25 +16,29 @@ export const route = createRoute({
},
description: 'Removes a changelog by id',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
export const registerChangelogDelete = async (api: typeof changelogApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const id = c.req.param('id')
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()
const [result] = await db
.delete(changelog)
.where(and(eq(changelog.userId, userId), eq(changelog.id, id)))
.returning()
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
return c.json(GeneralOutput.parse(result), 200)
})
return result
}
export default {
route,
func,
}

View File

@ -1,28 +1,130 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import { cors } from 'hono/cors'
import type { Variables } from '..'
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'
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'
export const changelogApi = new OpenAPIHono<{ Variables: Variables }>()
const app = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'changelog',
}
changelogApi.use('*', cors())
changelogApi.route('/commit', changelogCommitApi)
changelogApi.route('/version', version)
registerChangelogById(changelogApi)
registerChangelogCreate(changelogApi)
registerChangelogDelete(changelogApi)
registerChangelogUpdate(changelogApi)
registerChangelogList(changelogApi)
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,
},
})
}
})
export default 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

View File

@ -3,14 +3,9 @@ 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: {
@ -20,37 +15,40 @@ export const route = createRoute({
},
description: 'Return changelogs for current user',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
export const registerChangelogList = (api: typeof changelogApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const result = await db.query.changelog.findMany({
where: eq(changelog.userId, userId),
with: {
versions: true,
commits: {
columns: { id: true },
},
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)],
})
},
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)
return result.map((changelog) => {
const { versions, commits, ...rest } = changelog
return {
...rest,
computed: {
versionCount: versions.length,
commitCount: commits.length,
},
}
})
}
export default {
route,
func,
}

View File

@ -7,11 +7,6 @@ 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',
@ -30,32 +25,40 @@ export const route = createRoute({
},
description: 'Return updated changelog',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
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()
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()
const [result] = await db
.update(changelog)
.set({
...payload,
})
.where(and(eq(changelog.id, id), eq(changelog.userId, userId)))
.returning()
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
if (!result) {
throw new HTTPException(404, { message: 'Not found' })
}
if (result.pageId) {
redis.del(result.pageId)
}
return c.json(ChangelogUpdateOutput.parse(result), 200)
})
return result
}
export default {
route,
func,
}

View File

@ -1,22 +1,11 @@
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import { changelog, changelog_version, db } from '@boring.tools/database'
import { VersionByIdParams, VersionOutput } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
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'
import { and, eq } from 'drizzle-orm'
export const byId = createRoute({
method: 'get',
path: '/:id',
tags: ['version'],
request: {
params: VersionByIdParams,
},
@ -29,46 +18,49 @@ export const byId = createRoute({
},
description: 'Return version by id',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
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)
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,
},
})
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,24 +1,13 @@
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import { changelog, 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, inArray } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import semver from 'semver'
import type changelogVersionApi from '.'
import { verifyAuthentication } from '../../utils/authentication'
import { openApiErrorResponses, openApiSecurity } from '../../utils/openapi'
import { redis } from '../../utils/redis'
export const route = createRoute({
export const create = createRoute({
method: 'post',
path: '/',
tags: ['version'],
request: {
body: {
content: {
@ -33,71 +22,67 @@ export const route = createRoute({
},
description: 'Version created',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
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()
export const createFunc = async ({
userId,
payload,
}: {
userId: string
payload: z.infer<typeof VersionCreateInput>
}) => {
const formattedVersion = semver.coerce(payload.version)
const validVersion = semver.valid(formattedVersion)
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)
const changelogResult = await db.query.changelog.findFirst({
where: and(
eq(changelog.userId, userId),
eq(changelog.id, payload.changelogId),
),
})
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

@ -1,176 +0,0 @@
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,22 +1,12 @@
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import { changelog, 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 route = createRoute({
export const remove = createRoute({
method: 'delete',
path: '/:id',
tags: ['version'],
responses: {
200: {
content: {
@ -26,55 +16,48 @@ export const route = createRoute({
},
description: 'Removes a version by id',
},
...openApiErrorResponses,
},
...openApiSecurity,
})
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),
404: {
content: {
'application/json': {
schema: GeneralOutput,
},
},
})
description: 'Version not found',
},
500: {
description: 'Internal Server Error',
},
},
})
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)
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),
},
},
})
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()
}

View File

@ -1,24 +1,126 @@
import { OpenAPIHono } from '@hono/zod-openapi'
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'
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'
export const changelogVersionApi = new OpenAPIHono<{ Variables: Variables }>()
const app = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'changelog',
sub_module: 'version',
}
registerVersionCreateAuto(changelogVersionApi)
registerVersionById(changelogVersionApi)
registerVersionCreate(changelogVersionApi)
registerVersionDelete(changelogVersionApi)
registerVersionUpdate(changelogVersionApi)
app.openapi(create, async (c) => {
const userId = verifyAuthentication(c)
try {
const payload = await c.req.json()
const result = await createFunc({ userId, payload })
export default 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

View File

@ -1,23 +1,12 @@
import {
changelog,
changelog_commit,
changelog_version,
db,
} from '@boring.tools/database'
import { changelog, 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, inArray, notInArray } from 'drizzle-orm'
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 route = createRoute({
export const update = createRoute({
method: 'put',
path: '/:id',
tags: ['version'],
request: {
body: {
content: {
@ -32,69 +21,52 @@ export const route = createRoute({
},
description: 'Return updated version',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
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),
},
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),
},
})
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,19 +1,16 @@
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>
@ -24,7 +21,6 @@ export type Variables = {
export const app = new OpenAPIHono<{ Variables: Variables }>({
defaultHook: handleZodError,
strict: false,
})
// app.use(
@ -33,26 +29,14 @@ 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', userApi)
app.route('/v1/user', user)
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,7 +5,6 @@ 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({
@ -25,14 +24,18 @@ const route = createRoute({
},
description: 'Return changelog by id',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
export const registerPageById = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const userId = verifyAuthentication(c)
const { id } = c.req.valid('param')
const result = await db.query.page.findFirst({
@ -57,6 +60,6 @@ export const registerPageById = (api: typeof pageApi) => {
changelogs: changelogs.map((log) => log.changelog),
}
return c.json(PageOutput.parse(mappedResult), 200)
return c.json(mappedResult, 200)
})
}

View File

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

View File

@ -3,14 +3,11 @@ 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,
@ -25,25 +22,27 @@ const route = createRoute({
},
description: 'Removes a changelog by id',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
export const registerPageDelete = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const userId = 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({}, 200)
return c.json(result, 200)
})
}

View File

@ -1,7 +1,5 @@
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'
@ -10,10 +8,8 @@ import { registerPageList } from './list'
import { registerPagePublic } from './public'
import { registerPageUpdate } from './update'
export const pageApi = new OpenAPIHono<{ Variables: Variables }>({
defaultHook: handleZodError,
})
pageApi.use('*', timing())
export const pageApi = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'page',
}

View File

@ -1,17 +1,16 @@
import { db, page } from '@boring.tools/database'
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 { and, eq } from 'drizzle-orm'
import { PageListOutput } from '@boring.tools/schema'
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 list of pages',
description: 'Get a page list',
path: '/',
responses: {
200: {
@ -22,24 +21,27 @@ const route = createRoute({
},
description: 'Return changelog by id',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
export const registerPageList = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const userId = 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(PageListOutput.parse(result), 200)
return c.json(result, 200)
})
}

View File

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

View File

@ -9,8 +9,6 @@ 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({
@ -35,14 +33,18 @@ const route = createRoute({
},
description: 'Return changelog by id',
},
...openApiErrorResponses,
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
...openApiSecurity,
})
export const registerPageUpdate = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = await verifyAuthentication(c)
const userId = verifyAuthentication(c)
const { id } = c.req.valid('param')
const { changelogIds, ...rest }: z.infer<typeof PageUpdateInput> =
@ -57,10 +59,6 @@ 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) {
@ -81,8 +79,10 @@ export const registerPageUpdate = (api: typeof pageApi) => {
}
}
redis.del(id)
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(PageOutput.parse(result), 200)
return c.json(result, 200)
})
}

View File

@ -1,111 +0,0 @@
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

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

View File

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

View File

@ -1,5 +1,4 @@
import { access_token, db, user } from '@boring.tools/database'
import { logger } from '@boring.tools/logger'
import { access_token, db } from '@boring.tools/database'
import { clerkMiddleware, getAuth } from '@hono/clerk-auth'
import { eq } from 'drizzle-orm'
import type { Context, Next } from 'hono'
@ -25,11 +24,6 @@ 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()
@ -37,7 +31,7 @@ const generatedToken = async (c: Context, next: Next) => {
export const authentication = some(generatedToken, clerkMiddleware())
export const verifyAuthentication = async (c: Context) => {
export const verifyAuthentication = (c: Context) => {
const auth = getAuth(c)
if (!auth?.userId) {
const accessTokenUser = c.get('user')
@ -46,16 +40,5 @@ export const verifyAuthentication = async (c: Context) => {
}
return accessTokenUser.id
}
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
return auth.userId
}

View File

@ -1,26 +0,0 @@
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

@ -1,113 +0,0 @@
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

@ -1,73 +0,0 @@
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

@ -1,61 +0,0 @@
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

@ -1,68 +0,0 @@
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

@ -1,17 +0,0 @@
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,8 +5,6 @@ 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
@ -14,7 +12,7 @@ declare module 'bun' {
}
}
const TEST_VARIABLES = ['POSTGRES_URL', 'REDIS_URL', 'REDIS_PASSWORD']
const TEST_VARIABLES = ['POSTGRES_URL']
const DEVELOPMENT_VARIABLES = [
...TEST_VARIABLES,

View File

@ -20,14 +20,12 @@
"@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": {

View File

@ -1,12 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -1,11 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,72 +0,0 @@
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

@ -1,36 +0,0 @@
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

@ -1,86 +0,0 @@
'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

@ -1,44 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,74 +0,0 @@
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

@ -1,45 +0,0 @@
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 w-full" variant={'destructive'}>
<Alert className="mt-10 max-w-screen-md" variant={'destructive'}>
<TriangleAlertIcon className="h-4 w-4" />
<AlertTitle>Danger Zone</AlertTitle>
<AlertDescription className="inline-flex flex-col gap-3">

View File

@ -1,46 +0,0 @@
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

@ -0,0 +1,97 @@
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

@ -0,0 +1,102 @@
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,11 +8,7 @@ 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
@ -23,15 +19,6 @@ 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,8 +34,10 @@ export function Sidebar() {
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarChangelog />
<SidebarPage />
<SidebarMenu>
<SidebarChangelog />
<SidebarPage />
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>

View File

@ -2,7 +2,6 @@ import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
@ -10,91 +9,68 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@boring.tools/ui'
import { Link, useLocation } from '@tanstack/react-router'
import { Link } 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 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])
const { data, error } = useChangelogList()
return (
<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>
<Collapsible asChild>
<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>
{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>
))}
<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>
))}
<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>
<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>
)
}

View File

@ -2,7 +2,6 @@ import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
@ -10,88 +9,65 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@boring.tools/ui'
import { Link, useLocation } from '@tanstack/react-router'
import { Link } 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 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])
const { data, error } = usePageList()
return (
<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>
<Collapsible asChild>
<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>
{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>
))}
<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>
))}
<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>
<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>
)
}

View File

@ -1,4 +1,4 @@
import { ChevronsUpDown, KeyRoundIcon, TerminalIcon } from 'lucide-react'
import { ChevronsUpDown } from 'lucide-react'
import {
Avatar,
@ -14,7 +14,6 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
useSidebar,
} from '@boring.tools/ui'
import { SignOutButton, useUser } from '@clerk/clerk-react'
@ -26,26 +25,6 @@ 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

@ -1,67 +0,0 @@
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,8 +2,6 @@ import type {
ChangelogCreateInput,
ChangelogOutput,
ChangelogUpdateInput,
CommitOutput,
VersionCreateAutoInput,
VersionCreateInput,
VersionOutput,
VersionUpdateInput,
@ -19,11 +17,8 @@ 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({
@ -37,23 +32,6 @@ 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()
@ -157,29 +135,6 @@ 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

@ -1,20 +0,0 @@
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

@ -1,21 +0,0 @@
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 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.
// This file is auto-generated by TanStack Router
import { createFileRoute } from '@tanstack/react-router'
@ -17,17 +17,14 @@ 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')()
@ -38,60 +35,39 @@ 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(() =>
@ -99,21 +75,11 @@ 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(() =>
@ -121,7 +87,6 @@ const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({
)
const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({
id: '/',
path: '/',
getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() =>
@ -129,14 +94,12 @@ 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(() =>
@ -144,7 +107,6 @@ const ChangelogIdVersionCreateLazyRoute =
)
const ChangelogIdEditLazyRoute = ChangelogIdEditLazyImport.update({
id: '/edit',
path: '/edit',
getParentRoute: () => ChangelogIdLazyRoute,
} as any).lazy(() =>
@ -153,7 +115,6 @@ const ChangelogIdEditLazyRoute = ChangelogIdEditLazyImport.update({
const ChangelogIdVersionVersionIdRoute =
ChangelogIdVersionVersionIdImport.update({
id: '/version/$versionId',
path: '/version/$versionId',
getParentRoute: () => ChangelogIdLazyRoute,
} as any)
@ -169,20 +130,6 @@ 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'
@ -211,13 +158,6 @@ 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'
@ -320,13 +260,10 @@ 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
@ -340,11 +277,8 @@ 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
@ -359,13 +293,10 @@ 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
@ -381,13 +312,10 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/cli'
| '/access-tokens/new'
| '/changelog/$id'
| '/changelog/create'
| '/page/$id'
| '/page/create'
| '/access-tokens'
| '/changelog'
| '/page'
| '/user'
@ -400,11 +328,8 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/cli'
| '/access-tokens/new'
| '/changelog/create'
| '/page/create'
| '/access-tokens'
| '/changelog'
| '/page'
| '/user'
@ -417,13 +342,10 @@ export interface FileRouteTypes {
id:
| '__root__'
| '/'
| '/cli'
| '/access-tokens/new'
| '/changelog/$id'
| '/changelog/create'
| '/page/$id'
| '/page/create'
| '/access-tokens/'
| '/changelog/'
| '/page/'
| '/user/'
@ -438,13 +360,10 @@ 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
@ -452,13 +371,10 @@ 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,
@ -468,6 +384,8 @@ export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* prettier-ignore-end */
/* ROUTE_MANIFEST_START
{
"routes": {
@ -475,13 +393,10 @@ 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/"
@ -490,12 +405,6 @@ 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": [
@ -518,9 +427,6 @@ 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

@ -1,36 +0,0 @@
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

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

View File

@ -1,12 +1,58 @@
import { createLazyFileRoute } from '@tanstack/react-router'
import { ChangelogCommitList } from '../components/Changelog/CommitList'
import { ChangelogVersionList } from '../components/Changelog/VersionList'
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'
const Component = () => {
const { id } = Route.useParams()
const { data, isPending } = useChangelogById({ id })
return (
<div className="flex gap-5 flex-wrap">
<ChangelogVersionList />
<ChangelogCommitList />
<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>
)
}

View File

@ -13,6 +13,7 @@ import { useChangelogById } from '../hooks/useChangelog'
const Component = () => {
const { id } = Route.useParams()
const { data, error, isPending, refetch } = useChangelogById({ id })
console.log(data)
if (error) {
return (
<div className="flex items-center justify-center mt-32 flex-col">
@ -64,7 +65,16 @@ 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,30 +2,21 @@ 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'
@ -46,19 +37,17 @@ 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, useState } from 'react'
import { useEffect, useRef } 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` })
@ -66,8 +55,6 @@ 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,
@ -87,12 +74,9 @@ const Component = () => {
useEffect(() => {
if (data) {
mdxEditorRef.current?.setMarkdown(data.markdown)
form.reset({
...data,
commitIds: data.commits?.map((commit) => commit.id),
})
form.reset(data)
}
}, [data, form])
}, [data, form.reset])
if (error) {
return (
@ -109,332 +93,152 @@ const Component = () => {
<div className="flex flex-col gap-5">
<Separator />
{!isPending && data && (
<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>
<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}
>
<FormControl>
<Input placeholder="v1.0.1" {...field} />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<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>
)}
/>
<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"
<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',
)}
>
<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 />
</>
),
}),
]}
{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}
/>
</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>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<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>
<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>
<ChangelogVersionDelete id={id} versionId={versionId} />
</form>
</Form>
<ChangelogVersionDelete id={id} versionId={versionId} />
</div>
)}
</div>
)

View File

@ -2,11 +2,6 @@ import { VersionCreateInput } from '@boring.tools/schema'
import {
Button,
Calendar,
Card,
CardContent,
CardHeader,
CardTitle,
Checkbox,
Form,
FormControl,
FormField,
@ -17,12 +12,12 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
ScrollArea,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
cn,
} from '@boring.tools/ui'
import { zodResolver } from '@hookform/resolvers/zod'
@ -42,10 +37,7 @@ import { createLazyFileRoute } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import type { z } from 'zod'
import {
useChangelogCommitList,
useChangelogVersionCreate,
} from '../hooks/useChangelog'
import { useChangelogVersionCreate } from '../hooks/useChangelog'
import '@mdxeditor/editor/style.css'
import { format } from 'date-fns'
import { CalendarIcon } from 'lucide-react'
@ -54,9 +46,7 @@ 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: {
@ -64,18 +54,9 @@ 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) {
@ -85,213 +66,149 @@ const Component = () => {
}
return (
<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>
<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}
>
<FormControl>
<Input placeholder="v1.0.1" {...field} autoFocus />
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<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>
)}
/>
<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}
<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}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</div>
<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">
<div className="flex gap-5">
<Button
type="button"
variant={'ghost'}
@ -301,9 +218,9 @@ const Component = () => {
</Button>
<Button type="submit">Create</Button>
</div>
</div>
</form>
</Form>
</form>
</Form>
</div>
)
}

View File

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

View File

@ -26,45 +26,47 @@ 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 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>
<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>
<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>
<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>
</div>
</>
</PageWrapper>
)
}

View File

@ -1,121 +0,0 @@
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,71 +1,13 @@
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'
const Component = () => {
const { data } = useStatistic()
const user = useUser()
export const Route = createLazyFileRoute('/')({
component: Index,
})
function Index() {
return (
<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>
<div className="p-2">
<h3>Welcome Home!</h3>
</div>
)
}
export const Route = createLazyFileRoute('/')({
component: Component,
})

View File

@ -1,10 +1,6 @@
import { PageUpdateInput } from '@boring.tools/schema'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Command,
CommandEmpty,
CommandGroup,
@ -44,7 +40,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>) => {
@ -61,147 +57,120 @@ 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="flex flex-col gap-10 w-full max-w-screen-lg"
className="space-y-8 max-w-screen-md"
>
<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="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}
value={field.value ?? ''}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Some details about the page..."
{...field}
/>
</FormControl>{' '}
<FormMessage />
</FormItem>
)}
/>
<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',
)}
<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())
}}
>
{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">
<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">
<Button
type="button"
variant={'ghost'}
@ -209,7 +178,7 @@ const Component = () => {
>
Cancel
</Button>
<Button type="submit">Save</Button>
<Button type="submit">Update</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 } from 'lucide-react'
import { CircleMinusIcon, PlusCircleIcon } from 'lucide-react'
import { usePageById, usePageUpdate } from '../hooks/usePage'
const Component = () => {
@ -31,14 +31,12 @@ const Component = () => {
<Card className="w-full max-w-screen-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>
Changelogs ({data.changelogs?.length ?? 0})
</CardTitle>
<CardTitle>Changelogs ({data.changelogs?.length})</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,10 +1,6 @@
import { PageCreateInput } from '@boring.tools/schema'
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Command,
CommandEmpty,
CommandGroup,
@ -73,146 +69,115 @@ const Component = () => {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-10 w-full max-w-screen-lg"
className="space-y-8 max-w-screen-md"
>
<div className="flex gap-10 w-full">
<Card className="w-full">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My page" {...field} autoFocus />
</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="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
}
<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',
)}
return [...field.value, changelog.id]
}
form.setValue('changelogIds', getIds())
}}
>
{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>
<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>
</form>
</Form>
</div>

View File

@ -1,51 +1,10 @@
import { Card, CardContent, CardHeader, CardTitle } from '@boring.tools/ui'
import { createLazyFileRoute } from '@tanstack/react-router'
import { Link } from '@tanstack/react-router'
import { PlusCircleIcon } from 'lucide-react'
import { PageWrapper } from '../components/PageWrapper'
import { usePageList } from '../hooks/usePage'
//import { usePageById, usePageList } from '../hooks/usePage'
const Component = () => {
const { data, isPending } = usePageList()
//const { data, error } = usePageList()
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>
)
return <div>some</div>
}
export const Route = createLazyFileRoute('/page/')({

View File

@ -12,17 +12,13 @@ const url = import.meta.env.PROD
: 'http://localhost:3000'
export const queryFetch = async ({ path, method, data, token }: Fetch) => {
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.')
}
const response = await axios({
method,
url: `${url}/v1/${path}`,
data,
headers: {
Authorization: `Bearer ${token}`,
},
})
return response.data
}

View File

@ -1 +1 @@
{"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"}
{"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"}

175
apps/cli/.gitignore vendored
View File

@ -1,175 +0,0 @@
# 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

View File

@ -1,15 +0,0 @@
# 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.

View File

@ -1,22 +0,0 @@
{
"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"
}
}

View File

@ -1,21 +0,0 @@
#! /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

@ -1,49 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,20 +0,0 @@
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)
}

View File

@ -1,27 +0,0 @@
{
"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,7 +21,6 @@
"@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,7 +3,6 @@ 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
@ -30,9 +29,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
}
@ -56,7 +55,9 @@ const data: PageById = await response.json()
}
</div>
<div set:html={marked.parse(version.markdown ?? "")} />
<p>
{version.markdown}
</p>
</div>
)
})}
@ -65,7 +66,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) {
@ -88,7 +89,10 @@ const data: PageById = await response.json()
</p>
}
</div>
<div class="page" set:html={marked.parse(version.markdown ?? "")} />
<p>
{version.markdown}
</p>
</div>
)
})}

View File

@ -25,18 +25,8 @@ export default defineConfig({
favicon: '/favicon.svg',
sidebar: [
{
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: 'Guides',
items: [{ label: 'Getting started', slug: 'guides/getting-started' }],
},
{ 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