Compare commits
15 Commits
fix-23
...
feat/supab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfbe81f49c | ||
|
|
850b0fa876 | ||
|
|
0c1de23a19 | ||
|
|
4e344a5ae1 | ||
|
|
227e76524b | ||
|
|
4e1e99cbac | ||
|
|
bfb5e49981 | ||
|
|
69af62e032 | ||
|
|
474c327def | ||
|
|
b695f7bdf3 | ||
|
|
1712a1a41b | ||
|
|
e048e243d4 | ||
|
|
6178b4f309 | ||
|
|
1ba6fffd8b | ||
|
|
b54cfe8f91 |
@@ -1,18 +0,0 @@
|
||||
.env
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
Dockerfile
|
||||
./**/*/Dockerfile
|
||||
|
||||
.dockerignore
|
||||
|
||||
node_modules
|
||||
./**/*/node_modules
|
||||
|
||||
pnpm-debug.log
|
||||
./**/*/pnpm-debug.log
|
||||
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
39
.env.example
@@ -1,23 +1,26 @@
|
||||
POSTGRES_URL=
|
||||
# Since the ".env" file is gitignored, you can use the ".env.example" file to
|
||||
# build a new ".env" file when you clone the repo. Keep this file up-to-date
|
||||
# when you add new variables to `.env`.
|
||||
|
||||
EMAIL_FROM=
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
# This file will be committed to version control, so make sure not to have any
|
||||
# secrets in it. If you are cloning this repo, create a copy of this file named
|
||||
# ".env" and populate it with your secrets.
|
||||
|
||||
NEXT_PUBLIC_BASE_URL=
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
# Drizzle
|
||||
# Get the Database URL from the "prisma" dropdown selector in PlanetScale.
|
||||
# Change the query params at the end of the URL to "?ssl={"rejectUnauthorized":true}"
|
||||
DATABASE_URL='mysql://YOUR_MYSQL_URL_HERE?ssl={"rejectUnauthorized":true}'
|
||||
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
# NEXTAUTH_SECRET=""
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Next Auth Discord Provider
|
||||
DISCORD_CLIENT_ID=""
|
||||
DISCORD_CLIENT_SECRET=""
|
||||
|
||||
36
.eslintrc.cjs
Normal file
@@ -0,0 +1,36 @@
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: [
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended-type-checked",
|
||||
"plugin:@typescript-eslint/stylistic-type-checked",
|
||||
],
|
||||
rules: {
|
||||
// These opinionated rules are enabled in stylistic-type-checked above.
|
||||
// Feel free to reconfigure them to your own preference.
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "inline-type-imports",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
2,
|
||||
{
|
||||
checksVoidReturn: { attributes: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
37
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: "\U0001F41B Bug Report"
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Report
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
|
||||
- OS: [e.g. macOS, Windows, Linux]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Node version [e.g. 20]
|
||||
- App version/commit [if applicable]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
30
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,30 +0,0 @@
|
||||
---
|
||||
name: "✨ Feature Request"
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: feature
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Request
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
**Implementation ideas (optional)**
|
||||
If you have any ideas about how this feature could be implemented, please share them here.
|
||||
|
||||
**Would you like to work on this feature?**
|
||||
|
||||
- [ ] Yes, I'd like to help implement this feature
|
||||
- [ ] No, I'm just suggesting the feature
|
||||
97
.github/workflows/docker-publish.yml
vendored
@@ -1,97 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "21 21 * * *"
|
||||
push:
|
||||
branches: ["main"]
|
||||
# Publish semver tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Install the cosign tool except on PR
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||
with:
|
||||
cosign-release: "v2.2.4"
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
file: apps/web/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
# transparency data even for private images, pass --force to cosign below.
|
||||
# https://github.com/sigstore/cosign
|
||||
- name: Sign the published Docker image
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
env:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
30
.gitignore
vendored
@@ -1,24 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
**/node_modules/
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
coverage
|
||||
/coverage
|
||||
|
||||
# database
|
||||
/prisma/db.sqlite
|
||||
/prisma/db.sqlite-journal
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
out/
|
||||
/.next/
|
||||
/out/
|
||||
next-env.d.ts
|
||||
|
||||
# nitro
|
||||
.nitro/
|
||||
.output/
|
||||
|
||||
# production
|
||||
build
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -31,6 +31,7 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
@@ -38,11 +39,4 @@ yarn-error.log*
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
dist/
|
||||
.cache
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# docker
|
||||
docker-compose.override.yml
|
||||
*.tsbuildinfo
|
||||
|
||||
4
.npmrc
@@ -1,4 +0,0 @@
|
||||
node-linker=hoisted
|
||||
link-workspace-packages=true
|
||||
shamefully-hoist=true
|
||||
hoist=true
|
||||
8
.vscode/extensions.json
vendored
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
17
.vscode/launch.json
vendored
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "pnpm dev",
|
||||
"cwd": "${workspaceFolder}/apps/nextjs",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"sourceMaps": true,
|
||||
"sourceMapPathOverrides": {
|
||||
"/turbopack/[project]/*": "${webRoot}/*"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
27
.vscode/settings.json
vendored
@@ -1,29 +1,6 @@
|
||||
{
|
||||
"spellright.addToSystemDictionary": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[typescript,typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
|
||||
"eslint.runtime": "node",
|
||||
"eslint.workingDirectories": [
|
||||
{ "pattern": "apps/*/" },
|
||||
{ "pattern": "packages/*/" },
|
||||
{ "pattern": "tooling/*/" }
|
||||
],
|
||||
"prettier.ignorePath": ".gitignore",
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": "./tooling/tailwind/web.ts",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"next/router.d.ts",
|
||||
"next/dist/client/router.d.ts"
|
||||
],
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# Contributing to Kan
|
||||
|
||||
Thank you for your interest in contributing to Kan! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/kan.git`
|
||||
3. Create a new branch: `git checkout -b feat/your-feature-name`
|
||||
4. Install dependencies: `pnpm install`
|
||||
5. Copy `.env.example` to `.env` and configure your environment variables
|
||||
6. Make your changes
|
||||
7. Commit your changes: `git commit -m "feat: description of changes"`
|
||||
8. Push to your fork: `git push origin feat/your-feature-name`
|
||||
9. Open a Pull Request
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style
|
||||
|
||||
- Follow the existing code style
|
||||
- Use meaningful variable and function names
|
||||
- Add comments for complex logic
|
||||
- Keep functions focused and concise
|
||||
|
||||
### Commits
|
||||
|
||||
- Use clear and descriptive commit messages
|
||||
- Reference issue numbers when applicable
|
||||
- Keep commits focused on single changes
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- Provide a clear description of the changes
|
||||
- Include screenshots for UI changes
|
||||
- Keep PRs focused on a single feature/fix
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Join our [Discord server](https://discord.gg/e6ejRb6CmT) for questions
|
||||
- Check existing issues and pull requests
|
||||
- Email [henry@kan.bn](mailto:henry@kan.bn) for major concerns
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
|
||||
|
||||
We aim to foster an inclusive and welcoming community. Harassment and abusive behavior will not be tolerated.
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Kan, you agree that your contributions will be licensed under the AGPLv3 License.
|
||||
143
LICENSE
@@ -1,5 +1,5 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,15 +7,17 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -60,7 +72,7 @@ modification follow.
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
112
README.md
@@ -1,111 +1,3 @@
|
||||

|
||||
# Kan
|
||||
|
||||
<div align="center">
|
||||
<h3 align="center">Kan</h3>
|
||||
<p>The open-source project management alternative to Trello.</p>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://kan.bn/kan/roadmap">Roadmap</a>
|
||||
·
|
||||
<a href="https://kan.bn">Website</a>
|
||||
·
|
||||
<a href="https://docs.kan.bn">Docs</a>
|
||||
·
|
||||
<a href="https://discord.gg/e6ejRb6CmT">Discord</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/kanbn/kan/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPLv3-purple"></a>
|
||||
</div>
|
||||
|
||||
## Features 💫
|
||||
|
||||
- 👁️ **Board Visibility**: Control who can view and edit your boards
|
||||
- 🤝 **Workspace Members**: Invite members and collaborate with your team
|
||||
- 🚀 **Trello Imports**: Easily import your Trello boards
|
||||
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
||||
- 💬 **Comments**: Discuss and collaborate with your team
|
||||
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
||||
- 🎨 **Templates (coming soon)** : Save time with reusable board templates
|
||||
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
||||
|
||||
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 🛠️
|
||||
|
||||
- [Next.js](https://nextjs.org/?ref=kan.bn)
|
||||
- [tRPC](https://trpc.io/?ref=kan.bn)
|
||||
- [Better Auth](https://better-auth.com/?ref=kan.bn)
|
||||
- [Tailwind CSS](https://tailwindcss.com/?ref=kan.bn)
|
||||
- [Drizzle ORM](https://orm.drizzle.team/?ref=kan.bn)
|
||||
- [React Email](https://react.email/?ref=kan.bn)
|
||||
|
||||
## Local Development 🧑💻
|
||||
|
||||
1. Clone the repository (or fork)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/kanbn/kan.git
|
||||
```
|
||||
|
||||
2. Install dependencies
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Copy `.env.example` to `.env` and configure your environment variables
|
||||
4. Migrate database
|
||||
|
||||
```bash
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
5. Start the development server
|
||||
|
||||
```bash
|
||||
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>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | Yes | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | Yes | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | Yes | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/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 | `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.
|
||||
|
||||
## License 📝
|
||||
|
||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||
|
||||
## Contact 📧
|
||||
|
||||
For support or to get in touch, please email [henry@kan.bn](mailto:henry@kan.bn) or join our [Discord server](https://discord.gg/e6ejRb6CmT).
|
||||
The open source Trello alternative.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Mintlify Starter Kit
|
||||
|
||||
Click on `Use this template` to copy the Mintlify starter kit. The starter kit contains examples including
|
||||
|
||||
- Guide pages
|
||||
- Navigation
|
||||
- Customizations
|
||||
- API Reference pages
|
||||
- Use of popular components
|
||||
|
||||
### Development
|
||||
|
||||
Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
|
||||
|
||||
```
|
||||
npm i -g mintlify
|
||||
```
|
||||
|
||||
Run the following command at the root of your documentation (where mint.json is)
|
||||
|
||||
```
|
||||
mintlify dev
|
||||
```
|
||||
|
||||
### Publishing Changes
|
||||
|
||||
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
||||
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
title: "Introduction"
|
||||
description: "Learn how to integrate with Kan's REST API."
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://kan.bn/api/v1
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Most endpoints require authentication using your API key. You can create one in the [settings page](https://kan.bn/settings) of your account. Include this key in the `x-api-key` header of each request.
|
||||
|
||||
```
|
||||
'x-api-key': kan_123456789
|
||||
```
|
||||
|
||||
## Response codes
|
||||
|
||||
Kan uses standard HTTP codes to indicate the success or failure of your requests.
|
||||
|
||||
In general, `2xx` HTTP codes correspond to success, `4xx` codes are for user-related failures, and `5xx` codes are for infrastructure issues.
|
||||
|
||||
| Status | Description |
|
||||
| ------ | ------------------------------- |
|
||||
| `200` | Success |
|
||||
| `400` | Invalid parameters |
|
||||
| `401` | Missing token |
|
||||
| `403` | Invalid token |
|
||||
| `404` | Resource not found |
|
||||
| `500` | Error with Kan's infrastructure |
|
||||
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 433 KiB |
@@ -1,42 +0,0 @@
|
||||
---
|
||||
title: Trello
|
||||
description: "Import your Trello boards to Kan."
|
||||
---
|
||||
|
||||
## Getting your Trello API key and token
|
||||
|
||||
Before you can import your Trello boards, you need to get your Trello API key and token. Follow these steps (only takes a couple of minutes):
|
||||
|
||||
1. **Sign in to your Trello account**
|
||||
Go to [https://trello.com](https://trello.com) and log in with your Trello account.
|
||||
|
||||
2. **Create a new integration**
|
||||
Visit [https://trello.com/power-ups/admin](https://trello.com/power-ups/admin). Click **Create new** and fill in the following details:
|
||||
|
||||
- **Name**: Choose a name for your integration (e.g. "Kan Import")
|
||||
- **Workspace**: Select the workspace containing the boards you want to import
|
||||
- **Email**: Enter your email address
|
||||
- **Support email**: Enter your email address
|
||||
- **Author**: Add your name
|
||||
|
||||
3. **Get a Trello API Key**
|
||||
Now click **Generate a new API key** to create an API key.
|
||||
|
||||
4. **Generate a Trello Token**
|
||||
On the same page (to the right of the API key), click the link to **generate a Token**. Approve the permissions and copy your token (make sure to save it somewhere secure like a password manager).
|
||||
|
||||
## Importing your Trello boards
|
||||
|
||||
Once you have your API key and token, importing your Trello boards is easy. Follow these steps:
|
||||
|
||||
1. **Create a new import in Kan**
|
||||
Navigate to **[https://kan.bn/boards](https://kan.dev/boards)** and click **Import**.
|
||||
|
||||
2. **Select Trello as the Import Source**
|
||||
Choose **Trello** from the list of import options and click **Select source**.
|
||||
|
||||
3. **Enter Your Trello API Key and Token**
|
||||
Paste your API key and token into the provided fields and click **Fetch boards** (don't worry, we don't store your key or token).
|
||||
|
||||
4. **Start the Import**
|
||||
Select the board you want to import and click **Import boards**. Once the import is complete, you'll see your boards in Kan.
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
title: Introduction
|
||||
description: "Kan is the open source alternative to Trello."
|
||||
---
|
||||
|
||||
<Frame>
|
||||
<img
|
||||
className="block rounded-lg border border-gray-200 dark:hidden"
|
||||
src="/images/hero-light.png"
|
||||
alt="Hero Light"
|
||||
/>
|
||||
<img
|
||||
className="hidden rounded-lg dark:block"
|
||||
src="/images/hero-dark.png"
|
||||
alt="Hero Dark"
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
## Getting Started
|
||||
|
||||
Get started with Kan by choosing your preferred deployment option below.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Cloud" icon="cloud" href="https://kan.bn/signup">
|
||||
Get started instantly with our hosted solution - we'll handle all the
|
||||
hosting, scaling, and maintenance for you.
|
||||
</Card>
|
||||
<Card title="Self-host" icon="code" href="https://github.com/kanbn/kan">
|
||||
For full data ownership and control, you can self-host Kan on your
|
||||
infrastructure.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.6.14 -->
|
||||
<svg width="2400" height="900" viewBox="0 0 2400 900" xmlns="http://www.w3.org/2000/svg">
|
||||
<text id="kanbn" xml:space="preserve" x="239" y="681" font-family="Plus Jakarta Sans" font-size="598" font-weight="800" fill="#ededed" letter-spacing="-29.9">kan.bn</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 356 B |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.6.14 -->
|
||||
<svg width="2400" height="900" viewBox="0 0 2400 900" xmlns="http://www.w3.org/2000/svg">
|
||||
<text id="kanbn" xml:space="preserve" x="239" y="681" font-family="Plus Jakarta Sans" font-size="598" font-weight="800" fill="#161616" letter-spacing="-29.9">kan.bn</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 356 B |
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"$schema": "https://mintlify.com/schema.json",
|
||||
"name": "kan.bn",
|
||||
"logo": {
|
||||
"dark": "/logo/dark.svg",
|
||||
"light": "/logo/light.svg",
|
||||
"href": "https://kan.bn"
|
||||
},
|
||||
"favicon": "./favicon.png",
|
||||
"colors": {
|
||||
"primary": "#000000",
|
||||
"light": "#EDEDED",
|
||||
"dark": "#161616",
|
||||
"anchors": {
|
||||
"from": "#000000",
|
||||
"to": "#161616"
|
||||
}
|
||||
},
|
||||
"topbarLinks": [
|
||||
{
|
||||
"name": "Sign in",
|
||||
"url": "https://kan.bn/login"
|
||||
}
|
||||
],
|
||||
"topbarCtaButton": {
|
||||
"name": "Go to app",
|
||||
"url": "https://kan.bn/boards"
|
||||
},
|
||||
"anchors": [
|
||||
{
|
||||
"name": "API Reference",
|
||||
"openapi": "https://kan.bn/api/v1/openapi.json",
|
||||
"url": "api-reference",
|
||||
"icon": "square-terminal"
|
||||
},
|
||||
{
|
||||
"name": "GitHub",
|
||||
"icon": "github",
|
||||
"url": "https://github.com/kanbn"
|
||||
}
|
||||
],
|
||||
"navigation": [
|
||||
{
|
||||
"group": "Get Started",
|
||||
"pages": ["introduction"]
|
||||
},
|
||||
{
|
||||
"group": "Import",
|
||||
"pages": ["imports/trello"]
|
||||
},
|
||||
{
|
||||
"group": "API Documentation",
|
||||
"pages": ["api-reference/introduction"]
|
||||
}
|
||||
],
|
||||
"footerSocials": {
|
||||
"discord": "https://discord.gg/e6ejRb6CmT",
|
||||
"github": "https://github.com/kanbn"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "@kan/docs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm with-env mint build",
|
||||
"clean": "git clean -xdf .cache .turbo node_modules",
|
||||
"dev": "pnpm with-env mintlify dev --port 3001",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"mintlify": "^3.0.0"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
ARG NODE_VERSION=20
|
||||
ARG APP_DIRNAME=web
|
||||
ARG PROJECT=@kan/web
|
||||
|
||||
# 1. Alpine image
|
||||
FROM node:${NODE_VERSION}-alpine AS alpine
|
||||
RUN apk update
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
|
||||
# Setup pnpm and turbo on the alpine base
|
||||
FROM alpine AS base
|
||||
RUN corepack enable
|
||||
# Replace <your-major-version> with the major version installed in your repository. For example:
|
||||
# RUN npm install turbo@2.1.3 --global
|
||||
RUN npm install turbo@2.3.1 --global
|
||||
|
||||
RUN pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
# 2. Prune projects
|
||||
FROM base AS pruner
|
||||
# https://stackoverflow.com/questions/49681984/how-to-get-version-value-of-package-json-inside-of-dockerfile
|
||||
# RUN export VERSION=$(npm run version)
|
||||
|
||||
ARG PROJECT
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# It might be the path to <ROOT> turborepo
|
||||
COPY . .
|
||||
|
||||
# Generate a partial monorepo with a pruned lockfile for a target workspace.
|
||||
# Assuming "@acme/nextjs" is the name entered in the project's package.json: { name: "@acme/nextjs" }
|
||||
RUN turbo prune --scope=${PROJECT} --docker
|
||||
|
||||
# 3. Build the project
|
||||
FROM base AS builder
|
||||
ARG PROJECT
|
||||
|
||||
# Environment to skip .env validation on build
|
||||
ENV CI=true
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfile and package.json's of isolated subworkspace
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code of isolated subworkspace
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
RUN pnpm build --filter=${PROJECT}
|
||||
|
||||
# 4. Final image - runner stage to run the application
|
||||
FROM base AS runner
|
||||
ARG APP_DIRNAME
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --chown=nextjs:nodejs --from=builder /app/ ./
|
||||
WORKDIR /app/apps/${APP_DIRNAME}
|
||||
|
||||
ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import baseConfig, { restrictEnvAccess } from "@kan/eslint-config/base";
|
||||
import nextjsConfig from "@kan/eslint-config/nextjs";
|
||||
import reactConfig from "@kan/eslint-config/react";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [".next/**"],
|
||||
},
|
||||
...baseConfig,
|
||||
...reactConfig,
|
||||
...nextjsConfig,
|
||||
...restrictEnvAccess,
|
||||
];
|
||||
@@ -1,45 +0,0 @@
|
||||
import { fileURLToPath } from "url";
|
||||
import createJiti from "jiti";
|
||||
import { env } from "next-runtime-env";
|
||||
import { configureRuntimeEnv } from "next-runtime-env/build/configure.js";
|
||||
|
||||
// Import env files to validate at build time. Use jiti so we can load .ts files in here.
|
||||
createJiti(fileURLToPath(import.meta.url))("./src/env");
|
||||
|
||||
configureRuntimeEnv();
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: [
|
||||
"@kan/api",
|
||||
"@kan/db",
|
||||
"@kan/shared",
|
||||
"@kan/auth",
|
||||
"@kan/stripe",
|
||||
],
|
||||
|
||||
/** We already do linting and typechecking as separate tasks in CI */
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"name": "@kan/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm next build",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev | pino-pretty",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.802.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.812.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@kan/api": "workspace:*",
|
||||
"@kan/auth": "workspace:*",
|
||||
"@kan/db": "workspace:^",
|
||||
"@kan/shared": "workspace:^",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@trpc/client": "catalog:",
|
||||
"@trpc/next": "^11.0.0-rc.660",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"aws-sdk": "^2.1692.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"geist": "^1.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "^14.2.15",
|
||||
"next-logger": "^5.0.1",
|
||||
"next-runtime-env": "^1.7.2",
|
||||
"nextjs-cors": "^2.2.0",
|
||||
"pino": "^9.6.0",
|
||||
"react": "catalog:react18",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-contenteditable": "^3.3.7",
|
||||
"react-dom": "catalog:react18",
|
||||
"react-hook-form": "^7.51.1",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-lottie-player": "^1.5.5",
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kan/eslint-config": "workspace:*",
|
||||
"@kan/prettier-config": "workspace:*",
|
||||
"@kan/stripe": "workspace:*",
|
||||
"@kan/tailwind-config": "workspace:*",
|
||||
"@kan/tsconfig": "workspace:*",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^20.17.7",
|
||||
"@types/react": "catalog:react18",
|
||||
"@types/react-beautiful-dnd": "^13.1.7",
|
||||
"@types/react-dom": "catalog:react18",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"eslint": "catalog:",
|
||||
"jiti": "^1.21.6",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
window.__ENV = {"NEXT_PUBLIC_UMAMI_ID":"aaed1f55-25e2-4223-b918-e429c390be35","NEXT_PUBLIC_KAN_ENV":"cloud","NEXT_PUBLIC_BASE_URL":"http://localhost:3000","NEXT_PUBLIC_AVATAR_BUCKET_NAME":"avatars","NEXT_PUBLIC_STORAGE_DOMAIN":"kanbn.com","NEXT_PUBLIC_STORAGE_URL":"https://storage.kanbn.com"};
|
||||
|
Before Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 433 KiB |
@@ -1,118 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { FaGoogle } from "react-icons/fa";
|
||||
import { z } from "zod";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
|
||||
interface FormValues {
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface AuthProps {
|
||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||
}
|
||||
|
||||
const EmailSchema = z.object({ email: z.string().email() });
|
||||
|
||||
export function Auth({ setIsMagicLinkSent }: AuthProps) {
|
||||
const [isLoginWithGooglePending, setIsLoginWithGooglePending] =
|
||||
useState(false);
|
||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(EmailSchema),
|
||||
});
|
||||
|
||||
const handleLoginWithEmail = async (email: string) => {
|
||||
setIsLoginWithEmailPending(true);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
setIsLoginWithEmailPending(false);
|
||||
|
||||
if (error) {
|
||||
setLoginError(
|
||||
"Something went wrong, please try again later or contact customer support.",
|
||||
);
|
||||
} else {
|
||||
setIsMagicLinkSent(true, email);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithGoogle = async () => {
|
||||
setIsLoginWithGooglePending(true);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
setIsLoginWithGooglePending(false);
|
||||
|
||||
if (error) {
|
||||
setLoginError("Failed to login with Google. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await handleLoginWithEmail(values.email);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleLoginWithGoogle}
|
||||
isLoading={isLoginWithGooglePending}
|
||||
iconLeft={<FaGoogle />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">or</span>
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder="Enter your email address"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
<div className="mt-[1.5rem]">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
fullWidth
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
>
|
||||
Continue with email
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers";
|
||||
|
||||
const sizeMap = {
|
||||
xs: 20,
|
||||
sm: 24,
|
||||
md: 36,
|
||||
lg: 48,
|
||||
} as const;
|
||||
|
||||
const Avatar = ({
|
||||
size = "md",
|
||||
name,
|
||||
email,
|
||||
icon,
|
||||
imageUrl,
|
||||
isLoading,
|
||||
}: {
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
name: string;
|
||||
email: string;
|
||||
imageUrl?: string;
|
||||
icon?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}) => {
|
||||
const initials = name
|
||||
? getInitialsFromName(name)
|
||||
: inferInitialsFromEmail(email);
|
||||
|
||||
return (
|
||||
<>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
className="rounded-full bg-gray-50"
|
||||
width={sizeMap[size]}
|
||||
height={sizeMap[size]}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400",
|
||||
isLoading && "animate-pulse bg-light-200 dark:bg-dark-200",
|
||||
size === "xs" && "h-5 w-5",
|
||||
size === "sm" && "h-6 w-6",
|
||||
size === "lg" && "h-12 w-12",
|
||||
)}
|
||||
>
|
||||
{icon ? (
|
||||
<span className="text-[12px] text-white">{icon}</span>
|
||||
) : (
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium leading-none text-white",
|
||||
size === "xs" && "text-[8px]",
|
||||
size === "sm" && "text-[10px]",
|
||||
size === "lg" && "text-md",
|
||||
)}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const Badge = ({ value, iconLeft }: { value: string; iconLeft: ReactNode }) => (
|
||||
<span className="mt-1 inline-flex w-fit items-center gap-x-1.5 rounded-full px-2 py-1 text-[10px] font-medium text-neutral-600 ring-1 ring-inset ring-light-600 dark:text-dark-1000 dark:ring-dark-800">
|
||||
{iconLeft}
|
||||
<div>{value}</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default Badge;
|
||||
@@ -1,90 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
isLoading?: boolean;
|
||||
iconLeft?: React.ReactNode;
|
||||
iconRight?: React.ReactNode;
|
||||
href?: string;
|
||||
fullWidth?: boolean;
|
||||
openInNewTab?: boolean;
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
children,
|
||||
size = "md",
|
||||
iconLeft,
|
||||
iconRight,
|
||||
isLoading,
|
||||
variant = "primary",
|
||||
href,
|
||||
fullWidth,
|
||||
openInNewTab,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const classes = twMerge(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
|
||||
size === "sm" && "text-xs",
|
||||
size === "lg" && "py-[0.65rem]",
|
||||
fullWidth && "w-full",
|
||||
variant === "primary" &&
|
||||
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
||||
variant === "secondary" &&
|
||||
"border-[1px] border-light-600 bg-light-50 text-light-1000 dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000",
|
||||
variant === "danger" &&
|
||||
"dark:text-red-1000 border-[1px] border-red-600 bg-red-500 dark:border-red-600 dark:bg-red-500",
|
||||
variant === "ghost" &&
|
||||
"bg-none text-light-1000 shadow-none hover:bg-light-300 dark:text-dark-1000 dark:hover:bg-dark-200",
|
||||
props.disabled && "opacity-60",
|
||||
);
|
||||
|
||||
const content = (
|
||||
<span className="relative flex items-center justify-center">
|
||||
{isLoading && (
|
||||
<span className="absolute">
|
||||
<LoadingSpinner size={size} />
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center",
|
||||
isLoading ? "invisible" : "visible",
|
||||
)}
|
||||
>
|
||||
{iconLeft && <span className="mr-2">{iconLeft}</span>}
|
||||
{children}
|
||||
{iconRight && <span className="ml-1">{iconRight}</span>}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={classes}
|
||||
target={openInNewTab ? "_blank" : undefined}
|
||||
rel={openInNewTab ? "noopener noreferrer" : undefined}
|
||||
{...(props as React.AnchorHTMLAttributes<HTMLAnchorElement>)}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classes}
|
||||
disabled={isLoading ?? props.disabled}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { HiEllipsisHorizontal, HiMiniPlus } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface Item {
|
||||
key: string;
|
||||
value: string;
|
||||
selected: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
interface CheckboxDropdownProps {
|
||||
children: React.ReactNode;
|
||||
items?: Item[];
|
||||
groups?: Group[];
|
||||
createNewItemLabel?: string;
|
||||
menuSpacing?: "sm" | "md" | "lg";
|
||||
position?: "left" | "right";
|
||||
handleSelect: (
|
||||
groupKey: string | null,
|
||||
item: { key: string; value: string },
|
||||
) => void;
|
||||
handleEdit?: (key: string) => void;
|
||||
handleCreate?: () => void;
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
export default function CheckboxDropdown({
|
||||
children,
|
||||
items,
|
||||
groups,
|
||||
createNewItemLabel = "Create new",
|
||||
menuSpacing = "sm",
|
||||
position = "left",
|
||||
handleSelect,
|
||||
handleEdit,
|
||||
handleCreate,
|
||||
asChild = true,
|
||||
}: CheckboxDropdownProps) {
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
||||
|
||||
const menuSpacingClass = {
|
||||
sm: "top-[26px]",
|
||||
md: "top-[32px]",
|
||||
lg: "top-[38px]",
|
||||
};
|
||||
|
||||
const renderMenuItems = (items: Item[], groupKey: string | null) => (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={() =>
|
||||
handleSelect(groupKey, { key: item.key, value: item.value })
|
||||
}
|
||||
checked={item.selected}
|
||||
/>
|
||||
{item.leftIcon && (
|
||||
<span className="ml-3 flex items-center">{item.leftIcon}</span>
|
||||
)}
|
||||
<label
|
||||
htmlFor={item.key}
|
||||
className="ml-3 text-[12px] text-dark-900"
|
||||
>
|
||||
{item.value}
|
||||
</label>
|
||||
{handleEdit && (
|
||||
<button
|
||||
className="invisible ml-auto group-hover:visible"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEdit(item.key);
|
||||
}}
|
||||
>
|
||||
<HiEllipsisHorizontal size={20} className="text-dark-900" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
{handleCreate && (
|
||||
<button
|
||||
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={() => handleCreate()}
|
||||
>
|
||||
<HiMiniPlus size={20} className="pr-1.5" />
|
||||
{createNewItemLabel}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
as="div"
|
||||
className="relative flex w-full flex-wrap items-center text-left"
|
||||
>
|
||||
<>
|
||||
<Menu.Button
|
||||
as={asChild ? "div" : undefined}
|
||||
className="h-full w-full cursor-pointer focus-visible:outline-none"
|
||||
>
|
||||
{children}
|
||||
</Menu.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
afterLeave={() => setSelectedGroup(null)}
|
||||
>
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"mt-2s absolute z-50 w-56 origin-top-left rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200",
|
||||
position === "left" ? "left-0" : "right-0",
|
||||
menuSpacingClass[menuSpacing],
|
||||
)}
|
||||
>
|
||||
<div className="max-h-[350px] overflow-y-auto p-1">
|
||||
{!selectedGroup ? (
|
||||
<>
|
||||
{items && renderMenuItems(items, null)}
|
||||
|
||||
{groups?.map((group) => (
|
||||
<Menu.Item key={group.key}>
|
||||
<div
|
||||
className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedGroup(group.key);
|
||||
}}
|
||||
>
|
||||
<span className="mr-2 text-dark-900">{group.icon}</span>
|
||||
<span className="pointer-events-none text-[12px] text-dark-900">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{groups?.find((g) => g.key === selectedGroup)?.items &&
|
||||
renderMenuItems(
|
||||
groups.find((g) => g.key === selectedGroup)?.items || [],
|
||||
selectedGroup,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
import FeedbackButton from "./FeedbackButton";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
export default function Dashboard(props: { children: React.ReactNode }) {
|
||||
const { data, isLoading } = api.user.getUser.useQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
html {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
`}</style>
|
||||
<div className="flex h-screen min-w-[800px] flex-col items-center bg-light-100 dark:bg-dark-50">
|
||||
<div className="m-auto flex h-16 min-h-16 w-full justify-between border-b border-light-600 px-5 py-2 align-middle dark:border-dark-400">
|
||||
<div className="my-auto flex w-full items-center justify-between">
|
||||
<Link href="/">
|
||||
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<FeedbackButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-full">
|
||||
<SideNavigation
|
||||
user={{ email: data?.email, image: data?.image }}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<div className="w-full overflow-hidden">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function DeleteLabelConfirmation({
|
||||
labelPublicId,
|
||||
refetch,
|
||||
}: {
|
||||
labelPublicId: string;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const deleteLabelMutation = api.label.delete.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onError: () =>
|
||||
showPopup({
|
||||
header: "Error deleting label",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const handleDeleteLabel = () => {
|
||||
closeModal();
|
||||
deleteLabelMutation.mutate({
|
||||
labelPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
Are you sure you want to delete this label?
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{"This action can't be undone."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDeleteLabel}>Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
export default function Dropdown({
|
||||
items,
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
items: { label: string; action: () => void; icon?: React.ReactNode }[];
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button
|
||||
disabled={disabled}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200"
|
||||
>
|
||||
{children}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 z-30 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
|
||||
<div className="flex flex-col">
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.label}>
|
||||
<button
|
||||
onClick={item.action}
|
||||
className="flex w-auto items-center gap-2 rounded-[5px] px-2.5 py-1.5 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-950 dark:hover:bg-dark-400"
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import chatIconDark from "~/assets/chat-dark.json";
|
||||
import chatIconLight from "~/assets/chat-light.json";
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import LottieIcon from "~/components/LottieIcon";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface NewFeedbackFormInput {
|
||||
feedback: string;
|
||||
}
|
||||
|
||||
const FeedbackButton: React.FC = () => {
|
||||
const outsideRef = useRef<HTMLDivElement>(null);
|
||||
const { activeTheme } = useTheme();
|
||||
const { showPopup } = usePopup();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const { handleSubmit, setValue, watch, reset } =
|
||||
useForm<NewFeedbackFormInput>({
|
||||
defaultValues: {
|
||||
feedback: "",
|
||||
},
|
||||
});
|
||||
|
||||
const createFeedback = api.feedback.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
reset();
|
||||
showPopup({
|
||||
header: "Feedback sent",
|
||||
message: "Thank you for your feedback!",
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: async () => {
|
||||
showPopup({
|
||||
header: "Unable to send feedback",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
setIndex((index) => index + 1);
|
||||
};
|
||||
|
||||
const onSubmit = (values: NewFeedbackFormInput) => {
|
||||
createFeedback.mutate({
|
||||
feedback: values.feedback,
|
||||
url: window.location.href,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={outsideRef} />
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button
|
||||
onMouseEnter={handleMouseEnter}
|
||||
className="flex items-center rounded-md border-[1px] border-light-600 bg-light-50 px-2.5 py-1.5 text-sm font-normal text-neutral-900 shadow-sm dark:border-dark-400 dark:bg-dark-50 dark:text-dark-1000"
|
||||
>
|
||||
<LottieIcon
|
||||
index={index}
|
||||
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
|
||||
isPlaying={isHovered}
|
||||
/>
|
||||
<span className="ml-1">Feedback</span>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
as="div"
|
||||
static
|
||||
className="absolute right-0 z-30 mt-2 w-[350px] origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300"
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
|
||||
<Input
|
||||
placeholder="Ideas to improve this page..."
|
||||
onChange={(e) => {
|
||||
setValue("feedback", e.target.value);
|
||||
}}
|
||||
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)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between pt-2">
|
||||
<div>
|
||||
<p className="ml-2 text-xs text-neutral-900 dark:text-dark-1000">
|
||||
Need help?{" "}
|
||||
<Link
|
||||
href="mailto:support@kan.bn"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
, or see our{" "}
|
||||
<Link
|
||||
href="https://docs.kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-600 underline dark:text-blue-300"
|
||||
>
|
||||
docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="justify-end">
|
||||
<Button size="sm" isLoading={createFeedback.isPending}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackButton;
|
||||
@@ -1,105 +0,0 @@
|
||||
import React, { forwardRef, useState } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
contentEditable?: boolean;
|
||||
prefix?: string;
|
||||
iconRight?: React.ReactNode;
|
||||
minHeight?: number;
|
||||
value?: string;
|
||||
errorMessage?: string;
|
||||
className?: string;
|
||||
onChange?: (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
contentEditable,
|
||||
errorMessage,
|
||||
prefix,
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
iconRight,
|
||||
className,
|
||||
type = "text",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
if (contentEditable) {
|
||||
return (
|
||||
<ContentEditable
|
||||
placeholder={props.placeholder}
|
||||
html={value ?? ""}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
className={twMerge(
|
||||
"block min-h-[70px] w-full cursor-text overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-1.5 text-light-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 focus-visible:outline-none dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6",
|
||||
className && className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="relative flex">
|
||||
{prefix && (
|
||||
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-base dark:border-dark-700 dark:text-dark-1000 sm:text-sm/6">
|
||||
{prefix}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
type={type === "password" && showPassword ? "text" : type}
|
||||
className={twMerge(
|
||||
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6",
|
||||
prefix && "rounded-l-none",
|
||||
className && className,
|
||||
)}
|
||||
onKeyDown={onKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
{type === "password" && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 bg-light-50 pl-1 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900"
|
||||
tabIndex={-1}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<HiOutlineEyeSlash className="h-4 w-4" />
|
||||
) : (
|
||||
<HiOutlineEye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{iconRight && type !== "password" && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{iconRight}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<div className="text-xs text-red-500">{errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export default Input;
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
import { Fragment, useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { HiChevronUpDown, HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { colours } from "@kan/shared/constants";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface LabelFormInput {
|
||||
name: string;
|
||||
colour: Colour;
|
||||
isCreateAnotherEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface Colour {
|
||||
name: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function LabelForm({
|
||||
boardPublicId,
|
||||
refetch,
|
||||
isEdit,
|
||||
}: {
|
||||
boardPublicId: string;
|
||||
refetch: () => void;
|
||||
isEdit?: boolean;
|
||||
}) {
|
||||
const { closeModal, entityId, openModal } = useModal();
|
||||
|
||||
const label = api.label.byPublicId.useQuery(
|
||||
{
|
||||
labelPublicId: entityId,
|
||||
},
|
||||
{
|
||||
enabled: isEdit && !!entityId,
|
||||
},
|
||||
);
|
||||
|
||||
const { control, register, reset, handleSubmit, setValue, watch } =
|
||||
useForm<LabelFormInput>({
|
||||
values: {
|
||||
name: isEdit && label.data?.name ? label.data.name : "",
|
||||
colour: (isEdit && label.data?.colourCode
|
||||
? colours.find((c) => c.code === label.data?.colourCode)
|
||||
: colours[0]) as Colour,
|
||||
isCreateAnotherEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
|
||||
const createLabel = api.label.create.useMutation({
|
||||
onSuccess: () => {
|
||||
const currentColourIndex = colours.findIndex(
|
||||
(c) => c.code === watch("colour").code,
|
||||
);
|
||||
try {
|
||||
refetch();
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
reset({
|
||||
name: "",
|
||||
colour: colours[(currentColourIndex + 1) % colours.length],
|
||||
isCreateAnotherEnabled,
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const updateLabel = api.label.update.useMutation({
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
closeModal();
|
||||
reset({
|
||||
name: "",
|
||||
colour: colours[0],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: LabelFormInput) => {
|
||||
if (!values.colour.code) return;
|
||||
|
||||
if (isEdit) {
|
||||
updateLabel.mutate({
|
||||
labelPublicId: label.data?.publicId ?? "",
|
||||
name: values.name,
|
||||
colourCode: values.colour.code,
|
||||
});
|
||||
} else {
|
||||
createLabel.mutate({
|
||||
name: values.name,
|
||||
colourCode: values.colour.code,
|
||||
boardPublicId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#label-name");
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-medium">
|
||||
{isEdit ? "Edit label" : "New label"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="label-name"
|
||||
placeholder="Name"
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="colour"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Listbox {...field}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="relative mt-4">
|
||||
<Listbox.Button className="block w-full rounded-md border-0 bg-white/5 px-4 py-1.5 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">
|
||||
<span className="flex items-center">
|
||||
<span
|
||||
style={{ backgroundColor: field.value.code }}
|
||||
className={`inline-block h-2 w-2 flex-shrink-0 rounded-full`}
|
||||
/>
|
||||
<span className="ml-3 block truncate">
|
||||
{field.value.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<HiChevronUpDown
|
||||
className="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-light-50 py-2 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-dark-300 sm:text-sm">
|
||||
{colours.map((colour, index) => (
|
||||
<Listbox.Option
|
||||
key={`colours_${index}`}
|
||||
className="relative cursor-default select-none px-2 text-neutral-900 dark:text-dark-1000"
|
||||
value={colour}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<div className="flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-400">
|
||||
<span
|
||||
style={{ backgroundColor: colour.code }}
|
||||
className="ml-2 inline-block h-2 w-2 flex-shrink-0 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ml-3 block truncate font-normal">
|
||||
{colour.name}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
/>
|
||||
</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">
|
||||
{!isEdit && (
|
||||
<Toggle
|
||||
label="Create another"
|
||||
isChecked={!!isCreateAnotherEnabled}
|
||||
onChange={() =>
|
||||
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-x-2">
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_LABEL", entityId)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||
>
|
||||
{isEdit ? "Update label" : "Create label"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
const LabelIcon = ({ colourCode }: { colourCode: string | null }) => (
|
||||
<svg
|
||||
fill={colourCode ?? "#3730a3"}
|
||||
className="h-2 w-2"
|
||||
viewBox="0 0 6 6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx={3} cy={3} r={3} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default LabelIcon;
|
||||
@@ -1,36 +0,0 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const LoadingSpinner = ({ size = "md" }: { size?: "sm" | "md" | "lg" }) => {
|
||||
return (
|
||||
<svg
|
||||
className={twMerge(
|
||||
"animate-spin",
|
||||
size === "sm" && "h-3 w-3",
|
||||
size === "md" && "h-4 w-4",
|
||||
size === "lg" && "h-5 w-5",
|
||||
)}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
fill="none"
|
||||
stroke-width="10"
|
||||
className="stroke-current opacity-40"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
/>
|
||||
<circle
|
||||
fill="none"
|
||||
stroke-width="10"
|
||||
className="stroke-current"
|
||||
stroke-dasharray="280"
|
||||
stroke-dashoffset="210"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function NewWorkspaceForm() {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { switchWorkspace } = useWorkspace();
|
||||
const { register, handleSubmit } = useForm<FormValues>();
|
||||
|
||||
const createWorkspace = api.workspace.create.useMutation({
|
||||
onSuccess: (values) => {
|
||||
if (values.publicId && values.name) {
|
||||
switchWorkspace({
|
||||
publicId: values.publicId,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
});
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to create workspace",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#workspace-name");
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
createWorkspace.mutate({
|
||||
name: values.name,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
New workspace
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="workspace-name"
|
||||
placeholder="Workspace name"
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<Button type="submit" isLoading={createWorkspace.isPending}>
|
||||
Create workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import Head from "next/head";
|
||||
|
||||
export const PageHead = ({ title }: { title: string }) => {
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
const PatternedBackground = () => (
|
||||
<div className="absolute inset-0 h-full w-full">
|
||||
<svg className="h-full w-full">
|
||||
<pattern
|
||||
id="pattern"
|
||||
x="0.034759358288862785"
|
||||
y="3.335370511841166"
|
||||
width="14.423223834988539"
|
||||
height="14.423223834988539"
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternTransform="translate(-0.45072574484339184,-0.45072574484339184)"
|
||||
>
|
||||
<circle
|
||||
cx="0.45072574484339184"
|
||||
cy="0.45072574484339184"
|
||||
r="0.45072574484339184"
|
||||
fill="#3e3e3e"
|
||||
></circle>
|
||||
</pattern>
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="url(#pattern)"></rect>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PatternedBackground;
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineExclamationCircle,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
const Popup: React.FC = () => {
|
||||
const { isOpen, popupHeader, popupMessage, popupIcon, hidePopup } =
|
||||
usePopup();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const timer = setTimeout(() => {
|
||||
hidePopup();
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, hidePopup]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="pointer-events-none fixed inset-0 z-10 flex items-end p-3 sm:items-end"
|
||||
>
|
||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<Transition
|
||||
show={isOpen}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg border border-light-400 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 transition data-[closed]:data-[enter]:translate-y-2 data-[enter]:transform data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-100 data-[enter]:ease-out data-[leave]:ease-in dark:border-dark-300 dark:bg-dark-200 data-[closed]:data-[enter]:sm:translate-x-2 data-[closed]:data-[enter]:sm:translate-y-0">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
{popupIcon === "success" && (
|
||||
<HiOutlineCheckCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-green-400"
|
||||
/>
|
||||
)}
|
||||
{popupIcon === "error" && (
|
||||
<HiOutlineExclamationCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-red-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{popupHeader}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{popupMessage}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
hidePopup();
|
||||
}}
|
||||
className="mx-1 inline-flex h-fit items-center rounded-md p-1 px-1 text-sm font-semibold text-dark-50 hover:bg-light-100 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<HiXMark
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-dark-900"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Popup;
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { DroppableProps } from "react-beautiful-dnd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Droppable } from "react-beautiful-dnd";
|
||||
|
||||
export const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
const animation = requestAnimationFrame(() => setEnabled(true));
|
||||
return () => {
|
||||
cancelAnimationFrame(animation);
|
||||
setEnabled(false);
|
||||
};
|
||||
}, []);
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
return <Droppable {...props}>{children}</Droppable>;
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { CgDarkMode } from "react-icons/cg";
|
||||
|
||||
import { useTheme } from "~/providers/theme";
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const { themePreference, switchTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
switchTheme(themePreference === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
aria-label={`Switch to ${themePreference === "light" ? "dark" : "light"} theme`}
|
||||
>
|
||||
<CgDarkMode
|
||||
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
|
||||
themePreference === "dark" ? "rotate-180" : "rotate-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Switch } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
const Toggle = ({
|
||||
isChecked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
isChecked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
}) => (
|
||||
<div className="mr-4 flex items-center justify-end">
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
className={twMerge(
|
||||
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
|
||||
isChecked && "bg-indigo-600 dark:bg-indigo-600",
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={twMerge(
|
||||
"pointer-events-none inline-block h-3 w-3 translate-x-0 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out",
|
||||
isChecked && "translate-x-2",
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Toggle;
|
||||
@@ -1,59 +0,0 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { vercel } from "@t3-oss/env-nextjs/presets";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
extends: [vercel()],
|
||||
shared: {
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
},
|
||||
/**
|
||||
* Specify your server-side environment variables schema here.
|
||||
* This way you can ensure the app isn't built with invalid env vars.
|
||||
*/
|
||||
server: {
|
||||
BETTER_AUTH_SECRET: z.string(),
|
||||
BETTER_AUTH_URL: z.string(),
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: z.string(),
|
||||
POSTGRES_URL: z.string().url(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
S3_ACCESS_KEY_ID: z.string().optional(),
|
||||
S3_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
EMAIL_FROM: z.string(),
|
||||
EMAIL_URL: z.string(),
|
||||
EMAIL_TOKEN: z.string(),
|
||||
},
|
||||
|
||||
/**
|
||||
* Specify your client-side environment variables schema here.
|
||||
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
|
||||
*/
|
||||
client: {
|
||||
NEXT_PUBLIC_KAN_ENV: z.string().optional(),
|
||||
NEXT_PUBLIC_UMAMI_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_BASE_URL: z.string(),
|
||||
NEXT_PUBLIC_STORAGE_URL: z.string().optional(),
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
*/
|
||||
experimental__runtimeEnv: {
|
||||
NEXT_PUBLIC_KAN_ENV: process.env.NEXT_PUBLIC_KAN_ENV,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_UMAMI_ID: process.env.NEXT_PUBLIC_UMAMI_ID,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||
},
|
||||
skipValidation:
|
||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* A hook that delays updating a value until a specified delay has passed
|
||||
* @param value The value to debounce
|
||||
* @param delay The delay in milliseconds
|
||||
* @returns The debounced value
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): [T] {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return [debouncedValue];
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await require("pino");
|
||||
await require("next-logger");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import PublicBoardView from "~/views/public/board";
|
||||
|
||||
export default function PublicBoardsPage() {
|
||||
return <PublicBoardView />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import PublicBoardsView from "~/views/public/boards";
|
||||
|
||||
export default function PublicBoardsPage() {
|
||||
return <PublicBoardsView />;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
|
||||
import { initAuth } from "@kan/auth/server";
|
||||
import { createDrizzleClient } from "@kan/db/client";
|
||||
|
||||
export const config = { api: { bodyParser: false } };
|
||||
|
||||
const auth = initAuth(createDrizzleClient());
|
||||
|
||||
export default toNodeHandler(auth.handler);
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user?.stripeCustomerId) {
|
||||
return res.status(404).json({ error: "No billing account found" });
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
|
||||
|
||||
interface CheckoutSessionRequest {
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
slug: string;
|
||||
workspacePublicId: string;
|
||||
stripeCustomerId: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const body = req.body as CheckoutSessionRequest;
|
||||
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
||||
|
||||
if (!successUrl || !cancelUrl || !slug || !workspacePublicId) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
||||
|
||||
if (!slugResult.success) {
|
||||
return new Response(JSON.stringify({ error: "Invalid workspace slug" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
|
||||
const isMemberOfWorkspace = workspace.some(
|
||||
({ workspace }) => workspace.publicId === body.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!isMemberOfWorkspace) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [
|
||||
{
|
||||
price: process.env.STRIPE_PRO_PLAN_PRICE_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||
customer: user.stripeCustomerId ?? undefined,
|
||||
metadata: {
|
||||
workspaceSlug: slug,
|
||||
workspacePublicId,
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating checkout session" });
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
async function buffer(readable: Readable) {
|
||||
const chunks = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const sig = req.headers["stripe-signature"];
|
||||
|
||||
if (!sig) {
|
||||
return res.status(400).json({ message: "No signature found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = await buffer(req);
|
||||
const rawBody = buf.toString("utf8");
|
||||
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
sig,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
);
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const checkoutSession = event.data.object;
|
||||
|
||||
const metaData = checkoutSession.metadata;
|
||||
|
||||
if (metaData?.workspacePublicId && metaData.workspaceSlug) {
|
||||
await workspaceRepo.update(db, metaData.workspacePublicId, {
|
||||
slug: metaData.workspaceSlug,
|
||||
plan: "pro",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
}
|
||||
|
||||
return res.status(200).json({ received: true });
|
||||
} catch (err) {
|
||||
console.error("Webhook error:", err);
|
||||
return res.status(400).json({ message: "Webhook handler failed" });
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createNextApiHandler } from "@trpc/server/adapters/next";
|
||||
|
||||
import { appRouter } from "@kan/api/root";
|
||||
import { createTRPCContext } from "@kan/api/trpc";
|
||||
|
||||
const nextApiHandler = createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
onError:
|
||||
process.env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(200);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
return nextApiHandler(req, res);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
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 ?? "",
|
||||
credentials: {
|
||||
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
|
||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const signedUrl = await getSignedUrl(
|
||||
// @ts-ignore
|
||||
client,
|
||||
new PutObjectCommand({
|
||||
Bucket: env("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",
|
||||
Key: filename,
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({ url: signedUrl, key: filename });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import cors from "nextjs-cors";
|
||||
import { createOpenApiNextHandler } from "trpc-to-openapi";
|
||||
|
||||
import { appRouter } from "@kan/api";
|
||||
import { createRESTContext } from "@kan/api/trpc";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
await cors(req, res);
|
||||
|
||||
const openApiHandler = createOpenApiNextHandler({
|
||||
router: appRouter,
|
||||
createContext: createRESTContext,
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ REST failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return await openApiHandler(req, res);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { type NextApiRequest, type NextApiResponse } from "next";
|
||||
|
||||
import { openApiDocument } from "@kan/api/openapi";
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
res.status(200).send(openApiDocument);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
@@ -1,5 +0,0 @@
|
||||
import HomeView from "~/views/home";
|
||||
|
||||
export default function Home() {
|
||||
return <HomeView />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import PrivacyView from "~/views/privacy";
|
||||
|
||||
export default function Privacy() {
|
||||
return <PrivacyView />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import SignupView from "~/views/auth/signup";
|
||||
|
||||
export default function SignupPage() {
|
||||
return <SignupView />;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import TermsView from "~/views/terms";
|
||||
|
||||
export default function Terms() {
|
||||
return <TermsView />;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
interface PopupContextType {
|
||||
isOpen: boolean;
|
||||
showPopup: (params: {
|
||||
header: string;
|
||||
message: string;
|
||||
icon: string;
|
||||
}) => void;
|
||||
hidePopup: () => void;
|
||||
popupHeader: string;
|
||||
popupMessage: string;
|
||||
popupIcon: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PopupContext = createContext<PopupContextType | undefined>(undefined);
|
||||
|
||||
export const PopupProvider: React.FC<Props> = ({ children }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [popupHeader, setPopupHeader] = useState("");
|
||||
const [popupMessage, setPopupMessage] = useState("");
|
||||
const [popupIcon, setPopupIcon] = useState("");
|
||||
|
||||
const showPopup = ({
|
||||
header,
|
||||
message,
|
||||
icon,
|
||||
}: {
|
||||
header: string;
|
||||
message: string;
|
||||
icon: string;
|
||||
}) => {
|
||||
setIsOpen(true);
|
||||
setPopupHeader(header);
|
||||
setPopupMessage(message);
|
||||
setPopupIcon(icon);
|
||||
};
|
||||
|
||||
const hidePopup = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopupContext.Provider
|
||||
value={{
|
||||
isOpen,
|
||||
showPopup,
|
||||
hidePopup,
|
||||
popupHeader,
|
||||
popupMessage,
|
||||
popupIcon,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PopupContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePopup = () => {
|
||||
const context = useContext(PopupContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("usePopup must be used within a PopupProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface ThemeContextProps {
|
||||
themePreference: "light" | "dark" | "system";
|
||||
activeTheme: "light" | "dark";
|
||||
switchTheme: (theme: "light" | "dark" | "system") => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextProps | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [themePreference, setThemePreference] = useState<
|
||||
"light" | "dark" | "system"
|
||||
>("system");
|
||||
const [activeTheme, setActiveTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
const switchTheme = (theme: "light" | "dark" | "system") => {
|
||||
if (theme === "system") {
|
||||
localStorage.removeItem("theme");
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setActiveTheme(isDark ? "dark" : "light");
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
} else {
|
||||
const isDark = theme === "dark";
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
localStorage.theme = theme;
|
||||
setActiveTheme(isDark ? "dark" : "light");
|
||||
}
|
||||
setThemePreference(theme);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!("theme" in localStorage)) {
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
setActiveTheme(isDark ? "dark" : "light");
|
||||
setThemePreference("system");
|
||||
} else {
|
||||
const isDark = localStorage.theme === "dark";
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
setActiveTheme(isDark ? "dark" : "light");
|
||||
setThemePreference(localStorage.theme as "light" | "dark");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{ switchTheme, themePreference, activeTheme }}
|
||||
>
|
||||
{themePreference.length ? children : null}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = (): ThemeContextProps => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
[contenteditable=true]:empty:before {
|
||||
content: attr(placeholder);
|
||||
display: block;
|
||||
color: #707070;
|
||||
}
|
||||
|
||||
.gradient-border::before {
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
transparent 0deg,
|
||||
transparent 45deg,
|
||||
rgba(244, 114, 182, 0.4) 85deg,
|
||||
rgba(192, 132, 252, 0.4) 95deg,
|
||||
transparent 110deg,
|
||||
transparent 360deg
|
||||
);
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export const formatToArray = (
|
||||
value: string | string[] | undefined,
|
||||
): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item) => item !== undefined);
|
||||
}
|
||||
return value ? [value] : [];
|
||||
};
|
||||
|
||||
export const inferInitialsFromEmail = (email: string) => {
|
||||
const localPart = email.split("@")[0];
|
||||
if (!localPart) return "";
|
||||
const separators = /[._-]/;
|
||||
const parts = localPart.split(separators);
|
||||
|
||||
if (parts.length > 1) {
|
||||
return (
|
||||
(parts[0]?.[0] ?? "") + (parts[parts.length - 1]?.[0] ?? "")
|
||||
).toUpperCase();
|
||||
} else {
|
||||
return localPart.slice(0, 2).toUpperCase();
|
||||
}
|
||||
};
|
||||
|
||||
export const getInitialsFromName = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((namePart) => namePart.charAt(0).toUpperCase())
|
||||
.join("");
|
||||
};
|
||||
|
||||
export const formatMemberDisplayName = (
|
||||
name: string | null,
|
||||
email: string | null,
|
||||
) => {
|
||||
if (name) return name;
|
||||
if (!email) return "";
|
||||
|
||||
const localPart = email.split("@")[0];
|
||||
|
||||
if (!localPart) return "";
|
||||
|
||||
return localPart.replace(/[_-]/g, ".");
|
||||
};
|
||||
|
||||
export const getAvatarUrl = (imageOrKey: string) => {
|
||||
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
// import { useRouter } from "next/navigation";
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
// import { api } from "~/utils/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
// const router = useRouter();
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||
setIsMagicLinkSent(value);
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
// const authCookieExists = document.cookie
|
||||
// .split("; ")
|
||||
// .some((cookie) => cookie.includes("auth-token"));
|
||||
|
||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
||||
// enabled: authCookieExists ? true : false,
|
||||
// });
|
||||
|
||||
// if (data?.id) router.push("/boards");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Login | kan.bn" />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? "Check your inbox" : "Welcome back"}
|
||||
</p>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="text-md mt-2 text-center text-light-1000 dark:text-dark-1000">
|
||||
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
Don't have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/signup">Sign up</Link>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
// import { useRouter } from "next/navigation";
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
// import { api } from "~/utils/api";
|
||||
|
||||
export default function SignupPage() {
|
||||
// const router = useRouter();
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||
setIsMagicLinkSent(value);
|
||||
setMagicLinkRecipient(recipient);
|
||||
};
|
||||
|
||||
// const authCookieExists = document.cookie
|
||||
// .split("; ")
|
||||
// .some((cookie) => cookie.includes("auth-token"));
|
||||
|
||||
// const { data } = api.user.getUser.useQuery(undefined, {
|
||||
// enabled: authCookieExists ? true : false,
|
||||
// });
|
||||
|
||||
// if (data?.id) router.push("/boards");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Sign up | kan.bn" />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-10 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? "Check your inbox" : "Create your account"}
|
||||
</p>
|
||||
{isMagicLinkSent ? (
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<p className="text-md mt-2 text-center text-light-1000 dark:text-dark-1000">
|
||||
{`Click on the link we've sent to ${magicLinkRecipient} to sign in.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Auth setIsMagicLinkSent={handleMagicLinkSent} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/login">Sign in</Link>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
export default function BoardDropdown({ isLoading }: { isLoading: boolean }) {
|
||||
const { openModal } = useModal();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Edit board URL",
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: "Delete board",
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
const Card = ({
|
||||
title,
|
||||
labels,
|
||||
members,
|
||||
}: {
|
||||
title: string;
|
||||
labels: { name: string; colourCode: string | null }[];
|
||||
members: {
|
||||
publicId: string;
|
||||
email: string;
|
||||
user: { name: string | null; email: string; image: string | null } | null;
|
||||
}[];
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
<span>{title}</span>
|
||||
{labels.length || members.length ? (
|
||||
<div className="mt-2 flex flex-col justify-end">
|
||||
<div className="space-x-0.5">
|
||||
{labels.map((label) => (
|
||||
<Badge
|
||||
value={label.name}
|
||||
iconLeft={<LabelIcon colourCode={label.colourCode} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{members.map(({ user, email }) => {
|
||||
const avatarUrl = user?.image
|
||||
? getAvatarUrl(user.image)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
name={user?.name ?? ""}
|
||||
email={user?.email ?? email}
|
||||
imageUrl={avatarUrl}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function DeleteBoardConfirmation({
|
||||
boardPublicId,
|
||||
}: {
|
||||
boardPublicId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal } = useModal();
|
||||
|
||||
const deleteBoard = api.board.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
closeModal();
|
||||
router.push(`/boards`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteBoard = () => {
|
||||
if (boardPublicId)
|
||||
deleteBoard.mutate({
|
||||
boardPublicId: boardPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
Are you sure you want to delete this board?
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{"This action can't be undone."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface DeleteListConfirmationProps {
|
||||
listPublicId: string;
|
||||
queryParams: QueryParams;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export function DeleteListConfirmation({
|
||||
listPublicId,
|
||||
queryParams,
|
||||
}: DeleteListConfirmationProps) {
|
||||
const utils = api.useUtils();
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const deleteList = api.list.delete.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to delete list",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
closeModal();
|
||||
await utils.board.byId.invalidate(queryParams);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
Are you sure you want to delete this list?
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{"This action can't be undone."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={deleteList.isPending}
|
||||
onClick={() => deleteList.mutate({ listPublicId })}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
HiMiniXMark,
|
||||
HiOutlineTag,
|
||||
HiOutlineUserCircle,
|
||||
} from "react-icons/hi2";
|
||||
import { IoFilterOutline } from "react-icons/io5";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import {
|
||||
formatMemberDisplayName,
|
||||
formatToArray,
|
||||
getAvatarUrl,
|
||||
} from "~/utils/helpers";
|
||||
|
||||
interface Member {
|
||||
publicId: string;
|
||||
user: {
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
email: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Label {
|
||||
publicId: string;
|
||||
name: string;
|
||||
colourCode: string | null;
|
||||
}
|
||||
|
||||
const Filters = ({
|
||||
position = "right",
|
||||
labels,
|
||||
members,
|
||||
isLoading,
|
||||
}: {
|
||||
position?: "left" | "right";
|
||||
labels: Label[];
|
||||
members: Member[];
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const clearFilters = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, members: [], labels: [] },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const formattedMembers = members.map((member) => ({
|
||||
key: member.publicId,
|
||||
value: formatMemberDisplayName(
|
||||
member.user?.name ?? null,
|
||||
member.user?.email ?? null,
|
||||
),
|
||||
selected: !!router.query.members?.includes(member.publicId),
|
||||
leftIcon: (
|
||||
<Avatar
|
||||
size="xs"
|
||||
name={member.user?.name ?? ""}
|
||||
imageUrl={
|
||||
member.user?.image ? getAvatarUrl(member.user.image) : undefined
|
||||
}
|
||||
email={member.user?.email ?? ""}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const formattedLabels = labels.map((label) => ({
|
||||
key: label.publicId,
|
||||
value: label.name,
|
||||
selected: !!router.query.labels?.includes(label.publicId),
|
||||
leftIcon: <LabelIcon colourCode={label.colourCode} />,
|
||||
}));
|
||||
|
||||
const groups = [
|
||||
...(formattedMembers.length
|
||||
? [
|
||||
{
|
||||
key: "members",
|
||||
label: "Members",
|
||||
icon: <HiOutlineUserCircle size={16} />,
|
||||
items: formattedMembers,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "labels",
|
||||
label: "Labels",
|
||||
icon: <HiOutlineTag size={16} />,
|
||||
items: formattedLabels,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = async (
|
||||
groupKey: string | null,
|
||||
item: { key: string },
|
||||
) => {
|
||||
if (groupKey === null) return;
|
||||
const currentQuery = router.query[groupKey] ?? [];
|
||||
const formattedCurrentQuery = Array.isArray(currentQuery)
|
||||
? currentQuery
|
||||
: [currentQuery];
|
||||
|
||||
const updatedQuery = formattedCurrentQuery.includes(item.key)
|
||||
? formattedCurrentQuery.filter((key) => key !== item.key)
|
||||
: [...formattedCurrentQuery, item.key];
|
||||
|
||||
try {
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, [groupKey]: updatedQuery },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const numOfFilters = [
|
||||
...formatToArray(router.query.members),
|
||||
...formatToArray(router.query.labels),
|
||||
].length;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CheckboxDropdown
|
||||
groups={groups}
|
||||
handleSelect={handleSelect}
|
||||
menuSpacing="md"
|
||||
position={position}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isLoading}
|
||||
iconLeft={<IoFilterOutline />}
|
||||
>
|
||||
Filter
|
||||
</Button>
|
||||
{numOfFilters > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
aria-label="Clear filters"
|
||||
className="group absolute -right-[8px] -top-[8px] flex h-5 w-5 items-center justify-center rounded-full border-2 border-light-100 bg-light-1000 text-[8px] font-[700] text-light-600 dark:border-dark-50 dark:bg-dark-1000 dark:text-dark-600"
|
||||
>
|
||||
<span className="group-hover:hidden">{numOfFilters}</span>
|
||||
<span className="hidden text-light-50 group-hover:inline dark:text-dark-50">
|
||||
<HiMiniXMark size={12} />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Filters;
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import type { NewListInput } from "@kan/api/types";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type NewListFormInput = NewListInput & {
|
||||
isCreateAnotherEnabled: boolean;
|
||||
};
|
||||
|
||||
interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export function NewListForm({
|
||||
boardPublicId,
|
||||
queryParams,
|
||||
}: {
|
||||
boardPublicId: string;
|
||||
queryParams: QueryParams;
|
||||
}) {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { register, handleSubmit, reset, setValue, watch } =
|
||||
useForm<NewListFormInput>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
boardPublicId: boardPublicId,
|
||||
isCreateAnotherEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
|
||||
const createList = api.list.create.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.board.byId.cancel();
|
||||
|
||||
const currentState = utils.board.byId.getData(queryParams);
|
||||
|
||||
utils.board.byId.setData(queryParams, (oldBoard) => {
|
||||
if (!oldBoard) return oldBoard;
|
||||
|
||||
const newList = {
|
||||
publicId: generateUID(),
|
||||
name: args.name,
|
||||
boardId: 1,
|
||||
boardPublicId,
|
||||
cards: [],
|
||||
index: oldBoard.lists.length,
|
||||
};
|
||||
|
||||
const updatedLists = [...oldBoard.lists, newList];
|
||||
|
||||
return { ...oldBoard, lists: updatedLists };
|
||||
});
|
||||
|
||||
return { previousState: currentState };
|
||||
},
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to create list",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate(queryParams);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#list-name");
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const onSubmit = (data: NewListInput) => {
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
reset({
|
||||
name: "",
|
||||
isCreateAnotherEnabled,
|
||||
});
|
||||
|
||||
createList.mutate({
|
||||
name: data.name,
|
||||
boardPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
New list
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="list-name"
|
||||
placeholder="List name"
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
<Toggle
|
||||
label="Create another"
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
onChange={() =>
|
||||
setValue("isCreateAnotherEnabled", !isCreateAnotherEnabled)
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="submit">Create list</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const schema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: "Board URL must be at least 3 characters long",
|
||||
})
|
||||
.max(60, { message: "Board URL cannot exceed 60 characters" })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: "Board URL can only contain letters, numbers, and hyphens",
|
||||
}),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export function UpdateBoardSlugForm({
|
||||
boardPublicId,
|
||||
workspaceSlug,
|
||||
boardSlug,
|
||||
queryParams,
|
||||
}: {
|
||||
boardPublicId: string;
|
||||
workspaceSlug: string;
|
||||
boardSlug: string;
|
||||
queryParams: QueryParams;
|
||||
}) {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isDirty, errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
values: {
|
||||
slug: boardSlug,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const updateBoardSlug = api.board.update.useMutation({
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to update board URL",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
closeModal();
|
||||
await utils.board.byId.invalidate(queryParams);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#board-slug");
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
updateBoardSlug.mutate({
|
||||
slug: data.slug,
|
||||
boardPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
Edit board URL
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="board-slug"
|
||||
{...register("slug")}
|
||||
errorMessage={errors.slug?.message}
|
||||
prefix={`kan.bn/${workspaceSlug}/`}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateBoardSlug.isPending}
|
||||
disabled={
|
||||
!isDirty ||
|
||||
updateBoardSlug.isPending ||
|
||||
errors.slug?.message !== undefined
|
||||
}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiOutlineEye, HiOutlineEyeSlash } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
const VisibilityButton = ({
|
||||
visibility,
|
||||
boardPublicId,
|
||||
queryParams,
|
||||
isLoading,
|
||||
isAdmin,
|
||||
}: {
|
||||
visibility: "public" | "private";
|
||||
boardPublicId: string;
|
||||
queryParams: QueryParams;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
}) => {
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
const [stateVisibility, setStateVisibility] = useState<"public" | "private">(
|
||||
visibility,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setStateVisibility(visibility);
|
||||
}, [visibility]);
|
||||
|
||||
const isPublic = stateVisibility === "public";
|
||||
|
||||
const updateBoardVisibility = api.board.update.useMutation({
|
||||
onSuccess: () => {
|
||||
setStateVisibility(isPublic ? "private" : "public");
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: "Unable to update board visibility",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate(queryParams);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUpdateBoardVisibility = () => {
|
||||
updateBoardVisibility.mutate({
|
||||
visibility: isPublic ? "private" : "public",
|
||||
boardPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleUpdateBoardVisibility}
|
||||
iconLeft={isPublic ? <HiOutlineEye /> : <HiOutlineEyeSlash />}
|
||||
isLoading={updateBoardVisibility.isPending}
|
||||
disabled={isLoading || !isAdmin}
|
||||
>
|
||||
{isPublic ? "Public" : "Private"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisibilityButton;
|
||||
@@ -1,461 +0,0 @@
|
||||
import type { DropResult } from "react-beautiful-dnd";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiOutlinePlusSmall, HiOutlineSquare3Stack3D } from "react-icons/hi2";
|
||||
|
||||
import type { UpdateBoardInput } from "@kan/api/types";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { DeleteLabelConfirmation } from "~/components/DeleteLabelConfirmation";
|
||||
import { LabelForm } from "~/components/LabelForm";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { formatToArray } from "~/utils/helpers";
|
||||
import BoardDropdown from "./components/BoardDropdown";
|
||||
import Card from "./components/Card";
|
||||
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
|
||||
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
|
||||
import Filters from "./components/Filters";
|
||||
import List from "./components/List";
|
||||
import { NewCardForm } from "./components/NewCardForm";
|
||||
import { NewListForm } from "./components/NewListForm";
|
||||
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
||||
import VisibilityButton from "./components/VisibilityButton";
|
||||
|
||||
type PublicListId = string;
|
||||
|
||||
export default function BoardPage() {
|
||||
const params = useParams() as { boardId: string[] } | null;
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal, modalContentType, entityId } = useModal();
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const boardId = params?.boardId.length ? params.boardId[0] : null;
|
||||
|
||||
const updateBoard = api.board.update.useMutation();
|
||||
|
||||
const { register, handleSubmit, setValue } = useForm<UpdateBoardInput>({
|
||||
values: {
|
||||
boardPublicId: boardId ?? "",
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: UpdateBoardInput) => {
|
||||
updateBoard.mutate({
|
||||
boardPublicId: values.boardPublicId,
|
||||
name: values.name,
|
||||
});
|
||||
};
|
||||
|
||||
const queryParams = {
|
||||
boardPublicId: boardId ?? "",
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
};
|
||||
|
||||
const {
|
||||
data: boardData,
|
||||
isSuccess,
|
||||
isLoading: isQueryLoading,
|
||||
} = api.board.byId.useQuery(queryParams, {
|
||||
enabled: !!boardId,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const refetchBoard = async () => {
|
||||
if (boardId) await utils.board.byId.refetch({ boardPublicId: boardId });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (boardId) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}, [boardId]);
|
||||
|
||||
const isLoading = isInitialLoading || isQueryLoading;
|
||||
|
||||
const updateListMutation = api.list.update.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.board.byId.cancel();
|
||||
|
||||
const currentState = utils.board.byId.getData(queryParams);
|
||||
|
||||
utils.board.byId.setData(queryParams, (oldBoard) => {
|
||||
if (!oldBoard) return oldBoard;
|
||||
|
||||
const updatedLists = Array.from(oldBoard.lists);
|
||||
|
||||
const sourceList = updatedLists.find(
|
||||
(list) => list.publicId === args.listPublicId,
|
||||
);
|
||||
|
||||
const currentIndex = sourceList?.index;
|
||||
|
||||
if (currentIndex === undefined) return oldBoard;
|
||||
|
||||
const removedList = updatedLists.splice(currentIndex, 1)[0];
|
||||
|
||||
if (removedList && args.index !== undefined) {
|
||||
updatedLists.splice(args.index, 0, removedList);
|
||||
|
||||
return {
|
||||
...oldBoard,
|
||||
lists: updatedLists,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return { previousState: currentState };
|
||||
},
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to update list",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate(queryParams);
|
||||
},
|
||||
});
|
||||
|
||||
const updateCardMutation = api.card.update.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.board.byId.cancel();
|
||||
|
||||
const currentState = utils.board.byId.getData(queryParams);
|
||||
|
||||
utils.board.byId.setData(queryParams, (oldBoard) => {
|
||||
if (!oldBoard) return oldBoard;
|
||||
|
||||
const updatedLists = Array.from(oldBoard.lists);
|
||||
|
||||
const sourceList = updatedLists.find((list) =>
|
||||
list.cards.some((card) => card.publicId === args.cardPublicId),
|
||||
);
|
||||
const destinationList = updatedLists.find(
|
||||
(list) => list.publicId === args.listPublicId,
|
||||
);
|
||||
|
||||
const cardToMove = sourceList?.cards.find(
|
||||
(card) => card.publicId === args.cardPublicId,
|
||||
);
|
||||
|
||||
if (!cardToMove) return oldBoard;
|
||||
|
||||
const removedCard = sourceList?.cards.splice(cardToMove.index, 1)[0];
|
||||
|
||||
if (
|
||||
sourceList &&
|
||||
destinationList &&
|
||||
removedCard &&
|
||||
args.index !== undefined
|
||||
) {
|
||||
destinationList.cards.splice(args.index, 0, removedCard);
|
||||
|
||||
return {
|
||||
...oldBoard,
|
||||
lists: updatedLists,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return { previousState: currentState };
|
||||
},
|
||||
onError: (_error, _newList, context) => {
|
||||
utils.board.byId.setData(queryParams, context?.previousState);
|
||||
showPopup({
|
||||
header: "Unable to update card",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate(queryParams);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && boardData) {
|
||||
setValue("name", boardData.name || "");
|
||||
}
|
||||
}, [isSuccess, boardData, setValue]);
|
||||
|
||||
const openNewListForm = (publicBoardId: string) => {
|
||||
openModal("NEW_LIST");
|
||||
setSelectedPublicListId(publicBoardId);
|
||||
};
|
||||
|
||||
const onDragEnd = ({
|
||||
source,
|
||||
destination,
|
||||
draggableId,
|
||||
type,
|
||||
}: DropResult): void => {
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "LIST") {
|
||||
updateListMutation.mutate({
|
||||
listPublicId: draggableId,
|
||||
index: destination.index,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "CARD") {
|
||||
updateCardMutation.mutate({
|
||||
cardPublicId: draggableId,
|
||||
|
||||
listPublicId: destination.droppableId,
|
||||
index: destination.index,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={`${boardData?.name ?? "Board"} | ${workspace.name ?? "Workspace"}`}
|
||||
/>
|
||||
<div className="relative flex h-full flex-col">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 flex w-full justify-between p-8">
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="focus-visible:outline-none"
|
||||
>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
{...register("name")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="block border-0 bg-transparent p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 focus:ring-0 focus-visible:outline-none dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
queryParams={queryParams}
|
||||
isLoading={isLoading}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
<Filters
|
||||
labels={boardData?.labels ?? []}
|
||||
members={boardData?.workspace.members ?? []}
|
||||
position="left"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<Button
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall
|
||||
className="-mr-0.5 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
New list
|
||||
</Button>
|
||||
<BoardDropdown isLoading={isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] z-0 flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
{isLoading ? (
|
||||
<div className="ml-[2rem] flex">
|
||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{boardData?.lists.length === 0 ? (
|
||||
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineSquare3Stack3D className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
No lists
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
Get started by creating a new list
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (boardId) openNewListForm(boardId);
|
||||
}}
|
||||
>
|
||||
Create new list
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable
|
||||
droppableId="all-lists"
|
||||
direction="horizontal"
|
||||
type="LIST"
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
className="flex"
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
<div className="min-w-[2rem]" />
|
||||
{boardData?.lists.map((list, index) => (
|
||||
<List
|
||||
index={index}
|
||||
key={index}
|
||||
list={list}
|
||||
setSelectedPublicListId={(publicListId) =>
|
||||
setSelectedPublicListId(publicListId)
|
||||
}
|
||||
>
|
||||
<Droppable
|
||||
droppableId={`${list.publicId}`}
|
||||
type="CARD"
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar scrollbar-track-dark-100 scrollbar-thumb-dark-600"
|
||||
>
|
||||
{list.cards.map((card, index) => (
|
||||
<Draggable
|
||||
key={card.publicId}
|
||||
draggableId={card.publicId}
|
||||
index={index}
|
||||
>
|
||||
{(provided) => (
|
||||
<Link
|
||||
onClick={(e) => {
|
||||
if (
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
)
|
||||
e.preventDefault();
|
||||
}}
|
||||
key={card.publicId}
|
||||
href={`/cards/${card.publicId}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
labels={card.labels}
|
||||
members={card.members}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</List>
|
||||
))}
|
||||
<div className="min-w-[0.75rem]" />
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Modal modalSize={modalContentType === "NEW_CARD" ? "md" : "sm"}>
|
||||
{modalContentType === "DELETE_BOARD" && (
|
||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
||||
)}
|
||||
{modalContentType === "DELETE_LIST" && (
|
||||
<DeleteListConfirmation
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "NEW_CARD" && (
|
||||
<NewCardForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "NEW_LIST" && (
|
||||
<NewListForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
{modalContentType === "NEW_LABEL" && (
|
||||
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
|
||||
)}
|
||||
{modalContentType === "EDIT_LABEL" && (
|
||||
<LabelForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
refetch={refetchBoard}
|
||||
isEdit
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "DELETE_LABEL" && (
|
||||
<DeleteLabelConfirmation
|
||||
refetch={refetchBoard}
|
||||
labelPublicId={entityId}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "UPDATE_BOARD_SLUG" && (
|
||||
<UpdateBoardSlugForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||