Merge branch 'main' into feat/docker-image

This commit is contained in:
Henry
2025-06-03 21:24:05 +01:00
committed by GitHub
14 changed files with 171 additions and 18 deletions

View File

@@ -10,7 +10,6 @@ NEXT_PUBLIC_AVATAR_BUCKET_NAME=
S3_REGION= S3_REGION=
S3_ENDPOINT= S3_ENDPOINT=
S3_ENDPOINT=
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= S3_SECRET_ACCESS_KEY=

View File

@@ -32,6 +32,10 @@
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features. See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Screenshot 👁️
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/5f7b6ad3-f31d-4b45-93dc-0132b3f2afd4" />
## Made With 🛠️ ## Made With 🛠️
- [Next.js](https://nextjs.org/?ref=kan.bn) - [Next.js](https://nextjs.org/?ref=kan.bn)
@@ -41,7 +45,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn) - [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
- [React Email](https://react.email/?ref=kan.bn) - [React Email](https://react.email/?ref=kan.bn)
## Local Development 🛠️ ## Local Development 🧑‍💻
1. Clone the repository (or fork) 1. Clone the repository (or fork)
@@ -62,6 +66,30 @@ pnpm install
pnpm dev pnpm dev
``` ```
## Environment Variables 🔐
| Variable | Description | Required | Example |
| -------------------------------- | ----------------------------- | ---------------- | --------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | Yes | `"Kan <hello@mail.kan.bn>"` |
| `EMAIL_URL` | Email service API URL | Yes | `https://api.resend.com/emails` |
| `EMAIL_TOKEN` | Email service API token | Yes | `re_xxxx` |
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_URL` | Auth callback URL | Yes | Same as `NEXT_PUBLIC_BASE_URL` |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | For Google login | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `storage.kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
See `.env.example` for a complete list of supported environment variables.
## Contributing 🤝 ## Contributing 🤝
We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request. We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request.

View File

@@ -98,12 +98,16 @@ const FeedbackButton: React.FC = () => {
onChange={(e) => { onChange={(e) => {
setValue("feedback", e.target.value); setValue("feedback", e.target.value);
}} }}
onKeyDown={(e) => {
e.stopPropagation();
}}
value={watch("feedback")} value={watch("feedback")}
contentEditable contentEditable
className="max-h-[300px] min-h-[100px]" className="max-h-[300px] min-h-[100px]"
onKeyDown={async (e) => {
e.stopPropagation();
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
<div className="flex flex-row items-center justify-between pt-2"> <div className="flex flex-row items-center justify-between pt-2">
<div> <div>

View File

@@ -69,6 +69,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
prefix && "rounded-l-none", prefix && "rounded-l-none",
className && className, className && className,
)} )}
onKeyDown={onKeyDown}
{...props} {...props}
/> />
{type === "password" && ( {type === "password" && (

View File

@@ -1,5 +1,5 @@
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
import { Fragment } from "react"; import { Fragment, useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { HiChevronUpDown, HiXMark } from "react-icons/hi2"; import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
@@ -103,6 +103,12 @@ export function LabelForm({
} }
}; };
useEffect(() => {
const nameElement: HTMLElement | null =
document.querySelector<HTMLElement>("#label-name");
if (nameElement) nameElement.focus();
}, []);
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5"> <div className="px-5 pt-5">
@@ -111,6 +117,7 @@ export function LabelForm({
{isEdit ? "Edit label" : "New label"} {isEdit ? "Edit label" : "New label"}
</h2> </h2>
<button <button
type="button"
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300" className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -121,7 +128,17 @@ export function LabelForm({
</button> </button>
</div> </div>
<Input id="label-name" placeholder="Name" {...register("name")} /> <Input
id="label-name"
placeholder="Name"
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
<Controller <Controller
name="colour" name="colour"
control={control} control={control}

View File

@@ -61,6 +61,7 @@ export function NewWorkspaceForm() {
New workspace New workspace
</h2> </h2>
<button <button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300" className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -75,6 +76,12 @@ export function NewWorkspaceForm() {
id="workspace-name" id="workspace-name"
placeholder="Workspace name" placeholder="Workspace name"
{...register("name")} {...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">

View File

@@ -2,6 +2,10 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { env } from "next-runtime-env"; import { env } from "next-runtime-env";
import { createNextApiContext } from "@kan/api/trpc";
const allowedContentTypes = ["image/jpeg", "image/png"];
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@@ -12,14 +16,37 @@ export default async function handler(
} }
try { try {
const { filename, contentType } = req.body; const { user } = await createNextApiContext(req);
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { filename, contentType } = req.body as {
filename: string;
contentType: string;
};
// Specific to avatar uploads for now
const filenameRegex = /^[a-f0-9\-]+\/[a-zA-Z0-9_\-]+(\.jpg|\.jpeg|\.png)$/;
if (!filenameRegex.test(filename)) {
return res.status(400).json({ error: "Invalid filename" });
}
if (
typeof contentType !== "string" ||
!allowedContentTypes.includes(contentType)
) {
return res.status(400).json({ error: "Invalid content type" });
}
const client = new S3Client({ const client = new S3Client({
region: env.S3_REGION, region: env.S3_REGION ?? "",
endpoint: env.S3_ENDPOINT, endpoint: env.S3_ENDPOINT ?? "",
credentials: { credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID, accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: env.S3_SECRET_ACCESS_KEY, secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
}, },
}); });

View File

@@ -226,6 +226,7 @@ export function NewCardForm({
New card New card
</h2> </h2>
<button <button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300" className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => { onClick={(e) => {
closeModal(); closeModal();
@@ -237,7 +238,17 @@ export function NewCardForm({
</div> </div>
<div> <div>
<Input id="title" placeholder="Card title" {...register("title")} /> <Input
id="title"
placeholder="Card title"
{...register("title")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div> </div>
<div className="mt-2"> <div className="mt-2">
<Input <Input
@@ -245,6 +256,12 @@ export function NewCardForm({
onChange={(e) => setValue("description", e.target.value)} onChange={(e) => setValue("description", e.target.value)}
value={watch("description")} value={watch("description")}
contentEditable contentEditable
onKeyDown={async (e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
</div> </div>
<div className="mt-2 flex space-x-1"> <div className="mt-2 flex space-x-1">

View File

@@ -111,6 +111,7 @@ export function NewListForm({
New list New list
</h2> </h2>
<button <button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300" className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -121,7 +122,17 @@ export function NewListForm({
</button> </button>
</div> </div>
<Input id="list-name" placeholder="List name" {...register("name")} /> <Input
id="list-name"
placeholder="List name"
{...register("name")}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/>
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle <Toggle

View File

@@ -92,6 +92,7 @@ export function UpdateBoardSlugForm({
Edit board URL Edit board URL
</h2> </h2>
<button <button
type="button"
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300" className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -107,6 +108,12 @@ export function UpdateBoardSlugForm({
{...register("slug")} {...register("slug")}
errorMessage={errors.slug?.message} errorMessage={errors.slug?.message}
prefix={`kan.bn/${workspaceSlug}/`} prefix={`kan.bn/${workspaceSlug}/`}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
</div> </div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600"> <div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">

View File

@@ -251,6 +251,7 @@ export function ImportBoardsForm() {
</div> </div>
<button <button
type="button"
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-300" className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => closeModal()} onClick={() => closeModal()}
> >

View File

@@ -1,9 +1,11 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import type { NewBoardInput } from "@kan/api/types"; import type { NewBoardInput } from "@kan/api/types";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -33,12 +35,19 @@ export function NewBoardForm() {
createBoard.mutate(data); createBoard.mutate(data);
}; };
useEffect(() => {
const titleElement: HTMLElement | null =
document.querySelector<HTMLElement>("#name");
if (titleElement) titleElement.focus();
}, []);
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5"> <div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000"> <div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">New board</h2> <h2 className="text-sm font-bold">New board</h2>
<button <button
type="button"
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300" className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -48,11 +57,16 @@ export function NewBoardForm() {
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" /> <HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button> </button>
</div> </div>
<input <Input
id="name" id="name"
placeholder="Name" placeholder="Name"
{...register("name", { required: true })} {...register("name", { required: true })}
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6" onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
</div> </div>

View File

@@ -58,6 +58,12 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
disabled={false} disabled={false}
onChange={(e) => setValue("comment", e.target.value)} onChange={(e) => setValue("comment", e.target.value)}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6" className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
onKeyDown={async (e) => {
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<button <button

View File

@@ -1,9 +1,11 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import type { InviteMemberInput } from "@kan/api/types"; import type { InviteMemberInput } from "@kan/api/types";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -34,12 +36,19 @@ export function InviteMemberForm() {
createBoard.mutate(data); createBoard.mutate(data);
}; };
useEffect(() => {
const emailElement: HTMLElement | null =
document.querySelector<HTMLElement>("#email");
if (emailElement) emailElement.focus();
}, []);
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5"> <div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000"> <div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">Add member</h2> <h2 className="text-sm font-bold">Add member</h2>
<button <button
type="button"
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300" className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -49,11 +58,16 @@ export function InviteMemberForm() {
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" /> <HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button> </button>
</div> </div>
<input <Input
id="email" id="email"
placeholder="Email" placeholder="Email"
{...register("email", { required: true })} {...register("email", { required: true })}
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6" onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
</div> </div>