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_PRISMA_URL=postgresql://postgres:1234@localhost
|
||||||
POSTGRES_URL_NON_POOLING=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
|
# 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
|
patreon: # Replace with a single Patreon username
|
||||||
open_collective: # Replace with a single Open Collective username
|
open_collective: # Replace with a single Open Collective username
|
||||||
ko_fi: # Replace with a single Ko-fi username
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -28,6 +28,7 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
*.env
|
*.env
|
||||||
|
!scripts/build.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -38,3 +39,5 @@ next-env.d.ts
|
|||||||
|
|
||||||
# db
|
# db
|
||||||
postgres-data
|
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
|
## 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] Create reimbursement expenses
|
||||||
- [x] Progressive Web App
|
- [x] Progressive Web App
|
||||||
- [x] Select all/no participant for expenses
|
- [x] Select all/no participant for expenses
|
||||||
- [x] Split expenses unevenly [(#6)](https://github.com/scastiel/spliit2/issues/6)
|
- [x] Split expenses unevenly [(#6)](https://github.com/spliit-app/spliit/issues/6)
|
||||||
- [x] Mark a group as favorite [(#29)](https://github.com/scastiel/spliit2/issues/29)
|
- [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/scastiel/spliit2/issues/7)
|
- [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/scastiel/spliit2/issues/35)
|
- [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
|
### Possible incoming features
|
||||||
|
|
||||||
- [ ] Ability to create recurring expenses [(#5)](https://github.com/scastiel/spliit2/issues/5)
|
- [ ] Ability to create recurring expenses [(#5)](https://github.com/spliit-app/spliit/issues/5)
|
||||||
- [ ] Import expenses from Splitwise [(#22)](https://github.com/scastiel/spliit2/issues/22)
|
- [ ] Import expenses from Splitwise [(#22)](https://github.com/spliit-app/spliit/issues/22)
|
||||||
|
|
||||||
## Stack
|
## 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!
|
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
|
## Run locally
|
||||||
|
|
||||||
1. Clone the repository (or fork it if you intend to contribute)
|
1. Clone the repository (or fork it if you intend to contribute)
|
||||||
2. `npm install`
|
2. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you don’t have a server already.
|
||||||
3. 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. 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. `npm run dev`
|
5. Run `npm run dev` to start the development server
|
||||||
|
|
||||||
## Run in a container
|
## 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
|
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
|
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
|
## License
|
||||||
|
|
||||||
MIT, see [LICENSE](./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)
|
|
||||||
5721
package-lock.json
generated
71
package.json
@@ -1,78 +1,53 @@
|
|||||||
{
|
{
|
||||||
"name": "spliit2",
|
"name": "spliit-api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"lint": "eslint",
|
||||||
"build": "next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint",
|
|
||||||
"check-types": "tsc --noEmit",
|
"check-types": "tsc --noEmit",
|
||||||
"check-formatting": "prettier -c src",
|
"check-formatting": "prettier -c src",
|
||||||
"postinstall": "prisma migrate deploy && prisma generate",
|
"prettier": "prettier -w src",
|
||||||
"build-image": "./scripts/build-image.sh",
|
"postinstall": "prisma generate"
|
||||||
"start-container": "docker compose --env-file container.env up"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formatjs/intl-localematcher": "^0.5.4",
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
"@prisma/client": "^5.6.0",
|
"@prisma/client": "^5.6.0",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@trpc/client": "^11.0.0-rc.586",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@trpc/react-query": "^11.0.0-rc.586",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@trpc/server": "^11.0.0-rc.586",
|
||||||
"@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",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"content-disposition": "^0.5.4",
|
||||||
"cmdk": "^0.2.0",
|
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"lucide-react": "^0.290.0",
|
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"next": "^14.0.4",
|
"negotiator": "^0.6.3",
|
||||||
"next-plausible": "^3.12.0",
|
"openai": "^4.25.0",
|
||||||
"next-themes": "^0.2.1",
|
|
||||||
"next13-progressbar": "^1.1.1",
|
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"react": "^18.2.0",
|
"prisma": "^5.7.0",
|
||||||
"react-dom": "^18.2.0",
|
"sharp": "^0.33.2",
|
||||||
"react-hook-form": "^7.47.0",
|
"superjson": "^2.2.1",
|
||||||
"resend": "^2.1.0",
|
|
||||||
"tailwind-merge": "^1.14.0",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"ts-pattern": "^5.0.6",
|
"ts-pattern": "^5.0.6",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vaul": "^0.8.0",
|
"vaul": "^0.8.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.13.0",
|
||||||
"@total-typescript/ts-reset": "^0.5.1",
|
"@total-typescript/ts-reset": "^0.5.1",
|
||||||
|
"@types/content-disposition": "^0.5.8",
|
||||||
|
"@types/negotiator": "^0.6.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.10.9",
|
"@types/pg": "^8.10.9",
|
||||||
"@types/react": "^18",
|
|
||||||
"@types/react-dom": "^18",
|
|
||||||
"@types/uuid": "^9.0.6",
|
"@types/uuid": "^9.0.6",
|
||||||
"autoprefixer": "^10",
|
"autoprefixer": "^10",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-next": "^14.0.4",
|
"globals": "^15.11.0",
|
||||||
"postcss": "^8",
|
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
"prettier-plugin-organize-imports": "^3.2.3",
|
"prettier-plugin-organize-imports": "^3.2.3",
|
||||||
"prisma": "^5.7.0",
|
|
||||||
"tailwindcss": "^3",
|
|
||||||
"tsconfig-paths": "^4.2.0",
|
"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 {
|
model Group {
|
||||||
id String @id
|
id String @id
|
||||||
name String
|
name String
|
||||||
|
information String? @db.Text
|
||||||
currency String @default("$")
|
currency String @default("$")
|
||||||
participants Participant[]
|
participants Participant[]
|
||||||
expenses Expense[]
|
expenses Expense[]
|
||||||
|
activities Activity[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +53,17 @@ model Expense {
|
|||||||
isReimbursement Boolean @default(false)
|
isReimbursement Boolean @default(false)
|
||||||
splitMode SplitMode @default(EVENLY)
|
splitMode SplitMode @default(EVENLY)
|
||||||
createdAt DateTime @default(now())
|
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 {
|
enum SplitMode {
|
||||||
@@ -69,3 +82,21 @@ model ExpensePaidFor {
|
|||||||
|
|
||||||
@@id([expenseId, participantId])
|
@@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_APP_NAME=$(node -p -e "require('./package.json').name")
|
||||||
SPLIIT_VERSION=$(node -p -e "require('./package.json').version")
|
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
|
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 ];
|
if [ $? -eq 0 ];
|
||||||
then
|
then
|
||||||
echo "postgres is already running, doing nothing"
|
echo "postgres is already running, doing nothing"
|
||||||
@@ -6,6 +6,6 @@ else
|
|||||||
echo "postgres is not running, starting it"
|
echo "postgres is not running, starting it"
|
||||||
docker rm postgres --force
|
docker rm postgres --force
|
||||||
mkdir -p postgres-data
|
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
|
sleep 5 # Wait for postgres to start
|
||||||
fi
|
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,
|
|
||||||
}
|
|
||||||