Changelog Public Page #7

Merged
HashDot merged 19 commits from feature/changelog-public-page into main 2024-10-24 21:20:22 +00:00
72 changed files with 3326 additions and 155 deletions

View File

@ -70,4 +70,7 @@ jobs:
run: bun docker:api
- name: Build and Push App
run: bun docker:app
run: bun docker:app
- name: Build and Push Page
run: bun docker:page

View File

@ -32,6 +32,11 @@ export const func = async ({ userId, id }: { userId: string; id: string }) => {
const result = await db.query.changelog.findFirst({
where: and(eq(changelog.userId, userId), eq(changelog.id, id)),
with: {
pages: {
with: {
page: true,
},
},
versions: {
orderBy: (changelog_version, { desc }) => [
desc(changelog_version.createdAt),

View File

@ -10,6 +10,7 @@ import { HTTPException } from 'hono/http-exception'
export const route = createRoute({
method: 'put',
path: '/:id',
tags: ['changelog'],
request: {
body: {
content: {

View File

@ -8,7 +8,9 @@ import changelog from './changelog'
import version from './changelog/version'
import user from './user'
import pageApi from './page'
import { authentication } from './utils/authentication'
import { handleError, handleZodError } from './utils/errors'
import { startup } from './utils/startup'
type User = z.infer<typeof UserOutput>
@ -17,20 +19,24 @@ export type Variables = {
user: User
}
export const app = new OpenAPIHono<{ Variables: Variables }>()
export const app = new OpenAPIHono<{ Variables: Variables }>({
defaultHook: handleZodError,
})
app.use(
'*',
sentry({
dsn: 'https://1d7428bbab0a305078cf4aa380721aa2@o4508167321354240.ingest.de.sentry.io/4508167323648080',
}),
)
// app.use(
// '*',
// sentry({
// dsn: 'https://1d7428bbab0a305078cf4aa380721aa2@o4508167321354240.ingest.de.sentry.io/4508167323648080',
// }),
// )
app.onError(handleError)
app.use('*', cors())
app.use('/v1/*', authentication)
app.route('/v1/user', user)
app.route('/v1/changelog', changelog)
app.route('/v1/changelog/version', version)
app.route('/v1/page', pageApi)
app.doc('/openapi.json', {
openapi: '3.0.0',

65
apps/api/src/page/byId.ts Normal file
View File

@ -0,0 +1,65 @@
import { db, page } from '@boring.tools/database'
import { PageByIdParams, PageOutput } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import { verifyAuthentication } from '../utils/authentication'
import type { pageApi } from './index'
const route = createRoute({
method: 'get',
tags: ['page'],
description: 'Get a page by id',
path: '/:id',
request: {
params: PageByIdParams,
},
responses: {
200: {
content: {
'application/json': {
schema: PageOutput,
},
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
})
export const registerPageById = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const { id } = c.req.valid('param')
const result = await db.query.page.findFirst({
where: and(eq(page.id, id), eq(page.userId, userId)),
with: {
changelogs: {
with: {
changelog: true,
},
},
},
})
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
const { changelogs, ...rest } = result
const mappedResult = {
...rest,
changelogs: changelogs.map((log) => log.changelog),
}
return c.json(mappedResult, 200)
})
}

View File

@ -0,0 +1,69 @@
import { changelogs_to_pages, db, page } from '@boring.tools/database'
import { PageCreateInput, PageOutput } from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import { HTTPException } from 'hono/http-exception'
import { verifyAuthentication } from '../utils/authentication'
import type { pageApi } from './index'
const route = createRoute({
method: 'post',
tags: ['page'],
description: 'Create a page',
path: '/',
request: {
body: {
content: {
'application/json': { schema: PageCreateInput },
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: PageOutput,
},
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
})
export const registerPageCreate = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const { changelogIds, ...rest }: z.infer<typeof PageCreateInput> =
await c.req.json()
const [result] = await db
.insert(page)
.values({
...rest,
userId: userId,
})
.returning()
// TODO: implement transaction
if (changelogIds) {
await db.insert(changelogs_to_pages).values(
changelogIds.map((changelogId) => ({
changelogId,
pageId: result.id,
})),
)
}
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(result, 200)
})
}

View File

@ -0,0 +1,48 @@
import { db, page } from '@boring.tools/database'
import { GeneralOutput, PageByIdParams } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import type { pageApi } from '.'
import { verifyAuthentication } from '../utils/authentication'
const route = createRoute({
method: 'delete',
path: '/:id',
request: {
params: PageByIdParams,
},
responses: {
200: {
content: {
'application/json': {
schema: GeneralOutput,
},
},
description: 'Removes a changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
})
export const registerPageDelete = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const { id } = c.req.valid('param')
const result = await db
.delete(page)
.where(and(eq(page.userId, userId), eq(page.id, id)))
.returning()
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(result, 200)
})
}

View File

@ -0,0 +1,24 @@
import { OpenAPIHono } from '@hono/zod-openapi'
import type { Variables } from '..'
import type { ContextModule } from '../utils/sentry'
import { registerPageById } from './byId'
import { registerPageCreate } from './create'
import { registerPageDelete } from './delete'
import { registerPageList } from './list'
import { registerPagePublic } from './public'
import { registerPageUpdate } from './update'
export const pageApi = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'page',
}
registerPageById(pageApi)
registerPageCreate(pageApi)
registerPageList(pageApi)
registerPagePublic(pageApi)
registerPageDelete(pageApi)
registerPageUpdate(pageApi)
export default pageApi

47
apps/api/src/page/list.ts Normal file
View File

@ -0,0 +1,47 @@
import { db, page } from '@boring.tools/database'
import { createRoute } from '@hono/zod-openapi'
import { and, eq } from 'drizzle-orm'
import { PageListOutput } from '@boring.tools/schema'
import { HTTPException } from 'hono/http-exception'
import { verifyAuthentication } from '../utils/authentication'
import type { pageApi } from './index'
const route = createRoute({
method: 'get',
tags: ['page'],
description: 'Get a page list',
path: '/',
responses: {
200: {
content: {
'application/json': {
schema: PageListOutput,
},
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
})
export const registerPageList = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const result = await db.query.page.findMany({
where: and(eq(page.userId, userId)),
})
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(result, 200)
})
}

View File

@ -0,0 +1,86 @@
import { changelog_version, db, page } from '@boring.tools/database'
import { createRoute } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm'
import { PagePublicOutput, PagePublicParams } from '@boring.tools/schema'
import { HTTPException } from 'hono/http-exception'
import type { pageApi } from './index'
const route = createRoute({
method: 'get',
tags: ['page'],
description: 'Get a page',
path: '/:id/public',
request: {
params: PagePublicParams,
},
responses: {
200: {
content: {
'application/json': {
schema: PagePublicOutput,
},
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
})
export const registerPagePublic = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const { id } = c.req.valid('param')
const result = await db.query.page.findFirst({
where: eq(page.id, id),
columns: {
title: true,
description: true,
icon: true,
},
with: {
changelogs: {
with: {
changelog: {
columns: {
title: true,
description: true,
},
with: {
versions: {
where: eq(changelog_version.status, 'published'),
orderBy: (changelog_version, { desc }) => [
desc(changelog_version.createdAt),
],
columns: {
markdown: true,
version: true,
releasedAt: true,
},
},
},
},
},
},
},
})
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
const { changelogs, ...rest } = result
const mappedResult = {
...rest,
changelogs: changelogs.map((log) => log.changelog),
}
return c.json(mappedResult, 200)
})
}

View File

@ -0,0 +1,88 @@
import { changelogs_to_pages, db, page } from '@boring.tools/database'
import { createRoute, type z } from '@hono/zod-openapi'
import {
PageOutput,
PageUpdateInput,
PageUpdateParams,
} from '@boring.tools/schema'
import { and, eq } from 'drizzle-orm'
import { HTTPException } from 'hono/http-exception'
import { verifyAuthentication } from '../utils/authentication'
import type { pageApi } from './index'
const route = createRoute({
method: 'put',
tags: ['page'],
description: 'Update a page',
path: '/:id',
request: {
params: PageUpdateParams,
body: {
content: {
'application/json': { schema: PageUpdateInput },
},
},
},
responses: {
200: {
content: {
'application/json': {
schema: PageOutput,
},
},
description: 'Return changelog by id',
},
400: {
description: 'Bad Request',
},
500: {
description: 'Internal Server Error',
},
},
})
export const registerPageUpdate = (api: typeof pageApi) => {
return api.openapi(route, async (c) => {
const userId = verifyAuthentication(c)
const { id } = c.req.valid('param')
const { changelogIds, ...rest }: z.infer<typeof PageUpdateInput> =
await c.req.json()
const [result] = await db
.update(page)
.set({
...rest,
userId,
})
.where(and(eq(page.userId, userId), eq(page.id, id)))
.returning()
// TODO: implement transaction
if (changelogIds) {
if (changelogIds.length === 0) {
await db
.delete(changelogs_to_pages)
.where(eq(changelogs_to_pages.pageId, result.id))
}
if (changelogIds?.length >= 1) {
await db
.delete(changelogs_to_pages)
.where(eq(changelogs_to_pages.pageId, result.id))
await db.insert(changelogs_to_pages).values(
changelogIds.map((changelogId) => ({
changelogId,
pageId: result.id,
})),
)
}
}
if (!result) {
throw new HTTPException(404, { message: 'Not Found' })
}
return c.json(result, 200)
})
}

View File

@ -1,11 +1,13 @@
import { type UserSelect, db, user as userDb } from '@boring.tools/database'
import { db, user as userDb } from '@boring.tools/database'
import { UserOutput } from '@boring.tools/schema'
import { createRoute } from '@hono/zod-openapi'
import { eq } from 'drizzle-orm'
import type { userApi } from '.'
export const route = createRoute({
const route = createRoute({
method: 'get',
path: '/',
tags: ['user'],
responses: {
200: {
content: {
@ -22,19 +24,17 @@ export const route = createRoute({
},
})
export const func = async ({ user }: { user: UserSelect }) => {
const result = await db.query.user.findFirst({
where: eq(userDb.id, user.id),
export const registerUserGet = (api: typeof userApi) => {
return api.openapi(route, async (c) => {
const user = c.get('user')
const result = await db.query.user.findFirst({
where: eq(userDb.id, user.id),
})
if (!result) {
throw new Error('User not found')
}
return c.json(result, 200)
})
if (!result) {
throw new Error('User not found')
}
return result
}
export default {
route,
func,
}

View File

@ -1,48 +1,16 @@
import { logger } from '@boring.tools/logger'
import { OpenAPIHono } from '@hono/zod-openapi'
import { Webhook } from 'svix'
import type { Variables } from '..'
import { type ContextModule, captureSentry } from '../utils/sentry'
import get from './get'
import webhook from './webhook'
import type { ContextModule } from '../utils/sentry'
import { registerUserGet } from './get'
import { registerUserWebhook } from './webhook'
const app = new OpenAPIHono<{ Variables: Variables }>()
export const userApi = new OpenAPIHono<{ Variables: Variables }>()
const module: ContextModule = {
name: 'user',
}
app.openapi(get.route, async (c) => {
const user = c.get('user')
try {
const result = await get.func({ user })
return c.json(result, 201)
} catch (error) {
return captureSentry({
c,
error,
module,
user,
})
}
})
registerUserGet(userApi)
registerUserWebhook(userApi)
app.openapi(webhook.route, async (c) => {
try {
const wh = new Webhook(import.meta.env.CLERK_WEBHOOK_SECRET as string)
const payload = await c.req.json()
const headers = c.req.header()
const verifiedPayload = wh.verify(JSON.stringify(payload), headers)
const result = await webhook.func({ payload: verifiedPayload })
logger.info('Clerk Webhook', result)
return c.json(result, 200)
} catch (error) {
return captureSentry({
c,
error,
module,
})
}
})
export default app
export default userApi

View File

@ -3,10 +3,13 @@ import { logger } from '@boring.tools/logger'
import { UserOutput, UserWebhookInput } from '@boring.tools/schema'
import { createRoute, type z } from '@hono/zod-openapi'
import { HTTPException } from 'hono/http-exception'
import { Webhook } from 'svix'
import type userApi from '.'
export const route = createRoute({
const route = createRoute({
method: 'post',
path: '/webhook',
tags: ['user'],
request: {
body: {
content: {
@ -59,20 +62,20 @@ const userCreate = async ({
}
}
export const func = async ({
payload,
}: {
payload: z.infer<typeof UserWebhookInput>
}) => {
switch (payload.type) {
case 'user.created':
return userCreate({ payload })
default:
throw new HTTPException(404, { message: 'Webhook type not supported' })
}
}
export default {
route,
func,
export const registerUserWebhook = (api: typeof userApi) => {
return api.openapi(route, async (c) => {
const wh = new Webhook(import.meta.env.CLERK_WEBHOOK_SECRET as string)
const payload = await c.req.json()
const headers = c.req.header()
const verifiedPayload = wh.verify(JSON.stringify(payload), headers)
switch (verifiedPayload.type) {
case 'user.created': {
const result = await userCreate({ payload: verifiedPayload })
logger.info('Clerk Webhook', result)
return c.json(result, 200)
}
default:
throw new HTTPException(404, { message: 'Webhook type not supported' })
}
})
}

View File

@ -0,0 +1,46 @@
import type { ErrorCode } from '.'
type ErrorContext = Record<string, unknown>
export abstract class BaseError<
TContext extends ErrorContext = ErrorContext,
> extends Error {
public abstract readonly name: string
/**
* A distinct code for the error type used to differentiate between different types of errors.
* Used to build the URL for the error documentation.
* @example 'UNAUTHENTICATED' | 'INTERNAL_SERVER_ERROR'
*/
public abstract readonly code?: ErrorCode
public readonly cause?: BaseError
/**
* Additional context to help understand the error.
* @example { url: 'https://example.com/api', method: 'GET', statusCode: 401 }
*/
public readonly context?: TContext
constructor(opts: {
message: string
cause?: BaseError
context?: TContext
}) {
super(opts.message)
this.cause = opts.cause
this.context = opts.context
// TODO: add logger here!
}
public toString(): string {
return `${this.name}(${this.code}): ${
this.message
} - caused by ${this.cause?.toString()} - with context ${JSON.stringify(
this.context,
)}`
}
// get docs(): string {
// if (!this.code) return "https://example.com/docs/errors"
// return `https://example.com/docs/errors/${this.code}`;
// }
}

View File

@ -0,0 +1,35 @@
import { type ErrorCode, statusToCode } from '.'
import { BaseError } from './base-error'
type Context = {
url?: string
method?: string
statusCode?: number
}
export class HttpError extends BaseError<Context> {
public readonly name = HttpError.name
public readonly code: ErrorCode
constructor(opts: {
code: ErrorCode
message: string
cause?: BaseError
context?: Context
}) {
super(opts)
this.code = opts.code
}
public static fromRequest(request: Request, response: Response) {
return new HttpError({
code: statusToCode(response.status),
message: response.statusText, // can be overriden with { ...res, statusText: 'Custom message' }
context: {
url: request.url,
method: request.method,
statusCode: response.status,
},
})
}
}

View File

@ -0,0 +1,120 @@
import type { Context } from 'hono'
import { HTTPException } from 'hono/http-exception'
import { ZodError, z } from 'zod'
import { SchemaError } from './schema-error'
export const ErrorCodeEnum = z.enum([
'BAD_REQUEST',
'FORBIDDEN',
'INTERNAL_SERVER_ERROR',
'USAGE_EXCEEDED',
'DISABLED',
'CONFLICT',
'NOT_FOUND',
'NOT_UNIQUE',
'UNAUTHORIZED',
'METHOD_NOT_ALLOWED',
'UNPROCESSABLE_ENTITY',
])
export type ErrorCode = z.infer<typeof ErrorCodeEnum>
export function statusToCode(status: number): ErrorCode {
switch (status) {
case 400:
return 'BAD_REQUEST'
case 401:
return 'UNAUTHORIZED'
case 403:
return 'FORBIDDEN'
case 404:
return 'NOT_FOUND'
case 405:
return 'METHOD_NOT_ALLOWED'
case 409:
return 'METHOD_NOT_ALLOWED'
case 422:
return 'UNPROCESSABLE_ENTITY'
case 500:
return 'INTERNAL_SERVER_ERROR'
default:
return 'INTERNAL_SERVER_ERROR'
}
}
export type ErrorSchema = z.infer<ReturnType<typeof createErrorSchema>>
export function createErrorSchema(code: ErrorCode) {
return z.object({
code: ErrorCodeEnum.openapi({
example: code,
description: 'The error code related to the status code.',
}),
message: z.string().openapi({
description: 'A human readable message describing the issue.',
example: "Missing required field 'name'.",
}),
docs: z.string().openapi({
description: 'A link to the documentation for the error.',
example: `https://docs.openstatus.dev/api-references/errors/code/${code}`,
}),
})
}
export function handleError(err: Error, c: Context): Response {
if (err instanceof ZodError) {
const error = SchemaError.fromZod(err, c)
return c.json<ErrorSchema>(
{
code: 'BAD_REQUEST',
message: error.message,
docs: 'https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST',
},
{ status: 400 },
)
}
if (err instanceof HTTPException) {
const code = statusToCode(err.status)
return c.json<ErrorSchema>(
{
code: code,
message: err.message,
docs: `https://docs.openstatus.dev/api-references/errors/code/${code}`,
},
{ status: err.status },
)
}
return c.json<ErrorSchema>(
{
code: 'INTERNAL_SERVER_ERROR',
message: err.message ?? 'Something went wrong',
docs: 'https://docs.openstatus.dev/api-references/errors/code/INTERNAL_SERVER_ERROR',
},
{ status: 500 },
)
}
export function handleZodError(
result:
| {
success: true
data: unknown
}
| {
success: false
error: ZodError
},
c: Context,
) {
if (!result.success) {
const error = SchemaError.fromZod(result.error, c)
return c.json<z.infer<ReturnType<typeof createErrorSchema>>>(
{
code: 'BAD_REQUEST',
docs: 'https://docs.openstatus.dev/api-references/errors/code/BAD_REQUEST',
message: error.message,
},
{ status: 400 },
)
}
}

View File

@ -0,0 +1,30 @@
import type { ZodError } from 'zod'
import type { ErrorCode } from '.'
import { BaseError } from './base-error'
import { parseZodErrorIssues } from './utils'
type Context = { raw: unknown }
export class SchemaError extends BaseError<Context> {
public readonly name = SchemaError.name
public readonly code: ErrorCode
constructor(opts: {
code: ErrorCode
message: string
cause?: BaseError
context?: Context
}) {
super(opts)
this.code = opts.code
}
static fromZod<T>(e: ZodError<T>, raw: unknown): SchemaError {
return new SchemaError({
code: 'UNPROCESSABLE_ENTITY',
message: parseZodErrorIssues(e.issues),
context: { raw: JSON.stringify(raw) },
})
}
}

View File

@ -0,0 +1,66 @@
import type { ZodIssue } from 'zod'
import type { ErrorCode } from '.'
export function statusToCode(status: number): ErrorCode {
switch (status) {
case 400:
return 'BAD_REQUEST'
case 401:
return 'UNAUTHORIZED'
case 403:
return 'FORBIDDEN'
case 404:
return 'NOT_FOUND'
case 405:
return 'METHOD_NOT_ALLOWED'
case 409:
return 'METHOD_NOT_ALLOWED'
case 422:
return 'UNPROCESSABLE_ENTITY'
case 500:
return 'INTERNAL_SERVER_ERROR'
default:
return 'INTERNAL_SERVER_ERROR'
}
}
export function codeToStatus(code: ErrorCode): number {
switch (code) {
case 'BAD_REQUEST':
return 400
case 'UNAUTHORIZED':
return 401
case 'FORBIDDEN':
return 403
case 'NOT_FOUND':
return 404
case 'METHOD_NOT_ALLOWED':
return 405
case 'CONFLICT':
return 409
case 'UNPROCESSABLE_ENTITY':
return 422
case 'INTERNAL_SERVER_ERROR':
return 500
default:
return 500
}
}
// Props to cal.com: https://github.com/calcom/cal.com/blob/5d325495a9c30c5a9d89fc2adfa620b8fde9346e/packages/lib/server/getServerErrorFromUnknown.ts#L17
export function parseZodErrorIssues(issues: ZodIssue[]): string {
return issues
.map((i) =>
i.code === 'invalid_union'
? i.unionErrors.map((ue) => parseZodErrorIssues(ue.issues)).join('; ')
: i.code === 'unrecognized_keys'
? i.message
: `${i.path.length ? `${i.code} in '${i.path}': ` : ''}${i.message}`,
)
.join('; ')
}
export function redactError<TError extends Error | unknown>(err: TError) {
if (!(err instanceof Error)) return err
console.error(`Type of Error: ${err.constructor}`)
}

View File

@ -0,0 +1,72 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@boring.tools/ui'
import { useNavigate } from '@tanstack/react-router'
import { Trash2Icon } from 'lucide-react'
import { useState } from 'react'
import { usePageDelete } from '../../hooks/usePage'
export const PageDelete = ({ id }: { id: string }) => {
const remove = usePageDelete()
const navigate = useNavigate({ from: `/page/${id}` })
const [isOpen, setIsOpen] = useState(false)
const removeChangelog = () => {
remove.mutate(
{ id },
{
onSuccess: () => {
setIsOpen(false)
navigate({ to: '/page' })
},
},
)
}
return (
<Tooltip>
<AlertDialog open={isOpen}>
<AlertDialogTrigger asChild>
<TooltipTrigger asChild>
<Button variant={'ghost'} 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
page and remove your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setIsOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={removeChangelog} variant={'destructive'}>
Remove
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TooltipContent>
<p>Remove</p>
</TooltipContent>
</Tooltip>
)
}

View File

@ -1,37 +1,19 @@
import { ChevronRightIcon, FileStackIcon } from 'lucide-react'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
Sidebar as SidebarComp,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@boring.tools/ui'
import { Link } from '@tanstack/react-router'
import { useChangelogList } from '../hooks/useChangelog'
import { SidebarChangelog } from './SidebarChangelog'
import { SidebarPage } from './SidebarPage'
import { SidebarUser } from './SidebarUser'
const items = [
{
title: 'Changelog',
url: '/changelog',
icon: FileStackIcon,
isActive: true,
},
]
export function Sidebar() {
const { data, error } = useChangelogList()
return (
<SidebarComp>
<SidebarHeader>
@ -53,47 +35,8 @@ export function Sidebar() {
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
{items.map((item) => (
<Collapsible key={item.title} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={item.title}>
<Link
to={item.url}
activeProps={{ className: 'bg-sidebar-accent' }}
>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<ChevronRightIcon />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{!error &&
data?.map((changelog) => (
<SidebarMenuSubItem key={changelog.id}>
<SidebarMenuSubButton asChild>
<Link
to={`/changelog/${changelog.id}`}
activeProps={{
className: 'bg-sidebar-primary',
}}
>
<span>{changelog.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
))}
<SidebarChangelog />
<SidebarPage />
</SidebarMenu>
</SidebarGroup>
</SidebarContent>

View File

@ -0,0 +1,76 @@
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@boring.tools/ui'
import { Link } from '@tanstack/react-router'
import { ChevronRightIcon, FileStackIcon, PlusIcon } from 'lucide-react'
import { useChangelogList } from '../hooks/useChangelog'
export const SidebarChangelog = () => {
const { data, error } = useChangelogList()
return (
<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>
{!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>
)
}

View File

@ -0,0 +1,73 @@
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from '@boring.tools/ui'
import { Link } from '@tanstack/react-router'
import { ChevronRightIcon, NotebookTextIcon, PlusIcon } from 'lucide-react'
import { usePageList } from '../hooks/usePage'
export const SidebarPage = () => {
const { data, error } = usePageList()
return (
<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>
{!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>
)
}

View File

@ -0,0 +1,196 @@
import type {
PageByIdOutput,
PageCreateInput,
PageListOutput,
PageOutput,
PageUpdateInput,
} from '@boring.tools/schema'
import { useAuth } from '@clerk/clerk-react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { z } from 'zod'
import { queryFetch } from '../utils/queryFetch'
type Page = z.infer<typeof PageOutput>
type PageList = z.infer<typeof PageListOutput>
type PageById = z.infer<typeof PageByIdOutput>
type PageCreate = z.infer<typeof PageCreateInput>
type PageUpdate = z.infer<typeof PageUpdateInput>
export const usePageList = () => {
const { getToken } = useAuth()
return useQuery({
queryKey: ['pageList'],
queryFn: async (): Promise<Readonly<PageList>> =>
await queryFetch({
path: 'page',
method: 'get',
token: await getToken(),
}),
})
}
export const usePageById = ({ id }: { id: string }) => {
const { getToken } = useAuth()
return useQuery({
queryKey: ['pageById', id],
queryFn: async (): Promise<Readonly<PageById>> =>
await queryFetch({
path: `page/${id}`,
method: 'get',
token: await getToken(),
}),
})
}
export const usePageCreate = () => {
const { getToken } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: PageCreate): Promise<Readonly<Page>> =>
await queryFetch({
path: 'page',
data: payload,
method: 'post',
token: await getToken(),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pageList'] })
},
})
}
export const usePageDelete = () => {
const { getToken } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id }: { id: string }): Promise<Readonly<Page>> =>
await queryFetch({
path: `page/${id}`,
method: 'delete',
token: await getToken(),
}),
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: ['pageList'],
})
queryClient.invalidateQueries({
queryKey: ['pageById', data.id],
})
},
})
}
export const usePageUpdate = () => {
const { getToken } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
id,
payload,
}: {
id: string
payload: PageUpdate
}): Promise<Readonly<Page>> =>
await queryFetch({
path: `page/${id}`,
data: payload,
method: 'put',
token: await getToken(),
}),
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: ['pageById', data.id],
})
queryClient.invalidateQueries({
queryKey: ['pageList'],
})
},
})
}
/*
export const useChangelogVersionCreate = () => {
const { getToken } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: VersionCreate): Promise<Readonly<Version>> =>
await queryFetch({
path: 'changelog/version',
data: payload,
method: 'post',
token: await getToken(),
}),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['changelogList'] })
queryClient.invalidateQueries({
queryKey: ['changelogById', data.changelogId],
})
},
})
}
export const useChangelogVersionById = ({ id }: { id: string }) => {
const { getToken } = useAuth()
return useQuery({
queryKey: ['changelogVersionById', id],
queryFn: async (): Promise<Readonly<Version>> =>
await queryFetch({
path: `changelog/version/${id}`,
method: 'get',
token: await getToken(),
}),
})
}
export const useChangelogVersionUpdate = () => {
const { getToken } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
id,
payload,
}: {
id: string
payload: VersionUpdate
}): Promise<Readonly<Version>> =>
await queryFetch({
path: `changelog/version/${id}`,
data: payload,
method: 'put',
token: await getToken(),
}),
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: ['changelogById', data.id],
})
},
})
}
export const useChangelogVersionRemove = () => {
const { getToken } = useAuth()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id }: { id: string }): Promise<Readonly<Version>> =>
await queryFetch({
path: `changelog/version/${id}`,
method: 'delete',
token: await getToken(),
}),
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: ['changelogList', 'changelogById', data.id],
})
},
})
}
*/

View File

@ -19,10 +19,15 @@ import { Route as ChangelogIdVersionVersionIdImport } from './routes/changelog.$
const IndexLazyImport = createFileRoute('/')()
const UserIndexLazyImport = createFileRoute('/user/')()
const PageIndexLazyImport = createFileRoute('/page/')()
const ChangelogIndexLazyImport = createFileRoute('/changelog/')()
const PageCreateLazyImport = createFileRoute('/page/create')()
const PageIdLazyImport = createFileRoute('/page/$id')()
const ChangelogCreateLazyImport = createFileRoute('/changelog/create')()
const ChangelogIdLazyImport = createFileRoute('/changelog/$id')()
const PageIdIndexLazyImport = createFileRoute('/page/$id/')()
const ChangelogIdIndexLazyImport = createFileRoute('/changelog/$id/')()
const PageIdEditLazyImport = createFileRoute('/page/$id/edit')()
const ChangelogIdVersionCreateLazyImport = createFileRoute(
'/changelog/$id/versionCreate',
)()
@ -40,6 +45,11 @@ const UserIndexLazyRoute = UserIndexLazyImport.update({
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/user/index.lazy').then((d) => d.Route))
const PageIndexLazyRoute = PageIndexLazyImport.update({
path: '/page/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.index.lazy').then((d) => d.Route))
const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({
path: '/changelog/',
getParentRoute: () => rootRoute,
@ -47,6 +57,16 @@ const ChangelogIndexLazyRoute = ChangelogIndexLazyImport.update({
import('./routes/changelog.index.lazy').then((d) => d.Route),
)
const PageCreateLazyRoute = PageCreateLazyImport.update({
path: '/page/create',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.create.lazy').then((d) => d.Route))
const PageIdLazyRoute = PageIdLazyImport.update({
path: '/page/$id',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/page.$id.lazy').then((d) => d.Route))
const ChangelogCreateLazyRoute = ChangelogCreateLazyImport.update({
path: '/changelog/create',
getParentRoute: () => rootRoute,
@ -59,6 +79,13 @@ const ChangelogIdLazyRoute = ChangelogIdLazyImport.update({
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/changelog.$id.lazy').then((d) => d.Route))
const PageIdIndexLazyRoute = PageIdIndexLazyImport.update({
path: '/',
getParentRoute: () => PageIdLazyRoute,
} as any).lazy(() =>
import('./routes/page.$id.index.lazy').then((d) => d.Route),
)
const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({
path: '/',
getParentRoute: () => ChangelogIdLazyRoute,
@ -66,6 +93,11 @@ const ChangelogIdIndexLazyRoute = ChangelogIdIndexLazyImport.update({
import('./routes/changelog.$id.index.lazy').then((d) => d.Route),
)
const PageIdEditLazyRoute = PageIdEditLazyImport.update({
path: '/edit',
getParentRoute: () => PageIdLazyRoute,
} as any).lazy(() => import('./routes/page.$id.edit.lazy').then((d) => d.Route))
const ChangelogIdVersionCreateLazyRoute =
ChangelogIdVersionCreateLazyImport.update({
path: '/versionCreate',
@ -112,6 +144,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChangelogCreateLazyImport
parentRoute: typeof rootRoute
}
'/page/$id': {
id: '/page/$id'
path: '/page/$id'
fullPath: '/page/$id'
preLoaderRoute: typeof PageIdLazyImport
parentRoute: typeof rootRoute
}
'/page/create': {
id: '/page/create'
path: '/page/create'
fullPath: '/page/create'
preLoaderRoute: typeof PageCreateLazyImport
parentRoute: typeof rootRoute
}
'/changelog/': {
id: '/changelog/'
path: '/changelog'
@ -119,6 +165,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChangelogIndexLazyImport
parentRoute: typeof rootRoute
}
'/page/': {
id: '/page/'
path: '/page'
fullPath: '/page'
preLoaderRoute: typeof PageIndexLazyImport
parentRoute: typeof rootRoute
}
'/user/': {
id: '/user/'
path: '/user'
@ -140,6 +193,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChangelogIdVersionCreateLazyImport
parentRoute: typeof ChangelogIdLazyImport
}
'/page/$id/edit': {
id: '/page/$id/edit'
path: '/edit'
fullPath: '/page/$id/edit'
preLoaderRoute: typeof PageIdEditLazyImport
parentRoute: typeof PageIdLazyImport
}
'/changelog/$id/': {
id: '/changelog/$id/'
path: '/'
@ -147,6 +207,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChangelogIdIndexLazyImport
parentRoute: typeof ChangelogIdLazyImport
}
'/page/$id/': {
id: '/page/$id/'
path: '/'
fullPath: '/page/$id/'
preLoaderRoute: typeof PageIdIndexLazyImport
parentRoute: typeof PageIdLazyImport
}
'/changelog/$id/version/$versionId': {
id: '/changelog/$id/version/$versionId'
path: '/version/$versionId'
@ -177,26 +244,49 @@ const ChangelogIdLazyRouteWithChildren = ChangelogIdLazyRoute._addFileChildren(
ChangelogIdLazyRouteChildren,
)
interface PageIdLazyRouteChildren {
PageIdEditLazyRoute: typeof PageIdEditLazyRoute
PageIdIndexLazyRoute: typeof PageIdIndexLazyRoute
}
const PageIdLazyRouteChildren: PageIdLazyRouteChildren = {
PageIdEditLazyRoute: PageIdEditLazyRoute,
PageIdIndexLazyRoute: PageIdIndexLazyRoute,
}
const PageIdLazyRouteWithChildren = PageIdLazyRoute._addFileChildren(
PageIdLazyRouteChildren,
)
export interface FileRoutesByFullPath {
'/': typeof IndexLazyRoute
'/changelog/$id': typeof ChangelogIdLazyRouteWithChildren
'/changelog/create': typeof ChangelogCreateLazyRoute
'/page/$id': typeof PageIdLazyRouteWithChildren
'/page/create': typeof PageCreateLazyRoute
'/changelog': typeof ChangelogIndexLazyRoute
'/page': typeof PageIndexLazyRoute
'/user': typeof UserIndexLazyRoute
'/changelog/$id/edit': typeof ChangelogIdEditLazyRoute
'/changelog/$id/versionCreate': typeof ChangelogIdVersionCreateLazyRoute
'/page/$id/edit': typeof PageIdEditLazyRoute
'/changelog/$id/': typeof ChangelogIdIndexLazyRoute
'/page/$id/': typeof PageIdIndexLazyRoute
'/changelog/$id/version/$versionId': typeof ChangelogIdVersionVersionIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexLazyRoute
'/changelog/create': typeof ChangelogCreateLazyRoute
'/page/create': typeof PageCreateLazyRoute
'/changelog': typeof ChangelogIndexLazyRoute
'/page': typeof PageIndexLazyRoute
'/user': typeof UserIndexLazyRoute
'/changelog/$id/edit': typeof ChangelogIdEditLazyRoute
'/changelog/$id/versionCreate': typeof ChangelogIdVersionCreateLazyRoute
'/page/$id/edit': typeof PageIdEditLazyRoute
'/changelog/$id': typeof ChangelogIdIndexLazyRoute
'/page/$id': typeof PageIdIndexLazyRoute
'/changelog/$id/version/$versionId': typeof ChangelogIdVersionVersionIdRoute
}
@ -205,11 +295,16 @@ export interface FileRoutesById {
'/': typeof IndexLazyRoute
'/changelog/$id': typeof ChangelogIdLazyRouteWithChildren
'/changelog/create': typeof ChangelogCreateLazyRoute
'/page/$id': typeof PageIdLazyRouteWithChildren
'/page/create': typeof PageCreateLazyRoute
'/changelog/': typeof ChangelogIndexLazyRoute
'/page/': typeof PageIndexLazyRoute
'/user/': typeof UserIndexLazyRoute
'/changelog/$id/edit': typeof ChangelogIdEditLazyRoute
'/changelog/$id/versionCreate': typeof ChangelogIdVersionCreateLazyRoute
'/page/$id/edit': typeof PageIdEditLazyRoute
'/changelog/$id/': typeof ChangelogIdIndexLazyRoute
'/page/$id/': typeof PageIdIndexLazyRoute
'/changelog/$id/version/$versionId': typeof ChangelogIdVersionVersionIdRoute
}
@ -219,32 +314,46 @@ export interface FileRouteTypes {
| '/'
| '/changelog/$id'
| '/changelog/create'
| '/page/$id'
| '/page/create'
| '/changelog'
| '/page'
| '/user'
| '/changelog/$id/edit'
| '/changelog/$id/versionCreate'
| '/page/$id/edit'
| '/changelog/$id/'
| '/page/$id/'
| '/changelog/$id/version/$versionId'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/changelog/create'
| '/page/create'
| '/changelog'
| '/page'
| '/user'
| '/changelog/$id/edit'
| '/changelog/$id/versionCreate'
| '/page/$id/edit'
| '/changelog/$id'
| '/page/$id'
| '/changelog/$id/version/$versionId'
id:
| '__root__'
| '/'
| '/changelog/$id'
| '/changelog/create'
| '/page/$id'
| '/page/create'
| '/changelog/'
| '/page/'
| '/user/'
| '/changelog/$id/edit'
| '/changelog/$id/versionCreate'
| '/page/$id/edit'
| '/changelog/$id/'
| '/page/$id/'
| '/changelog/$id/version/$versionId'
fileRoutesById: FileRoutesById
}
@ -253,7 +362,10 @@ export interface RootRouteChildren {
IndexLazyRoute: typeof IndexLazyRoute
ChangelogIdLazyRoute: typeof ChangelogIdLazyRouteWithChildren
ChangelogCreateLazyRoute: typeof ChangelogCreateLazyRoute
PageIdLazyRoute: typeof PageIdLazyRouteWithChildren
PageCreateLazyRoute: typeof PageCreateLazyRoute
ChangelogIndexLazyRoute: typeof ChangelogIndexLazyRoute
PageIndexLazyRoute: typeof PageIndexLazyRoute
UserIndexLazyRoute: typeof UserIndexLazyRoute
}
@ -261,7 +373,10 @@ const rootRouteChildren: RootRouteChildren = {
IndexLazyRoute: IndexLazyRoute,
ChangelogIdLazyRoute: ChangelogIdLazyRouteWithChildren,
ChangelogCreateLazyRoute: ChangelogCreateLazyRoute,
PageIdLazyRoute: PageIdLazyRouteWithChildren,
PageCreateLazyRoute: PageCreateLazyRoute,
ChangelogIndexLazyRoute: ChangelogIndexLazyRoute,
PageIndexLazyRoute: PageIndexLazyRoute,
UserIndexLazyRoute: UserIndexLazyRoute,
}
@ -280,7 +395,10 @@ export const routeTree = rootRoute
"/",
"/changelog/$id",
"/changelog/create",
"/page/$id",
"/page/create",
"/changelog/",
"/page/",
"/user/"
]
},
@ -299,9 +417,22 @@ export const routeTree = rootRoute
"/changelog/create": {
"filePath": "changelog.create.lazy.tsx"
},
"/page/$id": {
"filePath": "page.$id.lazy.tsx",
"children": [
"/page/$id/edit",
"/page/$id/"
]
},
"/page/create": {
"filePath": "page.create.lazy.tsx"
},
"/changelog/": {
"filePath": "changelog.index.lazy.tsx"
},
"/page/": {
"filePath": "page.index.lazy.tsx"
},
"/user/": {
"filePath": "user/index.lazy.tsx"
},
@ -313,10 +444,18 @@ export const routeTree = rootRoute
"filePath": "changelog.$id.versionCreate.lazy.tsx",
"parent": "/changelog/$id"
},
"/page/$id/edit": {
"filePath": "page.$id.edit.lazy.tsx",
"parent": "/page/$id"
},
"/changelog/$id/": {
"filePath": "changelog.$id.index.lazy.tsx",
"parent": "/changelog/$id"
},
"/page/$id/": {
"filePath": "page.$id.index.lazy.tsx",
"parent": "/page/$id"
},
"/changelog/$id/version/$versionId": {
"filePath": "changelog.$id.version.$versionId.tsx",
"parent": "/changelog/$id"

View File

@ -13,7 +13,7 @@ import { useChangelogById } from '../hooks/useChangelog'
const Component = () => {
const { id } = Route.useParams()
const { data, error, isPending, refetch } = useChangelogById({ id })
console.log(data)
if (error) {
return (
<div className="flex items-center justify-center mt-32 flex-col">

View File

@ -0,0 +1,192 @@
import { PageUpdateInput } from '@boring.tools/schema'
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Textarea,
cn,
} from '@boring.tools/ui'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLazyFileRoute } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useForm } from 'react-hook-form'
import type { z } from 'zod'
import { useChangelogList } from '../hooks/useChangelog'
import { usePageById, usePageUpdate } from '../hooks/usePage'
const Component = () => {
const { id } = Route.useParams()
const navigate = useNavigate({ from: `/page/${id}/edit` })
const page = usePageById({ id })
const changelogList = useChangelogList()
const pageUpdate = usePageUpdate()
const form = useForm<z.infer<typeof PageUpdateInput>>({
resolver: zodResolver(PageUpdateInput),
defaultValues: {
...page.data,
changelogIds: page.data?.changelogs.map((log) => log.id),
},
})
const onSubmit = (values: z.infer<typeof PageUpdateInput>) => {
pageUpdate.mutate(
{ id, payload: values },
{
onSuccess(data) {
navigate({ to: '/page/$id', params: { id: data.id } })
},
},
)
}
return (
<>
<div className="flex flex-col gap-5">
<h1 className="text-3xl">Edit page</h1>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
>
<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="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())
}}
>
<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'}
onClick={() => navigate({ to: '/page/$id', params: { id } })}
>
Cancel
</Button>
<Button type="submit">Update</Button>
</div>
</form>
</Form>
</div>
</>
)
}
export const Route = createLazyFileRoute('/page/$id/edit')({
component: Component,
})

View File

@ -0,0 +1,73 @@
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
} from '@boring.tools/ui'
import { Link, createLazyFileRoute } from '@tanstack/react-router'
import { CircleMinusIcon, PlusCircleIcon } from 'lucide-react'
import { usePageById, usePageUpdate } from '../hooks/usePage'
const Component = () => {
const { id } = Route.useParams()
const { data, isPending } = usePageById({ id })
const pageUpdate = usePageUpdate()
const removeChangelog = (idToRemove: string) => {
const payload = {
title: data?.title,
description: data?.description,
changelogIds: data?.changelogs
.filter((log) => log.id !== idToRemove)
.map((l) => l.id),
}
pageUpdate.mutate({ id, payload })
}
return (
<div className="flex flex-col gap-5">
{!isPending && data && (
<div>
<Card className="w-full max-w-screen-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Changelogs ({data.changelogs?.length})</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-1">
{data.changelogs.map((changelog) => {
return (
<div className="flex gap-3" key={changelog.id}>
<Link
className="hover:bg-muted py-1 px-2 rounded transition flex gap-2 items-center w-full"
to="/changelog/$id"
params={{
id: changelog.id,
}}
>
{changelog.title}
</Link>
<Button
size={'icon'}
variant={'ghost-destructive'}
onClick={() => removeChangelog(changelog.id)}
>
<CircleMinusIcon className="w-4 h-4" />
</Button>
</div>
)
})}
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}
export const Route = createLazyFileRoute('/page/$id/')({
component: Component,
})

View File

@ -0,0 +1,114 @@
import {
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@boring.tools/ui'
import { Link, Outlet, createLazyFileRoute } from '@tanstack/react-router'
import { FileStackIcon, Globe2Icon, PencilIcon } from 'lucide-react'
import { PageDelete } from '../components/Page/Delete'
import { PageWrapper } from '../components/PageWrapper'
import { usePageById } from '../hooks/usePage'
const Component = () => {
const { id } = Route.useParams()
const { data, error, isPending, refetch } = usePageById({ id })
if (error) {
return (
<div className="flex items-center justify-center mt-32 flex-col">
<h1 className="text-3xl">Changelogs</h1>
<p>Please try again later</p>
<Button onClick={() => refetch()}>Retry</Button>
</div>
)
}
return (
<PageWrapper
breadcrumbs={[
{
name: 'Page',
to: '/page',
},
{ name: data?.title ?? '', to: `/page/${data?.id}` },
]}
>
<div className="flex flex-col gap-5">
{!isPending && data && (
<div>
<div className="flex justify-between items-center">
<div className="flex gap-3 items-center">
<FileStackIcon
strokeWidth={1.5}
className="w-10 h-10 text-muted-foreground"
/>
<div>
<h1 className="text-3xl">{data.title}</h1>
<p className="text-muted-foreground mt-2">
{data.description}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{/* <Tooltip>
<TooltipTrigger asChild>
<Button variant={'ghost'}>
<TerminalSquareIcon strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>CLI</p>
</TooltipContent>
</Tooltip>
*/}
<Tooltip>
<TooltipTrigger asChild>
<Button variant={'ghost'} asChild>
<a
href={`${import.meta.env.DEV ? 'http://localhost:4020' : 'https://page.boring.tools'}/${id}`}
target="_blank"
rel="noreferrer"
>
<Globe2Icon strokeWidth={1.5} />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Public Page</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link to={'/page/$id/edit'} params={{ id }}>
<Button variant={'ghost'}>
<PencilIcon strokeWidth={1.5} />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Edit</p>
</TooltipContent>
</Tooltip>
<PageDelete id={id} />
</div>
</div>
<div className="mt-5">
<Outlet />
</div>
</div>
)}
</div>
</PageWrapper>
)
}
export const Route = createLazyFileRoute('/page/$id')({
component: Component,
})

View File

@ -0,0 +1,190 @@
import { PageCreateInput } from '@boring.tools/schema'
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Textarea,
cn,
} from '@boring.tools/ui'
import { zodResolver } from '@hookform/resolvers/zod'
import { createLazyFileRoute } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'
import { Check, ChevronsUpDown } from 'lucide-react'
import { useForm } from 'react-hook-form'
import type { z } from 'zod'
import { PageWrapper } from '../components/PageWrapper'
import { useChangelogList } from '../hooks/useChangelog'
import { usePageCreate } from '../hooks/usePage'
const Component = () => {
const navigate = useNavigate({ from: '/page/create' })
const changelogList = useChangelogList()
const pageCreate = usePageCreate()
const form = useForm<z.infer<typeof PageCreateInput>>({
resolver: zodResolver(PageCreateInput),
defaultValues: {
title: '',
description: '',
icon: '',
changelogIds: [],
},
})
const onSubmit = (values: z.infer<typeof PageCreateInput>) => {
pageCreate.mutate(values, {
onSuccess(data) {
navigate({ to: '/page/$id', params: { id: data.id } })
},
})
}
return (
<PageWrapper
breadcrumbs={[
{
name: 'Page',
to: '/page',
},
{ name: 'New', to: '/page/create' },
]}
>
<div className="flex flex-col gap-5">
<h1 className="text-3xl">New page</h1>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-screen-md"
>
<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="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())
}}
>
<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>
</PageWrapper>
)
}
export const Route = createLazyFileRoute('/page/create')({
component: Component,
})

View File

@ -0,0 +1,12 @@
import { createLazyFileRoute } from '@tanstack/react-router'
//import { usePageById, usePageList } from '../hooks/usePage'
const Component = () => {
//const { data, error } = usePageList()
return <div>some</div>
}
export const Route = createLazyFileRoute('/page/')({
component: Component,
})

View File

@ -1 +1 @@
{"root":["./src/main.tsx","./src/routeTree.gen.ts","./src/vite-env.d.ts","./src/components/Layout.tsx","./src/components/Navigation.tsx","./src/components/NavigationMobile.tsx","./src/components/Sidebar.tsx","./src/components/SidebarUser.tsx","./src/components/Changelog/Delete.tsx","./src/components/Changelog/VersionDelete.tsx","./src/components/Changelog/VersionStatus.tsx","./src/hooks/useChangelog.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/user/index.lazy.tsx","./src/utils/navigation-routes.ts","./src/utils/queryFetch.ts"],"version":"5.6.2"}
{"root":["./src/main.tsx","./src/routeTree.gen.ts","./src/vite-env.d.ts","./src/components/Layout.tsx","./src/components/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"}

View File

@ -4,6 +4,9 @@ import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 4000,
},
build: {
outDir: '../../build/app',
emptyOutDir: true,

24
apps/page/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

47
apps/page/README.md Normal file
View File

@ -0,0 +1,47 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

View File

@ -0,0 +1,21 @@
// @ts-check
import { defineConfig } from 'astro/config'
import react from '@astrojs/react'
import tailwind from '@astrojs/tailwind'
import node from '@astrojs/node'
// https://astro.build/config
export default defineConfig({
output: 'server',
outDir: '../../build/page',
integrations: [react(), tailwind({ nesting: true })],
server: {
port: 4020,
},
adapter: node({
mode: 'standalone',
}),
})

30
apps/page/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "@boring.tools/page",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/node": "^8.3.4",
"@astrojs/react": "^3.6.2",
"@astrojs/tailwind": "^5.1.2",
"@boring.tools/schema": "workspace:*",
"@boring.tools/ui": "workspace:*",
"@tailwindcss/typography": "^0.5.15",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"astro": "^4.16.7",
"date-fns": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3"
}
}

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

1
apps/page/src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View File

@ -0,0 +1,108 @@
---
import type { PageByIdOutput } from '@boring.tools/schema'
import { Separator } from '@boring.tools/ui'
import type { z } from 'astro/zod'
import { format } from 'date-fns'
type PageById = z.infer<typeof PageByIdOutput>
const url = import.meta.env.DEV
? 'http://localhost:3000'
: 'https://api.boring.tools'
const { id } = Astro.params
const response = await fetch(`${url}/v1/page/${id}/public`)
const data: PageById = await response.json()
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{data.title} | boring.tools</title>
</head>
<body class="bg-neutral-100">
<div class="w-full flex items-center justify-center">
<div class="w-full max-w-screen-sm flex my-6 flex-col gap-8">
<div class="flex flex-col">
<h1 class="prose prose-2xl">{data.title}</h1>
<p class="prose text-sm">{data.description}</p>
</div>
{data.changelogs.length >= 2 && <div class="flex flex-col">
<h2 class="prose prose-xl">Changelogs</h2>
{data.changelogs.map((changelog) => {
if (changelog.versions && changelog.versions?.length < 1) {
return null
}
return <div>
<h3 class="font-bold">{changelog.title}</h3>
<Separator />
<div class="flex flex-col gap-5 my-6">
{changelog.versions?.map((version) => {
return (
<div class="flex gap-10 bg-white rounded p-3 border border-neutral-300">
<div>
<h2 class="prose text-3xl font-bold">
{version.version}
</h2>
{version.releasedAt &&
<p class="prose text-xs text-center">
{format(new Date(String(version.releasedAt)), "dd-MM-yy")}
</p>
}
</div>
<p>
{version.markdown}
</p>
</div>
)
})}
</div>
</div>
})}
</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) {
return null
}
return <div>
<div class="flex flex-col gap-5 my-3">
{changelog.versions?.map((version) => {
return (
<div class="flex gap-10 bg-white rounded p-3 border border-neutral-300">
<div>
<h2 class="prose text-3xl font-bold">
{version.version}
</h2>
{version.releasedAt &&
<p class="prose text-xs text-center">
{format(new Date(String(version.releasedAt)), "dd-MM-yy")}
</p>
}
</div>
<p>
{version.markdown}
</p>
</div>
)
})}
</div>
</div>
})}
</div>}
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,18 @@
---
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>Astro</title>
</head>
<body>
<div class="flex items-center justify-center w-full h-screen flex-col">
<h1 class="prose prose-2xl">boring.tools</h1>
<p class="prose prose-sm">CHANGELOG</p>
</div>
</body>
</html>

View File

@ -0,0 +1,86 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/ui/**/*.{js,ts,jsx,tsx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
/* fontFamily: {
sans: ['var(--font-sans)', ...fontFamily.sans],
}, */
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
}

7
apps/page/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}

View File

@ -2,6 +2,9 @@ import starlight from '@astrojs/starlight'
import { defineConfig } from 'astro/config'
export default defineConfig({
server: {
port: 4010,
},
outDir: '../../build/website',
integrations: [
starlight({

BIN
bun.lockb

Binary file not shown.

12
ci/docker/page/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM oven/bun:1
COPY ./build/page/dist .
COPY ./node_modules ./node_modules
ENV HOST=0.0.0.0
ENV PORT=4020
USER bun
EXPOSE 4020/tcp
ENTRYPOINT [ "bun", "run", "server/entry.mjs" ]

View File

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

View File

@ -20,7 +20,10 @@
"docker:api": "bun docker:api:build && bun docker:api:push",
"docker:app:build": "docker build -t git.hashdot.co/boring.tools/boring.tools/app -f ci/docker/app/Dockerfile .",
"docker:app:push": "docker push git.hashdot.co/boring.tools/boring.tools/app",
"docker:app": "bun docker:app:build && bun docker:app:push"
"docker:app": "bun docker:app:build && bun docker:app:push",
"docker:page:build": "docker build -t git.hashdot.co/boring.tools/boring.tools/page -f ci/docker/page/Dockerfile .",
"docker:page:push": "docker push git.hashdot.co/boring.tools/boring.tools/page",
"docker:page": "bun docker:page:build && bun docker:page:push"
},
"packageManager": "bun@1.1.29",
"workspaces": [

View File

@ -1,6 +1,6 @@
{
"name": "@boring.tools/database",
"module": "src/index.ts",
"main": "./src/index.ts",
"type": "module",
"scripts": {
"db:push": "drizzle-kit push",

View File

@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS "changelogs_to_pages" (
"changelogId" uuid NOT NULL,
"pageId" uuid NOT NULL,
CONSTRAINT "changelogs_to_pages_changelogId_pageId_pk" PRIMARY KEY("changelogId","pageId")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "page" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"userId" varchar(32),
"title" text NOT NULL,
"description" text NOT NULL,
"icon" text DEFAULT ''
);
--> statement-breakpoint
ALTER TABLE "changelog" ADD COLUMN "pageId" uuid;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "changelogs_to_pages" ADD CONSTRAINT "changelogs_to_pages_changelogId_changelog_id_fk" FOREIGN KEY ("changelogId") REFERENCES "public"."changelog"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "changelogs_to_pages" ADD CONSTRAINT "changelogs_to_pages_pageId_page_id_fk" FOREIGN KEY ("pageId") REFERENCES "public"."page"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "page" ADD CONSTRAINT "page_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "changelog" ADD CONSTRAINT "changelog_pageId_page_id_fk" FOREIGN KEY ("pageId") REFERENCES "public"."page"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@ -0,0 +1,509 @@
{
"id": "3dd358bb-891a-46fa-b49d-6018c900ce5f",
"prevId": "6217594b-c287-4f69-b0f2-9c80ed3f7f83",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "varchar(32)",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
}
},
"public.access_token": {
"name": "access_token",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"userId": {
"name": "userId",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"lastUsedOn": {
"name": "lastUsedOn",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"access_token_userId_user_id_fk": {
"name": "access_token_userId_user_id_fk",
"tableFrom": "access_token",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.changelog": {
"name": "changelog",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"userId": {
"name": "userId",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false
},
"pageId": {
"name": "pageId",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"isSemver": {
"name": "isSemver",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
}
},
"indexes": {},
"foreignKeys": {
"changelog_userId_user_id_fk": {
"name": "changelog_userId_user_id_fk",
"tableFrom": "changelog",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"changelog_pageId_page_id_fk": {
"name": "changelog_pageId_page_id_fk",
"tableFrom": "changelog",
"tableTo": "page",
"columnsFrom": [
"pageId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.changelog_commit": {
"name": "changelog_commit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"changelogId": {
"name": "changelogId",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"versionId": {
"name": "versionId",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"shortHash": {
"name": "shortHash",
"type": "varchar(8)",
"primaryKey": false,
"notNull": true
},
"author": {
"name": "author",
"type": "json",
"primaryKey": false,
"notNull": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"unique": {
"name": "unique",
"columns": [
{
"expression": "changelogId",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "shortHash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"changelog_commit_changelogId_changelog_id_fk": {
"name": "changelog_commit_changelogId_changelog_id_fk",
"tableFrom": "changelog_commit",
"tableTo": "changelog",
"columnsFrom": [
"changelogId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"changelog_commit_versionId_changelog_version_id_fk": {
"name": "changelog_commit_versionId_changelog_version_id_fk",
"tableFrom": "changelog_commit",
"tableTo": "changelog_version",
"columnsFrom": [
"versionId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.changelog_version": {
"name": "changelog_version",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"releasedAt": {
"name": "releasedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"changelogId": {
"name": "changelogId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"version": {
"name": "version",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"markdown": {
"name": "markdown",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
}
},
"indexes": {},
"foreignKeys": {
"changelog_version_changelogId_changelog_id_fk": {
"name": "changelog_version_changelogId_changelog_id_fk",
"tableFrom": "changelog_version",
"tableTo": "changelog",
"columnsFrom": [
"changelogId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.changelogs_to_pages": {
"name": "changelogs_to_pages",
"schema": "",
"columns": {
"changelogId": {
"name": "changelogId",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"pageId": {
"name": "pageId",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"changelogs_to_pages_changelogId_changelog_id_fk": {
"name": "changelogs_to_pages_changelogId_changelog_id_fk",
"tableFrom": "changelogs_to_pages",
"tableTo": "changelog",
"columnsFrom": [
"changelogId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"changelogs_to_pages_pageId_page_id_fk": {
"name": "changelogs_to_pages_pageId_page_id_fk",
"tableFrom": "changelogs_to_pages",
"tableTo": "page",
"columnsFrom": [
"pageId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"changelogs_to_pages_changelogId_pageId_pk": {
"name": "changelogs_to_pages_changelogId_pageId_pk",
"columns": [
"changelogId",
"pageId"
]
}
},
"uniqueConstraints": {}
},
"public.page": {
"name": "page",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"userId": {
"name": "userId",
"type": "varchar(32)",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"default": "''"
}
},
"indexes": {},
"foreignKeys": {
"page_userId_user_id_fk": {
"name": "page_userId_user_id_fk",
"tableFrom": "page",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {
"public.status": {
"name": "status",
"schema": "public",
"values": [
"draft",
"review",
"published"
]
}
},
"schemas": {},
"sequences": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -15,6 +15,13 @@
"when": 1728640705376,
"tag": "0001_daffy_rattler",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1729804659796,
"tag": "0002_fantastic_sleeper",
"breakpoints": true
}
]
}

View File

@ -3,6 +3,7 @@ import {
boolean,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
uuid,
@ -10,6 +11,7 @@ import {
} from 'drizzle-orm/pg-core'
import { json } from 'drizzle-orm/pg-core'
import { uniqueIndex } from 'drizzle-orm/pg-core'
import { page } from './page'
import { user } from './user'
export const changelog = pgTable('changelog', {
@ -21,11 +23,42 @@ export const changelog = pgTable('changelog', {
onDelete: 'cascade',
}),
pageId: uuid('pageId').references(() => page.id),
title: varchar('title', { length: 256 }),
description: text('description'),
isSemver: boolean('isSemver').default(true),
})
export const changelogs_to_pages = pgTable(
'changelogs_to_pages',
{
changelogId: uuid('changelogId')
.notNull()
.references(() => changelog.id, { onDelete: 'cascade' }),
pageId: uuid('pageId')
.notNull()
.references(() => page.id, { onDelete: 'cascade' }),
},
(t) => ({
pk: primaryKey({ columns: [t.changelogId, t.pageId] }),
}),
)
export const changelogs_to_pages_relations = relations(
changelogs_to_pages,
({ one }) => ({
changelog: one(changelog, {
fields: [changelogs_to_pages.changelogId],
references: [changelog.id],
}),
page: one(page, {
fields: [changelogs_to_pages.pageId],
references: [page.id],
}),
}),
)
export const changelog_relation = relations(changelog, ({ many, one }) => ({
versions: many(changelog_version),
commits: many(changelog_commit),
@ -33,6 +66,7 @@ export const changelog_relation = relations(changelog, ({ many, one }) => ({
fields: [changelog.userId],
references: [user.id],
}),
pages: many(changelogs_to_pages),
}))
export const changelog_version_status = pgEnum('status', [

View File

@ -1,3 +1,4 @@
export * from './user'
export * from './access_token'
export * from './changelog'
export * from './page'

View File

@ -0,0 +1,20 @@
import { relations } from 'drizzle-orm'
import { pgTable, text, uuid, varchar } from 'drizzle-orm/pg-core'
import { changelog, changelogs_to_pages } from './changelog'
import { user } from './user'
export const page = pgTable('page', {
id: uuid('id').primaryKey().defaultRandom(),
userId: varchar('userId', { length: 32 }).references(() => user.id, {
onDelete: 'cascade',
}),
title: text('title').notNull(),
description: text('description').notNull(),
icon: text('icon').default(''),
})
export const pageRelation = relations(page, ({ many }) => ({
changelogs: many(changelogs_to_pages),
}))

View File

@ -1,6 +1,6 @@
{
"name": "@boring.tools/logger",
"module": "src/index.ts",
"main": "./src/index.ts",
"type": "module",
"devDependencies": {
"@types/bun": "latest"

View File

@ -1,4 +1,9 @@
import { z } from '@hono/zod-openapi'
import { PageOutput } from '../page'
import { ChangelogOutput } from './base'
export const ChangelogListOutput = z.array(ChangelogOutput)
export const ChangelogListOutput = z.array(
ChangelogOutput.extend({
pages: z.array(PageOutput).optional(),
}),
)

View File

@ -2,3 +2,4 @@ export * from './general'
export * from './user'
export * from './changelog'
export * from './version'
export * from './page'

View File

@ -0,0 +1,12 @@
import { z } from '@hono/zod-openapi'
export const PageOutput = z
.object({
id: z.string().uuid().openapi({
example: '',
}),
title: z.string(),
description: z.string().optional(),
icon: z.string(),
})
.openapi('Page')

View File

@ -0,0 +1,20 @@
import { z } from '@hono/zod-openapi'
import { ChangelogOutput } from '../changelog'
import { PageOutput } from './base'
export const PageByIdOutput = PageOutput.extend({
changelogs: z.array(ChangelogOutput),
})
export const PageByIdParams = z.object({
id: z
.string()
.uuid()
.openapi({
param: {
name: 'id',
in: 'path',
},
example: 'a5ed5965-0506-44e6-aaec-0465ff9fe092',
}),
})

View File

@ -0,0 +1,16 @@
import { z } from '@hono/zod-openapi'
export const PageCreateInput = z
.object({
title: z.string().min(3).openapi({
example: 'My page',
}),
description: z.string().optional().openapi({
example: '',
}),
icon: z.string().optional().openapi({
example: 'base64...',
}),
changelogIds: z.array(z.string().uuid()),
})
.openapi('Page')

View File

@ -0,0 +1,6 @@
export * from './base'
export * from './create'
export * from './byId'
export * from './list'
export * from './public'
export * from './update'

View File

@ -0,0 +1,9 @@
import { z } from '@hono/zod-openapi'
import { ChangelogOutput } from '../changelog'
import { PageOutput } from './base'
export const PageListOutput = z.array(
PageOutput.extend({
changelogs: z.array(ChangelogOutput),
}),
)

View File

@ -0,0 +1,33 @@
import { z } from '@hono/zod-openapi'
export const PagePublicOutput = z.object({
title: z.string(),
description: z.string(),
icon: z.string(),
changelogs: z.array(
z.object({
title: z.string(),
description: z.string(),
versions: z.array(
z.object({
markdown: z.string(),
version: z.string(),
releasedAt: z.date(),
}),
),
}),
),
})
export const PagePublicParams = z.object({
id: z
.string()
.uuid()
.openapi({
param: {
name: 'id',
in: 'path',
},
example: 'a5ed5965-0506-44e6-aaec-0465ff9fe092',
}),
})

View File

@ -0,0 +1,26 @@
import { z } from '@hono/zod-openapi'
import { PageOutput } from './base'
import { PageCreateInput } from './create'
export const PageUpdateOutput = PageOutput
export const PageUpdateInput = z
.object({
title: z.string().min(3).optional().openapi({
example: 'My page',
}),
description: z.string().optional().openapi({
example: '',
}),
icon: z.string().optional().openapi({
example: 'base64...',
}),
changelogIds: z.array(z.string().uuid()).optional(),
})
.openapi('Page')
export const PageUpdateParams = z
.object({
id: z.string().uuid(),
})
.openapi({
required: ['id'],
})

View File

@ -11,7 +11,7 @@
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
@ -21,6 +21,7 @@
"@radix-ui/react-tooltip": "^1.1.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^4.1.0",
"lucide-react": "^0.446.0",
"react": "^18.3.1",

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -18,6 +18,8 @@ const buttonVariants = cva(
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
'ghost-destructive':
'hover:bg-destructive hover:text-destructive-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {

153
packages/ui/src/command.tsx Normal file
View File

@ -0,0 +1,153 @@
import type { DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import * as React from 'react'
import { Dialog, DialogContent } from './dialog'
import { cn } from './lib/cn'
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className,
)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

120
packages/ui/src/dialog.tsx Normal file
View File

@ -0,0 +1,120 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from 'react'
import { cn } from './lib/cn'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className,
)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className,
)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className,
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -26,3 +26,5 @@ export * from './collapsible'
export * from './avatar'
export * from './global.css'
export * from './breadcrumb'
export * from './command'
export * from './dialog'