Compare commits

..

136 Commits

Author SHA1 Message Date
andrzej 9950814ca6 improve title styling 2024-08-06 12:03:21 +02:00
andrzej 71f5a44c8e give each genrePicker cell a unique form id 2024-07-24 22:04:30 +02:00
andrzej cd90c92c6d improve genre picker cell 2024-07-24 17:24:04 +02:00
andrzej 055b3c254d fix missing keys 2024-07-24 17:23:42 +02:00
andrzej 79e3403902 implement genre picker cell (janky) 2024-07-23 17:40:35 +02:00
andrzej 41951a2ac6 move edit submission dialog to context menu 2024-07-21 19:14:20 +02:00
andrzej 0fa28a46eb edit submission, fix client side data validation 2024-07-20 14:07:13 +02:00
andrzej 6ee4128c85 edit submission functionality (partial) 2024-07-20 11:52:19 +02:00
andrzej aec413ba7a rearrange sub cols 2024-07-04 18:31:22 +02:00
andrzej 57cc55f414 delete unused imports 2024-07-03 00:41:20 +02:00
andrzej bc244497cd make inputs work with Enter 2024-07-02 23:01:26 +02:00
andrzej 29ab837aca fix added space 2024-07-02 22:52:10 +02:00
andrzej 1bad3ba5f8 Revert "make form input work with number and string"
This reverts commit 2294d0c0b0.
2024-07-02 22:16:17 +02:00
andrzej 2294d0c0b0 make form input work with number and string 2024-07-02 19:49:00 +02:00
andrzej 2e1409cf46 make fields open with space (to avoid conflicts) 2024-07-02 17:18:23 +02:00
andrzej f3dbd2cb9e fix enter key buy (partially) 2024-07-02 17:11:30 +02:00
andrzej 5206e415ed implement number input data validation (basic functionality) 2024-07-01 17:23:48 +02:00
andrzej fe9878cb7a implement inline number input 2024-06-30 23:28:05 +02:00
andrzej b6a56fca2b make update function concise/flexible 2024-06-30 23:21:48 +02:00
andrzej 8c4b9d27f2 improve tab nav 2024-06-30 20:28:02 +02:00
andrzej 5ea7a61915 add get method 2024-06-30 20:15:57 +02:00
andrzej 7c6f06e194 add open handler 2024-06-30 20:15:30 +02:00
andrzej 540413173c remove unneccessary buttons 2024-06-30 17:42:33 +02:00
andrzej 330226ecd6 implement inline text input 2024-06-30 17:36:44 +02:00
andrzej 23584a0a50 select checkboxes 2024-06-30 14:22:35 +02:00
andrzej 8ad3583c4e begin implementation of edit feature 2024-06-29 21:38:21 +02:00
andrzej c6496130e3 add deselect option to context menu 2024-06-29 16:26:46 +02:00
andrzej 97a537f5a2 fix multi-delete 2024-06-27 23:12:53 +02:00
andrzej 1fca1a2b81 partially implement multi-delete button 2024-06-27 16:49:56 +02:00
andrzej 4aa7194427 enable row selection 2024-06-27 16:08:14 +02:00
andrzej f163de99c8 add multi delete function 2024-06-27 16:08:03 +02:00
andrzej 10408f604a fix layout for smaller (laptop) screens 2024-06-27 15:35:48 +02:00
andrzej ee2a7c4cbf context menu -- keyboard navigation 2024-06-27 12:44:31 +02:00
andrzej 06fb2831ef context menu initial import 2024-06-26 22:52:33 +02:00
andrzej 5b919db59b neaten labels, capitalizations 2024-06-26 21:41:52 +02:00
andrzej 06b69b5ce7 fix focus issues on nested form items
now aria compliant!
2024-06-26 21:40:03 +02:00
andrzej 54a001183a implement basic create submission popover functionality 2024-06-26 19:55:18 +02:00
andrzej d210b13bde implement create pubs popup 2024-06-26 19:33:12 +02:00
andrzej 45af32d091 extrapolate genre picker 2024-06-26 18:21:06 +02:00
andrzej 0c39838f6a add createStoryDialog 2024-06-26 18:19:09 +02:00
andrzej 788051fa10 extrapolate create function 2024-06-26 18:18:44 +02:00
andrzej 191457d6c1 make table clickable 2024-06-26 14:54:04 +02:00
andrzej b5745a3c05 add edit button 2024-06-26 12:45:58 +02:00
andrzej 878daf35bb disable inspect button for submissions 2024-06-26 12:42:18 +02:00
andrzej 494521d51d clean up 2024-06-26 12:07:33 +02:00
andrzej aeb7bc1f6f add redirect 2024-06-26 12:06:55 +02:00
andrzej bea291aa92 clean up story inspect page 2024-06-26 12:06:12 +02:00
andrzej a691250637 add pub inspect page 2024-06-26 12:05:58 +02:00
andrzej 7dd912d6f6 charts 2024-06-25 22:50:54 +02:00
andrzej be83489ea6 add test data to db 2024-06-25 12:21:07 +02:00
andrzej 4145d84d65 improve client side data validation 2024-06-25 12:20:56 +02:00
andrzej ef70bd9d92 properly render null date values 2024-06-25 12:20:41 +02:00
andrzej e1e4ce23f1 delete unneccessary 2024-06-25 12:20:28 +02:00
andrzej 5563de438a clearer headings 2024-06-25 12:20:10 +02:00
andrzej c2bfee6b87 fix create pages 2024-06-25 11:18:30 +02:00
andrzej a4a2ba35cd fix pub create form styling 2024-06-24 23:15:48 +02:00
andrzej 21bee8cc8b add loaders 2024-06-24 23:15:29 +02:00
andrzej 447b4a7edd extrapolate loader 2024-06-24 22:27:36 +02:00
andrzej 26eb4cd9eb use Link instead of onClick in data-table 'create button' 2024-06-24 19:01:11 +02:00
andrzej e91caeb51c style field labels 2024-06-24 18:57:35 +02:00
andrzej 10b512bb5c style main header 2024-06-24 18:57:25 +02:00
andrzej c2107b14a3 misc styling 2024-06-24 18:50:16 +02:00
andrzej 27b368e0cb delete unused 2024-06-24 18:33:59 +02:00
andrzej c4b61069fd style create forms 2024-06-24 18:33:03 +02:00
andrzej be32c7e0a6 style input 2024-06-24 18:29:03 +02:00
andrzej 8d2bf53a1c add styled component for create pages, implement at create/story 2024-06-24 12:29:02 +02:00
andrzej be765fda2a db changes 2024-06-24 12:28:24 +02:00
andrzej b32aabcd08 remove unused import 2024-06-24 11:56:06 +02:00
andrzej f245b8d72d merge navlinks styling 2024-06-24 11:55:31 +02:00
andrzej 04688feb28 add mode toggle 2024-06-24 11:55:19 +02:00
andrzej febaec3220 make badge more legible 2024-06-24 11:53:48 +02:00
andrzej 40f2360ebd add tabletype 2024-06-24 10:27:53 +02:00
andrzej 1546a2ff31 style inspect button 2024-06-24 10:16:05 +02:00
andrzej c8f374f754 extrapolate actions column 2024-06-22 20:28:21 +02:00
andrzej c8b25c36f5 improve nav and layout styling 2024-06-22 18:12:55 +02:00
andrzej 1db71fb21b import globals.css NOT tailwind.css!
I was scratching my head about this for ages
2024-06-22 17:51:15 +02:00
andrzej a9257c2825 correct content field 2024-06-22 17:30:11 +02:00
andrzej 4f41415a80 remove unused import 2024-06-22 17:29:31 +02:00
andrzej 98b2b1e3cc add create link 2024-06-22 17:29:14 +02:00
andrzej c3ee490ce5 install color themes, tweak styling 2024-06-21 00:31:48 +02:00
andrzej e1391bec62 style layout 2024-06-20 23:21:37 +02:00
andrzej 5aaa45cade style navlinks 2024-06-20 20:02:25 +02:00
andrzej 96db18580e add logo 2024-06-20 12:54:56 +02:00
andrzej 782ccb76f5 style layoutr 2024-06-20 12:49:24 +02:00
andrzej c63175a0f8 implement basic story[id] page 2024-06-20 12:29:56 +02:00
andrzej d87eb3b342 implement submissions table 2024-06-20 11:39:35 +02:00
andrzej c7149fc8af implement publications table 2024-06-20 10:35:25 +02:00
andrzej 4a8b6f72df notice that filter doesn't work for nested objects 2024-06-19 23:51:55 +02:00
andrzej 8c62f7addf install radix dialog 2024-06-19 23:22:13 +02:00
andrzej 6ceb035b19 add layout links 2024-06-19 23:21:56 +02:00
andrzej fdf05f2b4c add delete dialog 2024-06-19 23:21:51 +02:00
andrzej ec96a1e988 add inspect button 2024-06-19 22:11:09 +02:00
andrzej 6dc05d2610 better delete icon 2024-06-19 21:58:19 +02:00
andrzej d8a2f3df7e install badge 2024-06-19 21:53:42 +02:00
andrzej b891780881 fix post-delete revalidation 2024-06-19 21:53:36 +02:00
andrzej 525f716f16 partially implement del function 2024-06-19 19:46:30 +02:00
andrzej 7b68a7451e add deleteStory function, fix schema to allow it 2024-06-19 18:01:42 +02:00
andrzej f454f6739e clean up imports 2024-06-19 13:14:41 +02:00
andrzej 99b2fb9628 style table 2024-06-19 13:14:23 +02:00
andrzej 7b994ec06e add genre badges 2024-06-19 13:14:12 +02:00
andrzej 52a30ec141 normalize typing 2024-06-19 13:13:54 +02:00
andrzej 3a91fd7cb4 merge in laptop work
Merge branch 'main' of 192.168.0.184:andrzej/subman-nextjs
2024-06-19 12:40:06 +02:00
andrzej 6839c1c369 tweaks 2024-06-19 11:54:07 +02:00
andrzej 285cef524c 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 9583d0da16 add definablei, reusable filtering 2024-06-19 11:34:57 +02:00
andrzej b5b8d8ad09 add fetched data to story table 2024-06-19 11:33:53 +02:00
andrzej 13a9407caa include genres in getStory api call 2024-06-19 11:33:27 +02:00
andrzej 0eb09073ca db updates 2024-06-17 23:31:24 +02:00
andrzej de2c8991c6 add create server actions 2024-06-17 23:23:09 +02:00
andrzej fff436f87c 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 e7f0cf3fb6 delete test entries 2024-06-17 13:52:09 +02:00
andrzej 1f3655f14c add createStory server action 2024-06-17 13:51:24 +02:00
andrzej 6080037d83 add radix components to publication create form 2024-06-17 12:41:21 +02:00
andrzej 688c260f4b Revert "add fully abstracted genrePicker"
This reverts commit 3a56b1f31e.
2024-06-17 12:20:52 +02:00
andrzej 3a56b1f31e add fully abstracted genrePicker 2024-06-17 10:47:44 +02:00
andrzej 2d340983e6 abstract genre checkbox components 2024-06-16 23:08:17 +02:00
andrzej 3151236ca0 improve genres popover 2024-06-16 17:16:43 +02:00
andrzej d4d73750b3 add rudimentary popover for checkboxes 2024-06-14 22:44:13 +02:00
andrzej 5ad03054a9 make checkboxes submit correct data 2024-06-14 22:41:41 +02:00
andrzej 7539b8a577 working shadcn date picker 2024-06-14 11:42:31 +02:00
andrzej 02d111098d radix select boxes working 2024-06-14 11:25:18 +02:00
andrzej b604ed48da still trying to get shadcn selects to work dynamically 2024-06-13 21:52:44 +02:00
andrzej f503647469 add dynamically fetched checkboxes 2024-06-13 12:11:09 +02:00
andrzej f3e5233171 trial shadcn form 2024-06-12 17:53:19 +02:00
andrzej 47756280d9 build / import table 2024-06-12 17:15:22 +02:00
andrzej 58d1fd1ed4 install and setup shadcn 2024-06-12 14:59:31 +02:00
andrzej ce8b52cb87 install and set up tailwind 2024-06-12 14:48:37 +02:00
andrzej c3ae4721d4 move get functions to lib 2024-06-12 12:19:44 +02:00
andrzej 438599a530 add forms+ 2024-06-12 11:32:15 +02:00
andrzej 50409895c0 add createStory action 2024-06-11 19:21:14 +02:00
andrzej ed8e71694f add create story page 2024-06-11 19:14:30 +02:00
andrzej d54b8180ce fix imports 2024-06-11 19:14:08 +02:00
andrzej b36f0edfb1 rename db with .ts extension
(it turns out typescript/nextjs is buggy with mjs)
2024-06-11 19:13:37 +02:00
andrzej 25f8f728c9 add lettercase function 2024-06-11 19:12:52 +02:00
andrzej d1c69c9c15 attempt to fix imports 2024-06-11 17:17:01 +02:00
andrzej dd07c259ac prisma env 2024-06-11 15:37:41 +02:00
67 changed files with 1045 additions and 2814 deletions

7
.env Normal file
View File

@ -0,0 +1,7 @@
# 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,15 +34,3 @@ 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
View File

@ -1,42 +0,0 @@
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,18 +1,36 @@
# Subman 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).
## A self-hosted literary submission manager
I developed this project as a demonstration of my full-stack development abilities, utilizing: ## Getting Started
- Nextjs First, run the development server:
- Tailwind
- heavily customised Shadcn components
- an Sqlite database with Prisma ORM as intermediary
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! ```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
## What it does Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
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. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
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,11 +1,4 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {};
output: "standalone",
// basePath: "/subman",
webpack: (config) => {
config.externals = [...config.externals, "bcrypt"];
return config;
},
};
export default nextConfig; export default nextConfig;

835
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,15 +5,13 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build && ./package.sh", "build": "next build",
"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",
"@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",
@ -26,17 +24,12 @@
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-table": "^8.17.3", "@tanstack/react-table": "^8.17.3",
"@types/bcrypt": "^5.0.2",
"bcrypt": "^5.1.1",
"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",
"jose": "^5.8.0",
"lucide": "^0.445.0",
"lucide-react": "^0.394.0", "lucide-react": "^0.394.0",
"next": "^14.2.13", "next": "14.2.3",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"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",
@ -44,11 +37,9 @@
"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",
"text-encoding": "^0.7.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.47.2",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",

View File

@ -1,13 +0,0 @@
#!/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

View File

@ -1,79 +0,0 @@
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

@ -10,13 +10,6 @@ datasource db {
url = "file:./dev.db" url = "file:./dev.db"
} }
model User {
id Int @id @default(autoincrement())
email String
password String
}
model Story { model Story {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
word_count Int word_count Int

View File

@ -1,24 +1,27 @@
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-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", "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className 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 md:px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", "h-12 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("md:p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props} {...props}
/> />
)) ))

View File

@ -1,145 +0,0 @@
"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,65 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,15 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ThemeProvider } from "./ui/theme"; import { ThemeProvider } from "./ui/theme";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import "./globals.css"; 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 LogoutButton from "./ui/logoutButton";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Subman", title: "Subman",
@ -17,6 +15,7 @@ export const metadata: Metadata = {
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
@ -31,23 +30,21 @@ export default function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
<div id="layout-container" className="md:p-4 w-screen h-screen mt-2 md:mt-6 flex justify-center"> <div id="layout-container" className="p-4 w-screen h-screen mt-6 flex justify-center">
<div className="w-full md:w-5/6 flex flex-col md:flex-row"> <div className="w-5/6 flex">
<div id="sidebar" className=" flex flex-row md:flex-col justify-between items-center"> <header className=""> <div id="sidebar" className="h-5/6 flex flex-col"> <header className="">
<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 <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>
">SubMan</h1> <p className="mt-2 mx-1 text-sm antialiased w-40">The self-hosted literary submission tracker.</p>
<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="md:mt-6" /> <Navlinks className="mt-6" />
<footer className="my-auto md:mt-auto flex justify-center"><ModeToggle /><LogoutButton /> <footer className="mt-auto"><ModeToggle /></footer>
</footer>
</div> </div>
<div className="flex justify-center w-screen"> <div className="flex justify-center w-full">
{children} {children}
</div> </div>
</div> </div>
</div> </div>
<Toaster test-id="toast" /> <Toaster />
</ThemeProvider> </ThemeProvider>
</body> </body>
</html > </html >

View File

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

View File

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

15
src/app/lib/edit.ts Normal file
View File

@ -0,0 +1,15 @@
"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")
}

View File

@ -1,14 +0,0 @@
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,4 +1,4 @@
export default function pluralize(word: string): string { export default function pluralize(word: "story" | "publication" | "submission"): string {
const map = { const map = {
story: "stories", story: "stories",
publication: "publications", publication: "publications",

View File

@ -1,113 +1,30 @@
"use server" "use server"
import { prepGenreData, prepPubData, prepStoryData } from "./validate" import { Genre } from "@prisma/client"
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 { subSchema } from "app/ui/forms/schemas" import { redirect } from "next/navigation"
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 }) {
"use server" const res = await prisma[table].update({
try { where: { id },
const res = await prisma[table].update({ data: {
where: { id }, [column]: datum
data: { }
[column]: datum })
} console.log(`updated record in ${table}: ${JSON.stringify(res)}`)
}) revalidatePath(pathname)
console.log(`updated record in ${table}: ${JSON.stringify(res)}`) redirect(pathname)
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 }) {
"use server" const res = await prisma[table].update({
try { where: { id },
const res = await prisma[table].update({ data: {
where: { id }, genres: { set: genres }
data: { }
genres: { set: genres } })
} console.log(`updated record in ${table}: ${JSON.stringify(res)}`)
}) revalidatePath(pathname)
console.log(`updated record in ${table}: ${JSON.stringify(res)}`) redirect(pathname)
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
}
}

View File

@ -1,39 +0,0 @@
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

@ -1,90 +0,0 @@
"use client"
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "@/components/ui/use-toast";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { redirect } from "next/navigation";
import { loginSchema } from "./schema";
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import revalidate from "./revalidate";
import { useState } from "react";
import Link from "next/link";
export default function LoginForm() {
const router = useRouter()
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!" })
}
})
return (
<main className="flex flex-col items-center justify-around h-60 w-26">
{submitted ? <div className="flex flex-col items-center justify-around h-30 w-26">
<h1>Logging in...</h1>
<Button asChild>
<Link href={redirect}>Continue</Link>
</Button>
</div> :
<Form {...form}>
<form onSubmit={onSubmit} className="flex flex-col items-center space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input placeholder="email goes here" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
></FormField>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password goes here" type="password"{...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
></FormField>
<Button type="submit" className="mt-4">SUBMIT</Button>
</form>
</Form>
}
</main>
)
}

View File

@ -1,11 +0,0 @@
"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
}
}

View File

@ -1,7 +0,0 @@
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,28 +1,13 @@
import Link from "next/link"; import Image from "next/image";
import { Button } from "@/components/ui/button"; import styles from "./page.module.css";
export default function Home() { export default function Home() {
return ( return (
<main className="flex flex-col gap-4 items-center justify-around h-60 w-26 m-6"> <main className={styles.main}>
< div > Hello
<h1 className="text-3xl font-black"> <h1 className="text-3xl font-black underline">
Welcome to Subman! Hello world!
</h1> </h1>
</div > </main>
<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} tableName="sub" /> <DataTable columns={columns} data={pubSubs} type="submission" />
</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, BookType, Clock, Drama, SquareArrowOutUpRight } from "lucide-react" import { ArrowUpDown } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { PubWithGenres } from "./page" import { Badge } from "@/components/ui/badge"
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 { pubSchema } from "app/ui/forms/schemas" import { formSchema } from "app/ui/forms/pub"
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<PubWithGenres>() const columnHelper = createColumnHelper<PubsWithGenres>()
export const columns: ColumnDef<PubWithGenres>[] = [ export const columns: ColumnDef<PubsWithGenres>[] = [
selectCol, selectCol,
{ {
accessorKey: "title", accessorKey: "title",
@ -23,73 +23,35 @@ export const columns: ColumnDef<PubWithGenres>[] = [
variant="ghost" variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
<span className="hidden sm:block"> Title
Title <ArrowUpDown className="ml-2 h-4 w-4" />
</span>
<span className="block sm:hidden"><BookType /></span>
<ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
</Button> </Button>
) )
}, },
cell: cell => ( cell: TextInputCell,
<> 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: () => ( header: "Link",
<div className="mx-auto w-fit"> cell: TextInputCell,
<span className="hidden sm:block">Link</span> meta: { formSchema }
<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: genrePickerFilterFn filterFn: "arrIncludes"
//TODO - write custom filter function, to account for an array of objects
}), }),
{ {
accessorKey: "query_after_days", accessorKey: "query_after_days",
header: () => ( header: "Query After (days)",
<div> cell: NumberInputCell,
<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: pubSchema formSchema
}, },
}, },

View File

@ -5,31 +5,21 @@ 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 open={isOpen} onOpenChange={setIsOpen}> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>Create new publication</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 dbAction={createPub} genres={genres} closeDialog={closeDialog} /> <PubForm createPub={createPub} genres={genres} />
<DialogFooter> <DialogFooter>
<Button form="pubform">Submit</Button> <Button form="pubform">Submit</Button>
</DialogFooter> </DialogFooter>

View File

@ -1,26 +0,0 @@
"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 PubWithGenres = Pub & { genres: Array<Genre> } export type PubsWithGenres = Pub & { genres: Array<Genre> }
@ -12,8 +12,8 @@ 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 px-0 md:px-4 mx-auto"> <div className="container mx-auto">
<DataTable data={pubs} columns={columns} tableName="pub" genres={genres}> <DataTable data={pubs} columns={columns} tableName="pubs" 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, BookType, Drama, Tally5 } from "lucide-react" import { ArrowUpDown } 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 { storySchema } from "app/ui/forms/schemas" import { formSchema } from "app/ui/forms/story"
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,25 +19,16 @@ 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")}
> >
<span className="hidden sm:block"> Title
Title <ArrowUpDown className="ml-2 h-4 w-4" />
</span>
<span className="block sm:hidden"><BookType /></span>
<ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
</Button> </Button>
) )
}, },
cell: cell => ( cell: TextInputCell,
<> 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",
@ -45,41 +36,23 @@ 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")}
> >
<span className="hidden sm:block"> Word Count
Word Count <ArrowUpDown className="ml-2 h-4 w-4" />
</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: cell => ( cell: NumberInputCell,
<>
{/* @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: storySchema formSchema
} }
}, },
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: genrePickerFilterFn, filterFn: "arrIncludes",
meta: {} meta: {}
}), }),

View File

@ -2,33 +2,24 @@
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, useState } from "react"; import { ComponentProps } 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 open={isOpen} onOpenChange={setIsOpen}> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<div> <Button>Create new story</Button>
<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 dbAction={createStory} genres={genres} className="" closeDialog={closeDialog} /> <StoryForm createStory={createStory} genres={genres} existingData={null} />
<DialogFooter> <DialogFooter>
<Button form="storyform">Submit</Button> <Button form="storyform">Submit</Button>
</DialogFooter> </DialogFooter>

View File

@ -4,10 +4,9 @@ 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: Story & { genres: number[] }): Promise<{ success: string }> { async function createStory(data) {
"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({
@ -27,14 +26,12 @@ 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} dbAction={createStory} className="mt-6" /> <StoryForm genres={genres} createStory={createStory} className="mt-6" />
</CreateContainerContent> </CreateContainerContent>
</CreateContainer> </CreateContainer>
) )

View File

@ -1,27 +0,0 @@
"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 px-1 md:px-4 mx-auto"> <div className="container mx-auto">
<DataTable columns={columns} data={storiesWithGenres} tableName="story" <DataTable columns={columns} data={storiesWithGenres} tableName="story"
genres={genres} genres={genres}
> >

View File

@ -0,0 +1,39 @@
"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, BookText, CalendarMinus, CalendarPlus, MessageCircleReply, NotepadText } from "lucide-react" import { ArrowUpDown } 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,12 +18,7 @@ export const columns: ColumnDef<SubComplete>[] = [
return "RECORD DELETED" return "RECORD DELETED"
}, },
id: "story", id: "story",
header: () => ( header: "Story",
<>
<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>)
}, },
{ {
@ -34,12 +29,7 @@ export const columns: ColumnDef<SubComplete>[] = [
return "RECORD DELETED" return "RECORD DELETED"
}, },
id: "pub", id: "pub",
header: () => ( header: "Publication",
<>
<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>)
}, },
{ {
@ -49,24 +39,16 @@ 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")}
> >
<span className="hidden md:block"> Date Submitted </span> Date Submitted
<CalendarPlus className="block md:hidden" /> <ArrowUpDown className="ml-2 h-4 w-4" />
<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>) => ( cell: (props: CellContext<any, any>) => (<p className="w-full text-center">{props.getValue().toLocaleDateString()}</p>)
<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,
@ -75,22 +57,16 @@ 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")}
> >
<span className="hidden md:block"> Date Responded </span> Date Responded
<CalendarMinus className="block md:hidden" /> <ArrowUpDown className="ml-2 h-4 w-4" />
<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 text-xs md:text-sm">{props.getValue()?.toLocaleDateString('ES', { cell: (props: CellContext<any, any>) => (<p className="w-full text-center">{props.getValue()?.toLocaleDateString()}</p>)
day: 'numeric',
month: 'numeric',
year: '2-digit'
})}</p>)
}, },
{ {
accessorFn: row => { accessorFn: row => {
@ -100,20 +76,8 @@ export const columns: ColumnDef<SubComplete>[] = [
return "RECORD DELETED" return "RECORD DELETED"
}, },
id: "response", id: "response",
header: ({ column }) => { header: "Response",
return ( cell: (props: CellContext<any, any>) => (<p className="w-full text-center">{props.getValue()}</p>)
<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,35 +6,33 @@ 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 }: ComponentProps<"div"> & { stories: StoryWithGenres[], pubs: PubWithGenres[], responses: Response[] }) { export default function CreateSubmissionDialog({ stories, pubs, responses, defaults }: ComponentProps<"div"> & { stories: Story[], pubs: Pub[], responses: Response[], defaults?: CreateSubDefaults }) {
const [isOpen, setIsOpen] = useState(false)
function closeDialog() {
setIsOpen(false)
}
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>Create new submission</Button>
<span className="hidden md:block">Create new submission</span>
<Plus className="block md:hidden" />
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="text-xs md:text-sm"> <DialogContent>
<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 pubs={pubs} responses={responses} stories={stories} closeDialog={closeDialog} /> <SubmissionForm createSub={createSub} pubs={pubs} responses={responses} stories={stories} defaults={defaults} />
<DialogFooter> <DialogFooter>
<DialogClose asChild>
</DialogClose>
<Button form="subform">Submit</Button> <Button form="subform">Submit</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -0,0 +1,29 @@
"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 { DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; import { createSub } from "app/lib/create"
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 { SubForm } from "app/ui/forms/sub"; import SubmissionForm, { 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,8 +16,10 @@ 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>
<EditSubmissionForm pubs={pubs} responses={responses} stories={stories} defaults={defaults} closeDialog={closeDialog} /> <SubmissionForm pubs={pubs} responses={responses} stories={stories} defaults={defaults} />
<DialogFooter> <DialogFooter>
<DialogClose asChild>
</DialogClose>
<Button form="subform">Submit</Button> <Button form="subform">Submit</Button>
</DialogFooter> </DialogFooter>
</> </>

View File

@ -1,10 +1,9 @@
import { getGenres, getPubs, getPubsWithGenres, getResponses, getStories, getStoriesWithGenres, getSubsComplete } from "app/lib/get" import { getGenres, getPubs, getResponses, getStories, 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 { Genre, Pub, Response, Story, Sub } from "@prisma/client" import { Pub, Response, Story, Sub } from "@prisma/client"
import CreateSubmissionDialog from "./create" import CreateSubmissionDialog from "./create"
import { PubWithGenres } from "app/publication/page" import { Trash2 } from "lucide-react"
import { StoryWithGenres } from "app/story/page"
export type SubComplete = Sub & { export type SubComplete = Sub & {
pub: Pub, pub: Pub,
@ -12,15 +11,13 @@ 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: StoryWithGenres[] = await getStoriesWithGenres() const stories = await getStories()
const pubs: PubWithGenres[] = await getPubsWithGenres() const pubs = await getPubs()
const responses: Response[] = await getResponses() const responses = await getResponses()
const genres: Genre[] = await getGenres() const genres = await getGenres()
return ( return (
<div className="container px-1 md:px-4 mx-auto"> <div className="container">
<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,10 +706,6 @@ body {
z-index: 100; z-index: 100;
} }
.m-6 {
margin: 1.5rem;
}
.m-auto { .m-auto {
margin: auto; margin: auto;
} }
@ -719,11 +715,6 @@ 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;
@ -749,9 +740,8 @@ body {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.my-auto { .mb-4 {
margin-top: auto; margin-bottom: 1rem;
margin-bottom: auto;
} }
.ml-2 { .ml-2 {
@ -762,14 +752,6 @@ 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;
} }
@ -786,8 +768,8 @@ body {
margin-top: 1.5rem; margin-top: 1.5rem;
} }
.block { .mt-auto {
display: block; margin-top: auto;
} }
.flex { .flex {
@ -810,10 +792,6 @@ body {
display: grid; display: grid;
} }
.hidden {
display: none;
}
.size-full { .size-full {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -855,8 +833,8 @@ body {
height: 1.25rem; height: 1.25rem;
} }
.h-60 { .h-5\/6 {
height: 15rem; height: 83.333333%;
} }
.h-7 { .h-7 {
@ -936,6 +914,10 @@ body {
width: 10rem; width: 10rem;
} }
.w-5\/6 {
width: 83.333333%;
}
.w-7 { .w-7 {
width: 1.75rem; width: 1.75rem;
} }
@ -990,26 +972,6 @@ 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%;
} }
@ -1130,10 +1092,6 @@ 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;
} }
@ -1142,10 +1100,6 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
.gap-3 {
gap: 0.75rem;
}
.gap-4 { .gap-4 {
gap: 1rem; gap: 1rem;
} }
@ -1217,12 +1171,6 @@ 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)));
@ -1241,24 +1189,10 @@ 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;
} }
@ -1271,6 +1205,11 @@ 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;
@ -1401,16 +1340,6 @@ 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;
@ -1511,6 +1440,11 @@ 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;
} }
@ -1625,6 +1559,10 @@ 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;
} }
@ -2361,22 +2299,6 @@ 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;
} }
@ -2415,87 +2337,15 @@ 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]) {

7
src/app/test/page.tsx Normal file
View File

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

View File

@ -1,3 +0,0 @@
import { Inter } from 'next/font/google';
export const inter = Inter({ subsets: ['latin'] });

View File

@ -1,265 +0,0 @@
"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

@ -0,0 +1,121 @@
"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-60 pl-3 text-left font-normal flex-wrap gap-y-1 h-fit min-h-10", "min-w-fit max-w-full w-fit 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,53 +17,49 @@ 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, Pub } from "@prisma/client" import { Genre } 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 default function PubForm({ genres, dbAction, className, closeDialog, defaults }: ComponentProps<"div"> & { genres: Array<Genre>, dbAction: (data: any) => Promise<{ success: string }>, closeDialog: () => void, defaults?: PubWithGenres }) { export const formSchema = z.object({
const form = useForm<z.infer<typeof pubSchema>>({ title: z.string().min(2).max(50),
resolver: zodResolver(pubSchema), link: z.string(),
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: {
id: defaults?.id, title: "",
title: defaults?.title ?? "", link: "",
link: defaults?.link ?? "", query_after_days: 30,
query_after_days: defaults?.query_after_days ?? 30, genres: []
genres: defaults?.genres.map(e => e.id) ?? []
}, },
}) })
const router = useRouter() function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
async function onSubmit(values: z.infer<typeof pubSchema>) { // ✅ This will be type-safe and validated.
try { toast({
const res = await dbAction({ title: "You submitted the following values:",
pub: { 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>
link: values.link, </pre>
query_after_days: values.query_after_days ),
}, genres: values.genres })
}) createPub(values)
if (!res?.success) throw new Error("something went wrong") console.log(values)
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: (
<Ban /> <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))

View File

@ -1,54 +0,0 @@
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,13 +15,10 @@ 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, SetStateAction } from "react" import { ComponentProps } 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(),
@ -30,47 +27,42 @@ export const formSchema = z.object({
genres: z.array(z.number()) genres: z.array(z.number())
}) })
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 }) { export default function StoryForm({ genres, createStory, className, existingData }: ComponentProps<"div"> & { genres: Array<Genre>, createStory: (data: any) => void, existingData: Story & { genres: number[] } | null }) {
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
id: defaults?.id, id: existingData?.id,
title: defaults?.title ?? "", title: existingData?.title ?? "",
word_count: defaults?.word_count ?? 500, word_count: existingData?.word_count ?? 500,
genres: defaults?.genres.map(e => e.id) ?? [] genres: existingData?.genres ?? []
}, },
}) })
const router = useRouter()
async function onSubmit(values: z.infer<typeof formSchema>) {
try { function onSubmit(values: z.infer<typeof formSchema>) {
const res = await dbAction({ toast({
story: { title: "You submitted the following values:",
id: values?.id, description: (
title: values.title, <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
word_count: values.word_count, <code className="text-white">{JSON.stringify(values, null, 2)}</code>
}, </pre>
genres: values.genres ),
}) })
//server actions return undefined if middleware authentication fails createStory(values)
if (!res?.success) throw new Error("something went wrong") console.log(values)
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({
description: (<Ban />) 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)) console.log(JSON.stringify(errors))
} }
@ -93,7 +85,7 @@ export default function StoryForm({ genres, dbAction, className, closeDialog, de
)} )}
/> />
<div className="inline-flex flex-wrap justify-around items-start w-full gap-x-16 gap-y-8 items-baseline max-w-full"> <div className="inline-flex flex-wrap w-full gap-x-16 gap-y-8 items-baseline max-w-full">
<GenrePicker <GenrePicker
genres={genres} genres={genres}
@ -104,10 +96,10 @@ export default function StoryForm({ genres, dbAction, className, closeDialog, de
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} {...field}></Input> <Input className=" w-24" type="number" step={500} min={1} {...field}></Input>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@ -32,20 +32,54 @@ 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 type SubForm = z.infer<typeof subSchema> export const formSchema = 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"
}
)
export type SubForm = z.infer<typeof formSchema>
export default function SubmissionForm({ stories, pubs, responses, defaults, closeDialog }: { stories: StoryWithGenres[], pubs: PubWithGenres[], responses: Response[], defaults?: Sub, closeDialog?: () => void }) { export default function SubmissionForm({ stories, pubs, responses, defaults }) {
const form = useForm<z.infer<typeof subSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(subSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
responseId: responses[0].id, responseId: responses[0].id,
...defaults ...defaults
@ -53,67 +87,51 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
}) })
const [isSubCalendarOpen, setIsSubCalendarOpen] = useState(false); const [isSubCalendarOpen, setIsSubCalendarOpen] = useState(false);
const [isRespCalendarOpen, setIsRespCalendarOpen] = useState(false); const [isRespCalendarOpen, setIsRespCalendarOpen] = useState(false);
const [relevantPubIds, setRelevantPubIds] = useState(pubs.map(e => e.id)); const storiesSelectItems = stories.map(e => (
<SelectItem value={e.id.toString()} key={e.title}>
function updateRelevantPubs(storyId: number) { {e.title}
console.log("storyId: " + storyId) </SelectItem>
console.log("stories: ", stories) ))
const story = stories.find(e => e.id == storyId) const pubsSelectItems = pubs.map(e => (
console.log("story: ", story) <SelectItem value={e.id}>
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.toString()} key={e.response}> <SelectItem value={e.id}>
{e.response} {e.response}
</SelectItem> </SelectItem>
)) ))
const router = useRouter()
async function onSubmit(values: z.infer<typeof subSchema>) { // 2. Define a submit handler.
try { function onSubmit(values: z.infer<typeof formSchema>) {
//@ts-ignore // Do something with the form values.
const res = await createSub(values) // ✅ This will be type-safe and validated.
if (!res) throw new Error("something went wrong") toast({
toast({ title: "Successfully created new submission!" }) title: "You submitted the following values:",
router.refresh() description: (
closeDialog() <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
} catch (error) { <code className="text-white">{JSON.stringify(values, null, 2)}</code>
toast({ </pre>
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: (
<Ban /> <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))
@ -121,14 +139,14 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
return ( return (
<Form {...form}> <Form {...form}>
<form id="subform" onSubmit={form.handleSubmit(onSubmit, onErrors)} className="space-y-2 md:space-y-8 text-xs"> <form id="subform" onSubmit={form.handleSubmit(onSubmit, onErrors)} className="space-y-8">
<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?.toString()}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select something"> <SelectValue placeholder="Select something">
@ -140,7 +158,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
{storiesSelectItems} {storiesSelectItems}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription className="text-xs md:text-base">The piece you submitted</FormDescription> <FormDescription>The piece you submitted</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -150,9 +168,8 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
name="pubId" name="pubId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-sm md:text-base">Publication</FormLabel> <FormLabel>Publication</FormLabel>
<Select onOpenChange={() => updateRelevantPubs(form.getValues().storyId <Select onValueChange={field.onChange} defaultValue={field.value}>
)} onValueChange={field.onChange} defaultValue={field.value?.toString()}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select something"> <SelectValue placeholder="Select something">
@ -164,7 +181,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
{pubsSelectItems} {pubsSelectItems}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription className="text-xs md:text-base">The market you sent it to. Bad genre fits are greyed out.</FormDescription> <FormDescription>The market you sent it to</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@ -175,7 +192,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
name="submitted" name="submitted"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel className="text-sm md:text-base">Date of submission</FormLabel> <FormLabel>Date of submission</FormLabel>
<Popover modal={true} open={isSubCalendarOpen} onOpenChange={setIsSubCalendarOpen}> <Popover modal={true} open={isSubCalendarOpen} onOpenChange={setIsSubCalendarOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@ -196,8 +213,9 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
{/* @ts-ignore */} <Calendar
<Calendar mode="single" selected={field.value} mode="single"
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")
@ -206,7 +224,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormDescription className="text-xs md:text-base"> <FormDescription>
The date you sent it The date you sent it
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@ -219,7 +237,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
name="responded" name="responded"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel className="text-sm md:text-base">Date of response</FormLabel> <FormLabel>Date of response</FormLabel>
<Popover modal={true} open={isRespCalendarOpen} onOpenChange={setIsRespCalendarOpen}> <Popover modal={true} open={isRespCalendarOpen} onOpenChange={setIsRespCalendarOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@ -240,8 +258,9 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
</FormControl> </FormControl>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
{/* @ts-ignore */} <Calendar
<Calendar mode="single" selected={field.value} mode="single"
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")
@ -250,7 +269,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<FormDescription className="text-xs md:text-base"> <FormDescription>
The date they wrote back The date they wrote back
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@ -264,8 +283,8 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
name="responseId" name="responseId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-sm md:text-base">Response</FormLabel> <FormLabel>Response</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value?.toString()}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue> <SelectValue>
@ -277,7 +296,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults, clo
{reponsesSelectItems} {reponsesSelectItems}
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription> <FormDescription>The market you sent it to</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@ -4,8 +4,8 @@ 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={props.className}>
{props.genres.map((e: Genre) => (<Badge className="text-xs md:text-sm" key={e.name}>{e.name}</Badge>))} {props.genres.map((e: Genre) => (<Badge key={e.name}>{e.name}</Badge>))}
</div> </div>
) )
} }

View File

@ -1,26 +0,0 @@
"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,7 +4,6 @@ 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 }) {
@ -15,24 +14,21 @@ 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", icon: <BookOpenText /> }, { link: "/story", label: "STORIES" },
{ link: "/publication", label: "PUBLICATIONS", icon: <BookOpen /> }, { link: "/publication", label: "PUBLICATIONS" },
{ link: "/submission", label: "SUBMISSIONS", icon: <ArrowUpNarrowWide /> }, { link: "/submission", label: "SUBMISSIONS" },
] ]
return ( return (
<div className={props.className}> <div className={props.className}>
<div className="text-secondary-foreground flex flex-row md:flex-col" > <div className="text-secondary-foreground" >
{ {
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 mr-2 p-2 pl-6 antialiased text-secondary-foreground bg-secondary rounded-3xl md:rounded-l-3xl ", 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",
{ {
"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,37 +1,87 @@
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 }: ComponentProps<"div"> & { table: Table<any>, row: Row<any> }) {
//@ts-ignore
const pathname = table.options.meta.pathname const pathname = table.options.meta.pathname
const selectedRows = table.getSelectedRowModel().flatRows const selectedRows = table.getSelectedRowModel().flatRows
const [dialog, setDialog] = useState<"edit" | "delete" | null>("delete")
return ( return (
<ContextMenuContent > <Dialog modal={true}>
{pathname !== "/submission" && selectedRows.length <= 1 ? <ContextMenuContent >
<> {pathname !== "/submission" && selectedRows.length <= 1 ?
<Link href={`${pathname}/${row.original.id}`}> <>
<ContextMenuItem>Inspect</ContextMenuItem> <Link href={`${pathname}/${row.original.id}`}>
</Link> <ContextMenuItem>Inspect</ContextMenuItem>
</> </Link>
: "" </>
}
<ContextMenuItem onClick={() => openEditDialog(row)}>
Edit
</ContextMenuItem>
{
selectedRows.length > 0 ?
<ContextMenuItem onClick={() => { table.resetRowSelection() }}>Deselect</ContextMenuItem>
: "" : ""
} }
<ContextMenuSeparator />
<ContextMenuItem className="text-destructive" onClick={() => openDeleteDialog(row)}>Delete</ContextMenuItem> {
</ContextMenuContent> pathname === "/submission" ?
<>
<DialogTrigger asChild>
<ContextMenuItem onClick={() => setDialog("edit")}>
Edit
</ContextMenuItem>
</DialogTrigger>
</>
: ""
}
{
selectedRows.length > 0 ?
<ContextMenuItem onClick={() => { table.resetRowSelection() }}>Deselect</ContextMenuItem>
: ""
}
<ContextMenuSeparator />
<DialogTrigger asChild>
<ContextMenuItem className="text-destructive" onClick={() => setDialog("delete")}>Delete</ContextMenuItem>
</DialogTrigger>
</ContextMenuContent>
<DialogContent>
{
dialog === "delete" ?
<>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
Deleting a {tableNameToItemName(table.options.meta.tableName)} cannot be undone!
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="destructive"
onClick={() => {
deleteRecord(row.original.id, pathname)
}}>Yes, delete it!
</Button>
</DialogClose>
</DialogFooter>
</>
: dialog === "edit" ?
<EditSubmissionDialog
stories={table.options.meta.stories}
pubs={table.options.meta.pubs}
responses={table.options.meta.responses}
defaults={row.original}
/>
:
<>
<DialogTitle>Edit/delete dialog</DialogTitle>
</>
}
</DialogContent>
</Dialog>
) )
} }

View File

@ -2,6 +2,8 @@
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 {
@ -13,7 +15,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 { ComponentProps, useState } from "react" import { Component, ComponentProps, use, useState } from "react"
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
@ -24,8 +26,7 @@ import {
getFilteredRowModel, getFilteredRowModel,
getCoreRowModel, getCoreRowModel,
getPaginationRowModel, getPaginationRowModel,
useReactTable, useReactTable
Row
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { import {
@ -36,22 +37,16 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table" } from "@/components/ui/table"
import { CircleHelp, EyeIcon, Filter, Trash2 } from "lucide-react" import { EyeIcon, Trash2 } from "lucide-react"
import { usePathname, useSearchParams } from "next/navigation" import { usePathname } from "next/navigation"
import FormContextMenu from "./contextMenu" import FormContextMenu from "./contextMenu"
import { deleteRecord, deleteRecords } from "app/lib/del" import { 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, updatePub, updateStory } from "app/lib/update" import { updateField } 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 { 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>[]
@ -70,9 +65,6 @@ export function DataTable<TData, TValue>({
genres genres
}: DataTableProps<TData, TValue> & ComponentProps<"div"> & { tableName: string, stories?: Story[], pubs?: Pub[], responses?: Response[], genres?: Genre[] }) { }: DataTableProps<TData, TValue> & ComponentProps<"div"> & { tableName: string, stories?: Story[], pubs?: Pub[], responses?: Response[], genres?: Genre[] }) {
//STATE //STATE
const [isEditDialogVisible, setIsEditDialogVisible] = useState<boolean>(false)
const [isDeleteDialogVisible, setIsDeleteDialogVisible] = useState<boolean>(false)
const [dialogRow, SetDialogRow] = useState<Row<any> | null>(null)
const [sorting, setSorting] = useState<SortingState>([]) const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>( const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(
[] []
@ -80,7 +72,7 @@ export function DataTable<TData, TValue>({
const [columnVisibility, setColumnVisibility] = const [columnVisibility, setColumnVisibility] =
useState<VisibilityState>({}) useState<VisibilityState>({})
// //
const pathname: string = usePathname() const pathname: Pathname = usePathname()
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
@ -111,39 +103,31 @@ export function DataTable<TData, TValue>({
}) })
function openEditDialog(row) {
setIsEditDialogVisible(true)
SetDialogRow(row)
}
function openDeleteDialog(row) {
setIsDeleteDialogVisible(true)
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 gap-2 justify-between items-center py-1 md:py-4"> <div className="flex justify-between items-center py-4">
<div className="flex gap-1"> <div className="flex gap-2">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="mx-0"> <p className="hidden md:block">Filter by</p><Filter className="block md:hidden" /> </Button> <Button variant="outline" className="ml-auto">
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())
//@ts-ignore .map((column) => {
.map((column) => { return (<DropdownMenuRadioItem value={column} className="capitalize" key={column.id}> {column.id} </DropdownMenuRadioItem>) })} return (
<DropdownMenuRadioItem value={column} className="capitalize" key={column.id}>
{column.id}
</DropdownMenuRadioItem>
)
})}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -159,61 +143,6 @@ export function DataTable<TData, TValue>({
{children} {children}
<Dialog open={isEditDialogVisible} onOpenChange={setIsEditDialogVisible}>
<DialogContent>
{tableName === "sub" ?
<EditSubmissionDialog
stories={stories}
pubs={pubs}
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>
</Dialog>
<Dialog open={isDeleteDialogVisible} onOpenChange={setIsDeleteDialogVisible}>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
Deleting a {tableNameToItemName(tableName)} cannot be undone!
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="destructive"
onClick={async () => {
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!
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="destructive" disabled={!(table.getIsSomeRowsSelected() || table.getIsAllRowsSelected())}> <Button variant="destructive" disabled={!(table.getIsSomeRowsSelected() || table.getIsAllRowsSelected())}>
@ -222,29 +151,23 @@ 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>
<Button variant="destructive" <DialogClose asChild>
onClick={async () => { <Button variant="destructive"
const selectedRows = table.getState().rowSelection onClick={() => {
const rowIds = Object.keys(selectedRows) const selectedRows = table.getState().rowSelection
//@ts-ignore const rowIds = Object.keys(selectedRows)
const recordIds = rowIds.map(id => Number(table.getRow(id).original.id)) const recordIds = rowIds.map(id => Number(table.getRow(id).original.id))
//@ts-ignore console.table(recordIds)
const res = await deleteRecords(recordIds, pathname) deleteRecords(recordIds, pathname)
if (!res) toast({ title: "Oh dear...", description: "Failed to delete." }) }}>
if (res) toast({ title: "Successfully deleted records of id:", description: JSON.stringify(recordIds) }) Yes, delete them!</Button>
table.resetRowSelection() </DialogClose>
setIsDeleteDialogVisible(false)
router.refresh()
}}>
Yes, delete them!</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -280,8 +203,7 @@ export function DataTable<TData, TValue>({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="rounded-md"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
@ -304,45 +226,28 @@ 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) => (
const classes = () => { <ContextMenu onOpenChange={open => setIsContextMenuOpen(open)} key={row.id + "contextMenu"}>
const classes = [] <ContextMenuTrigger asChild>
if (row.getValue('response') === "Pending") classes.push("bg-accent") <TableRow
if (row.getValue('response') === "Acceptance") classes.push("bg-primary") key={row.id}
return classes.join(" ") data-state={row.getIsSelected() && "selected"}
} tabIndex={0}
return ( >
<ContextMenu key={row.id + "contextMenu"}> {row.getVisibleCells().map((cell) => (
<ContextMenuTrigger asChild> <TableCell key={cell.id}>
<TableRow {flexRender(cell.column.columnDef.cell, cell.getContext())}
key={row.id} </TableCell>
className={classes()} ))}
data-state={row.getIsSelected() && "selected"} <FormContextMenu
tabIndex={0} key={"formContextMenu" + row.id}
onDoubleClick={() => { row={row}
if (tableName === "sub") { table={table}
openEditDialog(row) />
} </TableRow>
} </ContextMenuTrigger>
} </ContextMenu>
> ))
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
<FormContextMenu
key={"formContextMenu" + row.id}
row={row}
table={table}
openEditDialog={openEditDialog}
openDeleteDialog={openDeleteDialog}
/>
</TableRow>
</ContextMenuTrigger>
</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 { BaseSyntheticEvent, ComponentProps, EventHandler, useState } from "react" import { ComponentProps, 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,38 +12,36 @@ 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[] }, event: BaseSyntheticEvent) { async function onSubmit({ genres }: { genres: number[] }) {
event.preventDefault() event.preventDefault()
try { const genresArray = genres.map((e) => { return { id: e } })
const genresArray = genres.map((e) => { return { id: e } }) console.log(`genres: ${genres}, genresArray: ${JSON.stringify(genresArray)}`)
const res = await updateGenres({ toast({
id, title: "You submitted the following values:",
table, description: (
genres: genresArray, <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
pathname <code className="text-white">{JSON.stringify(genres)}</code>
}) </pre>
if (res === undefined) throw new Error("Something went wrong.") ),
toast({ title: "Field updated successfully." }) })
router.refresh() const res = await updateGenres({
} catch (error) { id,
console.error(error) table,
toast({ title: "Something went wrong." }) genres: genresArray,
} pathname
})
setIsActive(false) setIsActive(false)
} }
@ -74,14 +72,14 @@ export default function GenrePickerInputCell(props: CellContext<any, any>) {
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit, onErrors)} id={formId}> <form onSubmit={form.handleSubmit(onSubmit, onErrors)} id={formId}>
<Popover modal={true} open={isActive} onOpenChange={() => setIsActive(prev => !prev)} > <Popover modal={true} open={isActive} onOpenChange={() => setIsActive(prev => !prev)}>
<FormField <FormField
control={form.control} control={form.control}
name="genres" name="genres"
render={({ field }) => ( render={({ field }) => (
<FormItem className="w-full max-w-32 flex flex-col"> <FormItem className="flex flex-col">
<PopoverTrigger asChild> <PopoverTrigger>
{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> {value.length > 0 ? <GenreBadges genres={value} /> : <p>Add genres</p>
} }
</PopoverTrigger> </PopoverTrigger>
@ -103,6 +101,7 @@ 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,56 +9,58 @@ 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({ cellContext, className }: { cellContext: CellContext<any, any>, className: string }) { export default function NumberInputCell(props: 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]: cellContext.cell.getValue() [column]: props.cell.getValue()
}, },
}) })
async function onSubmit(value: z.infer<typeof formSchema>) { function onSubmit(value: z.infer<typeof formSchema>) {
try { toast({
const res = await updateField({ title: "You submitted the following values:",
id, description: (
table, <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
datum: value[column], <code className="text-white">{JSON.stringify(value, null, 2)}</code>
column, </pre>
pathname ),
}) })
if (res === undefined) throw new Error("something went wrong") updateField({
toast({ title: "Field updated successfully." }) id,
} catch (error) { table,
console.error(error) number: value[column],
toast({ title: "Something went wrong." }) column,
} pathname
})
setIsActive(false) setIsActive(false)
} }
function onErrors(errors: Error) { function onErrors(errors) {
toast({ toast({
title: "You have errors", title: "You have errors",
description: errors.message, 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))
} }
return ( return (
<div <div
onDoubleClick={() => setIsActive(true)} onDoubleClick={() => setIsActive(prev => !prev)}
className={className + " w-full h-fit flex items-center justify-center"} 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) {
@ -80,8 +82,13 @@ export default function NumberInputCell({ cellContext, className }: { cellContex
> >
<FormControl <FormControl
> >
{/* @ts-ignore */} <Input
<Input className="md:w-24" type="number" autoFocus={true} step={cellContext.column.columnDef.meta?.step} {...field} /> className="w-24"
type="number"
autoFocus={true}
step={props.column.columnDef.meta?.step}
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -90,7 +97,7 @@ export default function NumberInputCell({ cellContext, className }: { cellContex
</form> </form>
</Form> </Form>
: <p>{cellContext.cell.getValue()}</p> : <p>{props.cell.getValue()}</p>
} }
</div > </div >
) )

View File

@ -9,43 +9,40 @@ 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({ cellContext, className }: { className: string, cellContext: CellContext<any, any> }) { export function TextInputCell(props: CellContext<any, any>) {
const [isActive, setIsActive] = useState(false) const [isActive, setIsActive] = useState(false)
//@ts-ignore
const table = cellContext.table.options.meta.tableName const table = props.table.options.meta.tableName
const id = cellContext.row.original.id const id = props.row.original.id
const column = cellContext.column.id const column = props.column.id
//@ts-ignore const pathname = props.table.options.meta.pathname
const pathname = cellContext.table.options.meta.pathname const value = props.cell.getValue()
const value = cellContext.cell.getValue() const formSchema = props.column.columnDef.meta.formSchema.pick({ [column]: true })
//@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]: cellContext.cell.getValue() [column]: props.cell.getValue()
}, },
}) })
const router = useRouter()
async function onSubmit(value: z.infer<typeof formSchema>) { async function onSubmit(value: z.infer<typeof formSchema>) {
try { toast({
const res = await updateField({ title: "You submitted the following values:",
id, description: (
table, <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
datum: value[column], <code className="text-white">{JSON.stringify(value, null, 2)}</code>
column, </pre>
pathname ),
}) })
if (res === undefined) throw new Error("something went wrong") const res = await updateField({
toast({ title: "Field updated successfully." }) id,
router.refresh() table,
} catch (error) { datum: value[column],
console.error(error) column,
toast({ title: "Something went wrong." }) pathname
} })
setIsActive(false) setIsActive(false)
} }
@ -63,7 +60,7 @@ export function TextInputCell({ cellContext, className }: { className: string, c
return ( return (
<div <div
onDoubleClick={() => setIsActive(prev => !prev)} onDoubleClick={() => setIsActive(prev => !prev)}
className={className + " w-full h-fit flex items-center justify-left"} 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) {
@ -86,7 +83,7 @@ export function TextInputCell({ cellContext, className }: { className: string, c
<FormControl <FormControl
> >
<Input <Input
className="w-full" className="w-fit"
type="text" type="text"
autoFocus={true} autoFocus={true}
{...field} {...field}
@ -99,7 +96,7 @@ export function TextInputCell({ cellContext, className }: { className: string, c
</form> </form>
</Form> </Form>
: <TitleContainer>{cellContext.cell.getValue()}</TitleContainer> : <TitleContainer>{props.cell.getValue()}</TitleContainer>
} }
</div > </div >
) )

View File

@ -5,9 +5,8 @@ export const selectCol = {
id: "select", id: "select",
header: (props: HeaderContext<any, any>) => { header: (props: HeaderContext<any, any>) => {
return ( return (
<div className="flex items-start justify-left mx-auto"> <div className="flex items-center justify-center">
<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"
@ -16,12 +15,11 @@ export const selectCol = {
) )
}, },
enableColumnFilter: false,
cell: (props: CellContext<any, any>) => { cell: (props: CellContext<any, any>) => {
return ( return (
<div className="flex items-start justify-left"> <div className="flex items-center justify-center">
<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,9 +1,11 @@
import { ComponentProps } from "react"; import { ComponentProps } from "react";
export default function TitleContainer({ children }: ComponentProps<"div">) { export default function itleContainer({ children }: ComponentProps<"div">) {
let classes = "w-full text-left m-auto h-fit flex align-center text-xs md:text-sm" let classes = "w-full text-left m-auto"
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 <p className={classes}>{children}</p> return <span className="h-10 flex align-center"><p className={classes}>{children}</p></span>
} }

View File

@ -1,78 +0,0 @@
"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()
}
}
}

View File

@ -1,31 +0,0 @@
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()
})