add blog
This commit is contained in:
Generated
+1369
-13
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,8 @@
|
||||
"next-auth": "^4.23.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"superjson": "^1.13.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
+18
-2
@@ -7,6 +7,21 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model BlogPost {
|
||||
id String @id @default(uuid()) @map("_id")
|
||||
name String
|
||||
image String
|
||||
contents String
|
||||
tagline String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
createdBy User @relation(fields: [createdById], references: [id])
|
||||
createdById String
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id @default(uuid()) @map("_id")
|
||||
type String
|
||||
@@ -58,14 +73,15 @@ model Session {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid()) @map("_id")
|
||||
id String @id @default(cuid()) @map("_id")
|
||||
name String?
|
||||
email String? @unique
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
posts Post[]
|
||||
BlogPost BlogPost[]
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link';
|
||||
|
||||
type BlogCardProps = {
|
||||
image: string,
|
||||
name: string,
|
||||
tagline: string,
|
||||
id: string
|
||||
}
|
||||
export default function BlogCard(props: BlogCardProps) {
|
||||
return (
|
||||
<Link href={`/blog/${props.id}`}>
|
||||
<div className="inline-block">
|
||||
<div className="bg-white text-black text-center">
|
||||
<div>
|
||||
<Image src={props.image} width="300" height="300" alt="Blog Cover" />
|
||||
</div>
|
||||
<div className="p-4 divide-y divide-black">
|
||||
<h1 className="font-bold">{props.name}</h1>
|
||||
<p>{props.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export default function MusicCard() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col ml-2 pt-1.5">
|
||||
<h1>{musicFetch.data.track.name}</h1>
|
||||
<h1 className="text-sm truncate">{musicFetch.data.track.name}</h1>
|
||||
<p>{musicFetch.data.track.artist["#text"]}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="text-white text-center">
|
||||
<h1 className="font-bold text-2xl">Well, this is awkward..</h1>
|
||||
<p>There's nothing here right now :/</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export default function Header() {
|
||||
<HeaderItem content="home" linkTo="/" />
|
||||
<HeaderItem content="reviews" linkTo="/reviews" />
|
||||
<HeaderItem content="links" linkTo="/links" />
|
||||
<HeaderItem content="blog" linkTo="/blog" />
|
||||
{ status === "authenticated" &&
|
||||
<HeaderItem
|
||||
content="create"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import RootLayout from '@/components/RootLayout';
|
||||
|
||||
// next modules
|
||||
import Router, {useRouter} from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {useSession} from 'next-auth/react';
|
||||
|
||||
// api
|
||||
import {api} from '@/utils/api'
|
||||
|
||||
// icons
|
||||
import { Trash } from 'grommet-icons';
|
||||
|
||||
export default function BlogPage() {
|
||||
const router = useRouter()
|
||||
const id = router.query.id as string
|
||||
const { status } = useSession()
|
||||
|
||||
// trpc stuff
|
||||
const {data: blogData} = api.blog.getOne.useQuery(id)
|
||||
const deletionMutation = api.blog.delete.useMutation()
|
||||
|
||||
function deleteItem() {
|
||||
if (blogData) {
|
||||
deletionMutation.mutate(id)
|
||||
Router.replace("/").catch(() => {
|
||||
console.error("Failed to reroute user!")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (blogData) {
|
||||
return (
|
||||
<RootLayout>
|
||||
<div className="mx-auto w-1/2 text-white">
|
||||
<h1 className="text-2xl font-bold text-center">{blogData.name} { status == "authenticated" ? <Trash onClick={deleteItem} className="hover:cursor-pointer" /> : ""}</h1>
|
||||
|
||||
<div className="pt-2 pb-2">
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<Markdown remarkPlugins={[remarkGfm]} className="text-white w-1/2 mx-auto">
|
||||
{blogData.contents}
|
||||
</Markdown>
|
||||
</div>
|
||||
</RootLayout>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<RootLayout>
|
||||
<div className="text-center text-2xl font-bold text-white">
|
||||
<p>Well, that's not good..</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-white">
|
||||
<p>You've reached a page that doesn't exist. Lets fix that.</p>
|
||||
<Link href="/" className="hover:underline hover:cursor-pointer hover:text-gray-500">
|
||||
Head Home
|
||||
</Link>
|
||||
</div>
|
||||
</RootLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// trpc api
|
||||
import { api } from "@/utils/api";
|
||||
import {useState, useRef} from 'react';
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
// components
|
||||
import RootLayout from '@/components/RootLayout';
|
||||
|
||||
// types
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
export default function BlogCreatePage() {
|
||||
const [textContent, setTextContent] = useState<string | null>(null)
|
||||
const [showMarkdown, setShowMarkdown] = useState<boolean>(false)
|
||||
|
||||
// element refs
|
||||
const titleRef = useRef<HTMLInputElement>(null)
|
||||
const tagRef = useRef<HTMLInputElement>(null)
|
||||
const imageRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// trpc mutation
|
||||
const blogPostMutation = api.blog.create.useMutation()
|
||||
|
||||
function handleElementUpdate(e: FormEvent) {
|
||||
const textarea = e.target as HTMLTextAreaElement
|
||||
setTextContent(textarea.value)
|
||||
}
|
||||
|
||||
function submitBlogPost() {
|
||||
if (!textContent || textContent == "") {
|
||||
console.error("No text provided")
|
||||
return;
|
||||
}
|
||||
|
||||
if (!titleRef || !tagRef || !titleRef.current?.value || !tagRef.current?.value || titleRef.current?.value == "" || tagRef.current?.value == "") {
|
||||
console.error("No title/tagline provided")
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageRef?.current?.value || imageRef.current?.value == "") {
|
||||
console.error("No image URL provided")
|
||||
return
|
||||
}
|
||||
|
||||
const data = {
|
||||
name: titleRef.current?.value,
|
||||
tagline: tagRef.current?.value,
|
||||
image: imageRef.current?.value,
|
||||
contents: textContent,
|
||||
}
|
||||
|
||||
blogPostMutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<RootLayout protectedPage={true}>
|
||||
<div className="text-center pb-2">
|
||||
<button className="text-white mx-auto" onClick={() => setShowMarkdown(!showMarkdown)}>Show {showMarkdown ? "Text" : "Markdown"}</button>
|
||||
</div>
|
||||
|
||||
{
|
||||
showMarkdown ? <Markdown remarkPlugins={[remarkGfm]} className="text-white w-1/2 mx-auto">
|
||||
{textContent}
|
||||
</Markdown>
|
||||
: <textarea onInput={handleElementUpdate} className="resize-none block h-96 w-1/2 mx-auto bg-gray-900 rounded-sm text-white" defaultValue={textContent ?? ""} />
|
||||
}
|
||||
|
||||
<div className="text-center pt-4 space-y-2">
|
||||
<input type="text" ref={titleRef} placeholder="title" name="title" /><br />
|
||||
<input type="text" ref={imageRef} placeholder="image url" name="image" /><br />
|
||||
<input type="text" ref={tagRef} placeholder="tagline" name="tag" /><br />
|
||||
|
||||
<button onClick={submitBlogPost} className="text-white">Submit</button>
|
||||
</div>
|
||||
</RootLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import RootLayout from "@/components/RootLayout"
|
||||
import BlogCard from "@/components/BlogCard"
|
||||
import NotFound from "@/components/NotFound";
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type GenericItemType = {
|
||||
name: string,
|
||||
image: string,
|
||||
tagline: string,
|
||||
id: string
|
||||
}
|
||||
|
||||
export default function Blog() {
|
||||
const {data: itemData, isLoading: areItemsLoading} = api.blog.getAll.useQuery()
|
||||
const [list, setList] = useState<null | JSX.Element[]>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (itemData) {
|
||||
setList(
|
||||
itemData.map((v: GenericItemType, k) => {
|
||||
return <BlogCard
|
||||
name={v.name}
|
||||
tagline={v.tagline}
|
||||
id={v.id}
|
||||
image={v.image}
|
||||
key={k}
|
||||
/>
|
||||
})
|
||||
)
|
||||
}
|
||||
}, [itemData])
|
||||
|
||||
if (list) {
|
||||
return (
|
||||
<RootLayout>
|
||||
<div className="text-white w-1/2 mx-auto">
|
||||
<div className="space-x-2 space-y-2">
|
||||
{
|
||||
list.length > 0 ? list : <NotFound />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</RootLayout>
|
||||
)
|
||||
} else if (areItemsLoading) {
|
||||
<RootLayout>
|
||||
<div className="text-white">
|
||||
<p>Loading..</p>
|
||||
</div>
|
||||
</RootLayout>
|
||||
} else {
|
||||
return (
|
||||
<RootLayout>
|
||||
<NotFound />
|
||||
</RootLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import RootLayout from "@/components/RootLayout"
|
||||
import Image from "next/image"
|
||||
import ProjectCard from "@/components/ProjectsCard"
|
||||
|
||||
export default function Resume() {
|
||||
return (
|
||||
<RootLayout>
|
||||
<div className="text-center mx-auto text-white">
|
||||
<div className="flex flex-wrap justify-center pb-2">
|
||||
<Image src="https://storage.thatalex.dev/content/pfp.png" width="130" height="130" alt="PFP" className="rounded-3xl" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Alex Frantz</h1>
|
||||
<p>alex@alexavfrantz.com</p>
|
||||
<p>Manual QA Tester + Test Automation Engineer</p>
|
||||
|
||||
<div className="flex flex-wrap w-8/12 mx-auto">
|
||||
<div className="flex-1 p-4">
|
||||
<h1 className="pb-2">Experience</h1>
|
||||
<hr className="w-1/12 mx-auto" />
|
||||
<div className="flex flex-col justify-center mx-auto pt-2">
|
||||
<ProjectCard
|
||||
key="1"
|
||||
name="Medal.tv -- QA Engineer"
|
||||
subHeading="Mar. 2022 - Present"
|
||||
description="Testing features before anyone else to weed out breaking bugs. Also heading our test automation efforts to make testing as smooth as possible."
|
||||
urlTo="https://medal.tv"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 className="pb-2 pt-2">Education</h1>
|
||||
<hr className="w-1/12 mx-auto" />
|
||||
<div className="flex flex-col justify-center mx-auto pt-2">
|
||||
<ProjectCard
|
||||
key="1"
|
||||
name="Acellus Academy"
|
||||
description="High school accrediation, studying Computer Science"
|
||||
urlTo="https://medal.tv"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 p-4">
|
||||
<p className="pb-2">Skills</p>
|
||||
<hr className="w-1/12 mx-auto" />
|
||||
<ul className="pt-2 pb-2 list-disc list-inside">
|
||||
<li>
|
||||
Test Automation
|
||||
</li>
|
||||
<li>
|
||||
End-to-end Testing
|
||||
</li>
|
||||
<li>
|
||||
Customer Support
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr className="w-1/12 mx-auto" />
|
||||
|
||||
<p className="pt-2">Contact</p>
|
||||
<p>Email: alex@alexavfrantz.com</p>
|
||||
<p>LinkedIn: in/thatalex</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RootLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { postRouter } from "@/server/api/routers/post";
|
||||
import { linksRouter } from "./routers/links";
|
||||
import { miscRouter } from "./routers/misc";
|
||||
import { blogRouter } from "./routers/blog";
|
||||
import { createTRPCRouter } from "@/server/api/trpc";
|
||||
|
||||
/**
|
||||
@@ -11,7 +12,8 @@ import { createTRPCRouter } from "@/server/api/trpc";
|
||||
export const appRouter = createTRPCRouter({
|
||||
post: postRouter,
|
||||
links: linksRouter,
|
||||
misc: miscRouter
|
||||
misc: miscRouter,
|
||||
blog: blogRouter
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {z} from 'zod';
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure
|
||||
} from "@/server/api/trpc";
|
||||
|
||||
export const blogRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
contents: z.string().min(1),
|
||||
tagline: z.string().min(1).max(30),
|
||||
image: z.string().min(1)
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.db.blogPost.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
contents: input.contents,
|
||||
tagline: input.tagline,
|
||||
image: input.image,
|
||||
createdBy: { connect: { id: ctx.session.user.id } },
|
||||
},
|
||||
});
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.input(
|
||||
z.string().min(1)
|
||||
)
|
||||
.mutation(async ({ctx, input}) => {
|
||||
return ctx.db.blogPost.delete({
|
||||
where: {
|
||||
id: input
|
||||
}
|
||||
})
|
||||
}),
|
||||
getOne: publicProcedure
|
||||
.input(String)
|
||||
.query(({ ctx, input }) => {
|
||||
return ctx.db.blogPost.findFirst({
|
||||
where: { id: input },
|
||||
});
|
||||
}),
|
||||
getAll: publicProcedure
|
||||
.query(({ctx}) => {
|
||||
return ctx.db.blogPost.findMany()
|
||||
}),
|
||||
})
|
||||
@@ -17,4 +17,16 @@ body {
|
||||
animation: fall linear infinite;
|
||||
position:absolute;
|
||||
user-select:none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
Reference in New Issue
Block a user