Compare commits

..

226 Commits

Author SHA1 Message Date
andrzej e66ed6975d add explainers re editing
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-16 13:55:00 +02:00
andrzej 872c1fd53a describe why pubs are greyed out
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-16 12:13:44 +02:00
andrzej c8c29fab75 fix build errors
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-16 12:03:45 +02:00
andrzej 4459b9d644 grey out irrelevant pubs based on genre data
Gitea/subman-nextjs/pipeline/head Something is wrong with the build of this commit Details
2024-10-16 11:47:39 +02:00
andrzej ae4e1685c5 add more stories and pubs, show off pagination 2024-10-16 10:36:57 +02:00
andrzej c0f85e89fd style welcome screen
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-12 19:27:03 +02:00
andrzej f939c3896a fix build errors
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-12 19:19:03 +02:00
andrzej 39ba6901d1 better filter icon 2024-10-12 19:18:56 +02:00
andrzej e2b21601ea default to first *filterable* column
Gitea/subman-nextjs/pipeline/head There was a failure building this commit Details
2024-10-12 19:07:46 +02:00
andrzej 59a8f8bc41 make filter more intuitive, usable on mobile
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-12 18:23:04 +02:00
andrzej 01b98a0a08 conditonal row styling
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-04 22:23:24 +02:00
andrzej 3c3564bb29 only clean workspace when build fails 2024-10-04 22:23:02 +02:00
andrzej cda903175c make Jenkins install playwright browsers
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-04 21:45:30 +02:00
andrzej e1044b58b7 Merge branch 'tests'
Gitea/subman-nextjs/pipeline/head There was a failure building this commit Details
2024-10-04 21:41:06 +02:00
andrzej 84caa342f0 add test phase to jenkinsfile
Gitea/subman-nextjs/pipeline/head Something is wrong with the build of this commit Details
2024-10-04 21:36:58 +02:00
andrzej f9eb6254e3 login tests
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-04 19:02:54 +02:00
andrzej a5c40a7982 fix: redirect after login
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-04 18:47:41 +02:00
andrzej da361e0f55 continue button
Gitea/subman-nextjs/pipeline/head This commit looks good Details
for times when redirect doesn't work
2024-10-04 15:44:03 +02:00
andrzej 65c23d4872 landing page 2024-10-04 15:36:03 +02:00
andrzej e32df99446 make dot env file on build
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-04 15:11:59 +02:00
andrzej 885f5cd56c get env variables
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-04 14:53:55 +02:00
andrzej 392110f55c configure for root domain
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-04 13:05:17 +02:00
andrzej 0230118f4f generate db
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-04 12:19:40 +02:00
andrzej 4716264483 configure for subdomain
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-04 11:15:30 +02:00
andrzej 5fb913b6a3 local build works (including static) 2024-10-04 11:15:10 +02:00
andrzej 19109b2b35 set up local build environment for packaging 2024-10-04 10:36:00 +02:00
andrzej 447504667f fix: delete old standalone folder before making new one!
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-03 18:42:42 +02:00
andrzej 9179aab7d4 fix: use correct folder for tarball!
Gitea/subman-nextjs/pipeline/head There was a failure building this commit Details
2024-10-03 18:33:45 +02:00
andrzej 3b63bec700 clean only on failed build
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-03 18:23:42 +02:00
andrzej a5c6f9d3cb build in separate folder
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-03 18:18:19 +02:00
andrzej 740a57b30e clean jenkins workspace (post build)
Gitea/subman-nextjs/pipeline/head There was a failure building this commit Details
2024-10-03 18:09:32 +02:00
andrzej b16d293e27 un-ignore .next
Gitea/subman-nextjs/pipeline/head There was a failure building this commit Details
2024-10-03 17:49:56 +02:00
andrzej 01e7e20686 copy db files
Gitea/subman-nextjs/pipeline/head There was a failure building this commit Details
2024-10-03 17:14:59 +02:00
andrzej 1143cb5f22 ignore .next
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-03 17:10:17 +02:00
andrzej b9e94257b3 change static directory
Gitea/subman-nextjs/pipeline/head This commit looks good Details
cf this issue
https://github.com/vercel/next.js/issues/49283
2024-10-03 16:46:01 +02:00
andrzej 8836b71111 copy public and static
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-03 11:51:47 +02:00
andrzej 8995047aa5 set base url
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-03 11:29:05 +02:00
andrzej 0bade3c1c9 revert previous change (better to handle logging in script)
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-02 22:25:17 +02:00
andrzej 689364f3b6 add logging to upgrade script call
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-02 22:21:14 +02:00
andrzej 6ab29df9b6 make tar non-verbose
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-02 21:56:21 +02:00
andrzej 009433f483 exec server commands via ssh
Gitea/subman-nextjs/pipeline/head This commit is unstable Details
2024-10-02 17:31:02 +02:00
andrzej 78b599bfcf fix credentials
Gitea/subman-nextjs/pipeline/head This commit looks good Details
2024-10-01 12:25:48 +02:00
andrzej 5712e26e14 add links to previous attempt
Gitea/subman-nextjs/pipeline/head There was a failure building this commit Details
2024-10-01 12:14:34 +02:00
andrzej 395144cd65 ignore .env!
Gitea/subman-nextjs/pipeline/head There was a failure building this commit Details
2024-10-01 12:04:48 +02:00
andrzej 4c2c9c5680 Jenkinsfile 2024-10-01 11:58:50 +02:00
andrzej 058f406e2b readme 2024-10-01 10:54:59 +02:00
andrzej 3c4a999ef7 tidy up 2024-09-30 16:06:52 +02:00
andrzej db613357a7 fix: filtering 2024-09-30 16:06:41 +02:00
andrzej ca097dfb65 fix build errors 2024-09-30 14:44:47 +02:00
andrzej 20d211bdc4 new db entries 2024-09-30 12:06:44 +02:00
andrzej a20dbf3d9e clean up 2024-09-30 12:06:37 +02:00
andrzej 1c8a689b4c cleared db entries 2024-09-30 11:49:56 +02:00
andrzej 91a1bf468e misc styling 2024-09-30 11:49:47 +02:00
andrzej 5b4e961859 implement 'edit' functionality for pubs 2024-09-27 13:21:38 +02:00
andrzej 9bc1750848 style story form 2024-09-27 11:18:12 +02:00
andrzej 91e8a17525 style genre picker 2024-09-27 11:11:03 +02:00
andrzej a4f5467832 fix: show genres in edit story dialog 2024-09-27 11:05:29 +02:00
andrzej 6e501aa75f begin implementation of edit story functionality 2024-09-26 12:39:26 +02:00
andrzej b9d5cfc18d fix: janky login/logout refreshes and redirects 2024-09-26 10:48:50 +02:00
andrzej 454695ca1e style login page 2024-09-25 19:45:20 +02:00
andrzej fa62080e0c mobile friendly edit submission dialog 2024-09-25 19:39:12 +02:00
andrzej d2936f2a6b mobile friendly create submission dialog 2024-09-25 19:36:46 +02:00
andrzej 712c8da465 controlled create dialogs 2024-09-25 19:06:19 +02:00
andrzej 62d8f05d8a tweak refresh/notifications 2024-09-25 12:55:58 +02:00
andrzej bc675cd258 make subs page responsive 2024-09-25 10:31:34 +02:00
andrzej def21a236c render textinputcell only on large screens 2024-09-24 12:25:31 +02:00
andrzej d7a60b331c add more responsiveness 2024-09-23 18:18:05 +02:00
andrzej 2a2a994f90 Merge branch 'auth-third-attempt-JWT' of 192.168.0.184:andrzej/subman-nextjs into auth-third-attempt-JWT 2024-09-23 17:36:36 +02:00
andrzej d2815c7356 tailwind changews 2024-09-23 17:35:33 +02:00
andrzej 164097e821 add responsiveness 2024-09-23 14:15:26 +02:00
andrzej 35ac70b62b add responsiveness 2024-09-21 17:23:31 +02:00
andrzej f8bb05d8b9 logout functionality 2024-09-21 16:40:19 +02:00
andrzej f21159b849 update db 2024-09-20 16:05:54 +02:00
andrzej 158d73a6df trim 2024-09-20 16:00:05 +02:00
andrzej 3f22b2ce82 update sub server actions etc 2024-09-20 15:46:26 +02:00
andrzej c0f55be1b5 fix: update server actions 2024-09-20 15:19:31 +02:00
andrzej 0bb8eac362 fix: create server actions 2024-09-19 11:37:01 +02:00
andrzej c7dfbee0e0 redirect on login now works 2024-09-19 10:08:58 +02:00
andrzej 9eb558dc2f verifying jwt now works
issue - I was importing the textEncoder for browser, rather than using the native one
2024-09-16 14:55:34 +02:00
andrzej 9c2148076b login endpoint authentication now working 2024-09-16 13:17:15 +02:00
andrzej fff780922d begin debugging 2024-09-13 22:01:21 +02:00
andrzej 1d13d79682 make it a server component 2024-09-13 21:53:27 +02:00
andrzej 8dc7d08210 fix urls 2024-09-13 21:51:03 +02:00
andrzej f9f2a8352d fix callback 2024-09-13 21:50:41 +02:00
andrzej 5aa503a236 bcrypt fix
basically webpack tries to compile it which is not good
so I edited to next.config
2024-09-13 19:24:39 +02:00
andrzej 1861630bf3 checkout login page from previous attempt 2024-09-13 17:54:20 +02:00
andrzej a9e072ab5f create login api route 2024-09-13 12:21:45 +02:00
andrzej a204eec776 implement controlled edit / delete dialogs 2024-08-07 15:46:14 +02:00
andrzej e5d2aba207 optimize font 2024-08-06 12:52:15 +02:00
andrzej e9ed5ae9ea fix tablename 2024-08-06 12:51:23 +02:00
andrzej fdeeb955c9 improve title styling 2024-08-06 12:32:55 +02:00
andrzej c54720cc67 give each genrePicker cell a unique form id 2024-07-24 22:04:30 +02:00
andrzej 0586b33bcd improve genre picker cell 2024-07-24 17:24:04 +02:00
andrzej 5018422353 fix missing keys 2024-07-24 17:23:42 +02:00
andrzej 1b49672be6 implement genre picker cell (janky) 2024-07-23 17:40:35 +02:00
andrzej 6073a1dce5 move edit submission dialog to context menu 2024-07-21 19:14:20 +02:00
andrzej 2df9738364 edit submission, fix client side data validation 2024-07-20 14:07:13 +02:00
andrzej fb4911c067 edit submission functionality (partial) 2024-07-20 11:52:19 +02:00
andrzej 6e42145e88 rearrange sub cols 2024-07-04 18:31:22 +02:00
andrzej b69a172dd5 delete unused imports 2024-07-03 00:41:20 +02:00
andrzej edfee2c35d make inputs work with Enter 2024-07-02 23:01:26 +02:00
andrzej 83efc850d3 fix added space 2024-07-02 22:52:10 +02:00
andrzej 356c487ed7 Revert "make form input work with number and string"
This reverts commit 2294d0c0b0.
2024-07-02 22:16:17 +02:00
andrzej 349a191d12 make form input work with number and string 2024-07-02 19:49:00 +02:00
andrzej 0138e1aa2a make fields open with space (to avoid conflicts) 2024-07-02 17:18:23 +02:00
andrzej 380fc56d17 fix enter key buy (partially) 2024-07-02 17:11:30 +02:00
andrzej 23058ed48b implement number input data validation (basic functionality) 2024-07-01 17:23:48 +02:00
andrzej 773633d103 implement inline number input 2024-06-30 23:28:05 +02:00
andrzej 6378b358ed make update function concise/flexible 2024-06-30 23:21:48 +02:00
andrzej 0e0c2a71ea improve tab nav 2024-06-30 20:28:02 +02:00
andrzej 52578b7979 add get method 2024-06-30 20:15:57 +02:00
andrzej 6b9a8335de add open handler 2024-06-30 20:15:30 +02:00
andrzej e272894918 remove unneccessary buttons 2024-06-30 17:42:33 +02:00
andrzej a2682f4f05 implement inline text input 2024-06-30 17:36:44 +02:00
andrzej 24ddda4a9e select checkboxes 2024-06-30 14:22:35 +02:00
andrzej d32b689fbb begin implementation of edit feature 2024-06-29 21:38:21 +02:00
andrzej 221323ae83 add deselect option to context menu 2024-06-29 16:26:46 +02:00
andrzej e8c37f794d fix multi-delete 2024-06-27 23:12:53 +02:00
andrzej 2006641bb4 partially implement multi-delete button 2024-06-27 16:49:56 +02:00
andrzej 7a2d536318 enable row selection 2024-06-27 16:08:14 +02:00
andrzej 9bf60c2282 add multi delete function 2024-06-27 16:08:03 +02:00
andrzej d07d54731d fix layout for smaller (laptop) screens 2024-06-27 15:35:48 +02:00
andrzej eb2ecf6618 context menu -- keyboard navigation 2024-06-27 12:44:31 +02:00
andrzej 4f7d12edf5 context menu initial import 2024-06-26 22:52:33 +02:00
andrzej 21c0a1db6b neaten labels, capitalizations 2024-06-26 21:41:52 +02:00
andrzej c8cd1069da fix focus issues on nested form items
now aria compliant!
2024-06-26 21:40:03 +02:00
andrzej ae759de164 implement basic create submission popover functionality 2024-06-26 19:55:18 +02:00
andrzej 61d956b9cd implement create pubs popup 2024-06-26 19:33:12 +02:00
andrzej 527b0d2aac extrapolate genre picker 2024-06-26 18:21:06 +02:00
andrzej d741901afd add createStoryDialog 2024-06-26 18:19:09 +02:00
andrzej 483b9d987a extrapolate create function 2024-06-26 18:18:44 +02:00
andrzej 9c9b010dc1 make table clickable 2024-06-26 14:54:04 +02:00
andrzej b1de1477a6 add edit button 2024-06-26 12:45:58 +02:00
andrzej 5058f5192e disable inspect button for submissions 2024-06-26 12:42:18 +02:00
andrzej d0b7dd5910 clean up 2024-06-26 12:07:33 +02:00
andrzej 7912104e77 add redirect 2024-06-26 12:06:55 +02:00
andrzej 9ac1a7c288 clean up story inspect page 2024-06-26 12:06:12 +02:00
andrzej 9cebe4c2f6 add pub inspect page 2024-06-26 12:05:58 +02:00
andrzej 2fef5ae1cb charts 2024-06-25 22:50:54 +02:00
andrzej 854487a2f2 add test data to db 2024-06-25 12:21:07 +02:00
andrzej 15a1309275 improve client side data validation 2024-06-25 12:20:56 +02:00
andrzej ae25aca0e8 properly render null date values 2024-06-25 12:20:41 +02:00
andrzej 9c0423d4e8 delete unneccessary 2024-06-25 12:20:28 +02:00
andrzej d5cac57485 clearer headings 2024-06-25 12:20:10 +02:00
andrzej f2231ea24d fix create pages 2024-06-25 11:18:30 +02:00
andrzej 8e8228de16 fix pub create form styling 2024-06-24 23:15:48 +02:00
andrzej 2605226e0e add loaders 2024-06-24 23:15:29 +02:00
andrzej a89535c058 extrapolate loader 2024-06-24 22:27:36 +02:00
andrzej d3689ab4b2 use Link instead of onClick in data-table 'create button' 2024-06-24 19:01:11 +02:00
andrzej 06650dcb22 style field labels 2024-06-24 18:57:35 +02:00
andrzej 7bdfe64acd style main header 2024-06-24 18:57:25 +02:00
andrzej 7c33b50a40 misc styling 2024-06-24 18:50:16 +02:00
andrzej 0b4396a25f delete unused 2024-06-24 18:33:59 +02:00
andrzej 78b07861d9 style create forms 2024-06-24 18:33:03 +02:00
andrzej 944864eae2 style input 2024-06-24 18:29:03 +02:00
andrzej 66f5b6ff35 add styled component for create pages, implement at create/story 2024-06-24 12:29:02 +02:00
andrzej 7e7b25cd79 db changes 2024-06-24 12:28:24 +02:00
andrzej 0adc8a5c25 remove unused import 2024-06-24 11:56:06 +02:00
andrzej ad613517f4 merge navlinks styling 2024-06-24 11:55:31 +02:00
andrzej f6784cca29 add mode toggle 2024-06-24 11:55:19 +02:00
andrzej 4ffa702471 make badge more legible 2024-06-24 11:53:48 +02:00
andrzej ff524ac058 add tabletype 2024-06-24 10:27:53 +02:00
andrzej 37ae474de7 style inspect button 2024-06-24 10:16:05 +02:00
andrzej 1a7d439e30 extrapolate actions column 2024-06-22 20:28:21 +02:00
andrzej c339aa5002 improve nav and layout styling 2024-06-22 18:12:55 +02:00
andrzej ef1fba1187 import globals.css NOT tailwind.css!
I was scratching my head about this for ages
2024-06-22 17:51:15 +02:00
andrzej 771534c3d9 correct content field 2024-06-22 17:30:11 +02:00
andrzej fd3b9ac2b4 remove unused import 2024-06-22 17:29:31 +02:00
andrzej a78a2fa260 add create link 2024-06-22 17:29:14 +02:00
andrzej 0670fe87ea install color themes, tweak styling 2024-06-21 00:31:48 +02:00
andrzej f3d221e517 style layout 2024-06-20 23:21:37 +02:00
andrzej 054ba0b224 style navlinks 2024-06-20 20:02:25 +02:00
andrzej 1950e31cfe add logo 2024-06-20 12:54:56 +02:00
andrzej cee081fe61 style layoutr 2024-06-20 12:49:24 +02:00
andrzej 8975263c47 implement basic story[id] page 2024-06-20 12:29:56 +02:00
andrzej bf126255d8 implement submissions table 2024-06-20 11:39:35 +02:00
andrzej 28f85bd714 implement publications table 2024-06-20 10:35:25 +02:00
andrzej 588c37e68b notice that filter doesn't work for nested objects 2024-06-19 23:51:55 +02:00
andrzej f662ae8719 install radix dialog 2024-06-19 23:22:13 +02:00
andrzej 408fbad5fd add layout links 2024-06-19 23:21:56 +02:00
andrzej 4a5e2e4445 add delete dialog 2024-06-19 23:21:51 +02:00
andrzej ddaf2bf1c4 add inspect button 2024-06-19 22:11:09 +02:00
andrzej 99d693df6f better delete icon 2024-06-19 21:58:19 +02:00
andrzej 2dc4bb2279 install badge 2024-06-19 21:53:42 +02:00
andrzej 7df1ffdfaf fix post-delete revalidation 2024-06-19 21:53:36 +02:00
andrzej e0b47622ce partially implement del function 2024-06-19 19:46:30 +02:00
andrzej 37aad4605c add deleteStory function, fix schema to allow it 2024-06-19 18:01:42 +02:00
andrzej 53fbddd846 clean up imports 2024-06-19 13:14:41 +02:00
andrzej d34fb324bd style table 2024-06-19 13:14:23 +02:00
andrzej e05142999f add genre badges 2024-06-19 13:14:12 +02:00
andrzej 4b7549020e normalize typing 2024-06-19 13:13:54 +02:00
andrzej 5de197dfe0 merge in laptop work
Merge branch 'main' of 192.168.0.184:andrzej/subman-nextjs
2024-06-19 12:40:06 +02:00
andrzej 74c8e04fcb tweaks 2024-06-19 11:54:07 +02:00
andrzej bd4a690d95 split getStory function
it's a waste to be fetching genres unless we're going to use them
2024-06-19 11:53:58 +02:00
andrzej 4353536986 add definablei, reusable filtering 2024-06-19 11:34:57 +02:00
andrzej f3472d2a55 add fetched data to story table 2024-06-19 11:33:53 +02:00
andrzej a8f68ff737 include genres in getStory api call 2024-06-19 11:33:27 +02:00
andrzej a8a4538513 db updates 2024-06-17 23:31:24 +02:00
andrzej f1422b5b24 add create server actions 2024-06-17 23:23:09 +02:00
andrzej 0d25299ba6 extracted genre picker experiment
I need to properly handle refs for this to work, but this may be more trouble than it's worth
2024-06-17 22:56:24 +02:00
andrzej 3d571a871e delete test entries 2024-06-17 13:52:09 +02:00
andrzej 0a5095bd5f add createStory server action 2024-06-17 13:51:24 +02:00
andrzej a5e5c8c246 add radix components to publication create form 2024-06-17 12:41:21 +02:00
andrzej b1da7a6856 Revert "add fully abstracted genrePicker"
This reverts commit 3a56b1f31e.
2024-06-17 12:20:52 +02:00
andrzej 9e01f1d4ad add fully abstracted genrePicker 2024-06-17 10:47:44 +02:00
andrzej a650bd7183 abstract genre checkbox components 2024-06-16 23:08:17 +02:00
andrzej 7a7cd39f6e improve genres popover 2024-06-16 17:16:43 +02:00
andrzej c0428e2312 add rudimentary popover for checkboxes 2024-06-14 22:44:13 +02:00
andrzej 6f97e243e2 make checkboxes submit correct data 2024-06-14 22:41:41 +02:00
andrzej 0c4b3167ce working shadcn date picker 2024-06-14 11:42:31 +02:00
andrzej 4cb077e7b9 radix select boxes working 2024-06-14 11:25:18 +02:00
andrzej 87ec3c99c4 still trying to get shadcn selects to work dynamically 2024-06-13 21:52:44 +02:00
andrzej 4f53932f9c add dynamically fetched checkboxes 2024-06-13 12:11:09 +02:00
andrzej 6e640b3181 trial shadcn form 2024-06-12 17:53:19 +02:00
andrzej 114d59e9ed build / import table 2024-06-12 17:15:22 +02:00
andrzej 9585c4843f install and setup shadcn 2024-06-12 14:59:31 +02:00
andrzej c0ec0b0940 install and set up tailwind 2024-06-12 14:48:37 +02:00
andrzej bbea849de9 move get functions to lib 2024-06-12 12:19:44 +02:00
andrzej 34a18cae54 add forms+ 2024-06-12 11:32:15 +02:00
andrzej 877fc08bd6 add createStory action 2024-06-11 19:21:14 +02:00
andrzej 43b75b53b8 add create story page 2024-06-11 19:14:30 +02:00
andrzej f186f32846 fix imports 2024-06-11 19:14:08 +02:00
andrzej 495d6e2311 rename db with .ts extension
(it turns out typescript/nextjs is buggy with mjs)
2024-06-11 19:13:37 +02:00
andrzej db26cb602c add lettercase function 2024-06-11 19:12:52 +02:00
andrzej 7d5540a5f9 attempt to fix imports 2024-06-11 17:17:01 +02:00
andrzej f1d53b93b6 prisma env 2024-06-11 15:37:41 +02:00
70 changed files with 2208 additions and 1896 deletions

7
.env
View File

@ -1,7 +0,0 @@
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL="file:./dev.db"

12
.gitignore vendored
View File

@ -34,3 +34,15 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
#secret
.env
#build
/pack
subman.tar.gz
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

42
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,42 @@
pipeline {
agent any
environment{
JWT_SECRET=credentials('JWT_SECRET')
DATABASE_URL=credentials('DATABASE_URL')
}
stages{
stage('build'){
steps{
sh 'echo "JWT_SECRET=${JWT_SECRET}" | cat >> .env'
sh 'echo "DATABASE_URL=${DATABASE_URL}" | cat >> .env'
sh 'npm install'
sh 'npm run build'
}
}
stage('test'){
steps{
sh 'npx playwright install'
sh 'npx playwright test'
sh 'rm -r pack'
}
}
stage('deploy'){
steps{
sshPublisher(publishers: [sshPublisherDesc(configName: 'Demos', transfers: [sshTransfer(cleanRemote: false, excludes: '', execCommand: 'ssh-uploads/subman/upgrade.sh', execTimeout: 120000, flatten: false, makeEmptyDirs: false, noDefaultExcludes: false, patternSeparator: '[, ]+', remoteDirectory: 'ssh-uploads/subman/', remoteDirectorySDF: false, removePrefix: '', sourceFiles: 'subman.tar.gz')], usePromotionTimestamp: false, useWorkspaceInPromotion: false, verbose: false)])
}
}
}
post {
// Clean after build
always {
cleanWs(cleanWhenNotBuilt: true,
cleanWhenFailure: false,
deleteDirs: true,
disableDeferredWipeout: true,
// notFailBuild: true,
cleanWhenSuccess:false,
patterns: [[pattern: '**/*', type: 'INCLUDE'],
[pattern: '.propsfile', type: 'EXCLUDE']])
}
}
}

View File

@ -1,36 +1,18 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). # Subman
## A self-hosted literary submission manager
## Getting Started I developed this project as a demonstration of my full-stack development abilities, utilizing:
First, run the development server: - Nextjs
- Tailwind
- heavily customised Shadcn components
- an Sqlite database with Prisma ORM as intermediary
```bash My previous attempt at this project was [a Nodejs server](https://projects.ajstepien.xyz/andrzej/sub-manager-backend) with [ a React frontend ](https://projects.ajstepien.xyz/andrzej/sub-manager-frontend), but this version is much better!
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ## What it does
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. Subman was inspired by my experiences submitting short fiction to magazines for publication. It allows the user to track where submissions are pending, in addition to meta-data such as genres, word count and so on. What you see here is the Minimum Shippable Product.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -1,7 +1,9 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone",
// basePath: "/subman",
webpack: (config) => { webpack: (config) => {
config.externals.push("@node-rs/argon2", "@node-rs/bcrypt"); config.externals = [...config.externals, "bcrypt"];
return config; return config;
}, },
}; };

1044
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,14 +5,15 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build && ./package.sh",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"tailwind": "npx tailwindcss -i ./src/app/globals.css -o ./src/app/tailwind.css --watch" "tailwind": "npx tailwindcss -i ./src/app/globals.css -o ./src/app/tailwind.css --watch"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.6.0", "@hookform/resolvers": "^3.6.0",
"@lucia-auth/adapter-prisma": "^4.0.1", "@mapbox/node-pre-gyp": "^1.0.11",
"@next/env": "^14.2.14",
"@prisma/client": "^5.15.0", "@prisma/client": "^5.15.0",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-context-menu": "^2.2.1",
@ -30,12 +31,12 @@
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"lucia": "^3.2.0", "jose": "^5.8.0",
"lucide": "^0.445.0",
"lucide-react": "^0.394.0", "lucide-react": "^0.394.0",
"next": "14.2.3", "next": "^14.2.13",
"next-auth": "^4.24.7",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"oslo": "^1.2.1", "playwright": "^1.47.2",
"react": "^18", "react": "^18",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18", "react-dom": "^18",
@ -43,11 +44,12 @@
"recharts": "^2.12.7", "recharts": "^2.12.7",
"tailwind-merge": "^2.3.0", "tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2", "text-encoding": "^0.7.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.16.5", "@playwright/test": "^1.47.2",
"@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",

13
package.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
mkdir pack
cp -r public pack/
cp -r prisma pack/
cp -rT .next/standalone pack/
cp -r .next/static pack/.next/
cd pack
npx prisma db push
cd ..
tar -cf subman.tar.gz pack

79
playwright.config.ts Normal file
View File

@ -0,0 +1,79 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'node pack/server.js',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
},
});

Binary file not shown.

View File

@ -1,6 +0,0 @@
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL
);

View File

@ -12,16 +12,9 @@ datasource db {
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
sessions Session[]
email String email String
password String password String
}
model Session {
id Int @id @default(autoincrement())
expiresAt DateTime
userId Int
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
} }
model Story { model Story {

View File

@ -1,27 +1,24 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient() const prisma = new PrismaClient();
async function main() { async function main() {
// ... you will write your Prisma Client queries here // ... you will write your Prisma Client queries here
const story = await prisma.story.update({ const story = await prisma.story.update({
where: { id: 1 }, where: { id: 1 },
data: { data: {
title: "Ghost Aliens of Mars", title: "Ghost Aliens of Mars",
genres: { set: [{ id: 1 }, { id: 2 }], create: { name: "alien-punk" } } genres: { set: [{ id: 1 }, { id: 2 }], create: { name: "alien-punk" } },
} },
});
})
console.log(story)
} }
main() main()
.then(async () => { .then(async () => {
await prisma.$disconnect() await prisma.$disconnect();
}) })
.catch(async (e) => { .catch(async (e) => {
console.error(e) console.error(e);
await prisma.$disconnect() await prisma.$disconnect();
process.exit(1) process.exit(1);
}) });

View File

@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( 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", "peer h-3 w-3 sm:h-6 sm:w-6 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 className
)} )}
{...props} {...props}

View File

@ -73,7 +73,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", "h-12 md:px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className className
)} )}
{...props} {...props}
@ -87,7 +87,7 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} className={cn("md:p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props} {...props}
/> />
)) ))

145
src/app/api/auth/actions.ts Normal file
View File

@ -0,0 +1,145 @@
"use server"
import prisma from 'app/lib/db';
import { jwtVerify, JWTPayload, decodeJwt, SignJWT } from 'jose';
import { cookies } from 'next/headers';
import { loginSchema, LoginSchema } from 'app/login/schema';
export async function getJWTSecretKey<Uint8Array>() {
const secret = process.env.JWT_SECRET
if (!secret) throw new Error("There is no JWT secret key")
try {
const enc = new TextEncoder().encode(secret)
return enc
} catch (error) {
throw new Error("failed to getJWTSecretKey", error.message)
}
}
export async function verifyJwt(token: string): Promise<JWTPayload | null> {
try {
const key = await getJWTSecretKey()
const { payload } = await jwtVerify(token, key)
return payload
} catch {
return null
}
}
export async function getJwt() {
const cookieStore = cookies()
const token = cookieStore.get("token")
if (token) {
try {
const payload = await verifyJwt(token.value)
if (payload) {
const authPayload = {
email: payload.email as string,
iat: payload.iat as number,
exp: payload.exp as number
}
return authPayload
}
} catch (error) {
return null
}
}
return null
}
export async function logout() {
const cookieStore = cookies()
const token = cookieStore.get('token')
if (token) {
//empty catch swallows errors
try {
cookieStore.delete('token')
} catch { }
}
const userData = cookieStore.get("userData")
if (userData) {
try {
cookieStore.delete('userData')
return true
} catch (_) { }
}
//return false if there is no userdata
return null
}
export async function setUserDataCookie(userData) {
const cookieStore = cookies();
cookieStore.set({
name: 'userData',
value: JSON.stringify(userData),
path: '/',
maxAge: 3600,
sameSite: 'strict'
})
}
export async function login(userLogin: LoginSchema) {
const isSafe = loginSchema.safeParse(userLogin)
try {
if (!isSafe.success) throw new Error("parse failed")
const user = await prisma.user.findFirst({ where: { email: userLogin.email } })
if (!user) throw new Error("user does not exist")
const bcrypt = require("bcrypt");
const passwordIsValid = await bcrypt.compare(userLogin.password, user.password)
if (!passwordIsValid) throw new Error("password is not valid")
return { email: userLogin.email }
} catch (error) {
console.error("WHOOPS", error)
throw new Error('login failed')
}
}
export async function jwtExpires() {
}

View File

@ -1,18 +0,0 @@
import prisma from "app/lib/db";
import bcrypt from 'bcrypt';
export type User = {
id?: number,
email: string,
password: string,
}
export default async function authenticate(clientUser: User) {
const dbUser: User = await prisma.user.findFirst({ where: { email: clientUser.email } })
if (!dbUser) return false
const passwordMatches = await
bcrypt.compare(clientUser.password, dbUser.password)
if (!passwordMatches) return false
return true
}

View File

@ -1,65 +0,0 @@
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { Argon2id } from 'oslo/password';
import { lucia } from 'app/lib/lucia';
import prisma from 'app/lib/db';
import { toast } from '@/components/ui/use-toast';
export type User = {
id?: number,
email: string,
password: string,
}
const signIn = async (formData: FormData) => {
console.log("signIn called!")
const clientUser: User = {
email: formData.get("email") as string,
password: formData.get("password") as string
}
try {
const user = await prisma.user.findFirst({
where: { email: clientUser.email },
});
if (!user) {
console.log("NOT A USER")
// https://www.robinwieruch.de/next-forms/
toast({
title: "Whoops!",
description: "Username not recognised!"
})
}
const validPassword = await new Argon2id().verify(
user.password,
clientUser.password
);
console.log(`VALID PASSWORD: ${validPassword}`)
if (!validPassword) {
// https://www.robinwieruch.de/next-forms
console.log("NOT A VALID PASSWORD!")
throw new Error('Incorrect email or password');
}
const session = await lucia.createSession(user.id.toString(), {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
);
} catch (error) {
// TODO: add error feedback yourself
// https://www.robinwieruch.de/next-forms/
}
redirect('/submission');
};
export { signIn };

View File

@ -0,0 +1,65 @@
import { NextResponse, NextRequest } from "next/server";
import prisma from "app/lib/db";
import { SignJWT } from "jose";
import { getJWTSecretKey, login, setUserDataCookie } from "../actions";
export interface UserLoginRequest {
email: string
password: string
}
//render route afresh every time
const dynamic = 'force-dynamic'
//POST endpoint
export async function POST(request: NextRequest) {
const body = await request.json()
const { email, password } = body
if (!email || !password) {
const res = {
succes: false,
message: 'Email or password missing'
}
return NextResponse.json(res, { status: 400 })
}
try {
//fetch user from db, throw if email or password are invalid
const user = await login({ email, password })
//create and sign JWT
const token = await new SignJWT({
...user
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(await getJWTSecretKey())
//make response
const res = { success: true }
const response = NextResponse.json(res)
//Store jwt as secure http-only cookie
response.cookies.set({
name: 'token',
value: token,
path: '/', //defines where the cookie can be accessed - in this case, site wide
maxAge: 3600, //1 hour
httpOnly: true,
sameSite: 'strict'
})
//Store public user data as cookie
setUserDataCookie(user)
return response
} catch (error) {
console.error(error)
const res = { success: false, message: error.message || 'something went wrong' }
return NextResponse.json(res, { status: 500 })
}
}

View File

@ -0,0 +1,20 @@
import { revalidatePath } from "next/cache";
import { NextRequest } from "next/server";
import { logout } from "../actions";
const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
await logout()
revalidatePath('/login')
const response = {
success: true,
message: 'Logged out successfully',
};
return new Response(JSON.stringify(response), {
headers: {
'Content-Type': 'application/json',
},
});
}

View File

@ -6,6 +6,8 @@ import "./globals.css";
import Navlinks from "./ui/navLinks"; import Navlinks from "./ui/navLinks";
import { ModeToggle } from "./ui/modeToggle"; import { ModeToggle } from "./ui/modeToggle";
import { inter } from "./ui/fonts"; import { inter } from "./ui/fonts";
import LogoutButton from "./ui/logoutButton";
@ -15,7 +17,6 @@ export const metadata: Metadata = {
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
@ -30,21 +31,23 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<div id="layout-container" className="p-4 w-screen h-screen mt-6 flex justify-center"> <div id="layout-container" className="md:p-4 w-screen h-screen mt-2 md:mt-6 flex justify-center">
<div className="w-5/6 flex"> <div className="w-full md:w-5/6 flex flex-col md:flex-row">
<div id="sidebar" className="h-5/6 flex flex-col"> <header className=""> <div id="sidebar" className=" flex flex-row md:flex-col justify-between items-center"> <header className="">
<h1 className="font-black text-4xl text-primary-foreground bg-primary antialiased w-full p-2 rounded-tl-3xl pl-6 pr-4">SubMan</h1> <h1 className="font-black text-primary-foreground bg-primary antialiased w-full p-2 rounded-tl-3xl pl-6 pr-4 text-sm sm:text-4xl
<p className="mt-2 mx-1 text-sm antialiased w-40">The self-hosted literary submission tracker.</p> ">SubMan</h1>
<p className="mt-2 mx-1 text-sm antialiased w-40 hidden md:block">The self-hosted literary submission tracker.</p>
</header> </header>
<Navlinks className="mt-6" /> <Navlinks className="md:mt-6" />
<footer className="mt-auto"><ModeToggle /></footer> <footer className="my-auto md:mt-auto flex justify-center"><ModeToggle /><LogoutButton />
</footer>
</div> </div>
<div className="flex justify-center w-full"> <div className="flex justify-center w-screen">
{children} {children}
</div> </div>
</div> </div>
</div> </div>
<Toaster /> <Toaster test-id="toast" />
</ThemeProvider> </ThemeProvider>
</body> </body>
</html > </html >

View File

@ -1,59 +1,80 @@
"use server" "use server"
import { Genre, Story } from "@prisma/client" import { Pub, Story, Sub } from "@prisma/client"
import prisma from "./db" import prisma from "./db"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation" import { z } from "zod"
import { pubSchema } from "app/ui/forms/schemas"
import { subSchema } from "app/ui/forms/schemas"
import { prepGenreData, prepStoryData } from "./validate"
export async function createStory(data: Story & { genres: number[] }) {
export async function createStory({ story, genres }: { story: Story, genres: number[] }): Promise<{ success: string }> {
// will return undefined if middleware authorization fails
"use server" "use server"
const genresArray = data.genres.map((e) => { return { id: e } }) try {
const res = await prisma.story.create({ const storyData = await prepStoryData(story)
data: { const genresArray = await prepGenreData(genres)
title: data.title,
word_count: data.word_count, //submit
} const res = await prisma.story.create({ data: storyData })
}) await prisma.story.update({
console.log(res) where: { id: res.id },
const genresRes = await prisma.story.update({ data: {
where: { id: res.id }, genres: { set: genresArray }
data: { }
genres: { set: genresArray } })
} revalidatePath("/story")
}) return { success: `Created the story '${story.title}'.` }
console.log(genresRes) } catch (error) {
revalidatePath("/story") console.error(error)
redirect("/story") }
} }
export async function createPub(data) { export async function createPub({ pub, genres }: { pub: Pub, genres: number[] }): Promise<{ success: string }> {
"use server" "use server"
const genresArray = data.genres.map(e => { return { id: e } }) const genresArray = genres.map(e => { return { id: e } })
const res = await prisma.pub.create({
data: { //prepare schemas
title: data.title, const schema = pubSchema.omit({ genres: true })
link: data.link, const genreSchema = z.object({ id: z.number() })
query_after_days: data.query_after_days const genresSchema = z.array(genreSchema)
}
}) try {
console.log(res)
const genresRes = await prisma.pub.update({ //validate
where: { id: res.id }, schema.parse(pub)
data: genresSchema.safeParse(genresArray)
{ genres: { set: genresArray } }
}) //submit
console.log(genresRes) const res = await prisma.pub.create({
revalidatePath("/publication") data: pub
redirect("/publication") })
const genresRes = await prisma.pub.update({
where: { id: res.id },
data:
{ genres: { set: genresArray } }
})
revalidatePath("/publication")
return { success: `Created the publication '${pub.title}'.` }
} catch (error) {
console.error(error)
}
} }
export async function createSub(data) { export async function createSub(data: Sub): Promise<Sub | boolean> {
"use server" "use server"
const res = await prisma.sub.create({ data }) try {
console.log(res) subSchema.parse(data)
revalidatePath("/submission") const res = await prisma.sub.create({ data })
redirect("/submission") revalidatePath("/submission")
return res
} catch (error) {
console.error(error)
return false
}
} }

View File

@ -10,24 +10,34 @@ const tableMap = {
"/submission": "sub" "/submission": "sub"
} }
export async function deleteRecord(id: number, pathname: Pathname) { export async function deleteRecord(id: number, pathname: string): Promise<undefined | boolean> {
const table = tableMap[pathname] const table = tableMap[pathname]
const res = await prisma[table].delete({ where: { id } }) try {
console.log(`deleted from ${table}: ${res.id}`) //@ts-ignore
console.log("revalidating: " + pathname) const res = await prisma[table].delete({ where: { id } })
revalidatePath(pathname)
redirect(pathname)
}
export async function deleteRecords(ids: number[], pathname: "/story" | "/publication" | "/submission") {
const table = tableMap[pathname]
ids.forEach(async (id) => {
const res = await prisma[table].delete({
where: { id }
})
console.log(`deleted from ${table}: ${res.id}`) console.log(`deleted from ${table}: ${res.id}`)
}) revalidatePath(pathname)
revalidatePath(pathname) return true
redirect(pathname) } catch (error) {
console.error(error)
return undefined
}
}
export async function deleteRecords(ids: number[], pathname: "/story" | "/publication" | "/submission"): Promise<boolean | undefined> {
try {
const table = tableMap[pathname]
ids.forEach(async (id) => {
const res = await prisma[table].delete({
where: { id }
})
console.log(`deleted from ${table}: ${res.id}`)
})
revalidatePath(pathname)
return true
} catch (error) {
console.error(error)
return undefined
}
} }

View File

@ -1,15 +0,0 @@
"use server"
import prisma from "./db"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { SubForm } from "app/ui/forms/sub"
export async function editSubmission(data: SubForm) {
const res = await prisma.sub.update({
where: { id: data.id },
data
})
console.log(`updated ${data} to ${res}`)
revalidatePath("/submission")
redirect("/submission")
}

14
src/app/lib/filterFns.ts Normal file
View File

@ -0,0 +1,14 @@
import { Genre } from "@prisma/client";
import { FilterFn, Row } from "@tanstack/react-table";
export const genrePickerFilterFn = (row: Row<any>, columnId: string, filterValue: any) => {
const genres: Genre[] = row.getValue(columnId)
for (let index = 0; index < genres.length; index++) {
if (genres[genres.length - 1].name.includes(filterValue)) {
return true
}
}
return false
}

View File

@ -1,34 +0,0 @@
import prisma from './db';
import { Lucia } from 'lucia';
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
const adapter = new PrismaAdapter(prisma.session, prisma.user);
export const lucia = new Lucia(adapter, {
sessionCookie: {
// this sets cookies with super long expiration
// since Next.js doesn't allow Lucia to extend cookie expiration when rendering pages
expires: false,
attributes: {
// set to `true` when using HTTPS
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes: (attributes) => {
return {
// attributes has the type of DatabaseUserAttributes
email: attributes.email,
};
},
});
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
email: string;
}

View File

@ -1,4 +1,4 @@
export default function pluralize(word: "story" | "publication" | "submission"): string { export default function pluralize(word: string): string {
const map = { const map = {
story: "stories", story: "stories",
publication: "publications", publication: "publications",

View File

@ -1,30 +1,113 @@
"use server" "use server"
import { Genre } from "@prisma/client" import { prepGenreData, prepPubData, prepStoryData } from "./validate"
import { Genre, Pub, Story, Sub } from "@prisma/client"
import prisma from "./db" import prisma from "./db"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation" import { subSchema } from "app/ui/forms/schemas"
import { SubForm } from "app/ui/forms/sub"
export async function updateField({ datum, table, column, id, pathname }: { datum?: string | number | Genre[], table: string, column: string, id: number, pathname: string }) { export async function updateField({ datum, table, column, id, pathname }: { datum?: string | number | Genre[], table: string, column: string, id: number, pathname: string }) {
const res = await prisma[table].update({ "use server"
where: { id }, try {
data: { const res = await prisma[table].update({
[column]: datum where: { id },
} data: {
}) [column]: datum
console.log(`updated record in ${table}: ${JSON.stringify(res)}`) }
revalidatePath(pathname) })
redirect(pathname) console.log(`updated record in ${table}: ${JSON.stringify(res)}`)
revalidatePath(pathname)
return res
} catch (error) {
console.error(error)
return null
}
} }
export async function updateGenres({ genres, table, id, pathname }: { genres: { id: number }[], table: string, id: number, pathname: string }) { export async function updateGenres({ genres, table, id, pathname }: { genres: { id: number }[], table: string, id: number, pathname: string }) {
const res = await prisma[table].update({ "use server"
where: { id }, try {
data: { const res = await prisma[table].update({
genres: { set: genres } where: { id },
} data: {
}) genres: { set: genres }
console.log(`updated record in ${table}: ${JSON.stringify(res)}`) }
revalidatePath(pathname) })
redirect(pathname) console.log(`updated record in ${table}: ${JSON.stringify(res)}`)
revalidatePath(pathname)
return res
} catch (error) {
console.error(error)
return null
}
} }
export async function updateSub(data: SubForm): Promise<Sub> {
"use server"
try {
subSchema.parse(data)
const res = await prisma.sub.update({ where: { id: data.id }, data })
revalidatePath("submission")
return res
} catch (error) {
console.error(error)
return null
}
}
export async function updateStory(data: Story & { genres: number[] }): Promise<{ success: string }> {
"use server"
try {
//prep and validate
const storyData = await prepStoryData(data)
const genresArray = await prepGenreData(data.genres)
//submit
const res = await prisma.story.update({
where: { id: data.id },
data: storyData
})
const genreRes = await prisma.story.update({
where: { id: data.id },
data: {
genres: { set: genresArray }
}
})
return { success: "Updated the story '" + res.title + "'." }
} catch (error) {
console.error(error)
return null
}
}
export async function updatePub(data: Pub & { genres: number[] }): Promise<{ success: string }> {
"use server"
try {
//prep and validate
const pubData = await prepPubData
(data)
const genresArray = await prepGenreData(data.genres)
//submit
const res = await prisma.pub.update({
where: { id: data.id },
data: pubData
})
await prisma.pub.update({
where: { id: data.id },
data: {
genres: { set: genresArray }
}
})
return { success: "Updated the publication '" + res.title + "'" }
} catch (error) {
console.error(error)
return null
}
}

39
src/app/lib/validate.ts Normal file
View File

@ -0,0 +1,39 @@
import { z } from "zod";
import { storySchema } from "app/ui/forms/schemas";
import { Pub, Story } from "@prisma/client";
import { pubSchema } from "app/ui/forms/schemas";
import { StoryWithGenres } from "app/story/page";
//schemas
const storySchemaTrimmed = storySchema.omit({ genres: true })
const pubSchemaTrimmed = pubSchema.omit({ genres: true })
const genreSchema = z.object({ id: z.number() })
const genresSchema = z.array(genreSchema)
export async function prepStoryData(data: Story): Promise<{ title: string, word_count: number }> {
const storyData = structuredClone(data)
//throw an error if validation fails
storySchemaTrimmed.safeParse(storyData)
return storyData
}
export async function prepPubData(data: Pub): Promise<Pub> {
const pubData = structuredClone(data)
pubSchemaTrimmed.safeParse(pubData)
return pubData
}
export async function prepGenreData(data: number[]): Promise<{ id: number }[]> {
"use server"
//prepare data
const genresArray = data.map((e) => { return { id: e } })
//prepare schemas
//throws error if validation fails
genresSchema.safeParse(genresArray)
return genresArray
}

View File

@ -6,57 +6,84 @@ import { toast } from "@/components/ui/use-toast";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { signIn } from "app/api/auth/actions/sign-in"; import { redirect } from "next/navigation";
import { loginSchema } from "./schema";
const formSchema = z.object({ import { useRouter } from "next/navigation";
email: z.string().email(), import { useSearchParams } from "next/navigation";
password: z.string().min(6) import revalidate from "./revalidate";
}) import { useState } from "react";
import Link from "next/link";
export default function LoginForm() { export default function LoginForm() {
const form = useForm<z.infer<typeof formSchema>>({ const router = useRouter()
resolver: zodResolver(formSchema), const searchParams = useSearchParams()
const redirect = searchParams.get("from") ?? "/submission"
const form = useForm<z.infer<typeof loginSchema>>({
resolver: zodResolver(loginSchema),
})
const [submitted, setSubmitted] = useState(false)
const onSubmit = form.handleSubmit(async (data, event) => {
event.preventDefault()
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-type': 'application/json',
},
body: JSON.stringify(data),
})
if (res.status === 200) {
toast({ title: "login successful!" })
setSubmitted(true)
await revalidate(redirect)
window.location.href = redirect
} else {
toast({ title: "login failed!" })
}
}) })
function onErrors(errors) {
toast({
title: "WHOOPS",
description: JSON.stringify(errors)
})
}
return ( return (
<Form {...form}> <main className="flex flex-col items-center justify-around h-60 w-26">
<form action={signIn}> {submitted ? <div className="flex flex-col items-center justify-around h-30 w-26">
<FormField <h1>Logging in...</h1>
control={form.control} <Button asChild>
name="email" <Link href={redirect}>Continue</Link>
render={({ field }) => ( </Button>
<FormItem> </div> :
<FormLabel>Email Address</FormLabel> <Form {...form}>
<FormControl> <form onSubmit={onSubmit} className="flex flex-col items-center space-y-6">
<Input placeholder="email goes here" {...field} /> <FormField
</FormControl> control={form.control}
<FormMessage /> name="email"
</FormItem> render={({ field }) => (
)} <FormItem>
></FormField> <FormLabel>Email Address</FormLabel>
<FormField <FormControl>
control={form.control} <Input placeholder="email goes here" {...field} />
name="password" </FormControl>
render={({ field }) => ( <FormMessage />
<FormItem> </FormItem>
<FormLabel>Password</FormLabel> )}
<FormControl> ></FormField>
<Input placeholder="password goes here" type="password"{...field} /> <FormField
</FormControl> control={form.control}
<FormMessage /> name="password"
</FormItem> render={({ field }) => (
)} <FormItem>
></FormField> <FormLabel>Password</FormLabel>
<Button type="submit">SUBMIT</Button> <FormControl>
</form> <Input placeholder="password goes here" type="password"{...field} />
</Form> </FormControl>
<FormMessage />
</FormItem>
)}
></FormField>
<Button type="submit" className="mt-4">SUBMIT</Button>
</form>
</Form>
}
</main>
) )
} }

View File

@ -0,0 +1,11 @@
"use server"
import { revalidatePath } from "next/cache"
export default async function revalidate(path: string) {
try {
revalidatePath(path)
return true
} catch (error) {
console.error(error)
return false
}
}

7
src/app/login/schema.ts Normal file
View File

@ -0,0 +1,7 @@
import { z } from "zod"
export const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6)
})
export type LoginSchema = z.infer<typeof loginSchema>

View File

@ -1,13 +1,28 @@
import Image from "next/image"; import Link from "next/link";
import styles from "./page.module.css"; import { Button } from "@/components/ui/button";
export default function Home() { export default function Home() {
return ( return (
<main className={styles.main}> <main className="flex flex-col gap-4 items-center justify-around h-60 w-26 m-6">
Hello < div >
<h1 className="text-3xl font-black underline"> <h1 className="text-3xl font-black">
Hello world! Welcome to Subman!
</h1> </h1>
</main> </div >
<div className="flex flex-col gap-3">
<p>
This app is for demonstration purposes only. Data is reset periodically.
</p>
<p>
<b>USERNAME:</b> demo@demo.demo
</p>
<p>
<b>PASSWORD:</b> password
</p>
</div>
<Button className="mt-6">
<Link href="/login">Log in</Link>
</Button>
</main >
); );
} }

View File

@ -31,7 +31,7 @@ export default async function Page({ params }: { params: { id: string } }) {
<PageHeader>{pub.title}</PageHeader> <PageHeader>{pub.title}</PageHeader>
<GenreBadges genres={pub.genres} className="my-6" /> <GenreBadges genres={pub.genres} className="my-6" />
<PageSubHeader>Submissions:</PageSubHeader> <PageSubHeader>Submissions:</PageSubHeader>
<DataTable columns={columns} data={pubSubs} type="submission" /> <DataTable columns={columns} data={pubSubs} tableName="sub" />
</div> </div>
) )
} }

View File

@ -1,19 +1,19 @@
"use client" "use client"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table" import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { ArrowUpDown } from "lucide-react" import { ArrowUpDown, BookType, Clock, Drama, SquareArrowOutUpRight } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { PubWithGenres } from "./page"
import { PubsWithGenres } from "./page"
import { TextInputCell } from "app/ui/tables/inputs/textInput" import { TextInputCell } from "app/ui/tables/inputs/textInput"
import { selectCol } from "app/ui/tables/selectColumn" import { selectCol } from "app/ui/tables/selectColumn"
import NumberInputCell from "app/ui/tables/inputs/numberInput" import NumberInputCell from "app/ui/tables/inputs/numberInput"
import { formSchema } from "app/ui/forms/pub" import { pubSchema } from "app/ui/forms/schemas"
import GenrePickerInputCell from "app/ui/tables/inputs/genrePickerInput" import GenrePickerInputCell from "app/ui/tables/inputs/genrePickerInput"
import { genrePickerFilterFn } from "app/lib/filterFns"
const columnHelper = createColumnHelper<PubsWithGenres>() const columnHelper = createColumnHelper<PubWithGenres>()
export const columns: ColumnDef<PubsWithGenres>[] = [ export const columns: ColumnDef<PubWithGenres>[] = [
selectCol, selectCol,
{ {
accessorKey: "title", accessorKey: "title",
@ -23,35 +23,73 @@ export const columns: ColumnDef<PubsWithGenres>[] = [
variant="ghost" variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
Title <span className="hidden sm:block">
<ArrowUpDown className="ml-2 h-4 w-4" /> Title
</span>
<span className="block sm:hidden"><BookType /></span>
<ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
</Button> </Button>
) )
}, },
cell: TextInputCell, cell: cell => (
meta: { formSchema } <>
{/* @ts-ignore */}
<p className="block text-xs max-w-24 break-words md:hidden">{cell.getValue()}</p>
<TextInputCell cellContext={cell} className="hidden md:block" />
</>
),
meta: { formSchema: pubSchema }
}, },
{ {
accessorKey: "link", accessorKey: "link",
header: "Link", header: () => (
cell: TextInputCell, <div className="mx-auto w-fit">
meta: { formSchema } <span className="hidden sm:block">Link</span>
<span className="block sm:hidden"><SquareArrowOutUpRight /></span>
</div>
),
cell: cell => (
<>
{/* @ts-ignore */}
<p className="block text-xs max-w-16 truncate md:hidden">{cell.getValue()}</p>
<TextInputCell cellContext={cell} className="hidden md:block" />
</>
),
meta: { formSchema: pubSchema }
}, },
columnHelper.accessor("genres", { columnHelper.accessor("genres", {
header: () => (
<div className="w-fit mx-auto">
<span className="hidden sm:block">Genres</span>
<span className="sm:hidden"><Drama /></span>
</div>
),
cell: GenrePickerInputCell, cell: GenrePickerInputCell,
filterFn: "arrIncludes" filterFn: genrePickerFilterFn
//TODO - write custom filter function, to account for an array of objects
}), }),
{ {
accessorKey: "query_after_days", accessorKey: "query_after_days",
header: "Query After (days)", header: () => (
cell: NumberInputCell, <div>
<span className="hidden sm:block">Query After (days)</span>
<span className="sm:hidden"><Clock /></span>
</div>
),
enableColumnFilter: false,
cell: cell => (
<>
{/* @ts-ignore */}
<p className="block md:hidden text-center">{cell.getValue()}</p>
<NumberInputCell cellContext={cell} className="hidden md:block" />
</>
),
meta: { meta: {
step: 10, step: 10,
formSchema formSchema: pubSchema
}, },
}, },

View File

@ -5,21 +5,31 @@ import { ComponentProps } from "react";
import { Genre } from "@prisma/client"; import { Genre } from "@prisma/client";
import { createPub } from "app/lib/create"; import { createPub } from "app/lib/create";
import PubForm from "app/ui/forms/pub"; import PubForm from "app/ui/forms/pub";
import { Plus } from "lucide-react";
import { useState } from "react";
export default function CreatePubDialog({ genres }: ComponentProps<"div"> & { genres: Genre[] }) { export default function CreatePubDialog({ genres }: ComponentProps<"div"> & { genres: Genre[] }) {
const [isOpen, setIsOpen] = useState(false)
function closeDialog() {
setIsOpen(false)
}
return ( return (
<Dialog> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button>Create new publication</Button> <Button>
<span className="hidden md:block">Create new publication</span>
<Plus className="block md:hidden" />
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>New publication</DialogTitle> <DialogTitle>New publication</DialogTitle>
<DialogDescription>Create an entry for a new publication i.e. a place you intend to submit stories to.</DialogDescription> <DialogDescription>Create an entry for a new publication i.e. a place you intend to submit stories to.</DialogDescription>
</DialogHeader> </DialogHeader>
<PubForm createPub={createPub} genres={genres} /> <PubForm dbAction={createPub} genres={genres} closeDialog={closeDialog} />
<DialogFooter> <DialogFooter>
<Button form="pubform">Submit</Button> <Button form="pubform">Submit</Button>
</DialogFooter> </DialogFooter>

View File

@ -0,0 +1,26 @@
"use client"
import { DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ComponentProps } from "react";
import { Genre, Pub } from "@prisma/client";
import PubForm from "app/ui/forms/pub";
import { PubWithGenres } from "./page";
export default function EditPubDialog({ genres, closeDialog, defaults, dbAction }: ComponentProps<"div"> & { genres: Genre[], closeDialog: () => void, defaults: PubWithGenres, dbAction: (data: Pub & { genres: number[] }) => Promise<{ success: string }> }) {
return (
<>
<DialogHeader>
<DialogTitle>Edit publication</DialogTitle>
<DialogDescription>Modify an entry for an existing publication. Remember - you can edit fields inline by double clicking on them!</DialogDescription>
</DialogHeader>
<PubForm dbAction={dbAction} genres={genres} closeDialog={closeDialog} defaults={defaults} />
<DialogFooter>
<Button form="pubform">Submit</Button>
</DialogFooter>
</>
)
}

View File

@ -4,7 +4,7 @@ import { columns } from "./columns";
import { DataTable } from "app/ui/tables/data-table"; import { DataTable } from "app/ui/tables/data-table";
import CreatePubDialog from "./create"; import CreatePubDialog from "./create";
export type PubsWithGenres = Pub & { genres: Array<Genre> } export type PubWithGenres = Pub & { genres: Array<Genre> }
@ -12,7 +12,7 @@ export default async function Page() {
const genres = await getGenres() const genres = await getGenres()
const pubs = await getPubsWithGenres() const pubs = await getPubsWithGenres()
return ( return (
<div className="container mx-auto"> <div className="container px-0 md:px-4 mx-auto">
<DataTable data={pubs} columns={columns} tableName="pub" genres={genres}> <DataTable data={pubs} columns={columns} tableName="pub" genres={genres}>
<CreatePubDialog genres={genres} /> <CreatePubDialog genres={genres} />
</DataTable> </DataTable>

View File

@ -1,14 +1,14 @@
"use client" "use client"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table" import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { StoryWithGenres } from "./page" import { StoryWithGenres } from "./page"
import { ArrowUpDown } from "lucide-react" import { ArrowUpDown, BookType, Drama, Tally5 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import GenreBadges from "app/ui/genreBadges"
import { selectCol } from "app/ui/tables/selectColumn" import { selectCol } from "app/ui/tables/selectColumn"
import NumberInputCell from "app/ui/tables/inputs/numberInput" import NumberInputCell from "app/ui/tables/inputs/numberInput"
import { formSchema } from "app/ui/forms/story" import { storySchema } from "app/ui/forms/schemas"
import { TextInputCell } from "app/ui/tables/inputs/textInput" import { TextInputCell } from "app/ui/tables/inputs/textInput"
import GenrePickerInputCell from "app/ui/tables/inputs/genrePickerInput" import GenrePickerInputCell from "app/ui/tables/inputs/genrePickerInput"
import { genrePickerFilterFn } from "app/lib/filterFns"
const columnHelper = createColumnHelper<StoryWithGenres>() const columnHelper = createColumnHelper<StoryWithGenres>()
export const columns: ColumnDef<StoryWithGenres>[] = [ export const columns: ColumnDef<StoryWithGenres>[] = [
@ -19,16 +19,25 @@ export const columns: ColumnDef<StoryWithGenres>[] = [
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="px-1"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
Title <span className="hidden sm:block">
<ArrowUpDown className="ml-2 h-4 w-4" /> Title
</span>
<span className="block sm:hidden"><BookType /></span>
<ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
</Button> </Button>
) )
}, },
cell: TextInputCell, cell: cell => (
meta: { formSchema } <>
{/* @ts-ignore */}
<p className="block break-words max-w-28 md:hidden text-xs">{cell.getValue()}</p>
<TextInputCell cellContext={cell} className="hidden md:block" />
</>
),
meta: { formSchema: storySchema }
}, },
{ {
accessorKey: "word_count", accessorKey: "word_count",
@ -36,23 +45,41 @@ export const columns: ColumnDef<StoryWithGenres>[] = [
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="px-1"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
Word Count <span className="hidden sm:block">
<ArrowUpDown className="ml-2 h-4 w-4" /> Word Count
</span>
<span className="sm:hidden">
<Tally5 />
</span>
<ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
</Button> </Button>
) )
}, },
enableColumnFilter: false, enableColumnFilter: false,
cell: NumberInputCell, cell: cell => (
<>
{/* @ts-ignore */}
<p className="block md:hidden text-center text-xs">{cell.getValue()}</p>
<NumberInputCell cellContext={cell} className="hidden md:block" />
</>
),
meta: { meta: {
step: 50, step: 50,
formSchema formSchema: storySchema
} }
}, },
columnHelper.accessor("genres", { columnHelper.accessor("genres", {
header: () => (
<div className="w-fit mx-auto">
<span className="hidden sm:block">Genres</span>
<span className="sm:hidden"><Drama /></span>
</div>
),
cell: GenrePickerInputCell, cell: GenrePickerInputCell,
filterFn: "arrIncludes", filterFn: genrePickerFilterFn,
meta: {} meta: {}
}), }),

View File

@ -2,24 +2,33 @@
import { createStory } from "app/lib/create" import { createStory } from "app/lib/create"
import { Dialog, DialogHeader, DialogTrigger, DialogContent, DialogClose, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { Dialog, DialogHeader, DialogTrigger, DialogContent, DialogClose, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ComponentProps } from "react"; import { ComponentProps, useState } from "react";
import { Genre } from "@prisma/client"; import { Genre } from "@prisma/client";
import StoryForm from "app/ui/forms/story"; import StoryForm from "app/ui/forms/story";
import { Plus } from "lucide-react";
export default function CreateStoryDialog({ genres }: ComponentProps<"div"> & { genres: Genre[] }) { export default function CreateStoryDialog({ genres }: ComponentProps<"div"> & { genres: Genre[] }) {
const [isOpen, setIsOpen] = useState(false)
function closeDialog() {
setIsOpen(false)
}
return ( return (
<Dialog> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button>Create new story</Button> <div>
<Button className="hidden md:block">Create new story</Button>
<Button className="block md:hidden"><Plus /> </Button>
</div>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>New story</DialogTitle> <DialogTitle>New story</DialogTitle>
<DialogDescription>Create an entry for a new story i.e. a thing you intend to submit for publication.</DialogDescription> <DialogDescription>Create an entry for a new story i.e. a thing you intend to submit for publication.</DialogDescription>
</DialogHeader> </DialogHeader>
<StoryForm createStory={createStory} genres={genres} existingData={null} /> <StoryForm dbAction={createStory} genres={genres} className="" closeDialog={closeDialog} />
<DialogFooter> <DialogFooter>
<Button form="storyform">Submit</Button> <Button form="storyform">Submit</Button>
</DialogFooter> </DialogFooter>

View File

@ -4,9 +4,10 @@ import prisma from "app/lib/db";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { CreateContainerContent, CreateContainerHeader, CreateContainer, CreateContainerDescription } from "app/ui/createContainer"; import { CreateContainerContent, CreateContainerHeader, CreateContainer, CreateContainerDescription } from "app/ui/createContainer";
import { Story } from "@prisma/client";
export default async function Page() { export default async function Page() {
const genres = await getGenres() const genres = await getGenres()
async function createStory(data) { async function createStory(data: Story & { genres: number[] }): Promise<{ success: string }> {
"use server" "use server"
const genresArray = data.genres.map(e => { return { id: e } }) const genresArray = data.genres.map(e => { return { id: e } })
const res = await prisma.story.create({ const res = await prisma.story.create({
@ -26,12 +27,14 @@ export default async function Page() {
revalidatePath("/story") revalidatePath("/story")
redirect("/story") redirect("/story")
} }
return ( return (
<CreateContainer> <CreateContainer>
<CreateContainerHeader>New story</CreateContainerHeader> <CreateContainerHeader>New story</CreateContainerHeader>
<CreateContainerContent> <CreateContainerContent>
<CreateContainerDescription>Make an entry for a new work of fiction i.e. a thing you intend to submit for publication.</CreateContainerDescription> <CreateContainerDescription>Make an entry for a new work of fiction i.e. a thing you intend to submit for publication.</CreateContainerDescription>
<StoryForm genres={genres} createStory={createStory} className="mt-6" /> <StoryForm genres={genres} dbAction={createStory} className="mt-6" />
</CreateContainerContent> </CreateContainerContent>
</CreateContainer> </CreateContainer>
) )

27
src/app/story/edit.tsx Normal file
View File

@ -0,0 +1,27 @@
"use client"
import { DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ComponentProps, useState } from "react";
import { Genre, Story } from "@prisma/client";
import StoryForm from "app/ui/forms/story";
import { StoryWithGenres } from "./page";
export default function EditStoryDialog({ genres, closeDialog, defaults, dbAction }: ComponentProps<"div"> & { genres: Genre[], closeDialog: () => void, defaults: StoryWithGenres, dbAction: (data: Story & { genres: number[] }) => Promise<{ success: string }> }) {
return (
<>
<DialogHeader>
<DialogTitle>Edit story</DialogTitle>
<DialogDescription>Modify an entry for an existing story. Remember - you can edit fields inline by double-clicking on them!</DialogDescription>
</DialogHeader>
<StoryForm dbAction={dbAction} genres={genres} className="" closeDialog={closeDialog} defaults={defaults} />
<DialogFooter>
<Button form="storyform">Submit</Button>
</DialogFooter>
</>
)
}

View File

@ -19,7 +19,7 @@ export default async function Page() {
return ( return (
<div className="container mx-auto"> <div className="container px-1 md:px-4 mx-auto">
<DataTable columns={columns} data={storiesWithGenres} tableName="story" <DataTable columns={columns} data={storiesWithGenres} tableName="story"
genres={genres} genres={genres}
> >

View File

@ -1,39 +0,0 @@
"use client"
import { LineChart, Line, CartesianGrid, XAxis, YAxis, PieChart, Pie } from "recharts"
import { SubComplete } from "./page"
export function SubsChart({ data }: { data: Array<SubComplete> }) {
const pieData: Array<{ story: string, occurrences: number }> = []
data.forEach(dataRow => {
const story = dataRow.story.title
const exists = pieData.findIndex(pieRow => story === pieRow.story)
if (exists === -1) {
//add the story to pieData if it doesn't already exist
pieData.push({ story: story, occurrences: 0 })
return
}
pieData[exists].occurrences++
})
console.log(pieData)
return (
<>
<PieChart width={400} height={400}>
<Pie data={pieData} dataKey="story" outerRadius={50} fill="teal" />
</PieChart>
<LineChart width={400} height={400} data={data}>
<Line type="monotone" dataKey="id" stroke="#8884d8" />
<CartesianGrid />
<XAxis dataKey="submitted" />
<YAxis />
</LineChart>
</>
)
}

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { CellContext, ColumnDef, createColumnHelper } from "@tanstack/react-table" import { CellContext, ColumnDef, createColumnHelper } from "@tanstack/react-table"
import { ArrowUpDown } from "lucide-react" import { ArrowUpDown, BookText, CalendarMinus, CalendarPlus, MessageCircleReply, NotepadText } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { SubComplete } from "./page" import { SubComplete } from "./page"
import { selectCol } from "app/ui/tables/selectColumn" import { selectCol } from "app/ui/tables/selectColumn"
@ -18,7 +18,12 @@ export const columns: ColumnDef<SubComplete>[] = [
return "RECORD DELETED" return "RECORD DELETED"
}, },
id: "story", id: "story",
header: "Story", header: () => (
<>
<span className="hidden md:block">Story</span>
<NotepadText className="block md:hidden" />
</>
),
cell: (props: CellContext<any, any>) => (<TitleContainer>{props.getValue()}</TitleContainer>) cell: (props: CellContext<any, any>) => (<TitleContainer>{props.getValue()}</TitleContainer>)
}, },
{ {
@ -29,7 +34,12 @@ export const columns: ColumnDef<SubComplete>[] = [
return "RECORD DELETED" return "RECORD DELETED"
}, },
id: "pub", id: "pub",
header: "Publication", header: () => (
<>
<span className="hidden md:block">Publication</span>
<BookText className="block md:hidden" />
</>
),
cell: (props: CellContext<any, any>) => (<TitleContainer>{props.getValue()}</TitleContainer>) cell: (props: CellContext<any, any>) => (<TitleContainer>{props.getValue()}</TitleContainer>)
}, },
{ {
@ -39,16 +49,24 @@ export const columns: ColumnDef<SubComplete>[] = [
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="p-0 flex justify-center w-full"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
Date Submitted <span className="hidden md:block"> Date Submitted </span>
<ArrowUpDown className="ml-2 h-4 w-4" /> <CalendarPlus className="block md:hidden" />
<ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
</Button> </Button>
) )
}, },
enableColumnFilter: false, enableColumnFilter: false,
sortingFn: "datetime", sortingFn: "datetime",
cell: (props: CellContext<any, any>) => (<p className="w-full text-center">{props.getValue().toLocaleDateString()}</p>) cell: (props: CellContext<any, any>) => (
<p className="w-full text-center text-xs md:text-sm">{props.getValue().toLocaleDateString('ES', {
day: 'numeric',
month: 'numeric',
year: '2-digit'
})}</p>
)
}, },
{ {
accessorFn: row => row.responded ? new Date(row.responded) : null, accessorFn: row => row.responded ? new Date(row.responded) : null,
@ -57,16 +75,22 @@ export const columns: ColumnDef<SubComplete>[] = [
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="p-0 flex justify-center w-full"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
Date Responded <span className="hidden md:block"> Date Responded </span>
<ArrowUpDown className="ml-2 h-4 w-4" /> <CalendarMinus className="block md:hidden" />
<ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
</Button> </Button>
) )
}, },
enableColumnFilter: false, enableColumnFilter: false,
sortingFn: "datetime", sortingFn: "datetime",
cell: (props: CellContext<any, any>) => (<p className="w-full text-center">{props.getValue()?.toLocaleDateString()}</p>) cell: (props: CellContext<any, any>) => (<p className="w-full text-center text-xs md:text-sm">{props.getValue()?.toLocaleDateString('ES', {
day: 'numeric',
month: 'numeric',
year: '2-digit'
})}</p>)
}, },
{ {
accessorFn: row => { accessorFn: row => {
@ -76,8 +100,20 @@ export const columns: ColumnDef<SubComplete>[] = [
return "RECORD DELETED" return "RECORD DELETED"
}, },
id: "response", id: "response",
header: "Response", header: ({ column }) => {
cell: (props: CellContext<any, any>) => (<p className="w-full text-center">{props.getValue()}</p>) return (
<Button
variant="ghost"
className="p-0 flex justify-center w-full"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<span className="hidden md:block"> Response </span>
<MessageCircleReply className="block md:hidden" />
<ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
</Button>
)
},
cell: (props: CellContext<any, any>) => (<p className="w-full text-center text-xs md:text-sm">{props.getValue()}</p>)
}, },
] ]

View File

@ -6,33 +6,35 @@ import { Button } from "@/components/ui/button";
import { ComponentProps } from "react"; import { ComponentProps } from "react";
import { Pub, Response, Story } from "@prisma/client"; import { Pub, Response, Story } from "@prisma/client";
import SubmissionForm from "app/ui/forms/sub"; import SubmissionForm from "app/ui/forms/sub";
import { Plus } from "lucide-react";
import { useState } from "react";
import { StoryWithGenres } from "app/story/page";
import { PubWithGenres } from "app/publication/page";
type CreateSubDefaults = {
subId?: number,
storyId: number,
pubId: number,
submitted: Date,
responded: Date,
respoonseId: number
}
export default function CreateSubmissionDialog({ stories, pubs, responses, defaults }: ComponentProps<"div"> & { stories: Story[], pubs: Pub[], responses: Response[], defaults?: CreateSubDefaults }) { export default function CreateSubmissionDialog({ stories, pubs, responses }: ComponentProps<"div"> & { stories: StoryWithGenres[], pubs: PubWithGenres[], responses: Response[] }) {
const [isOpen, setIsOpen] = useState(false)
function closeDialog() {
setIsOpen(false)
}
return ( return (
<Dialog> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button>Create new submission</Button> <Button>
<span className="hidden md:block">Create new submission</span>
<Plus className="block md:hidden" />
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="text-xs md:text-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>New submission</DialogTitle> <DialogTitle>New submission</DialogTitle>
<DialogDescription>Create an entry for a new story i.e. a thing you intend to submit for publication.</DialogDescription> <DialogDescription>Create an entry for a new story i.e. a thing you intend to submit for publication.</DialogDescription>
</DialogHeader> </DialogHeader>
<SubmissionForm createSub={createSub} pubs={pubs} responses={responses} stories={stories} defaults={defaults} /> <SubmissionForm pubs={pubs} responses={responses} stories={stories} closeDialog={closeDialog} />
<DialogFooter> <DialogFooter>
<DialogClose asChild>
</DialogClose>
<Button form="subform">Submit</Button> <Button form="subform">Submit</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -1,29 +0,0 @@
"use server"
import { getPubs, getResponses, getStories } from "app/lib/get";
import SubmissionForm from "app/ui/forms/sub";
import prisma from "app/lib/db";
import { CreateContainer, CreateContainerContent, CreateContainerHeader } from "app/ui/createContainer";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export default async function Page() {
const stories = await getStories()
const pubs = await getPubs()
const responses = await getResponses()
async function createSub(data) {
"use server"
const res = await prisma.sub.create({ data })
console.log(res)
revalidatePath("/submission")
redirect("/submission")
}
return (
<CreateContainer>
<CreateContainerHeader>New submission</CreateContainerHeader>
<CreateContainerContent>
<SubmissionForm stories={stories} pubs={pubs} responses={responses} createSub={createSub} />
</CreateContainerContent>
</CreateContainer>
)
}

View File

@ -1,14 +1,14 @@
"use client" "use client"
import { createSub } from "app/lib/create" import { DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Dialog, DialogHeader, DialogTrigger, DialogContent, DialogClose, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ComponentProps } from "react"; import { ComponentProps } from "react";
import { Pub, Response, Story } from "@prisma/client"; import { Pub, Response, Story } from "@prisma/client";
import SubmissionForm, { SubForm } from "app/ui/forms/sub"; import { SubForm } from "app/ui/forms/sub";
import EditSubmissionForm from "app/ui/forms/editSub";
export default function EditSubmissionDialog({ stories, pubs, responses, defaults, children, closeDialog }: ComponentProps<"div"> & { stories: Story[], pubs: Pub[], responses: Response[], defaults: SubForm, closeDialog: () => void }) {
export default function EditSubmissionDialog({ stories, pubs, responses, defaults, children }: ComponentProps<"div"> & { stories: Story[], pubs: Pub[], responses: Response[], defaults: SubForm }) {
return ( return (
<> <>
@ -16,10 +16,8 @@ export default function EditSubmissionDialog({ stories, pubs, responses, default
<DialogTitle>Edit Submission</DialogTitle> <DialogTitle>Edit Submission</DialogTitle>
<DialogDescription>Change response status, edit dates etc</DialogDescription> <DialogDescription>Change response status, edit dates etc</DialogDescription>
</DialogHeader> </DialogHeader>
<SubmissionForm pubs={pubs} responses={responses} stories={stories} defaults={defaults} /> <EditSubmissionForm pubs={pubs} responses={responses} stories={stories} defaults={defaults} closeDialog={closeDialog} />
<DialogFooter> <DialogFooter>
<DialogClose asChild>
</DialogClose>
<Button form="subform">Submit</Button> <Button form="subform">Submit</Button>
</DialogFooter> </DialogFooter>
</> </>

View File

@ -1,9 +1,10 @@
import { getGenres, getPubs, getResponses, getStories, getSubsComplete } from "app/lib/get" import { getGenres, getPubs, getPubsWithGenres, getResponses, getStories, getStoriesWithGenres, getSubsComplete } from "app/lib/get"
import { DataTable } from "app/ui/tables/data-table" import { DataTable } from "app/ui/tables/data-table"
import { columns } from "./columns" import { columns } from "./columns"
import { Pub, Response, Story, Sub } from "@prisma/client" import { Genre, Pub, Response, Story, Sub } from "@prisma/client"
import CreateSubmissionDialog from "./create" import CreateSubmissionDialog from "./create"
import { Trash2 } from "lucide-react" import { PubWithGenres } from "app/publication/page"
import { StoryWithGenres } from "app/story/page"
export type SubComplete = Sub & { export type SubComplete = Sub & {
pub: Pub, pub: Pub,
@ -11,13 +12,15 @@ export type SubComplete = Sub & {
response: Response response: Response
} }
export default async function Page() { export default async function Page() {
const subs: Array<SubComplete> = await getSubsComplete() const subs: Array<SubComplete> = await getSubsComplete()
const stories = await getStories() const stories: StoryWithGenres[] = await getStoriesWithGenres()
const pubs = await getPubs() const pubs: PubWithGenres[] = await getPubsWithGenres()
const responses = await getResponses() const responses: Response[] = await getResponses()
const genres = await getGenres() const genres: Genre[] = await getGenres()
return ( return (
<div className="container"> <div className="container px-1 md:px-4 mx-auto">
<DataTable data={subs} columns={columns} tableName="sub" <DataTable data={subs} columns={columns} tableName="sub"
stories={stories} stories={stories}
pubs={pubs} pubs={pubs}

View File

@ -706,6 +706,10 @@ body {
z-index: 100; z-index: 100;
} }
.m-6 {
margin: 1.5rem;
}
.m-auto { .m-auto {
margin: auto; margin: auto;
} }
@ -715,6 +719,11 @@ body {
margin-right: -0.25rem; margin-right: -0.25rem;
} }
.mx-0 {
margin-left: 0px;
margin-right: 0px;
}
.mx-1 { .mx-1 {
margin-left: 0.25rem; margin-left: 0.25rem;
margin-right: 0.25rem; margin-right: 0.25rem;
@ -740,8 +749,9 @@ body {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.mb-4 { .my-auto {
margin-bottom: 1rem; margin-top: auto;
margin-bottom: auto;
} }
.ml-2 { .ml-2 {
@ -752,6 +762,14 @@ body {
margin-left: auto; margin-left: auto;
} }
.mr-2 {
margin-right: 0.5rem;
}
.mr-4 {
margin-right: 1rem;
}
.mt-2 { .mt-2 {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@ -768,8 +786,8 @@ body {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.mt-auto { .block {
margin-top: auto; display: block;
} }
.flex { .flex {
@ -792,6 +810,10 @@ body {
display: grid; display: grid;
} }
.hidden {
display: none;
}
.size-full { .size-full {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -833,8 +855,8 @@ body {
height: 1.25rem; height: 1.25rem;
} }
.h-5\/6 { .h-60 {
height: 83.333333%; height: 15rem;
} }
.h-7 { .h-7 {
@ -914,10 +936,6 @@ body {
width: 10rem; width: 10rem;
} }
.w-5\/6 {
width: 83.333333%;
}
.w-7 { .w-7 {
width: 1.75rem; width: 1.75rem;
} }
@ -972,6 +990,26 @@ body {
min-width: fit-content; min-width: fit-content;
} }
.max-w-16 {
max-width: 4rem;
}
.max-w-24 {
max-width: 6rem;
}
.max-w-28 {
max-width: 7rem;
}
.max-w-32 {
max-width: 8rem;
}
.max-w-60 {
max-width: 15rem;
}
.max-w-full { .max-w-full {
max-width: 100%; max-width: 100%;
} }
@ -984,10 +1022,6 @@ body {
max-width: 24rem; max-width: 24rem;
} }
.max-w-xs {
max-width: 20rem;
}
.shrink-0 { .shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
@ -1096,6 +1130,10 @@ body {
justify-content: space-between; justify-content: space-between;
} }
.justify-around {
justify-content: space-around;
}
.gap-1 { .gap-1 {
gap: 0.25rem; gap: 0.25rem;
} }
@ -1104,6 +1142,10 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
.gap-3 {
gap: 0.75rem;
}
.gap-4 { .gap-4 {
gap: 1rem; gap: 1rem;
} }
@ -1175,6 +1217,12 @@ body {
margin-bottom: calc(1rem * var(--tw-space-y-reverse)); margin-bottom: calc(1rem * var(--tw-space-y-reverse));
} }
.space-y-6 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.space-y-8 > :not([hidden]) ~ :not([hidden]) { .space-y-8 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0; --tw-space-y-reverse: 0;
margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse))); margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse)));
@ -1193,10 +1241,24 @@ body {
overflow: hidden; overflow: hidden;
} }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.whitespace-nowrap { .whitespace-nowrap {
white-space: nowrap; white-space: nowrap;
} }
.break-words {
overflow-wrap: break-word;
}
.rounded-3xl {
border-radius: 1.5rem;
}
.rounded-full { .rounded-full {
border-radius: 9999px; border-radius: 9999px;
} }
@ -1209,11 +1271,6 @@ body {
border-radius: calc(var(--radius) - 4px); border-radius: calc(var(--radius) - 4px);
} }
.rounded-l-3xl {
border-top-left-radius: 1.5rem;
border-bottom-left-radius: 1.5rem;
}
.rounded-t-3xl { .rounded-t-3xl {
border-top-left-radius: 1.5rem; border-top-left-radius: 1.5rem;
border-top-right-radius: 1.5rem; border-top-right-radius: 1.5rem;
@ -1344,6 +1401,16 @@ body {
padding: 1.5rem; padding: 1.5rem;
} }
.px-0 {
padding-left: 0px;
padding-right: 0px;
}
.px-1 {
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.px-2 { .px-2 {
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
@ -1444,11 +1511,6 @@ body {
line-height: 2.25rem; line-height: 2.25rem;
} }
.text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
}
.text-\[0\.8rem\] { .text-\[0\.8rem\] {
font-size: 0.8rem; font-size: 0.8rem;
} }
@ -1563,10 +1625,6 @@ body {
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
.underline {
text-decoration-line: underline;
}
.underline-offset-4 { .underline-offset-4 {
text-underline-offset: 4px; text-underline-offset: 4px;
} }
@ -2303,6 +2361,22 @@ body {
top: auto; top: auto;
} }
.sm\:block {
display: block;
}
.sm\:hidden {
display: none;
}
.sm\:h-6 {
height: 1.5rem;
}
.sm\:w-6 {
width: 1.5rem;
}
.sm\:flex-row { .sm\:flex-row {
flex-direction: row; flex-direction: row;
} }
@ -2341,15 +2415,87 @@ body {
text-align: left; text-align: left;
} }
.sm\:text-4xl {
font-size: 2.25rem;
line-height: 2.5rem;
}
.data-\[state\=open\]\:sm\:slide-in-from-bottom-full[data-state=open] { .data-\[state\=open\]\:sm\:slide-in-from-bottom-full[data-state=open] {
--tw-enter-translate-y: 100%; --tw-enter-translate-y: 100%;
} }
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.md\:mt-6 {
margin-top: 1.5rem;
}
.md\:mt-auto {
margin-top: auto;
}
.md\:block {
display: block;
}
.md\:hidden {
display: none;
}
.md\:w-24 {
width: 6rem;
}
.md\:w-5\/6 {
width: 83.333333%;
}
.md\:max-w-\[420px\] { .md\:max-w-\[420px\] {
max-width: 420px; max-width: 420px;
} }
.md\:flex-row {
flex-direction: row;
}
.md\:flex-col {
flex-direction: column;
}
.md\:space-y-8 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(2rem * var(--tw-space-y-reverse));
}
.md\:rounded-l-3xl {
border-top-left-radius: 1.5rem;
border-bottom-left-radius: 1.5rem;
}
.md\:p-4 {
padding: 1rem;
}
.md\:px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.md\:py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.md\:text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.md\:text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
} }
.\[\&\:has\(\[aria-selected\]\)\]\:bg-accent:has([aria-selected]) { .\[\&\:has\(\[aria-selected\]\)\]\:bg-accent:has([aria-selected]) {

View File

@ -1,7 +0,0 @@
import { CheckboxReactHookFormMultiple } from "app/ui/forms/Checkboxdemo";
export default function Page() {
return (
<CheckboxReactHookFormMultiple />
)
}

View File

@ -0,0 +1,265 @@
"use client"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/use-toast"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
import { CalendarIcon } from "@radix-ui/react-icons"
import { cn } from "@/lib/utils"
import { format } from "date-fns"
import {
Form,
FormItem,
FormLabel,
FormField,
FormControl,
FormDescription,
FormMessage
} from "@/components/ui/form"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useState } from "react"
import { createSub } from "app/lib/create"
import { subSchema } from "./schemas"
import { updateSub } from "app/lib/update"
import { useRouter } from "next/navigation"
export type SubForm = z.infer<typeof subSchema>
export default function EditSubmissionForm({ stories, pubs, responses, defaults, closeDialog }) {
const form = useForm<z.infer<typeof subSchema>>({
resolver: zodResolver(subSchema),
defaultValues: {
responseId: responses[0].id,
...defaults
}
})
const [isSubCalendarOpen, setIsSubCalendarOpen] = useState(false);
const [isRespCalendarOpen, setIsRespCalendarOpen] = useState(false);
const storiesSelectItems = stories.map(e => (
<SelectItem value={e.id.toString()} key={e.title}>
{e.title}
</SelectItem>
))
const pubsSelectItems = pubs.map(e => (
<SelectItem value={e.id} key={e.title}>
{e.title}
</SelectItem>
))
const reponsesSelectItems = responses.map(e => (
<SelectItem value={e.id} key={e.title}>
{e.response}
</SelectItem>
))
const router = useRouter()
async function onSubmit(values: z.infer<typeof subSchema>) {
try {
const res = await updateSub(values)
if (res === undefined) throw new Error("something went wrong")
toast({ title: "Successfully created new submission!" })
router.refresh()
closeDialog()
} catch (error) {
toast({
title: "UH-OH",
description: error.message
})
}
}
function onErrors(errors) {
toast({
title: "You have errors",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(errors, null, 2)}</code>
</pre>
),
})
console.log(JSON.stringify(errors))
}
return (
<Form {...form}>
<form id="subform" onSubmit={form.handleSubmit(onSubmit, onErrors)} className="space-y-2 md:space-y-8">
<FormField
control={form.control}
name="storyId"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm md:text-base">Story</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select something">
<p>{stories?.find(e => e.id === Number(field.value))?.title ?? null}</p>
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
{storiesSelectItems}
</SelectContent>
</Select>
<FormDescription className="text-xs md:text-base">The piece you submitted</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pubId"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm md:text-base">Publication</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select something">
<p>{pubs?.find(e => e.id === Number(field.value))?.title ?? null}</p>
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
{pubsSelectItems}
</SelectContent>
</Select>
<FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="submitted"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-sm md:text-base">Date of submission</FormLabel>
<Popover modal={true} open={isSubCalendarOpen} onOpenChange={setIsSubCalendarOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
{/* @ts-ignore */}
<Calendar mode="single" selected={field.value}
onSelect={(e) => { field.onChange(e); setIsSubCalendarOpen(false); }}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormDescription className="text-xs md:text-base">
The date you sent it
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="responded"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="text-sm md:text-base">Date of response</FormLabel>
<Popover modal={true} open={isRespCalendarOpen} onOpenChange={setIsRespCalendarOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"w-[240px] pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
format(field.value, "PPP")
) : (
<span>Pick a date</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
{/* @ts-ignore */}
<Calendar selected={field.value} onSelect={(e) => { field.onChange(e); setIsRespCalendarOpen(false); }}
disabled={(date) =>
date > new Date() || date < new Date("1900-01-01")
}
initialFocus
/>
</PopoverContent>
</Popover>
<FormDescription className="text-xs md:text-base">
The date they wrote back
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="responseId"
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm md:text-base">Response</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
<FormControl>
<SelectTrigger>
<SelectValue>
<p>{responses?.find(e => e.id === Number(field.value))?.response ?? null}</p>
</SelectValue>
</SelectTrigger>
</FormControl>
<SelectContent>
{reponsesSelectItems}
</SelectContent>
</Select>
<FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)
}

View File

@ -1,121 +0,0 @@
"use client"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Genre } from "@prisma/client"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Checkbox } from "@/components/ui/checkbox"
const formSchema = z.object({
title: z.string().min(2).max(50),
word_count: z.number(),
genres: z.object({ id: z.number(), name: z.string() }).array()
})
export default function FancyForm({ genres }) {
// 1. Define your form.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
word_count: 0,
genres: genres
},
})
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="title goes here..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="word_count"
render={({ field }) => (
<FormItem>
<FormLabel>Word count</FormLabel>
<FormControl>
<Input type="number" step={500} min={0} {...field}></Input>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="genres"
render={({ field }) => (
<FormItem>
<div className="mb-4">
<FormLabel>Genres</FormLabel>
<FormDescription>genres baby</FormDescription>
</div>
{genres.map((item) => (
<FormField
key={item.id}
control={form.control}
name="genres"
render={({ field }) => {
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, item.id])
: field.onChange(
field.value?.filter(
(value) => value !== item.id
)
)
}}
/>
</FormControl>
<FormLabel className="text-sm font-normal">
{item.name}
</FormLabel>
</FormItem>
)
}}
/>
))}
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}

View File

@ -21,7 +21,7 @@ export default function GenrePicker({ genres, form }: ComponentProps<"div"> & {
<Button <Button
variant={"outline"} variant={"outline"}
className={cn( className={cn(
"min-w-fit max-w-full w-fit pl-3 text-left font-normal flex-wrap gap-y-1 h-fit min-h-10", "min-w-fit max-w-60 pl-3 text-left font-normal flex-wrap gap-y-1 h-fit min-h-10",
!field.value && "text-muted-foreground" !field.value && "text-muted-foreground"
)} )}
> >

View File

@ -17,49 +17,53 @@ import { toast } from "@/components/ui/use-toast"
import { randomPublicationTitle } from "app/lib/shortStoryTitleGenerator" import { randomPublicationTitle } from "app/lib/shortStoryTitleGenerator"
import { ComponentProps } from "react" import { ComponentProps } from "react"
import { Genre } from "@prisma/client" import { Genre, Pub } from "@prisma/client"
import GenrePicker from "./genrePicker" import GenrePicker from "./genrePicker"
import { pubSchema } from "./schemas"
import { useRouter } from "next/navigation"
import { Ban } from "lucide-react"
import { PubWithGenres } from "app/publication/page"
export const formSchema = z.object({ export default function PubForm({ genres, dbAction, className, closeDialog, defaults }: ComponentProps<"div"> & { genres: Array<Genre>, dbAction: (data: any) => Promise<{ success: string }>, closeDialog: () => void, defaults?: PubWithGenres }) {
title: z.string().min(2).max(50), const form = useForm<z.infer<typeof pubSchema>>({
link: z.string(), resolver: zodResolver(pubSchema),
query_after_days: z.coerce.number().min(30),
genres: z.array(z.number()),
})
export default function PubForm({ genres, createPub, className }: ComponentProps<"div"> & { genres: Array<Genre>, createPub: (data: any) => void }) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
title: "", id: defaults?.id,
link: "", title: defaults?.title ?? "",
query_after_days: 30, link: defaults?.link ?? "",
genres: [] query_after_days: defaults?.query_after_days ?? 30,
genres: defaults?.genres.map(e => e.id) ?? []
}, },
}) })
function onSubmit(values: z.infer<typeof formSchema>) { const router = useRouter()
// Do something with the form values.
// ✅ This will be type-safe and validated. async function onSubmit(values: z.infer<typeof pubSchema>) {
toast({ try {
title: "You submitted the following values:", const res = await dbAction({
description: ( pub: {
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> id: values?.id,
<code className="text-white">{JSON.stringify(values, null, 2)}</code> title: values.title,
</pre> link: values.link,
), query_after_days: values.query_after_days
}) }, genres: values.genres
createPub(values) })
console.log(values) if (!res?.success) throw new Error("something went wrong")
toast({ title: "Success!", description: res.success })
router.refresh()
closeDialog()
} catch (error) {
toast({
title: "Oh dear... ",
description: error.message
})
}
} }
function onErrors(errors) { function onErrors(errors) {
toast({ toast({
title: "You have errors",
description: ( description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> <Ban />
<code className="text-white">{JSON.stringify(errors, null, 2)}</code>
</pre>
), ),
}) })
console.log(JSON.stringify(errors)) console.log(JSON.stringify(errors))

View File

@ -0,0 +1,54 @@
import { z } from "zod";
export const storySchema = z.object({
title: z.string().min(2).max(50),
word_count: z.coerce.number(),
genres: z.object({ id: z.number(), name: z.string() }).array()
})
export const pubSchema = z.object({
id: z.coerce.number().optional(),
title: z.string().min(2).max(50),
link: z.string(),
query_after_days: z.coerce.number().min(30),
genres: z.array(z.number()),
})
export const subSchema = z.object({
id: z.number().optional(),
storyId: z.coerce.number(),
pubId: z.coerce.number(),
submitted: z.coerce.date().transform((date) => date.toString()),
responded: z.coerce.date().transform((date) => {
if (date.toString() !== new Date(null).toString()) {
return date.toString()
}
return null
}).optional(),
responseId: z.coerce.number()
})
.refine(object => {
const submitted = new Date(object.submitted)
const responded = object.responded ? new Date(object.responded) : null
return responded >= submitted || responded === null
},
{
path: ["responded"],
message: "'Responded' must be a later date than 'submitted'"
})
.refine(object => {
if (object.responded) {
//there is a 'responded' date and the response is not 'pending'
return object.responseId !== 7
}
if (!object.responded) {
//there is not a 'responded' date and the response is pending
return object.responseId === 7
}
},
{
path: ["responseId"],
message: "A pending response cannot have a date, and a non-pending response must have a date"
}
)

View File

@ -15,10 +15,13 @@ import {
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
import { ComponentProps } from "react" import { ComponentProps, SetStateAction } from "react"
import { Genre, Story } from "@prisma/client" import { Genre, Story } from "@prisma/client"
import { randomStoryTitle } from "app/lib/shortStoryTitleGenerator" import { randomStoryTitle } from "app/lib/shortStoryTitleGenerator"
import GenrePicker from "./genrePicker" import GenrePicker from "./genrePicker"
import { useRouter } from "next/navigation"
import { Ban, Cross } from "lucide-react"
import { StoryWithGenres } from "app/story/page"
export const formSchema = z.object({ export const formSchema = z.object({
id: z.number().optional(), id: z.number().optional(),
@ -27,42 +30,47 @@ export const formSchema = z.object({
genres: z.array(z.number()) genres: z.array(z.number())
}) })
export default function StoryForm({ genres, createStory, className, existingData }: ComponentProps<"div"> & { genres: Array<Genre>, createStory: (data: any) => void, existingData: Story & { genres: number[] } | null }) { export default function StoryForm({ genres, dbAction, className, closeDialog, defaults }: ComponentProps<"div"> & { genres: Array<Genre>, dbAction: (data: any) => Promise<{ success: string }>, className: string, closeDialog?: () => void, defaults?: StoryWithGenres }) {
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: existingData?.id, id: defaults?.id,
title: existingData?.title ?? "", title: defaults?.title ?? "",
word_count: existingData?.word_count ?? 500, word_count: defaults?.word_count ?? 500,
genres: existingData?.genres ?? [] genres: defaults?.genres.map(e => e.id) ?? []
}, },
}) })
const router = useRouter()
async function onSubmit(values: z.infer<typeof formSchema>) {
function onSubmit(values: z.infer<typeof formSchema>) { try {
toast({ const res = await dbAction({
title: "You submitted the following values:", story: {
description: ( id: values?.id,
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> title: values.title,
<code className="text-white">{JSON.stringify(values, null, 2)}</code> word_count: values.word_count,
</pre> },
), genres: values.genres
}) })
createStory(values) //server actions return undefined if middleware authentication fails
console.log(values) if (!res?.success) throw new Error("something went wrong")
toast({ title: "Success!", description: res.success })
router.refresh()
closeDialog()
} catch (error) {
toast({
title: "Oh dear... ",
description: error.message
})
}
} }
function onErrors(errors) { function onErrors(errors) {
toast({ toast({
title: "You have errors", description: (<Ban />)
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(errors, null, 2)}</code>
</pre>
),
}) })
console.log(JSON.stringify(errors)) console.log(JSON.stringify(errors))
} }
@ -85,7 +93,7 @@ export default function StoryForm({ genres, createStory, className, existingData
)} )}
/> />
<div className="inline-flex flex-wrap w-full gap-x-16 gap-y-8 items-baseline max-w-full"> <div className="inline-flex flex-wrap justify-around items-start w-full gap-x-16 gap-y-8 items-baseline max-w-full">
<GenrePicker <GenrePicker
genres={genres} genres={genres}
@ -96,10 +104,10 @@ export default function StoryForm({ genres, createStory, className, existingData
control={form.control} control={form.control}
name="word_count" name="word_count"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col ">
<FormLabel className="h-5">Word count</FormLabel> <FormLabel className="h-5">Word count</FormLabel>
<FormControl> <FormControl>
<Input className=" w-24" type="number" step={500} min={1} {...field}></Input> <Input className=" w-24" type="number" step={500} {...field}></Input>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@ -32,54 +32,20 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { useState } from "react" import { useState } from "react"
import { editSubmission } from "app/lib/edit"
import { createSub } from "app/lib/create" import { createSub } from "app/lib/create"
import { subSchema } from "./schemas"
import { useRouter } from "next/navigation"
import { Ban } from "lucide-react"
import { Pub, Response, Story, Sub } from "@prisma/client"
import { StoryWithGenres } from "app/story/page"
import { PubWithGenres } from "app/publication/page"
export const formSchema = z.object({ export type SubForm = z.infer<typeof subSchema>
id: z.number().optional(),
storyId: z.coerce.number(),
pubId: z.coerce.number(),
submitted: z.coerce.date().transform((date) => date.toString()),
responded: z.coerce.date().transform((date) => {
if (date.toString() !== new Date(null).toString()) {
return date.toString()
}
return null
}).optional(),
responseId: z.coerce.number()
})
.refine(object => {
const submitted = new Date(object.submitted)
const responded = object.responded ? new Date(object.responded) : null
return responded >= submitted || responded === null
},
{
path: ["responded"],
message: "'Responded' must be a later date than 'submitted'"
})
.refine(object => {
if (object.responded) {
//there is a 'responded' date and the response is not 'pending'
return object.responseId !== 7
}
if (!object.responded) {
//there is not a 'responded' date and the response is pending
return object.responseId === 7
}
},
{
path: ["responseId"],
message: "A pending response cannot have a date, and a non-pending response must have a date"
}
)
export type SubForm = z.infer<typeof formSchema>
export default function SubmissionForm({ stories, pubs, responses, defaults }) { export default function SubmissionForm({ stories, pubs, responses, defaults, closeDialog }: { stories: StoryWithGenres[], pubs: PubWithGenres[], responses: Response[], defaults?: Sub, closeDialog?: () => void }) {
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof subSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(subSchema),
defaultValues: { defaultValues: {
responseId: responses[0].id, responseId: responses[0].id,
...defaults ...defaults
@ -87,51 +53,67 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
}) })
const [isSubCalendarOpen, setIsSubCalendarOpen] = useState(false); const [isSubCalendarOpen, setIsSubCalendarOpen] = useState(false);
const [isRespCalendarOpen, setIsRespCalendarOpen] = useState(false); const [isRespCalendarOpen, setIsRespCalendarOpen] = useState(false);
const storiesSelectItems = stories.map(e => ( const [relevantPubIds, setRelevantPubIds] = useState(pubs.map(e => e.id));
<SelectItem value={e.id.toString()} key={e.title}>
{e.title} function updateRelevantPubs(storyId: number) {
</SelectItem> console.log("storyId: " + storyId)
)) console.log("stories: ", stories)
const pubsSelectItems = pubs.map(e => ( const story = stories.find(e => e.id == storyId)
<SelectItem value={e.id}> console.log("story: ", story)
const storyGenreIds = story?.genres.map(e => e.id) ?? []
const relevantPubIds = pubs.filter(e => {
const pubGenreIds = e.genres.map(e => e.id)
for (let i = 0; i < storyGenreIds.length; i++) {
const storyGenreId = storyGenreIds[i];
if (pubGenreIds.includes(storyGenreId)) return true
}
}).map(e => e.id)
console.log("relevant pubs: ", relevantPubIds)
setRelevantPubIds(relevantPubIds)
}
const storiesSelectItems = stories.map((e: Story) => (
<SelectItem value={e.id?.toString()} key={e.title}>
{e.title} {e.title}
</SelectItem> </SelectItem>
)) ))
const pubsSelectItems = pubs.map(e => {
const isDisabled = !relevantPubIds.includes(e.id)
return (
<SelectItem disabled={isDisabled} value={e.id.toString()} key={e.title}>
{e.title}
</SelectItem>
)
})
const reponsesSelectItems = responses.map(e => ( const reponsesSelectItems = responses.map(e => (
<SelectItem value={e.id}> <SelectItem value={e.id.toString()} key={e.response}>
{e.response} {e.response}
</SelectItem> </SelectItem>
)) ))
const router = useRouter()
// 2. Define a submit handler. async function onSubmit(values: z.infer<typeof subSchema>) {
function onSubmit(values: z.infer<typeof formSchema>) { try {
// Do something with the form values. //@ts-ignore
// ✅ This will be type-safe and validated. const res = await createSub(values)
toast({ if (!res) throw new Error("something went wrong")
title: "You submitted the following values:", toast({ title: "Successfully created new submission!" })
description: ( router.refresh()
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> closeDialog()
<code className="text-white">{JSON.stringify(values, null, 2)}</code> } catch (error) {
</pre> toast({
), title: "UH-OH",
}) description: error.message
if (values.id) { })
editSubmission(values)
} else {
createSub(values)
} }
console.log(values)
} }
function onErrors(errors) { function onErrors(errors) {
toast({ toast({
title: "You have errors",
description: ( description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> <Ban />
<code className="text-white">{JSON.stringify(errors, null, 2)}</code>
</pre>
), ),
}) })
console.log(JSON.stringify(errors)) console.log(JSON.stringify(errors))
@ -139,14 +121,14 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
return ( return (
<Form {...form}> <Form {...form}>
<form id="subform" onSubmit={form.handleSubmit(onSubmit, onErrors)} className="space-y-8"> <form id="subform" onSubmit={form.handleSubmit(onSubmit, onErrors)} className="space-y-2 md:space-y-8 text-xs">
<FormField <FormField
control={form.control} control={form.control}
name="storyId" name="storyId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Story</FormLabel> <FormLabel>Story</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value?.toString()}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select something"> <SelectValue placeholder="Select something">
@ -158,7 +140,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
{storiesSelectItems} {storiesSelectItems}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>The piece you submitted</FormDescription> <FormDescription className="text-xs md:text-base">The piece you submitted</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -168,8 +150,9 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
name="pubId" name="pubId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Publication</FormLabel> <FormLabel className="text-sm md:text-base">Publication</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onOpenChange={() => updateRelevantPubs(form.getValues().storyId
)} onValueChange={field.onChange} defaultValue={field.value?.toString()}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select something"> <SelectValue placeholder="Select something">
@ -181,7 +164,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
{pubsSelectItems} {pubsSelectItems}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>The market you sent it to</FormDescription> <FormDescription className="text-xs md:text-base">The market you sent it to. Bad genre fits are greyed out.</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -192,7 +175,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
name="submitted" name="submitted"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel>Date of submission</FormLabel> <FormLabel className="text-sm md:text-base">Date of submission</FormLabel>
<Popover modal={true} open={isSubCalendarOpen} onOpenChange={setIsSubCalendarOpen}> <Popover modal={true} open={isSubCalendarOpen} onOpenChange={setIsSubCalendarOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@ -213,9 +196,8 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<Calendar {/* @ts-ignore */}
mode="single" <Calendar mode="single" selected={field.value}
selected={field.value}
onSelect={(e) => { field.onChange(e); setIsSubCalendarOpen(false); }} onSelect={(e) => { field.onChange(e); setIsSubCalendarOpen(false); }}
disabled={(date) => disabled={(date) =>
date > new Date() || date < new Date("1900-01-01") date > new Date() || date < new Date("1900-01-01")
@ -224,7 +206,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormDescription> <FormDescription className="text-xs md:text-base">
The date you sent it The date you sent it
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@ -237,7 +219,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
name="responded" name="responded"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel>Date of response</FormLabel> <FormLabel className="text-sm md:text-base">Date of response</FormLabel>
<Popover modal={true} open={isRespCalendarOpen} onOpenChange={setIsRespCalendarOpen}> <Popover modal={true} open={isRespCalendarOpen} onOpenChange={setIsRespCalendarOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@ -258,9 +240,8 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<Calendar {/* @ts-ignore */}
mode="single" <Calendar mode="single" selected={field.value}
selected={field.value}
onSelect={(e) => { field.onChange(e); setIsRespCalendarOpen(false); }} onSelect={(e) => { field.onChange(e); setIsRespCalendarOpen(false); }}
disabled={(date) => disabled={(date) =>
date > new Date() || date < new Date("1900-01-01") date > new Date() || date < new Date("1900-01-01")
@ -269,7 +250,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormDescription> <FormDescription className="text-xs md:text-base">
The date they wrote back The date they wrote back
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@ -283,8 +264,8 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
name="responseId" name="responseId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Response</FormLabel> <FormLabel className="text-sm md:text-base">Response</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value?.toString()}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue> <SelectValue>
@ -296,7 +277,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
{reponsesSelectItems} {reponsesSelectItems}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription>The market you sent it to</FormDescription> <FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
export default function GenreBadges(props: ComponentProps<"div"> & { genres: Array<Genre> }) { export default function GenreBadges(props: ComponentProps<"div"> & { genres: Array<Genre> }) {
return ( return (
<div className={"flex flex-wrap gap-1 justify-center " + props.className}> <div className={"flex flex-wrap gap-1 justify-center " + props.className}>
{props.genres.map((e: Genre) => (<Badge key={e.name}>{e.name}</Badge>))} {props.genres.map((e: Genre) => (<Badge className="text-xs md:text-sm" key={e.name}>{e.name}</Badge>))}
</div> </div>
) )
} }

View File

@ -0,0 +1,26 @@
"use client"
import { Button } from "@/components/ui/button"
import { LogOutIcon } from "lucide-react"
import { useRouter } from "next/navigation"
export default function LogoutButton() {
const router = useRouter()
async function handleLogout() {
const res = await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Content-type': 'application/json',
}
})
console.log(res)
router.refresh()
}
return (
<Button variant="outline" className="w-fit" onClick={handleLogout} >
<LogOutIcon />
</Button>
)
}

View File

@ -4,6 +4,7 @@ import { usePathname } from "next/navigation";
import { ComponentProps } from "react"; import { ComponentProps } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { ArrowUpNarrowWide, BookOpen, BookOpenText } from "lucide-react";
function NavLink(props: ComponentProps<"div"> & { href: string }) { function NavLink(props: ComponentProps<"div"> & { href: string }) {
@ -14,21 +15,24 @@ function NavLink(props: ComponentProps<"div"> & { href: string }) {
export default function Navlinks(props: ComponentProps<"div">) { export default function Navlinks(props: ComponentProps<"div">) {
const pathname = usePathname() const pathname = usePathname()
const links = [ const links = [
{ link: "/story", label: "STORIES" }, { link: "/story", label: "STORIES", icon: <BookOpenText /> },
{ link: "/publication", label: "PUBLICATIONS" }, { link: "/publication", label: "PUBLICATIONS", icon: <BookOpen /> },
{ link: "/submission", label: "SUBMISSIONS" }, { link: "/submission", label: "SUBMISSIONS", icon: <ArrowUpNarrowWide /> },
] ]
return ( return (
<div className={props.className}> <div className={props.className}>
<div className="text-secondary-foreground" > <div className="text-secondary-foreground flex flex-row md:flex-col" >
{ {
links.map(e => (<NavLink key={e.link} href={e.link} links.map(e => (<NavLink key={e.link} href={e.link}
className={twMerge(clsx("text-xl drop-shadow font-black my-2 w-full p-2 pl-6 antialiased text-secondary-foreground bg-secondary rounded-l-3xl", className={twMerge(clsx("text-xl drop-shadow font-black my-2 w-full mr-2 p-2 pl-6 antialiased text-secondary-foreground bg-secondary rounded-3xl md:rounded-l-3xl ",
{ {
"text-primary-foreground bg-primary": pathname.includes(e.link) "text-primary-foreground bg-primary": pathname.includes(e.link)
} }
))} ))}
><p className="drop-shadow-sm">{e.label}</p></NavLink >)) >
<p className="drop-shadow-sm hidden md:block">{e.label}</p>
<span className="block md:hidden">{e.icon}</span>
</NavLink >))
} }
</ div> </ div>
</div> </div>

View File

@ -1,14 +1,10 @@
import { Dialog, DialogTrigger, DialogClose, DialogDescription, DialogContent, DialogTitle, DialogHeader, DialogFooter } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { ContextMenuContent, ContextMenuItem, ContextMenuSubTrigger, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent } from "@/components/ui/context-menu" import { ContextMenuContent, ContextMenuItem, ContextMenuSubTrigger, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent } from "@/components/ui/context-menu"
import { deleteRecord } from "app/lib/del"
import Link from "next/link" import Link from "next/link"
import { ComponentProps, useState } from "react" import { ComponentProps, useState } from "react"
import { Row, Table, TableState } from "@tanstack/react-table" import { Row, Table, TableState } from "@tanstack/react-table"
import { tableNameToItemName } from "app/lib/nameMaps"
import EditSubmissionDialog from "app/submission/edit"
export default function FormContextMenu({ table, row, openEditDialog, openDeleteDialog }: ComponentProps<"div"> & { table: Table<any>, row: Row<any>, openEditDialog: (row: Row<any>) => void, openDeleteDialog: (row: Row<any>) => void }) { export default function FormContextMenu({ table, row, openEditDialog, openDeleteDialog }: ComponentProps<"div"> & { table: Table<any>, row: Row<any>, openEditDialog: (row: Row<any>) => void, openDeleteDialog: (row: Row<any>) => void }) {
//@ts-ignore
const pathname = table.options.meta.pathname const pathname = table.options.meta.pathname
const selectedRows = table.getSelectedRowModel().flatRows const selectedRows = table.getSelectedRowModel().flatRows
@ -24,15 +20,9 @@ export default function FormContextMenu({ table, row, openEditDialog, openDelete
: "" : ""
} }
{ <ContextMenuItem onClick={() => openEditDialog(row)}>
pathname === "/submission" ? Edit
<> </ContextMenuItem>
<ContextMenuItem onClick={() => openEditDialog(row)}>
Edit
</ContextMenuItem>
</>
: ""
}
{ {
selectedRows.length > 0 ? selectedRows.length > 0 ?

View File

@ -2,8 +2,6 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
ContextMenu, ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
import { import {
@ -15,7 +13,7 @@ import {
DropdownMenuRadioGroup DropdownMenuRadioGroup
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Component, ComponentProps, use, useState } from "react" import { ComponentProps, useState } from "react"
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
@ -38,18 +36,22 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { EyeIcon, Trash2 } from "lucide-react" import { CircleHelp, EyeIcon, Filter, Trash2 } from "lucide-react"
import { usePathname } from "next/navigation" import { usePathname, useSearchParams } from "next/navigation"
import FormContextMenu from "./contextMenu" import FormContextMenu from "./contextMenu"
import { deleteRecord, deleteRecords } from "app/lib/del" import { deleteRecord, deleteRecords } from "app/lib/del"
import { Pathname } from "app/types" import { Pathname } from "app/types"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTrigger } from "@/components/ui/dialog" import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTrigger } from "@/components/ui/dialog"
import pluralize from "app/lib/pluralize" import pluralize from "app/lib/pluralize"
import { updateField } from "app/lib/update" import { updateField, updatePub, updateStory } from "app/lib/update"
import { tableNameToItemName } from "app/lib/nameMaps" import { tableNameToItemName } from "app/lib/nameMaps"
import { Genre, Pub, Response, Story } from "@prisma/client" import { Genre, Pub, Response, Story } from "@prisma/client"
import EditSubmissionDialog from "app/submission/edit" import EditSubmissionDialog from "app/submission/edit"
import { DialogTitle } from "@radix-ui/react-dialog" import { DialogTitle } from "@radix-ui/react-dialog"
import { toast } from "@/components/ui/use-toast"
import { useRouter } from "next/navigation"
import EditStoryDialog from "app/story/edit"
import EditPubDialog from "app/publication/edit"
export interface DataTableProps<TData, TValue> { export interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[] columns: ColumnDef<TData, TValue>[]
@ -78,7 +80,7 @@ export function DataTable<TData, TValue>({
const [columnVisibility, setColumnVisibility] = const [columnVisibility, setColumnVisibility] =
useState<VisibilityState>({}) useState<VisibilityState>({})
// //
const pathname: Pathname = usePathname() const pathname: string = usePathname()
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
@ -117,31 +119,31 @@ export function DataTable<TData, TValue>({
setIsDeleteDialogVisible(true) setIsDeleteDialogVisible(true)
SetDialogRow(row) SetDialogRow(row)
} }
function closeEditDialog() {
setIsEditDialogVisible(false)
}
const [filterBy, setFilterBy] = useState(table.getAllColumns()[0])
const router = useRouter()
const [filterBy, setFilterBy] = useState(table.getAllColumns().filter(e => e.getCanFilter())[0])
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false) const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
return (<> return (<>
<div className="flex justify-between items-center py-4"> <div className="flex gap-2 justify-between items-center py-1 md:py-4">
<div className="flex gap-2"> <div className="flex gap-1">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto"> <Button variant="outline" className="mx-0"> <p className="hidden md:block">Filter by</p><Filter className="block md:hidden" /> </Button>
Filter by
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{/*@ts-ignore*/}
<DropdownMenuRadioGroup value={filterBy} onValueChange={setFilterBy} > <DropdownMenuRadioGroup value={filterBy} onValueChange={setFilterBy} >
{table {table
.getAllColumns() .getAllColumns()
.filter((column) => column.getCanFilter()) .filter((column) => column.getCanFilter())
.map((column) => { //@ts-ignore
return ( .map((column) => { return (<DropdownMenuRadioItem value={column} className="capitalize" key={column.id}> {column.id} </DropdownMenuRadioItem>) })}
<DropdownMenuRadioItem value={column} className="capitalize" key={column.id}>
{column.id}
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -159,12 +161,30 @@ export function DataTable<TData, TValue>({
<Dialog open={isEditDialogVisible} onOpenChange={setIsEditDialogVisible}> <Dialog open={isEditDialogVisible} onOpenChange={setIsEditDialogVisible}>
<DialogContent> <DialogContent>
<EditSubmissionDialog {tableName === "sub" ?
stories={stories} <EditSubmissionDialog
pubs={pubs} stories={stories}
responses={responses} pubs={pubs}
defaults={dialogRow?.original} responses={responses}
/> defaults={dialogRow?.original}
closeDialog={closeEditDialog}
/>
: tableName === "story" ?
< EditStoryDialog
dbAction={updateStory}
genres={genres}
defaults={dialogRow?.original}
closeDialog={closeEditDialog}
/>
: tableName === "pub" ?
<EditPubDialog
dbAction={updatePub}
genres={genres}
defaults={dialogRow?.original}
closeDialog={closeEditDialog}
/>
: ""
}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -181,8 +201,12 @@ export function DataTable<TData, TValue>({
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button variant="destructive" <Button variant="destructive"
onClick={() => { onClick={async () => {
deleteRecord(dialogRow.original.id, pathname) const res = await deleteRecord(dialogRow.original.id, pathname)
if (!res) toast({ title: "Oh dear...", description: "Failed to delete." })
if (res) toast({ title: "Successfully deleted record of id:", description: dialogRow.original.id })
table.resetRowSelection()
router.refresh()
}}>Yes, delete it! }}>Yes, delete it!
</Button> </Button>
</DialogClose> </DialogClose>
@ -198,23 +222,29 @@ export function DataTable<TData, TValue>({
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete items?</DialogTitle>
{`Delete ${Object.keys(table.getState().rowSelection).length} ${pluralize(pathname.slice(1))}?`} {`Delete ${Object.keys(table.getState().rowSelection).length} ${pluralize(pathname.slice(1))}?`}
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
{/* @ts-ignore */}
{`Deleting ${pluralize(tableNameToItemName(table.options.meta.tableName))} cannot be undone!`} {`Deleting ${pluralize(tableNameToItemName(table.options.meta.tableName))} cannot be undone!`}
</DialogDescription> </DialogDescription>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <Button variant="destructive"
<Button variant="destructive" onClick={async () => {
onClick={() => { const selectedRows = table.getState().rowSelection
const selectedRows = table.getState().rowSelection const rowIds = Object.keys(selectedRows)
const rowIds = Object.keys(selectedRows) //@ts-ignore
const recordIds = rowIds.map(id => Number(table.getRow(id).original.id)) const recordIds = rowIds.map(id => Number(table.getRow(id).original.id))
console.table(recordIds) //@ts-ignore
deleteRecords(recordIds, pathname) const res = await deleteRecords(recordIds, pathname)
}}> if (!res) toast({ title: "Oh dear...", description: "Failed to delete." })
Yes, delete them!</Button> if (res) toast({ title: "Successfully deleted records of id:", description: JSON.stringify(recordIds) })
</DialogClose> table.resetRowSelection()
setIsDeleteDialogVisible(false)
router.refresh()
}}>
Yes, delete them!</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -250,7 +280,8 @@ export function DataTable<TData, TValue>({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="rounded-md border"> <div className="rounded-md">
<Table> <Table>
<TableHeader> <TableHeader>
@ -273,36 +304,45 @@ export function DataTable<TData, TValue>({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => {
<ContextMenu key={row.id + "contextMenu"}> const classes = () => {
<ContextMenuTrigger asChild> const classes = []
<TableRow if (row.getValue('response') === "Pending") classes.push("bg-accent")
key={row.id} if (row.getValue('response') === "Acceptance") classes.push("bg-primary")
data-state={row.getIsSelected() && "selected"} return classes.join(" ")
tabIndex={0} }
onDoubleClick={() => { return (
if (tableName === "sub") { <ContextMenu key={row.id + "contextMenu"}>
openEditDialog(row) <ContextMenuTrigger asChild>
<TableRow
key={row.id}
className={classes()}
data-state={row.getIsSelected() && "selected"}
tabIndex={0}
onDoubleClick={() => {
if (tableName === "sub") {
openEditDialog(row)
}
} }
} }
} >
> {row.getVisibleCells().map((cell) => (
{row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}>
<TableCell key={cell.id}> {flexRender(cell.column.columnDef.cell, cell.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())} </TableCell>
</TableCell> ))}
))} <FormContextMenu
<FormContextMenu key={"formContextMenu" + row.id}
key={"formContextMenu" + row.id} row={row}
row={row} table={table}
table={table} openEditDialog={openEditDialog}
openEditDialog={openEditDialog} openDeleteDialog={openDeleteDialog}
openDeleteDialog={openDeleteDialog} />
/> </TableRow>
</TableRow> </ContextMenuTrigger>
</ContextMenuTrigger> </ContextMenu>
</ContextMenu> )
)) })
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center"> <TableCell colSpan={columns.length} className="h-24 text-center">

View File

@ -3,7 +3,7 @@ import { FormField, FormItem, FormLabel, FormMessage, FormControl, Form } from "
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { ComponentProps, useState } from "react" import { BaseSyntheticEvent, ComponentProps, EventHandler, useState } from "react"
import { EventType, useForm, UseFormReturn } from "react-hook-form" import { EventType, useForm, UseFormReturn } from "react-hook-form"
import { CellContext } from "@tanstack/react-table" import { CellContext } from "@tanstack/react-table"
import { z } from "zod" import { z } from "zod"
@ -12,36 +12,38 @@ import { toast } from "@/components/ui/use-toast"
import GenreBadges from "app/ui/genreBadges" import GenreBadges from "app/ui/genreBadges"
import { updateField, updateGenres } from "app/lib/update" import { updateField, updateGenres } from "app/lib/update"
import { Genre } from "@prisma/client" import { Genre } from "@prisma/client"
import { useRouter } from "next/navigation"
export default function GenrePickerInputCell(props: CellContext<any, any>) { export default function GenrePickerInputCell(props: CellContext<any, any>) {
//@ts-ignore
const table = props.table.options.meta.tableName const table = props.table.options.meta.tableName
//@ts-ignore
const pathname = props.table.options.meta.pathname const pathname = props.table.options.meta.pathname
const id = props.row.original.id const id = props.row.original.id
const column = props.column.id const column = props.column.id
const value = props.cell.getValue() const value = props.cell.getValue()
//@ts-ignore
const genres = props.table.options.meta.genres const genres = props.table.options.meta.genres
const [isActive, setIsActive] = useState(false) const [isActive, setIsActive] = useState(false)
const router = useRouter()
async function onSubmit({ genres }: { genres: number[] }) { async function onSubmit({ genres }: { genres: number[] }, event: BaseSyntheticEvent) {
event.preventDefault() event.preventDefault()
const genresArray = genres.map((e) => { return { id: e } }) try {
console.log(`genres: ${genres}, genresArray: ${JSON.stringify(genresArray)}`) const genresArray = genres.map((e) => { return { id: e } })
toast({ const res = await updateGenres({
title: "You submitted the following values:", id,
description: ( table,
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> genres: genresArray,
<code className="text-white">{JSON.stringify(genres)}</code> pathname
</pre> })
), if (res === undefined) throw new Error("Something went wrong.")
}) toast({ title: "Field updated successfully." })
const res = await updateGenres({ router.refresh()
id, } catch (error) {
table, console.error(error)
genres: genresArray, toast({ title: "Something went wrong." })
pathname }
})
setIsActive(false) setIsActive(false)
} }
@ -77,9 +79,9 @@ export default function GenrePickerInputCell(props: CellContext<any, any>) {
control={form.control} control={form.control}
name="genres" name="genres"
render={({ field }) => ( render={({ field }) => (
<FormItem className="w-full max-w-xs flex flex-col"> <FormItem className="w-full max-w-32 flex flex-col">
<PopoverTrigger asChild> <PopoverTrigger asChild>
{value.length > 0 ? <Button variant="ghost" className="h-fit"><GenreBadges genres={value} className="w-full" /></Button> : <Button variant="outline" type="button" className="w-fit m-auto">Add genres</Button> {value.length > 0 ? <Button variant="ghost" className="h-fit p-0"><GenreBadges genres={value} className="w-full" /></Button> : <Button variant="outline" type="button" className="text-xs md:text-sm w-fit m-auto">Add genres</Button>
} }
</PopoverTrigger> </PopoverTrigger>
@ -101,7 +103,6 @@ export default function GenrePickerInputCell(props: CellContext<any, any>) {
<Checkbox <Checkbox
checked={field.value?.includes(item.id)} checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
console.log(field.value)
return checked return checked
? field.onChange( ? field.onChange(
[...field.value, item.id] [...field.value, item.id]

View File

@ -9,58 +9,56 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
export default function NumberInputCell(props: CellContext<any, any>) { export default function NumberInputCell({ cellContext, className }: { cellContext: CellContext<any, any>, className: string }) {
const [isActive, setIsActive] = useState(false) const [isActive, setIsActive] = useState(false)
const table = props.table.options.meta.tableName //@ts-ignore
const id = props.row.original.id const table = cellContext.table.options.meta.tableName
const column = props.column.id const id = cellContext.row.original.id
const pathname = props.table.options.meta.pathname const column = cellContext.column.id
const value = props.cell.getValue() //@ts-ignore
const formSchema = props.column.columnDef.meta.formSchema.pick({ [column]: true }) const pathname = cellContext.table.options.meta.pathname
const value = cellContext.cell.getValue()
//@ts-ignore
const formSchema = cellContext.column.columnDef.meta.formSchema.pick({ [column]: true })
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
[column]: props.cell.getValue() [column]: cellContext.cell.getValue()
}, },
}) })
function onSubmit(value: z.infer<typeof formSchema>) { async function onSubmit(value: z.infer<typeof formSchema>) {
toast({ try {
title: "You submitted the following values:", const res = await updateField({
description: ( id,
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> table,
<code className="text-white">{JSON.stringify(value, null, 2)}</code> datum: value[column],
</pre> column,
), pathname
}) })
updateField({ if (res === undefined) throw new Error("something went wrong")
id, toast({ title: "Field updated successfully." })
table, } catch (error) {
number: value[column], console.error(error)
column, toast({ title: "Something went wrong." })
pathname }
})
setIsActive(false) setIsActive(false)
} }
function onErrors(errors) { function onErrors(errors: Error) {
toast({ toast({
title: "You have errors", title: "You have errors",
description: ( description: errors.message,
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(errors, null, 2)}</code>
</pre>
),
}) })
console.log(JSON.stringify(errors)) console.log(JSON.stringify(errors))
} }
return ( return (
<div <div
onDoubleClick={() => setIsActive(prev => !prev)} onDoubleClick={() => setIsActive(true)}
className="w-full h-fit flex items-center justify-center" className={className + " w-full h-fit flex items-center justify-center"}
tabIndex={0} tabIndex={0}
onKeyDown={e => { onKeyDown={e => {
if (e.code === "Enter" && !isActive) { if (e.code === "Enter" && !isActive) {
@ -82,13 +80,8 @@ export default function NumberInputCell(props: CellContext<any, any>) {
> >
<FormControl <FormControl
> >
<Input {/* @ts-ignore */}
className="w-24" <Input className="md:w-24" type="number" autoFocus={true} step={cellContext.column.columnDef.meta?.step} {...field} />
type="number"
autoFocus={true}
step={props.column.columnDef.meta?.step}
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -97,7 +90,7 @@ export default function NumberInputCell(props: CellContext<any, any>) {
</form> </form>
</Form> </Form>
: <p>{props.cell.getValue()}</p> : <p>{cellContext.cell.getValue()}</p>
} }
</div > </div >
) )

View File

@ -9,40 +9,43 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@/components/ui/use-toast"; import { toast } from "@/components/ui/use-toast";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import TitleContainer from "app/ui/titleContainer"; import TitleContainer from "app/ui/titleContainer";
import { useRouter } from "next/navigation";
export function TextInputCell(props: CellContext<any, any>) { export function TextInputCell({ cellContext, className }: { className: string, cellContext: CellContext<any, any> }) {
const [isActive, setIsActive] = useState(false) const [isActive, setIsActive] = useState(false)
//@ts-ignore
const table = props.table.options.meta.tableName const table = cellContext.table.options.meta.tableName
const id = props.row.original.id const id = cellContext.row.original.id
const column = props.column.id const column = cellContext.column.id
const pathname = props.table.options.meta.pathname //@ts-ignore
const value = props.cell.getValue() const pathname = cellContext.table.options.meta.pathname
const formSchema = props.column.columnDef.meta.formSchema.pick({ [column]: true }) const value = cellContext.cell.getValue()
//@ts-ignore
const formSchema = cellContext.column.columnDef.meta.formSchema.pick({ [column]: true })
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
[column]: props.cell.getValue() [column]: cellContext.cell.getValue()
}, },
}) })
const router = useRouter()
async function onSubmit(value: z.infer<typeof formSchema>) { async function onSubmit(value: z.infer<typeof formSchema>) {
toast({ try {
title: "You submitted the following values:", const res = await updateField({
description: ( id,
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4"> table,
<code className="text-white">{JSON.stringify(value, null, 2)}</code> datum: value[column],
</pre> column,
), pathname
}) })
const res = await updateField({ if (res === undefined) throw new Error("something went wrong")
id, toast({ title: "Field updated successfully." })
table, router.refresh()
datum: value[column], } catch (error) {
column, console.error(error)
pathname toast({ title: "Something went wrong." })
}) }
setIsActive(false) setIsActive(false)
} }
@ -60,7 +63,7 @@ export function TextInputCell(props: CellContext<any, any>) {
return ( return (
<div <div
onDoubleClick={() => setIsActive(prev => !prev)} onDoubleClick={() => setIsActive(prev => !prev)}
className="w-full h-fit flex items-center justify-left" className={className + " w-full h-fit flex items-center justify-left"}
tabIndex={0} tabIndex={0}
onKeyDown={e => { onKeyDown={e => {
if (e.code === "Enter" && !isActive) { if (e.code === "Enter" && !isActive) {
@ -96,7 +99,7 @@ export function TextInputCell(props: CellContext<any, any>) {
</form> </form>
</Form> </Form>
: <TitleContainer>{props.cell.getValue()}</TitleContainer> : <TitleContainer>{cellContext.cell.getValue()}</TitleContainer>
} }
</div > </div >
) )

View File

@ -5,8 +5,9 @@ export const selectCol = {
id: "select", id: "select",
header: (props: HeaderContext<any, any>) => { header: (props: HeaderContext<any, any>) => {
return ( return (
<div className="flex items-center justify-center"> <div className="flex items-start justify-left mx-auto">
<Checkbox <Checkbox
className="mr-4 ml-2"
checked={props.table.getIsAllRowsSelected()} checked={props.table.getIsAllRowsSelected()}
onCheckedChange={props.table.toggleAllRowsSelected} onCheckedChange={props.table.toggleAllRowsSelected}
aria-label="select/deselect all rows" aria-label="select/deselect all rows"
@ -15,11 +16,12 @@ export const selectCol = {
) )
}, },
enableColumnFilter: false,
cell: (props: CellContext<any, any>) => { cell: (props: CellContext<any, any>) => {
return ( return (
<div className="flex items-center justify-center"> <div className="flex items-start justify-left">
<Checkbox <Checkbox
className="mr-4 ml-2"
checked={props.row.getIsSelected()} checked={props.row.getIsSelected()}
onCheckedChange={props.row.toggleSelected} onCheckedChange={props.row.toggleSelected}
aria-label="select/deselect row" aria-label="select/deselect row"

View File

@ -1,11 +1,9 @@
import { ComponentProps } from "react"; import { ComponentProps } from "react";
export default function itleContainer({ children }: ComponentProps<"div">) { export default function TitleContainer({ children }: ComponentProps<"div">) {
let classes = "w-full text-left m-auto" let classes = "w-full text-left m-auto h-fit flex align-center text-xs md:text-sm"
console.table(children)
if (children == "RECORD DELETED") { if (children == "RECORD DELETED") {
console.log("BINGO")
classes = classes + " text-destructive font-bold" classes = classes + " text-destructive font-bold"
} }
return <span className="h-10 flex align-center"><p className={classes}>{children}</p></span> return <p className={classes}>{children}</p>
} }

78
src/middleware.ts Normal file
View File

@ -0,0 +1,78 @@
"use server"
import { NextRequest, NextResponse } from "next/server";
import { verifyJwt } from "app/api/auth/actions";
const protectedRoutes = ['/story', '/submission', '/publication']
// Function to match the * wildcard character
function matchesWildcard(path: string, pattern: string): boolean {
if (pattern.endsWith('/*')) {
const basePattern = pattern.slice(0, -2);
return path.startsWith(basePattern);
}
return path === pattern;
}
export default async function(request: NextRequest): Promise<NextResponse> | undefined {
const url = request.nextUrl.clone()
url.pathname = "/login"
url.searchParams.set('from', request.nextUrl.pathname)
if (protectedRoutes.some(pattern => matchesWildcard(request.nextUrl.pathname, pattern))) {
const token = request.cookies.get('token')
//NOTE - may need to add logic to return 401 for api routes
if (!token) {
console.log("there is no jwt")
return NextResponse.redirect(url)
}
try {
//decode and verify jwt cookie
const jwtIsVerified = await verifyJwt(token.value)
if (!jwtIsVerified) {
//delete token
console.log('could not verify jwt')
request.cookies.delete('token')
return NextResponse.redirect(url)
}
} catch (error) {
//delete token (failsafe)
console.error("failed to very jwt", error.message)
request.cookies.delete('token')
return NextResponse.redirect(url)
}
//TODO - TEST THIS BECAUSE IT PROBABLY DOESN'T WORK
//redirect from login if already logged in
let redirectToApp = false
if (request.nextUrl.pathname === "/login") {
const token = request.cookies.get("token")
if (token) {
try {
const payload = await verifyJwt(token.value)
if (payload) {
redirectToApp = true
} else {
request.cookies.delete('token')
}
} catch (error) {
request.cookies.delete('token')
}
}
}
if (redirectToApp) {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL}/submission`)
} else {
return NextResponse.next()
}
}
}

31
tests/login.spec.ts Normal file
View File

@ -0,0 +1,31 @@
import { test, expect } from '@playwright/test'
test('should redirect to login page if not logged in', async ({ page }) => {
await page.goto('/')
await page.click('text=Stories')
await expect(page).toHaveURL('/login?from=%2Fstory')
await page.click('text=Publications')
await expect(page).toHaveURL('/login?from=%2Fpublication')
await page.click('text=Submissions')
await expect(page).toHaveURL('/login?from=%2Fsubmission')
})
test('positive login', async ({ page }) => {
await page.goto('/login')
await page.getByRole('textbox', { name: 'email' }).fill('demo@demo.demo')
await page.getByRole('textbox', { name: 'password' }).fill('password')
await page.getByRole('button', { name: 'submit' }).click()
await page.waitForURL('**/submission', { timeout: 5000 })
await expect(page).toHaveURL('/submission');
})
test('negative login', async ({ page }) => {
await page.goto('/login')
await page.getByRole('textbox', { name: 'email' }).fill('demo@demo.negative')
await page.getByRole('textbox', { name: 'password' }).fill('negative')
await page.getByRole('button', { name: 'submit' }).click()
await expect(page.getByText("login failed!")).toBeTruthy()
})