├── .npmrc ├── public └── favicon.ico ├── .env.example ├── src ├── root.css ├── entry-server.tsx ├── routes │ ├── api │ │ ├── auth │ │ │ └── [...solidauth].ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── index.tsx │ ├── p │ │ └── [id].tsx │ ├── account.tsx │ └── submit.tsx ├── entry-client.tsx ├── env │ ├── schema.ts │ ├── server.ts │ └── client.ts ├── server │ ├── db │ │ └── client.ts │ ├── trpc │ │ ├── router │ │ │ ├── _app.ts │ │ │ ├── posts.ts │ │ │ └── comments.ts │ │ ├── context.ts │ │ └── utils.ts │ └── auth.ts ├── utils │ ├── format-comments.ts │ ├── auth.ts │ └── trpc.ts ├── components │ ├── Navbar.tsx │ ├── Comments │ │ ├── index.tsx │ │ ├── Form.tsx │ │ └── List.tsx │ ├── AuthGuard.tsx │ └── Post.tsx └── root.tsx ├── .gitignore ├── .eslintrc.json ├── vite.config.ts ├── tsconfig.json ├── uno.config.ts ├── README.md ├── LICENSE ├── prisma └── schema.prisma └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts=true -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nexxeln/hackernews/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | GITHUB_CLIENT_SECRET= 3 | GITHUB_CLIENT_ID= 4 | VITE_SESSION_SECRET= 5 | SITE_URL= -------------------------------------------------------------------------------- /src/root.css: -------------------------------------------------------------------------------- 1 | html { 2 | color-scheme: dark; 3 | } 4 | 5 | body { 6 | --at-apply: bg-neutral-9 text-neutral-1 font-sans; 7 | } 8 | -------------------------------------------------------------------------------- /src/entry-server.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StartServer, 3 | createHandler, 4 | renderAsync, 5 | } from "solid-start/entry-server"; 6 | 7 | export default createHandler( 8 | renderAsync((event) => ) 9 | ); 10 | -------------------------------------------------------------------------------- /src/routes/api/auth/[...solidauth].ts: -------------------------------------------------------------------------------- 1 | import { createSolidAuthHandler } from "solid-auth"; 2 | import { type User } from "@prisma/client"; 3 | 4 | import { authenticator } from "~/server/auth"; 5 | 6 | const handler = createSolidAuthHandler(authenticator); 7 | 8 | export const POST = handler; 9 | export const GET = handler; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .solid 3 | .output 4 | .vercel 5 | .netlify 6 | netlify 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | *.launch 16 | .settings/ 17 | 18 | # Temp 19 | gitignore 20 | 21 | # System Files 22 | .DS_Store 23 | Thumbs.db 24 | 25 | .env 26 | db.sqlite 27 | beSchema.txt -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "plugins": ["@typescript-eslint"], 7 | "extends": [ 8 | "plugin:solid/typescript", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/consistent-type-imports": "warn" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/routes/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createSolidAPIHandler } from "solid-start-trpc"; 2 | 3 | import { createContext } from "~/server/trpc/context"; 4 | import { appRouter } from "~/server/trpc/router/_app"; 5 | 6 | const handler = createSolidAPIHandler({ 7 | router: appRouter, 8 | createContext, 9 | }); 10 | 11 | export const GET = handler; 12 | export const POST = handler; 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import solid from "solid-start/vite"; 2 | // @ts-expect-error no typing 3 | import vercel from "solid-start-vercel"; 4 | import UnoCSS from "unocss/vite"; 5 | import dotenv from "dotenv"; 6 | import { defineConfig } from "vite"; 7 | 8 | export default defineConfig(() => { 9 | dotenv.config(); 10 | return { 11 | plugins: [solid({ ssr: false, adapter: vercel() }), UnoCSS()], 12 | }; 13 | }); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "strict": true, 6 | "target": "ESNext", 7 | "module": "ESNext", 8 | "moduleResolution": "node", 9 | "jsxImportSource": "solid-js", 10 | "jsx": "preserve", 11 | "types": ["vite/client"], 12 | "baseUrl": "./", 13 | "paths": { 14 | "~/*": ["./src/*"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/entry-client.tsx: -------------------------------------------------------------------------------- 1 | import { mount, StartClient } from "solid-start/entry-client"; 2 | 3 | import { client, queryClient, trpc } from "./utils/trpc"; 4 | 5 | mount( 6 | () => ( 7 | 8 |
13 | 14 |
15 |
16 | ), 17 | document 18 | ); 19 | -------------------------------------------------------------------------------- /uno.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetUno, 4 | presetWebFonts, 5 | transformerDirectives, 6 | transformerVariantGroup, 7 | } from "unocss"; 8 | 9 | export default defineConfig({ 10 | presets: [ 11 | presetUno(), 12 | presetWebFonts({ 13 | provider: "fontshare", 14 | fonts: { 15 | sans: "Satoshi", 16 | }, 17 | }), 18 | ], 19 | transformers: [transformerVariantGroup(), transformerDirectives()], 20 | }); 21 | -------------------------------------------------------------------------------- /src/env/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const serverSchema = z.object({ 4 | NODE_ENV: z 5 | .enum(["development", "production", "test"]) 6 | .default("development"), 7 | DATABASE_URL: z.string(), 8 | GITHUB_CLIENT_ID: z.string(), 9 | GITHUB_CLIENT_SECRET: z.string(), 10 | SITE_URL: z.string(), 11 | }); 12 | 13 | export const clientSchema = z.object({ 14 | MODE: z.enum(["development", "production", "test"]).default("development"), 15 | VITE_SESSION_SECRET: z.string(), 16 | }); 17 | -------------------------------------------------------------------------------- /src/server/db/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | import { serverEnv } from "~/env/server"; 4 | 5 | declare global { 6 | // eslint-disable-next-line no-var 7 | var prisma: PrismaClient | undefined; 8 | } 9 | 10 | export const prisma = 11 | global.prisma || 12 | new PrismaClient({ 13 | log: 14 | serverEnv.NODE_ENV === "development" 15 | ? ["query", "error", "warn"] 16 | : ["error"], 17 | }); 18 | 19 | if (serverEnv.NODE_ENV !== "production") { 20 | global.prisma = prisma; 21 | } 22 | -------------------------------------------------------------------------------- /src/server/trpc/router/_app.ts: -------------------------------------------------------------------------------- 1 | import type { inferRouterOutputs } from "@trpc/server"; 2 | 3 | import { t } from "../utils"; 4 | import { commentsRouter } from "./comments"; 5 | import { postsRouter } from "./posts"; 6 | 7 | export const appRouter = t.router({ 8 | posts: postsRouter, 9 | comments: commentsRouter, 10 | }); 11 | 12 | export type IAppRouter = typeof appRouter; 13 | 14 | export type Comment = 15 | inferRouterOutputs["comments"]["getAll"][number]; 16 | 17 | export type CommentWithChildren = Comment & { 18 | children: CommentWithChildren[]; 19 | }; 20 | -------------------------------------------------------------------------------- /src/server/trpc/context.ts: -------------------------------------------------------------------------------- 1 | import type { inferAsyncReturnType } from "@trpc/server"; 2 | import type { createSolidAPIHandlerContext } from "solid-start-trpc"; 3 | 4 | import { prisma } from "~/server/db/client"; 5 | 6 | export const createContextInner = async ( 7 | opts: createSolidAPIHandlerContext 8 | ) => { 9 | return { 10 | prisma, 11 | ...opts, 12 | }; 13 | }; 14 | 15 | export const createContext = async (opts: createSolidAPIHandlerContext) => { 16 | return await createContextInner(opts); 17 | }; 18 | 19 | export type IContext = inferAsyncReturnType; 20 | -------------------------------------------------------------------------------- /src/server/trpc/utils.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC, TRPCError } from "@trpc/server"; 2 | 3 | import type { IContext } from "./context"; 4 | import { authenticator } from "../auth"; 5 | 6 | export const t = initTRPC.context().create(); 7 | 8 | export const protectedProcedure = t.procedure.use( 9 | t.middleware(async ({ ctx, next }) => { 10 | const user = await authenticator.isAuthenticated(ctx.req); 11 | 12 | if (!user) { 13 | throw new TRPCError({ 14 | code: "UNAUTHORIZED", 15 | message: "You are unautorized to access this endpoint", 16 | }); 17 | } 18 | 19 | return next({ ctx: { ...ctx, user } }); 20 | }) 21 | ); 22 | -------------------------------------------------------------------------------- /src/utils/format-comments.ts: -------------------------------------------------------------------------------- 1 | import type { CommentWithChildren } from "~/server/trpc/router/_app"; 2 | 3 | export const formatComments = (comments: CommentWithChildren[]) => { 4 | const map = new Map(); 5 | const roots: CommentWithChildren[] = []; 6 | 7 | for (let i = 0; i < comments.length; i++) { 8 | const commentId = comments[i].id; 9 | 10 | map.set(commentId, i); 11 | comments[i].children = []; 12 | 13 | if (typeof comments[i].parentId === "string") { 14 | const parentCommentIndex = map.get(comments[i].parentId); 15 | comments[parentCommentIndex].children.push(comments[i]); 16 | 17 | continue; 18 | } 19 | 20 | roots.push(comments[i]); 21 | } 22 | 23 | return roots; 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from "solid-start"; 2 | import { createSolidAuthClient } from "solid-auth"; 3 | 4 | import { clientEnv } from "~/env/client"; 5 | 6 | const getBaseUrl = () => { 7 | if (typeof window !== "undefined") return ""; 8 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; 9 | return `http://localhost:${process.env.PORT ?? 3000}`; 10 | }; 11 | 12 | export const sessionStorage = createCookieSessionStorage({ 13 | cookie: { 14 | name: "_session", 15 | secrets: [clientEnv.VITE_SESSION_SECRET], 16 | secure: true, 17 | maxAge: 60 * 60 * 24 * 30, 18 | }, 19 | }); 20 | 21 | export const authClient = createSolidAuthClient(`${getBaseUrl()}/api/auth`); 22 | -------------------------------------------------------------------------------- /src/env/server.ts: -------------------------------------------------------------------------------- 1 | import type { ZodFormattedError } from "zod"; 2 | 3 | import { serverSchema } from "./schema"; 4 | 5 | export const formatErrors = ( 6 | errors: ZodFormattedError, string> 7 | ) => 8 | Object.entries(errors) 9 | .map(([name, value]) => { 10 | if (value && "_errors" in value) 11 | return `${name}: ${value._errors.join(", ")}\n`; 12 | }) 13 | .filter(Boolean); 14 | 15 | const env = serverSchema.safeParse(process.env); 16 | 17 | if (env.success === false) { 18 | console.error( 19 | "❌ Invalid environment variables:\n", 20 | ...formatErrors(env.error.format()) 21 | ); 22 | throw new Error("Invalid environment variables"); 23 | } 24 | 25 | export const serverEnv = env.data; 26 | -------------------------------------------------------------------------------- /src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCSolid } from "solid-trpc"; 2 | import { httpBatchLink } from "@trpc/client"; 3 | import { QueryClient } from "@tanstack/solid-query"; 4 | 5 | import type { IAppRouter } from "~/server/trpc/router/_app"; 6 | 7 | const getBaseUrl = () => { 8 | if (typeof window !== "undefined") return ""; 9 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; 10 | return `http://localhost:${process.env.PORT ?? 3000}`; 11 | }; 12 | 13 | export const trpc = createTRPCSolid(); 14 | 15 | export const client = trpc.createClient({ 16 | links: [ 17 | httpBatchLink({ 18 | url: `${getBaseUrl()}/api/trpc`, 19 | }), 20 | ], 21 | }); 22 | 23 | export const queryClient = new QueryClient(); 24 | -------------------------------------------------------------------------------- /src/env/client.ts: -------------------------------------------------------------------------------- 1 | import type { ZodFormattedError } from "zod"; 2 | 3 | import { clientSchema } from "./schema"; 4 | 5 | export const formatErrors = ( 6 | errors: ZodFormattedError, string> 7 | ) => 8 | Object.entries(errors) 9 | .map(([name, value]) => { 10 | if (value && "_errors" in value) 11 | return `${name}: ${value._errors.join(", ")}\n`; 12 | }) 13 | .filter(Boolean); 14 | 15 | const env = clientSchema.safeParse(import.meta.env); 16 | 17 | if (env.success === false) { 18 | console.error( 19 | "❌ Invalid environment variables:\n", 20 | ...formatErrors(env.error.format()) 21 | ); 22 | throw new Error("Invalid environment variables"); 23 | } 24 | 25 | export const clientEnv = env.data; 26 | -------------------------------------------------------------------------------- /src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { A } from "solid-start"; 2 | import type { Component } from "solid-js"; 3 | 4 | const NavItem: Component<{ href: string; text: string }> = (props) => { 5 | return ( 6 | 7 | {props.text} 8 | 9 | ); 10 | }; 11 | 12 | export const Navbar = () => { 13 | return ( 14 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/server/trpc/router/posts.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { t } from "../utils"; 4 | 5 | export const postsRouter = t.router({ 6 | getLatest: t.procedure.query(async ({ ctx }) => { 7 | const latestPosts = await ctx.prisma.post.findMany({ 8 | orderBy: { createdAt: "desc" }, 9 | include: { 10 | User: { select: { displayName: true } }, 11 | }, 12 | }); 13 | 14 | return latestPosts; 15 | }), 16 | getPostById: t.procedure 17 | .input(z.object({ id: z.string() })) 18 | .query(async ({ ctx, input: { id } }) => { 19 | const post = await ctx.prisma.post.findUnique({ 20 | where: { id }, 21 | include: { 22 | User: { select: { displayName: true } }, 23 | Comment: true, 24 | }, 25 | }); 26 | 27 | return post; 28 | }), 29 | }); 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | A simple [Hacker News](https://news.ycombinator.com/) clone built using [SolidStart](https://start.solidjs.com), [UnoCSS](https://uno.antfu.me), [tRPC](https://trpc.io), [Solid Auth](https://github.com/OrJDev/solid-auth), [Prisma](https://prisma.io), [PlanetScale](https://planetscale.com), bootstrapped using [create-jd-app](https://github.com/OrJDev/create-jd-app) and hosted on [Vercel](https://vercel.com). 4 | 5 | This is mostly a learning project to try out SolidStart and tRPC together. 6 | 7 | ## Features 8 | 9 | - [x] Authentication 10 | - [x] Nested comments 11 | - [ ] Better error handling 12 | - [ ] Upvote/Downvote 13 | - [ ] Pagination 14 | - [ ] Optimize queries 15 | - [ ] Search 16 | - [ ] Meta tags 17 | - [ ] Deploy on the edge 18 | 19 | ## Credits 20 | 21 | Huge shoutout to [OrJDev](https://github.com/OrJDev) for making the SolidJS ecosystem better with his amazing libraries and putting up with my issues. -------------------------------------------------------------------------------- /src/components/Comments/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { type Component, Switch, Match } from "solid-js"; 3 | 4 | import { formatComments } from "~/utils/format-comments"; 5 | import { trpc } from "~/utils/trpc"; 6 | import { ListComments } from "./List"; 7 | 8 | export const CommentSection: Component<{ id: string }> = (props) => { 9 | const comments = trpc.comments.getAll.useQuery(() => ({ id: props.id })); 10 | 11 | return ( 12 |
13 | Something went wrong

}> 14 | 15 |

Loading comments...

16 |
17 | 18 | {(comments) => ( 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | 21 | )} 22 | 23 |
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/server/auth.ts: -------------------------------------------------------------------------------- 1 | import { Authenticator, GitHubStrategy } from "solid-auth"; 2 | import { type User } from "@prisma/client"; 3 | 4 | import { serverEnv } from "~/env/server"; 5 | import { sessionStorage } from "~/utils/auth"; 6 | import { prisma } from "./db/client"; 7 | 8 | export const authenticator = new Authenticator(sessionStorage).use( 9 | new GitHubStrategy( 10 | { 11 | clientID: serverEnv.GITHUB_CLIENT_ID, 12 | clientSecret: serverEnv.GITHUB_CLIENT_SECRET, 13 | callbackURL: serverEnv.SITE_URL + "/api/auth/github/callback", 14 | scope: ["read:user"], 15 | }, 16 | async ({ profile }) => { 17 | let user = await prisma.user.findUnique({ 18 | where: { 19 | id: profile.id, 20 | }, 21 | }); 22 | 23 | if (!user) { 24 | user = await prisma.user.create({ 25 | data: { 26 | id: profile.id, 27 | displayName: profile.displayName, 28 | }, 29 | }); 30 | } 31 | return user; 32 | } 33 | ) 34 | ); 35 | -------------------------------------------------------------------------------- /src/components/AuthGuard.tsx: -------------------------------------------------------------------------------- 1 | import { useRouteData } from "solid-start"; 2 | import { createServerData$, redirect } from "solid-start/server"; 3 | import { Match, Switch, type Component } from "solid-js"; 4 | import { type User } from "@prisma/client"; 5 | 6 | import { authenticator } from "~/server/auth"; 7 | 8 | export const AuthGuard = (Component: ProtectedRouter) => { 9 | const routeData = () => { 10 | return createServerData$(async (_, { request }) => { 11 | const user = await authenticator.isAuthenticated(request); 12 | if (!user) { 13 | throw redirect("/account"); 14 | } 15 | return user; 16 | }); 17 | }; 18 | return { 19 | routeData, 20 | Page: () => { 21 | const current = useRouteData(); 22 | return ( 23 | }> 24 | 25 |

Loading...

26 |
27 |
28 | ); 29 | }, 30 | }; 31 | }; 32 | 33 | export type ProtectedRouter = Component; 34 | -------------------------------------------------------------------------------- /src/root.tsx: -------------------------------------------------------------------------------- 1 | // @refresh reload 2 | import { 3 | Body, 4 | ErrorBoundary, 5 | FileRoutes, 6 | Head, 7 | Html, 8 | Meta, 9 | Routes, 10 | Scripts, 11 | Title, 12 | } from "solid-start"; 13 | import { Suspense } from "solid-js"; 14 | import "uno.css"; 15 | import "@unocss/reset/tailwind.css"; 16 | 17 | import { Navbar } from "~/components/Navbar"; 18 | import "./root.css"; 19 | 20 | export default function Root() { 21 | return ( 22 | 23 | 24 | Hacker News 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 | 34 | 35 | 36 |
37 |
38 |
39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Shoubhit Dash 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { For, Match, Switch, type ParentComponent } from "solid-js"; 2 | 3 | import { PostCard } from "~/components/Post"; 4 | import { trpc } from "~/utils/trpc"; 5 | 6 | const Home: ParentComponent = () => { 7 | const posts = trpc.posts.getLatest.useQuery(); 8 | return ( 9 | <> 10 | 11 | 12 |

Loading...

13 |
14 | 15 |

Oh no! Something went wrong!

16 |
17 | 18 |
19 | 20 | {({ id, title, link, createdAt, User }) => ( 21 | 28 | )} 29 | 30 |
31 |
32 |
33 | 34 | ); 35 | }; 36 | 37 | export default Home; 38 | -------------------------------------------------------------------------------- /src/routes/p/[id].tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import { useParams } from "solid-start"; 3 | import { Match, Switch } from "solid-js"; 4 | 5 | import { CommentSection } from "~/components/Comments"; 6 | import { CommentForm } from "~/components/Comments/Form"; 7 | import { Post } from "~/components/Post"; 8 | import { trpc } from "~/utils/trpc"; 9 | 10 | export default function PostPage() { 11 | const { id } = useParams(); 12 | const post = trpc.posts.getPostById.useQuery(() => ({ id })); 13 | 14 | return ( 15 | 16 | 17 |

Loading...

18 |
19 | 20 | 21 |

Oh no! Something went wrong!

22 |
23 | 24 | 25 | {(post) => ( 26 |
27 | 35 | 36 |
37 | 38 |
39 | )} 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/routes/account.tsx: -------------------------------------------------------------------------------- 1 | import { useRouteData } from "solid-start"; 2 | import { Match, Switch } from "solid-js"; 3 | import { createServerData$ } from "solid-start/server"; 4 | 5 | import { authenticator } from "~/server/auth"; 6 | import { authClient } from "~/utils/auth"; 7 | 8 | export const routeData = () => { 9 | return { 10 | user: createServerData$(async (_, { request }) => { 11 | const user = await authenticator.isAuthenticated(request); 12 | return user; 13 | }), 14 | }; 15 | }; 16 | 17 | export default function Account() { 18 | const { user } = useRouteData(); 19 | 20 | return ( 21 | <> 22 | 26 | authClient.login("github", { 27 | successRedirect: "/account", 28 | failureRedirect: "/account", 29 | }) 30 | } 31 | > 32 | Login with GitHub 33 | 34 | } 35 | > 36 | 37 |
38 | Hi {user()?.displayName}! 39 | 48 |
49 |
50 |
51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | previewFeatures = ["referentialIntegrity"] 4 | } 5 | 6 | datasource db { 7 | provider = "mysql" 8 | url = env("DATABASE_URL") 9 | referentialIntegrity = "prisma" 10 | } 11 | 12 | model Post { 13 | id String @id @default(cuid()) 14 | createdAt DateTime @default(now()) 15 | updatedAt DateTime @updatedAt 16 | title String @db.VarChar(255) 17 | description String? @db.Text 18 | link String @db.Text 19 | User User? @relation("PostedBy", fields: [userId], references: [id]) 20 | userId String? 21 | Comment Comment[] 22 | } 23 | 24 | model User { 25 | id String @id 26 | displayName String 27 | posts Post[] @relation("PostedBy") 28 | Comment Comment[] 29 | } 30 | 31 | model Comment { 32 | id String @id @default(cuid()) 33 | createdAt DateTime @default(now()) 34 | updatedAt DateTime @updatedAt 35 | text String @db.VarChar(1000) 36 | User User @relation(fields: [userId], references: [id]) 37 | userId String 38 | Post Post @relation(fields: [postId], references: [id]) 39 | postId String 40 | children Comment[] @relation("CommentChildren") 41 | parent Comment? @relation("CommentChildren", fields: [parentId], references: [id], onDelete: NoAction, onUpdate: NoAction) 42 | parentId String? 43 | } 44 | -------------------------------------------------------------------------------- /src/server/trpc/router/comments.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { z } from "zod"; 3 | 4 | import { protectedProcedure, t } from "../utils"; 5 | 6 | export const commentsRouter = t.router({ 7 | getAll: t.procedure 8 | .input(z.object({ id: z.string() })) 9 | .query(async ({ ctx, input: { id } }) => { 10 | try { 11 | const comments = await ctx.prisma.comment.findMany({ 12 | where: { postId: id }, 13 | include: { User: true }, 14 | }); 15 | 16 | return comments; 17 | } catch (error) { 18 | console.log(error); 19 | 20 | throw new TRPCError({ code: "BAD_REQUEST" }); 21 | } 22 | }), 23 | 24 | create: protectedProcedure 25 | .input( 26 | z.object({ 27 | id: z.string(), 28 | text: z.string(), 29 | parentId: z.string().optional(), 30 | }) 31 | ) 32 | .mutation(async ({ ctx, input }) => { 33 | const { id, text, parentId } = input; 34 | const { user } = ctx; 35 | 36 | try { 37 | const comment = await ctx.prisma.comment.create({ 38 | data: { 39 | text, 40 | Post: { connect: { id } }, 41 | User: { connect: { id: user.id } }, 42 | ...(parentId && { 43 | parent: { connect: { id: parentId } }, 44 | }), 45 | }, 46 | }); 47 | 48 | return comment; 49 | } catch (error) { 50 | console.log(error); 51 | 52 | throw new TRPCError({ code: "BAD_REQUEST" }); 53 | } 54 | }), 55 | }); 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "scripts": { 4 | "dev": "solid-start dev", 5 | "build": "solid-start build", 6 | "start": "solid-start start", 7 | "lint": "eslint --fix \"**/*.{ts,tsx,js,jsx}\"", 8 | "push": "prisma db push", 9 | "generate": "prisma generate", 10 | "postinstall": "prisma generate", 11 | "postbuild": "cp node_modules/@prisma/engines/*query* .vercel/output/functions/render.func/ && cp prisma/schema.prisma .vercel/output/functions/render.func/" 12 | }, 13 | "type": "module", 14 | "devDependencies": { 15 | "@typescript-eslint/eslint-plugin": "^5.42.1", 16 | "@typescript-eslint/parser": "^5.42.1", 17 | "@unocss/reset": "^0.46.5", 18 | "eslint": "^8.27.0", 19 | "eslint-plugin-solid": "^0.8.0", 20 | "prisma": "^4.6.1", 21 | "solid-start-node": "^0.2.1", 22 | "solid-start-vercel": "^0.2.5", 23 | "typescript": "^4.8.3", 24 | "unocss": "^0.46.5", 25 | "vite": "^3.1.0" 26 | }, 27 | "dependencies": { 28 | "@prisma/client": "^4.6.1", 29 | "@solidjs/meta": "^0.28.0", 30 | "@solidjs/router": "^0.5.0", 31 | "@tanstack/solid-query": "^4.15.1", 32 | "@trpc/client": "^10.0.0", 33 | "@trpc/server": "^10.0.0", 34 | "clsx": "^1.2.1", 35 | "date-fns": "^2.29.3", 36 | "dotenv": "^16.0.3", 37 | "solid-auth": "^0.0.1", 38 | "solid-js": "^1.5.7", 39 | "solid-start": "^0.2.1", 40 | "solid-start-trpc": "^0.0.13", 41 | "solid-trpc": "0.0.11-rc.2", 42 | "undici": "5.11.0", 43 | "zod": "^3.19.1" 44 | }, 45 | "engines": { 46 | "node": ">=16" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/Comments/Form.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "solid-start"; 2 | import { createSignal, Show, type Component } from "solid-js"; 3 | 4 | import { trpc } from "~/utils/trpc"; 5 | 6 | export const CommentForm: Component<{ id: string; parentId?: string }> = ( 7 | props 8 | ) => { 9 | const navigate = useNavigate(); 10 | const [text, setText] = createSignal(""); 11 | 12 | const utils = trpc.useContext(); 13 | const createPost = trpc.comments.create.useMutation({ 14 | onSuccess: () => { 15 | setText(""); 16 | utils.comments.getAll.invalidate({ id: props.id }); 17 | }, 18 | onError: (error) => { 19 | error.data?.code === "UNAUTHORIZED" && navigate("/account"); 20 | }, 21 | }); 22 | 23 | return ( 24 |
25 |
{ 28 | event.preventDefault(); 29 | 30 | createPost.mutate({ 31 | id: props.id, 32 | text: text(), 33 | parentId: props.parentId, 34 | }); 35 | }} 36 | > 37 | setText(event.currentTarget.value)} 45 | /> 46 | 47 | 56 |
57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/Comments/List.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "solid-start"; 2 | import { createSignal, For, Show, type Component } from "solid-js"; 3 | import { formatDistanceToNow } from "date-fns"; 4 | 5 | import type { CommentWithChildren } from "~/server/trpc/router/_app"; 6 | import { CommentForm } from "./Form"; 7 | 8 | export const CommentCard: Component<{ comment: CommentWithChildren }> = ( 9 | props 10 | ) => { 11 | const { id } = useParams(); 12 | const [replying, setReplying] = createSignal(false); 13 | 14 | return ( 15 |
16 |

{props.comment.text}

17 |
18 | by {props.comment.User.displayName} 19 | 20 | 21 | {formatDistanceToNow(new Date(props.comment.createdAt))} ago 22 | 23 | 24 | 32 |
33 | 34 | 35 | 36 | 37 | 38 | 0}> 39 |
40 | 41 |
42 |
43 |
44 | ); 45 | }; 46 | 47 | export const ListComments: Component<{ comments: CommentWithChildren[] }> = ( 48 | props 49 | ) => { 50 | return ( 51 |
52 | 53 | {(comment) => ( 54 |
55 | 56 |
57 | )} 58 |
59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/components/Post.tsx: -------------------------------------------------------------------------------- 1 | import { A } from "solid-start"; 2 | import { type Component, Show } from "solid-js"; 3 | import { formatDistanceToNow, formatRFC7231 } from "date-fns"; 4 | 5 | export const PostCard: Component<{ 6 | id: string; 7 | title: string; 8 | link: string; 9 | // fix this 10 | username: string | undefined; 11 | createdAt: string; 12 | }> = (props) => { 13 | return ( 14 | 35 | ); 36 | }; 37 | 38 | export const Post: Component<{ 39 | title: string; 40 | link: string; 41 | description: string | null; 42 | // fix this 43 | username: string | undefined; 44 | comments: number; 45 | createdAt: string; 46 | }> = (props) => { 47 | return ( 48 |
49 |
50 | 55 | {props.title} 56 | 57 | 58 | 59 |

{props.description}

60 |
61 | 62 |
63 | by {props.username} 64 | 65 | {formatRFC7231(new Date(props.createdAt))} 66 | 67 | {props.comments} comments 68 |
69 |
70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/routes/submit.tsx: -------------------------------------------------------------------------------- 1 | import { createServerAction$, redirect } from "solid-start/server"; 2 | import { Show } from "solid-js"; 3 | import { z } from "zod"; 4 | 5 | import { AuthGuard } from "~/components/AuthGuard"; 6 | import { authenticator } from "~/server/auth"; 7 | import { prisma } from "~/server/db/client"; 8 | 9 | const inputSchema = z.object({ 10 | title: z.string().min(1).max(255), 11 | link: z.string().url(), 12 | description: z.string().max(1000).optional(), 13 | }); 14 | 15 | export const { routeData, Page } = AuthGuard(() => { 16 | const [submit, { Form }] = createServerAction$( 17 | async (form: FormData, { request }) => { 18 | const title = form.get("title"); 19 | const link = form.get("url"); 20 | const description = form.get("description"); 21 | 22 | const input = inputSchema.safeParse({ title, link, description }); 23 | 24 | if (input.success === false) { 25 | console.log(input.error.format()); 26 | throw new Error(input.error.format()._errors[0]); 27 | } 28 | 29 | const user = await authenticator.isAuthenticated(request); 30 | 31 | if (!user) { 32 | throw redirect("/account"); 33 | } 34 | 35 | await prisma.post.create({ 36 | data: { 37 | title: input.data.title, 38 | link: input.data.link, 39 | description: input.data.description, 40 | userId: user.id, 41 | }, 42 | }); 43 | 44 | throw redirect("/"); 45 | } 46 | ); 47 | 48 | return ( 49 |
50 |
51 | 52 | {submit.error.message} 53 | 54 |
55 | 56 | 63 |
64 | 65 |
66 | 67 | 74 |
75 | 76 |
77 | 78 |