105 Commits
neutral ... api

Author SHA1 Message Date
Sebastien Castiel
0b72fd2000 Only Irish coffee provides in a single glass all four essential food groups --
alcohol, caffeine, sugar, and fat.
		-- Alex Levine
2024-10-25 15:45:32 -04:00
Sebastien Castiel
a7873fda02 A thing is not necessarily true because a man dies for it.
-- Oscar Wilde, "The Portrait of Mr. W.H."
2024-10-25 15:03:04 -04:00
Sebastien Castiel
c1c75fa260 Q: Why do mountain climbers rope themselves together?
A:	To prevent the sensible ones from going home.
2024-10-25 15:02:31 -04:00
Sebastien Castiel
9bdc8a715c Everyone wants results, but no one is willing to do what it takes to get them.
-- Dirty Harry
2024-10-25 14:37:17 -04:00
Sebastien Castiel
35bbb04b9d Test-tube babies shouldn't throw stones. 2024-10-25 14:28:32 -04:00
Sebastien Castiel
5d96cdc1c2 If at first you don't succeed, redefine success. 2024-10-25 14:20:24 -04:00
Sebastien Castiel
56b4010b91 Experience varies directly with equipment ruined. 2024-10-25 14:19:15 -04:00
Sebastien Castiel
62cfad1a32 Hindsight is always 20:20.
-- Billy Wilder
2024-10-25 14:08:53 -04:00
Sebastien Castiel
4db788680e Use tRPC for recent groups page (#253)
* Use tRPC for recent groups page

* Use tRPC for adding group by URL

* Use tRPC for saving visited group

* Group context
2024-10-20 17:50:52 -04:00
Sebastien Castiel
39c1a2ffc6 Fix languages in Romanian translation 2024-10-20 11:51:04 -04:00
stefansn
f5154393e2 Add Romanian translations (#248)
* Add Romanian translations

Create ro.json.

* Add ro option.

Add ro option.

* Update ro.json

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-10-19 23:10:58 -04:00
Marco Sciuto
e9d583113a Update it-IT.json (#245) 2024-10-19 23:09:07 -04:00
Sebastien Castiel
21d0c02687 Use tRPC for expense form (#251) 2024-10-19 22:59:47 -04:00
Sebastien Castiel
2281316d58 Use tRPC for group create form (#250) 2024-10-19 21:48:17 -04:00
Sebastien Castiel
210c12b7ef Use tRPC in other group pages (#249)
* Use tRPC in group edition + group layout

* Use tRPC in group modals

* Use tRPC in group stats

* Use tRPC in group activity
2024-10-19 21:29:53 -04:00
Sebastien Castiel
66e15e419e Add tRPC, use it for group expenses, balances and information page (#246)
* Add tRPC, use it for group expense list

* Use tRPC for balances

* Use tRPC in group information + better loading states
2024-10-19 17:42:11 -04:00
Paweł Kotiuk
727803ea5c [translation] Add Polish language (#243)
* Add polish translation file

* Add polish to other translations
2024-10-14 19:19:59 -04:00
Thorsten Herfurtner
7add7efea2 Fix missing translation for expense title in expense-form when making a reinbursement (#244) 2024-10-14 19:13:10 -04:00
bitgroestl
a7c80f65c3 fix Dockerfile (#206)
Co-authored-by: samuel <samuel@t460.localdomain>
2024-10-14 19:11:50 -04:00
Serge D.
1e4edf7504 add ua-UA (#241) 2024-10-11 17:10:04 -04:00
Maxco10
24053ca5ab Update it-IT.json (#237) 2024-10-11 17:07:28 -04:00
Maxco10
343363d54f Added Italian language (#233)
* Update i18n.ts

* Update de-DE.json

* Update en-US.json

* Update es.json

* Update fi.json

* Update fr-FR.json

* Update ru-RU.json

* Update zh-CN.json

* Create it-IT.json

* Update it-IT.json

* Update it-IT.json

* Update it-IT.json

* Update it-IT.json

* Update it-IT.json

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-10-05 10:30:45 -04:00
Mert Demir
8742bd59da Fix format for integer amounts (#231)
* support both fractions and integer values

* test fractions

* prettier
2024-09-29 09:24:10 -04:00
Kostas Vrouvas
8eea062218 Fix form columns not working properly (#224) 2024-09-28 18:41:19 -04:00
Mert Demir
9a5674e239 Fix amount preview for scanned receipts (#227)
* no division of amount

* use gpt-4-turbo

* testing setup and naive test

* test multiple variants

* document

* correct locale names

* test large amounts

* test wth strings

* prettier
2024-09-28 18:39:01 -04:00
Tobias Genannt
50b3a2e431 Fix: Correctly display loaded expense (#210)
* Fix #209: Correctly display loaded expense

- Don't load default split options after displaying an existing expense
- Re-validate form after changing the "paidFor" selection.
  This fixes the error message "The expense must be paid for at least one
  participant." after clicking "Select None" and the selecting one participant.

* Fix Paid For Field reset in Edit Expense Page for split Mode 'Unevenly - By amount'

---------

Co-authored-by: partho.kunda <partho.kunda@chaldal.net>
2024-09-28 18:28:27 -04:00
Nikita Utkin
e8d46cd4f3 Add russian localization (#216) 2024-09-28 18:23:17 -04:00
Zack_Z
8f896f7412 Add Chinese translation (#215)
* Added Chinese translation

* Add home page translation

* Fix translations

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-09-28 18:18:27 -04:00
chrisschuller
504631454a feat: add German language support (#207)
* feat: add German language support

* fix: translate other locale names to German

* chore: integrate recommendations from the PR review

* i18n: add translation recommendations from the PR

* Fix translations

---------

Co-authored-by: Christian Schuller <christianschuller.biz@gmail.com>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-09-28 18:11:30 -04:00
Pau Sansa
345f3716c9 feature: add Spanish language support (#214)
* create ES i18n json

* add ES locale to i18n and existing locales

* capitalize words at es.json

* Add missing translation

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-09-28 18:06:15 -04:00
Sebastien Castiel
5fff8da08d Fix translation 2024-09-28 17:58:04 -04:00
Strekol
07e24f7fcb Add French translation (#196)
* Added french version and title/description from json messages

* Revert back default language to en-US

* Code reviewed with prettier :)

* Updated json to add information field

* Updated json to add information block (missed on previous)

* Reviewed code language

* correction traduction "groupes étoilés" en "groupes favoris"

---------

Co-authored-by: Andy Trouvé <andy@strekol.eu>
2024-09-28 17:56:55 -04:00
Sebastien Castiel
5dfe03b3f1 Make header buttons smaller (#191) 2024-08-02 12:22:39 -04:00
Sebastien Castiel
26bed11116 Update Next.js + Npm audit fix (#190)
* Audit fix

* Upade Next
2024-08-02 12:18:49 -04:00
Chris Johnston
972bb9dadb add group information field to group settings and Information tab (#164)
* add group information field to group and Information tab to display

* add breaks to info page

* Improve UX

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-08-02 12:03:36 -04:00
Tuomas Jaakola
4f5e124ff0 Internationalization + Finnish language (#181)
* I18n with next-intl

* package-lock

* Finnish translations

* Development fix

* Use locale for positioning currency symbol

* Translations: Expenses.ActiveUserModal

* Translations: group 404

* Better translation for ExpenseCard

* Apply translations in CategorySelect search

* Fix for Finnish translation

* Translations for ExpenseDocumentsInput

* Translations for CreateFromReceipt

* Fix for Finnish translation

* Translations for schema errors

* Fix for Finnish translation

* Fixes for Finnish translations

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-08-02 11:26:23 -04:00
Miska Pajukangas
c392c06b39 feat: add auto-balancing for the amount edit (#173)
* feat: add auto-balancing for the amount edit

this implementation allocates the rest of the total
to participants, whose rows have yet not been edited.

* fix: reset already edited on total amount change
2024-08-02 11:04:21 -04:00
Laszlo Makk
002e867bc4 Make recalculation stable across repayments in suggested reimbursements (#179)
* suggested reimbursements: make recalculation stable across repayments

Previously, after a group participant executed a suggested reimbursement, rerunning getSuggestedReimbursements() could return a completely new list of suggestions.

With this change, getSuggestedReimbursements() should now be stable:
if it returns a graph with n edges, and then a repayment is made according to one of those edges, when called again, it should now return the same graph but with that one edge removed.

The trick is that the main logic in getSuggestedReimbursements() does not rely on balancesArray being sorted based on .total values, only that the array gets partitioned into participants with credit first and then participants with debt last. After a repayment is made, re-sorting based on .total values would result in a new order hence new suggestions, but sorting based on usernames/participantIds should be unaffected.

fixes https://github.com/spliit-app/spliit/issues/178

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-08-02 10:58:46 -04:00
Tuomas Jaakola
9b8f716a6a Use unique name for postgres container (#171) 2024-08-02 10:58:33 -04:00
Tuomas Jaakola
853f1791d2 recent-groups-page.tsx removed (#182) 2024-08-02 10:57:39 -04:00
Sergio Behrends
7145cb6f30 Increase fuzzines of search results (#187)
* Introduce normalizeString fn

* Prettier

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-08-02 10:57:18 -04:00
Sebastien Castiel
e990e00a75 Upgrade Next.js & React to latest versions (#159) 2024-05-29 22:25:52 -04:00
Stefan Hynst
0c05499107 Add support for group income (= negative expenses) (#158)
* Allow negative amount for expenses to be entered

- an expense becomes an income
- this does not affect calculations, i.e. an income can be split just like an expense

* Incomes should not be reimbursements

when entering a negative number
- deselect 'isReimbursement'
- hide reimbursement checkbox

* Change captions when entering a negative number

- "expense" becomes "income"
- "paid" becomes "received"

* Format incomes on expense list

- replace "paid by" with "received by"

* Format incomes on "Stats" tab

- a group's or participants balance might be negative
- in this case "spendings" will be "earnings" (display accordingly)
- always display positive numbers
- for active user: highlight spendings/earnings in red/green

* Fix typo

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:20:04 -04:00
Oliver Wong
3887efd9ee Use placeholder for new participant input (#153)
* use placeholder for new participant

* Fix formatting

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:11:24 -04:00
dcbr
e619c1a5b4 Add basic activity log (#141)
* Add basic activity log

* Add database migration

* Fix layout

* Fix types

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 22:06:45 -04:00
Lauri Vuorela
10e13d1f6b copy the next.config.js in order to get custom domains working again (#147) 2024-05-29 21:46:16 -04:00
Lauri Vuorela
f9d915378b change onClick to onFocus, with a slight delay for safari (#144)
* change onClick to onFocus, with a slight delay for safari

* typo

* fix variable name

* Fix style

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 21:45:46 -04:00
Antonin RAFFIN
74465c0565 Fix relative path docker db script (#154)
Without this, docker complained:
```
docker: Error response from daemon: create ./postgres-data: "./postgres-data" includes invalid characters for a local volume name, only "[a-zA-Z0-9][a-zA-Z0-9_.-]" are allowed. If you intended to pass a host directory, use absolute path.
```

Followed recommendation from https://stackoverflow.com/questions/46526165/docker-invalid-characters-for-volume-when-using-relative-paths
2024-05-29 21:38:37 -04:00
Stefan Hynst
d3fd8027a5 Implement "infinite scroll" for expenses (#95)
* Extract ExpenseCard vom ExpenseList

* Implement simple pagination of expenses (see #30)

- display only this year's entries by default
- a "Show more" button reveals all expenses

* Turn getPrisma() into constant "prisma"

- getPrisma() is not async and doesn't need to be awaited
- turn getPrisma() into exported constant "prisma"

* Select fields to be returned by getGroupExpenses()

- make JSON more concise and less redundant
- some properties were removed (i.e.instead of "expense.paidById" use "expense.paidBy.id")

* Remove "participants" from ExpenseCard

- no need to search for participant by id to get it's name
- name property is already present in expense

* Add option to fetch a slice of group expenses

- specify offset and length to get expenses for [offset, offset+length[
- add function to get total number of group expenses

* Add api route for client to fetch group expenses

* Remove "Show more" button from expense list

* Implement infinite scroll

- in server component Page
  - only load first 200 expenses max
  - pass preloaded expenses and total count

- in client component ExpenseList, if there are more expenses to show
  - test if there are more expenses
  - append preloading "skeletons" to end of list
  - fetch more expenses when last item in list comes into view
  - after each fetch increase fetch-length by factor 1.5
    - rationale: db fetch usually is not the issue here, the longer the list gets, the longer react needs to redraw

* Use server action instead of api endpoint

* Fixes

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-05-29 21:36:07 -04:00
Stefan Hynst
833237b613 Add "x" button to cancel search in search bar (#107) 2024-05-29 21:26:04 -04:00
dcbr
1cd2b273f9 Show the impact of an expense on the active user's balance (#139)
* Add devcontainer configuration for codespace support

* Show the impact of an expense on the active user's balance

* Run prettier

* Put the balance on a different line

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-04-13 13:07:18 -04:00
dcbr
1ad470309b Add devcontainer configuration for codespace support (#138) 2024-04-13 12:57:47 -04:00
Deep Golani
2fd38aadd9 Add notes in expense (#126)
* Feature: Added notes in expense

* Add missing notes in form values

* Prettier

---------

Co-authored-by: deep.golani <deep.golani@bfhl.in>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-04-05 08:38:38 -04:00
magomzr
b61d1836ea Add titles for a better user experience (#137)
Co-authored-by: Mario Gómez <60667991+mgomezarr@users.noreply.github.com>
2024-04-05 08:29:08 -04:00
Sahil Mehra
c3903849ec Bug: Fixed wrong paid by Name in Reimbursement (#134) 2024-04-02 08:20:56 -04:00
Jan T
b67a0be0dd Add "save as default splitting options" feature (#120)
* Add "save as default splitting options" feature

* Fix type issue

* Run autoformatter
2024-03-09 11:45:53 -05:00
Guhan
e07d237218 Ask for confirmation to delete an expense (#124)
* feat: added a popup asking for confirmation to delete an expense

* fix: changed cancel option as a button and formatting issues

* fix: removed unnecessary tags and replaced generic tags with proper components

* Small fix to avoid warning in console

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-03-09 11:38:30 -05:00
annalouisep
cc37083389 Expense list: add section for planned purchases (#122)
* add planned purchases

* Updating verbiage to reflect possible future entry types
2024-03-09 11:30:24 -05:00
Sebastien Castiel
552953151a Don’t count reimbursements in stats (fixes #118) (#119) 2024-02-29 10:21:23 -05:00
Jan T
b227401dd6 Minor: reorder Dockerfile layers for better cache use (#116) 2024-02-28 10:59:19 -05:00
sashkent3
6a5efc5f3f Fix the default value for the expense shares field (#113)
* fix default shares value

* fix default shares value for reimbursements

* prettier
2024-02-28 10:58:49 -05:00
Jan T
4c5f8a6aa5 Fix decimal separator issue in numeric form fields (#115)
* Revert 5b65b8f, fix comma issue with type="text" and onChange

* Fix comma issue in "paid for" input

* Run prettier autoformat

* Allow only digits and dots in currency inputs

* Fix behaviour in paidFor field

* Fix duplicated onChange prop

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-02-28 10:57:55 -05:00
Lauri Vuorela
c2b591349b add a prettier script for ease of use (#105) 2024-02-28 10:45:02 -05:00
Lauri Vuorela
56c1865264 Add onClick-event to select all to amount input (#104)
* add onfocus-event to select all to amount input

* use onClick instead of onFocus
2024-02-28 10:44:27 -05:00
Anurag
2f991e680b feat: initialise a new totals tab with basic UI (#94)
* feat: initialise a new totals tab with basic UI

* fix: update group tabs and add stats page

* fix: styling within the new elements

* Prettier

* Display active user expenses only if active user is set

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-02-28 10:43:25 -05:00
Jan T
2af0660383 Optimize docker image size (#91)
* Move prisma to runtime dependencies

* Optimize Dockerfile and build script

* Fix: remove mention of generated next-env.d.ts in Dockerfile

* Add missing reset.d.ts file to Dockerfile

* Remove compression steps from Dockerfile and entrypoint script

* Add an env file with mocked env vars added for Docker production builds

* Use server actions to get runtime env vars

* Refactor types and names

* Rollback serverActions, use parsed Zod object for runtime env

* Reintroduce featureFlags object to avoid passing secret envs to the frontend

* Improve string to boolean coercion

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>

* Run prettier autoformat

* Fix type issue, rename function to match behaviour better

---------

Co-authored-by: Lauri Vuorela <lauri.vuorela@gmail.com>
Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-02-14 10:18:30 -05:00
Sebastien Castiel
50525ad881 Add cancel button on expense form (fixes #92) 2024-02-14 09:49:06 -05:00
Lauri Vuorela
f7a13a0436 Round totals rather than expense by expense (#88)
* do balance rounding only on full balances rather than on every expense

* use "public balances" calculated from reimbursements to show on balance page

* fixes for totals that did not work as expected

* prettier
2024-02-13 14:35:57 -05:00
Jan T
5b65b8f049 Replace commas with dots in expense form schema amount field (#90) 2024-02-13 14:26:45 -05:00
Sebastien Castiel
0e6a2bdc6c Limit file upload size on the client (#84) 2024-02-06 10:19:57 -05:00
Lauri Vuorela
be0964d9e1 format currency with thousand separators (#81) 2024-02-04 20:16:30 -05:00
Mert Demir
fb49fb596a Automatic category from expense title (#80)
* environment variable

* random category draft

* get category from ai

* input limit and documentation

* use watch

* use field.name

* prettier

* presigned upload, readme warning, category to string util

* prettier

* check whether feature is enabled

* use process.env

* improved prompt to return id only

* remove console.debug

* show loader

* share class name

* prettier

* use template literals

* rename format util

* prettier
2024-02-04 12:23:11 -05:00
Sebastien Castiel
10fd69404a Add splash screen for iOS PWA 2024-02-04 11:17:53 -05:00
Raymond Berger
6dd631b03a Update start_url to /groups page (#77) 2024-02-03 10:30:27 -05:00
Mert Demir
08d75fd75c Support for additional S3 providers (#71)
* support for other s3 providers

* remove redundant route options

* use type safe env

* prettier
2024-01-31 17:00:19 -05:00
Sebastien Castiel
e6467b41fc Improve receipt scanning 2024-01-30 20:07:46 -05:00
Sebastien Castiel
4a9bf575bd Create expense from receipt (#69)
* Create expense from receipt

* Add modal

* Update README
2024-01-30 16:36:29 -05:00
Sebastien Castiel
9e300e0ff0 Use React’s cache to avoid some queries to the database 2024-01-30 12:57:21 -05:00
Sebastien Castiel
3847a67a19 Sort expenses by expense date, then by creation date (partial workaround for #67) 2024-01-29 15:14:51 -05:00
Sebastien Castiel
7695ffd62d Fix uploaded image names 2024-01-29 10:39:49 -05:00
Sebastien Castiel
091cd02c06 Use carousel to display images (fix dimensions) 2024-01-28 23:41:18 -05:00
Sebastien Castiel
9876d7045f Use carousel to display images 2024-01-28 23:28:44 -05:00
Lauri Vuorela
9759f61e0e Production target for Dockerfile (#57)
* add production build

* add back updates and use slim image

* udpate command

* ignore scripts

* add workdir

* fix workdirs

* docker image improvements

* use .example instead

* use dummy data instead

* remove unused env var and add comment

* fix entrypoints

* change name of script and add possibility for different commands

* change to safer default for volume

* add instructions for the dev docker container

* update copy

* add empty lines under topics to keep uniformity

* most RUN's in a single command

* add comment about volumes for dev target

* remove dev workflow

* remove dev workflow from readme

* Prettify README

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-28 19:30:26 -05:00
Sebastien Castiel
d43e731fe1 Attach documents to expenses (#64)
* Upload documents to receipts

* Improve documents

* Make the feature opt-in

* Fix file name issue
2024-01-28 18:51:29 -05:00
Sebastien Castiel
11d2e298e8 Improve recent groups design 2024-01-26 16:11:51 -05:00
Sebastien Castiel
0647000a77 Disable prefetch on export link 2024-01-26 15:50:32 -05:00
Vid Čufar
2228415323 Fix search functionality (#62)
* Improve README instructions for local setup

* Fix search functionality #61
- use 'includes' for expense filtering

* Ensure expense groups with no matching expenses are hidden after filtering

* Improve README instructions for local setup
2024-01-26 10:27:34 -05:00
Mert Demir
58ee685e22 paid for all, split evenly (#59) 2024-01-26 10:26:58 -05:00
Sebastien Castiel
545cf75e99 Join group by URL (Closes #55) 2024-01-24 11:12:55 -05:00
Sebastien Castiel
7956156d70 Upgrade Next.js to 14.1.0 2024-01-24 09:50:37 -05:00
Sebastien Castiel
2f58e466da Fix image size to prevent warning in the console 2024-01-23 22:27:07 -05:00
Sebastien Castiel
89ee5ae247 Add date and bring back group name in exported filename (#54) 2024-01-23 16:41:07 -05:00
Sebastien Castiel
1bd3f99d38 Fix export file name (Fix #54) 2024-01-22 13:05:35 -05:00
Sebastien Castiel
e32a12ce41 Update FUNDING.yml 2024-01-19 23:39:01 -05:00
Sebastien Castiel
49218e8e9d Update README.md 2024-01-19 23:37:12 -05:00
Sebastien Castiel
23eedcb619 Update FUNDING.yml 2024-01-19 23:33:28 -05:00
Sebastien Castiel
ba4107e440 Update README.md 2024-01-19 16:43:31 -05:00
Ankit Bahl
ae7cb2ccc8 Added search bar for expense list page (#52)
* Added search bar for expense list page

* Change search input styling

---------

Co-authored-by: Sebastien Castiel <sebastien@castiel.me>
2024-01-19 16:38:18 -05:00
Sebastien Castiel
3735509fea Update GitHub repository URL 2024-01-19 16:13:02 -05:00
Sebastien Castiel
1b1ebf015e Bring back NEXT_PUBLIC_BASE_URL (continued) 2024-01-19 12:49:22 -05:00
Sebastien Castiel
c138afadb9 Bring back NEXT_PUBLIC_BASE_URL 2024-01-19 12:43:17 -05:00
Sebastien Castiel
18ac2142a8 Update README.md 2024-01-19 12:10:19 -05:00
Sebastien Castiel
875b9787d0 Add deploy with Vercel button 2024-01-19 12:04:48 -05:00
Sebastien Castiel
4d86c8c727 Remove varianle NEXT_PUBLIC_BASE_URL 2024-01-19 12:03:16 -05:00
Sebastien Castiel
23524cb943 Clean project from marketing content (#50)
* Clean project from marketing content

* Remove some dependencies
2024-01-19 11:28:25 -05:00
140 changed files with 2125 additions and 10659 deletions

View 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"
}

View 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:

View File

@@ -1,3 +1,2 @@
POSTGRES_PRISMA_URL=postgresql://postgres:1234@localhost
POSTGRES_URL_NON_POOLING=postgresql://postgres:1234@localhost
NEXT_PUBLIC_BASE_URL=http://localhost:3000

2
.github/FUNDING.yml vendored
View File

@@ -1,6 +1,6 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: ['scastiel']
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username

3
.gitignore vendored
View File

@@ -28,6 +28,7 @@ yarn-error.log*
# local env files
.env*.local
*.env
!scripts/build.env
# vercel
.vercel
@@ -38,3 +39,5 @@ next-env.d.ts
# db
postgres-data
/dist

View File

@@ -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"]

View File

@@ -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:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fspliit-app%2Fspliit&project-name=my-spliit-instance&repository-name=my-spliit-instance&stores=%5B%7B%22type%22%3A%22postgres%22%7D%5D&)
## Features
@@ -10,15 +12,18 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
- [x] Create reimbursement expenses
- [x] Progressive Web App
- [x] Select all/no participant for expenses
- [x] Split expenses unevenly [(#6)](https://github.com/scastiel/spliit2/issues/6)
- [x] Mark a group as favorite [(#29)](https://github.com/scastiel/spliit2/issues/29)
- [x] Tell the application who you are when opening a group [(#7)](https://github.com/scastiel/spliit2/issues/7)
- [x] Assign a category to expenses [(#35)](https://github.com/scastiel/spliit2/issues/35)
- [x] Split expenses unevenly [(#6)](https://github.com/spliit-app/spliit/issues/6)
- [x] Mark a group as favorite [(#29)](https://github.com/spliit-app/spliit/issues/29)
- [x] Tell the application who you are when opening a group [(#7)](https://github.com/spliit-app/spliit/issues/7)
- [x] Assign a category to expenses [(#35)](https://github.com/spliit-app/spliit/issues/35)
- [x] Search for expenses in a group [(#51)](https://github.com/spliit-app/spliit/issues/51)
- [x] Upload and attach images to expenses [(#63)](https://github.com/spliit-app/spliit/issues/63)
- [x] Create expense by scanning a receipt [(#23)](https://github.com/spliit-app/spliit/issues/23)
### Possible incoming features
- [ ] Ability to create recurring expenses [(#5)](https://github.com/scastiel/spliit2/issues/5)
- [ ] Import expenses from Splitwise [(#22)](https://github.com/scastiel/spliit2/issues/22)
- [ ] Ability to create recurring expenses [(#5)](https://github.com/spliit-app/spliit/issues/5)
- [ ] Import expenses from Splitwise [(#22)](https://github.com/spliit-app/spliit/issues/22)
## Stack
@@ -32,15 +37,18 @@ Spliit is a free and open source alternative to Splitwise. I created it back in
The project is open to contributions. Feel free to open an issue or even a pull-request!
If you want to contribute financially and help us keep the application free and without ads, you can also [make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba) ❤️.
If you want to contribute financially and help us keep the application free and without ads, you can also:
- 💜 [Sponsor me (Sebastien)](https://github.com/sponsors/scastiel), or
- 💙 [Make a small one-time donation](https://donate.stripe.com/28o3eh96G7hH8k89Ba).
## Run locally
1. Clone the repository (or fork it if you intend to contribute)
2. `npm install`
3. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you dont have a server already.
4. Copy the file `.env.example` as `.env`
5. `npm run dev`
2. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you dont have a server already.
3. Copy the file `.env.example` as `.env`
4. Run `npm install` to install dependencies. This will also apply database migrations and update Prisma Client.
5. Run `npm run dev` to start the development server
## Run in a container
@@ -49,6 +57,53 @@ If you want to contribute financially and help us keep the application free and
3. Run `npm run start-container` to start the postgres and the spliit2 containers
4. You can access the app by browsing to http://localhost:3000
## Opt-in features
### Expense documents
Spliit offers users to upload images (to an AWS S3 bucket) and attach them to expenses. To enable this feature:
- Follow the instructions in the _S3 bucket_ and _IAM user_ sections of [next-s3-upload](https://next-s3-upload.codingvalue.com/setup#s3-bucket) to create and set up an S3 bucket where images will be stored.
- Update your environments variables with appropriate values:
```.env
NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS=true
S3_UPLOAD_KEY=AAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_SECRET=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
S3_UPLOAD_BUCKET=name-of-s3-bucket
S3_UPLOAD_REGION=us-east-1
```
You can also use other S3 providers by providing a custom endpoint:
```.env
S3_UPLOAD_ENDPOINT=http://localhost:9000
```
### Create expense from receipt
You can offer users to create expense by uploading a receipt. This feature relies on [OpenAI GPT-4 with Vision](https://platform.openai.com/docs/guides/vision) and a public S3 storage endpoint.
To enable the feature:
- You must enable expense documents feature as well (see section above). That might change in the future, but for now we need to store images to make receipt scanning work.
- Subscribe to OpenAI API and get access to GPT 4 with Vision (you might need to buy credits in advance).
- Update your environment variables with appropriate values:
```.env
NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT=true
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
### Deduce category from title
You can offer users to automatically deduce the expense category from the title. Since this feature relies on a OpenAI subscription, follow the signup instructions above and configure the following environment variables:
```.env
NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT=true
OPENAI_API_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
## License
MIT, see [LICENSE](./LICENSE).

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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
View 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,
]

View File

@@ -1,9 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [{ hostname: 'avatars.githubusercontent.com' }],
},
}
const { withPlausibleProxy } = require('next-plausible')
module.exports = withPlausibleProxy()(nextConfig)

5729
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,78 +1,53 @@
{
"name": "spliit2",
"name": "spliit-api",
"version": "0.1.0",
"private": true,
"main": "src/index.ts",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint",
"check-types": "tsc --noEmit",
"check-formatting": "prettier -c src",
"postinstall": "prisma migrate deploy && prisma generate",
"build-image": "./scripts/build-image.sh",
"start-container": "docker compose --env-file container.env up"
"prettier": "prettier -w src",
"postinstall": "prisma generate"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@hookform/resolvers": "^3.3.2",
"@prisma/client": "^5.6.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@react-email/heading": "^0.0.11",
"@react-email/html": "^0.0.7",
"@react-email/preview": "^0.0.8",
"@react-email/text": "^0.0.7",
"@tailwindcss/typography": "^0.5.10",
"@trpc/client": "^11.0.0-rc.586",
"@trpc/react-query": "^11.0.0-rc.586",
"@trpc/server": "^11.0.0-rc.586",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"content-disposition": "^0.5.4",
"dayjs": "^1.11.10",
"lucide-react": "^0.290.0",
"nanoid": "^5.0.4",
"next": "^14.0.4",
"next-plausible": "^3.12.0",
"next-themes": "^0.2.1",
"next13-progressbar": "^1.1.1",
"negotiator": "^0.6.3",
"openai": "^4.25.0",
"pg": "^8.11.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.47.0",
"resend": "^2.1.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"prisma": "^5.7.0",
"sharp": "^0.33.2",
"superjson": "^2.2.1",
"ts-pattern": "^5.0.6",
"uuid": "^9.0.1",
"vaul": "^0.8.0",
"zod": "^3.22.4"
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@total-typescript/ts-reset": "^0.5.1",
"@types/content-disposition": "^0.5.8",
"@types/negotiator": "^0.6.3",
"@types/node": "^20",
"@types/pg": "^8.10.9",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uuid": "^9.0.6",
"autoprefixer": "^10",
"dotenv": "^16.3.1",
"eslint": "^8",
"eslint-config-next": "^14.0.4",
"postcss": "^8",
"eslint": "^8.57.1",
"globals": "^15.11.0",
"prettier": "^3.0.3",
"prettier-plugin-organize-imports": "^3.2.3",
"prisma": "^5.7.0",
"tailwindcss": "^3",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"typescript-eslint": "^8.11.0"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "documentUrls" TEXT[] DEFAULT ARRAY[]::TEXT[];

View File

@@ -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[];

View File

@@ -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;

View 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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Expense" ADD COLUMN "notes" TEXT;

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "information" TEXT;

View File

@@ -14,9 +14,11 @@ datasource db {
model Group {
id String @id
name String
information String? @db.Text
currency String @default("$")
participants Participant[]
expenses Expense[]
activities Activity[]
createdAt DateTime @default(now())
}
@@ -37,20 +39,31 @@ model Category {
}
model Expense {
id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
id String @id
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
expenseDate DateTime @default(dbgenerated("CURRENT_DATE")) @db.Date
title String
category Category? @relation(fields: [categoryId], references: [id])
categoryId Int @default(0)
category Category? @relation(fields: [categoryId], references: [id])
categoryId Int @default(0)
amount Int
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
paidBy Participant @relation(fields: [paidById], references: [id], onDelete: Cascade)
paidById String
paidFor ExpensePaidFor[]
groupId String
isReimbursement Boolean @default(false)
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
isReimbursement Boolean @default(false)
splitMode SplitMode @default(EVENLY)
createdAt DateTime @default(now())
documents ExpenseDocument[]
notes String?
}
model ExpenseDocument {
id String @id
url String
width Int
height Int
Expense Expense? @relation(fields: [expenseId], references: [id])
expenseId String?
}
enum SplitMode {
@@ -69,3 +82,21 @@ model ExpensePaidFor {
@@id([expenseId, participantId])
}
model Activity {
id String @id
group Group @relation(fields: [groupId], references: [id])
groupId String
time DateTime @default(now())
activityType ActivityType
participantId String?
expenseId String?
data String?
}
enum ActivityType {
UPDATE_GROUP
CREATE_EXPENSE
UPDATE_EXPENSE
DELETE_EXPENSE
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -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

View File

@@ -3,6 +3,10 @@
SPLIIT_APP_NAME=$(node -p -e "require('./package.json').name")
SPLIIT_VERSION=$(node -p -e "require('./package.json').version")
docker buildx build --no-cache -t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} -t ${SPLIIT_APP_NAME}:latest .
# we need to set dummy data for POSTGRES env vars in order for build not to fail
docker buildx build \
-t ${SPLIIT_APP_NAME}:${SPLIIT_VERSION} \
-t ${SPLIIT_APP_NAME}:latest \
.
docker image prune -f

22
scripts/build.env Normal file
View 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

View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -euxo pipefail
npx prisma migrate deploy
npm run start

View File

@@ -1,5 +0,0 @@
#!/bin/bash
prisma migrate deploy
prisma generate
npm run dev

View File

@@ -1,4 +1,4 @@
result=$(docker ps | grep postgres)
result=$(docker ps | grep spliit-db)
if [ $? -eq 0 ];
then
echo "postgres is already running, doing nothing"
@@ -6,6 +6,6 @@ else
echo "postgres is not running, starting it"
docker rm postgres --force
mkdir -p postgres-data
docker run --name postgres -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v ./postgres-data:/var/lib/postgresql/data postgres
docker run --name spliit-db -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql/data" postgres
sleep 5 # Wait for postgres to start
fi

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -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 dont 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>
)
}

View File

@@ -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
}
}

View File

@@ -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}
/>
)
}

View File

@@ -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 doesnt 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',
})
}

View File

@@ -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"`,
},
})
}

View File

@@ -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}
/>
)
}

View File

@@ -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>
)
}

View File

@@ -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 }} />
</>
)
}

View File

@@ -1,9 +0,0 @@
import { redirect } from 'next/navigation'
export default async function GroupPage({
params: { groupId },
}: {
params: { groupId: string }
}) {
redirect(`/groups/${groupId}/expenses`)
}

View File

@@ -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 doesnt 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>
)
}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -1,7 +0,0 @@
'use server'
import { getGroups } from '@/lib/api'
export async function getGroupsAction(groupIds: string[]) {
'use server'
return getGroups(groupIds)
}

View File

@@ -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} />
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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)),
)
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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',
},
],
}
}

View File

@@ -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>
)
}

View File

@@ -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`,
}
}

View File

@@ -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,
},
]
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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)
}

View File

@@ -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 wont 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} />,
})
}

View File

@@ -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),
})

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
Well 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>
)
}

View File

@@ -1,13 +0,0 @@
'use client'
import { Next13ProgressBar } from 'next13-progressbar'
export function ProgressBar() {
return (
<Next13ProgressBar
height="2px"
color="#64748b"
options={{ showSpinner: false }}
showOnShallow
/>
)
}

View File

@@ -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
}

View File

@@ -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>
)
}

View File

@@ -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>
}

View File

@@ -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>
)
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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 }

View File

@@ -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,
}

Some files were not shown because too many files have changed in this diff Show More