Compare commits
	
		
			29 Commits
		
	
	
		
			89e338a0ac
			...
			0ab70fec08
		
	
	| Author | SHA1 | Date | 
|---|---|---|
| 
							
							
								
								 | 
						0ab70fec08 | |
| 
							
							
								
								 | 
						fa52882880 | |
| 
							
							
								
								 | 
						ef797f7bce | |
| 
							
							
								
								 | 
						37e7f4d15f | |
| 
							
							
								
								 | 
						0cf00121b9 | |
| 
							
							
								
								 | 
						81b36d0c8c | |
| 
							
							
								
								 | 
						3998180830 | |
| 
							
							
								
								 | 
						a1708002f9 | |
| 
							
							
								
								 | 
						f6c9ac9015 | |
| 
							
							
								
								 | 
						c3978b04a4 | |
| 
							
							
								
								 | 
						60be9f2fdc | |
| 
							
							
								
								 | 
						be7e63f675 | |
| 
							
							
								
								 | 
						ca6bfb45fe | |
| 
							
							
								
								 | 
						d082f4fefb | |
| 
							
							
								
								 | 
						a0305d3e0f | |
| 
							
							
								
								 | 
						1f358c5b84 | |
| 
							
							
								
								 | 
						10c64d3883 | |
| 
							
							
								
								 | 
						2e947b8e78 | |
| 
							
							
								
								 | 
						62f0e75abd | |
| 
							
							
								
								 | 
						e1cdba824a | |
| 
							
							
								
								 | 
						8a04297768 | |
| 
							
							
								
								 | 
						274ee8e4de | |
| 
							
							
								
								 | 
						c0fe9dcf0f | |
| 
							
							
								
								 | 
						98f762d31e | |
| 
							
							
								
								 | 
						2b64b98d1d | |
| 
							
							
								
								 | 
						cc11e80e78 | |
| 
							
							
								
								 | 
						f97d7e7de1 | |
| 
							
							
								
								 | 
						8e5d2d3082 | |
| 
							
							
								
								 | 
						2b6125582d | 
							
								
								
									
										3
									
								
								.env
								
								
								
								
							
							
						
						
									
										3
									
								
								.env
								
								
								
								
							| 
						 | 
				
			
			@ -4,4 +4,5 @@
 | 
			
		|||
# 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"
 | 
			
		||||
DATABASE_URL="file:./dev.db"
 | 
			
		||||
JWT_SECRET="/VziPKch4YqGXA85ghyYGd/JIvO7fJ+BZEyxc910sEc="
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,9 @@
 | 
			
		|||
/** @type {import('next').NextConfig} */
 | 
			
		||||
const nextConfig = {};
 | 
			
		||||
const nextConfig = {
 | 
			
		||||
  webpack: (config) => {
 | 
			
		||||
    config.externals = [...config.externals, "bcrypt"];
 | 
			
		||||
    return config;
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default nextConfig;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| 
						 | 
				
			
			@ -12,6 +12,7 @@
 | 
			
		|||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@hookform/resolvers": "^3.6.0",
 | 
			
		||||
    "@mapbox/node-pre-gyp": "^1.0.11",
 | 
			
		||||
    "@prisma/client": "^5.15.0",
 | 
			
		||||
    "@radix-ui/react-checkbox": "^1.0.4",
 | 
			
		||||
    "@radix-ui/react-context-menu": "^2.2.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -24,11 +25,15 @@
 | 
			
		|||
    "@radix-ui/react-slot": "^1.0.2",
 | 
			
		||||
    "@radix-ui/react-toast": "^1.1.5",
 | 
			
		||||
    "@tanstack/react-table": "^8.17.3",
 | 
			
		||||
    "@types/bcrypt": "^5.0.2",
 | 
			
		||||
    "bcrypt": "^5.1.1",
 | 
			
		||||
    "class-variance-authority": "^0.7.0",
 | 
			
		||||
    "clsx": "^2.1.1",
 | 
			
		||||
    "date-fns": "^3.6.0",
 | 
			
		||||
    "jose": "^5.8.0",
 | 
			
		||||
    "lucide": "^0.445.0",
 | 
			
		||||
    "lucide-react": "^0.394.0",
 | 
			
		||||
    "next": "14.2.3",
 | 
			
		||||
    "next": "^14.2.13",
 | 
			
		||||
    "next-themes": "^0.3.0",
 | 
			
		||||
    "react": "^18",
 | 
			
		||||
    "react-day-picker": "^8.10.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -37,6 +42,7 @@
 | 
			
		|||
    "recharts": "^2.12.7",
 | 
			
		||||
    "tailwind-merge": "^2.3.0",
 | 
			
		||||
    "tailwindcss-animate": "^1.0.7",
 | 
			
		||||
    "text-encoding": "^0.7.0",
 | 
			
		||||
    "zod": "^3.23.8"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										
											BIN
										
									
								
								prisma/dev.db
								
								
								
								
							
							
						
						
									
										
											BIN
										
									
								
								prisma/dev.db
								
								
								
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
				
			
			@ -10,6 +10,13 @@ datasource db {
 | 
			
		|||
  url      = "file:./dev.db"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model User {
 | 
			
		||||
id Int @id @default(autoincrement())
 | 
			
		||||
email String
 | 
			
		||||
password String
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
model Story {
 | 
			
		||||
  id         Int     @id @default(autoincrement())
 | 
			
		||||
  word_count Int
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -73,7 +73,7 @@ const TableHead = React.forwardRef<
 | 
			
		|||
  <th
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn(
 | 
			
		||||
      "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
 | 
			
		||||
      "h-12 md:px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
 | 
			
		||||
      className
 | 
			
		||||
    )}
 | 
			
		||||
    {...props}
 | 
			
		||||
| 
						 | 
				
			
			@ -87,7 +87,7 @@ const TableCell = React.forwardRef<
 | 
			
		|||
>(({ className, ...props }, ref) => (
 | 
			
		||||
  <td
 | 
			
		||||
    ref={ref}
 | 
			
		||||
    className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
 | 
			
		||||
    className={cn("md:p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
 | 
			
		||||
    {...props}
 | 
			
		||||
  />
 | 
			
		||||
))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,141 @@
 | 
			
		|||
"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';
 | 
			
		||||
import { NextResponse } from 'next/server';
 | 
			
		||||
 | 
			
		||||
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: Uint8Array = 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() {
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,66 @@
 | 
			
		|||
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()
 | 
			
		||||
	console.log(`body: ${JSON.stringify(body)}`)
 | 
			
		||||
	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 })
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
import { revalidatePath } from "next/cache";
 | 
			
		||||
import { NextRequest } from "next/server";
 | 
			
		||||
import { logout } from "../actions";
 | 
			
		||||
 | 
			
		||||
const dynamic = 'force-dynamic'
 | 
			
		||||
 | 
			
		||||
export async function POST(request: NextRequest) {
 | 
			
		||||
	await logout()
 | 
			
		||||
	revalidatePath('/login')
 | 
			
		||||
	const response = {
 | 
			
		||||
		success: true,
 | 
			
		||||
		message: 'Logged out successfully',
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return new Response(JSON.stringify(response), {
 | 
			
		||||
		headers: {
 | 
			
		||||
			'Content-Type': 'application/json',
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +6,8 @@ import "./globals.css";
 | 
			
		|||
import Navlinks from "./ui/navLinks";
 | 
			
		||||
import { ModeToggle } from "./ui/modeToggle";
 | 
			
		||||
import { inter } from "./ui/fonts";
 | 
			
		||||
import LogoutButton from "./ui/logoutButton";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -15,7 +17,6 @@ export const metadata: Metadata = {
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export default function RootLayout({
 | 
			
		||||
  children,
 | 
			
		||||
}: Readonly<{
 | 
			
		||||
| 
						 | 
				
			
			@ -30,14 +31,15 @@ export default function RootLayout({
 | 
			
		|||
          enableSystem
 | 
			
		||||
          disableTransitionOnChange
 | 
			
		||||
        >
 | 
			
		||||
          <div id="layout-container" className="p-4 w-screen h-screen mt-6 flex justify-center">
 | 
			
		||||
            <div className="w-5/6 flex">
 | 
			
		||||
              <div id="sidebar" className="h-5/6 flex flex-col"> <header className="">
 | 
			
		||||
                <h1 className="font-black text-4xl text-primary-foreground bg-primary antialiased w-full p-2 rounded-tl-3xl pl-6 pr-4">SubMan</h1>
 | 
			
		||||
                <p className="mt-2 mx-1 text-sm antialiased w-40">The self-hosted literary submission tracker.</p>
 | 
			
		||||
          <div id="layout-container" className="md:p-4 w-screen h-screen mt-2 md:mt-6 flex justify-center">
 | 
			
		||||
            <div className="w-full md:w-5/6 flex flex-col md:flex-row">
 | 
			
		||||
              <div id="sidebar" className=" flex flex-row md:flex-col  justify-between"> <header className="">
 | 
			
		||||
                <h1 className="font-black text-4xl text-primary-foreground bg-primary antialiased w-full p-2 rounded-tl-3xl pl-6 pr-4 hidden md:block">SubMan</h1>
 | 
			
		||||
                <p className="mt-2 mx-1 text-sm antialiased w-40 hidden md:block">The self-hosted literary submission tracker.</p>
 | 
			
		||||
              </header>
 | 
			
		||||
                <Navlinks className="mt-6" />
 | 
			
		||||
                <footer className="mt-auto"><ModeToggle /></footer>
 | 
			
		||||
                <Navlinks className="md:mt-6" />
 | 
			
		||||
                <footer className="my-auto md:mt-auto flex justify-center"><ModeToggle /><LogoutButton />
 | 
			
		||||
                </footer>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div className="flex justify-center w-full">
 | 
			
		||||
                {children}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,59 +1,91 @@
 | 
			
		|||
"use server"
 | 
			
		||||
import { Genre, Story } from "@prisma/client"
 | 
			
		||||
import { Genre, Pub, Story, Sub } from "@prisma/client"
 | 
			
		||||
import prisma from "./db"
 | 
			
		||||
import { revalidatePath } from "next/cache"
 | 
			
		||||
import { redirect } from "next/navigation"
 | 
			
		||||
import { z } from "zod"
 | 
			
		||||
import { storySchema } from "app/ui/forms/schemas"
 | 
			
		||||
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[] }) {
 | 
			
		||||
//TODO - data validation, error handling, unauthorized access handling
 | 
			
		||||
 | 
			
		||||
export async function createStory(data: Story & { genres: number[] }): Promise<Story | boolean | undefined> {
 | 
			
		||||
	// will return undefined if middleware authorization fails
 | 
			
		||||
	"use server"
 | 
			
		||||
	const genresArray = data.genres.map((e) => { return { id: e } })
 | 
			
		||||
	const res = await prisma.story.create({
 | 
			
		||||
		data: {
 | 
			
		||||
			title: data.title,
 | 
			
		||||
			word_count: data.word_count,
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	console.log(res)
 | 
			
		||||
	const genresRes = await prisma.story.update({
 | 
			
		||||
		where: { id: res.id },
 | 
			
		||||
		data: {
 | 
			
		||||
			genres: { set: genresArray }
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	console.log(genresRes)
 | 
			
		||||
	revalidatePath("/story")
 | 
			
		||||
	redirect("/story")
 | 
			
		||||
	try {
 | 
			
		||||
		const storyData = await prepStoryData(data)
 | 
			
		||||
		const genresArray = await prepGenreData(data.genres)
 | 
			
		||||
 | 
			
		||||
		//submit
 | 
			
		||||
		const res = await prisma.story.create({ data: storyData })
 | 
			
		||||
		await prisma.story.update({
 | 
			
		||||
			where: { id: res.id },
 | 
			
		||||
			data: {
 | 
			
		||||
				genres: { set: genresArray }
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
		revalidatePath("/story")
 | 
			
		||||
		return res
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(error)
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export async function createPub(data) {
 | 
			
		||||
export async function createPub(data: Pub & { genres: number[] }): Promise<Pub | boolean | undefined> {
 | 
			
		||||
	"use server"
 | 
			
		||||
	//prepare data
 | 
			
		||||
	const pubData = {
 | 
			
		||||
		title: data.title,
 | 
			
		||||
		link: data.link,
 | 
			
		||||
		query_after_days: data.query_after_days
 | 
			
		||||
	}
 | 
			
		||||
	const genresArray = data.genres.map(e => { return { id: e } })
 | 
			
		||||
	const res = await prisma.pub.create({
 | 
			
		||||
		data: {
 | 
			
		||||
			title: data.title,
 | 
			
		||||
			link: data.link,
 | 
			
		||||
			query_after_days: data.query_after_days
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	console.log(res)
 | 
			
		||||
	const genresRes = await prisma.pub.update({
 | 
			
		||||
		where: { id: res.id },
 | 
			
		||||
		data:
 | 
			
		||||
			{ genres: { set: genresArray } }
 | 
			
		||||
	})
 | 
			
		||||
	console.log(genresRes)
 | 
			
		||||
	revalidatePath("/publication")
 | 
			
		||||
	redirect("/publication")
 | 
			
		||||
 | 
			
		||||
	//prepare schemas
 | 
			
		||||
	const schema = pubSchema.omit({ genres: true })
 | 
			
		||||
	const genreSchema = z.object({ id: z.number() })
 | 
			
		||||
	const genresSchema = z.array(genreSchema)
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
 | 
			
		||||
		//validate
 | 
			
		||||
		schema.parse(pubData)
 | 
			
		||||
		genresSchema.safeParse(genresArray)
 | 
			
		||||
 | 
			
		||||
		//submit
 | 
			
		||||
		const res = await prisma.pub.create({
 | 
			
		||||
			data: pubData
 | 
			
		||||
		})
 | 
			
		||||
		const genresRes = await prisma.pub.update({
 | 
			
		||||
			where: { id: res.id },
 | 
			
		||||
			data:
 | 
			
		||||
				{ genres: { set: genresArray } }
 | 
			
		||||
		})
 | 
			
		||||
		revalidatePath("/publication")
 | 
			
		||||
		return genresRes
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(error)
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export async function createSub(data) {
 | 
			
		||||
export async function createSub(data: Sub): Promise<Sub | undefined> {
 | 
			
		||||
	"use server"
 | 
			
		||||
	const res = await prisma.sub.create({ data })
 | 
			
		||||
	console.log(res)
 | 
			
		||||
	revalidatePath("/submission")
 | 
			
		||||
	redirect("/submission")
 | 
			
		||||
 | 
			
		||||
	try {
 | 
			
		||||
		subSchema.parse(data)
 | 
			
		||||
		const res = await prisma.sub.create({ data })
 | 
			
		||||
		revalidatePath("/submission")
 | 
			
		||||
		return res
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(error)
 | 
			
		||||
		return undefined
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,24 +10,33 @@ const tableMap = {
 | 
			
		|||
	"/submission": "sub"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function deleteRecord(id: number, pathname: Pathname) {
 | 
			
		||||
export async function deleteRecord(id: number, pathname: Pathname): Promise<undefined | boolean> {
 | 
			
		||||
	const table = tableMap[pathname]
 | 
			
		||||
	const res = await prisma[table].delete({ where: { id } })
 | 
			
		||||
	console.log(`deleted from ${table}: ${res.id}`)
 | 
			
		||||
	console.log("revalidating: " + pathname)
 | 
			
		||||
	revalidatePath(pathname)
 | 
			
		||||
	redirect(pathname)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function deleteRecords(ids: number[], pathname: "/story" | "/publication" | "/submission") {
 | 
			
		||||
	const table = tableMap[pathname]
 | 
			
		||||
	ids.forEach(async (id) => {
 | 
			
		||||
		const res = await prisma[table].delete({
 | 
			
		||||
			where: { id }
 | 
			
		||||
		})
 | 
			
		||||
	try {
 | 
			
		||||
		const res = await prisma[table].delete({ where: { id } })
 | 
			
		||||
		console.log(`deleted from ${table}: ${res.id}`)
 | 
			
		||||
	})
 | 
			
		||||
	revalidatePath(pathname)
 | 
			
		||||
	redirect(pathname)
 | 
			
		||||
		revalidatePath(pathname)
 | 
			
		||||
		return true
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(error)
 | 
			
		||||
		return undefined
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function deleteRecords(ids: number[], pathname: "/story" | "/publication" | "/submission"): Promise<boolean | undefined> {
 | 
			
		||||
	try {
 | 
			
		||||
		const table = tableMap[pathname]
 | 
			
		||||
		ids.forEach(async (id) => {
 | 
			
		||||
			const res = await prisma[table].delete({
 | 
			
		||||
				where: { id }
 | 
			
		||||
			})
 | 
			
		||||
			console.log(`deleted from ${table}: ${res.id}`)
 | 
			
		||||
		})
 | 
			
		||||
		revalidatePath(pathname)
 | 
			
		||||
		return true
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(error)
 | 
			
		||||
		return undefined
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,30 +1,86 @@
 | 
			
		|||
"use server"
 | 
			
		||||
import { Genre } from "@prisma/client"
 | 
			
		||||
import { Genre, Story, Sub } from "@prisma/client"
 | 
			
		||||
import prisma from "./db"
 | 
			
		||||
import { revalidatePath } from "next/cache"
 | 
			
		||||
import { redirect } from "next/navigation"
 | 
			
		||||
import { storySchema, subSchema } from "app/ui/forms/schemas"
 | 
			
		||||
import { z } from "zod"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export async function updateField({ datum, table, column, id, pathname }: { datum?: string | number | Genre[], table: string, column: string, id: number, pathname: string }) {
 | 
			
		||||
	const res = await prisma[table].update({
 | 
			
		||||
		where: { id },
 | 
			
		||||
		data: {
 | 
			
		||||
			[column]: datum
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	console.log(`updated record in ${table}: ${JSON.stringify(res)}`)
 | 
			
		||||
	revalidatePath(pathname)
 | 
			
		||||
	redirect(pathname)
 | 
			
		||||
	"use server"
 | 
			
		||||
	try {
 | 
			
		||||
		const res = await prisma[table].update({
 | 
			
		||||
			where: { id },
 | 
			
		||||
			data: {
 | 
			
		||||
				[column]: datum
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
		console.log(`updated record in ${table}: ${JSON.stringify(res)}`)
 | 
			
		||||
		revalidatePath(pathname)
 | 
			
		||||
		return res
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(error)
 | 
			
		||||
		return undefined
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function updateGenres({ genres, table, id, pathname }: { genres: { id: number }[], table: string, id: number, pathname: string }) {
 | 
			
		||||
	const res = await prisma[table].update({
 | 
			
		||||
		where: { id },
 | 
			
		||||
		data: {
 | 
			
		||||
			genres: { set: genres }
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	console.log(`updated record in ${table}: ${JSON.stringify(res)}`)
 | 
			
		||||
	revalidatePath(pathname)
 | 
			
		||||
	redirect(pathname)
 | 
			
		||||
	"use server"
 | 
			
		||||
	try {
 | 
			
		||||
		const res = await prisma[table].update({
 | 
			
		||||
			where: { id },
 | 
			
		||||
			data: {
 | 
			
		||||
				genres: { set: genres }
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
		console.log(`updated record in ${table}: ${JSON.stringify(res)}`)
 | 
			
		||||
		revalidatePath(pathname)
 | 
			
		||||
		return res
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(error)
 | 
			
		||||
		return undefined
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function updateSub(data: Sub): Promise<Sub | undefined> {
 | 
			
		||||
	"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 undefined
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export async function updateStory(data: Story & { genres: number[] }): Promise<Story | undefined> {
 | 
			
		||||
	"use server"
 | 
			
		||||
	//prepare data
 | 
			
		||||
	const genresArray = data.genres.map((e) => { return { id: e } })
 | 
			
		||||
	const storyData = {
 | 
			
		||||
		title: data.title,
 | 
			
		||||
		word_count: data.word_count,
 | 
			
		||||
	}
 | 
			
		||||
	//prepare schemas
 | 
			
		||||
	const schema = storySchema.omit({ id: true, genres: true })
 | 
			
		||||
	const genreSchema = z.object({ id: z.number() })
 | 
			
		||||
	const genresSchema = z.array(genreSchema)
 | 
			
		||||
	try {
 | 
			
		||||
		//validate
 | 
			
		||||
		schema.safeParse(storyData)
 | 
			
		||||
		genresSchema.safeParse(genresArray)
 | 
			
		||||
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(error)
 | 
			
		||||
		return undefined
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
import { z } from "zod";
 | 
			
		||||
import { storySchema } from "app/ui/forms/schemas";
 | 
			
		||||
import { Story } from "@prisma/client";
 | 
			
		||||
 | 
			
		||||
//schemas
 | 
			
		||||
 | 
			
		||||
const schema = storySchema.omit({ id: true, genres: true })
 | 
			
		||||
const genreSchema = z.object({ id: z.number() })
 | 
			
		||||
const genresSchema = z.array(genreSchema)
 | 
			
		||||
 | 
			
		||||
export async function prepStoryData(data: Story & { genres: number[] }): Promise<{ title: string, word_count: number }> {
 | 
			
		||||
	const storyData = {
 | 
			
		||||
		title: data.title,
 | 
			
		||||
		word_count: data.word_count,
 | 
			
		||||
	}
 | 
			
		||||
	//prepare schemas
 | 
			
		||||
 | 
			
		||||
	//throw an error if validation fails
 | 
			
		||||
	schema.safeParse(storyData)
 | 
			
		||||
 | 
			
		||||
	return storyData
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,86 @@
 | 
			
		|||
"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"
 | 
			
		||||
	console.log(redirect)
 | 
			
		||||
	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)
 | 
			
		||||
			router.push(redirect)
 | 
			
		||||
		} else {
 | 
			
		||||
			toast({ title: "login failed!" })
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			{submitted ? <p>Logging in...</p> :
 | 
			
		||||
				<Form {...form}>
 | 
			
		||||
					<form onSubmit={onSubmit} className="mt-20 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>
 | 
			
		||||
			}
 | 
			
		||||
		</>
 | 
			
		||||
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
"use server"
 | 
			
		||||
import { revalidatePath } from "next/cache"
 | 
			
		||||
export default async function revalidate(path: string) {
 | 
			
		||||
	try {
 | 
			
		||||
		revalidatePath(path)
 | 
			
		||||
		return true
 | 
			
		||||
	} catch (error) {
 | 
			
		||||
		console.error(error)
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
import { z } from "zod"
 | 
			
		||||
export const loginSchema = z.object({
 | 
			
		||||
	email: z.string().email(),
 | 
			
		||||
	password: z.string().min(6)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export type LoginSchema = z.infer<typeof loginSchema>
 | 
			
		||||
| 
						 | 
				
			
			@ -1,13 +1,12 @@
 | 
			
		|||
"use client"
 | 
			
		||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
 | 
			
		||||
import { ArrowUpDown } from "lucide-react"
 | 
			
		||||
import { ArrowUpDown, BookType, Clock, Drama, SquareArrowOutUpRight } from "lucide-react"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import { Badge } from "@/components/ui/badge"
 | 
			
		||||
import { PubsWithGenres } from "./page"
 | 
			
		||||
import { TextInputCell } from "app/ui/tables/inputs/textInput"
 | 
			
		||||
import { selectCol } from "app/ui/tables/selectColumn"
 | 
			
		||||
import NumberInputCell from "app/ui/tables/inputs/numberInput"
 | 
			
		||||
import { formSchema } from "app/ui/forms/pub"
 | 
			
		||||
import { pubSchema } from "app/ui/forms/schemas"
 | 
			
		||||
import GenrePickerInputCell from "app/ui/tables/inputs/genrePickerInput"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -23,23 +22,47 @@ export const columns: ColumnDef<PubsWithGenres>[] = [
 | 
			
		|||
          variant="ghost"
 | 
			
		||||
          onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
 | 
			
		||||
        >
 | 
			
		||||
          Title
 | 
			
		||||
          <span className="hidden sm:block">
 | 
			
		||||
            Title
 | 
			
		||||
          </span>
 | 
			
		||||
          <span className="block sm:hidden"><BookType /></span>
 | 
			
		||||
          <ArrowUpDown className="ml-2 h-4 w-4" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      )
 | 
			
		||||
    },
 | 
			
		||||
    cell: TextInputCell,
 | 
			
		||||
    meta: { formSchema }
 | 
			
		||||
    cell: cell => (
 | 
			
		||||
      <>
 | 
			
		||||
        <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",
 | 
			
		||||
    header: "Link",
 | 
			
		||||
    cell: TextInputCell,
 | 
			
		||||
    meta: { formSchema }
 | 
			
		||||
    header: () => (
 | 
			
		||||
      <div className="mx-auto w-fit">
 | 
			
		||||
        <span className="hidden sm:block">Link</span>
 | 
			
		||||
        <span className="block sm:hidden"><SquareArrowOutUpRight /></span>
 | 
			
		||||
      </div>
 | 
			
		||||
    ),
 | 
			
		||||
    cell: cell => (
 | 
			
		||||
      <>
 | 
			
		||||
        <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", {
 | 
			
		||||
    header: () => (
 | 
			
		||||
      <div className="w-fit mx-auto">
 | 
			
		||||
        <span className="hidden sm:block">Genres</span>
 | 
			
		||||
        <span className="sm:hidden"><Drama /></span>
 | 
			
		||||
      </div>
 | 
			
		||||
    ),
 | 
			
		||||
    cell: GenrePickerInputCell,
 | 
			
		||||
    filterFn: "arrIncludes"
 | 
			
		||||
    //TODO - write custom filter function, to account for an array of objects
 | 
			
		||||
| 
						 | 
				
			
			@ -47,11 +70,21 @@ export const columns: ColumnDef<PubsWithGenres>[] = [
 | 
			
		|||
 | 
			
		||||
  {
 | 
			
		||||
    accessorKey: "query_after_days",
 | 
			
		||||
    header: "Query After (days)",
 | 
			
		||||
    cell: NumberInputCell,
 | 
			
		||||
    header: () => (
 | 
			
		||||
      <div>
 | 
			
		||||
        <span className="hidden sm:block">Query After (days)</span>
 | 
			
		||||
        <span className="sm:hidden"><Clock /></span>
 | 
			
		||||
      </div>
 | 
			
		||||
    ),
 | 
			
		||||
    cell: cell => (
 | 
			
		||||
      <>
 | 
			
		||||
        <p className="block md:hidden text-center">{cell.getValue()}</p>
 | 
			
		||||
        <NumberInputCell cellContext={cell} className="hidden md:block" />
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
    meta: {
 | 
			
		||||
      step: 10,
 | 
			
		||||
      formSchema
 | 
			
		||||
      formSchema: pubSchema
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,21 +5,31 @@ import { ComponentProps } from "react";
 | 
			
		|||
import { Genre } from "@prisma/client";
 | 
			
		||||
import { createPub } from "app/lib/create";
 | 
			
		||||
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[] }) {
 | 
			
		||||
 | 
			
		||||
  const [isOpen, setIsOpen] = useState(false)
 | 
			
		||||
  function closeDialog() {
 | 
			
		||||
    setIsOpen(false)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
 | 
			
		||||
    <Dialog>
 | 
			
		||||
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
 | 
			
		||||
      <DialogTrigger asChild>
 | 
			
		||||
        <Button>Create new publication</Button>
 | 
			
		||||
        <Button>
 | 
			
		||||
          <span className="hidden md:block">Create new publication</span>
 | 
			
		||||
          <Plus className="block md:hidden" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </DialogTrigger>
 | 
			
		||||
      <DialogContent>
 | 
			
		||||
        <DialogHeader>
 | 
			
		||||
          <DialogTitle>New publication</DialogTitle>
 | 
			
		||||
          <DialogDescription>Create an entry for a new publication i.e. a place you intend to submit stories to.</DialogDescription>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
        <PubForm createPub={createPub} genres={genres} />
 | 
			
		||||
        <PubForm createPub={createPub} genres={genres} closeDialog={closeDialog} />
 | 
			
		||||
        <DialogFooter>
 | 
			
		||||
          <Button form="pubform">Submit</Button>
 | 
			
		||||
        </DialogFooter>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,7 +12,7 @@ export default async function Page() {
 | 
			
		|||
  const genres = await getGenres()
 | 
			
		||||
  const pubs = await getPubsWithGenres()
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container mx-auto">
 | 
			
		||||
    <div className="container px-1 md:px-4 mx-auto">
 | 
			
		||||
      <DataTable data={pubs} columns={columns} tableName="pub" genres={genres}>
 | 
			
		||||
        <CreatePubDialog genres={genres} />
 | 
			
		||||
      </DataTable>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,11 @@
 | 
			
		|||
"use client"
 | 
			
		||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
 | 
			
		||||
import { StoryWithGenres } from "./page"
 | 
			
		||||
import { ArrowUpDown } from "lucide-react"
 | 
			
		||||
import { ArrowUpDown, BookType, Drama, Tally5 } from "lucide-react"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import GenreBadges from "app/ui/genreBadges"
 | 
			
		||||
import { selectCol } from "app/ui/tables/selectColumn"
 | 
			
		||||
import NumberInputCell from "app/ui/tables/inputs/numberInput"
 | 
			
		||||
import { formSchema } from "app/ui/forms/story"
 | 
			
		||||
import { storySchema } from "app/ui/forms/schemas"
 | 
			
		||||
import { TextInputCell } from "app/ui/tables/inputs/textInput"
 | 
			
		||||
import GenrePickerInputCell from "app/ui/tables/inputs/genrePickerInput"
 | 
			
		||||
const columnHelper = createColumnHelper<StoryWithGenres>()
 | 
			
		||||
| 
						 | 
				
			
			@ -19,16 +18,24 @@ export const columns: ColumnDef<StoryWithGenres>[] = [
 | 
			
		|||
      return (
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="ghost"
 | 
			
		||||
          className="px-1"
 | 
			
		||||
          onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
 | 
			
		||||
        >
 | 
			
		||||
          Title
 | 
			
		||||
          <span className="hidden sm:block">
 | 
			
		||||
            Title
 | 
			
		||||
          </span>
 | 
			
		||||
          <span className="block sm:hidden"><BookType /></span>
 | 
			
		||||
          <ArrowUpDown className="ml-2 h-4 w-4" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      )
 | 
			
		||||
    },
 | 
			
		||||
    cell: TextInputCell,
 | 
			
		||||
    meta: { formSchema }
 | 
			
		||||
 | 
			
		||||
    cell: cell => (
 | 
			
		||||
      <>
 | 
			
		||||
        <p className="block break-words max-w-36 md:hidden text-xs">{cell.getValue()}</p>
 | 
			
		||||
        <TextInputCell cellContext={cell} className="hidden md:block" />
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
    meta: { formSchema: storySchema }
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    accessorKey: "word_count",
 | 
			
		||||
| 
						 | 
				
			
			@ -36,21 +43,38 @@ export const columns: ColumnDef<StoryWithGenres>[] = [
 | 
			
		|||
      return (
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="ghost"
 | 
			
		||||
          className="px-1"
 | 
			
		||||
          onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
 | 
			
		||||
        >
 | 
			
		||||
          Word Count
 | 
			
		||||
          <span className="hidden sm:block">
 | 
			
		||||
            Word Count
 | 
			
		||||
          </span>
 | 
			
		||||
          <span className="sm:hidden">
 | 
			
		||||
            <Tally5 />
 | 
			
		||||
          </span>
 | 
			
		||||
          <ArrowUpDown className="ml-2 h-4 w-4" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      )
 | 
			
		||||
    },
 | 
			
		||||
    enableColumnFilter: false,
 | 
			
		||||
    cell: NumberInputCell,
 | 
			
		||||
    cell: cell => (
 | 
			
		||||
      <>
 | 
			
		||||
        <p className="block md:hidden text-center text-xs">{cell.getValue()}</p>
 | 
			
		||||
        <NumberInputCell cellContext={cell} className="hidden md:block" />
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
    meta: {
 | 
			
		||||
      step: 50,
 | 
			
		||||
      formSchema
 | 
			
		||||
      formSchema: storySchema
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  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,
 | 
			
		||||
    filterFn: "arrIncludes",
 | 
			
		||||
    meta: {}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,24 +2,33 @@
 | 
			
		|||
import { createStory } from "app/lib/create"
 | 
			
		||||
import { Dialog, DialogHeader, DialogTrigger, DialogContent, DialogClose, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { ComponentProps } from "react";
 | 
			
		||||
import { ComponentProps, useState } from "react";
 | 
			
		||||
import { Genre } from "@prisma/client";
 | 
			
		||||
import StoryForm from "app/ui/forms/story";
 | 
			
		||||
import { Plus } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export default function CreateStoryDialog({ genres }: ComponentProps<"div"> & { genres: Genre[] }) {
 | 
			
		||||
 | 
			
		||||
  const [isOpen, setIsOpen] = useState(false)
 | 
			
		||||
  function closeDialog() {
 | 
			
		||||
    setIsOpen(false)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog>
 | 
			
		||||
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
 | 
			
		||||
      <DialogTrigger asChild>
 | 
			
		||||
        <Button>Create new story</Button>
 | 
			
		||||
        <div>
 | 
			
		||||
          <Button className="hidden md:block">Create new story</Button>
 | 
			
		||||
          <Button className="block md:hidden"><Plus /> </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </DialogTrigger>
 | 
			
		||||
      <DialogContent>
 | 
			
		||||
        <DialogHeader>
 | 
			
		||||
          <DialogTitle>New story</DialogTitle>
 | 
			
		||||
          <DialogDescription>Create an entry for a new story i.e. a thing you intend to submit for publication.</DialogDescription>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
        <StoryForm createStory={createStory} genres={genres} existingData={null} />
 | 
			
		||||
        <StoryForm createStory={createStory} genres={genres} className="" closeDialog={closeDialog} />
 | 
			
		||||
        <DialogFooter>
 | 
			
		||||
          <Button form="storyform">Submit</Button>
 | 
			
		||||
        </DialogFooter>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,7 +19,7 @@ export default async function Page() {
 | 
			
		|||
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container mx-auto">
 | 
			
		||||
    <div className="container px-1 md:px-4 mx-auto">
 | 
			
		||||
      <DataTable columns={columns} data={storiesWithGenres} tableName="story"
 | 
			
		||||
        genres={genres}
 | 
			
		||||
      >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,39 +0,0 @@
 | 
			
		|||
"use client"
 | 
			
		||||
import { LineChart, Line, CartesianGrid, XAxis, YAxis, PieChart, Pie } from "recharts"
 | 
			
		||||
import { SubComplete } from "./page"
 | 
			
		||||
export function SubsChart({ data }: { data: Array<SubComplete> }) {
 | 
			
		||||
  const pieData: Array<{ story: string, occurrences: number }> = []
 | 
			
		||||
  data.forEach(dataRow => {
 | 
			
		||||
    const story = dataRow.story.title
 | 
			
		||||
    const exists = pieData.findIndex(pieRow => story === pieRow.story)
 | 
			
		||||
    if (exists === -1) {
 | 
			
		||||
      //add the story to pieData if it doesn't already exist
 | 
			
		||||
      pieData.push({ story: story, occurrences: 0 })
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
    pieData[exists].occurrences++
 | 
			
		||||
  })
 | 
			
		||||
  console.log(pieData)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <PieChart width={400} height={400}>
 | 
			
		||||
        <Pie data={pieData} dataKey="story" outerRadius={50} fill="teal" />
 | 
			
		||||
      </PieChart>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
      <LineChart width={400} height={400} data={data}>
 | 
			
		||||
        <Line type="monotone" dataKey="id" stroke="#8884d8" />
 | 
			
		||||
        <CartesianGrid />
 | 
			
		||||
        <XAxis dataKey="submitted" />
 | 
			
		||||
        <YAxis />
 | 
			
		||||
 | 
			
		||||
      </LineChart>
 | 
			
		||||
    </>
 | 
			
		||||
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,10 +1,11 @@
 | 
			
		|||
"use client"
 | 
			
		||||
import { CellContext, ColumnDef, createColumnHelper } from "@tanstack/react-table"
 | 
			
		||||
import { ArrowUpDown } from "lucide-react"
 | 
			
		||||
import { ArrowUpDown, BookText, CalendarMinus, CalendarPlus, MessageCircleReply, NotepadText } from "lucide-react"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import { SubComplete } from "./page"
 | 
			
		||||
import { selectCol } from "app/ui/tables/selectColumn"
 | 
			
		||||
import TitleContainer from "app/ui/titleContainer"
 | 
			
		||||
import { CalendarArrowUp } from "lucide"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -18,7 +19,12 @@ export const columns: ColumnDef<SubComplete>[] = [
 | 
			
		|||
      return "RECORD DELETED"
 | 
			
		||||
    },
 | 
			
		||||
    id: "story",
 | 
			
		||||
    header: "Story",
 | 
			
		||||
    header: () => (
 | 
			
		||||
      <>
 | 
			
		||||
        <span className="hidden md:block">Story</span>
 | 
			
		||||
        <NotepadText className="block md:hidden" />
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
    cell: (props: CellContext<any, any>) => (<TitleContainer>{props.getValue()}</TitleContainer>)
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
| 
						 | 
				
			
			@ -29,7 +35,12 @@ export const columns: ColumnDef<SubComplete>[] = [
 | 
			
		|||
      return "RECORD DELETED"
 | 
			
		||||
    },
 | 
			
		||||
    id: "pub",
 | 
			
		||||
    header: "Publication",
 | 
			
		||||
    header: () => (
 | 
			
		||||
      <>
 | 
			
		||||
        <span className="hidden md:block">Publication</span>
 | 
			
		||||
        <BookText className="block md:hidden" />
 | 
			
		||||
      </>
 | 
			
		||||
    ),
 | 
			
		||||
    cell: (props: CellContext<any, any>) => (<TitleContainer>{props.getValue()}</TitleContainer>)
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
| 
						 | 
				
			
			@ -39,16 +50,24 @@ export const columns: ColumnDef<SubComplete>[] = [
 | 
			
		|||
      return (
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="ghost"
 | 
			
		||||
          className="p-0"
 | 
			
		||||
          onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
 | 
			
		||||
        >
 | 
			
		||||
          Date Submitted
 | 
			
		||||
          <ArrowUpDown className="ml-2 h-4 w-4" />
 | 
			
		||||
          <span className="hidden md:block"> Date Submitted </span>
 | 
			
		||||
          <CalendarPlus className="block md:hidden" />
 | 
			
		||||
          <ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      )
 | 
			
		||||
    },
 | 
			
		||||
    enableColumnFilter: false,
 | 
			
		||||
    sortingFn: "datetime",
 | 
			
		||||
    cell: (props: CellContext<any, any>) => (<p className="w-full text-center">{props.getValue().toLocaleDateString()}</p>)
 | 
			
		||||
    cell: (props: CellContext<any, any>) => (
 | 
			
		||||
      <p className="w-full text-center text-xs md:text-sm">{props.getValue().toLocaleDateString('ES', {
 | 
			
		||||
        day: 'numeric',
 | 
			
		||||
        month: 'numeric',
 | 
			
		||||
        year: '2-digit'
 | 
			
		||||
      })}</p>
 | 
			
		||||
    )
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    accessorFn: row => row.responded ? new Date(row.responded) : null,
 | 
			
		||||
| 
						 | 
				
			
			@ -57,16 +76,22 @@ export const columns: ColumnDef<SubComplete>[] = [
 | 
			
		|||
      return (
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="ghost"
 | 
			
		||||
          className="p-0"
 | 
			
		||||
          onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
 | 
			
		||||
        >
 | 
			
		||||
          Date Responded
 | 
			
		||||
          <ArrowUpDown className="ml-2 h-4 w-4" />
 | 
			
		||||
          <span className="hidden md:block"> Date Responded </span>
 | 
			
		||||
          <CalendarMinus className="block md:hidden" />
 | 
			
		||||
          <ArrowUpDown className="ml-2 h-4 w-4 hidden md:block" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      )
 | 
			
		||||
    },
 | 
			
		||||
    enableColumnFilter: false,
 | 
			
		||||
    sortingFn: "datetime",
 | 
			
		||||
    cell: (props: CellContext<any, any>) => (<p className="w-full text-center">{props.getValue()?.toLocaleDateString()}</p>)
 | 
			
		||||
    cell: (props: CellContext<any, any>) => (<p className="w-full text-center text-xs md:text-sm">{props.getValue()?.toLocaleDateString('ES', {
 | 
			
		||||
      day: 'numeric',
 | 
			
		||||
      month: 'numeric',
 | 
			
		||||
      year: '2-digit'
 | 
			
		||||
    })}</p>)
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    accessorFn: row => {
 | 
			
		||||
| 
						 | 
				
			
			@ -76,8 +101,20 @@ export const columns: ColumnDef<SubComplete>[] = [
 | 
			
		|||
      return "RECORD DELETED"
 | 
			
		||||
    },
 | 
			
		||||
    id: "response",
 | 
			
		||||
    header: "Response",
 | 
			
		||||
    cell: (props: CellContext<any, any>) => (<p className="w-full text-center">{props.getValue()}</p>)
 | 
			
		||||
    header: ({ column }) => {
 | 
			
		||||
      return (
 | 
			
		||||
        <Button
 | 
			
		||||
          variant="ghost"
 | 
			
		||||
          className="p-0"
 | 
			
		||||
          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>)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
 | 
			
		|||
import { ComponentProps } from "react";
 | 
			
		||||
import { Pub, Response, Story } from "@prisma/client";
 | 
			
		||||
import SubmissionForm from "app/ui/forms/sub";
 | 
			
		||||
import { Plus } from "lucide-react";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
type CreateSubDefaults = {
 | 
			
		||||
| 
						 | 
				
			
			@ -19,20 +21,26 @@ type CreateSubDefaults = {
 | 
			
		|||
 | 
			
		||||
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 (
 | 
			
		||||
    <Dialog>
 | 
			
		||||
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
 | 
			
		||||
      <DialogTrigger asChild>
 | 
			
		||||
        <Button>Create new submission</Button>
 | 
			
		||||
        <Button>
 | 
			
		||||
          <span className="hidden md:block">Create new submission</span>
 | 
			
		||||
          <Plus className="block md:hidden" />
 | 
			
		||||
        </Button>
 | 
			
		||||
      </DialogTrigger>
 | 
			
		||||
      <DialogContent>
 | 
			
		||||
      <DialogContent className="text-xs md:text-sm">
 | 
			
		||||
        <DialogHeader>
 | 
			
		||||
          <DialogTitle>New submission</DialogTitle>
 | 
			
		||||
          <DialogDescription>Create an entry for a new story i.e. a thing you intend to submit for publication.</DialogDescription>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
        <SubmissionForm createSub={createSub} pubs={pubs} responses={responses} stories={stories} defaults={defaults} />
 | 
			
		||||
        <SubmissionForm pubs={pubs} responses={responses} stories={stories} defaults={defaults} closeDialog={closeDialog} />
 | 
			
		||||
        <DialogFooter>
 | 
			
		||||
          <DialogClose asChild>
 | 
			
		||||
          </DialogClose>
 | 
			
		||||
          <Button form="subform">Submit</Button>
 | 
			
		||||
        </DialogFooter>
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,10 +5,12 @@ import { Button } from "@/components/ui/button";
 | 
			
		|||
import { ComponentProps } from "react";
 | 
			
		||||
import { Pub, Response, Story } from "@prisma/client";
 | 
			
		||||
import SubmissionForm, { SubForm } from "app/ui/forms/sub";
 | 
			
		||||
import EditSubmissionForm from "app/ui/forms/editSub";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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 (
 | 
			
		||||
    <>
 | 
			
		||||
| 
						 | 
				
			
			@ -16,10 +18,8 @@ export default function EditSubmissionDialog({ stories, pubs, responses, default
 | 
			
		|||
        <DialogTitle>Edit Submission</DialogTitle>
 | 
			
		||||
        <DialogDescription>Change response status, edit dates etc</DialogDescription>
 | 
			
		||||
      </DialogHeader>
 | 
			
		||||
      <SubmissionForm pubs={pubs} responses={responses} stories={stories} defaults={defaults} />
 | 
			
		||||
      <EditSubmissionForm pubs={pubs} responses={responses} stories={stories} defaults={defaults} closeDialog={closeDialog} />
 | 
			
		||||
      <DialogFooter>
 | 
			
		||||
        <DialogClose asChild>
 | 
			
		||||
        </DialogClose>
 | 
			
		||||
        <Button form="subform">Submit</Button>
 | 
			
		||||
      </DialogFooter>
 | 
			
		||||
    </>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,7 +3,6 @@ import { DataTable } from "app/ui/tables/data-table"
 | 
			
		|||
import { columns } from "./columns"
 | 
			
		||||
import { Pub, Response, Story, Sub } from "@prisma/client"
 | 
			
		||||
import CreateSubmissionDialog from "./create"
 | 
			
		||||
import { Trash2 } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
export type SubComplete = Sub & {
 | 
			
		||||
  pub: Pub,
 | 
			
		||||
| 
						 | 
				
			
			@ -11,13 +10,15 @@ export type SubComplete = Sub & {
 | 
			
		|||
  response: Response
 | 
			
		||||
}
 | 
			
		||||
export default async function Page() {
 | 
			
		||||
 | 
			
		||||
  const subs: Array<SubComplete> = await getSubsComplete()
 | 
			
		||||
  const stories = await getStories()
 | 
			
		||||
  const pubs = await getPubs()
 | 
			
		||||
  const responses = await getResponses()
 | 
			
		||||
  const genres = await getGenres()
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container">
 | 
			
		||||
    <div className="container px-1 md:px-4 mx-auto">
 | 
			
		||||
      <DataTable data={subs} columns={columns} tableName="sub"
 | 
			
		||||
        stories={stories}
 | 
			
		||||
        pubs={pubs}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -740,8 +740,9 @@ body {
 | 
			
		|||
  margin-bottom: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mb-4 {
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
.my-auto {
 | 
			
		||||
  margin-top: auto;
 | 
			
		||||
  margin-bottom: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.ml-2 {
 | 
			
		||||
| 
						 | 
				
			
			@ -752,10 +753,18 @@ body {
 | 
			
		|||
  margin-left: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mr-4 {
 | 
			
		||||
  margin-right: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mt-2 {
 | 
			
		||||
  margin-top: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mt-20 {
 | 
			
		||||
  margin-top: 5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mt-3 {
 | 
			
		||||
  margin-top: 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -768,8 +777,8 @@ body {
 | 
			
		|||
  margin-top: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.mt-auto {
 | 
			
		||||
  margin-top: auto;
 | 
			
		||||
.block {
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.flex {
 | 
			
		||||
| 
						 | 
				
			
			@ -792,6 +801,10 @@ body {
 | 
			
		|||
  display: grid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.hidden {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.size-full {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
| 
						 | 
				
			
			@ -833,10 +846,6 @@ body {
 | 
			
		|||
  height: 1.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.h-5\/6 {
 | 
			
		||||
  height: 83.333333%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.h-7 {
 | 
			
		||||
  height: 1.75rem;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -914,10 +923,6 @@ body {
 | 
			
		|||
  width: 10rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.w-5\/6 {
 | 
			
		||||
  width: 83.333333%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.w-7 {
 | 
			
		||||
  width: 1.75rem;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -972,6 +977,18 @@ body {
 | 
			
		|||
  min-width: fit-content;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-w-16 {
 | 
			
		||||
  max-width: 4rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-w-24 {
 | 
			
		||||
  max-width: 6rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-w-36 {
 | 
			
		||||
  max-width: 9rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.max-w-full {
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1175,6 +1192,12 @@ body {
 | 
			
		|||
  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]) {
 | 
			
		||||
  --tw-space-y-reverse: 0;
 | 
			
		||||
  margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse)));
 | 
			
		||||
| 
						 | 
				
			
			@ -1193,10 +1216,24 @@ body {
 | 
			
		|||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.truncate {
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  text-overflow: ellipsis;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.whitespace-nowrap {
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.break-words {
 | 
			
		||||
  overflow-wrap: break-word;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.rounded-3xl {
 | 
			
		||||
  border-radius: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.rounded-full {
 | 
			
		||||
  border-radius: 9999px;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1209,11 +1246,6 @@ body {
 | 
			
		|||
  border-radius: calc(var(--radius) - 4px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.rounded-l-3xl {
 | 
			
		||||
  border-top-left-radius: 1.5rem;
 | 
			
		||||
  border-bottom-left-radius: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.rounded-t-3xl {
 | 
			
		||||
  border-top-left-radius: 1.5rem;
 | 
			
		||||
  border-top-right-radius: 1.5rem;
 | 
			
		||||
| 
						 | 
				
			
			@ -1344,6 +1376,11 @@ body {
 | 
			
		|||
  padding: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.px-1 {
 | 
			
		||||
  padding-left: 0.25rem;
 | 
			
		||||
  padding-right: 0.25rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.px-2 {
 | 
			
		||||
  padding-left: 0.5rem;
 | 
			
		||||
  padding-right: 0.5rem;
 | 
			
		||||
| 
						 | 
				
			
			@ -2303,6 +2340,14 @@ body {
 | 
			
		|||
    top: auto;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:block {
 | 
			
		||||
    display: block;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:hidden {
 | 
			
		||||
    display: none;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .sm\:flex-row {
 | 
			
		||||
    flex-direction: row;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			@ -2347,9 +2392,76 @@ body {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
@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\] {
 | 
			
		||||
    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]) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,268 @@
 | 
			
		|||
"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}>
 | 
			
		||||
			{e.title}
 | 
			
		||||
		</SelectItem>
 | 
			
		||||
	))
 | 
			
		||||
 | 
			
		||||
	const reponsesSelectItems = responses.map(e => (
 | 
			
		||||
		<SelectItem value={e.id}>
 | 
			
		||||
			{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}>
 | 
			
		||||
								<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}>
 | 
			
		||||
								<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">
 | 
			
		||||
									<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">
 | 
			
		||||
									<Calendar
 | 
			
		||||
										mode="single"
 | 
			
		||||
										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}>
 | 
			
		||||
								<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>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,121 +0,0 @@
 | 
			
		|||
"use client"
 | 
			
		||||
 | 
			
		||||
import { z } from "zod"
 | 
			
		||||
import { zodResolver } from "@hookform/resolvers/zod"
 | 
			
		||||
import { useForm } from "react-hook-form"
 | 
			
		||||
import { Genre } from "@prisma/client"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import {
 | 
			
		||||
	Form,
 | 
			
		||||
	FormControl,
 | 
			
		||||
	FormDescription,
 | 
			
		||||
	FormField,
 | 
			
		||||
	FormItem,
 | 
			
		||||
	FormLabel,
 | 
			
		||||
	FormMessage,
 | 
			
		||||
} from "@/components/ui/form"
 | 
			
		||||
import { Input } from "@/components/ui/input"
 | 
			
		||||
import { Checkbox } from "@/components/ui/checkbox"
 | 
			
		||||
 | 
			
		||||
const formSchema = z.object({
 | 
			
		||||
	title: z.string().min(2).max(50),
 | 
			
		||||
	word_count: z.number(),
 | 
			
		||||
	genres: z.object({ id: z.number(), name: z.string() }).array()
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export default function FancyForm({ genres }) {
 | 
			
		||||
	// 1. Define your form.
 | 
			
		||||
	const form = useForm<z.infer<typeof formSchema>>({
 | 
			
		||||
		resolver: zodResolver(formSchema),
 | 
			
		||||
		defaultValues: {
 | 
			
		||||
			title: "",
 | 
			
		||||
			word_count: 0,
 | 
			
		||||
			genres: genres
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
	// 2. Define a submit handler.
 | 
			
		||||
	function onSubmit(values: z.infer<typeof formSchema>) {
 | 
			
		||||
		// Do something with the form values.
 | 
			
		||||
		// ✅ This will be type-safe and validated.
 | 
			
		||||
		console.log(values)
 | 
			
		||||
	}
 | 
			
		||||
	return (
 | 
			
		||||
		<Form {...form}>
 | 
			
		||||
			<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
 | 
			
		||||
				<FormField
 | 
			
		||||
					control={form.control}
 | 
			
		||||
					name="title"
 | 
			
		||||
					render={({ field }) => (
 | 
			
		||||
						<FormItem>
 | 
			
		||||
							<FormLabel>Title</FormLabel>
 | 
			
		||||
							<FormControl>
 | 
			
		||||
								<Input placeholder="title goes here..." {...field} />
 | 
			
		||||
							</FormControl>
 | 
			
		||||
							<FormMessage />
 | 
			
		||||
						</FormItem>
 | 
			
		||||
					)}
 | 
			
		||||
				/>
 | 
			
		||||
				<FormField
 | 
			
		||||
					control={form.control}
 | 
			
		||||
					name="word_count"
 | 
			
		||||
					render={({ field }) => (
 | 
			
		||||
						<FormItem>
 | 
			
		||||
							<FormLabel>Word count</FormLabel>
 | 
			
		||||
							<FormControl>
 | 
			
		||||
								<Input type="number" step={500} min={0} {...field}></Input>
 | 
			
		||||
							</FormControl>
 | 
			
		||||
						</FormItem>
 | 
			
		||||
					)}
 | 
			
		||||
				/>
 | 
			
		||||
				<FormField
 | 
			
		||||
					control={form.control}
 | 
			
		||||
					name="genres"
 | 
			
		||||
					render={({ field }) => (
 | 
			
		||||
						<FormItem>
 | 
			
		||||
							<div className="mb-4">
 | 
			
		||||
								<FormLabel>Genres</FormLabel>
 | 
			
		||||
								<FormDescription>genres baby</FormDescription>
 | 
			
		||||
							</div>
 | 
			
		||||
							{genres.map((item) => (
 | 
			
		||||
								<FormField
 | 
			
		||||
									key={item.id}
 | 
			
		||||
									control={form.control}
 | 
			
		||||
									name="genres"
 | 
			
		||||
									render={({ field }) => {
 | 
			
		||||
										return (
 | 
			
		||||
											<FormItem
 | 
			
		||||
												key={item.id}
 | 
			
		||||
												className="flex flex-row items-start space-x-3 space-y-0"
 | 
			
		||||
											>
 | 
			
		||||
												<FormControl>
 | 
			
		||||
													<Checkbox
 | 
			
		||||
														checked={field.value?.includes(item.id)}
 | 
			
		||||
														onCheckedChange={(checked) => {
 | 
			
		||||
															return checked
 | 
			
		||||
																? field.onChange([...field.value, item.id])
 | 
			
		||||
																: field.onChange(
 | 
			
		||||
																	field.value?.filter(
 | 
			
		||||
																		(value) => value !== item.id
 | 
			
		||||
																	)
 | 
			
		||||
																)
 | 
			
		||||
														}}
 | 
			
		||||
													/>
 | 
			
		||||
												</FormControl>
 | 
			
		||||
												<FormLabel className="text-sm font-normal">
 | 
			
		||||
													{item.name}
 | 
			
		||||
												</FormLabel>
 | 
			
		||||
											</FormItem>
 | 
			
		||||
										)
 | 
			
		||||
									}}
 | 
			
		||||
								/>
 | 
			
		||||
							))}
 | 
			
		||||
						</FormItem>
 | 
			
		||||
					)}
 | 
			
		||||
 | 
			
		||||
				/>
 | 
			
		||||
				<Button type="submit">Submit</Button>
 | 
			
		||||
			</form>
 | 
			
		||||
		</Form>
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -19,17 +19,13 @@ import { randomPublicationTitle } from "app/lib/shortStoryTitleGenerator"
 | 
			
		|||
import { ComponentProps } from "react"
 | 
			
		||||
import { Genre } from "@prisma/client"
 | 
			
		||||
import GenrePicker from "./genrePicker"
 | 
			
		||||
import { pubSchema } from "./schemas"
 | 
			
		||||
import { useRouter } from "next/navigation"
 | 
			
		||||
import { Ban } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
export const formSchema = z.object({
 | 
			
		||||
	title: z.string().min(2).max(50),
 | 
			
		||||
	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),
 | 
			
		||||
export default function PubForm({ genres, createPub, className, closeDialog }: ComponentProps<"div"> & { genres: Array<Genre>, createPub: (data: any) => void, closeDialog: () => void }) {
 | 
			
		||||
	const form = useForm<z.infer<typeof pubSchema>>({
 | 
			
		||||
		resolver: zodResolver(pubSchema),
 | 
			
		||||
		defaultValues: {
 | 
			
		||||
			title: "",
 | 
			
		||||
			link: "",
 | 
			
		||||
| 
						 | 
				
			
			@ -38,28 +34,27 @@ export default function PubForm({ genres, createPub, className }: ComponentProps
 | 
			
		|||
		},
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	function onSubmit(values: z.infer<typeof formSchema>) {
 | 
			
		||||
		// Do something with the form values.
 | 
			
		||||
		// ✅ This will be type-safe and validated.
 | 
			
		||||
		toast({
 | 
			
		||||
			title: "You submitted the following values:",
 | 
			
		||||
			description: (
 | 
			
		||||
				<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
 | 
			
		||||
					<code className="text-white">{JSON.stringify(values, null, 2)}</code>
 | 
			
		||||
				</pre>
 | 
			
		||||
			),
 | 
			
		||||
		})
 | 
			
		||||
		createPub(values)
 | 
			
		||||
		console.log(values)
 | 
			
		||||
	const router = useRouter()
 | 
			
		||||
 | 
			
		||||
	async function onSubmit(values: z.infer<typeof pubSchema>) {
 | 
			
		||||
		try {
 | 
			
		||||
			const res = await createPub(values)
 | 
			
		||||
			if (!res) throw new Error("something went wrong")
 | 
			
		||||
			toast({ title: "Successfully submitted:", description: values.title })
 | 
			
		||||
			router.refresh()
 | 
			
		||||
			closeDialog()
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			toast({
 | 
			
		||||
				title: "Oh dear... ",
 | 
			
		||||
				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>
 | 
			
		||||
				<Ban />
 | 
			
		||||
			),
 | 
			
		||||
		})
 | 
			
		||||
		console.log(JSON.stringify(errors))
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
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({
 | 
			
		||||
	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"
 | 
			
		||||
		}
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -15,10 +15,12 @@ import {
 | 
			
		|||
import { Input } from "@/components/ui/input"
 | 
			
		||||
import { toast } from "@/components/ui/use-toast"
 | 
			
		||||
 | 
			
		||||
import { ComponentProps } from "react"
 | 
			
		||||
import { ComponentProps, SetStateAction } from "react"
 | 
			
		||||
import { Genre, Story } from "@prisma/client"
 | 
			
		||||
import { randomStoryTitle } from "app/lib/shortStoryTitleGenerator"
 | 
			
		||||
import GenrePicker from "./genrePicker"
 | 
			
		||||
import { useRouter } from "next/navigation"
 | 
			
		||||
import { Ban, Cross } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
export const formSchema = z.object({
 | 
			
		||||
	id: z.number().optional(),
 | 
			
		||||
| 
						 | 
				
			
			@ -27,42 +29,38 @@ export const formSchema = z.object({
 | 
			
		|||
	genres: z.array(z.number())
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export default function StoryForm({ genres, createStory, className, existingData }: ComponentProps<"div"> & { genres: Array<Genre>, createStory: (data: any) => void, existingData: Story & { genres: number[] } | null }) {
 | 
			
		||||
export default function StoryForm({ genres, createStory, className, closeDialog }: ComponentProps<"div"> & { genres: Array<Genre>, createStory: (data: any) => void, className: string, closeDialog: () => void }) {
 | 
			
		||||
	const form = useForm<z.infer<typeof formSchema>>({
 | 
			
		||||
		resolver: zodResolver(formSchema),
 | 
			
		||||
		defaultValues: {
 | 
			
		||||
			id: existingData?.id,
 | 
			
		||||
			title: existingData?.title ?? "",
 | 
			
		||||
			word_count: existingData?.word_count ?? 500,
 | 
			
		||||
			genres: existingData?.genres ?? []
 | 
			
		||||
			word_count: 500,
 | 
			
		||||
			genres: []
 | 
			
		||||
		},
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	const router = useRouter()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	function onSubmit(values: z.infer<typeof formSchema>) {
 | 
			
		||||
		toast({
 | 
			
		||||
			title: "You submitted the following values:",
 | 
			
		||||
			description: (
 | 
			
		||||
				<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
 | 
			
		||||
					<code className="text-white">{JSON.stringify(values, null, 2)}</code>
 | 
			
		||||
				</pre>
 | 
			
		||||
			),
 | 
			
		||||
		})
 | 
			
		||||
		createStory(values)
 | 
			
		||||
		console.log(values)
 | 
			
		||||
	async function onSubmit(values: z.infer<typeof formSchema>) {
 | 
			
		||||
		try {
 | 
			
		||||
			const res = await createStory(values)
 | 
			
		||||
			//server actions return undefined if middleware authentication fails
 | 
			
		||||
			if (!res) throw new Error("something went wrong")
 | 
			
		||||
			toast({ title: "Sucessfully submitted:", description: values.title })
 | 
			
		||||
			router.refresh()
 | 
			
		||||
			closeDialog()
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			toast({
 | 
			
		||||
				title: "Oh dear... ",
 | 
			
		||||
				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>
 | 
			
		||||
			),
 | 
			
		||||
			description: (<Ban />)
 | 
			
		||||
		})
 | 
			
		||||
		console.log(JSON.stringify(errors))
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -99,7 +97,7 @@ export default function StoryForm({ genres, createStory, className, existingData
 | 
			
		|||
								<FormItem className="flex flex-col">
 | 
			
		||||
									<FormLabel className="h-5">Word count</FormLabel>
 | 
			
		||||
									<FormControl>
 | 
			
		||||
										<Input className=" w-24" type="number" step={500} min={1} {...field}></Input>
 | 
			
		||||
										<Input className=" w-24" type="number" step={500} {...field}></Input>
 | 
			
		||||
									</FormControl>
 | 
			
		||||
									<FormMessage />
 | 
			
		||||
								</FormItem>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,54 +32,17 @@ import {
 | 
			
		|||
	SelectValue,
 | 
			
		||||
} from "@/components/ui/select"
 | 
			
		||||
import { useState } from "react"
 | 
			
		||||
import { editSubmission } from "app/lib/edit"
 | 
			
		||||
import { createSub } from "app/lib/create"
 | 
			
		||||
import { subSchema } from "./schemas"
 | 
			
		||||
import { useRouter } from "next/navigation"
 | 
			
		||||
import { Ban } from "lucide-react"
 | 
			
		||||
 | 
			
		||||
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 type SubForm = z.infer<typeof subSchema>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		||||
	const form = useForm<z.infer<typeof formSchema>>({
 | 
			
		||||
		resolver: zodResolver(formSchema),
 | 
			
		||||
export default function SubmissionForm({ stories, pubs, responses, defaults, closeDialog }: { stories: any, pubs: any, responses: any, defaults: any, closeDialog: () => void }) {
 | 
			
		||||
	const form = useForm<z.infer<typeof subSchema>>({
 | 
			
		||||
		resolver: zodResolver(subSchema),
 | 
			
		||||
		defaultValues: {
 | 
			
		||||
			responseId: responses[0].id,
 | 
			
		||||
			...defaults
 | 
			
		||||
| 
						 | 
				
			
			@ -104,34 +67,27 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
		</SelectItem>
 | 
			
		||||
	))
 | 
			
		||||
 | 
			
		||||
	const router = useRouter()
 | 
			
		||||
 | 
			
		||||
	// 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.
 | 
			
		||||
		toast({
 | 
			
		||||
			title: "You submitted the following values:",
 | 
			
		||||
			description: (
 | 
			
		||||
				<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
 | 
			
		||||
					<code className="text-white">{JSON.stringify(values, null, 2)}</code>
 | 
			
		||||
				</pre>
 | 
			
		||||
			),
 | 
			
		||||
		})
 | 
			
		||||
		if (values.id) {
 | 
			
		||||
			editSubmission(values)
 | 
			
		||||
		} else {
 | 
			
		||||
			createSub(values)
 | 
			
		||||
	async function onSubmit(values: z.infer<typeof subSchema>) {
 | 
			
		||||
		try {
 | 
			
		||||
			const res = await createSub(values)
 | 
			
		||||
			if (!res) throw new Error("something went wrong")
 | 
			
		||||
			toast({ title: "Successfully created new submission!" })
 | 
			
		||||
			router.refresh()
 | 
			
		||||
			closeDialog()
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			toast({
 | 
			
		||||
				title: "UH-OH",
 | 
			
		||||
				description: error.message
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
		console.log(values)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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>
 | 
			
		||||
				<Ban />
 | 
			
		||||
			),
 | 
			
		||||
		})
 | 
			
		||||
		console.log(JSON.stringify(errors))
 | 
			
		||||
| 
						 | 
				
			
			@ -139,7 +95,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
 | 
			
		||||
	return (
 | 
			
		||||
		<Form {...form}>
 | 
			
		||||
			<form id="subform" onSubmit={form.handleSubmit(onSubmit, onErrors)} className="space-y-8">
 | 
			
		||||
			<form id="subform" onSubmit={form.handleSubmit(onSubmit, onErrors)} className="space-y-2 md:space-y-8 text-xs">
 | 
			
		||||
				<FormField
 | 
			
		||||
					control={form.control}
 | 
			
		||||
					name="storyId"
 | 
			
		||||
| 
						 | 
				
			
			@ -158,7 +114,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
									{storiesSelectItems}
 | 
			
		||||
								</SelectContent>
 | 
			
		||||
							</Select>
 | 
			
		||||
							<FormDescription>The piece you submitted</FormDescription>
 | 
			
		||||
							<FormDescription className="text-xs md:text-base">The piece you submitted</FormDescription>
 | 
			
		||||
							<FormMessage />
 | 
			
		||||
						</FormItem>
 | 
			
		||||
					)}
 | 
			
		||||
| 
						 | 
				
			
			@ -168,7 +124,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
					name="pubId"
 | 
			
		||||
					render={({ field }) => (
 | 
			
		||||
						<FormItem>
 | 
			
		||||
							<FormLabel>Publication</FormLabel>
 | 
			
		||||
							<FormLabel className="text-sm md:text-base">Publication</FormLabel>
 | 
			
		||||
							<Select onValueChange={field.onChange} defaultValue={field.value}>
 | 
			
		||||
								<FormControl>
 | 
			
		||||
									<SelectTrigger>
 | 
			
		||||
| 
						 | 
				
			
			@ -181,7 +137,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
									{pubsSelectItems}
 | 
			
		||||
								</SelectContent>
 | 
			
		||||
							</Select>
 | 
			
		||||
							<FormDescription>The market you sent it to</FormDescription>
 | 
			
		||||
							<FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription>
 | 
			
		||||
							<FormMessage />
 | 
			
		||||
						</FormItem>
 | 
			
		||||
					)}
 | 
			
		||||
| 
						 | 
				
			
			@ -192,7 +148,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
					name="submitted"
 | 
			
		||||
					render={({ field }) => (
 | 
			
		||||
						<FormItem className="flex flex-col">
 | 
			
		||||
							<FormLabel>Date of submission</FormLabel>
 | 
			
		||||
							<FormLabel className="text-sm md:text-base">Date of submission</FormLabel>
 | 
			
		||||
							<Popover modal={true} open={isSubCalendarOpen} onOpenChange={setIsSubCalendarOpen}>
 | 
			
		||||
								<PopoverTrigger asChild>
 | 
			
		||||
									<FormControl>
 | 
			
		||||
| 
						 | 
				
			
			@ -224,7 +180,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
									/>
 | 
			
		||||
								</PopoverContent>
 | 
			
		||||
							</Popover>
 | 
			
		||||
							<FormDescription>
 | 
			
		||||
							<FormDescription className="text-xs md:text-base">
 | 
			
		||||
								The date you sent it
 | 
			
		||||
							</FormDescription>
 | 
			
		||||
							<FormMessage />
 | 
			
		||||
| 
						 | 
				
			
			@ -237,7 +193,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
					name="responded"
 | 
			
		||||
					render={({ field }) => (
 | 
			
		||||
						<FormItem className="flex flex-col">
 | 
			
		||||
							<FormLabel>Date of response</FormLabel>
 | 
			
		||||
							<FormLabel className="text-sm md:text-base">Date of response</FormLabel>
 | 
			
		||||
							<Popover modal={true} open={isRespCalendarOpen} onOpenChange={setIsRespCalendarOpen}>
 | 
			
		||||
								<PopoverTrigger asChild>
 | 
			
		||||
									<FormControl>
 | 
			
		||||
| 
						 | 
				
			
			@ -269,7 +225,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
									/>
 | 
			
		||||
								</PopoverContent>
 | 
			
		||||
							</Popover>
 | 
			
		||||
							<FormDescription>
 | 
			
		||||
							<FormDescription className="text-xs md:text-base">
 | 
			
		||||
								The date they wrote back
 | 
			
		||||
							</FormDescription>
 | 
			
		||||
							<FormMessage />
 | 
			
		||||
| 
						 | 
				
			
			@ -283,7 +239,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
					name="responseId"
 | 
			
		||||
					render={({ field }) => (
 | 
			
		||||
						<FormItem>
 | 
			
		||||
							<FormLabel>Response</FormLabel>
 | 
			
		||||
							<FormLabel className="text-sm md:text-base">Response</FormLabel>
 | 
			
		||||
							<Select onValueChange={field.onChange} defaultValue={field.value}>
 | 
			
		||||
								<FormControl>
 | 
			
		||||
									<SelectTrigger>
 | 
			
		||||
| 
						 | 
				
			
			@ -296,7 +252,7 @@ export default function SubmissionForm({ stories, pubs, responses, defaults }) {
 | 
			
		|||
									{reponsesSelectItems}
 | 
			
		||||
								</SelectContent>
 | 
			
		||||
							</Select>
 | 
			
		||||
							<FormDescription>The market you sent it to</FormDescription>
 | 
			
		||||
							<FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription>
 | 
			
		||||
							<FormMessage />
 | 
			
		||||
						</FormItem>
 | 
			
		||||
					)}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
 | 
			
		|||
export default function GenreBadges(props: ComponentProps<"div"> & { genres: Array<Genre> }) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={"flex flex-wrap gap-1 justify-center " + props.className}>
 | 
			
		||||
      {props.genres.map((e: Genre) => (<Badge key={e.name}>{e.name}</Badge>))}
 | 
			
		||||
      {props.genres.map((e: Genre) => (<Badge className="" key={e.name}>{e.name}</Badge>))}
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
"use client"
 | 
			
		||||
import { Button } from "@/components/ui/button"
 | 
			
		||||
import { LogOutIcon } from "lucide-react"
 | 
			
		||||
import { useRouter } from "next/navigation"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export default function LogoutButton() {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
 | 
			
		||||
  async function handleLogout() {
 | 
			
		||||
    const res = await fetch('/api/auth/logout', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Content-type': 'application/json',
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    console.log(res)
 | 
			
		||||
    router.refresh()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Button variant="outline" className="w-fit" onClick={handleLogout} >
 | 
			
		||||
      <LogOutIcon />
 | 
			
		||||
    </Button>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -4,6 +4,7 @@ import { usePathname } from "next/navigation";
 | 
			
		|||
import { ComponentProps } from "react";
 | 
			
		||||
import clsx from "clsx";
 | 
			
		||||
import { twMerge } from "tailwind-merge";
 | 
			
		||||
import { ArrowUpNarrowWide, BookOpen, BookOpenText } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function NavLink(props: ComponentProps<"div"> & { href: string }) {
 | 
			
		||||
| 
						 | 
				
			
			@ -14,21 +15,24 @@ function NavLink(props: ComponentProps<"div"> & { href: string }) {
 | 
			
		|||
export default function Navlinks(props: ComponentProps<"div">) {
 | 
			
		||||
  const pathname = usePathname()
 | 
			
		||||
  const links = [
 | 
			
		||||
    { link: "/story", label: "STORIES" },
 | 
			
		||||
    { link: "/publication", label: "PUBLICATIONS" },
 | 
			
		||||
    { link: "/submission", label: "SUBMISSIONS" },
 | 
			
		||||
    { link: "/story", label: "STORIES", icon: <BookOpenText /> },
 | 
			
		||||
    { link: "/publication", label: "PUBLICATIONS", icon: <BookOpen /> },
 | 
			
		||||
    { link: "/submission", label: "SUBMISSIONS", icon: <ArrowUpNarrowWide /> },
 | 
			
		||||
  ]
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={props.className}>
 | 
			
		||||
      <div className="text-secondary-foreground" >
 | 
			
		||||
      <div className="text-secondary-foreground flex flex-row md:flex-col" >
 | 
			
		||||
        {
 | 
			
		||||
          links.map(e => (<NavLink key={e.link} href={e.link}
 | 
			
		||||
            className={twMerge(clsx("text-xl drop-shadow  font-black my-2 w-full p-2 pl-6 antialiased text-secondary-foreground bg-secondary rounded-l-3xl",
 | 
			
		||||
            className={twMerge(clsx("text-xl drop-shadow  font-black my-2 w-full p-2 pl-6 antialiased text-secondary-foreground bg-secondary rounded-3xl md:rounded-l-3xl ",
 | 
			
		||||
              {
 | 
			
		||||
                "text-primary-foreground bg-primary": pathname.includes(e.link)
 | 
			
		||||
              }
 | 
			
		||||
            ))}
 | 
			
		||||
          ><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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,7 @@
 | 
			
		|||
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 { deleteRecord } from "app/lib/del"
 | 
			
		||||
import Link from "next/link"
 | 
			
		||||
import { ComponentProps, useState } from "react"
 | 
			
		||||
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 }) {
 | 
			
		||||
  const pathname = table.options.meta.pathname
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -39,7 +39,7 @@ import {
 | 
			
		|||
  TableRow,
 | 
			
		||||
} from "@/components/ui/table"
 | 
			
		||||
import { EyeIcon, Trash2 } from "lucide-react"
 | 
			
		||||
import { usePathname } from "next/navigation"
 | 
			
		||||
import { usePathname, useSearchParams } from "next/navigation"
 | 
			
		||||
import FormContextMenu from "./contextMenu"
 | 
			
		||||
import { deleteRecord, deleteRecords } from "app/lib/del"
 | 
			
		||||
import { Pathname } from "app/types"
 | 
			
		||||
| 
						 | 
				
			
			@ -50,6 +50,8 @@ import { tableNameToItemName } from "app/lib/nameMaps"
 | 
			
		|||
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"
 | 
			
		||||
 | 
			
		||||
export interface DataTableProps<TData, TValue> {
 | 
			
		||||
  columns: ColumnDef<TData, TValue>[]
 | 
			
		||||
| 
						 | 
				
			
			@ -117,16 +119,22 @@ export function DataTable<TData, TValue>({
 | 
			
		|||
    setIsDeleteDialogVisible(true)
 | 
			
		||||
    SetDialogRow(row)
 | 
			
		||||
  }
 | 
			
		||||
  function closeEditDialog() {
 | 
			
		||||
    setIsEditDialogVisible(false)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
 | 
			
		||||
  const [filterBy, setFilterBy] = useState(table.getAllColumns()[0])
 | 
			
		||||
  const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
 | 
			
		||||
  return (<>
 | 
			
		||||
    <div className="flex justify-between items-center py-4">
 | 
			
		||||
    <div className="flex justify-between items-center py-1 md:py-4">
 | 
			
		||||
      <div className="flex gap-2">
 | 
			
		||||
        <DropdownMenu>
 | 
			
		||||
          <DropdownMenuTrigger asChild>
 | 
			
		||||
            <Button variant="outline" className="ml-auto">
 | 
			
		||||
            <Button variant="outline" className="hidden md:display-blockml-auto">
 | 
			
		||||
              Filter by
 | 
			
		||||
            </Button>
 | 
			
		||||
          </DropdownMenuTrigger>
 | 
			
		||||
| 
						 | 
				
			
			@ -164,6 +172,7 @@ export function DataTable<TData, TValue>({
 | 
			
		|||
            pubs={pubs}
 | 
			
		||||
            responses={responses}
 | 
			
		||||
            defaults={dialogRow?.original}
 | 
			
		||||
            closeDialog={closeEditDialog}
 | 
			
		||||
          />
 | 
			
		||||
        </DialogContent>
 | 
			
		||||
      </Dialog>
 | 
			
		||||
| 
						 | 
				
			
			@ -181,8 +190,11 @@ export function DataTable<TData, TValue>({
 | 
			
		|||
          <DialogFooter>
 | 
			
		||||
            <DialogClose asChild>
 | 
			
		||||
              <Button variant="destructive"
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  deleteRecord(dialogRow.original.id, pathname)
 | 
			
		||||
                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 })
 | 
			
		||||
                  router.refresh()
 | 
			
		||||
                }}>Yes, delete it!
 | 
			
		||||
              </Button>
 | 
			
		||||
            </DialogClose>
 | 
			
		||||
| 
						 | 
				
			
			@ -204,17 +216,18 @@ export function DataTable<TData, TValue>({
 | 
			
		|||
            {`Deleting ${pluralize(tableNameToItemName(table.options.meta.tableName))} cannot be undone!`}
 | 
			
		||||
          </DialogDescription>
 | 
			
		||||
          <DialogFooter>
 | 
			
		||||
            <DialogClose asChild>
 | 
			
		||||
              <Button variant="destructive"
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  const selectedRows = table.getState().rowSelection
 | 
			
		||||
                  const rowIds = Object.keys(selectedRows)
 | 
			
		||||
                  const recordIds = rowIds.map(id => Number(table.getRow(id).original.id))
 | 
			
		||||
                  console.table(recordIds)
 | 
			
		||||
                  deleteRecords(recordIds, pathname)
 | 
			
		||||
                }}>
 | 
			
		||||
                Yes, delete them!</Button>
 | 
			
		||||
            </DialogClose>
 | 
			
		||||
            <Button variant="destructive"
 | 
			
		||||
              onClick={async () => {
 | 
			
		||||
                const selectedRows = table.getState().rowSelection
 | 
			
		||||
                const rowIds = Object.keys(selectedRows)
 | 
			
		||||
                const recordIds = rowIds.map(id => Number(table.getRow(id).original.id))
 | 
			
		||||
                const res = await deleteRecords(recordIds, pathname)
 | 
			
		||||
                if (!res) toast({ title: "Oh dear...", description: "Failed to delete." })
 | 
			
		||||
                if (res) toast({ title: "Sucessfully deleted records of id:", description: JSON.stringify(recordIds) })
 | 
			
		||||
                router.refresh()
 | 
			
		||||
                setIsDeleteDialogVisible(false)
 | 
			
		||||
              }}>
 | 
			
		||||
              Yes, delete them!</Button>
 | 
			
		||||
          </DialogFooter>
 | 
			
		||||
        </DialogContent>
 | 
			
		||||
      </Dialog>
 | 
			
		||||
| 
						 | 
				
			
			@ -252,6 +265,7 @@ export function DataTable<TData, TValue>({
 | 
			
		|||
    </div>
 | 
			
		||||
    <div className="rounded-md border">
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
      <Table>
 | 
			
		||||
        <TableHeader>
 | 
			
		||||
          {table.getHeaderGroups().map((headerGroup) => (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,6 +12,7 @@ import { toast } from "@/components/ui/use-toast"
 | 
			
		|||
import GenreBadges from "app/ui/genreBadges"
 | 
			
		||||
import { updateField, updateGenres } from "app/lib/update"
 | 
			
		||||
import { Genre } from "@prisma/client"
 | 
			
		||||
import { useRouter } from "next/navigation"
 | 
			
		||||
export default function GenrePickerInputCell(props: CellContext<any, any>) {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -23,25 +24,24 @@ export default function GenrePickerInputCell(props: CellContext<any, any>) {
 | 
			
		|||
  const value = props.cell.getValue()
 | 
			
		||||
  const genres = props.table.options.meta.genres
 | 
			
		||||
  const [isActive, setIsActive] = useState(false)
 | 
			
		||||
 | 
			
		||||
  async function onSubmit({ genres }: { genres: number[] }) {
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  async function onSubmit({ genres }: { genres: number[] }, event: Event) {
 | 
			
		||||
    event.preventDefault()
 | 
			
		||||
    const genresArray = genres.map((e) => { return { id: e } })
 | 
			
		||||
    console.log(`genres: ${genres}, genresArray: ${JSON.stringify(genresArray)}`)
 | 
			
		||||
    toast({
 | 
			
		||||
      title: "You submitted the following values:",
 | 
			
		||||
      description: (
 | 
			
		||||
        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
 | 
			
		||||
          <code className="text-white">{JSON.stringify(genres)}</code>
 | 
			
		||||
        </pre>
 | 
			
		||||
      ),
 | 
			
		||||
    })
 | 
			
		||||
    const res = await updateGenres({
 | 
			
		||||
      id,
 | 
			
		||||
      table,
 | 
			
		||||
      genres: genresArray,
 | 
			
		||||
      pathname
 | 
			
		||||
    })
 | 
			
		||||
    try {
 | 
			
		||||
      const genresArray = genres.map((e) => { return { id: e } })
 | 
			
		||||
      const res = await updateGenres({
 | 
			
		||||
        id,
 | 
			
		||||
        table,
 | 
			
		||||
        genres: genresArray,
 | 
			
		||||
        pathname
 | 
			
		||||
      })
 | 
			
		||||
      if (res === undefined) throw new Error("Something went wrong.")
 | 
			
		||||
      toast({ title: "Field updated successfully." })
 | 
			
		||||
      router.refresh()
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error)
 | 
			
		||||
      toast({ title: "Something went wrong." })
 | 
			
		||||
    }
 | 
			
		||||
    setIsActive(false)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -79,7 +79,7 @@ export default function GenrePickerInputCell(props: CellContext<any, any>) {
 | 
			
		|||
            render={({ field }) => (
 | 
			
		||||
              <FormItem className="w-full max-w-xs flex flex-col">
 | 
			
		||||
                <PopoverTrigger asChild>
 | 
			
		||||
                  {value.length > 0 ? <Button variant="ghost" className="h-fit"><GenreBadges genres={value} className="w-full" /></Button> : <Button variant="outline" type="button" className="w-fit m-auto">Add genres</Button>
 | 
			
		||||
                  {value.length > 0 ? <Button variant="ghost" className="h-fit p-1"><GenreBadges genres={value} className="w-full" /></Button> : <Button variant="outline" type="button" className="text-xs md:text-sm w-fit m-auto">Add genres</Button>
 | 
			
		||||
                  }
 | 
			
		||||
                </PopoverTrigger>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -101,7 +101,6 @@ export default function GenrePickerInputCell(props: CellContext<any, any>) {
 | 
			
		|||
                              <Checkbox
 | 
			
		||||
                                checked={field.value?.includes(item.id)}
 | 
			
		||||
                                onCheckedChange={(checked) => {
 | 
			
		||||
                                  console.log(field.value)
 | 
			
		||||
                                  return checked
 | 
			
		||||
                                    ? field.onChange(
 | 
			
		||||
                                      [...field.value, item.id]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,58 +9,53 @@ import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		|||
import { toast } from "@/components/ui/use-toast";
 | 
			
		||||
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
 | 
			
		||||
 | 
			
		||||
export default function NumberInputCell(props: CellContext<any, any>) {
 | 
			
		||||
export default function NumberInputCell({ cellContext, className }: { cellContext: CellContext<any, any>, className: string }) {
 | 
			
		||||
  const [isActive, setIsActive] = useState(false)
 | 
			
		||||
 | 
			
		||||
  const table = props.table.options.meta.tableName
 | 
			
		||||
  const id = props.row.original.id
 | 
			
		||||
  const column = props.column.id
 | 
			
		||||
  const pathname = props.table.options.meta.pathname
 | 
			
		||||
  const value = props.cell.getValue()
 | 
			
		||||
  const formSchema = props.column.columnDef.meta.formSchema.pick({ [column]: true })
 | 
			
		||||
  const table = cellContext.table.options.meta.tableName
 | 
			
		||||
  const id = cellContext.row.original.id
 | 
			
		||||
  const column = cellContext.column.id
 | 
			
		||||
  const pathname = cellContext.table.options.meta.pathname
 | 
			
		||||
  const value = cellContext.cell.getValue()
 | 
			
		||||
  const formSchema = cellContext.column.columnDef.meta.formSchema.pick({ [column]: true })
 | 
			
		||||
 | 
			
		||||
  const form = useForm<z.infer<typeof formSchema>>({
 | 
			
		||||
    resolver: zodResolver(formSchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      [column]: props.cell.getValue()
 | 
			
		||||
      [column]: cellContext.cell.getValue()
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  function onSubmit(value: z.infer<typeof formSchema>) {
 | 
			
		||||
    toast({
 | 
			
		||||
      title: "You submitted the following values:",
 | 
			
		||||
      description: (
 | 
			
		||||
        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
 | 
			
		||||
          <code className="text-white">{JSON.stringify(value, null, 2)}</code>
 | 
			
		||||
        </pre>
 | 
			
		||||
      ),
 | 
			
		||||
    })
 | 
			
		||||
    updateField({
 | 
			
		||||
      id,
 | 
			
		||||
      table,
 | 
			
		||||
      number: value[column],
 | 
			
		||||
      column,
 | 
			
		||||
      pathname
 | 
			
		||||
    })
 | 
			
		||||
  async function onSubmit(value: z.infer<typeof formSchema>) {
 | 
			
		||||
    try {
 | 
			
		||||
      const res = await updateField({
 | 
			
		||||
        id,
 | 
			
		||||
        table,
 | 
			
		||||
        datum: value[column],
 | 
			
		||||
        column,
 | 
			
		||||
        pathname
 | 
			
		||||
      })
 | 
			
		||||
      if (res === undefined) throw new Error("something went wrong")
 | 
			
		||||
      toast({ title: "Field updated successfully." })
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error)
 | 
			
		||||
      toast({ title: "Something went wrong." })
 | 
			
		||||
    }
 | 
			
		||||
    setIsActive(false)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function onErrors(errors) {
 | 
			
		||||
  function onErrors(errors: Error) {
 | 
			
		||||
    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>
 | 
			
		||||
      ),
 | 
			
		||||
      description: errors.message,
 | 
			
		||||
    })
 | 
			
		||||
    console.log(JSON.stringify(errors))
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      onDoubleClick={() => setIsActive(prev => !prev)}
 | 
			
		||||
      className="w-full h-fit flex items-center justify-center"
 | 
			
		||||
      onDoubleClick={() => setIsActive(true)}
 | 
			
		||||
      className={className + " w-full h-fit flex items-center justify-center"}
 | 
			
		||||
      tabIndex={0}
 | 
			
		||||
      onKeyDown={e => {
 | 
			
		||||
        if (e.code === "Enter" && !isActive) {
 | 
			
		||||
| 
						 | 
				
			
			@ -83,10 +78,10 @@ export default function NumberInputCell(props: CellContext<any, any>) {
 | 
			
		|||
                  <FormControl
 | 
			
		||||
                  >
 | 
			
		||||
                    <Input
 | 
			
		||||
                      className="w-24"
 | 
			
		||||
                      className="md:w-24"
 | 
			
		||||
                      type="number"
 | 
			
		||||
                      autoFocus={true}
 | 
			
		||||
                      step={props.column.columnDef.meta?.step}
 | 
			
		||||
                      step={cellContext.column.columnDef.meta?.step}
 | 
			
		||||
                      {...field}
 | 
			
		||||
                    />
 | 
			
		||||
                  </FormControl>
 | 
			
		||||
| 
						 | 
				
			
			@ -97,7 +92,7 @@ export default function NumberInputCell(props: CellContext<any, any>) {
 | 
			
		|||
 | 
			
		||||
          </form>
 | 
			
		||||
        </Form>
 | 
			
		||||
        : <p>{props.cell.getValue()}</p>
 | 
			
		||||
        : <p>{cellContext.cell.getValue()}</p>
 | 
			
		||||
      }
 | 
			
		||||
    </div >
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,40 +9,44 @@ import { zodResolver } from "@hookform/resolvers/zod";
 | 
			
		|||
import { toast } from "@/components/ui/use-toast";
 | 
			
		||||
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
 | 
			
		||||
import TitleContainer from "app/ui/titleContainer";
 | 
			
		||||
import { useRouter } from "next/navigation";
 | 
			
		||||
 | 
			
		||||
export function TextInputCell(props: CellContext<any, any>) {
 | 
			
		||||
export function TextInputCell({ cellContext, className }: { className: string, cellContext: CellContext<any, any> }) {
 | 
			
		||||
  const [isActive, setIsActive] = useState(false)
 | 
			
		||||
 | 
			
		||||
  const table = props.table.options.meta.tableName
 | 
			
		||||
  const id = props.row.original.id
 | 
			
		||||
  const column = props.column.id
 | 
			
		||||
  const pathname = props.table.options.meta.pathname
 | 
			
		||||
  const value = props.cell.getValue()
 | 
			
		||||
  const formSchema = props.column.columnDef.meta.formSchema.pick({ [column]: true })
 | 
			
		||||
  if (cellContext === undefined) {
 | 
			
		||||
    console.error("CELL CONTEXT UNDEFINED!")
 | 
			
		||||
    return false
 | 
			
		||||
  }
 | 
			
		||||
  const table = cellContext.table.options.meta.tableName
 | 
			
		||||
  const id = cellContext.row.original.id
 | 
			
		||||
  const column = cellContext.column.id
 | 
			
		||||
  const pathname = cellContext.table.options.meta.pathname
 | 
			
		||||
  const value = cellContext.cell.getValue()
 | 
			
		||||
  const formSchema = cellContext.column.columnDef.meta.formSchema.pick({ [column]: true })
 | 
			
		||||
 | 
			
		||||
  const form = useForm<z.infer<typeof formSchema>>({
 | 
			
		||||
    resolver: zodResolver(formSchema),
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      [column]: props.cell.getValue()
 | 
			
		||||
      [column]: cellContext.cell.getValue()
 | 
			
		||||
    },
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const router = useRouter()
 | 
			
		||||
  async function onSubmit(value: z.infer<typeof formSchema>) {
 | 
			
		||||
    toast({
 | 
			
		||||
      title: "You submitted the following values:",
 | 
			
		||||
      description: (
 | 
			
		||||
        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
 | 
			
		||||
          <code className="text-white">{JSON.stringify(value, null, 2)}</code>
 | 
			
		||||
        </pre>
 | 
			
		||||
      ),
 | 
			
		||||
    })
 | 
			
		||||
    const res = await updateField({
 | 
			
		||||
      id,
 | 
			
		||||
      table,
 | 
			
		||||
      datum: value[column],
 | 
			
		||||
      column,
 | 
			
		||||
      pathname
 | 
			
		||||
    })
 | 
			
		||||
    try {
 | 
			
		||||
      const res = await updateField({
 | 
			
		||||
        id,
 | 
			
		||||
        table,
 | 
			
		||||
        datum: value[column],
 | 
			
		||||
        column,
 | 
			
		||||
        pathname
 | 
			
		||||
      })
 | 
			
		||||
      if (res === undefined) throw new Error("something went wrong")
 | 
			
		||||
      toast({ title: "Field updated successfully." })
 | 
			
		||||
      router.refresh()
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(error)
 | 
			
		||||
      toast({ title: "Something went wrong." })
 | 
			
		||||
    }
 | 
			
		||||
    setIsActive(false)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -60,7 +64,7 @@ export function TextInputCell(props: CellContext<any, any>) {
 | 
			
		|||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      onDoubleClick={() => setIsActive(prev => !prev)}
 | 
			
		||||
      className="w-full h-fit flex items-center justify-left"
 | 
			
		||||
      className={className + " w-full h-fit flex items-center justify-left"}
 | 
			
		||||
      tabIndex={0}
 | 
			
		||||
      onKeyDown={e => {
 | 
			
		||||
        if (e.code === "Enter" && !isActive) {
 | 
			
		||||
| 
						 | 
				
			
			@ -96,7 +100,7 @@ export function TextInputCell(props: CellContext<any, any>) {
 | 
			
		|||
 | 
			
		||||
          </form>
 | 
			
		||||
        </Form>
 | 
			
		||||
        : <TitleContainer>{props.cell.getValue()}</TitleContainer>
 | 
			
		||||
        : <TitleContainer>{cellContext.cell.getValue()}</TitleContainer>
 | 
			
		||||
      }
 | 
			
		||||
    </div >
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,8 +5,9 @@ export const selectCol = {
 | 
			
		|||
  id: "select",
 | 
			
		||||
  header: (props: HeaderContext<any, any>) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="flex items-center justify-center">
 | 
			
		||||
      <div className="flex items-start justify-left mx-auto">
 | 
			
		||||
        <Checkbox
 | 
			
		||||
          className="mr-4 ml-2"
 | 
			
		||||
          checked={props.table.getIsAllRowsSelected()}
 | 
			
		||||
          onCheckedChange={props.table.toggleAllRowsSelected}
 | 
			
		||||
          aria-label="select/deselect all rows"
 | 
			
		||||
| 
						 | 
				
			
			@ -18,8 +19,9 @@ export const selectCol = {
 | 
			
		|||
 | 
			
		||||
  cell: (props: CellContext<any, any>) => {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="flex items-center justify-center">
 | 
			
		||||
      <div className="flex items-start justify-left">
 | 
			
		||||
        <Checkbox
 | 
			
		||||
          className="mr-4 ml-2"
 | 
			
		||||
          checked={props.row.getIsSelected()}
 | 
			
		||||
          onCheckedChange={props.row.toggleSelected}
 | 
			
		||||
          aria-label="select/deselect row"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,9 @@
 | 
			
		|||
import { ComponentProps } from "react";
 | 
			
		||||
 | 
			
		||||
export default function itleContainer({ children }: ComponentProps<"div">) {
 | 
			
		||||
  let classes = "w-full text-left m-auto"
 | 
			
		||||
  console.table(children)
 | 
			
		||||
export default function TitleContainer({ children }: ComponentProps<"div">) {
 | 
			
		||||
  let classes = "w-full text-left m-auto h-fit flex align-center text-xs md:text-sm"
 | 
			
		||||
  if (children == "RECORD DELETED") {
 | 
			
		||||
    console.log("BINGO")
 | 
			
		||||
    classes = classes + " text-destructive font-bold"
 | 
			
		||||
  }
 | 
			
		||||
  return <span className="h-10 flex align-center"><p className={classes}>{children}</p></span>
 | 
			
		||||
  return <p className={classes}>{children}</p>
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,78 @@
 | 
			
		|||
"use server"
 | 
			
		||||
import { NextRequest, NextResponse } from "next/server";
 | 
			
		||||
import { verifyJwt } from "app/api/auth/actions";
 | 
			
		||||
 | 
			
		||||
const protectedRoutes = ['/story', '/submission', '/publication']
 | 
			
		||||
 | 
			
		||||
// Function to match the * wildcard character
 | 
			
		||||
function matchesWildcard(path: string, pattern: string): boolean {
 | 
			
		||||
	if (pattern.endsWith('/*')) {
 | 
			
		||||
		const basePattern = pattern.slice(0, -2);
 | 
			
		||||
		return path.startsWith(basePattern);
 | 
			
		||||
	}
 | 
			
		||||
	return path === pattern;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default async function(request: NextRequest): Promise<NextResponse> | undefined {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	const url = request.nextUrl.clone()
 | 
			
		||||
	url.pathname = "/login"
 | 
			
		||||
	url.searchParams.set('from', request.nextUrl.pathname)
 | 
			
		||||
	if (protectedRoutes.some(pattern => matchesWildcard(request.nextUrl.pathname, pattern))) {
 | 
			
		||||
		const token = request.cookies.get('token')
 | 
			
		||||
		//NOTE - may need to add logic to return 401 for api routes
 | 
			
		||||
 | 
			
		||||
		if (!token) {
 | 
			
		||||
			console.log("there is no jwt")
 | 
			
		||||
			return NextResponse.redirect(url)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		try {
 | 
			
		||||
			//decode and verify jwt cookie
 | 
			
		||||
			const jwtIsVerified = await verifyJwt(token.value)
 | 
			
		||||
			if (!jwtIsVerified) {
 | 
			
		||||
				//delete token
 | 
			
		||||
				console.log('could not verify jwt')
 | 
			
		||||
				request.cookies.delete('token')
 | 
			
		||||
				return NextResponse.redirect(url)
 | 
			
		||||
			}
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			//delete token (failsafe)
 | 
			
		||||
			console.error("failed to very jwt", error.message)
 | 
			
		||||
			request.cookies.delete('token')
 | 
			
		||||
			return NextResponse.redirect(url)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//TODO - TEST THIS BECAUSE IT PROBABLY DOESN'T WORK
 | 
			
		||||
		//redirect from login if already logged in
 | 
			
		||||
		let redirectToApp = false
 | 
			
		||||
		if (request.nextUrl.pathname === "/login") {
 | 
			
		||||
			const token = request.cookies.get("token")
 | 
			
		||||
			if (token) {
 | 
			
		||||
				try {
 | 
			
		||||
					const payload = await verifyJwt(token.value)
 | 
			
		||||
					if (payload) {
 | 
			
		||||
						redirectToApp = true
 | 
			
		||||
					} else {
 | 
			
		||||
						request.cookies.delete('token')
 | 
			
		||||
					}
 | 
			
		||||
				} catch (error) {
 | 
			
		||||
					request.cookies.delete('token')
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (redirectToApp) {
 | 
			
		||||
			return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL}/submission`)
 | 
			
		||||
		} else {
 | 
			
		||||
			return NextResponse.next()
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue