├── 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 |
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 |
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 |
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 |
23 |
24 | ### Demo
25 |
26 |
27 |
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 |
52 |
53 |
54 |
55 | 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 |
73 | )}
74 |
75 |
76 |
77 | {isLoading ? (
78 |
79 | ) : (
80 |
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 |
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 |
69 | >
70 | ) : (
71 | <>
72 |
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 |
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 |
--------------------------------------------------------------------------------