diff --git a/.env.example b/.env.example index df46b227..d8e1e8ab 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,6 @@ NEXT_PUBLIC_AVATAR_BUCKET_NAME= S3_REGION= S3_ENDPOINT= -S3_ENDPOINT= S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= diff --git a/README.md b/README.md index 2ca1a116..434ee875 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features. +## Screenshot 👁️ + +hero-dark + ## Made With 🛠️ - [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) - [React Email](https://react.email/?ref=kan.bn) -## Local Development 🛠️ +## Local Development 🧑‍💻 1. Clone the repository (or fork) @@ -62,6 +66,30 @@ pnpm install 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 "` | +| `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 🤝 We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING.md) before submitting a pull request. diff --git a/apps/web/src/components/FeedbackButton.tsx b/apps/web/src/components/FeedbackButton.tsx index 4495cd57..887f7de1 100644 --- a/apps/web/src/components/FeedbackButton.tsx +++ b/apps/web/src/components/FeedbackButton.tsx @@ -98,12 +98,16 @@ const FeedbackButton: React.FC = () => { onChange={(e) => { setValue("feedback", e.target.value); }} - onKeyDown={(e) => { - e.stopPropagation(); - }} value={watch("feedback")} contentEditable className="max-h-[300px] min-h-[100px]" + onKeyDown={async (e) => { + e.stopPropagation(); + if (e.key === "Enter" && e.shiftKey) { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} />
diff --git a/apps/web/src/components/Input.tsx b/apps/web/src/components/Input.tsx index d2712b2e..8a24d212 100644 --- a/apps/web/src/components/Input.tsx +++ b/apps/web/src/components/Input.tsx @@ -69,6 +69,7 @@ const Input = forwardRef( prefix && "rounded-l-none", className && className, )} + onKeyDown={onKeyDown} {...props} /> {type === "password" && ( diff --git a/apps/web/src/components/LabelForm.tsx b/apps/web/src/components/LabelForm.tsx index ef1a5496..5b2d8b41 100644 --- a/apps/web/src/components/LabelForm.tsx +++ b/apps/web/src/components/LabelForm.tsx @@ -1,5 +1,5 @@ import { Listbox, Transition } from "@headlessui/react"; -import { Fragment } from "react"; +import { Fragment, useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { HiChevronUpDown, HiXMark } from "react-icons/hi2"; @@ -103,6 +103,12 @@ export function LabelForm({ } }; + useEffect(() => { + const nameElement: HTMLElement | null = + document.querySelector("#label-name"); + if (nameElement) nameElement.focus(); + }, []); + return (
@@ -111,6 +117,7 @@ export function LabelForm({ {isEdit ? "Edit label" : "New label"}
- + { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} + />
diff --git a/apps/web/src/pages/api/upload/image.ts b/apps/web/src/pages/api/upload/image.ts index b824cc5d..2d903d4f 100644 --- a/apps/web/src/pages/api/upload/image.ts +++ b/apps/web/src/pages/api/upload/image.ts @@ -2,6 +2,10 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { env } from "next-runtime-env"; +import { createNextApiContext } from "@kan/api/trpc"; + + +const allowedContentTypes = ["image/jpeg", "image/png"]; export default async function handler( req: NextApiRequest, @@ -12,14 +16,37 @@ export default async function handler( } 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({ - region: env.S3_REGION, - endpoint: env.S3_ENDPOINT, + region: env.S3_REGION ?? "", + endpoint: env.S3_ENDPOINT ?? "", credentials: { - accessKeyId: env.S3_ACCESS_KEY_ID, - secretAccessKey: env.S3_SECRET_ACCESS_KEY, + accessKeyId: env.S3_ACCESS_KEY_ID ?? "", + secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "", }, }); diff --git a/apps/web/src/views/board/components/NewCardForm.tsx b/apps/web/src/views/board/components/NewCardForm.tsx index ba76f42e..6d028e3a 100644 --- a/apps/web/src/views/board/components/NewCardForm.tsx +++ b/apps/web/src/views/board/components/NewCardForm.tsx @@ -226,6 +226,7 @@ export function NewCardForm({ New card
- + { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} + />
setValue("description", e.target.value)} value={watch("description")} contentEditable + onKeyDown={async (e) => { + if (e.key === "Enter" && e.shiftKey) { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} />
diff --git a/apps/web/src/views/board/components/NewListForm.tsx b/apps/web/src/views/board/components/NewListForm.tsx index ec642421..b6c64693 100644 --- a/apps/web/src/views/board/components/NewListForm.tsx +++ b/apps/web/src/views/board/components/NewListForm.tsx @@ -111,6 +111,7 @@ export function NewListForm({ New list
- + { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} + />
diff --git a/apps/web/src/views/boards/components/ImportBoardsForm.tsx b/apps/web/src/views/boards/components/ImportBoardsForm.tsx index a68c595a..d8aab5e1 100644 --- a/apps/web/src/views/boards/components/ImportBoardsForm.tsx +++ b/apps/web/src/views/boards/components/ImportBoardsForm.tsx @@ -251,6 +251,7 @@ export function ImportBoardsForm() {
- { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} /> diff --git a/apps/web/src/views/card/components/NewCommentForm.tsx b/apps/web/src/views/card/components/NewCommentForm.tsx index b105a11b..128a1ae8 100644 --- a/apps/web/src/views/card/components/NewCommentForm.tsx +++ b/apps/web/src/views/card/components/NewCommentForm.tsx @@ -58,6 +58,12 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => { disabled={false} 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" + onKeyDown={async (e) => { + if (e.key === "Enter" && e.shiftKey) { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} />
- { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} />