Merge remote-tracking branch 'refs/remotes/origin/main'
Gitea/subman-nextjs/pipeline/head There was a failure building this commit
Details
Gitea/subman-nextjs/pipeline/head There was a failure building this commit
Details
This commit is contained in:
commit
3b6652c617
|
@ -34,3 +34,6 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
#secret
|
||||
.env
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
pipeline {
|
||||
agent any
|
||||
environment{
|
||||
JWT_SECRET=credentials('JWT_SECRET')
|
||||
DATABASE_URL=credentials('DATABASE_URL')
|
||||
}
|
||||
stages{
|
||||
stage('build'){
|
||||
steps{
|
||||
sh 'npm install'
|
||||
sh 'npm run build'
|
||||
sh ' tar -C .next -cvf subman.tar.gz standalone '
|
||||
}
|
||||
}
|
||||
stage('deploy'){
|
||||
steps{
|
||||
sshPublisher(publishers: [sshPublisherDesc(configName: 'Demos', transfers: [sshTransfer(cleanRemote: false, excludes: '', execCommand: 'ssh-uploads/subman/upgrade.sh', execTimeout: 120000, flatten: false, makeEmptyDirs: false, noDefaultExcludes: false, patternSeparator: '[, ]+', remoteDirectory: 'ssh-uploads/subman/', remoteDirectorySDF: false, removePrefix: '', sourceFiles: 'subman.tar.gz')], usePromotionTimestamp: false, useWorkspaceInPromotion: false, verbose: false)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
38
README.md
38
README.md
|
@ -1,36 +1,18 @@
|
|||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
# Subman
|
||||
## A self-hosted literary submission manager
|
||||
|
||||
## Getting Started
|
||||
I developed this project as a demonstration of my full-stack development abilities, utilizing:
|
||||
|
||||
First, run the development server:
|
||||
- Nextjs
|
||||
- Tailwind
|
||||
- heavily customised Shadcn components
|
||||
- an Sqlite database with Prisma ORM as intermediary
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
My previous attempt at this project was [a Nodejs server](https://projects.ajstepien.xyz/andrzej/sub-manager-backend) with [ a React frontend ](https://projects.ajstepien.xyz/andrzej/sub-manager-frontend), but this version is much better!
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## What it does
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
Subman was inspired by my experiences submitting short fiction to magazines for publication. It allows the user to track where submissions are pending, in addition to meta-data such as genres, word count and so on. What you see here is the Minimum Shippable Product.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
webpack: (config) => {
|
||||
config.externals = [...config.externals, "bcrypt"];
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// ... you will write your Prisma Client queries here
|
||||
|
@ -8,20 +8,17 @@ async function main() {
|
|||
where: { id: 1 },
|
||||
data: {
|
||||
title: "Ghost Aliens of Mars",
|
||||
genres: { set: [{ id: 1 }, { id: 2 }], create: { name: "alien-punk" } }
|
||||
}
|
||||
|
||||
})
|
||||
console.log(story)
|
||||
|
||||
genres: { set: [{ id: 1 }, { id: 2 }], create: { name: "alien-punk" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect()
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
@ -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 = 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,65 @@
|
|||
import { NextResponse, NextRequest } from "next/server";
|
||||
import prisma from "app/lib/db";
|
||||
import { SignJWT } from "jose";
|
||||
|
||||
import { getJWTSecretKey, login, setUserDataCookie } from "../actions";
|
||||
|
||||
export interface UserLoginRequest {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
//render route afresh every time
|
||||
const dynamic = 'force-dynamic'
|
||||
|
||||
//POST endpoint
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json()
|
||||
const { email, password } = body
|
||||
|
||||
if (!email || !password) {
|
||||
const res = {
|
||||
succes: false,
|
||||
message: 'Email or password missing'
|
||||
}
|
||||
return NextResponse.json(res, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
//fetch user from db, throw if email or password are invalid
|
||||
const user = await login({ email, password })
|
||||
|
||||
//create and sign JWT
|
||||
const token = await new SignJWT({
|
||||
...user
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('1h')
|
||||
.sign(await getJWTSecretKey())
|
||||
|
||||
//make response
|
||||
const res = { success: true }
|
||||
const response = NextResponse.json(res)
|
||||
|
||||
//Store jwt as secure http-only cookie
|
||||
response.cookies.set({
|
||||
name: 'token',
|
||||
value: token,
|
||||
path: '/', //defines where the cookie can be accessed - in this case, site wide
|
||||
maxAge: 3600, //1 hour
|
||||
httpOnly: true,
|
||||
sameSite: 'strict'
|
||||
})
|
||||
//Store public user data as cookie
|
||||
setUserDataCookie(user)
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
const res = { success: false, message: error.message || 'something went wrong' }
|
||||
return NextResponse.json(res, { status: 500 })
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { Genre } from "@prisma/client";
|
||||
import { FilterFn, Row } from "@tanstack/react-table";
|
||||
|
||||
export const genrePickerFilterFn = (row: Row<any>, columnId: string, filterValue: any) => {
|
||||
|
||||
const genres: Genre[] = row.getValue(columnId)
|
||||
|
||||
for (let index = 0; index < genres.length; index++) {
|
||||
if (genres[genres.length - 1].name.includes(filterValue)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { z } from "zod";
|
||||
import { storySchema } from "app/ui/forms/schemas";
|
||||
import { Pub, Story } from "@prisma/client";
|
||||
import { pubSchema } from "app/ui/forms/schemas";
|
||||
import { StoryWithGenres } from "app/story/page";
|
||||
|
||||
//schemas
|
||||
|
||||
const storySchemaTrimmed = storySchema.omit({ genres: true })
|
||||
const pubSchemaTrimmed = pubSchema.omit({ genres: true })
|
||||
const genreSchema = z.object({ id: z.number() })
|
||||
const genresSchema = z.array(genreSchema)
|
||||
|
||||
export async function prepStoryData(data: Story): Promise<{ title: string, word_count: number }> {
|
||||
const storyData = structuredClone(data)
|
||||
//throw an error if validation fails
|
||||
storySchemaTrimmed.safeParse(storyData)
|
||||
return storyData
|
||||
}
|
||||
|
||||
export async function prepPubData(data: Pub): Promise<Pub> {
|
||||
const pubData = structuredClone(data)
|
||||
pubSchemaTrimmed.safeParse(pubData)
|
||||
return pubData
|
||||
}
|
||||
|
||||
export async function prepGenreData(data: number[]): Promise<{ id: number }[]> {
|
||||
"use server"
|
||||
|
||||
//prepare data
|
||||
const genresArray = data.map((e) => { return { id: e } })
|
||||
|
||||
//prepare schemas
|
||||
|
||||
//throws error if validation fails
|
||||
genresSchema.safeParse(genresArray)
|
||||
return genresArray
|
||||
|
||||
}
|
|
@ -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"
|
||||
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)
|
||||
//BUG:the first time user logs in, page refreshes instead of redirecting
|
||||
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>
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
import { Dialog, DialogHeader, DialogTrigger, DialogContent, DialogClose, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComponentProps } from "react";
|
||||
import { Genre, Pub } 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";
|
||||
import { PubWithGenres } from "./page";
|
||||
|
||||
export default function EditPubDialog({ genres, closeDialog, defaults, dbAction }: ComponentProps<"div"> & { genres: Genre[], closeDialog: () => void, defaults: PubWithGenres, dbAction: (data: Pub & { genres: number[] }) => Promise<{ success: string }> }) {
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit publication</DialogTitle>
|
||||
<DialogDescription>Modify an entry for an existing publication.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PubForm dbAction={dbAction} genres={genres} closeDialog={closeDialog} defaults={defaults} />
|
||||
<DialogFooter>
|
||||
<Button form="pubform">Submit</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
"use client"
|
||||
import { DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComponentProps, useState } from "react";
|
||||
import { Genre, Story } from "@prisma/client";
|
||||
import StoryForm from "app/ui/forms/story";
|
||||
import { StoryWithGenres } from "./page";
|
||||
|
||||
|
||||
export default function EditStoryDialog({ genres, closeDialog, defaults, dbAction }: ComponentProps<"div"> & { genres: Genre[], closeDialog: () => void, defaults: StoryWithGenres, dbAction: (data: Story & { genres: number[] }) => Promise<{ success: string }> }) {
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit story</DialogTitle>
|
||||
<DialogDescription>Create an entry for a new story i.e. a thing you intend to submit for publication.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<StoryForm dbAction={dbAction} genres={genres} className="" closeDialog={closeDialog} defaults={defaults} />
|
||||
<DialogFooter>
|
||||
<Button form="storyform">Submit</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
"use client"
|
||||
|
||||
import { z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { CalendarIcon } from "@radix-ui/react-icons"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { format } from "date-fns"
|
||||
import {
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormField,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage
|
||||
} from "@/components/ui/form"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useState } from "react"
|
||||
import { createSub } from "app/lib/create"
|
||||
import { subSchema } from "./schemas"
|
||||
import { updateSub } from "app/lib/update"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export type SubForm = z.infer<typeof subSchema>
|
||||
|
||||
|
||||
export default function EditSubmissionForm({ stories, pubs, responses, defaults, closeDialog }) {
|
||||
const form = useForm<z.infer<typeof subSchema>>({
|
||||
resolver: zodResolver(subSchema),
|
||||
defaultValues: {
|
||||
responseId: responses[0].id,
|
||||
...defaults
|
||||
}
|
||||
})
|
||||
const [isSubCalendarOpen, setIsSubCalendarOpen] = useState(false);
|
||||
const [isRespCalendarOpen, setIsRespCalendarOpen] = useState(false);
|
||||
const storiesSelectItems = stories.map(e => (
|
||||
<SelectItem value={e.id.toString()} key={e.title}>
|
||||
{e.title}
|
||||
</SelectItem>
|
||||
))
|
||||
const pubsSelectItems = pubs.map(e => (
|
||||
<SelectItem value={e.id} key={e.title}>
|
||||
{e.title}
|
||||
</SelectItem>
|
||||
))
|
||||
|
||||
const reponsesSelectItems = responses.map(e => (
|
||||
<SelectItem value={e.id} key={e.title}>
|
||||
{e.response}
|
||||
</SelectItem>
|
||||
))
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
async function onSubmit(values: z.infer<typeof subSchema>) {
|
||||
try {
|
||||
const res = await updateSub(values)
|
||||
if (res === undefined) throw new Error("something went wrong")
|
||||
toast({ title: "Successfully created new submission!" })
|
||||
router.refresh()
|
||||
closeDialog()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "UH-OH",
|
||||
description: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onErrors(errors) {
|
||||
toast({
|
||||
title: "You have errors",
|
||||
description: (
|
||||
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
|
||||
<code className="text-white">{JSON.stringify(errors, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
})
|
||||
console.log(JSON.stringify(errors))
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id="subform" onSubmit={form.handleSubmit(onSubmit, onErrors)} className="space-y-2 md:space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="storyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm md:text-base">Story</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select something">
|
||||
<p>{stories?.find(e => e.id === Number(field.value))?.title ?? null}</p>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{storiesSelectItems}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription className="text-xs md:text-base">The piece you submitted</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pubId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm md:text-base">Publication</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select something">
|
||||
<p>{pubs?.find(e => e.id === Number(field.value))?.title ?? null}</p>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{pubsSelectItems}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="submitted"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="text-sm md:text-base">Date of submission</FormLabel>
|
||||
<Popover modal={true} open={isSubCalendarOpen} onOpenChange={setIsSubCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-[240px] pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
{/* @ts-ignore */}
|
||||
<Calendar mode="single" selected={field.value}
|
||||
onSelect={(e) => { field.onChange(e); setIsSubCalendarOpen(false); }}
|
||||
disabled={(date) =>
|
||||
date > new Date() || date < new Date("1900-01-01")
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription className="text-xs md:text-base">
|
||||
The date you sent it
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="responded"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="text-sm md:text-base">Date of response</FormLabel>
|
||||
<Popover modal={true} open={isRespCalendarOpen} onOpenChange={setIsRespCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-[240px] pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, "PPP")
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
{/* @ts-ignore */}
|
||||
<Calendar selected={field.value} onSelect={(e) => { field.onChange(e); setIsRespCalendarOpen(false); }}
|
||||
disabled={(date) =>
|
||||
date > new Date() || date < new Date("1900-01-01")
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription className="text-xs md:text-base">
|
||||
The date they wrote back
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="responseId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-sm md:text-base">Response</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue>
|
||||
<p>{responses?.find(e => e.id === Number(field.value))?.response ?? null}</p>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{reponsesSelectItems}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription className="text-xs md:text-base">The market you sent it to</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const storySchema = z.object({
|
||||
title: z.string().min(2).max(50),
|
||||
word_count: z.coerce.number(),
|
||||
genres: z.object({ id: z.number(), name: z.string() }).array()
|
||||
})
|
||||
|
||||
export const pubSchema = z.object({
|
||||
id: z.coerce.number().optional(),
|
||||
title: z.string().min(2).max(50),
|
||||
link: z.string(),
|
||||
query_after_days: z.coerce.number().min(30),
|
||||
genres: z.array(z.number()),
|
||||
})
|
||||
|
||||
export const subSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
storyId: z.coerce.number(),
|
||||
pubId: z.coerce.number(),
|
||||
submitted: z.coerce.date().transform((date) => date.toString()),
|
||||
responded: z.coerce.date().transform((date) => {
|
||||
if (date.toString() !== new Date(null).toString()) {
|
||||
return date.toString()
|
||||
}
|
||||
return null
|
||||
}).optional(),
|
||||
responseId: z.coerce.number()
|
||||
})
|
||||
.refine(object => {
|
||||
const submitted = new Date(object.submitted)
|
||||
const responded = object.responded ? new Date(object.responded) : null
|
||||
return responded >= submitted || responded === null
|
||||
},
|
||||
{
|
||||
path: ["responded"],
|
||||
message: "'Responded' must be a later date than 'submitted'"
|
||||
})
|
||||
.refine(object => {
|
||||
if (object.responded) {
|
||||
//there is a 'responded' date and the response is not 'pending'
|
||||
return object.responseId !== 7
|
||||
}
|
||||
if (!object.responded) {
|
||||
//there is not a 'responded' date and the response is pending
|
||||
return object.responseId === 7
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ["responseId"],
|
||||
message: "A pending response cannot have a date, and a non-pending response must have a date"
|
||||
}
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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