Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b72fd2000 | ||
|
|
a7873fda02 | ||
|
|
c1c75fa260 | ||
|
|
9bdc8a715c | ||
|
|
35bbb04b9d | ||
|
|
5d96cdc1c2 | ||
|
|
56b4010b91 | ||
|
|
62cfad1a32 | ||
|
|
4db788680e | ||
|
|
39c1a2ffc6 | ||
|
|
f5154393e2 | ||
|
|
e9d583113a | ||
|
|
21d0c02687 | ||
|
|
2281316d58 | ||
|
|
210c12b7ef | ||
|
|
66e15e419e | ||
|
|
727803ea5c | ||
|
|
7add7efea2 | ||
|
|
a7c80f65c3 | ||
|
|
1e4edf7504 | ||
|
|
24053ca5ab | ||
|
|
343363d54f | ||
|
|
8742bd59da | ||
|
|
8eea062218 | ||
|
|
9a5674e239 | ||
|
|
50b3a2e431 | ||
|
|
e8d46cd4f3 | ||
|
|
8f896f7412 | ||
|
|
504631454a | ||
|
|
345f3716c9 | ||
|
|
5fff8da08d | ||
|
|
07e24f7fcb | ||
|
|
5dfe03b3f1 | ||
|
|
26bed11116 | ||
|
|
972bb9dadb | ||
|
|
4f5e124ff0 | ||
|
|
c392c06b39 | ||
|
|
002e867bc4 | ||
|
|
9b8f716a6a | ||
|
|
853f1791d2 | ||
|
|
7145cb6f30 | ||
|
|
e990e00a75 | ||
|
|
0c05499107 | ||
|
|
3887efd9ee | ||
|
|
e619c1a5b4 | ||
|
|
10e13d1f6b | ||
|
|
f9d915378b | ||
|
|
74465c0565 | ||
|
|
d3fd8027a5 | ||
|
|
833237b613 | ||
|
|
1cd2b273f9 | ||
|
|
1ad470309b | ||
|
|
2fd38aadd9 | ||
|
|
b61d1836ea | ||
|
|
c3903849ec | ||
|
|
b67a0be0dd | ||
|
|
e07d237218 | ||
|
|
cc37083389 | ||
|
|
552953151a | ||
|
|
b227401dd6 | ||
|
|
6a5efc5f3f | ||
|
|
4c5f8a6aa5 | ||
|
|
c2b591349b | ||
|
|
56c1865264 | ||
|
|
2f991e680b | ||
|
|
2af0660383 | ||
|
|
50525ad881 | ||
|
|
f7a13a0436 | ||
|
|
5b65b8f049 | ||
|
|
0e6a2bdc6c | ||
|
|
be0964d9e1 | ||
|
|
fb49fb596a | ||
|
|
10fd69404a | ||
|
|
6dd631b03a | ||
|
|
08d75fd75c | ||
|
|
e6467b41fc | ||
|
|
4a9bf575bd | ||
|
|
9e300e0ff0 | ||
|
|
3847a67a19 | ||
|
|
7695ffd62d | ||
|
|
091cd02c06 | ||
|
|
9876d7045f | ||
|
|
9759f61e0e | ||
|
|
d43e731fe1 | ||
|
|
11d2e298e8 | ||
|
|
0647000a77 | ||
|
|
2228415323 | ||
|
|
58ee685e22 | ||
|
|
545cf75e99 | ||
|
|
7956156d70 | ||
|
|
2f58e466da | ||
|
|
89ee5ae247 | ||
|
|
1bd3f99d38 | ||
|
|
e32a12ce41 | ||
|
|
49218e8e9d | ||
|
|
23eedcb619 | ||
|
|
ba4107e440 | ||
|
|
ae7cb2ccc8 | ||
|
|
3735509fea | ||
|
|
1b1ebf015e | ||
|
|
c138afadb9 | ||
|
|
18ac2142a8 | ||
|
|
875b9787d0 | ||
|
|
4d86c8c727 | ||
|
|
23524cb943 |
42
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,42 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-postgres
|
||||
{
|
||||
"name": "spliit",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {
|
||||
// "ghcr.io/frntn/devcontainers-features/prism:1": {}
|
||||
// },
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "cp container.env.example .env && npm install",
|
||||
"postAttachCommand": {
|
||||
"npm": "npm run dev"
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// This can be used to network with other containers or with the host.
|
||||
"forwardPorts": [3000, 5432],
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "App"
|
||||
},
|
||||
"5432": {
|
||||
"label": "PostgreSQL"
|
||||
}
|
||||
},
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"codespaces": {
|
||||
"openFiles": [
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
33
.devcontainer/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/typescript-node:latest
|
||||
|
||||
volumes:
|
||||
- ../..:/workspaces:cached
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
|
||||
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
|
||||
network_mode: service:db
|
||||
|
||||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
db:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_PASSWORD: 1234
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_DB: postgres
|
||||
|
||||
# Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
@@ -1,3 +1,2 @@
|
||||
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
2
.github/FUNDING.yml
vendored
@@ -1,6 +1,6 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
github: ['scastiel']
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
|
||||
3
.gitignore
vendored
@@ -28,6 +28,7 @@ yarn-error.log*
|
||||
# local env files
|
||||
.env*.local
|
||||
*.env
|
||||
!scripts/build.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -38,3 +39,5 @@ next-env.d.ts
|
||||
|
||||
# db
|
||||
postgres-data
|
||||
|
||||
/dist
|
||||
|
||||
17
Dockerfile
@@ -1,17 +0,0 @@
|
||||
FROM node:slim
|
||||
|
||||
EXPOSE 3000/tcp
|
||||
WORKDIR /usr/app
|
||||
COPY ./ ./
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
RUN apt update && \
|
||||
apt install openssl -y && \
|
||||
apt clean && \
|
||||
apt autoclean && \
|
||||
apt autoremove && \
|
||||
npm install --ignore-scripts && \
|
||||
npm install -g prisma
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "-c", "scripts/image-startup.sh"]
|
||||
81
README.md
@@ -1,6 +1,8 @@
|
||||
[<img alt="Spliit" height="60" src="https://github.com/scastiel/spliit2/blob/main/public/logo-with-text.png?raw=true" />](https://spliit.app)
|
||||
[<img alt="Spliit" height="60" src="https://github.com/spliit-app/spliit/blob/main/public/logo-with-text.png?raw=true" />](https://spliit.app)
|
||||
|
||||
Spliit is a free and open source alternative to Splitwise. I created it back in 2022 as a side project to learn the Go language, but rewrote it with Next.js since.
|
||||
Spliit is a free and open source alternative to Splitwise. You can either use the official instance at [Spliit.app](https://spliit.app), or deploy your own instance:
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fspliit-app%2Fspliit&project-name=my-spliit-instance&repository-name=my-spliit-instance&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D&)
|
||||
|
||||
## Features
|
||||
|
||||
@@ -10,15 +12,18 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
|
||||
- [x] Create reimbursement expenses
|
||||
- [x] Progressive Web App
|
||||
- [x] Select all/no participant for expenses
|
||||
- [x] Split expenses unevenly [(#6)](https://github.com/scastiel/spliit2/issues/6)
|
||||
- [x] Mark a group as favorite [(#29)](https://github.com/scastiel/spliit2/issues/29)
|
||||
- [x] Tell the application who you are when opening a group [(#7)](https://github.com/scastiel/spliit2/issues/7)
|
||||
- [x] Assign a category to expenses [(#35)](https://github.com/scastiel/spliit2/issues/35)
|
||||
- [x] Split expenses unevenly [(#6)](https://github.com/spliit-app/spliit/issues/6)
|
||||
- [x] Mark a group as favorite [(#29)](https://github.com/spliit-app/spliit/issues/29)
|
||||
- [x] Tell the application who you are when opening a group [(#7)](https://github.com/spliit-app/spliit/issues/7)
|
||||
- [x] Assign a category to expenses [(#35)](https://github.com/spliit-app/spliit/issues/35)
|
||||
- [x] Search for expenses in a group [(#51)](https://github.com/spliit-app/spliit/issues/51)
|
||||
- [x] Upload and attach images to expenses [(#63)](https://github.com/spliit-app/spliit/issues/63)
|
||||
- [x] Create expense by scanning a receipt [(#23)](https://github.com/spliit-app/spliit/issues/23)
|
||||
|
||||
### Possible incoming features
|
||||
|
||||
- [ ] Ability to create recurring expenses [(#5)](https://github.com/scastiel/spliit2/issues/5)
|
||||
- [ ] Import expenses from Splitwise [(#22)](https://github.com/scastiel/spliit2/issues/22)
|
||||
- [ ] Ability to create recurring expenses [(#5)](https://github.com/spliit-app/spliit/issues/5)
|
||||
- [ ] Import expenses from Splitwise [(#22)](https://github.com/spliit-app/spliit/issues/22)
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -32,15 +37,18 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
|
||||
|
||||
The project is open to contributions. Feel free to open an issue or even a pull-request!
|
||||
|
||||
If you want to contribute financially and help us keep the application free and without ads, you can also [make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba) ❤️.
|
||||
If you want to contribute financially and help us keep the application free and without ads, you can also:
|
||||
|
||||
- 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
|
||||
- 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
|
||||
|
||||
## Run locally
|
||||
|
||||
1. Clone the repository (or fork it if you intend to contribute)
|
||||
2. `npm install`
|
||||
3. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you don’t have a server already.
|
||||
4. Copy the file `.env.example` as `.env`
|
||||
5. `npm run dev`
|
||||
2. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you don’t have a server already.
|
||||
3. Copy the file `.env.example` as `.env`
|
||||
4. Run `npm install` to install dependencies. This will also apply database migrations and update Prisma Client.
|
||||
5. Run `npm run dev` to start the development server
|
||||
|
||||
## Run in a container
|
||||
|
||||
@@ -49,6 +57,53 @@ If you want to contribute financially and help us keep the application free and
|
||||
3. Run `npm run start-container` to start the postgres and the spliit2 containers
|
||||
4. You can access the app by browsing to http://localhost:3000
|
||||
|
||||
## Opt-in features
|
||||
|
||||
### Expense documents
|
||||
|
||||
Spliit offers users to upload images (to an AWS S3 bucket) and attach them to expenses. To enable this feature:
|
||||
|
||||
- Follow the instructions in the _S3 bucket_ and _IAM user_ sections of [next-s3-upload](https://next-s3-upload.codingvalue.com/setup#s3-bucket) to create and set up an S3 bucket where images will be stored.
|
||||
- Update your environments variables with appropriate values:
|
||||
|
||||
```.env
|
||||
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=true
|
||||
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_BUCKET=name-of-s3-bucket
|
||||
S3_UPLOAD_REGION=us-east-1
|
||||
```
|
||||
|
||||
You can also use other S3 providers by providing a custom endpoint:
|
||||
|
||||
```.env
|
||||
S3_UPLOAD_ENDPOINT=http://localhost:9000
|
||||
```
|
||||
|
||||
### Create expense from receipt
|
||||
|
||||
You can offer users to create expense by uploading a receipt. This feature relies on [OpenAI GPT-4 with Vision](https://platform.openai.com/docs/guides/vision) and a public S3 storage endpoint.
|
||||
|
||||
To enable the feature:
|
||||
|
||||
- You must enable expense documents feature as well (see section above). That might change in the future, but for now we need to store images to make receipt scanning work.
|
||||
- Subscribe to OpenAI API and get access to GPT 4 with Vision (you might need to buy credits in advance).
|
||||
- Update your environment variables with appropriate values:
|
||||
|
||||
```.env
|
||||
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=true
|
||||
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
### Deduce category from title
|
||||
|
||||
You can offer users to automatically deduce the expense category from the title. Since this feature relies on a OpenAI subscription, follow the signup instructions above and configure the following environment variables:
|
||||
|
||||
```.env
|
||||
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=true
|
||||
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT, see [LICENSE](./LICENSE).
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
24
compose.yaml
@@ -1,24 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
image: spliit2:latest
|
||||
ports:
|
||||
- 3000:3000
|
||||
env_file:
|
||||
- container.env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:latest
|
||||
ports:
|
||||
- 5432:5432
|
||||
env_file:
|
||||
- container.env
|
||||
volumes:
|
||||
- /var/lib/postgresql/data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -1,7 +0,0 @@
|
||||
# db
|
||||
POSTGRES_PASSWORD=1234
|
||||
|
||||
# app
|
||||
POSTGRES_PRISMA_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:${POSTGRES_PASSWORD}@db
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
11
eslint.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
import pluginJs from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default [
|
||||
{ files: ['**/*.{js,mjs,cjs,ts}'] },
|
||||
{ languageOptions: { globals: globals.browser } },
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
]
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [{ hostname: 'avatars.githubusercontent.com' }],
|
||||
},
|
||||
}
|
||||
|
||||
const { withPlausibleProxy } = require('next-plausible')
|
||||
module.exports = withPlausibleProxy()(nextConfig)
|
||||
5729
package-lock.json
generated
71
package.json
@@ -1,78 +1,53 @@
|
||||
{
|
||||
"name": "spliit2",
|
||||
"name": "spliit-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint",
|
||||
"check-types": "tsc --noEmit",
|
||||
"check-formatting": "prettier -c src",
|
||||
"postinstall": "prisma migrate deploy && prisma generate",
|
||||
"build-image": "./scripts/build-image.sh",
|
||||
"start-container": "docker compose --env-file container.env up"
|
||||
"prettier": "prettier -w src",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@prisma/client": "^5.6.0",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.0.7",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@react-email/heading": "^0.0.11",
|
||||
"@react-email/html": "^0.0.7",
|
||||
"@react-email/preview": "^0.0.8",
|
||||
"@react-email/text": "^0.0.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@trpc/client": "^11.0.0-rc.586",
|
||||
"@trpc/react-query": "^11.0.0-rc.586",
|
||||
"@trpc/server": "^11.0.0-rc.586",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"content-disposition": "^0.5.4",
|
||||
"dayjs": "^1.11.10",
|
||||
"lucide-react": "^0.290.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"next": "^14.0.4",
|
||||
"next-plausible": "^3.12.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"next13-progressbar": "^1.1.1",
|
||||
"negotiator": "^0.6.3",
|
||||
"openai": "^4.25.0",
|
||||
"pg": "^8.11.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"resend": "^2.1.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"prisma": "^5.7.0",
|
||||
"sharp": "^0.33.2",
|
||||
"superjson": "^2.2.1",
|
||||
"ts-pattern": "^5.0.6",
|
||||
"uuid": "^9.0.1",
|
||||
"vaul": "^0.8.0",
|
||||
"zod": "^3.22.4"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@total-typescript/ts-reset": "^0.5.1",
|
||||
"@types/content-disposition": "^0.5.8",
|
||||
"@types/negotiator": "^0.6.3",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/uuid": "^9.0.6",
|
||||
"autoprefixer": "^10",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^14.0.4",
|
||||
"postcss": "^8",
|
||||
"eslint": "^8.57.1",
|
||||
"globals": "^15.11.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"prisma": "^5.7.0",
|
||||
"tailwindcss": "^3",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "documentUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The `documentUrls` column on the `Expense` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" DROP COLUMN "documentUrls",
|
||||
ADD COLUMN "documentUrls" JSONB[] DEFAULT ARRAY[]::JSONB[];
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `documentUrls` on the `Expense` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" DROP COLUMN "documentUrls";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ExpenseDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"expenseId" TEXT,
|
||||
|
||||
CONSTRAINT "ExpenseDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ExpenseDocument" ADD CONSTRAINT "ExpenseDocument_expenseId_fkey" FOREIGN KEY ("expenseId") REFERENCES "Expense"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
10
prisma/migrations/20240128202400_add_doc_info/migration.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `height` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `width` to the `ExpenseDocument` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "ExpenseDocument" ADD COLUMN "height" INTEGER NOT NULL,
|
||||
ADD COLUMN "width" INTEGER NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActivityType" AS ENUM ('UPDATE_GROUP', 'CREATE_EXPENSE', 'UPDATE_EXPENSE', 'DELETE_EXPENSE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Activity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"activityType" "ActivityType" NOT NULL,
|
||||
"participantId" TEXT,
|
||||
"expenseId" TEXT,
|
||||
"data" TEXT,
|
||||
|
||||
CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Activity" ADD CONSTRAINT "Activity_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Group" ADD COLUMN "information" TEXT;
|
||||
@@ -14,9 +14,11 @@ datasource db {
|
||||
model Group {
|
||||
id String @id
|
||||
name String
|
||||
information String? @db.Text
|
||||
currency String @default("$")
|
||||
participants Participant[]
|
||||
expenses Expense[]
|
||||
activities Activity[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -37,20 +39,31 @@ model Category {
|
||||
}
|
||||
|
||||
model Expense {
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
|
||||
title String
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int @default(0)
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
categoryId Int @default(0)
|
||||
amount Int
|
||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
|
||||
paidById String
|
||||
paidFor ExpensePaidFor[]
|
||||
groupId String
|
||||
isReimbursement Boolean @default(false)
|
||||
splitMode SplitMode @default(EVENLY)
|
||||
createdAt DateTime @default(now())
|
||||
isReimbursement Boolean @default(false)
|
||||
splitMode SplitMode @default(EVENLY)
|
||||
createdAt DateTime @default(now())
|
||||
documents ExpenseDocument[]
|
||||
notes String?
|
||||
}
|
||||
|
||||
model ExpenseDocument {
|
||||
id String @id
|
||||
url String
|
||||
width Int
|
||||
height Int
|
||||
Expense Expense? @relation(fields: [expenseId], references: [id])
|
||||
expenseId String?
|
||||
}
|
||||
|
||||
enum SplitMode {
|
||||
@@ -69,3 +82,21 @@ model ExpensePaidFor {
|
||||
|
||||
@@id([expenseId, participantId])
|
||||
}
|
||||
|
||||
model Activity {
|
||||
id String @id
|
||||
group Group @relation(fields: [groupId], references: [id])
|
||||
groupId String
|
||||
time DateTime @default(now())
|
||||
activityType ActivityType
|
||||
participantId String?
|
||||
expenseId String?
|
||||
data String?
|
||||
}
|
||||
|
||||
enum ActivityType {
|
||||
UPDATE_GROUP
|
||||
CREATE_EXPENSE
|
||||
UPDATE_EXPENSE
|
||||
DELETE_EXPENSE
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 52 KiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" ?><svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#ccc;}.cls-2{fill:#ed5167;}.cls-3{fill:#72d3b8;}.cls-4{fill:#55bc9c;}.cls-5{fill:#e8e8e8;}.cls-6{fill:#333538;}</style></defs><title/><g data-name="1 funnel" id="_1_funnel"><path class="cls-1" d="M261.25,134.9h0a76.62,76.62,0,0,1,76.62,76.62V423.22a0,0,0,0,1,0,0H261.25a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(285.06 -130.08) rotate(45)"/><path class="cls-2" d="M161.87,470.93l75.07-75.08a17.06,17.06,0,0,0,0-24.14l-40.19-40.19a17.06,17.06,0,0,0-24.14,0l-10.8,10.8a91,91,0,0,0-129.58.92c-34.17,35.14-34.17,91.69,0,126.83a91,91,0,0,0,129.58.92ZM57,447.11a57.21,57.21,0,1,1,80.9,0A57.22,57.22,0,0,1,57,447.11Z"/><rect class="cls-3" height="253.49" rx="47.61" transform="translate(511.66 282.22) rotate(180)" width="344.78" x="83.44" y="14.36"/><path class="cls-4" d="M126,184.86V97.36a40.46,40.46,0,0,0,40.46-40.45H345.23a40.44,40.44,0,0,0,40.44,40.45v87.5a40.44,40.44,0,0,0-40.44,40.44H166.44A40.46,40.46,0,0,0,126,184.86Z"/><circle class="cls-3" cx="255.83" cy="141.11" r="60.11"/><circle class="cls-3" cx="349.72" cy="141.11" r="21.88"/><circle class="cls-3" cx="161.94" cy="141.11" r="21.88"/><path class="cls-5" d="M174.13,134.9h76.62a0,0,0,0,1,0,0V346.61a76.62,76.62,0,0,1-76.62,76.62h0a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(559.99 326.17) rotate(135)"/><path class="cls-2" d="M350.19,471a91,91,0,0,0,129.58-.92c34.17-35.14,34.17-91.69,0-126.83a91,91,0,0,0-129.58-.92l-10.8-10.8a17.06,17.06,0,0,0-24.14,0l-40.19,40.19a17.06,17.06,0,0,0,0,24.14l75.07,75.08Zm23.89-23.88a57.21,57.21,0,1,1,80.9,0A57.2,57.2,0,0,1,374.08,447.11Z"/><circle class="cls-6" cx="256" cy="322.62" r="20.01"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -3,6 +3,10 @@
|
||||
SPLIIT_APP_NAME=$(node -p -e "require('./package.json').name")
|
||||
SPLIIT_VERSION=$(node -p -e "require('./package.json').version")
|
||||
|
||||
docker buildx build --no-cache -t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} -t ${SPLIIT_APP_NAME}:latest .
|
||||
# we need to set dummy data for POSTGRES env vars in order for build not to fail
|
||||
docker buildx build \
|
||||
-t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \
|
||||
-t ${SPLIIT_APP_NAME}:latest \
|
||||
.
|
||||
|
||||
docker image prune -f
|
||||
|
||||
22
scripts/build.env
Normal file
@@ -0,0 +1,22 @@
|
||||
# build file that contains all possible env vars with mocked values
|
||||
# as most of them are used at build time in order to have the production build to work properly
|
||||
|
||||
# db
|
||||
POSTGRES_PASSWORD=1234
|
||||
|
||||
# app
|
||||
POSTGRES_PRISMA_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db
|
||||
POSTGRES_URL_NON_POOLING=postgresql://postgres:${POSTGRES_PASSWORD}@db
|
||||
|
||||
# app-minio
|
||||
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=false
|
||||
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
S3_UPLOAD_BUCKET=spliit
|
||||
S3_UPLOAD_REGION=eu-north-1
|
||||
S3_UPLOAD_ENDPOINT=s3://minio.example.com
|
||||
|
||||
# app-openai
|
||||
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=false
|
||||
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=false
|
||||
6
scripts/container-entrypoint.sh
Executable file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
npx prisma migrate deploy
|
||||
npm run start
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
prisma migrate deploy
|
||||
prisma generate
|
||||
npm run dev
|
||||
@@ -1,4 +1,4 @@
|
||||
result=$(docker ps | grep postgres)
|
||||
result=$(docker ps | grep spliit-db)
|
||||
if [ $? -eq 0 ];
|
||||
then
|
||||
echo "postgres is already running, doing nothing"
|
||||
@@ -6,6 +6,6 @@ else
|
||||
echo "postgres is not running, starting it"
|
||||
docker rm postgres --force
|
||||
mkdir -p postgres-data
|
||||
docker run --name postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v ./postgres-data:/var/lib/postgresql/data postgres
|
||||
docker run --name spliit-db -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql/data" postgres
|
||||
sleep 5 # Wait for postgres to start
|
||||
fi
|
||||
|
||||
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,67 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 163 94% 24%;
|
||||
--primary-foreground: 0 100% 100%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 142.1 76.2% 36.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 0 0% 95%;
|
||||
--card: 24 9.8% 10%;
|
||||
--card-foreground: 0 0% 95%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 95%;
|
||||
--primary: 161 90% 45%;
|
||||
--primary-foreground: 144.9 80.4% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 85.7% 97.3%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 142.4 71.8% 29.2%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.landing-header {
|
||||
@apply bg-gradient-to-br from-emerald-800 to-emerald-600 dark:from-emerald-300 dark:to-emerald-600 bg-clip-text;
|
||||
}
|
||||
|
||||
.landing-header strong {
|
||||
@apply font-bold text-transparent;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Balances } from '@/lib/balances'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Participant } from '@prisma/client'
|
||||
|
||||
type Props = {
|
||||
balances: Balances
|
||||
participants: Participant[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function BalancesList({ balances, participants, currency }: Props) {
|
||||
const maxBalance = Math.max(
|
||||
...Object.values(balances).map((b) => Math.abs(b.total)),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{participants.map((participant) => {
|
||||
const balance = balances[participant.id]?.total ?? 0
|
||||
const isLeft = balance >= 0
|
||||
return (
|
||||
<div
|
||||
key={participant.id}
|
||||
className={cn('flex', isLeft || 'flex-row-reverse')}
|
||||
>
|
||||
<div className={cn('w-1/2 p-2', isLeft && 'text-right')}>
|
||||
{participant.name}
|
||||
</div>
|
||||
<div className={cn('w-1/2 relative', isLeft || 'text-right')}>
|
||||
<div className="absolute inset-0 p-2 z-20">
|
||||
{currency} {(balance / 100).toFixed(2)}
|
||||
</div>
|
||||
{balance !== 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1 h-7 z-10',
|
||||
isLeft
|
||||
? 'bg-green-200 dark:bg-green-800 left-0 rounded-r-lg border border-green-300 dark:border-green-700'
|
||||
: 'bg-red-200 dark:bg-red-800 right-0 rounded-l-lg border border-red-300 dark:border-red-700',
|
||||
)}
|
||||
style={{
|
||||
width: (Math.abs(balance) / maxBalance) * 100 + '%',
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { BalancesList } from '@/app/groups/[groupId]/balances-list'
|
||||
import { ReimbursementList } from '@/app/groups/[groupId]/reimbursement-list'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { getBalances, getSuggestedReimbursements } from '@/lib/balances'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Balances',
|
||||
}
|
||||
|
||||
export default async function GroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
const expenses = await getGroupExpenses(groupId)
|
||||
const balances = getBalances(expenses)
|
||||
const reimbursements = getSuggestedReimbursements(balances)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Balances</CardTitle>
|
||||
<CardDescription>
|
||||
This is the amount that each participant paid or was paid for.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BalancesList
|
||||
balances={balances}
|
||||
participants={group.participants}
|
||||
currency={group.currency}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Suggested reimbursements</CardTitle>
|
||||
<CardDescription>
|
||||
Here are suggestions for optimized reimbursements between
|
||||
participants.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ReimbursementList
|
||||
reimbursements={reimbursements}
|
||||
participants={group.participants}
|
||||
currency={group.currency}
|
||||
groupId={groupId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { GroupForm } from '@/components/group-form'
|
||||
import { getGroup, getGroupExpensesParticipants, updateGroup } from '@/lib/api'
|
||||
import { groupFormSchema } from '@/lib/schemas'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Settings',
|
||||
}
|
||||
|
||||
export default async function EditGroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
async function updateGroupAction(values: unknown) {
|
||||
'use server'
|
||||
const groupFormValues = groupFormSchema.parse(values)
|
||||
const group = await updateGroup(groupId, groupFormValues)
|
||||
redirect(`/groups/${group.id}`)
|
||||
}
|
||||
|
||||
const protectedParticipantIds = await getGroupExpensesParticipants(groupId)
|
||||
return (
|
||||
<GroupForm
|
||||
group={group}
|
||||
onSubmit={updateGroupAction}
|
||||
protectedParticipantIds={protectedParticipantIds}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ExpenseForm } from '@/components/expense-form'
|
||||
import {
|
||||
deleteExpense,
|
||||
getCategories,
|
||||
getExpense,
|
||||
getGroup,
|
||||
updateExpense,
|
||||
} from '@/lib/api'
|
||||
import { expenseFormSchema } from '@/lib/schemas'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Edit expense',
|
||||
}
|
||||
|
||||
export default async function EditExpensePage({
|
||||
params: { groupId, expenseId },
|
||||
}: {
|
||||
params: { groupId: string; expenseId: string }
|
||||
}) {
|
||||
const categories = await getCategories()
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
const expense = await getExpense(groupId, expenseId)
|
||||
if (!expense) notFound()
|
||||
|
||||
async function updateExpenseAction(values: unknown) {
|
||||
'use server'
|
||||
const expenseFormValues = expenseFormSchema.parse(values)
|
||||
await updateExpense(groupId, expenseId, expenseFormValues)
|
||||
redirect(`/groups/${groupId}`)
|
||||
}
|
||||
|
||||
async function deleteExpenseAction() {
|
||||
'use server'
|
||||
await deleteExpense(expenseId)
|
||||
redirect(`/groups/${groupId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpenseForm
|
||||
group={group}
|
||||
expense={expense}
|
||||
categories={categories}
|
||||
onSubmit={updateExpenseAction}
|
||||
onDelete={deleteExpenseAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/drawer'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { getGroup } from '@/lib/api'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ComponentProps, useEffect, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
}
|
||||
|
||||
export function ActiveUserModal({ group }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
useEffect(() => {
|
||||
const tempUser = localStorage.getItem(`newGroup-activeUser`)
|
||||
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
||||
if (!tempUser && !activeUser) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [group])
|
||||
|
||||
function updateOpen(open: boolean) {
|
||||
if (!open && !localStorage.getItem(`${group.id}-activeUser`)) {
|
||||
localStorage.setItem(`${group.id}-activeUser`, 'None')
|
||||
}
|
||||
setOpen(open)
|
||||
}
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={updateOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Who are you?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tell us which participant you are to let us customize how the
|
||||
information is displayed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ActiveUserForm group={group} close={() => setOpen(false)} />
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
This setting can be changed later in the group settings.
|
||||
</p>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={updateOpen}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>Who are you?</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Tell us which participant you are to let us customize how the
|
||||
information is displayed.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<ActiveUserForm
|
||||
className="px-4"
|
||||
group={group}
|
||||
close={() => setOpen(false)}
|
||||
/>
|
||||
<DrawerFooter className="pt-2">
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
This setting can be changed later in the group settings.
|
||||
</p>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function ActiveUserForm({
|
||||
group,
|
||||
close,
|
||||
className,
|
||||
}: ComponentProps<'form'> & { group: Props['group']; close: () => void }) {
|
||||
const [selected, setSelected] = useState('None')
|
||||
|
||||
return (
|
||||
<form
|
||||
className={cn('grid items-start gap-4', className)}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
localStorage.setItem(`${group.id}-activeUser`, selected)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<RadioGroup defaultValue="none" onValueChange={setSelected}>
|
||||
<div className="flex flex-col gap-4 my-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="none" id="none" />
|
||||
<Label htmlFor="none" className="italic font-normal flex-1">
|
||||
I don’t want to select anyone
|
||||
</Label>
|
||||
</div>
|
||||
{group.participants.map((participant) => (
|
||||
<div key={participant.id} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={participant.id} id={participant.id} />
|
||||
<Label htmlFor={participant.id} className="flex-1">
|
||||
{participant.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Category } from '@prisma/client'
|
||||
import {
|
||||
Armchair,
|
||||
Baby,
|
||||
Banknote,
|
||||
Bike,
|
||||
Bus,
|
||||
Car,
|
||||
CarTaxiFront,
|
||||
Cat,
|
||||
Clapperboard,
|
||||
CupSoda,
|
||||
Dices,
|
||||
Dumbbell,
|
||||
Eraser,
|
||||
FerrisWheel,
|
||||
Fuel,
|
||||
Gift,
|
||||
Home,
|
||||
Hotel,
|
||||
Lamp,
|
||||
Landmark,
|
||||
LibraryBig,
|
||||
LucideIcon,
|
||||
LucideProps,
|
||||
Martini,
|
||||
Music,
|
||||
ParkingMeter,
|
||||
Phone,
|
||||
PiggyBank,
|
||||
Plane,
|
||||
Plug,
|
||||
PlugZap,
|
||||
Shirt,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
ThermometerSun,
|
||||
Train,
|
||||
Trash,
|
||||
Utensils,
|
||||
Wine,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
|
||||
export function CategoryIcon({
|
||||
category,
|
||||
...props
|
||||
}: { category: Category | null } & LucideProps) {
|
||||
const Icon = getCategoryIcon(`${category?.grouping}/${category?.name}`)
|
||||
return <Icon {...props} />
|
||||
}
|
||||
|
||||
function getCategoryIcon(category: string): LucideIcon {
|
||||
switch (category) {
|
||||
case 'Uncategorized/General':
|
||||
return Banknote
|
||||
case 'Uncategorized/Payment':
|
||||
return Banknote
|
||||
case 'Entertainment/Entertainment':
|
||||
return FerrisWheel
|
||||
case 'Entertainment/Games':
|
||||
return Dices
|
||||
case 'Entertainment/Movies':
|
||||
return Clapperboard
|
||||
case 'Entertainment/Music':
|
||||
return Music
|
||||
case 'Entertainment/Sports':
|
||||
return Dumbbell
|
||||
case 'Food and Drink/Food and Drink':
|
||||
return Utensils
|
||||
case 'Food and Drink/Dining Out':
|
||||
return Martini
|
||||
case 'Food and Drink/Groceries':
|
||||
return ShoppingCart
|
||||
case 'Food and Drink/Liquor':
|
||||
return Wine
|
||||
case 'Home/Home':
|
||||
return Home
|
||||
case 'Home/Electronics':
|
||||
return Plug
|
||||
case 'Home/Furniture':
|
||||
return Armchair
|
||||
case 'Home/Household Supplies':
|
||||
return Lamp
|
||||
case 'Home/Maintenance':
|
||||
return Wrench
|
||||
case 'Home/Mortgage':
|
||||
return Landmark
|
||||
case 'Home/Pets':
|
||||
return Cat
|
||||
case 'Home/Rent':
|
||||
return PiggyBank
|
||||
case 'Home/Services':
|
||||
return Wrench
|
||||
case 'Life/Childcare':
|
||||
return Baby
|
||||
case 'Life/Clothing':
|
||||
return Shirt
|
||||
case 'Life/Education':
|
||||
return LibraryBig
|
||||
case 'Life/Gifts':
|
||||
return Gift
|
||||
case 'Life/Insurance':
|
||||
return Landmark
|
||||
case 'Life/Medical Expenses':
|
||||
return Stethoscope
|
||||
case 'Life/Taxes':
|
||||
return Banknote
|
||||
case 'Transportation/Transportation':
|
||||
return Bus
|
||||
case 'Transportation/Bicycle':
|
||||
return Bike
|
||||
case 'Transportation/Bus/Train':
|
||||
return Train
|
||||
case 'Transportation/Car':
|
||||
return Car
|
||||
case 'Transportation/Gas/Fuel':
|
||||
return Fuel
|
||||
case 'Transportation/Hotel':
|
||||
return Hotel
|
||||
case 'Transportation/Parking':
|
||||
return ParkingMeter
|
||||
case 'Transportation/Plane':
|
||||
return Plane
|
||||
case 'Transportation/Taxi':
|
||||
return CarTaxiFront
|
||||
case 'Utilities/Utilities':
|
||||
return Banknote
|
||||
case 'Utilities/Cleaning':
|
||||
return Eraser
|
||||
case 'Utilities/Electricity':
|
||||
return PlugZap
|
||||
case 'Utilities/Heat/Gas':
|
||||
return ThermometerSun
|
||||
case 'Utilities/Trash':
|
||||
return Trash
|
||||
case 'Utilities/TV/Phone/Internet':
|
||||
return Phone
|
||||
case 'Utilities/Water':
|
||||
return CupSoda
|
||||
default:
|
||||
return Banknote
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ExpenseForm } from '@/components/expense-form'
|
||||
import { createExpense, getCategories, getGroup } from '@/lib/api'
|
||||
import { expenseFormSchema } from '@/lib/schemas'
|
||||
import { Metadata } from 'next'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create expense',
|
||||
}
|
||||
|
||||
export default async function ExpensePage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const categories = await getCategories()
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
async function createExpenseAction(values: unknown) {
|
||||
'use server'
|
||||
const expenseFormValues = expenseFormSchema.parse(values)
|
||||
await createExpense(expenseFormValues, groupId)
|
||||
redirect(`/groups/${groupId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpenseForm
|
||||
group={group}
|
||||
categories={categories}
|
||||
onSubmit={createExpenseAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
'use client'
|
||||
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getGroupExpenses } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Expense, Participant } from '@prisma/client'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Fragment, useEffect } from 'react'
|
||||
|
||||
type Props = {
|
||||
expenses: Awaited<ReturnType<typeof getGroupExpenses>>
|
||||
participants: Participant[]
|
||||
currency: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
const EXPENSE_GROUPS = {
|
||||
THIS_WEEK: 'This week',
|
||||
EARLIER_THIS_MONTH: 'Earlier this month',
|
||||
LAST_MONTH: 'Last month',
|
||||
EARLIER_THIS_YEAR: 'Earlier this year',
|
||||
LAST_YEAR: 'Last year',
|
||||
OLDER: 'Older',
|
||||
}
|
||||
|
||||
function getExpenseGroup(date: Dayjs, today: Dayjs) {
|
||||
if (today.isSame(date, 'week')) {
|
||||
return EXPENSE_GROUPS.THIS_WEEK
|
||||
} else if (today.isSame(date, 'month')) {
|
||||
return EXPENSE_GROUPS.EARLIER_THIS_MONTH
|
||||
} else if (today.subtract(1, 'month').isSame(date, 'month')) {
|
||||
return EXPENSE_GROUPS.LAST_MONTH
|
||||
} else if (today.isSame(date, 'year')) {
|
||||
return EXPENSE_GROUPS.EARLIER_THIS_YEAR
|
||||
} else if (today.subtract(1, 'year').isSame(date, 'year')) {
|
||||
return EXPENSE_GROUPS.LAST_YEAR
|
||||
} else {
|
||||
return EXPENSE_GROUPS.OLDER
|
||||
}
|
||||
}
|
||||
|
||||
function getGroupedExpensesByDate(
|
||||
expenses: Awaited<ReturnType<typeof getGroupExpenses>>,
|
||||
) {
|
||||
const today = dayjs()
|
||||
return expenses.reduce(
|
||||
(result: { [key: string]: Expense[] }, expense: Expense) => {
|
||||
const expenseGroup = getExpenseGroup(dayjs(expense.expenseDate), today)
|
||||
result[expenseGroup] = result[expenseGroup] ?? []
|
||||
result[expenseGroup].push(expense)
|
||||
return result
|
||||
},
|
||||
{},
|
||||
)
|
||||
}
|
||||
|
||||
export function ExpenseList({
|
||||
expenses,
|
||||
currency,
|
||||
participants,
|
||||
groupId,
|
||||
}: Props) {
|
||||
useEffect(() => {
|
||||
const activeUser = localStorage.getItem('newGroup-activeUser')
|
||||
const newUser = localStorage.getItem(`${groupId}-newUser`)
|
||||
if (activeUser || newUser) {
|
||||
localStorage.removeItem('newGroup-activeUser')
|
||||
localStorage.removeItem(`${groupId}-newUser`)
|
||||
if (activeUser === 'None') {
|
||||
localStorage.setItem(`${groupId}-activeUser`, 'None')
|
||||
} else {
|
||||
const userId = participants.find(
|
||||
(p) => p.name === (activeUser || newUser),
|
||||
)?.id
|
||||
if (userId) {
|
||||
localStorage.setItem(`${groupId}-activeUser`, userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [groupId, participants])
|
||||
|
||||
const getParticipant = (id: string) => participants.find((p) => p.id === id)
|
||||
const router = useRouter()
|
||||
|
||||
const groupedExpensesByDate = getGroupedExpensesByDate(expenses)
|
||||
|
||||
return expenses.length > 0 ? (
|
||||
Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
|
||||
const groupExpenses = groupedExpensesByDate[expenseGroup]
|
||||
if (!groupExpenses) return null
|
||||
return (
|
||||
<div key={expenseGroup}>
|
||||
<div
|
||||
className={
|
||||
'text-muted-foreground text-xs pl-4 sm:pl-6 py-1 font-semibold sticky top-16 bg-white dark:bg-[#1b1917]'
|
||||
}
|
||||
>
|
||||
{expenseGroup}
|
||||
</div>
|
||||
{groupExpenses.map((expense: any) => (
|
||||
<div
|
||||
key={expense.id}
|
||||
className={cn(
|
||||
'flex justify-between sm:mx-6 px-4 sm:rounded-lg sm:pr-2 sm:pl-4 py-4 text-sm cursor-pointer hover:bg-accent gap-1 items-stretch',
|
||||
expense.isReimbursement && 'italic',
|
||||
)}
|
||||
onClick={() => {
|
||||
router.push(`/groups/${groupId}/expenses/${expense.id}/edit`)
|
||||
}}
|
||||
>
|
||||
<CategoryIcon
|
||||
category={expense.category}
|
||||
className="w-4 h-4 mr-2 mt-0.5 text-muted-foreground"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={cn('mb-1', expense.isReimbursement && 'italic')}
|
||||
>
|
||||
{expense.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paid by{' '}
|
||||
<strong>{getParticipant(expense.paidById)?.name}</strong> for{' '}
|
||||
{expense.paidFor.map((paidFor: any, index: number) => (
|
||||
<Fragment key={index}>
|
||||
{index !== 0 && <>, </>}
|
||||
<strong>
|
||||
{
|
||||
participants.find(
|
||||
(p) => p.id === paidFor.participantId,
|
||||
)?.name
|
||||
}
|
||||
</strong>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between items-end">
|
||||
<div
|
||||
className={cn(
|
||||
'tabular-nums whitespace-nowrap',
|
||||
expense.isReimbursement ? 'italic' : 'font-bold',
|
||||
)}
|
||||
>
|
||||
{currency} {(expense.amount / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDate(expense.expenseDate)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="link"
|
||||
className="self-center hidden sm:flex"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/groups/${groupId}/expenses/${expense.id}/edit`}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="px-6 text-sm py-6">
|
||||
Your group doesn’t contain any expense yet.{' '}
|
||||
<Button variant="link" asChild className="-m-4">
|
||||
<Link href={`/groups/${groupId}/expenses/create`}>
|
||||
Create the first one
|
||||
</Link>
|
||||
</Button>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { getPrisma } from '@/lib/prisma'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params: { groupId } }: { params: { groupId: string } },
|
||||
) {
|
||||
console.log({ groupId })
|
||||
const prisma = await getPrisma()
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id: groupId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
currency: true,
|
||||
expenses: {
|
||||
select: {
|
||||
expenseDate: true,
|
||||
title: true,
|
||||
category: { select: { grouping: true, name: true } },
|
||||
amount: true,
|
||||
paidById: true,
|
||||
paidFor: { select: { participantId: true, shares: true } },
|
||||
isReimbursement: true,
|
||||
splitMode: true,
|
||||
},
|
||||
},
|
||||
participants: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
if (!group)
|
||||
return NextResponse.json({ error: 'Invalid group ID' }, { status: 404 })
|
||||
return NextResponse.json(group, {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-disposition': `attachment; filename="${group.name}.json"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { ActiveUserModal } from '@/app/groups/[groupId]/expenses/active-user-modal'
|
||||
import { ExpenseList } from '@/app/groups/[groupId]/expenses/expense-list'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getGroup, getGroupExpenses } from '@/lib/api'
|
||||
import { Download, Plus } from 'lucide-react'
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Expenses',
|
||||
}
|
||||
|
||||
export default async function GroupExpensesPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-4 rounded-none -mx-4 border-x-0 sm:border-x sm:rounded-lg sm:mx-0">
|
||||
<div className="flex flex-1">
|
||||
<CardHeader className="flex-1 p-4 sm:p-6">
|
||||
<CardTitle>Expenses</CardTitle>
|
||||
<CardDescription>
|
||||
Here are the expenses that you created for your group.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardHeader className="p-4 sm:p-6 flex flex-row space-y-0 gap-2">
|
||||
<Button variant="secondary" size="icon" asChild>
|
||||
<Link
|
||||
href={`/groups/${groupId}/expenses/export/json`}
|
||||
target="_blank"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="icon">
|
||||
<Link href={`/groups/${groupId}/expenses/create`}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
|
||||
<Suspense
|
||||
fallback={[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-t flex justify-between items-center px-6 py-4 text-sm"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-32 rounded-full" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-4 w-16 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
>
|
||||
<Expenses groupId={groupId} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ActiveUserModal group={group} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function Expenses({ groupId }: { groupId: string }) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
const expenses = await getGroupExpenses(group.id)
|
||||
|
||||
return (
|
||||
<ExpenseList
|
||||
expenses={expenses}
|
||||
groupId={group.id}
|
||||
currency={group.currency}
|
||||
participants={group.participants}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
|
||||
type Props = {
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export function GroupTabs({ groupId }: Props) {
|
||||
const pathname = usePathname()
|
||||
const value =
|
||||
pathname.replace(/\/groups\/[^\/]+\/([^/]+).*/, '$1') || 'expenses'
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={value}
|
||||
className="[&>*]:border"
|
||||
onValueChange={(value) => {
|
||||
router.push(`/groups/${groupId}/${value}`)
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="expenses">Expenses</TabsTrigger>
|
||||
<TabsTrigger value="balances">Balances</TabsTrigger>
|
||||
<TabsTrigger value="edit">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { GroupTabs } from '@/app/groups/[groupId]/group-tabs'
|
||||
import { SaveGroupLocally } from '@/app/groups/[groupId]/save-recent-group'
|
||||
import { ShareButton } from '@/app/groups/[groupId]/share-button'
|
||||
import { getGroup } from '@/lib/api'
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
groupId: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params: { groupId },
|
||||
}: Props): Promise<Metadata> {
|
||||
const group = await getGroup(groupId)
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: group?.name ?? '',
|
||||
template: `%s · ${group?.name} · Spliit`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function GroupLayout({
|
||||
children,
|
||||
params: { groupId },
|
||||
}: PropsWithChildren<Props>) {
|
||||
const group = await getGroup(groupId)
|
||||
if (!group) notFound()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-3">
|
||||
<h1 className="font-bold text-2xl">
|
||||
<Link href={`/groups/${groupId}`}>{group.name}</Link>
|
||||
</h1>
|
||||
|
||||
<div className="flex gap-2 justify-between">
|
||||
<GroupTabs groupId={groupId} />
|
||||
<ShareButton group={group} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
<SaveGroupLocally group={{ id: group.id, name: group.name }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function GroupPage({
|
||||
params: { groupId },
|
||||
}: {
|
||||
params: { groupId: string }
|
||||
}) {
|
||||
redirect(`/groups/${groupId}/expenses`)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Reimbursement } from '@/lib/balances'
|
||||
import { Participant } from '@prisma/client'
|
||||
import Link from 'next/link'
|
||||
|
||||
type Props = {
|
||||
reimbursements: Reimbursement[]
|
||||
participants: Participant[]
|
||||
currency: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export function ReimbursementList({
|
||||
reimbursements,
|
||||
participants,
|
||||
currency,
|
||||
groupId,
|
||||
}: Props) {
|
||||
if (reimbursements.length === 0) {
|
||||
return (
|
||||
<p className="px-6 text-sm pb-6">
|
||||
It looks like your group doesn’t need any reimbursement 😁
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const getParticipant = (id: string) => participants.find((p) => p.id === id)
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{reimbursements.map((reimbursement, index) => (
|
||||
<div className="border-t px-6 py-4 flex justify-between" key={index}>
|
||||
<div className="flex flex-col gap-1 items-start sm:flex-row sm:items-baseline sm:gap-4">
|
||||
<div>
|
||||
<strong>{getParticipant(reimbursement.from)?.name}</strong> owes{' '}
|
||||
<strong>{getParticipant(reimbursement.to)?.name}</strong>
|
||||
</div>
|
||||
<Button variant="link" asChild className="-mx-4 -my-3">
|
||||
<Link
|
||||
href={`/groups/${groupId}/expenses/create?reimbursement=yes&from=${reimbursement.from}&to=${reimbursement.to}&amount=${reimbursement.amount}`}
|
||||
>
|
||||
Mark as paid
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{currency} {(reimbursement.amount / 100).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
'use client'
|
||||
import {
|
||||
RecentGroup,
|
||||
saveRecentGroup,
|
||||
} from '@/app/groups/recent-groups-helpers'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type Props = {
|
||||
group: RecentGroup
|
||||
}
|
||||
|
||||
export function SaveGroupLocally({ group }: Props) {
|
||||
useEffect(() => {
|
||||
saveRecentGroup(group)
|
||||
}, [group])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { ShareUrlButton } from '@/components/share-url-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { env } from '@/lib/env'
|
||||
import { Group } from '@prisma/client'
|
||||
import { Share } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
group: Group
|
||||
}
|
||||
|
||||
export function ShareButton({ group }: Props) {
|
||||
const url = `${env.NEXT_PUBLIC_BASE_URL}/groups/${group.id}/expenses?ref=share`
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon">
|
||||
<Share className="w-4 h-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="[&_p]:text-sm flex flex-col gap-3">
|
||||
<p>
|
||||
For other participants to see the group and add expenses, share its
|
||||
URL with them.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input className="flex-1" defaultValue={url} readOnly />
|
||||
<CopyButton text={url} />
|
||||
<ShareUrlButton
|
||||
text={`Join my group ${group.name} on Spliit`}
|
||||
url={url}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Warning!</strong> Every person with the group URL will be able
|
||||
to see and edit expenses. Share with caution!
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
'use server'
|
||||
import { getGroups } from '@/lib/api'
|
||||
|
||||
export async function getGroupsAction(groupIds: string[]) {
|
||||
'use server'
|
||||
return getGroups(groupIds)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { GroupForm } from '@/components/group-form'
|
||||
import { createGroup } from '@/lib/api'
|
||||
import { groupFormSchema } from '@/lib/schemas'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function CreateGroupPage() {
|
||||
async function createGroupAction(values: unknown) {
|
||||
'use server'
|
||||
const groupFormValues = groupFormSchema.parse(values)
|
||||
const group = await createGroup(groupFormValues)
|
||||
redirect(`/groups/${group.id}`)
|
||||
}
|
||||
|
||||
return <GroupForm onSubmit={createGroupAction} />
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import {
|
||||
FeedbackButton,
|
||||
FeedbackModal,
|
||||
} from '@/components/feedback-button/feedback-button'
|
||||
import { env } from '@/lib/env'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
export default function GroupsLayout({ children }: PropsWithChildren<{}>) {
|
||||
return (
|
||||
<>
|
||||
<main className="flex-1 max-w-screen-md w-full mx-auto px-4 py-6 flex flex-col gap-6">
|
||||
{children}
|
||||
</main>
|
||||
<FeedbackModal donationUrl={env.STRIPE_DONATION_LINK}>
|
||||
<FeedbackButton />
|
||||
</FeedbackModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>This group does not exist.</p>
|
||||
<p>
|
||||
<Button asChild variant="secondary">
|
||||
<Link href="/groups">Go to recently visited groups</Link>
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { RecentGroupList } from '@/app/groups/recent-group-list'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Recently visited groups',
|
||||
}
|
||||
|
||||
export default async function GroupsPage() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<h1 className="font-bold text-2xl">
|
||||
<Link href="/groups">My groups</Link>
|
||||
</h1>
|
||||
<Button asChild size="icon">
|
||||
<Link href="/groups/create">
|
||||
<Plus className="w-4 h-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<RecentGroupList />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
'use client'
|
||||
import { RecentGroupsState } from '@/app/groups/recent-group-list'
|
||||
import {
|
||||
RecentGroup,
|
||||
archiveGroup,
|
||||
deleteRecentGroup,
|
||||
getArchivedGroups,
|
||||
getStarredGroups,
|
||||
saveRecentGroup,
|
||||
starGroup,
|
||||
unarchiveGroup,
|
||||
unstarGroup,
|
||||
} from '@/app/groups/recent-groups-helpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { StarFilledIcon } from '@radix-ui/react-icons'
|
||||
import { Calendar, MoreHorizontal, Star, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { SetStateAction } from 'react'
|
||||
|
||||
export function RecentGroupListCard({
|
||||
group,
|
||||
state,
|
||||
setState,
|
||||
}: {
|
||||
group: RecentGroup
|
||||
state: RecentGroupsState
|
||||
setState: (state: SetStateAction<RecentGroupsState>) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const details =
|
||||
state.status === 'complete'
|
||||
? state.groupsDetails.find((d) => d.id === group.id)
|
||||
: null
|
||||
|
||||
if (state.status === 'pending') return null
|
||||
|
||||
const refreshGroupsFromStorage = () =>
|
||||
setState({
|
||||
...state,
|
||||
starredGroups: getStarredGroups(),
|
||||
archivedGroups: getArchivedGroups(),
|
||||
})
|
||||
|
||||
const isStarred = state.starredGroups.includes(group.id)
|
||||
const isArchived = state.archivedGroups.includes(group.id)
|
||||
|
||||
return (
|
||||
<li key={group.id}>
|
||||
<Button variant="outline" className="h-fit w-full py-3" asChild>
|
||||
<div
|
||||
className="text-base"
|
||||
onClick={() => router.push(`/groups/${group.id}`)}
|
||||
>
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div className="text-base flex gap-2 justify-between">
|
||||
<Link
|
||||
href={`/groups/${group.id}`}
|
||||
className="flex-1 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
<span className="flex-shrink-0">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="-my-3 -ml-3 -mr-1.5"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (isStarred) {
|
||||
unstarGroup(group.id)
|
||||
} else {
|
||||
starGroup(group.id)
|
||||
unarchiveGroup(group.id)
|
||||
}
|
||||
refreshGroupsFromStorage()
|
||||
}}
|
||||
>
|
||||
{isStarred ? (
|
||||
<StarFilledIcon className="w-4 h-4 text-orange-400" />
|
||||
) : (
|
||||
<Star className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="-my-3 -mr-3 -ml-1.5"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteRecentGroup(group)
|
||||
setState({
|
||||
...state,
|
||||
groups: state.groups.filter((g) => g.id !== group.id),
|
||||
})
|
||||
toast.toast({
|
||||
title: 'Group has been removed',
|
||||
description:
|
||||
'The group was removed from your recent groups list.',
|
||||
action: (
|
||||
<ToastAction
|
||||
altText="Undo group removal"
|
||||
onClick={() => {
|
||||
saveRecentGroup(group)
|
||||
setState({
|
||||
...state,
|
||||
groups: state.groups,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</ToastAction>
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove from recent groups
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (isArchived) {
|
||||
unarchiveGroup(group.id)
|
||||
} else {
|
||||
archiveGroup(group.id)
|
||||
unstarGroup(group.id)
|
||||
}
|
||||
refreshGroupsFromStorage()
|
||||
}}
|
||||
>
|
||||
{isArchived ? <>Unarchive group</> : <>Archive group</>}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground font-normal text-xs">
|
||||
{details ? (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Users className="w-3 h-3 inline mr-1" />
|
||||
<span>{details._count.participants}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 inline mx-1" />
|
||||
<span>
|
||||
{new Date(details.createdAt).toLocaleDateString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-4 w-6 rounded-full" />
|
||||
<Skeleton className="h-4 w-24 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
'use client'
|
||||
import { getGroupsAction } from '@/app/groups/actions'
|
||||
import {
|
||||
RecentGroups,
|
||||
getArchivedGroups,
|
||||
getRecentGroups,
|
||||
getStarredGroups,
|
||||
} from '@/app/groups/recent-groups-helpers'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getGroups } from '@/lib/api'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { SetStateAction, useEffect, useState } from 'react'
|
||||
import { RecentGroupListCard } from './recent-group-list-card'
|
||||
|
||||
export type RecentGroupsState =
|
||||
| { status: 'pending' }
|
||||
| {
|
||||
status: 'partial'
|
||||
groups: RecentGroups
|
||||
starredGroups: string[]
|
||||
archivedGroups: string[]
|
||||
}
|
||||
| {
|
||||
status: 'complete'
|
||||
groups: RecentGroups
|
||||
groupsDetails: Awaited<ReturnType<typeof getGroups>>
|
||||
starredGroups: string[]
|
||||
archivedGroups: string[]
|
||||
}
|
||||
|
||||
function sortGroups(
|
||||
state: RecentGroupsState & { status: 'complete' | 'partial' },
|
||||
) {
|
||||
const starredGroupInfo = []
|
||||
const groupInfo = []
|
||||
const archivedGroupInfo = []
|
||||
for (const group of state.groups) {
|
||||
if (state.starredGroups.includes(group.id)) {
|
||||
starredGroupInfo.push(group)
|
||||
} else if (state.archivedGroups.includes(group.id)) {
|
||||
archivedGroupInfo.push(group)
|
||||
} else {
|
||||
groupInfo.push(group)
|
||||
}
|
||||
}
|
||||
return {
|
||||
starredGroupInfo,
|
||||
groupInfo,
|
||||
archivedGroupInfo,
|
||||
}
|
||||
}
|
||||
|
||||
export function RecentGroupList() {
|
||||
const [state, setState] = useState<RecentGroupsState>({ status: 'pending' })
|
||||
|
||||
useEffect(() => {
|
||||
const groupsInStorage = getRecentGroups()
|
||||
const starredGroups = getStarredGroups()
|
||||
const archivedGroups = getArchivedGroups()
|
||||
setState({
|
||||
status: 'partial',
|
||||
groups: groupsInStorage,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
})
|
||||
getGroupsAction(groupsInStorage.map((g) => g.id)).then((groupsDetails) => {
|
||||
setState({
|
||||
status: 'complete',
|
||||
groups: groupsInStorage,
|
||||
groupsDetails,
|
||||
starredGroups,
|
||||
archivedGroups,
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (state.status === 'pending') {
|
||||
return (
|
||||
<p>
|
||||
<Loader2 className="w-4 m-4 mr-2 inline animate-spin" /> Loading recent
|
||||
groups…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (state.groups.length === 0) {
|
||||
return (
|
||||
<div className="text-sm space-y-2">
|
||||
<p>You have not visited any group recently.</p>
|
||||
<p>
|
||||
<Button variant="link" asChild className="-m-4">
|
||||
<Link href={`/groups/create`}>Create one</Link>
|
||||
</Button>{' '}
|
||||
or ask a friend to send you the link to an existing one.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { starredGroupInfo, groupInfo, archivedGroupInfo } = sortGroups(state)
|
||||
|
||||
return (
|
||||
<>
|
||||
{starredGroupInfo.length > 0 && (
|
||||
<>
|
||||
<h2 className="mb-2">Starred groups</h2>
|
||||
<GroupList
|
||||
groups={starredGroupInfo}
|
||||
state={state}
|
||||
setState={setState}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{groupInfo.length > 0 && (
|
||||
<>
|
||||
<h2 className="mt-6 mb-2">Recent groups</h2>
|
||||
<GroupList groups={groupInfo} state={state} setState={setState} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{archivedGroupInfo.length > 0 && (
|
||||
<>
|
||||
<h2 className="mt-6 mb-2 opacity-50">Archived groups</h2>
|
||||
<div className="opacity-50">
|
||||
<GroupList
|
||||
groups={archivedGroupInfo}
|
||||
state={state}
|
||||
setState={setState}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupList({
|
||||
groups,
|
||||
state,
|
||||
setState,
|
||||
}: {
|
||||
groups: RecentGroups
|
||||
state: RecentGroupsState
|
||||
setState: (state: SetStateAction<RecentGroupsState>) => void
|
||||
}) {
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{groups.map((group) => (
|
||||
<RecentGroupListCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
state={state}
|
||||
setState={setState}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const recentGroupsSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const starredGroupsSchema = z.array(z.string())
|
||||
export const archivedGroupsSchema = z.array(z.string())
|
||||
|
||||
export type RecentGroups = z.infer<typeof recentGroupsSchema>
|
||||
export type RecentGroup = RecentGroups[number]
|
||||
|
||||
const STORAGE_KEY = 'recentGroups'
|
||||
const STARRED_GROUPS_STORAGE_KEY = 'starredGroups'
|
||||
const ARCHIVED_GROUPS_STORAGE_KEY = 'archivedGroups'
|
||||
|
||||
export function getRecentGroups() {
|
||||
const groupsInStorageJson = localStorage.getItem(STORAGE_KEY)
|
||||
const groupsInStorageRaw = groupsInStorageJson
|
||||
? JSON.parse(groupsInStorageJson)
|
||||
: []
|
||||
const parseResult = recentGroupsSchema.safeParse(groupsInStorageRaw)
|
||||
return parseResult.success ? parseResult.data : []
|
||||
}
|
||||
|
||||
export function saveRecentGroup(group: RecentGroup) {
|
||||
const recentGroups = getRecentGroups()
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify([group, ...recentGroups.filter((rg) => rg.id !== group.id)]),
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteRecentGroup(group: RecentGroup) {
|
||||
const recentGroups = getRecentGroups()
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify(recentGroups.filter((rg) => rg.id !== group.id)),
|
||||
)
|
||||
}
|
||||
|
||||
export function getStarredGroups() {
|
||||
const starredGroupsJson = localStorage.getItem(STARRED_GROUPS_STORAGE_KEY)
|
||||
const starredGroupsRaw = starredGroupsJson
|
||||
? JSON.parse(starredGroupsJson)
|
||||
: []
|
||||
const parseResult = starredGroupsSchema.safeParse(starredGroupsRaw)
|
||||
return parseResult.success ? parseResult.data : []
|
||||
}
|
||||
|
||||
export function starGroup(groupId: string) {
|
||||
const starredGroups = getStarredGroups()
|
||||
localStorage.setItem(
|
||||
STARRED_GROUPS_STORAGE_KEY,
|
||||
JSON.stringify([...starredGroups, groupId]),
|
||||
)
|
||||
}
|
||||
|
||||
export function unstarGroup(groupId: string) {
|
||||
const starredGroups = getStarredGroups()
|
||||
localStorage.setItem(
|
||||
STARRED_GROUPS_STORAGE_KEY,
|
||||
JSON.stringify(starredGroups.filter((g) => g !== groupId)),
|
||||
)
|
||||
}
|
||||
|
||||
export function getArchivedGroups() {
|
||||
const archivedGroupsJson = localStorage.getItem(ARCHIVED_GROUPS_STORAGE_KEY)
|
||||
const archivedGroupsRaw = archivedGroupsJson
|
||||
? JSON.parse(archivedGroupsJson)
|
||||
: []
|
||||
const parseResult = archivedGroupsSchema.safeParse(archivedGroupsRaw)
|
||||
return parseResult.success ? parseResult.data : []
|
||||
}
|
||||
|
||||
export function archiveGroup(groupId: string) {
|
||||
const archivedGroups = getArchivedGroups()
|
||||
localStorage.setItem(
|
||||
ARCHIVED_GROUPS_STORAGE_KEY,
|
||||
JSON.stringify([...archivedGroups, groupId]),
|
||||
)
|
||||
}
|
||||
|
||||
export function unarchiveGroup(groupId: string) {
|
||||
const archivedGroups = getArchivedGroups()
|
||||
localStorage.setItem(
|
||||
ARCHIVED_GROUPS_STORAGE_KEY,
|
||||
JSON.stringify(archivedGroups.filter((g) => g !== groupId)),
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" ?><svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:#ccc;}.cls-2{fill:#ed5167;}.cls-3{fill:#72d3b8;}.cls-4{fill:#55bc9c;}.cls-5{fill:#e8e8e8;}.cls-6{fill:#333538;}</style></defs><title/><g data-name="1 funnel" id="_1_funnel"><path class="cls-1" d="M261.25,134.9h0a76.62,76.62,0,0,1,76.62,76.62V423.22a0,0,0,0,1,0,0H261.25a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(285.06 -130.08) rotate(45)"/><path class="cls-2" d="M161.87,470.93l75.07-75.08a17.06,17.06,0,0,0,0-24.14l-40.19-40.19a17.06,17.06,0,0,0-24.14,0l-10.8,10.8a91,91,0,0,0-129.58.92c-34.17,35.14-34.17,91.69,0,126.83a91,91,0,0,0,129.58.92ZM57,447.11a57.21,57.21,0,1,1,80.9,0A57.22,57.22,0,0,1,57,447.11Z"/><rect class="cls-3" height="253.49" rx="47.61" transform="translate(511.66 282.22) rotate(180)" width="344.78" x="83.44" y="14.36"/><path class="cls-4" d="M126,184.86V97.36a40.46,40.46,0,0,0,40.46-40.45H345.23a40.44,40.44,0,0,0,40.44,40.45v87.5a40.44,40.44,0,0,0-40.44,40.44H166.44A40.46,40.46,0,0,0,126,184.86Z"/><circle class="cls-3" cx="255.83" cy="141.11" r="60.11"/><circle class="cls-3" cx="349.72" cy="141.11" r="21.88"/><circle class="cls-3" cx="161.94" cy="141.11" r="21.88"/><path class="cls-5" d="M174.13,134.9h76.62a0,0,0,0,1,0,0V346.61a76.62,76.62,0,0,1-76.62,76.62h0a0,0,0,0,1,0,0V134.9a0,0,0,0,1,0,0Z" transform="translate(559.99 326.17) rotate(135)"/><path class="cls-2" d="M350.19,471a91,91,0,0,0,129.58-.92c34.17-35.14,34.17-91.69,0-126.83a91,91,0,0,0-129.58-.92l-10.8-10.8a17.06,17.06,0,0,0-24.14,0l-40.19,40.19a17.06,17.06,0,0,0,0,24.14l75.07,75.08Zm23.89-23.88a57.21,57.21,0,1,1,80.9,0A57.2,57.2,0,0,1,374.08,447.11Z"/><circle class="cls-6" cx="256" cy="322.62" r="20.01"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,164 +0,0 @@
|
||||
import { FeedbackModal } from '@/components/feedback-button/feedback-button'
|
||||
import { ProgressBar } from '@/components/progress-bar'
|
||||
import { ThemeProvider } from '@/components/theme-provider'
|
||||
import { ThemeToggle } from '@/components/theme-toggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { env } from '@/lib/env'
|
||||
import { HeartFilledIcon } from '@radix-ui/react-icons'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import PlausibleProvider from 'next-plausible'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(env.NEXT_PUBLIC_BASE_URL),
|
||||
title: {
|
||||
default: 'Spliit · Share Expenses with Friends & Family',
|
||||
template: '%s · Spliit',
|
||||
},
|
||||
description:
|
||||
'Spliit is a minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
openGraph: {
|
||||
title: 'Spliit · Share Expenses with Friends & Family',
|
||||
description:
|
||||
'Spliit is a minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
images: `/banner.png`,
|
||||
type: 'website',
|
||||
url: '/',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
creator: '@scastiel',
|
||||
site: '@scastiel',
|
||||
images: `/banner.png`,
|
||||
title: 'Spliit · Share Expenses with Friends & Family',
|
||||
description:
|
||||
'Spliit is a minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'Spliit',
|
||||
},
|
||||
applicationName: 'Spliit',
|
||||
icons: [
|
||||
{
|
||||
url: '/android-chrome-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
url: '/android-chrome-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#047857',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
{env.PLAUSIBLE_DOMAIN && (
|
||||
<PlausibleProvider domain={env.PLAUSIBLE_DOMAIN} trackOutboundLinks />
|
||||
)}
|
||||
<body className="pt-16 min-h-[100dvh] flex flex-col items-stretch bg-slate-50 bg-opacity-30 dark:bg-background">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ProgressBar />
|
||||
<header className="fixed top-0 left-0 right-0 h-16 flex justify-between bg-white dark:bg-gray-950 bg-opacity-50 dark:bg-opacity-50 p-2 border-b backdrop-blur-sm z-50">
|
||||
<Link
|
||||
className="flex items-center gap-2 hover:scale-105 transition-transform"
|
||||
href="/"
|
||||
>
|
||||
<h1>
|
||||
<Image
|
||||
src="/logo-with-text.png"
|
||||
className="m-1 h-auto"
|
||||
width={(35 * 522) / 180}
|
||||
height={35}
|
||||
alt="Spliit"
|
||||
/>
|
||||
</h1>
|
||||
</Link>
|
||||
<div role="navigation" aria-label="Menu" className="flex">
|
||||
<ul className="flex items-center text-sm">
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
asChild
|
||||
className="-my-3 text-primary"
|
||||
>
|
||||
<Link href="/groups">Groups</Link>
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<ThemeToggle />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
|
||||
<footer className="sm:p-8 md:p-16 sm:mt-16 sm:text-sm md:text-base md:mt-32 bg-slate-50 dark:bg-card border-t p-6 mt-8 flex flex-col sm:flex-row sm:justify-between gap-4 text-xs [&_a]:underline">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="sm:text-lg font-semibold text-base flex space-x-2 items-center">
|
||||
<Link className="flex items-center gap-2" href="/">
|
||||
<Image
|
||||
src="/logo-with-text.png"
|
||||
className="m-1 h-auto"
|
||||
width={(35 * 522) / 180}
|
||||
height={35}
|
||||
alt="Spliit"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col space-y a--no-underline-text-white">
|
||||
<span>Made in Montréal, Québec 🇨🇦</span>
|
||||
<span>
|
||||
Built by{' '}
|
||||
<a href="https://scastiel.dev" target="_blank" rel="noopener">
|
||||
Sebastien Castiel
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href="https://github.com/scastiel/spliit2/graphs/contributors"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
contributors
|
||||
</a>
|
||||
</span>
|
||||
<span>
|
||||
<FeedbackModal
|
||||
donationUrl={env.STRIPE_DONATION_LINK}
|
||||
defaultTab="support"
|
||||
>
|
||||
<Button variant="link" className="text-pink-600 -mx-4">
|
||||
<HeartFilledIcon className="w-4 h-4 mr-2" />
|
||||
Support us
|
||||
</Button>
|
||||
</FeedbackModal>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Spliit',
|
||||
short_name: 'Spliit',
|
||||
description:
|
||||
'A minimalist web application to share expenses with friends and family. No ads, no account, no problem.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#fff',
|
||||
theme_color: '#047857',
|
||||
icons: [
|
||||
{
|
||||
src: '/android-chrome-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/android-chrome-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: '/logo-512x512-maskable.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
220
src/app/page.tsx
@@ -1,220 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
BarChartHorizontalBig,
|
||||
CircleDollarSign,
|
||||
Divide,
|
||||
FolderTree,
|
||||
Github,
|
||||
List,
|
||||
LucideIcon,
|
||||
Share,
|
||||
ShieldX,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
// FIX for https://github.com/vercel/next.js/issues/58615
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main>
|
||||
<section className="py-16 md:py-24 lg:py-32">
|
||||
<div className="container flex max-w-screen-md flex-col items-center gap-4 text-center">
|
||||
<h1 className="!leading-none font-bold text-3xl sm:text-5xl md:text-6xl lg:text-7xl landing-header py-2">
|
||||
Share <strong>Expenses</strong> <br /> with <strong>Friends</strong>{' '}
|
||||
& <strong>Family</strong>
|
||||
</h1>
|
||||
<p className="max-w-[42rem] leading-normal text-muted-foreground sm:text-xl sm:leading-8">
|
||||
No ads. No account. <br className="sm:hidden" /> Open Source.
|
||||
Forever Free.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild size="lg">
|
||||
<Link
|
||||
className="inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 h-11 px-8 rounded-md"
|
||||
href="/groups/create"
|
||||
>
|
||||
Create a group
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-slate-50 dark:bg-card py-16 md:py-24 lg:py-32">
|
||||
<div className="p-4 flex mx-auto max-w-screen-md flex-col items-center text-center">
|
||||
<h2 className="font-bold text-3xl leading-[1.1] sm:text-3xl md:text-6xl">
|
||||
Features
|
||||
</h2>
|
||||
<p
|
||||
className="mt-2 md:mt-3 leading-normal text-muted-foreground sm:text-lg sm:leading-7"
|
||||
style={{ textWrap: 'balance' } as any}
|
||||
>
|
||||
Spliit is a minimalist application to track and share expenses with
|
||||
your friends and family.
|
||||
</p>
|
||||
<div className="mt-8 md:mt-6 w-full grid grid-cols-2 sm:grid-cols-3 gap-2 sm:gap-4 text-left">
|
||||
<Feature
|
||||
Icon={Users}
|
||||
name="Groups"
|
||||
description="Create a group for a travel, an event, a gift…"
|
||||
/>
|
||||
<Feature
|
||||
Icon={List}
|
||||
name="Expenses"
|
||||
description="Create and list expenses in your group."
|
||||
/>
|
||||
<Feature
|
||||
Icon={FolderTree}
|
||||
name="Categories"
|
||||
description="Assign categories to your expenses."
|
||||
/>
|
||||
<Feature
|
||||
Icon={Divide}
|
||||
name="Advanced split"
|
||||
description="Split expenses by percentage, shares or amount."
|
||||
/>
|
||||
<Feature
|
||||
Icon={Share}
|
||||
name="Share"
|
||||
description="Send the group link to participants."
|
||||
/>
|
||||
<Feature
|
||||
Icon={BarChartHorizontalBig}
|
||||
name="Balances"
|
||||
description="Visualize how much each participant spent."
|
||||
/>
|
||||
<Feature
|
||||
Icon={CircleDollarSign}
|
||||
name="Reimbursements"
|
||||
description="Optimize money transfers between participants."
|
||||
/>
|
||||
<Feature
|
||||
Icon={ShieldX}
|
||||
name="No ads"
|
||||
description="No account. No limitation. No problem."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 md:py-24 lg:py-32">
|
||||
<div className="container flex max-w-screen-md flex-col items-center text-center">
|
||||
<h2 className="font-bold text-3xl leading-[1.1] sm:text-3xl md:text-6xl">
|
||||
Proudly Open Source
|
||||
</h2>
|
||||
<p
|
||||
className="mt-2 leading-normal text-muted-foreground sm:text-lg sm:leading-7"
|
||||
style={{ textWrap: 'balance' } as any}
|
||||
>
|
||||
Spliit is open source and lives thanks to amazing{' '}
|
||||
<a
|
||||
className="underline"
|
||||
target="_blank"
|
||||
href="https://github.com/scastiel/spliit2/graphs/contributors"
|
||||
>
|
||||
contributors
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
<ul className="flex gap-4 mt-6">
|
||||
{[
|
||||
{
|
||||
avatar:
|
||||
'https://avatars.githubusercontent.com/u/301948?s=120&v=4',
|
||||
user: 'scastiel',
|
||||
name: 'Sebastien Castiel',
|
||||
},
|
||||
{
|
||||
avatar:
|
||||
'https://avatars.githubusercontent.com/u/3932568?s=120&v=4',
|
||||
user: 'ChristopherJohnston',
|
||||
name: 'Chris Johnston',
|
||||
},
|
||||
{
|
||||
avatar:
|
||||
'https://avatars.githubusercontent.com/u/11523186?s=120&v=4',
|
||||
user: 'acuteengle',
|
||||
name: 'Brandon Eng',
|
||||
},
|
||||
{
|
||||
avatar:
|
||||
'https://avatars.githubusercontent.com/u/24687853?s=120&v=4',
|
||||
user: 'Max-TheCat',
|
||||
name: 'Max',
|
||||
},
|
||||
{
|
||||
avatar:
|
||||
'https://avatars.githubusercontent.com/u/10518723?s=120&v=4',
|
||||
user: 'ankitbahl',
|
||||
name: 'Ankit Bahl',
|
||||
},
|
||||
{
|
||||
avatar:
|
||||
'https://avatars.githubusercontent.com/u/13032812?s=120&v=4',
|
||||
user: '174n',
|
||||
name: 'Ivan Alexandrov',
|
||||
},
|
||||
].map((contributor) => (
|
||||
<li key={contributor.user}>
|
||||
<a
|
||||
href={`https://github.com/${contributor.user}`}
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
>
|
||||
<Image
|
||||
src={contributor.avatar}
|
||||
width={60}
|
||||
height={60}
|
||||
alt={contributor.user}
|
||||
className="rounded-full border hover:scale-110 transition-transform"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-4 md:mt-6">
|
||||
<Button asChild variant="secondary" size="lg">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/scastiel/spliit2"
|
||||
>
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Feature({
|
||||
name,
|
||||
Icon,
|
||||
description,
|
||||
}: {
|
||||
name: ReactNode
|
||||
Icon: LucideIcon
|
||||
description: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-card border rounded-md p-4 flex flex-col gap-2">
|
||||
<Icon className="w-8 h-8" />
|
||||
<div>
|
||||
<strong>{name}</strong>
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-muted-foreground"
|
||||
style={{ textWrap: 'balance' } as any}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: '/groups/',
|
||||
},
|
||||
sitemap: `${env.NEXT_PUBLIC_BASE_URL}/sitemap.xml`,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { env } from '@/lib/env'
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: env.NEXT_PUBLIC_BASE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client'
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
type Props = ButtonProps & {
|
||||
action?: () => Promise<void>
|
||||
loadingContent?: ReactNode
|
||||
}
|
||||
|
||||
export function AsyncButton({
|
||||
action,
|
||||
children,
|
||||
loadingContent,
|
||||
...props
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
return (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
await action?.()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />{' '}
|
||||
{loadingContent ?? children}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from '@/components/ui/command'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import { Category } from '@prisma/client'
|
||||
import { forwardRef, useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
categories: Category[]
|
||||
onValueChange: (categoryId: Category['id']) => void
|
||||
defaultValue: Category['id']
|
||||
}
|
||||
|
||||
export function CategorySelector({
|
||||
categories,
|
||||
onValueChange,
|
||||
defaultValue,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState<number>(defaultValue)
|
||||
const isDesktop = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
const selectedCategory =
|
||||
categories.find((category) => category.id === value) ?? categories[0]
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<CategoryButton category={selectedCategory} open={open} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<CategoryCommand
|
||||
categories={categories}
|
||||
onValueChange={(id) => {
|
||||
setValue(id)
|
||||
onValueChange(id)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<CategoryButton category={selectedCategory} open={open} />
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="p-0">
|
||||
<CategoryCommand
|
||||
categories={categories}
|
||||
onValueChange={(id) => {
|
||||
setValue(id)
|
||||
onValueChange(id)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryCommand({
|
||||
categories,
|
||||
onValueChange,
|
||||
}: {
|
||||
categories: Category[]
|
||||
onValueChange: (categoryId: Category['id']) => void
|
||||
}) {
|
||||
const categoriesByGroup = categories.reduce<Record<string, Category[]>>(
|
||||
(acc, category) => ({
|
||||
...acc,
|
||||
[category.grouping]: [...(acc[category.grouping] ?? []), category],
|
||||
}),
|
||||
{},
|
||||
)
|
||||
|
||||
return (
|
||||
<Command>
|
||||
<CommandInput placeholder="Search category..." className="text-base" />
|
||||
<CommandEmpty>No category found.</CommandEmpty>
|
||||
<div className="w-full max-h-[300px] overflow-y-auto">
|
||||
{Object.entries(categoriesByGroup).map(
|
||||
([group, groupCategories], index) => (
|
||||
<CommandGroup key={index} heading={group}>
|
||||
{groupCategories.map((category) => (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={`${category.id} ${category.grouping} ${category.name}`}
|
||||
onSelect={(currentValue) => {
|
||||
const id = Number(currentValue.split(' ')[0])
|
||||
onValueChange(id)
|
||||
}}
|
||||
>
|
||||
<CategoryLabel category={category} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
)
|
||||
}
|
||||
|
||||
type CategoryButtonProps = {
|
||||
category: Category
|
||||
open: boolean
|
||||
}
|
||||
const CategoryButton = forwardRef<HTMLButtonElement, CategoryButtonProps>(
|
||||
({ category, open, ...props }: ButtonProps & CategoryButtonProps, ref) => {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex w-full justify-between"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<CategoryLabel category={category} />
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
)
|
||||
CategoryButton.displayName = 'CategoryButton'
|
||||
|
||||
function CategoryLabel({ category }: { category: Category }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<CategoryIcon category={category} className="w-4 h-4" />
|
||||
{category.name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type Props = { text: string }
|
||||
|
||||
export function CopyButton({ text }: Props) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) {
|
||||
let timeout = setTimeout(() => setCopied(false), 1000)
|
||||
return () => {
|
||||
setCopied(false)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,532 +0,0 @@
|
||||
'use client'
|
||||
import { AsyncButton } from '@/components/async-button'
|
||||
import { CategorySelector } from '@/components/category-selector'
|
||||
import { SubmitButton } from '@/components/submit-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { getCategories, getExpense, getGroup } from '@/lib/api'
|
||||
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Save, Trash2 } from 'lucide-react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { match } from 'ts-pattern'
|
||||
|
||||
export type Props = {
|
||||
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
|
||||
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
|
||||
onSubmit: (values: ExpenseFormValues) => Promise<void>
|
||||
onDelete?: () => Promise<void>
|
||||
}
|
||||
|
||||
export function ExpenseForm({
|
||||
group,
|
||||
expense,
|
||||
categories,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const isCreate = expense === undefined
|
||||
const searchParams = useSearchParams()
|
||||
const getSelectedPayer = (field?: { value: string }) => {
|
||||
if (isCreate && typeof window !== 'undefined') {
|
||||
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
|
||||
if (activeUser && activeUser !== 'None') {
|
||||
return activeUser
|
||||
}
|
||||
}
|
||||
return field?.value
|
||||
}
|
||||
const form = useForm<ExpenseFormValues>({
|
||||
resolver: zodResolver(expenseFormSchema),
|
||||
defaultValues: expense
|
||||
? {
|
||||
title: expense.title,
|
||||
expenseDate: expense.expenseDate ?? new Date(),
|
||||
amount: String(expense.amount / 100) as unknown as number, // hack
|
||||
category: expense.categoryId,
|
||||
paidBy: expense.paidById,
|
||||
paidFor: expense.paidFor.map(({ participantId, shares }) => ({
|
||||
participant: participantId,
|
||||
shares: String(shares / 100) as unknown as number,
|
||||
})),
|
||||
splitMode: expense.splitMode,
|
||||
isReimbursement: expense.isReimbursement,
|
||||
}
|
||||
: searchParams.get('reimbursement')
|
||||
? {
|
||||
title: 'Reimbursement',
|
||||
expenseDate: new Date(),
|
||||
amount: String(
|
||||
(Number(searchParams.get('amount')) || 0) / 100,
|
||||
) as unknown as number, // hack
|
||||
category: 1, // category with Id 1 is Payment
|
||||
paidBy: searchParams.get('from') ?? undefined,
|
||||
paidFor: [
|
||||
searchParams.get('to')
|
||||
? { participant: searchParams.get('to')!, shares: 1 }
|
||||
: undefined,
|
||||
],
|
||||
isReimbursement: true,
|
||||
splitMode: 'EVENLY',
|
||||
}
|
||||
: {
|
||||
title: '',
|
||||
expenseDate: new Date(),
|
||||
amount: 0,
|
||||
category: 0, // category with Id 0 is General
|
||||
paidFor: [],
|
||||
paidBy: getSelectedPayer(),
|
||||
isReimbursement: false,
|
||||
splitMode: 'EVENLY',
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((values) => onSubmit(values))}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isCreate ? <>Create expense</> : <>Edit expense</>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid sm:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem className="">
|
||||
<FormLabel>Expense title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Monday evening restaurant"
|
||||
className="text-base"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter a description for the expense.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expenseDate"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-1">
|
||||
<FormLabel>Expense date</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="date-base"
|
||||
type="date"
|
||||
defaultValue={formatDate(field.value)}
|
||||
onChange={(event) => {
|
||||
return field.onChange(new Date(event.target.value))
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the date the expense was made.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-3">
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span>{group.currency}</span>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-base max-w-[120px]"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step={0.01}
|
||||
placeholder="0.00"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isReimbursement"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row gap-2 items-center space-y-0 pt-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<div>
|
||||
<FormLabel>This is a reimbursement</FormLabel>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<FormItem className="order-3 sm:order-2">
|
||||
<FormLabel>Category</FormLabel>
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
<FormDescription>
|
||||
Select the expense category.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="paidBy"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-5">
|
||||
<FormLabel>Paid by</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={getSelectedPayer(field)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a participant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{group.participants.map(({ id, name }) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select the participant who paid the expense.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between">
|
||||
<span>Paid for</span>
|
||||
<Button
|
||||
variant="link"
|
||||
type="button"
|
||||
className="-my-2 -mx-4"
|
||||
onClick={() => {
|
||||
const paidFor = form.getValues().paidFor
|
||||
const allSelected =
|
||||
paidFor.length === group.participants.length
|
||||
const newPaidFor = allSelected
|
||||
? []
|
||||
: group.participants.map((p) => ({
|
||||
participant: p.id,
|
||||
shares:
|
||||
paidFor.find((pfor) => pfor.participant === p.id)
|
||||
?.shares ?? ('1' as unknown as number),
|
||||
}))
|
||||
form.setValue('paidFor', newPaidFor, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{form.getValues().paidFor.length ===
|
||||
group.participants.length ? (
|
||||
<>Select none</>
|
||||
) : (
|
||||
<>Select all</>
|
||||
)}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Select who the expense was paid for.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="paidFor"
|
||||
render={() => (
|
||||
<FormItem className="sm:order-4 row-span-2 space-y-0">
|
||||
{group.participants.map(({ id, name }) => (
|
||||
<FormField
|
||||
key={id}
|
||||
control={form.control}
|
||||
name="paidFor"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<div
|
||||
data-id={`${id}/${form.getValues().splitMode}/${
|
||||
group.currency
|
||||
}`}
|
||||
className="flex items-center border-t last-of-type:border-b last-of-type:!mb-4 -mx-6 px-6 py-3"
|
||||
>
|
||||
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.some(
|
||||
({ participant }) => participant === id,
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([
|
||||
...field.value,
|
||||
{
|
||||
participant: id,
|
||||
shares: '1',
|
||||
},
|
||||
])
|
||||
: field.onChange(
|
||||
field.value?.filter(
|
||||
(value) => value.participant !== id,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal flex-1">
|
||||
{name}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
{form.getValues().splitMode !== 'EVENLY' && (
|
||||
<FormField
|
||||
name={`paidFor[${field.value.findIndex(
|
||||
({ participant }) => participant === id,
|
||||
)}].shares`}
|
||||
render={() => {
|
||||
const sharesLabel = (
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
'text-muted': !field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{match(form.getValues().splitMode)
|
||||
.with('BY_SHARES', () => <>share(s)</>)
|
||||
.with('BY_PERCENTAGE', () => <>%</>)
|
||||
.with('BY_AMOUNT', () => (
|
||||
<>{group.currency}</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<></>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{form.getValues().splitMode ===
|
||||
'BY_AMOUNT' && sharesLabel}
|
||||
<FormControl>
|
||||
<Input
|
||||
key={String(
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
),
|
||||
)}
|
||||
className="text-base w-[80px] -my-2"
|
||||
type="number"
|
||||
disabled={
|
||||
!field.value?.some(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)
|
||||
}
|
||||
value={
|
||||
field.value?.find(
|
||||
({ participant }) =>
|
||||
participant === id,
|
||||
)?.shares
|
||||
}
|
||||
onChange={(event) =>
|
||||
field.onChange(
|
||||
field.value.map((p) =>
|
||||
p.participant === id
|
||||
? {
|
||||
participant: id,
|
||||
shares:
|
||||
event.target.value,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
)
|
||||
}
|
||||
inputMode={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 'decimal'
|
||||
: 'numeric'
|
||||
}
|
||||
step={
|
||||
form.getValues().splitMode ===
|
||||
'BY_AMOUNT'
|
||||
? 0.01
|
||||
: 1
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{[
|
||||
'BY_SHARES',
|
||||
'BY_PERCENTAGE',
|
||||
].includes(
|
||||
form.getValues().splitMode,
|
||||
) && sharesLabel}
|
||||
</div>
|
||||
<FormMessage className="float-right" />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Collapsible className="mt-5">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="link" className="-mx-4">
|
||||
Advanced splitting options…
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="grid sm:grid-cols-2 gap-6 pt-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="splitMode"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:order-2">
|
||||
<FormLabel>Split mode</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
form.setValue('splitMode', value as any, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="EVENLY">Evenly</SelectItem>
|
||||
<SelectItem value="BY_SHARES">
|
||||
Unevenly – By shares
|
||||
</SelectItem>
|
||||
<SelectItem value="BY_PERCENTAGE">
|
||||
Unevenly – By percentage
|
||||
</SelectItem>
|
||||
<SelectItem value="BY_AMOUNT">
|
||||
Unevenly – By amount
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Select how to split the expense.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex mt-4 gap-2">
|
||||
<SubmitButton
|
||||
loadingContent={isCreate ? <>Creating…</> : <>Saving…</>}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isCreate ? <>Create</> : <>Save</>}
|
||||
</SubmitButton>
|
||||
{!isCreate && onDelete && (
|
||||
<AsyncButton
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loadingContent="Deleting…"
|
||||
action={onDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</AsyncButton>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(date?: Date) {
|
||||
if (!date || isNaN(date as any)) date = new Date()
|
||||
return date.toISOString().substring(0, 10)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
'use server'
|
||||
import { formSchema } from '@/components/feedback-button/feedback-button-common'
|
||||
import { FeedbackButtonEmail } from '@/components/feedback-button/feedback-button-email'
|
||||
import { getResend } from '@/lib/resend'
|
||||
import { env } from 'process'
|
||||
|
||||
export async function sendFeedback(values: unknown) {
|
||||
'use server'
|
||||
const { email, message } = formSchema.parse(values)
|
||||
const resend = getResend()
|
||||
if (!resend || !env.FEEDBACK_EMAIL_FROM || !env.FEEDBACK_EMAIL_TO) {
|
||||
console.warn(
|
||||
'Resend is not properly configured. Feedback email won’t be sent.',
|
||||
)
|
||||
return
|
||||
}
|
||||
await resend.emails.send({
|
||||
from: env.FEEDBACK_EMAIL_FROM,
|
||||
to: env.FEEDBACK_EMAIL_TO,
|
||||
subject: `Spliit: new feedback from ${email || 'anonymous user'}`,
|
||||
react: <FeedbackButtonEmail email={email} message={message} />,
|
||||
})
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const formSchema = z.object({
|
||||
email: z.union([
|
||||
z.string().email('Please enter a valid email address.'),
|
||||
z.string().max(0),
|
||||
]),
|
||||
message: z.string().min(10, 'Please enter at least 10 characters.').max(5000),
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Heading } from '@react-email/heading'
|
||||
import { Html } from '@react-email/html'
|
||||
import { Preview } from '@react-email/preview'
|
||||
import { Text } from '@react-email/text'
|
||||
|
||||
type Props = {
|
||||
email?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function FeedbackButtonEmail({ email, message }: Props) {
|
||||
return (
|
||||
<Html>
|
||||
<Preview>New feedback from {email || 'anonymous user'}</Preview>
|
||||
<Heading>New feedback on Spliit</Heading>
|
||||
<Text>
|
||||
Email address: <strong>{email || 'not provided'}</strong>
|
||||
</Text>
|
||||
<pre style={{ padding: 16, borderLeft: '2px solid lightgray' }}>
|
||||
{message}
|
||||
</pre>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
'use client'
|
||||
import { sendFeedback } from '@/components/feedback-button/feedback-button-actions'
|
||||
import { formSchema } from '@/components/feedback-button/feedback-button-common'
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useMediaQuery } from '@/lib/hooks'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Heart, HeartIcon, Loader2, MessageCircle } from 'lucide-react'
|
||||
import { PropsWithChildren, ReactNode, SetStateAction, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import * as z from 'zod'
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
|
||||
type Props = {
|
||||
donationUrl: string
|
||||
defaultTab?: 'feedback' | 'support'
|
||||
}
|
||||
|
||||
export function FeedbackModal({
|
||||
donationUrl,
|
||||
defaultTab = 'feedback',
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const { toast } = useToast()
|
||||
const isDesktop = useMediaQuery('(min-width: 640px)')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
await sendFeedback(values)
|
||||
toast({
|
||||
title: 'Thank you for your feedback!',
|
||||
description:
|
||||
'We will have a look at it as soon as possible, and will get back to you if needed.',
|
||||
})
|
||||
}
|
||||
|
||||
const Wrapper = isDesktop ? FeedbackDialog : FeedbackDrawer
|
||||
|
||||
return (
|
||||
<Wrapper open={open} setOpen={setOpen} button={children}>
|
||||
<FeedbackContent
|
||||
onSubmit={async (values) => {
|
||||
await onSubmit(values)
|
||||
setOpen(false)
|
||||
}}
|
||||
donationUrl={donationUrl}
|
||||
defaultTab={defaultTab}
|
||||
/>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedbackDrawer({
|
||||
children,
|
||||
open,
|
||||
setOpen,
|
||||
button,
|
||||
}: PropsWithChildren<{
|
||||
open: boolean
|
||||
setOpen: (open: SetStateAction<boolean>) => void
|
||||
button: ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{button}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="p-4">{children}</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedbackDialog({
|
||||
children,
|
||||
open,
|
||||
setOpen,
|
||||
button,
|
||||
}: PropsWithChildren<{
|
||||
open: boolean
|
||||
setOpen: (open: SetStateAction<boolean>) => void
|
||||
button: ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="pt-4">{children}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedbackContent({
|
||||
onSubmit,
|
||||
donationUrl,
|
||||
defaultTab,
|
||||
}: {
|
||||
onSubmit: (values: FormValues) => Promise<void>
|
||||
donationUrl: string
|
||||
defaultTab: 'feedback' | 'support'
|
||||
}) {
|
||||
return (
|
||||
<Tabs defaultValue={defaultTab}>
|
||||
<div className="mt-2 mb-6">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="feedback">Give feedback</TabsTrigger>
|
||||
<TabsTrigger value="support">Support us</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="feedback">
|
||||
<FeedbackForm onSubmit={onSubmit} />
|
||||
</TabsContent>
|
||||
<TabsContent value="support">
|
||||
<DonationForm donationUrl={donationUrl} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedbackForm({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (values: FormValues) => Promise<void>
|
||||
}) {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { email: '', message: '' },
|
||||
})
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold leading-none tracking-tight pb-1.5">
|
||||
Give us your feedback
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We are always working to improve the user experience, and your
|
||||
feedback helps us a lot.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Your email address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="your@email.com"
|
||||
className="text-base"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Provide it if you want us to get back to you.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Your feedback</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter your feedback"
|
||||
className="text-base"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center mt-1">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Submitting…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageCircle className="w-4 h-4 mr-2" /> Send
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
function DonationForm({ donationUrl }: { donationUrl: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold leading-none tracking-tight pb-1.5">
|
||||
Support us
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Help keep <strong>Spliit</strong> free and without ads!
|
||||
</p>
|
||||
</div>
|
||||
<div className="prose prose-sm dark:prose-invert">
|
||||
<p>
|
||||
Spliit is offered for free, but costs money and energy. If you like
|
||||
the app, you can choose to support it by buying me (Sebastien) a
|
||||
coffee with a one-time small donation.
|
||||
</p>
|
||||
<p>By supporting Spliit:</p>
|
||||
<ul>
|
||||
<li>
|
||||
You contribute to the <strong>hosting costs</strong> for the app
|
||||
(currently ~$150/year).
|
||||
</li>
|
||||
<li>
|
||||
You help us keeping the application{' '}
|
||||
<strong>free and without ads</strong>.
|
||||
</li>
|
||||
<li>
|
||||
You give me energy to build <strong>new features</strong> and
|
||||
improve the application.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
You will be redirected to <strong>Stripe</strong>, our payment
|
||||
provider, where you can choose an amount to donate and complete the
|
||||
payment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600"
|
||||
>
|
||||
<a href={donationUrl} target="_blank">
|
||||
<Heart className="w-4 h-4 mr-2" /> Support us
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FeedbackButton({ ...props }: ButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
className="bg-pink-700 hover:bg-pink-600 dark:bg-pink-500 dark:hover:bg-pink-600 fixed right-0 bottom-4 rounded-r-none gap-2"
|
||||
{...props}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<HeartIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
'use client'
|
||||
import { SubmitButton } from '@/components/submit-button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/hover-card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { getGroup } from '@/lib/api'
|
||||
import { GroupFormValues, groupFormSchema } from '@/lib/schemas'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Save, Trash2 } from 'lucide-react'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
|
||||
export type Props = {
|
||||
group?: NonNullable<Awaited<ReturnType<typeof getGroup>>>
|
||||
onSubmit: (groupFormValues: GroupFormValues) => Promise<void>
|
||||
protectedParticipantIds?: string[]
|
||||
}
|
||||
|
||||
export function GroupForm({
|
||||
group,
|
||||
onSubmit,
|
||||
protectedParticipantIds = [],
|
||||
}: Props) {
|
||||
const form = useForm<GroupFormValues>({
|
||||
resolver: zodResolver(groupFormSchema),
|
||||
defaultValues: group
|
||||
? {
|
||||
name: group.name,
|
||||
currency: group.currency,
|
||||
participants: group.participants,
|
||||
}
|
||||
: {
|
||||
name: '',
|
||||
currency: '',
|
||||
participants: [{ name: 'John' }, { name: 'Jane' }, { name: 'Jack' }],
|
||||
},
|
||||
})
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'participants',
|
||||
keyName: 'key',
|
||||
})
|
||||
|
||||
let activeUser = 'None'
|
||||
|
||||
const updateActiveUser = () => {
|
||||
if (group?.id) {
|
||||
const participant = group.participants.find((p) => p.name === activeUser)
|
||||
if (participant?.id) {
|
||||
localStorage.setItem(`${group.id}-activeUser`, participant.id)
|
||||
} else {
|
||||
localStorage.setItem(`${group.id}-newUser`, activeUser)
|
||||
}
|
||||
} else {
|
||||
localStorage.setItem('newGroup-activeUser', activeUser)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
await onSubmit(values)
|
||||
})}
|
||||
>
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Group information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-base"
|
||||
placeholder="Summer vacations"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter a name for your group.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Currency symbol</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="text-base"
|
||||
placeholder="$, €, £…"
|
||||
max={5}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
We’ll use it to display amounts.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Participants</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the name for each participant
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{fields.map((item, index) => (
|
||||
<li key={item.key}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`participants.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="sr-only">
|
||||
Participant #{index + 1}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input className="text-base" {...field} />
|
||||
{item.id &&
|
||||
protectedParticipantIds.includes(item.id) ? (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive-"
|
||||
type="button"
|
||||
size="icon"
|
||||
disabled
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive opacity-50" />
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="end"
|
||||
className="text-sm"
|
||||
>
|
||||
This participant is part of expenses, and can
|
||||
not be removed.
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
type="button"
|
||||
size="icon"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
append({ name: 'New' })
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Add participant
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Local settings</CardTitle>
|
||||
<CardDescription>
|
||||
These settings are set per-device, and are used to customize your
|
||||
experience.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<FormItem>
|
||||
<FormLabel>Active user</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
activeUser = value
|
||||
}}
|
||||
defaultValue={
|
||||
fields.find(
|
||||
(f) =>
|
||||
f.id ===
|
||||
localStorage.getItem(`${group?.id}-activeUser`),
|
||||
)?.name || 'None'
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a participant" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[{ name: 'None' }, ...form.watch('participants')]
|
||||
.filter((item) => item.name.length > 0)
|
||||
.map(({ name }) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
User used as default for paying expenses.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SubmitButton
|
||||
size="lg"
|
||||
loadingContent={group ? 'Saving…' : 'Creating…'}
|
||||
onClick={updateActiveUser}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" /> {group ? <>Save</> : <> Create</>}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client'
|
||||
import { Next13ProgressBar } from 'next13-progressbar'
|
||||
|
||||
export function ProgressBar() {
|
||||
return (
|
||||
<Next13ProgressBar
|
||||
height="2px"
|
||||
color="#64748b"
|
||||
options={{ showSpinner: false }}
|
||||
showOnShallow
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Share } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
text: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export function ShareUrlButton({ url, text }: Props) {
|
||||
const canShare = useCanShare(url, text)
|
||||
if (!canShare) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.share) {
|
||||
navigator.share({ text, url })
|
||||
} else {
|
||||
console.log('Sharing is not available', { text, url })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Share className="w-4 h-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function useCanShare(url: string, text: string) {
|
||||
const [canShare, setCanShare] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setCanShare(
|
||||
navigator.share !== undefined && navigator.canShare({ url, text }),
|
||||
)
|
||||
}, [text, url])
|
||||
|
||||
return canShare
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Button, ButtonProps } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { ReactNode } from 'react'
|
||||
import { useFormState } from 'react-hook-form'
|
||||
|
||||
type Props = {
|
||||
loadingContent: ReactNode
|
||||
} & ButtonProps
|
||||
|
||||
export function SubmitButton({ children, loadingContent, ...props }: Props) {
|
||||
const { isSubmitting } = useFormState()
|
||||
return (
|
||||
<Button type="submit" disabled={isSubmitting} {...props}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> {loadingContent}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import { type ThemeProviderProps } from 'next-themes/dist/types'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { MoonIcon, SunIcon } from '@radix-ui/react-icons'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-primary">
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,79 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -1,155 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -1,25 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -1,117 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||