├── src ├── styles │ └── globals.css ├── pages │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── _document.tsx │ ├── 404.tsx │ ├── _app.tsx │ ├── index.tsx │ ├── post │ │ └── [id].tsx │ ├── create.tsx │ └── user │ │ └── [id].tsx ├── types │ ├── next-auth.d.ts │ └── env.d.ts ├── components │ ├── post │ │ ├── posts-grid.tsx │ │ ├── loading-card.tsx │ │ ├── user-hover-card.tsx │ │ ├── like-button.tsx │ │ ├── post-card.tsx │ │ └── more-button.tsx │ ├── layout │ │ ├── navbar.tsx │ │ ├── index.tsx │ │ ├── footer.tsx │ │ └── user-avatar.tsx │ ├── error-page.tsx │ └── ui │ │ ├── label.tsx │ │ ├── toaster.tsx │ │ ├── input.tsx │ │ ├── slider.tsx │ │ ├── hover-card.tsx │ │ ├── avatar.tsx │ │ ├── tabs.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── toast.tsx │ │ ├── select.tsx │ │ └── dropdown-menu.tsx ├── server │ ├── db.ts │ ├── api │ │ ├── root.ts │ │ ├── routers │ │ │ ├── like.ts │ │ │ ├── user.ts │ │ │ └── post.ts │ │ └── trpc.ts │ └── auth.ts ├── lib │ ├── utils.ts │ └── api.ts ├── hooks │ ├── use-translations.ts │ └── use-toast.ts └── locales │ ├── Translations.ts │ ├── en.ts │ ├── sv.ts │ └── fi.ts ├── screenshot.png ├── public └── favicon.ico ├── postcss.config.js ├── .vscode ├── extensions.json └── settings.json ├── .env.example ├── next.config.js ├── .eslintrc.json ├── prettier.config.js ├── .github └── workflows │ └── ci.yml ├── tsconfig.json ├── .gitignore ├── tailwind.config.js ├── LICENSE ├── README.md ├── package.json └── prisma └── schema.prisma /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WilliamTuominiemi/NFT-Art-Platform/HEAD/screenshot.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WilliamTuominiemi/NFT-Art-Platform/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from "@/server/auth"; 2 | import NextAuth from "next-auth"; 3 | 4 | export default NextAuth(authOptions); 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "Prisma.prisma", 6 | "bradlc.vscode-tailwindcss" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { type DefaultSession } from "next-auth"; 2 | 3 | declare module "next-auth" { 4 | interface Session extends DefaultSession { 5 | user: { 6 | id: string; 7 | } & DefaultSession["user"]; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # PlanetSclale connection string 2 | DATABASE_URL="" 3 | 4 | # Next Auth 5 | # openssl rand -base64 32 6 | NEXTAUTH_SECRET="" 7 | NEXTAUTH_URL="http://localhost:3000" 8 | 9 | # Next Auth Google Provider 10 | GOOGLE_CLIENT_ID="" 11 | GOOGLE_CLIENT_SECRET="" 12 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("next").NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | i18n: { 5 | locales: ["en", "sv", "fi"], 6 | defaultLocale: "en", 7 | }, 8 | images: { remotePatterns: [{ hostname: "lh3.googleusercontent.com" }] }, 9 | }; 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier", "jsx-a11y"], 3 | "extends": [ 4 | "next/core-web-vitals", 5 | "next/typescript", 6 | "plugin:jsx-a11y/recommended" 7 | ], 8 | "rules": { 9 | "prettier/prettier": "error", 10 | "no-console": "error" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/post/posts-grid.tsx: -------------------------------------------------------------------------------- 1 | interface PostsGridProps { 2 | children: React.ReactNode; 3 | } 4 | 5 | export const PostsGrid = ({ children }: PostsGridProps) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | interface ProcessEnv extends NodeJS.ProcessEnv { 3 | NODE_ENV: "development" | "production" | "test"; 4 | DATABASE_URL: string; 5 | NEXTAUTH_SECRET: string; 6 | NEXTAUTH_URL: string; 7 | GOOGLE_CLIENT_ID: string; 8 | GOOGLE_CLIENT_SECRET: string; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | bracketSpacing: true, 4 | printWidth: 80, 5 | singleQuote: false, 6 | trailingComma: "all", 7 | semi: true, 8 | tabWidth: 2, 9 | endOfLine: "auto", 10 | arrowParens: "always", 11 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 12 | }; 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": "explicit", 6 | "source.organizeImports": "explicit" 7 | }, 8 | "typescript.tsdk": "node_modules/typescript/lib", 9 | "typescript.enablePromptUseWorkspaceTsdk": true 10 | } 11 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from "next/document"; 2 | 3 | const Document = () => { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | }; 14 | 15 | export default Document; 16 | -------------------------------------------------------------------------------- /src/server/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; 4 | 5 | export const prisma = 6 | globalForPrisma.prisma || 7 | new PrismaClient({ 8 | log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"], 9 | }); 10 | 11 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v3 16 | - name: Install Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 20 20 | cache: "npm" 21 | - name: Install dependencies 22 | run: npm ci 23 | - name: Lint 24 | run: npm run check:all 25 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorPage } from "@/components/error-page"; 2 | import { Button } from "@/components/ui/button"; 3 | import { useTranslation } from "@/hooks/use-translations"; 4 | import { useRouter } from "next/router"; 5 | 6 | const NotFound = () => { 7 | const { t } = useTranslation(); 8 | const router = useRouter(); 9 | 10 | return ( 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default NotFound; 18 | -------------------------------------------------------------------------------- /src/server/api/root.ts: -------------------------------------------------------------------------------- 1 | import { likeRouter } from "@/server/api/routers/like"; 2 | import { postRouter } from "@/server/api/routers/post"; 3 | import { userRouter } from "@/server/api/routers/user"; 4 | import { createTRPCRouter } from "@/server/api/trpc"; 5 | 6 | /** 7 | * This is the primary router for your server. 8 | * 9 | * All routers added in /api/routers should be manually added here. 10 | */ 11 | export const appRouter = createTRPCRouter({ 12 | post: postRouter, 13 | user: userRouter, 14 | like: likeRouter, 15 | }); 16 | 17 | // export type definition of API 18 | export type AppRouter = typeof appRouter; 19 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { appRouter } from "@/server/api/root"; 2 | import { createTRPCContext } from "@/server/api/trpc"; 3 | import { createNextApiHandler } from "@trpc/server/adapters/next"; 4 | 5 | // export API handler 6 | export default createNextApiHandler({ 7 | router: appRouter, 8 | createContext: createTRPCContext, 9 | onError: 10 | process.env.NODE_ENV === "development" 11 | ? ({ path, error }) => { 12 | // eslint-disable-next-line no-console 13 | console.error( 14 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`, 15 | ); 16 | } 17 | : undefined, 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/layout/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { UserAvatar } from "@/components/layout/user-avatar"; 2 | import { Pencil } from "lucide-react"; 3 | import Link from "next/link"; 4 | 5 | export const Navbar = () => { 6 | return ( 7 |
8 |
9 | 10 | 11 | Baynet 12 | 13 | 14 |
15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /src/components/error-page.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from "@/components/layout"; 2 | 3 | interface ErrorPageProps { 4 | title: string; 5 | description: string; 6 | children: React.ReactNode; 7 | } 8 | 9 | export const ErrorPage = ({ title, description, children }: ErrorPageProps) => { 10 | return ( 11 | 12 |
13 |

14 | {title} 15 |

16 |

17 | {description} 18 |

19 | {children} 20 |
21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); 5 | 6 | export const kFormatter = (num: number) => { 7 | if (num < 1000) { 8 | return `${num}`; 9 | } 10 | 11 | const base = Math.floor(Math.log(Math.abs(num)) / Math.log(1000)); 12 | const suffix = "kmb"[base - 1]; 13 | const abbrev = String(num / 1000 ** base).substring(0, 3); 14 | return (abbrev.endsWith(".") ? abbrev.slice(0, -1) : abbrev) + suffix; 15 | }; 16 | 17 | export const formatUserJoinedString = ( 18 | joined: string, 19 | lang: string, 20 | date: Date, 21 | ) => 22 | `${joined} ${date.toLocaleDateString(lang, { 23 | dateStyle: "long", 24 | })}`; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | -------------------------------------------------------------------------------- /src/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "@/components/layout/footer"; 2 | import { Navbar } from "@/components/layout/navbar"; 3 | import Head from "next/head"; 4 | 5 | interface LayoutProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | export const Layout = ({ children }: LayoutProps) => { 10 | return ( 11 | <> 12 | 13 | Baynet 14 | 15 | 16 | 17 |
18 | 19 |
20 |
{children}
21 |
22 |
23 |
24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 9 | ); 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )); 22 | Label.displayName = LabelPrimitive.Root.displayName; 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "@/components/ui/toaster"; 2 | import { api } from "@/lib/api"; 3 | import "@/styles/globals.css"; 4 | import { Analytics } from "@vercel/analytics/react"; 5 | import { type Session } from "next-auth"; 6 | import { SessionProvider } from "next-auth/react"; 7 | import { ThemeProvider } from "next-themes"; 8 | import { type AppType } from "next/app"; 9 | 10 | const MyApp: AppType<{ session: Session | null }> = ({ 11 | Component, 12 | pageProps: { session, ...pageProps }, 13 | }) => { 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default api.withTRPC(MyApp); 28 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Toast, 3 | ToastClose, 4 | ToastDescription, 5 | ToastProvider, 6 | ToastTitle, 7 | ToastViewport, 8 | } from "@/components/ui/toast"; 9 | import { useToast } from "@/hooks/use-toast"; 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast(); 13 | 14 | return ( 15 | 16 | {toasts.map(function ({ id, title, description, action, ...props }) { 17 | return ( 18 | 19 |
20 | {title && {title}} 21 | {description && ( 22 | {description} 23 | )} 24 |
25 | {action} 26 | 27 |
28 | ); 29 | })} 30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/components/post/loading-card.tsx: -------------------------------------------------------------------------------- 1 | export const LoadingCard = () => { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: "class", 4 | content: ["./src/**/*.{ts,tsx}"], 5 | theme: { 6 | container: { 7 | center: true, 8 | padding: "2rem", 9 | screens: { 10 | "2xl": "1400px", 11 | }, 12 | }, 13 | extend: { 14 | keyframes: { 15 | "accordion-down": { 16 | from: { height: "0" }, 17 | to: { height: "var(--radix-accordion-content-height)" }, 18 | }, 19 | "accordion-up": { 20 | from: { height: "var(--radix-accordion-content-height)" }, 21 | to: { height: "0" }, 22 | }, 23 | }, 24 | animation: { 25 | "accordion-down": "accordion-down 0.2s ease-out", 26 | "accordion-up": "accordion-up 0.2s ease-out", 27 | }, 28 | }, 29 | }, 30 | plugins: [ 31 | require("tailwindcss-animate"), 32 | require("@tailwindcss/aspect-ratio"), 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Input = React.forwardRef< 6 | HTMLInputElement, 7 | React.InputHTMLAttributes 8 | >(({ className, type, ...props }, ref) => { 9 | return ( 10 | 19 | ); 20 | }); 21 | Input.displayName = "Input"; 22 | 23 | export { Input }; 24 | -------------------------------------------------------------------------------- /src/server/api/routers/like.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; 2 | import { z } from "zod"; 3 | 4 | export const likeRouter = createTRPCRouter({ 5 | create: protectedProcedure 6 | .input( 7 | z.object({ 8 | postId: z.string(), 9 | }), 10 | ) 11 | .mutation(async ({ ctx, input }) => { 12 | const like = await ctx.prisma.like.create({ 13 | data: { 14 | userId: ctx.session.user.id, 15 | postId: input.postId, 16 | }, 17 | }); 18 | return like; 19 | }), 20 | 21 | delete: protectedProcedure 22 | .input( 23 | z.object({ 24 | postId: z.string(), 25 | }), 26 | ) 27 | .mutation(async ({ ctx, input }) => { 28 | await ctx.prisma.like.delete({ 29 | where: { 30 | userId_postId: { 31 | userId: ctx.session.user.id, 32 | postId: input.postId, 33 | }, 34 | }, 35 | }); 36 | }), 37 | }); 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 William Tuominiemi 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/hooks/use-translations.ts: -------------------------------------------------------------------------------- 1 | import { type Translations } from "@/locales/Translations"; 2 | import { englishTranslations } from "@/locales/en"; 3 | import { finnishTranslations } from "@/locales/fi"; 4 | import { swedishTranslations } from "@/locales/sv"; 5 | import dayjs from "dayjs"; 6 | import { useRouter } from "next/router"; 7 | import("dayjs/locale/en"); 8 | import("dayjs/locale/sv"); 9 | import("dayjs/locale/fi"); 10 | 11 | export const useTranslation = (): { 12 | t: Translations; 13 | changeLanguage: (language: string) => void; 14 | currentLanguage: string; 15 | } => { 16 | const router = useRouter(); 17 | dayjs.locale(router.locale); 18 | 19 | let t: Translations; 20 | switch (router.locale) { 21 | case "en": 22 | t = englishTranslations; 23 | break; 24 | case "sv": 25 | t = swedishTranslations; 26 | break; 27 | case "fi": 28 | t = finnishTranslations; 29 | break; 30 | default: 31 | t = englishTranslations; 32 | break; 33 | } 34 | 35 | const changeLanguage = (locale: string) => { 36 | dayjs.locale(locale); 37 | router.push(router.pathname, router.asPath, { locale }); 38 | }; 39 | 40 | return { t, changeLanguage, currentLanguage: router.locale || "en" }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/server/api/routers/user.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCRouter, publicProcedure } from "@/server/api/trpc"; 2 | import { TRPCError } from "@trpc/server"; 3 | import { z } from "zod"; 4 | 5 | export const userRouter = createTRPCRouter({ 6 | getById: publicProcedure 7 | .input( 8 | z.object({ 9 | id: z.string(), 10 | }), 11 | ) 12 | .query(async ({ ctx, input }) => { 13 | const user = await ctx.prisma.user.findFirst({ 14 | where: { 15 | id: input.id, 16 | }, 17 | include: { 18 | posts: { 19 | orderBy: { 20 | createdAt: "desc", 21 | }, 22 | include: { 23 | likes: true, 24 | user: true, 25 | }, 26 | }, 27 | likes: { 28 | orderBy: { 29 | createdAt: "desc", 30 | }, 31 | include: { 32 | post: { 33 | include: { 34 | user: true, 35 | likes: true, 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }); 42 | 43 | if (!user) 44 | throw new TRPCError({ 45 | code: "NOT_FOUND", 46 | }); 47 | return user; 48 | }), 49 | }); 50 | -------------------------------------------------------------------------------- /src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | import * as SliderPrimitive from "@radix-ui/react-slider"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Slider = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 19 | 20 | 21 | 22 | 23 | )); 24 | Slider.displayName = SliderPrimitive.Root.displayName; 25 | 26 | export { Slider }; 27 | -------------------------------------------------------------------------------- /src/components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "@/hooks/use-translations"; 2 | import { Pencil } from "lucide-react"; 3 | 4 | export const Footer = () => { 5 | const { t } = useTranslation(); 6 | 7 | return ( 8 |
9 |
10 |
11 | 12 |

13 | {t.footer.builtBy}{" "} 14 | 15 | William & Hagelstam LLC 16 | 17 |

18 |
19 |

20 | {t.footer.sourceCode}{" "} 21 | 27 | GitHub 28 | 29 | . 30 |

31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const HoverCard = HoverCardPrimitive.Root; 7 | 8 | const HoverCardTrigger = HoverCardPrimitive.Trigger; 9 | 10 | const HoverCardContent = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 14 | 24 | )); 25 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; 26 | 27 | export { HoverCard, HoverCardContent, HoverCardTrigger }; 28 | -------------------------------------------------------------------------------- /src/locales/Translations.ts: -------------------------------------------------------------------------------- 1 | export interface Translations { 2 | errorMessages: { 3 | error: string; 4 | createPostError: string; 5 | likeError: string; 6 | unLikeError: string; 7 | getPostsError: string; 8 | notFound: string; 9 | getProfileError: string; 10 | noPostsYet: string; 11 | noLikesYet: string; 12 | deleteError: string; 13 | pinError: string; 14 | unPinError: string; 15 | tryAgain: string; 16 | goHome: string; 17 | }; 18 | navbar: { 19 | profile: string; 20 | draw: string; 21 | language: string; 22 | theme: string; 23 | login: string; 24 | logout: string; 25 | light: string; 26 | dark: string; 27 | system: string; 28 | }; 29 | footer: { 30 | builtBy: string; 31 | sourceCode: string; 32 | }; 33 | create: { 34 | color: string; 35 | thickness: string; 36 | undo: string; 37 | redo: string; 38 | clear: string; 39 | create: string; 40 | }; 41 | home: { 42 | title: string; 43 | description: string; 44 | loadMore: string; 45 | pinned: string; 46 | linkCopied: string; 47 | orderBy: { 48 | newest: string; 49 | oldest: string; 50 | mostLiked: string; 51 | }; 52 | }; 53 | profile: { 54 | drawings: string; 55 | likedDrawings: string; 56 | joined: string; 57 | }; 58 | postMenu: { 59 | share: string; 60 | copyLink: string; 61 | delete: string; 62 | pin: string; 63 | unpin: string; 64 | deleteDialog: { 65 | title: string; 66 | description: string; 67 | cancel: string; 68 | }; 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )); 19 | Avatar.displayName = AvatarPrimitive.Root.displayName; 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )); 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )); 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 47 | 48 | export { Avatar, AvatarFallback, AvatarImage }; 49 | -------------------------------------------------------------------------------- /src/server/auth.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "@/server/db"; 2 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 3 | import { type GetServerSidePropsContext } from "next"; 4 | import { getServerSession, type NextAuthOptions } from "next-auth"; 5 | import GoogleProvider from "next-auth/providers/google"; 6 | 7 | /** 8 | * Options for NextAuth.js used to configure adapters, providers, callbacks, etc. 9 | * 10 | * @see https://next-auth.js.org/configuration/options 11 | */ 12 | export const authOptions: NextAuthOptions = { 13 | callbacks: { 14 | session({ session, user }) { 15 | if (session.user) { 16 | session.user.id = user.id; 17 | } 18 | return session; 19 | }, 20 | }, 21 | adapter: PrismaAdapter(prisma), 22 | providers: [ 23 | GoogleProvider({ 24 | clientId: process.env.GOOGLE_CLIENT_ID, 25 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 26 | }), 27 | /** 28 | * ...add more providers here. 29 | * 30 | * Most other providers require a bit more work than the Discord provider. For example, the 31 | * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account 32 | * model. Refer to the NextAuth.js docs for the provider you want to use. Example: 33 | * 34 | * @see https://next-auth.js.org/providers/github 35 | */ 36 | ], 37 | }; 38 | 39 | /** 40 | * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file. 41 | * 42 | * @see https://next-auth.js.org/configuration/nextjs 43 | */ 44 | export const getServerAuthSession = (ctx: { 45 | req: GetServerSidePropsContext["req"]; 46 | res: GetServerSidePropsContext["res"]; 47 | }) => { 48 | return getServerSession(ctx.req, ctx.res, authOptions); 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/post/user-hover-card.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 2 | import { 3 | HoverCard, 4 | HoverCardContent, 5 | HoverCardTrigger, 6 | } from "@/components/ui/hover-card"; 7 | import { useTranslation } from "@/hooks/use-translations"; 8 | import { formatUserJoinedString } from "@/lib/utils"; 9 | import type { User } from "@prisma/client"; 10 | import { CalendarDays, User as UserIcon } from "lucide-react"; 11 | 12 | interface UserHoverCardProps { 13 | user: User; 14 | children: React.ReactNode; 15 | } 16 | 17 | export const UserHoverCard = ({ user, children }: UserHoverCardProps) => { 18 | const { t, currentLanguage } = useTranslation(); 19 | 20 | return ( 21 | 22 | {children} 23 | 24 |
25 | 26 | 27 | 28 | {user.name} 29 | 30 | 31 | 32 |
33 |

{user.name}

34 |
35 | {" "} 36 | 37 | {formatUserJoinedString( 38 | t.profile.joined, 39 | currentLanguage, 40 | user.createdAt, 41 | )} 42 | 43 |
44 |
45 |
46 |
47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /src/locales/en.ts: -------------------------------------------------------------------------------- 1 | import { type Translations } from "@/locales/Translations"; 2 | 3 | export const englishTranslations: Translations = { 4 | errorMessages: { 5 | error: "Error", 6 | createPostError: "Could not post drawing", 7 | likeError: "Could not like drawing", 8 | unLikeError: "Could not unlike drawing", 9 | getPostsError: "There was an error fetching the drawings", 10 | notFound: "Page not found", 11 | getProfileError: "Could not find profile", 12 | noPostsYet: "No drawings yet", 13 | noLikesYet: "No liked drawings yet", 14 | deleteError: "Could not delete drawing", 15 | pinError: "Could not pin drawing", 16 | unPinError: "Could not unpin drawing", 17 | tryAgain: "Try again", 18 | goHome: "Go home", 19 | }, 20 | navbar: { 21 | draw: "Draw", 22 | login: "Login", 23 | logout: "Logout", 24 | profile: "Profile", 25 | theme: "Theme", 26 | language: "Language", 27 | light: "Light", 28 | dark: "Dark", 29 | system: "System", 30 | }, 31 | footer: { 32 | builtBy: "Built by", 33 | sourceCode: "The source code is available on", 34 | }, 35 | create: { 36 | create: "Create", 37 | color: "Color", 38 | thickness: "Thickness", 39 | clear: "Clear", 40 | undo: "Undo", 41 | redo: "Redo", 42 | }, 43 | home: { 44 | title: "Feed", 45 | description: "Drawings from the community", 46 | loadMore: "Load more", 47 | pinned: "Pinned", 48 | linkCopied: "Link copied to clipboard", 49 | orderBy: { 50 | newest: "Newest", 51 | oldest: "Oldest", 52 | mostLiked: "Most liked", 53 | }, 54 | }, 55 | profile: { 56 | drawings: "Drawings", 57 | likedDrawings: "Likes", 58 | joined: "Joined", 59 | }, 60 | postMenu: { 61 | share: "Share", 62 | copyLink: "Copy link", 63 | delete: "Delete", 64 | pin: "Pin to profile", 65 | unpin: "Unpin from profile", 66 | deleteDialog: { 67 | title: "Delete drawing", 68 | description: "Are you sure you want to delete this drawing?", 69 | cancel: "Cancel", 70 | }, 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /src/locales/sv.ts: -------------------------------------------------------------------------------- 1 | import { type Translations } from "@/locales/Translations"; 2 | 3 | export const swedishTranslations: Translations = { 4 | errorMessages: { 5 | error: "Fel", 6 | createPostError: "Kunde inte skapa ritningen", 7 | likeError: "Kunde inte gilla ritningen", 8 | unLikeError: "Kunde inte ogilla ritningen", 9 | getPostsError: "Det uppstod ett fel när ritningarna skulle hämtas", 10 | notFound: "Sidan inte hittades inte", 11 | getProfileError: "Kunde inte hitta profilen", 12 | noPostsYet: "Inga ritningar ännu", 13 | noLikesYet: "Inga gillade ritningar ännu", 14 | deleteError: "Kunde inte radera ritningen", 15 | pinError: "Kunde inte fästa ritningen på profilen", 16 | unPinError: "Kunde inte lossa ritningen från profilen", 17 | tryAgain: "Försök igen", 18 | goHome: "Gå hem", 19 | }, 20 | navbar: { 21 | draw: "Rita", 22 | login: "Logga in", 23 | logout: "Logga ut", 24 | profile: "Profil", 25 | theme: "Tema", 26 | language: "Språk", 27 | light: "Ljus", 28 | dark: "Mörk", 29 | system: "System", 30 | }, 31 | footer: { 32 | builtBy: "Byggd av", 33 | sourceCode: "Koden är tillgänglig på", 34 | }, 35 | create: { 36 | create: "Skapa", 37 | color: "Färg", 38 | thickness: "Tjocklek", 39 | clear: "Rensa", 40 | undo: "Ångra", 41 | redo: "Gör om", 42 | }, 43 | home: { 44 | title: "Flöde", 45 | description: "Ritningar från communityn", 46 | loadMore: "Ladda mer", 47 | pinned: "Fäst", 48 | linkCopied: "Länk kopierad till urklipp", 49 | orderBy: { 50 | newest: "Nyaste", 51 | oldest: "Äldsta", 52 | mostLiked: "Mest gillade", 53 | }, 54 | }, 55 | profile: { 56 | drawings: "Ritningar", 57 | likedDrawings: "Gillade", 58 | joined: "Gick med", 59 | }, 60 | postMenu: { 61 | share: "Dela", 62 | copyLink: "Kopiera länk", 63 | delete: "Radera", 64 | pin: "Fäst på profilen", 65 | unpin: "Lossa från profilen", 66 | deleteDialog: { 67 | title: "Radera ritning", 68 | description: "Är du säker att du vill radera denna ritning?", 69 | cancel: "Avbryt", 70 | }, 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /src/locales/fi.ts: -------------------------------------------------------------------------------- 1 | import { type Translations } from "@/locales/Translations"; 2 | 3 | export const finnishTranslations: Translations = { 4 | errorMessages: { 5 | error: "Virhe", 6 | createPostError: "Piirrustuksen lähettäminen epäonnistui", 7 | likeError: "Piirrustuksen tykkääminen epäonnistui", 8 | unLikeError: "Tykkäyksen poisto epäonnistui", 9 | getPostsError: "Piirroksien lataaminen epäonnistui", 10 | notFound: "Sivua ei löytynyt", 11 | getProfileError: "Profiilia ei löytynyt", 12 | noPostsYet: "Piirustuksia ei olla vielä luotu", 13 | noLikesYet: "Piirustuksia ei olla vielä tykätty", 14 | deleteError: "Piirustuksen poisto epäonnistui", 15 | pinError: "Piirustuksen kiinnitys epäonnistui", 16 | unPinError: "Piirustuksen irroitus epäonnistui", 17 | tryAgain: "Yritä uudelleen", 18 | goHome: "Mene kotisivulle", 19 | }, 20 | navbar: { 21 | draw: "Piirrä", 22 | login: "Kirjaudu", 23 | logout: "Kirjaudu ulos", 24 | profile: "Profiili", 25 | theme: "Teema", 26 | language: "Kieli", 27 | light: "Vaalea", 28 | dark: "Tumma", 29 | system: "Systeemi", 30 | }, 31 | footer: { 32 | builtBy: "Rakennettu", 33 | sourceCode: "Koodi on saatavilla", 34 | }, 35 | create: { 36 | create: "Luo", 37 | color: "Väri", 38 | thickness: "Paksuus", 39 | clear: "Tyhjennä", 40 | undo: "Kumoa", 41 | redo: "Tee uudelleen", 42 | }, 43 | home: { 44 | title: "Syöte", 45 | description: "Yhteisön luomia piirroksia", 46 | loadMore: "Lataa lisää", 47 | pinned: "Kiinnitetty", 48 | linkCopied: "Linkki kopioitu leikepöydälle", 49 | orderBy: { 50 | newest: "Uusimmat", 51 | oldest: "Vanhimmat", 52 | mostLiked: "Tykätyimmät", 53 | }, 54 | }, 55 | profile: { 56 | drawings: "Piirrokset", 57 | likedDrawings: "Tykätyt", 58 | joined: "Liittyi", 59 | }, 60 | postMenu: { 61 | share: "Jaa", 62 | copyLink: "Kopioi linkki", 63 | delete: "Poista", 64 | pin: "Kiinnitä profiiliin", 65 | unpin: "Poista profiilista", 66 | deleteDialog: { 67 | title: "Poista piirustus", 68 | description: "Haluatko varmasti poistaa tämän piirustuksen?", 69 | cancel: "Peruuta", 70 | }, 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 2 | import * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const Tabs = TabsPrimitive.Root; 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | TabsList.displayName = TabsPrimitive.List.displayName; 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )); 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )); 51 | TabsContent.displayName = TabsPrimitive.Content.displayName; 52 | 53 | export { Tabs, TabsContent, TabsList, TabsTrigger }; 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

Baynet

4 | 5 | actions 6 | 7 | 8 | last commit 9 | 10 | 11 | forks 12 | 13 | 14 | stars 15 | 16 | 17 | open issues 18 | 19 | 20 | license 21 | 22 |
23 | 24 | ### Demo 25 | 26 | 27 | screenshot 28 | 29 | 30 | ### Features 31 | 32 | - Feed, with pagination and sorting 33 | - Highly customizable drawing canvas 34 | - Like and share drawings 35 | - User profiles 36 | - Pinned drawings 37 | - Responsive design 38 | - Light and dark mode 39 | - English, Swedish and Finnish translations 40 | 41 | ### Get started 42 | 43 | Install dependencies: 44 | 45 | ```bash 46 | npm install 47 | ``` 48 | 49 | Create e `.env` file and fill it out as per `.env.example`: 50 | 51 | ```bash 52 | cp .env.example .env 53 | ``` 54 | 55 | Create database tables from Prisma schema: 56 | 57 | ```bash 58 | npm run db:push 59 | ``` 60 | 61 | Start the development server: 62 | 63 | ```bash 64 | npm run dev 65 | ``` 66 | 67 | ### Tech stack 68 | 69 | Baynet is built with the [T3 Stack](https://create.t3.gg/) and [ShadcnUI](https://ui.shadcn.com/). 70 | -------------------------------------------------------------------------------- /src/lib/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which 3 | * contains the Next.js App-wrapper, as well as your type-safe React Query hooks. 4 | * 5 | * We also create a few inference helpers for input and output types. 6 | */ 7 | import { type AppRouter } from "@/server/api/root"; 8 | import { httpBatchLink, loggerLink } from "@trpc/client"; 9 | import { createTRPCNext } from "@trpc/next"; 10 | import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; 11 | import superjson from "superjson"; 12 | 13 | const getBaseUrl = () => { 14 | if (typeof window !== "undefined") return ""; // browser should use relative url 15 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 16 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 17 | }; 18 | 19 | /** A set of type-safe react-query hooks for your tRPC API. */ 20 | export const api = createTRPCNext({ 21 | config() { 22 | return { 23 | /** 24 | * Transformer used for data de-serialization from the server. 25 | * 26 | * @see https://trpc.io/docs/data-transformers 27 | */ 28 | transformer: superjson, 29 | 30 | /** 31 | * Links used to determine request flow from client to server. 32 | * 33 | * @see https://trpc.io/docs/links 34 | */ 35 | links: [ 36 | loggerLink({ 37 | enabled: (opts) => 38 | process.env.NODE_ENV === "development" || 39 | (opts.direction === "down" && opts.result instanceof Error), 40 | }), 41 | httpBatchLink({ 42 | url: `${getBaseUrl()}/api/trpc`, 43 | }), 44 | ], 45 | }; 46 | }, 47 | /** 48 | * Whether tRPC should await queries when server rendering pages. 49 | * 50 | * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false 51 | */ 52 | ssr: false, 53 | }); 54 | 55 | /** 56 | * Inference helper for inputs. 57 | * 58 | * @example type HelloInput = RouterInputs['example']['hello'] 59 | */ 60 | export type RouterInputs = inferRouterInputs; 61 | 62 | /** 63 | * Inference helper for outputs. 64 | * 65 | * @example type HelloOutput = RouterOutputs['example']['hello'] 66 | */ 67 | export type RouterOutputs = inferRouterOutputs; 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "baynet", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "postinstall": "prisma generate", 9 | "lint": "next lint", 10 | "start": "next start", 11 | "format": "prettier --write .", 12 | "db:push": "prisma db push", 13 | "check:types": "tsc --noEmit", 14 | "check:format": "prettier --check .", 15 | "check:all": "npm run lint && npm run check:format && npm run check:types" 16 | }, 17 | "dependencies": { 18 | "@next-auth/prisma-adapter": "^1.0.7", 19 | "@prisma/client": "^6.0.0", 20 | "@radix-ui/react-avatar": "^1.1.1", 21 | "@radix-ui/react-dialog": "^1.1.2", 22 | "@radix-ui/react-dropdown-menu": "^2.1.2", 23 | "@radix-ui/react-hover-card": "^1.1.2", 24 | "@radix-ui/react-label": "^2.1.0", 25 | "@radix-ui/react-select": "^2.1.2", 26 | "@radix-ui/react-slider": "^1.2.1", 27 | "@radix-ui/react-slot": "^1.1.0", 28 | "@radix-ui/react-tabs": "^1.1.1", 29 | "@radix-ui/react-toast": "^1.2.2", 30 | "@tanstack/react-query": "^4.18.0", 31 | "@trpc/client": "^10.45.2", 32 | "@trpc/next": "^10.45.2", 33 | "@trpc/react-query": "^10.45.2", 34 | "@trpc/server": "^10.45.2", 35 | "@vercel/analytics": "^1.4.1", 36 | "class-variance-authority": "^0.7.1", 37 | "clsx": "^2.1.1", 38 | "dayjs": "^1.11.13", 39 | "lucide-react": "^0.462.0", 40 | "next": "^14.2.17", 41 | "next-auth": "^4.24.10", 42 | "next-themes": "^0.4.3", 43 | "react": "18.3.1", 44 | "react-dom": "18.3.1", 45 | "react-sketch-canvas": "^6.2.0", 46 | "sharp": "^0.33.5", 47 | "superjson": "2.2.1", 48 | "tailwind-merge": "^2.5.5", 49 | "tailwindcss-animate": "^1.0.7", 50 | "zod": "^3.23.8" 51 | }, 52 | "devDependencies": { 53 | "@tailwindcss/aspect-ratio": "^0.4.2", 54 | "@types/node": "^22.10.1", 55 | "@types/react": "^18.3.12", 56 | "@types/react-dom": "^18.3.1", 57 | "autoprefixer": "^10.4.20", 58 | "eslint": "^8.57.0", 59 | "eslint-config-next": "^15.0.3", 60 | "eslint-plugin-jsx-a11y": "^6.10.2", 61 | "eslint-plugin-prettier": "^5.2.1", 62 | "postcss": "^8.4.49", 63 | "prettier": "^3.4.1", 64 | "prettier-plugin-tailwindcss": "^0.6.9", 65 | "prisma": "^6.0.0", 66 | "tailwindcss": "^3.4.15", 67 | "typescript": "^5.7.2" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | relationMode = "prisma" 9 | } 10 | 11 | model Post { 12 | id String @id @default(cuid()) 13 | createdAt DateTime @default(now()) 14 | image String @db.Text 15 | user User @relation(fields: [userId], references: [id]) 16 | userId String 17 | likes Like[] 18 | pinned Boolean @default(false) 19 | 20 | @@index([userId]) 21 | } 22 | 23 | model Like { 24 | userId String 25 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 26 | postId String 27 | post Post @relation(fields: [postId], references: [id], onDelete: Cascade) 28 | createdAt DateTime @default(now()) 29 | 30 | @@id([userId, postId]) 31 | @@index([userId]) 32 | @@index([postId]) 33 | } 34 | 35 | model Account { 36 | id String @id @default(cuid()) 37 | userId String 38 | type String 39 | provider String 40 | providerAccountId String 41 | refresh_token String? @db.Text 42 | access_token String? @db.Text 43 | expires_at Int? 44 | token_type String? 45 | scope String? 46 | id_token String? @db.Text 47 | session_state String? 48 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 49 | 50 | @@unique([provider, providerAccountId]) 51 | @@index([userId]) 52 | } 53 | 54 | model Session { 55 | id String @id @default(cuid()) 56 | sessionToken String @unique 57 | userId String 58 | expires DateTime 59 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 60 | 61 | @@index([userId]) 62 | } 63 | 64 | model User { 65 | id String @id @default(cuid()) 66 | createdAt DateTime @default(now()) 67 | name String 68 | email String? @unique 69 | emailVerified DateTime? 70 | image String 71 | 72 | accounts Account[] 73 | sessions Session[] 74 | posts Post[] 75 | likes Like[] 76 | } 77 | 78 | model VerificationToken { 79 | identifier String 80 | token String @unique 81 | expires DateTime 82 | 83 | @@unique([identifier, token]) 84 | } 85 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-zinc-950 dark:focus-visible:ring-zinc-300", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-zinc-900 text-zinc-50 hover:bg-zinc-900/90 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/90", 14 | destructive: 15 | "bg-red-500 text-zinc-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/90", 16 | outline: 17 | "border border-zinc-200 bg-white hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50", 18 | secondary: 19 | "bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80", 20 | ghost: 21 | "hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50", 22 | link: "text-zinc-900 underline-offset-4 hover:underline dark:text-zinc-50", 23 | }, 24 | size: { 25 | default: "h-10 px-4 py-2", 26 | sm: "h-9 rounded-md px-3", 27 | lg: "h-11 rounded-md px-8", 28 | icon: "h-10 w-10", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | }, 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | }, 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /src/components/post/like-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { useToast } from "@/hooks/use-toast"; 3 | import { useTranslation } from "@/hooks/use-translations"; 4 | import { api } from "@/lib/api"; 5 | import { cn, kFormatter } from "@/lib/utils"; 6 | import { Like, Post } from "@prisma/client"; 7 | import { Heart, Loader2 } from "lucide-react"; 8 | import { useSession } from "next-auth/react"; 9 | import { useState } from "react"; 10 | 11 | interface LikeButtonProps { 12 | post: Post & { 13 | likes: Like[]; 14 | }; 15 | isBig?: boolean; 16 | } 17 | 18 | export const LikeButton = ({ post, isBig = false }: LikeButtonProps) => { 19 | const { data: session } = useSession(); 20 | const { toast } = useToast(); 21 | const { t } = useTranslation(); 22 | const ctx = api.useContext(); 23 | 24 | const [likeCount, setLikeCount] = useState(post.likes.length); 25 | const [isLiked, setIsLiked] = useState( 26 | !session?.user 27 | ? false 28 | : post.likes.some((like) => like.userId === session.user.id), 29 | ); 30 | 31 | const { mutate: like, isLoading: likeIsLoading } = 32 | api.like.create.useMutation({ 33 | onSuccess: () => { 34 | ctx.invalidate(); 35 | setIsLiked(true); 36 | setLikeCount((prev) => prev + 1); 37 | }, 38 | onError: () => { 39 | toast({ 40 | variant: "destructive", 41 | title: t.errorMessages.error, 42 | description: t.errorMessages.likeError, 43 | }); 44 | }, 45 | }); 46 | 47 | const { mutate: unLike, isLoading: unLikeIsLoading } = 48 | api.like.delete.useMutation({ 49 | onSuccess: () => { 50 | ctx.invalidate(); 51 | setIsLiked(false); 52 | setLikeCount((prev) => prev + -1); 53 | }, 54 | onError: () => { 55 | toast({ 56 | variant: "destructive", 57 | title: t.errorMessages.error, 58 | description: t.errorMessages.unLikeError, 59 | }); 60 | }, 61 | }); 62 | 63 | return ( 64 | 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /src/components/post/post-card.tsx: -------------------------------------------------------------------------------- 1 | import { LikeButton } from "@/components/post/like-button"; 2 | import { MoreButton } from "@/components/post/more-button"; 3 | import { UserHoverCard } from "@/components/post/user-hover-card"; 4 | import { Dialog, DialogContent } from "@/components/ui/dialog"; 5 | import { useTranslation } from "@/hooks/use-translations"; 6 | import type { Like, Post, User } from "@prisma/client"; 7 | import dayjs from "dayjs"; 8 | import relativeTime from "dayjs/plugin/relativeTime"; 9 | import { Pin } from "lucide-react"; 10 | import { useSession } from "next-auth/react"; 11 | import Image from "next/image"; 12 | import Link from "next/link"; 13 | import { useState } from "react"; 14 | 15 | dayjs.extend(relativeTime); 16 | 17 | interface PostCardProps { 18 | post: Post & { 19 | user: User; 20 | likes: Like[]; 21 | }; 22 | showPinned?: boolean; 23 | } 24 | 25 | export const PostCard = ({ post, showPinned = false }: PostCardProps) => { 26 | const [isOpen, setIsOpen] = useState(false); 27 | const { t } = useTranslation(); 28 | const { data: session } = useSession(); 29 | 30 | const adminUserIds = [ 31 | "clzo1nl2e0005137e1z9cnajv", 32 | "clzlkp5j40000ibxoiwtfhtv4", 33 | ]; 34 | 35 | const userId = session?.user.id ?? ""; 36 | const isOwner = userId === post.user.id; 37 | const isAdmin = adminUserIds.includes(userId); 38 | 39 | return ( 40 | <> 41 | 42 | 43 | Post image 50 | 51 | 52 | 53 |
54 |
55 | Post image setIsOpen(true)} 61 | className="object-cover object-top transition-all duration-500 hover:cursor-pointer group-hover:scale-105" 62 | /> 63 |
64 |
65 |
66 |
67 | 68 | 72 | {post.user.name} 73 | 74 | 75 |

76 | {`${"·"} ${dayjs(post.createdAt).fromNow()}`} 77 |

78 |
79 | 80 |
81 |
82 | 83 | {post.pinned && showPinned ? ( 84 |
85 | {" "} 86 | {t.home.pinned} 87 |
88 | ) : null} 89 |
90 |
91 |
92 | 93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /src/server/api/routers/post.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createTRPCRouter, 3 | protectedProcedure, 4 | publicProcedure, 5 | } from "@/server/api/trpc"; 6 | import { TRPCError } from "@trpc/server"; 7 | import { z } from "zod"; 8 | 9 | export const postRouter = createTRPCRouter({ 10 | getAll: publicProcedure 11 | .input( 12 | z.object({ 13 | limit: z.number(), 14 | cursor: z.string().nullish(), 15 | skip: z.number().optional(), 16 | sortBy: z.string(), 17 | }), 18 | ) 19 | .query(async ({ ctx, input }) => { 20 | const { limit, skip, cursor, sortBy } = input; 21 | 22 | let orderBy = {}; 23 | if (sortBy === "old") { 24 | orderBy = { createdAt: "asc" }; 25 | } else if (sortBy === "top") { 26 | orderBy = { likes: { _count: "desc" } }; 27 | } else { 28 | orderBy = { createdAt: "desc" }; 29 | } 30 | 31 | const posts = await ctx.prisma.post.findMany({ 32 | take: limit + 1, 33 | skip: skip, 34 | cursor: cursor ? { id: cursor } : undefined, 35 | orderBy, 36 | include: { 37 | user: true, 38 | likes: true, 39 | }, 40 | }); 41 | 42 | let nextCursor: typeof cursor | undefined = undefined; 43 | if (posts.length > limit) { 44 | const nextItem = posts.pop(); 45 | nextCursor = nextItem?.id; 46 | } 47 | 48 | return { posts, nextCursor }; 49 | }), 50 | 51 | create: protectedProcedure 52 | .input(z.object({ image: z.string() })) 53 | .mutation(async ({ ctx, input }) => { 54 | const post = await ctx.prisma.post.create({ 55 | data: { 56 | userId: ctx.session.user.id, 57 | image: input.image, 58 | }, 59 | }); 60 | return post; 61 | }), 62 | 63 | delete: protectedProcedure 64 | .input(z.object({ id: z.string() })) 65 | .mutation(async ({ ctx, input }) => { 66 | await ctx.prisma.post.delete({ 67 | where: { 68 | id: input.id, 69 | }, 70 | }); 71 | }), 72 | 73 | updatePinned: protectedProcedure 74 | .input(z.object({ id: z.string(), pinned: z.boolean() })) 75 | .mutation(async ({ ctx, input }) => { 76 | if (input.pinned) { 77 | await ctx.prisma.$transaction(async (prisma) => { 78 | const existingPinnedPost = await prisma.post.findFirst({ 79 | where: { 80 | user: { 81 | id: ctx.session.user.id, 82 | }, 83 | pinned: true, 84 | }, 85 | }); 86 | 87 | if (existingPinnedPost) { 88 | await prisma.post.update({ 89 | where: { 90 | id: existingPinnedPost.id, 91 | }, 92 | data: { 93 | pinned: false, 94 | }, 95 | }); 96 | } 97 | 98 | await prisma.post.update({ 99 | where: { 100 | id: input.id, 101 | }, 102 | data: { 103 | pinned: true, 104 | }, 105 | }); 106 | }); 107 | } else { 108 | await ctx.prisma.post.update({ 109 | where: { 110 | id: input.id, 111 | }, 112 | data: { 113 | pinned: false, 114 | }, 115 | }); 116 | } 117 | }), 118 | 119 | getById: publicProcedure 120 | .input( 121 | z.object({ 122 | id: z.string(), 123 | }), 124 | ) 125 | .query(async ({ ctx, input }) => { 126 | const post = await ctx.prisma.post.findFirst({ 127 | orderBy: { 128 | createdAt: "desc", 129 | }, 130 | where: { 131 | id: input.id, 132 | }, 133 | include: { 134 | user: true, 135 | likes: true, 136 | }, 137 | }); 138 | if (!post) 139 | throw new TRPCError({ 140 | code: "NOT_FOUND", 141 | }); 142 | return post; 143 | }), 144 | }); 145 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorPage } from "@/components/error-page"; 2 | import { Layout } from "@/components/layout"; 3 | import { LoadingCard } from "@/components/post/loading-card"; 4 | import { PostCard } from "@/components/post/post-card"; 5 | import { PostsGrid } from "@/components/post/posts-grid"; 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | Select, 9 | SelectContent, 10 | SelectItem, 11 | SelectTrigger, 12 | SelectValue, 13 | } from "@/components/ui/select"; 14 | import { useTranslation } from "@/hooks/use-translations"; 15 | import { api } from "@/lib/api"; 16 | import { Brush, Loader2 } from "lucide-react"; 17 | import { type NextPage } from "next"; 18 | import { signOut } from "next-auth/react"; 19 | import { useRouter } from "next/router"; 20 | import { Fragment, useState } from "react"; 21 | 22 | const LIMIT = 16; 23 | 24 | const Home: NextPage = () => { 25 | const { t } = useTranslation(); 26 | const router = useRouter(); 27 | const [sortBy, setSortBy] = useState("new"); 28 | 29 | const { 30 | data, 31 | isLoading, 32 | isError, 33 | fetchNextPage, 34 | hasNextPage, 35 | isFetchingNextPage, 36 | refetch, 37 | } = api.post.getAll.useInfiniteQuery( 38 | { limit: LIMIT, sortBy }, 39 | { 40 | keepPreviousData: true, 41 | getNextPageParam: (lastPage) => lastPage.nextCursor, 42 | refetchOnWindowFocus: false, 43 | }, 44 | ); 45 | 46 | if (isError) 47 | return ( 48 | 52 | 53 | 54 | ); 55 | 56 | return ( 57 | 58 |
59 |
60 |

{t.home.title}

61 |

62 | {t.home.description} 63 |

64 |
65 |
66 | 84 | 88 |
89 |
90 | 91 | {isLoading ? ( 92 | <> 93 | {Array(LIMIT) 94 | .fill(1) 95 | .map((_, idx) => ( 96 | 97 | ))} 98 | 99 | ) : ( 100 | <> 101 | {data.pages.map((page) => ( 102 | 103 | {page.posts?.map((post) => ( 104 | 105 | ))} 106 | 107 | ))} 108 | 109 | )} 110 | 111 |
112 | 122 |
123 |
124 | ); 125 | }; 126 | 127 | export default Home; 128 | -------------------------------------------------------------------------------- /src/pages/post/[id].tsx: -------------------------------------------------------------------------------- 1 | import { ErrorPage } from "@/components/error-page"; 2 | import { Layout } from "@/components/layout"; 3 | import { LikeButton } from "@/components/post/like-button"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useTranslation } from "@/hooks/use-translations"; 6 | import { api } from "@/lib/api"; 7 | import dayjs from "dayjs"; 8 | import relativeTime from "dayjs/plugin/relativeTime"; 9 | import { type NextPage } from "next"; 10 | import { signOut } from "next-auth/react"; 11 | import Image from "next/image"; 12 | import Link from "next/link"; 13 | import { useRouter } from "next/router"; 14 | 15 | dayjs.extend(relativeTime); 16 | 17 | const Profile: NextPage = () => { 18 | const { t } = useTranslation(); 19 | const router = useRouter(); 20 | 21 | const { 22 | data: post, 23 | isLoading, 24 | isError, 25 | error, 26 | } = api.post.getById.useQuery( 27 | { 28 | id: String(router.query.id), 29 | }, 30 | { 31 | retry(_failureCount, error) { 32 | if (error.data?.code === "NOT_FOUND") return false; 33 | return true; 34 | }, 35 | }, 36 | ); 37 | 38 | if (isError) 39 | return ( 40 | 48 | {error.data?.code === "NOT_FOUND" ? ( 49 | 52 | ) : ( 53 | 54 | )} 55 | 56 | ); 57 | 58 | return ( 59 | 60 |
61 |
62 |
63 | {isLoading ? ( 64 |
65 | ) : ( 66 | Post image 73 | )} 74 |
75 |
76 |
77 | {isLoading ? ( 78 |
79 | ) : ( 80 | User profile 87 | )} 88 |
89 |
90 | {isLoading ? ( 91 | <> 92 |
93 |
94 | 95 | ) : ( 96 | <> 97 | 101 | {post?.user.name} 102 | 103 |

{`${dayjs( 104 | post.createdAt, 105 | ).fromNow()}`}

106 | 107 | )} 108 |
109 |
110 |
111 | {!isLoading && } 112 |
113 |
114 |
115 | 116 | ); 117 | }; 118 | 119 | export default Profile; 120 | -------------------------------------------------------------------------------- /src/server/api/trpc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: 3 | * 1. You want to modify request context (see Part 1). 4 | * 2. You want to create a new middleware or type of procedure (see Part 3). 5 | * 6 | * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will 7 | * need to use are documented accordingly near the end. 8 | */ 9 | 10 | /** 11 | * 1. CONTEXT 12 | * 13 | * This section defines the "contexts" that are available in the backend API. 14 | * 15 | * These allow you to access things when processing a request, like the database, the session, etc. 16 | */ 17 | import { getServerAuthSession } from "@/server/auth"; 18 | import { prisma } from "@/server/db"; 19 | import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; 20 | import { type Session } from "next-auth"; 21 | 22 | type CreateContextOptions = { 23 | session: Session | null; 24 | }; 25 | 26 | /** 27 | * This helper generates the "internals" for a tRPC context. If you need to use it, you can export 28 | * it from here. 29 | * 30 | * Examples of things you may need it for: 31 | * - testing, so we don't have to mock Next.js' req/res 32 | * - tRPC's `createSSGHelpers`, where we don't have req/res 33 | * 34 | * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts 35 | */ 36 | const createInnerTRPCContext = (opts: CreateContextOptions) => { 37 | return { 38 | session: opts.session, 39 | prisma, 40 | }; 41 | }; 42 | 43 | /** 44 | * This is the actual context you will use in your router. It will be used to process every request 45 | * that goes through your tRPC endpoint. 46 | * 47 | * @see https://trpc.io/docs/context 48 | */ 49 | export const createTRPCContext = async (opts: CreateNextContextOptions) => { 50 | const { req, res } = opts; 51 | 52 | // Get the session from the server using the getServerSession wrapper function 53 | const session = await getServerAuthSession({ req, res }); 54 | 55 | return createInnerTRPCContext({ 56 | session, 57 | }); 58 | }; 59 | 60 | /** 61 | * 2. INITIALIZATION 62 | * 63 | * This is where the tRPC API is initialized, connecting the context and transformer. We also parse 64 | * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation 65 | * errors on the backend. 66 | */ 67 | import { initTRPC, TRPCError } from "@trpc/server"; 68 | import superjson from "superjson"; 69 | import { ZodError } from "zod"; 70 | 71 | const t = initTRPC.context().create({ 72 | transformer: superjson, 73 | errorFormatter({ shape, error }) { 74 | return { 75 | ...shape, 76 | data: { 77 | ...shape.data, 78 | zodError: 79 | error.cause instanceof ZodError ? error.cause.flatten() : null, 80 | }, 81 | }; 82 | }, 83 | }); 84 | 85 | /** 86 | * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) 87 | * 88 | * These are the pieces you use to build your tRPC API. You should import these a lot in the 89 | * "/src/server/api/routers" directory. 90 | */ 91 | 92 | /** 93 | * This is how you create new routers and sub-routers in your tRPC API. 94 | * 95 | * @see https://trpc.io/docs/router 96 | */ 97 | export const createTRPCRouter = t.router; 98 | 99 | /** 100 | * Public (unauthenticated) procedure 101 | * 102 | * This is the base piece you use to build new queries and mutations on your tRPC API. It does not 103 | * guarantee that a user querying is authorized, but you can still access user session data if they 104 | * are logged in. 105 | */ 106 | export const publicProcedure = t.procedure; 107 | 108 | /** Reusable middleware that enforces users are logged in before running the procedure. */ 109 | const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { 110 | if (!ctx.session || !ctx.session.user) { 111 | throw new TRPCError({ code: "UNAUTHORIZED" }); 112 | } 113 | return next({ 114 | ctx: { 115 | // infers the `session` as non-nullable 116 | session: { ...ctx.session, user: ctx.session.user }, 117 | }, 118 | }); 119 | }); 120 | 121 | /** 122 | * Protected (authenticated) procedure 123 | * 124 | * If you want a query or mutation to ONLY be accessible to logged in users, use this. It verifies 125 | * the session is valid and guarantees `ctx.session.user` is not null. 126 | * 127 | * @see https://trpc.io/docs/procedures 128 | */ 129 | export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); 130 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 2 | import { X } from "lucide-react"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Dialog = DialogPrimitive.Root; 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger; 10 | 11 | const DialogPortal = DialogPrimitive.Portal; 12 | 13 | const DialogClose = DialogPrimitive.Close; 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )); 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )); 52 | DialogContent.displayName = DialogPrimitive.Content.displayName; 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ); 66 | DialogHeader.displayName = "DialogHeader"; 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ); 80 | DialogFooter.displayName = "DialogFooter"; 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )); 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )); 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 108 | 109 | export { 110 | Dialog, 111 | DialogClose, 112 | DialogContent, 113 | DialogDescription, 114 | DialogFooter, 115 | DialogHeader, 116 | DialogOverlay, 117 | DialogPortal, 118 | DialogTitle, 119 | DialogTrigger, 120 | }; 121 | -------------------------------------------------------------------------------- /src/hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | // Inspired by react-hot-toast library 2 | import * as React from "react"; 3 | 4 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; 5 | 6 | const TOAST_LIMIT = 1; 7 | const TOAST_REMOVE_DELAY = 1000000; 8 | 9 | type ToasterToast = ToastProps & { 10 | id: string; 11 | title?: React.ReactNode; 12 | description?: React.ReactNode; 13 | action?: ToastActionElement; 14 | }; 15 | 16 | enum ActionType { 17 | ADD_TOAST = "ADD_TOAST", 18 | UPDATE_TOAST = "UPDATE_TOAST", 19 | DISMISS_TOAST = "DISMISS_TOAST", 20 | REMOVE_TOAST = "REMOVE_TOAST", 21 | } 22 | 23 | let count = 0; 24 | 25 | function genId() { 26 | count = (count + 1) % Number.MAX_SAFE_INTEGER; 27 | return count.toString(); 28 | } 29 | 30 | type Action = 31 | | { 32 | type: ActionType.ADD_TOAST; 33 | toast: ToasterToast; 34 | } 35 | | { 36 | type: ActionType.UPDATE_TOAST; 37 | toast: Partial; 38 | } 39 | | { 40 | type: ActionType.DISMISS_TOAST; 41 | toastId?: ToasterToast["id"]; 42 | } 43 | | { 44 | type: ActionType.REMOVE_TOAST; 45 | toastId?: ToasterToast["id"]; 46 | }; 47 | 48 | interface State { 49 | toasts: ToasterToast[]; 50 | } 51 | 52 | const toastTimeouts = new Map>(); 53 | 54 | const addToRemoveQueue = (toastId: string) => { 55 | if (toastTimeouts.has(toastId)) { 56 | return; 57 | } 58 | 59 | const timeout = setTimeout(() => { 60 | toastTimeouts.delete(toastId); 61 | dispatch({ 62 | type: ActionType.REMOVE_TOAST, 63 | toastId: toastId, 64 | }); 65 | }, TOAST_REMOVE_DELAY); 66 | 67 | toastTimeouts.set(toastId, timeout); 68 | }; 69 | 70 | export const reducer = (state: State, action: Action): State => { 71 | switch (action.type) { 72 | case ActionType.ADD_TOAST: 73 | return { 74 | ...state, 75 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 76 | }; 77 | 78 | case ActionType.UPDATE_TOAST: 79 | return { 80 | ...state, 81 | toasts: state.toasts.map((t) => 82 | t.id === action.toast.id ? { ...t, ...action.toast } : t, 83 | ), 84 | }; 85 | 86 | case ActionType.DISMISS_TOAST: { 87 | const { toastId } = action; 88 | 89 | // ! Side effects ! - This could be extracted into a dismissToast() action, 90 | // but I'll keep it here for simplicity 91 | if (toastId) { 92 | addToRemoveQueue(toastId); 93 | } else { 94 | state.toasts.forEach((toast) => { 95 | addToRemoveQueue(toast.id); 96 | }); 97 | } 98 | 99 | return { 100 | ...state, 101 | toasts: state.toasts.map((t) => 102 | t.id === toastId || toastId === undefined 103 | ? { 104 | ...t, 105 | open: false, 106 | } 107 | : t, 108 | ), 109 | }; 110 | } 111 | case ActionType.REMOVE_TOAST: 112 | if (action.toastId === undefined) { 113 | return { 114 | ...state, 115 | toasts: [], 116 | }; 117 | } 118 | return { 119 | ...state, 120 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 121 | }; 122 | } 123 | }; 124 | 125 | const listeners: Array<(state: State) => void> = []; 126 | 127 | let memoryState: State = { toasts: [] }; 128 | 129 | function dispatch(action: Action) { 130 | memoryState = reducer(memoryState, action); 131 | listeners.forEach((listener) => { 132 | listener(memoryState); 133 | }); 134 | } 135 | 136 | type Toast = Omit; 137 | 138 | function toast({ ...props }: Toast) { 139 | const id = genId(); 140 | 141 | const update = (props: ToasterToast) => 142 | dispatch({ 143 | type: ActionType.UPDATE_TOAST, 144 | toast: { ...props, id }, 145 | }); 146 | const dismiss = () => 147 | dispatch({ type: ActionType.DISMISS_TOAST, toastId: id }); 148 | 149 | dispatch({ 150 | type: ActionType.ADD_TOAST, 151 | toast: { 152 | ...props, 153 | id, 154 | open: true, 155 | onOpenChange: (open) => { 156 | if (!open) dismiss(); 157 | }, 158 | }, 159 | }); 160 | 161 | return { 162 | id: id, 163 | dismiss, 164 | update, 165 | }; 166 | } 167 | 168 | function useToast() { 169 | const [state, setState] = React.useState(memoryState); 170 | 171 | React.useEffect(() => { 172 | listeners.push(setState); 173 | return () => { 174 | const index = listeners.indexOf(setState); 175 | if (index > -1) { 176 | listeners.splice(index, 1); 177 | } 178 | }; 179 | }, [state]); 180 | 181 | return { 182 | ...state, 183 | toast, 184 | dismiss: (toastId?: string) => 185 | dispatch({ type: ActionType.DISMISS_TOAST, toastId }), 186 | }; 187 | } 188 | 189 | export { toast, useToast }; 190 | -------------------------------------------------------------------------------- /src/components/layout/user-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuPortal, 8 | DropdownMenuSeparator, 9 | DropdownMenuSub, 10 | DropdownMenuSubContent, 11 | DropdownMenuSubTrigger, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | import { useTranslation } from "@/hooks/use-translations"; 15 | import { Brush, Globe, Laptop, LogOut, Moon, Sun, User } from "lucide-react"; 16 | import { signIn, signOut, useSession } from "next-auth/react"; 17 | import { useTheme } from "next-themes"; 18 | import { useRouter } from "next/router"; 19 | 20 | export const UserAvatar = () => { 21 | const router = useRouter(); 22 | const { t, changeLanguage } = useTranslation(); 23 | const { setTheme } = useTheme(); 24 | const { data: session } = useSession(); 25 | 26 | if (!session?.user) 27 | return ( 28 | 31 | ); 32 | 33 | return ( 34 | 35 | 36 | 37 | {session.user.image ? ( 38 | 39 | ) : ( 40 | 41 | {session.user.name} 42 | 43 | 44 | )} 45 | 46 | 47 | 48 |
49 |
50 |

{session.user.name}

51 |

52 | {session.user.email} 53 |

54 |
55 |
56 | 57 | { 59 | e.preventDefault(); 60 | router.push(`/user/${session.user.id}`); 61 | }} 62 | > 63 | 64 | {t.navbar.profile} 65 | 66 | { 68 | e.preventDefault(); 69 | router.push("/create"); 70 | }} 71 | > 72 | 73 | {t.navbar.draw} 74 | 75 | 76 | 77 | 78 | 79 | {t.navbar.language} 80 | 81 | 82 | 83 | changeLanguage("en")}> 84 | English 85 | 86 | changeLanguage("sv")}> 87 | Svenska 88 | 89 | changeLanguage("fi")}> 90 | Suomi 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | {t.navbar.theme} 100 | 101 | 102 | 103 | setTheme("light")}> 104 | 105 | {t.navbar.light} 106 | 107 | setTheme("dark")}> 108 | 109 | {t.navbar.dark} 110 | 111 | setTheme("system")}> 112 | 113 | {t.navbar.system} 114 | 115 | 116 | 117 | 118 | 119 | { 122 | e.preventDefault(); 123 | signOut(); 124 | }} 125 | > 126 | 127 | {t.navbar.logout} 128 | 129 |
130 |
131 | ); 132 | }; 133 | -------------------------------------------------------------------------------- /src/pages/create.tsx: -------------------------------------------------------------------------------- 1 | import { Layout } from "@/components/layout"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Label } from "@/components/ui/label"; 5 | import { Slider } from "@/components/ui/slider"; 6 | import { useToast } from "@/hooks/use-toast"; 7 | import { useTranslation } from "@/hooks/use-translations"; 8 | import { api } from "@/lib/api"; 9 | import { Loader2, Redo, Trash, Undo } from "lucide-react"; 10 | import { type NextPage } from "next"; 11 | import { signIn, useSession } from "next-auth/react"; 12 | import { useRouter } from "next/router"; 13 | import { createRef, useEffect, useState } from "react"; 14 | import { 15 | ReactSketchCanvas, 16 | type ReactSketchCanvasRef, 17 | } from "react-sketch-canvas"; 18 | 19 | const INITIAL_STROKE_WIDTH = 10; 20 | 21 | const Create: NextPage = () => { 22 | const { data: session, status } = useSession(); 23 | if (!session?.user && status !== "loading") { 24 | signIn("google"); 25 | } 26 | 27 | const { t } = useTranslation(); 28 | const { toast } = useToast(); 29 | const router = useRouter(); 30 | const ctx = api.useContext(); 31 | 32 | const canvasRef = createRef(); 33 | const [color, setColor] = useState("#000000"); 34 | const [width, setWidth] = useState(INITIAL_STROKE_WIDTH); 35 | const [isEmpty, setIsEmpty] = useState(true); 36 | 37 | const { mutate, isLoading } = api.post.create.useMutation({ 38 | onError: () => { 39 | toast({ 40 | variant: "destructive", 41 | title: t.errorMessages.error, 42 | description: t.errorMessages.createPostError, 43 | }); 44 | }, 45 | onSuccess: () => { 46 | ctx.invalidate(); 47 | router.push("/"); 48 | }, 49 | }); 50 | 51 | const handleCreate = async () => { 52 | const exportImage = canvasRef.current?.exportImage; 53 | if (exportImage) { 54 | const image = await exportImage("png"); 55 | mutate({ image }); 56 | } 57 | }; 58 | 59 | useEffect(() => { 60 | const shortcut = (e: KeyboardEvent) => { 61 | if (e.key === "z" && e.ctrlKey) { 62 | canvasRef.current?.undo(); 63 | } else if (e.key === "y" && e.ctrlKey) { 64 | canvasRef.current?.redo(); 65 | } 66 | }; 67 | 68 | document.addEventListener("keydown", shortcut); 69 | return () => document.removeEventListener("keydown", shortcut); 70 | }); 71 | 72 | return ( 73 | 74 |
75 |
76 | setIsEmpty(false)} 86 | /> 87 |
88 |
89 |
90 | 91 |
92 | 99 | setColor(e.target.value)} 104 | /> 105 |
106 |
107 |
108 | 109 | setWidth(e[0])} 115 | /> 116 |
117 |
118 | 122 | 126 | 136 |
137 |
138 | 142 |
143 |
144 |
145 |
146 | ); 147 | }; 148 | 149 | export default Create; 150 | -------------------------------------------------------------------------------- /src/pages/user/[id].tsx: -------------------------------------------------------------------------------- 1 | import { ErrorPage } from "@/components/error-page"; 2 | import { Layout } from "@/components/layout"; 3 | import { LoadingCard } from "@/components/post/loading-card"; 4 | import { PostCard } from "@/components/post/post-card"; 5 | import { PostsGrid } from "@/components/post/posts-grid"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 8 | import { useTranslation } from "@/hooks/use-translations"; 9 | import { api } from "@/lib/api"; 10 | import { formatUserJoinedString } from "@/lib/utils"; 11 | import { type NextPage } from "next"; 12 | import { signOut } from "next-auth/react"; 13 | import Image from "next/image"; 14 | import { useRouter } from "next/router"; 15 | 16 | const LIMIT = 8; 17 | 18 | const Profile: NextPage = () => { 19 | const { t, currentLanguage } = useTranslation(); 20 | const router = useRouter(); 21 | 22 | const { 23 | data: user, 24 | isLoading, 25 | isError, 26 | error, 27 | } = api.user.getById.useQuery( 28 | { 29 | id: String(router.query.id), 30 | }, 31 | { 32 | retry(_failureCount, error) { 33 | if (error.data?.code === "NOT_FOUND") return false; 34 | return true; 35 | }, 36 | }, 37 | ); 38 | 39 | if (isError) 40 | return ( 41 | 49 | {error.data?.code === "NOT_FOUND" ? ( 50 | 53 | ) : ( 54 | 55 | )} 56 | 57 | ); 58 | 59 | return ( 60 | 61 |
62 | {isLoading ? ( 63 | <> 64 |
65 |
66 |
67 |
68 |
69 | 70 | ) : ( 71 | <> 72 | User profile 79 |
80 |

{user.name}

81 |

82 | {formatUserJoinedString( 83 | t.profile.joined, 84 | currentLanguage, 85 | user.createdAt, 86 | )} 87 |

88 |
89 | 90 | )} 91 |
92 | 93 | 94 | {t.profile.drawings} 95 | {t.profile.likedDrawings} 96 | 97 | 98 | {!isLoading && user.posts?.length === 0 && ( 99 |

100 | {t.errorMessages.noPostsYet} 101 |

102 | )} 103 | 104 | {isLoading ? ( 105 | <> 106 | {Array(LIMIT) 107 | .fill(1) 108 | .map((_, idx) => ( 109 | 110 | ))} 111 | 112 | ) : ( 113 | <> 114 | {user.posts 115 | .sort((a, b) => Number(b.pinned) - Number(a.pinned)) 116 | .map((post) => ( 117 | 118 | ))} 119 | 120 | )} 121 | 122 |
123 | 124 | {!isLoading && user.likes?.length === 0 && ( 125 |

126 | {t.errorMessages.noLikesYet} 127 |

128 | )} 129 | 130 | {isLoading ? ( 131 | <> 132 | {Array(LIMIT) 133 | .fill(1) 134 | .map((_, idx) => ( 135 | 136 | ))} 137 | 138 | ) : ( 139 | <> 140 | {user.likes.map((like) => ( 141 | 145 | ))} 146 | 147 | )} 148 | 149 |
150 |
151 | 152 | ); 153 | }; 154 | 155 | export default Profile; 156 | -------------------------------------------------------------------------------- /src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | import * as ToastPrimitives from "@radix-ui/react-toast"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import { X } from "lucide-react"; 4 | import * as React from "react"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const ToastProvider = ToastPrimitives.Provider; 9 | 10 | const ToastViewport = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName; 24 | 25 | const toastVariants = cva( 26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-zinc-200 p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-zinc-800", 27 | { 28 | variants: { 29 | variant: { 30 | default: 31 | "border bg-white text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50", 32 | destructive: 33 | "destructive group border-red-500 bg-red-500 text-zinc-50 dark:border-red-900 dark:bg-red-900 dark:text-zinc-50", 34 | }, 35 | }, 36 | defaultVariants: { 37 | variant: "default", 38 | }, 39 | }, 40 | ); 41 | 42 | const Toast = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef & 45 | VariantProps 46 | >(({ className, variant, ...props }, ref) => { 47 | return ( 48 | 53 | ); 54 | }); 55 | Toast.displayName = ToastPrimitives.Root.displayName; 56 | 57 | const ToastAction = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 69 | )); 70 | ToastAction.displayName = ToastPrimitives.Action.displayName; 71 | 72 | const ToastClose = React.forwardRef< 73 | React.ElementRef, 74 | React.ComponentPropsWithoutRef 75 | >(({ className, ...props }, ref) => ( 76 | 85 | 86 | 87 | )); 88 | ToastClose.displayName = ToastPrimitives.Close.displayName; 89 | 90 | const ToastTitle = React.forwardRef< 91 | React.ElementRef, 92 | React.ComponentPropsWithoutRef 93 | >(({ className, ...props }, ref) => ( 94 | 99 | )); 100 | ToastTitle.displayName = ToastPrimitives.Title.displayName; 101 | 102 | const ToastDescription = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )); 112 | ToastDescription.displayName = ToastPrimitives.Description.displayName; 113 | 114 | type ToastProps = React.ComponentPropsWithoutRef; 115 | 116 | type ToastActionElement = React.ReactElement; 117 | 118 | export { 119 | Toast, 120 | ToastAction, 121 | ToastClose, 122 | ToastDescription, 123 | ToastProvider, 124 | ToastTitle, 125 | ToastViewport, 126 | type ToastActionElement, 127 | type ToastProps, 128 | }; 129 | -------------------------------------------------------------------------------- /src/components/post/more-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Dialog, 4 | DialogContent, 5 | DialogDescription, 6 | DialogFooter, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "@/components/ui/dialog"; 10 | import { 11 | DropdownMenu, 12 | DropdownMenuContent, 13 | DropdownMenuItem, 14 | DropdownMenuSeparator, 15 | DropdownMenuTrigger, 16 | } from "@/components/ui/dropdown-menu"; 17 | import { useToast } from "@/hooks/use-toast"; 18 | import { useTranslation } from "@/hooks/use-translations"; 19 | import { api } from "@/lib/api"; 20 | import { Post } from "@prisma/client"; 21 | import { 22 | Link2, 23 | Loader2, 24 | MoreHorizontal, 25 | Pin, 26 | PinOff, 27 | Trash, 28 | } from "lucide-react"; 29 | import { useState } from "react"; 30 | 31 | interface MoreButtonProps { 32 | post: Post; 33 | isOwner: boolean; 34 | } 35 | 36 | export const MoreButton = ({ post, isOwner }: MoreButtonProps) => { 37 | const { t } = useTranslation(); 38 | const { toast } = useToast(); 39 | const ctx = api.useContext(); 40 | const [deleteIsOpen, setDeleteIsOpen] = useState(false); 41 | const [menuIsOpen, setMenuIsOpen] = useState(false); 42 | 43 | const { mutate: deletePost, isLoading: deleteIsLoading } = 44 | api.post.delete.useMutation({ 45 | onSuccess: () => { 46 | ctx.invalidate(); 47 | setDeleteIsOpen(false); 48 | }, 49 | onError: () => { 50 | setDeleteIsOpen(false); 51 | toast({ 52 | variant: "destructive", 53 | title: t.errorMessages.error, 54 | description: t.errorMessages.deleteError, 55 | }); 56 | }, 57 | }); 58 | 59 | const { mutate: pinPost, isLoading: pinIsLoading } = 60 | api.post.updatePinned.useMutation({ 61 | onSuccess: () => { 62 | ctx.invalidate(); 63 | setMenuIsOpen(false); 64 | }, 65 | onError: () => { 66 | setMenuIsOpen(false); 67 | toast({ 68 | variant: "destructive", 69 | title: t.errorMessages.error, 70 | description: post.pinned 71 | ? t.errorMessages.unPinError 72 | : t.errorMessages.pinError, 73 | }); 74 | }, 75 | }); 76 | 77 | return ( 78 | <> 79 | 80 | 81 | 82 | {t.postMenu.deleteDialog.title} 83 | 84 | {t.postMenu.deleteDialog.description} 85 | 86 | 87 | 88 | 91 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 119 | 120 | 121 | { 124 | e.preventDefault(); 125 | navigator.clipboard.writeText( 126 | `${window.location.origin}/post/${post.id}`, 127 | ); 128 | toast({ 129 | description: t.home.linkCopied, 130 | }); 131 | setMenuIsOpen(false); 132 | }} 133 | > 134 | 135 | {t.postMenu.copyLink} 136 | 137 | {isOwner ? ( 138 | <> 139 | pinPost({ id: post.id, pinned: !post.pinned })} 143 | onSelect={(e) => { 144 | e.preventDefault(); 145 | pinPost({ id: post.id, pinned: !post.pinned }); 146 | }} 147 | > 148 | {pinIsLoading ? ( 149 | 150 | ) : ( 151 | <> 152 | {post.pinned ? ( 153 | 154 | ) : ( 155 | 156 | )} 157 | 158 | )} 159 | {post.pinned ? t.postMenu.unpin : t.postMenu.pin} 160 | 161 | 162 | { 165 | e.preventDefault(); 166 | setDeleteIsOpen(true); 167 | }} 168 | > 169 | 170 | {t.postMenu.delete} 171 | 172 | 173 | ) : null} 174 | 175 | 176 | 177 | ); 178 | }; 179 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as SelectPrimitive from "@radix-ui/react-select"; 2 | import { Check, ChevronDown, ChevronUp } from "lucide-react"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const Select = SelectPrimitive.Root; 8 | 9 | const SelectGroup = SelectPrimitive.Group; 10 | 11 | const SelectValue = SelectPrimitive.Value; 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, children, ...props }, ref) => ( 17 | span]:line-clamp-1", 21 | className, 22 | )} 23 | {...props} 24 | > 25 | {children} 26 | 27 | 28 | 29 | 30 | )); 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; 32 | 33 | const SelectScrollUpButton = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | 46 | 47 | )); 48 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; 49 | 50 | const SelectScrollDownButton = React.forwardRef< 51 | React.ElementRef, 52 | React.ComponentPropsWithoutRef 53 | >(({ className, ...props }, ref) => ( 54 | 62 | 63 | 64 | )); 65 | SelectScrollDownButton.displayName = 66 | SelectPrimitive.ScrollDownButton.displayName; 67 | 68 | const SelectContent = React.forwardRef< 69 | React.ElementRef, 70 | React.ComponentPropsWithoutRef 71 | >(({ className, children, position = "popper", ...props }, ref) => ( 72 | 73 | 84 | 85 | 92 | {children} 93 | 94 | 95 | 96 | 97 | )); 98 | SelectContent.displayName = SelectPrimitive.Content.displayName; 99 | 100 | const SelectLabel = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 109 | )); 110 | SelectLabel.displayName = SelectPrimitive.Label.displayName; 111 | 112 | const SelectItem = React.forwardRef< 113 | React.ElementRef, 114 | React.ComponentPropsWithoutRef 115 | >(({ className, children, ...props }, ref) => ( 116 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | {children} 131 | 132 | )); 133 | SelectItem.displayName = SelectPrimitive.Item.displayName; 134 | 135 | const SelectSeparator = React.forwardRef< 136 | React.ElementRef, 137 | React.ComponentPropsWithoutRef 138 | >(({ className, ...props }, ref) => ( 139 | 144 | )); 145 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName; 146 | 147 | export { 148 | Select, 149 | SelectContent, 150 | SelectGroup, 151 | SelectItem, 152 | SelectLabel, 153 | SelectScrollDownButton, 154 | SelectScrollUpButton, 155 | SelectSeparator, 156 | SelectTrigger, 157 | SelectValue, 158 | }; 159 | -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; 2 | import { Check, ChevronRight, Circle } from "lucide-react"; 3 | import * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root; 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group; 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub; 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef, 21 | React.ComponentPropsWithoutRef & { 22 | inset?: boolean; 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | 34 | {children} 35 | 36 | 37 | )); 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName; 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 53 | )); 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName; 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | 62 | 71 | 72 | )); 73 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; 74 | 75 | const DropdownMenuItem = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef & { 78 | inset?: boolean; 79 | } 80 | >(({ className, inset, ...props }, ref) => ( 81 | 90 | )); 91 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; 92 | 93 | const DropdownMenuCheckboxItem = React.forwardRef< 94 | React.ElementRef, 95 | React.ComponentPropsWithoutRef 96 | >(({ className, children, checked, ...props }, ref) => ( 97 | 106 | 107 | 108 | 109 | 110 | 111 | {children} 112 | 113 | )); 114 | DropdownMenuCheckboxItem.displayName = 115 | DropdownMenuPrimitive.CheckboxItem.displayName; 116 | 117 | const DropdownMenuRadioItem = React.forwardRef< 118 | React.ElementRef, 119 | React.ComponentPropsWithoutRef 120 | >(({ className, children, ...props }, ref) => ( 121 | 129 | 130 | 131 | 132 | 133 | 134 | {children} 135 | 136 | )); 137 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; 138 | 139 | const DropdownMenuLabel = React.forwardRef< 140 | React.ElementRef, 141 | React.ComponentPropsWithoutRef & { 142 | inset?: boolean; 143 | } 144 | >(({ className, inset, ...props }, ref) => ( 145 | 154 | )); 155 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; 156 | 157 | const DropdownMenuSeparator = React.forwardRef< 158 | React.ElementRef, 159 | React.ComponentPropsWithoutRef 160 | >(({ className, ...props }, ref) => ( 161 | 166 | )); 167 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; 168 | 169 | const DropdownMenuShortcut = ({ 170 | className, 171 | ...props 172 | }: React.HTMLAttributes) => { 173 | return ( 174 | 178 | ); 179 | }; 180 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; 181 | 182 | export { 183 | DropdownMenu, 184 | DropdownMenuCheckboxItem, 185 | DropdownMenuContent, 186 | DropdownMenuGroup, 187 | DropdownMenuItem, 188 | DropdownMenuLabel, 189 | DropdownMenuPortal, 190 | DropdownMenuRadioGroup, 191 | DropdownMenuRadioItem, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuSub, 195 | DropdownMenuSubContent, 196 | DropdownMenuSubTrigger, 197 | DropdownMenuTrigger, 198 | }; 199 | --------------------------------------------------------------------------------