├── .env
├── .gitattributes
├── .prettierignore
├── prisma
├── README.md
└── postgresql
│ └── migrations
│ ├── 20231026031107_add_authenticator_field_name
│ └── migration.sql
│ ├── 20231021115443_add_paste_field_type
│ └── migration.sql
│ ├── migration_lock.toml
│ ├── 20231021123408_add_fields
│ └── migration.sql
│ ├── 20231024065713_add_web_authn_fields
│ └── migration.sql
│ └── 20231026133156_recreate_next_auth_tables
│ └── migration.sql
├── app
├── favicon.ico
├── [locale]
│ ├── (app)
│ │ ├── dashboard
│ │ │ ├── snippets
│ │ │ │ ├── _components
│ │ │ │ │ ├── snippet.module.scss
│ │ │ │ │ ├── button.tsx
│ │ │ │ │ ├── modal.tsx
│ │ │ │ │ └── snippet.tsx
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── _components
│ │ │ │ ├── shell.tsx
│ │ │ │ ├── header.tsx
│ │ │ │ ├── skeleton.tsx
│ │ │ │ └── aside-nav.tsx
│ │ │ ├── settings
│ │ │ │ ├── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── @profile
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── loading.tsx
│ │ │ │ └── _components
│ │ │ │ │ ├── app-settings.tsx
│ │ │ │ │ └── button.tsx
│ │ │ ├── notifications
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ └── layout.tsx
│ │ ├── v
│ │ │ └── [cuid]
│ │ │ │ ├── _components
│ │ │ │ ├── shiki
│ │ │ │ │ ├── shiki.scss
│ │ │ │ │ ├── LineNumbers.tsx
│ │ │ │ │ └── Header.tsx
│ │ │ │ ├── CodePreviewIntlProvider.tsx
│ │ │ │ └── CodePreview.module.scss
│ │ │ │ └── raw
│ │ │ │ └── route.ts
│ │ ├── about
│ │ │ └── page.tsx
│ │ ├── _components
│ │ │ ├── home
│ │ │ │ ├── form.tsx
│ │ │ │ └── Announcement.tsx
│ │ │ └── layout
│ │ │ │ └── header
│ │ │ │ └── Navigation.tsx
│ │ ├── page.tsx
│ │ └── layout.tsx
│ ├── (auth)
│ │ ├── auth
│ │ │ ├── error
│ │ │ │ ├── page.tsx
│ │ │ │ └── _components
│ │ │ │ │ └── preview.tsx
│ │ │ ├── password
│ │ │ │ └── reset
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ ├── _components
│ │ │ │ │ └── form.tsx
│ │ │ │ │ └── [token]
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── _components
│ │ │ │ │ └── form.tsx
│ │ │ ├── signin
│ │ │ │ └── _components
│ │ │ │ │ └── OAuthProvider.tsx
│ │ │ └── signup
│ │ │ │ └── page.tsx
│ │ ├── _components
│ │ │ └── layouts
│ │ │ │ ├── logo.tsx
│ │ │ │ ├── content.tsx
│ │ │ │ └── go-back-button.tsx
│ │ ├── layout.module.scss
│ │ └── layout.tsx
│ └── layout.tsx
├── api
│ ├── auth
│ │ ├── [...nextauth]
│ │ │ └── route.ts
│ │ └── signup
│ │ │ └── _route.ts
│ ├── task
│ │ └── route.ts
│ └── user
│ │ └── authenticators
│ │ └── route.ts
├── layout.tsx
├── not-found.tsx
└── error.tsx
├── .husky
├── pre-commit
└── commit-msg
├── enums
├── user.ts
├── paste.ts
├── app.ts
└── response.ts
├── .prettierrc.js
├── config
├── app.tsx
└── dashboard.tsx
├── libs
├── auth
│ ├── index.ts
│ ├── providers.tsx
│ └── config
│ │ └── edge.ts
├── requests.ts
├── framer
│ └── shaking.ts
├── validation
│ └── user.ts
├── navigation.ts
├── prisma
│ └── client.ts
├── i18n.tsx
├── services
│ └── users
│ │ └── user.ts
└── shiki
│ └── extends.ts
├── vite.config.ts
├── vercel.json
├── postcss.config.js
├── utils
├── fs.ts
├── user.ts
├── helper.ts
├── middlewares.ts
├── actions.ts
├── formatter.ts
├── cookies.ts
├── types.ts
├── app.ts
├── response.ts
└── strings.ts
├── .vscode
├── settings.json
├── i18n-ally-custom-framework.yml
└── unocss.json
├── .editorconfig
├── lint-staged.config.js
├── .env.example
├── components
├── ui
│ ├── provider.tsx
│ ├── link-button.tsx
│ ├── close-button.tsx
│ ├── timeline.tsx
│ ├── radio.tsx
│ ├── number-input.tsx
│ ├── status.tsx
│ ├── checkbox.tsx
│ ├── rating.tsx
│ ├── blockquote.tsx
│ ├── pin-input.tsx
│ ├── data-list.tsx
│ ├── progress.tsx
│ ├── hover-card.tsx
│ ├── field.tsx
│ ├── tag.tsx
│ ├── empty-state.tsx
│ ├── button.tsx
│ ├── progress-circle.tsx
│ ├── toaster.tsx
│ ├── action-bar.tsx
│ ├── segmented-control.tsx
│ ├── breadcrumb.tsx
│ ├── skeleton.tsx
│ ├── tooltip.tsx
│ ├── switch.tsx
│ ├── native-select.tsx
│ ├── alert.tsx
│ ├── accordion.tsx
│ ├── stepper-input.tsx
│ ├── toggle.tsx
│ ├── drawer.tsx
│ ├── input-group.tsx
│ ├── popover.tsx
│ ├── dialog.tsx
│ ├── radio-card.tsx
│ ├── checkbox-card.tsx
│ ├── stat.tsx
│ ├── toggle-tip.tsx
│ ├── color-mode.tsx
│ ├── avatar.tsx
│ ├── steps.tsx
│ └── slider.tsx
├── provider.tsx
├── server-provider.tsx
├── navigation-link.tsx
├── status.tsx
└── uno-css-indicator.tsx
├── .commitlintrc.js
├── types
└── index.d.ts
├── public
├── vercel.svg
└── next.svg
├── .gitignore
├── tsconfig.json
├── .eslintrc.js
├── renovate.json
├── .stylelintrc.js
├── hooks
├── requests
│ └── user.ts
├── toast.ts
└── actions.ts
├── scripts
├── tools
│ └── gen-oauth.ts
└── prisma
│ ├── migrate.ts
│ ├── db.ts
│ └── generate.ts
├── README.md
├── env.mjs
├── uno.config.ts
├── actions
└── paste.ts
├── .github
└── workflows
│ └── node.yml
└── styles
└── global.scss
/.env:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | *.lockb binary diff=lockb
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .next/
2 | auto-imports.d.ts
3 | pnpm-lock.yaml
4 |
--------------------------------------------------------------------------------
/prisma/README.md:
--------------------------------------------------------------------------------
1 | # Prisma
2 |
3 | 本目录存放相关文件,分目录存放各个数据库的配置文件。
4 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/greenhat616/pastebin/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm lint-staged
5 |
--------------------------------------------------------------------------------
/enums/user.ts:
--------------------------------------------------------------------------------
1 | export enum Role {
2 | User = 1, // Normal user
3 | Admin = 100 // Admin user
4 | }
5 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | pnpm commitlint --edit $1
5 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/snippets/_components/snippet.module.scss:
--------------------------------------------------------------------------------
1 | .snippet {
2 | @apply flex;
3 | }
4 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: false,
3 | trailingComma: 'none',
4 | singleQuote: true
5 | }
6 |
--------------------------------------------------------------------------------
/enums/paste.ts:
--------------------------------------------------------------------------------
1 | export enum PasteType {
2 | Normal = 1, // Normal mode
3 | Gist = 2 // Gist like mode
4 | }
5 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/v/[cuid]/_components/shiki/shiki.scss:
--------------------------------------------------------------------------------
1 | .code-preview {
2 | .shiki {
3 | overflow: inherit;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/config/app.tsx:
--------------------------------------------------------------------------------
1 | export const appConfig = {
2 | i18n: {
3 | en: 'English - en',
4 | 'zh-CN': '简体中文 - zh-CN'
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/prisma/postgresql/migrations/20231026031107_add_authenticator_field_name/migration.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "authenticators" ADD COLUMN "name" TEXT;
2 |
--------------------------------------------------------------------------------
/prisma/postgresql/migrations/20231021115443_add_paste_field_type/migration.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "public"."pastes" ADD COLUMN "type" INTEGER NOT NULL DEFAULT 1;
2 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/auth/error/page.tsx:
--------------------------------------------------------------------------------
1 | import ErrorPreview from './_components/preview'
2 |
3 | export default async function ErrorPage() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/prisma/postgresql/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/enums/app.ts:
--------------------------------------------------------------------------------
1 | export enum RuntimeMode {
2 | Development = 0,
3 | Production = 1,
4 | Test
5 | }
6 |
7 | export enum CredentialsAuthType {
8 | Password = 0,
9 | WebAuthn = 1
10 | }
11 |
--------------------------------------------------------------------------------
/libs/auth/index.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth'
2 | import 'server-only'
3 | import { authConfig } from './config'
4 |
5 | export const { auth, signIn, signOut, handlers } = NextAuth(authConfig)
6 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import tsconfigPaths from 'vite-tsconfig-paths'
3 |
4 | export default defineConfig({
5 | root: './',
6 | base: './',
7 | plugins: [tsconfigPaths()]
8 | })
9 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/about/page.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | params: Promise<{ locale: string }>
3 | }
4 |
5 | export default function About(props: Props) {
6 | return (
7 |
8 |
About
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://openapi.vercel.sh/vercel.json",
3 | "cleanUrls": true,
4 | "crons": [
5 | {
6 | "path": "/api/task",
7 | "schedule": "5 0 * * *"
8 | }
9 | ],
10 | "trailingSlash": false
11 | }
12 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | 'postcss-import': {},
4 | tailwindcss: {},
5 | // '@unocss/postcss': {
6 | // content: ['**/*.{html,js,ts,jsx,tsx}']
7 | // },
8 | autoprefixer: {}
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/prisma/postgresql/migrations/20231021123408_add_fields/migration.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "public"."users" ADD COLUMN "website" TEXT;
2 | ALTER TABLE "public"."users" ADD COLUMN "bio" TEXT;
3 | ALTER TABLE "public"."pastes" ADD COLUMN "syntax" TEXT NOT NULL DEFAULT 'text';
4 |
--------------------------------------------------------------------------------
/utils/fs.ts:
--------------------------------------------------------------------------------
1 | import { readdir } from 'node:fs/promises'
2 |
3 | export async function getDictionaries(path: string) {
4 | return (await readdir(path, { withFileTypes: true }))
5 | .filter((dirent) => dirent.isDirectory())
6 | .map((dirent) => dirent.name)
7 | }
8 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "css.customData": [".vscode/unocss.json"],
3 | "i18n-ally.localesPaths": ["messages"],
4 | "i18n-ally.keystyle": "nested",
5 | "typescript.tsdk": "node_modules/typescript/lib",
6 | "typescript.updateImportsOnFileMove.enabled": "always"
7 | }
8 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/_components/shell.tsx:
--------------------------------------------------------------------------------
1 | import { Grid } from '@chakra-ui/react'
2 |
3 | export default function Shell({ children }: { children: React.ReactNode }) {
4 | return (
5 |
6 | {children}
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/app/api/auth/[...nextauth]/route.ts:
--------------------------------------------------------------------------------
1 | // export { GET, POST } from '@/libs/auth'
2 |
3 | import { handlers } from '@/libs/auth'
4 | // import NextAuth from 'next-auth/next'
5 |
6 | // const handler = NextAuth(config)
7 | // export { handler as GET, handler as POST }
8 | export const { GET, POST } = handlers
9 |
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '**.{ts,tsx}': [
3 | 'prettier --write',
4 | 'eslint -c .eslintrc.js',
5 | () => 'tsc -p tsconfig.json --noEmit'
6 | ],
7 | '**.{js,jsx}': ['prettier --write', 'eslint -c .eslintrc.js'],
8 | '*.scss,*.css': ['stylelint --config .stylelintrc.json']
9 | }
10 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/auth/password/reset/page.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from '@chakra-ui/react'
2 | import { ApplyPasswordResetForm } from './_components/form'
3 |
4 | export default async function PasswordResetPage() {
5 | return (
6 |
7 |
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | ###############
2 | # Database #
3 | ###############
4 | DB_ADAPTER=postgresql
5 | PG_URL=
6 | PG_DIRECT_URL=
7 |
8 | NEXT_PUBLIC_APP_URL=http://localhost:6754
9 | NEXTAUTH_SECRET=
10 | NEXTAUTH_URL=http://localhost:6754
11 | AUTH_GITHUB_ID=
12 | AUTH_GITHUB_SECRET=
13 |
14 | AUTH_GOOGLE_ID=
15 | AUTH_GOOGLE_SECRET=
16 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react'
2 |
3 | type Props = {
4 | children: ReactNode
5 | }
6 |
7 | // Since we have a `not-found.tsx` page on the root, a layout file
8 | // is required, even if it's just passing children through.
9 | export default function RootLayout({ children }: Props) {
10 | return children
11 | }
12 |
--------------------------------------------------------------------------------
/libs/auth/providers.tsx:
--------------------------------------------------------------------------------
1 | import GitHubIcon from '~icons/mdi/github'
2 | import GoogleIcon from '~icons/mdi/google'
3 | export const providers = [
4 | {
5 | id: 'github',
6 | name: 'GitHub',
7 | icon:
8 | },
9 | {
10 | id: 'google',
11 | name: 'Google',
12 | icon:
13 | }
14 | ]
15 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/_components/layouts/logo.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import AnimatedLogo, {
4 | type AnimatedLogoProps
5 | } from '@/components/animated-logo'
6 | import { usePathname } from 'next/navigation'
7 |
8 | export default function Logo(props: AnimatedLogoProps) {
9 | const pathname = usePathname()
10 | return
11 | }
12 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/_components/home/form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { CreateNormalSnippet } from '@/components/form'
4 |
5 | export function CreateSnippetForm({
6 | nickname,
7 | className
8 | }: {
9 | nickname?: string
10 | className?: string
11 | }) {
12 | return (
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/components/ui/provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ChakraProvider, defaultSystem } from '@chakra-ui/react'
4 | import { ColorModeProvider, type ColorModeProviderProps } from './color-mode'
5 |
6 | export function Provider(props: ColorModeProviderProps) {
7 | return (
8 |
9 |
10 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/libs/requests.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from '@tanstack/react-query'
2 | import 'client-only'
3 | import { ofetch } from 'ofetch'
4 | // Requests should
5 |
6 | export const ofetchClient = ofetch.create({
7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
8 | // @ts-ignore ts(7017)
9 | fetch: globalThis._nextOriginalFetch || globalThis.fetch
10 | })
11 |
12 | export const queryClient = new QueryClient()
13 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/auth/password/reset/_components/form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button, Input } from '@chakra-ui/react'
4 |
5 | export function ApplyPasswordResetForm() {
6 | return (
7 | <>
8 |
9 |
12 | >
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/components/provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { queryClient } from '@/libs/requests'
3 | import { QueryClientProvider as ReactQueryClientProvider } from '@tanstack/react-query'
4 |
5 | export function QueryClientProvider({
6 | children
7 | }: {
8 | children: React.ReactNode
9 | }) {
10 | return (
11 |
12 | {children}
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/.commitlintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional'],
3 | rules: {
4 | 'type-enum': [
5 | 2,
6 | 'always',
7 | [
8 | 'build',
9 | 'ci',
10 | 'chore',
11 | 'docs',
12 | 'feat',
13 | 'fix',
14 | 'perf',
15 | 'refactor',
16 | 'revert',
17 | 'style',
18 | 'test'
19 | ]
20 | ]
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Error from 'next/error'
4 |
5 | // Render the default Next.js 404 page when a route
6 | // is requested that doesn't match the middleware and
7 | // therefore doesn't have a locale associated with it.
8 |
9 | export default function NotFound() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/auth/password/reset/[token]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from '@chakra-ui/react'
2 | import { PasswordResetForm } from './_components/form'
3 |
4 | type Props = {
5 | params: Promise<{
6 | token: string
7 | }>
8 | }
9 |
10 | export default function ResetPasswordPage(props: Props) {
11 | // 1. verify token
12 | // 2. if token is valid, show reset password form
13 | return (
14 |
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/components/ui/link-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { HTMLChakraProps, RecipeProps } from '@chakra-ui/react'
4 | import { createRecipeContext } from '@chakra-ui/react'
5 |
6 | export interface LinkButtonProps
7 | extends HTMLChakraProps<'a', RecipeProps<'button'>> {}
8 |
9 | const { withContext } = createRecipeContext({ key: 'button' })
10 |
11 | // Replace "a" with your framework's link component
12 | export const LinkButton = withContext('a')
13 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/_components/layouts/content.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { motion } from 'framer-motion'
4 | import React from 'react'
5 |
6 | type Props = {
7 | className?: string
8 | children: React.ReactNode
9 | }
10 |
11 | export default function Content(props: Props) {
12 | return (
13 |
18 | {props.children}
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/settings/page.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from '@/libs/auth'
2 | import { Card, CardBody, Heading } from '@chakra-ui/react'
3 |
4 | import AppSettings from './_components/app-settings'
5 |
6 | export default async function DashboardPage() {
7 | const session = await auth()
8 | return (
9 |
10 |
11 | Apps
12 |
13 |
14 |
15 |
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/auth/password/reset/[token]/_components/form.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button, Input } from '@chakra-ui/react'
4 |
5 | export function PasswordResetForm() {
6 | return (
7 | <>
8 |
9 |
15 |
18 | >
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export type NavItem = {
4 | title: string
5 | href: string
6 | disabled?: boolean
7 | }
8 |
9 | export type MainNavItem = NavItem
10 |
11 | export type SidebarNavItem = {
12 | title: string
13 | disabled?: boolean
14 | external?: boolean
15 | icon?: React.ReactElement
16 | } & (
17 | | {
18 | href: string
19 | items?: never
20 | }
21 | | {
22 | href?: string
23 | items: NavLink[]
24 | }
25 | )
26 |
27 | export type DashboardConfig = {
28 | sidebarNavItems: SidebarNavItem[]
29 | }
30 |
--------------------------------------------------------------------------------
/utils/user.ts:
--------------------------------------------------------------------------------
1 | import { env } from '@/env.mjs'
2 | import { User } from '@prisma/client'
3 | import md5 from 'md5'
4 | import { type Session } from 'next-auth'
5 |
6 | export function getUserAvatar(session: Session | User | undefined | null) {
7 | const user: Partial> =
8 | (session as Session)?.user || (session as User) || undefined
9 | // console.log(user)
10 | return (
11 | user?.avatar ??
12 | env.NEXT_PUBLIC_AUTH_GRAVATAR_MIRROR.replace(
13 | '{hash}',
14 | user?.email ? md5(user.email) : ''
15 | )
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/components/ui/close-button.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonProps } from '@chakra-ui/react'
2 | import { IconButton as ChakraIconButton } from '@chakra-ui/react'
3 | import * as React from 'react'
4 | import { LuX } from 'react-icons/lu'
5 |
6 | export type CloseButtonProps = ButtonProps
7 |
8 | export const CloseButton = React.forwardRef<
9 | HTMLButtonElement,
10 | CloseButtonProps
11 | >(function CloseButton(props, ref) {
12 | return (
13 |
14 | {props.children ?? }
15 |
16 | )
17 | })
18 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.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 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | .idea/
38 |
39 | .eslintcache
40 | .stylelintcache
41 |
42 | tsx-0/
43 | tsconfig.tsbuildinfo
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/_components/header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function Header({
4 | text,
5 | heading,
6 | children
7 | }: {
8 | text?: React.ReactNode
9 | heading: React.ReactNode
10 | children?: React.ReactNode
11 | }) {
12 | return (
13 |
14 |
15 |
16 | {heading}
17 |
18 | {text &&
{text}
}
19 |
20 | {children}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/settings/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Grid } from '@chakra-ui/react'
2 | import React from 'react'
3 | import Header from '../_components/header'
4 | import Shell from '../_components/shell'
5 |
6 | type SettingsLayoutProps = {
7 | profile: React.ReactNode
8 | children: React.ReactNode // Site settings
9 | }
10 |
11 | export default async function SettingsLayout(props: SettingsLayoutProps) {
12 | return (
13 |
14 |
15 |
16 | {props.profile}
17 | {props.children}
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/libs/framer/shaking.ts:
--------------------------------------------------------------------------------
1 | const getRandomTransformOrigin = () => {
2 | const value = (16 + 40 * Math.random()) / 100
3 | const value2 = (15 + 36 * Math.random()) / 100
4 | return {
5 | originX: value,
6 | originY: value2
7 | }
8 | }
9 |
10 | const getRandomDelay = () => -(Math.random() * 0.7 + 0.05)
11 |
12 | const randomDuration = () => Math.random() * 0.07 + 0.23
13 |
14 | export const variants = {
15 | start: (i: number) => ({
16 | rotate: i % 2 === 0 ? [-1, 1.3, 0] : [1, -1.4, 0],
17 | transition: {
18 | delay: getRandomDelay(),
19 | repeat: Infinity,
20 | duration: randomDuration()
21 | }
22 | }),
23 | reset: {
24 | rotate: 0
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/app/error.tsx:
--------------------------------------------------------------------------------
1 | 'use client' // Error components must be Client Components
2 |
3 | import { useEffect } from 'react'
4 |
5 | export default function Error({
6 | error,
7 | reset
8 | }: {
9 | error: Error & { digest?: string }
10 | reset: () => void
11 | }) {
12 | useEffect(() => {
13 | // Log the error to an error reporting service
14 | console.error(error)
15 | }, [error])
16 |
17 | return (
18 |
19 |
Something went wrong!
20 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/v/[cuid]/_components/CodePreviewIntlProvider.tsx:
--------------------------------------------------------------------------------
1 | import { IntlClientProvider } from '@/components/server-provider'
2 | import { pick } from 'lodash-es'
3 | import { useLocale, useMessages, type AbstractIntlMessages } from 'next-intl'
4 | import React from 'react'
5 |
6 | type Props = {
7 | children: React.ReactNode
8 | }
9 |
10 | export default function CodePreviewIntlProvider(props: Props) {
11 | const messages = useMessages()
12 | const locale = useLocale()
13 |
14 | return (
15 |
19 | {props.children}
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/notifications/page.tsx:
--------------------------------------------------------------------------------
1 | import Header from '../_components/header'
2 | import Shell from '../_components/shell'
3 |
4 | export default function NotificationsPage() {
5 | return (
6 |
7 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | No notifications found.
18 |
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/utils/helper.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * classNames is a utility function to join class names together.
3 | * It filters out falsy values.
4 | * @param input
5 | * @return {string}
6 | */
7 | export function classNames(
8 | ...input: Array
9 | ): string {
10 | input = input.filter(Boolean) as string[]
11 | return input.join(' ')
12 | }
13 |
14 | /**
15 | * getLocalePathname return the pathname, which is the rest of the url after the locale.
16 | * @param {string} pathname - Raw pathname
17 | * @return {string} - Pathname without the first '/'
18 | */
19 | export function getLocalePathname(pathname: string): string {
20 | if (pathname === '/') return pathname
21 | return pathname.replace(/^\/[a-z]{2}\//, '/')
22 | }
23 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from '@/libs/auth'
2 | import { useLocale } from 'next-intl'
3 | import { getTimeZone } from 'next-intl/server'
4 | import Header from './_components/header'
5 | import Shell from './_components/shell'
6 |
7 | export default async function DashboardPage() {
8 | const session = await auth()
9 | // eslint-disable-next-line react-hooks/rules-of-hooks
10 | const locale = useLocale()
11 | const timeZone = await getTimeZone({ locale })
12 | const now = newDayjs(undefined, { locale, timeZone })
13 | return (
14 |
15 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/components/server-provider.tsx:
--------------------------------------------------------------------------------
1 | // Note that it is a Server Component
2 | import { NextIntlClientProvider, type AbstractIntlMessages } from 'next-intl'
3 | import { getNow, getTimeZone } from 'next-intl/server'
4 | import 'server-only'
5 |
6 | type IntlProviderProps = {
7 | locale: string
8 | messages: AbstractIntlMessages
9 | children: React.ReactNode
10 | }
11 |
12 | export async function IntlClientProvider({
13 | locale,
14 | messages,
15 | children
16 | }: IntlProviderProps) {
17 | const now = await getNow({ locale })
18 | const timeZone = await getTimeZone({ locale })
19 | return (
20 |
21 | {children}
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/_components/layouts/go-back-button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRouter } from '@/libs/navigation'
4 | import { Button, type ButtonProps } from '@chakra-ui/react'
5 | import { useMemoizedFn } from 'ahooks'
6 |
7 | export type Props = {
8 | className?: string
9 | children: React.ReactNode
10 | } & ButtonProps
11 |
12 | export default function GoBackButton(props: Props) {
13 | const router = useRouter()
14 | const goBackFn = useMemoizedFn(() => {
15 | router.back()
16 | })
17 |
18 | return (
19 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import { dashboardConfig } from '@/config/dashboard'
2 | import { auth } from '@/libs/auth'
3 | import { redirect } from '@/libs/navigation'
4 | import { Box, Flex } from '@chakra-ui/react'
5 | import AsideNav from './_components/aside-nav'
6 |
7 | type DashboardLayoutProps = {
8 | children: React.ReactNode
9 | }
10 |
11 | export default async function DashboardLayout(props: DashboardLayoutProps) {
12 | const session = await auth()
13 | if (!session) redirect('/auth/signin')
14 | return (
15 |
16 |
17 |
18 | {props.children}
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/components/navigation-link.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Link, pathnames } from '@/libs/navigation'
4 | import { useSelectedLayoutSegment } from 'next/navigation'
5 | import { ComponentProps } from 'react'
6 |
7 | export default function NavigationLink<
8 | Pathname extends keyof typeof pathnames
9 | >({ href, ...rest }: ComponentProps>) {
10 | const selectedLayoutSegment = useSelectedLayoutSegment()
11 | const pathname = selectedLayoutSegment ? `/${selectedLayoutSegment}` : '/'
12 | const isActive = pathname === href
13 |
14 | return (
15 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/config/dashboard.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardConfig } from '@/types'
2 |
3 | export const dashboardConfig = {
4 | sidebarNavItems: [
5 | {
6 | title: 'Overview',
7 | href: '/dashboard',
8 | icon:
9 | },
10 | {
11 | title: 'Snippets',
12 | href: '/dashboard/snippets',
13 | icon:
14 | },
15 | {
16 | title: 'Notifications',
17 | href: '/dashboard/notifications',
18 | icon:
19 | },
20 | {
21 | title: 'Settings',
22 | href: '/dashboard/settings',
23 | icon:
24 | }
25 | ]
26 | } satisfies DashboardConfig
27 |
--------------------------------------------------------------------------------
/components/status.tsx:
--------------------------------------------------------------------------------
1 | export type EmptyPlaceholderProps = {
2 | text: string
3 | className?: string
4 | iconClassName?: string
5 | textClassName?: string
6 | }
7 |
8 | export function EmptyPlaceholder(props: EmptyPlaceholderProps) {
9 | return (
10 |
11 |
12 |
17 |
18 |
19 |
25 | {props.text}
26 |
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/components/ui/timeline.tsx:
--------------------------------------------------------------------------------
1 | import { Timeline as ChakraTimeline } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export const TimelineConnector = React.forwardRef<
5 | HTMLDivElement,
6 | ChakraTimeline.IndicatorProps
7 | >(function TimelineConnector(props, ref) {
8 | return (
9 |
10 |
11 |
12 |
13 | )
14 | })
15 |
16 | export const TimelineRoot = ChakraTimeline.Root
17 | export const TimelineContent = ChakraTimeline.Content
18 | export const TimelineItem = ChakraTimeline.Item
19 | export const TimelineIndicator = ChakraTimeline.Indicator
20 | export const TimelineTitle = ChakraTimeline.Title
21 | export const TimelineDescription = ChakraTimeline.Description
22 |
--------------------------------------------------------------------------------
/components/uno-css-indicator.tsx:
--------------------------------------------------------------------------------
1 | import { RuntimeMode } from '@/enums/app'
2 |
3 | const mode = getRuntimeMode()
4 |
5 | export function UnoCSSIndicator() {
6 | if (mode === RuntimeMode.Production) return null
7 | return (
8 |
9 |
xs
10 |
11 | sm
12 |
13 |
md
14 |
lg
15 |
xl
16 |
2xl
17 |
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/enums/response.ts:
--------------------------------------------------------------------------------
1 | export enum ResponseCode {
2 | NotInvoke = -1, // 未调用
3 | OK = 0,
4 | InternalError = 50, // 内部发生错误
5 | ValidationFailed = 51, // 数据验证失败
6 | DBOperationError = 52, // 数据库操作错误
7 | InvalidParameters = 53, // 当前操作给定的参数无效
8 | MissingParameter = 54, // 当前操作的参数缺失
9 | InvalidOperation = 55, // 函数不能这样使用
10 | InvalidConfiguration = 56, // 当前操作的配置无效
11 | MissingConfiguration = 57, // 当前操作缺少配置
12 | NotImplemented = 58, // 操作尚未执行
13 | NotSupported = 59, // 操作不支持
14 | OperationFailed = 60, // 我试过了,但是我不能给你想要的
15 | NotAuthorized = 61, // 未授权
16 | SecurityReason = 62, // 由于安全原因,操作被拒绝
17 | ServerBusy = 63, // 服务器繁忙
18 | UnknownError = 64, // 未知错误
19 | NotFound = 65, // 资源不存在
20 | InvalidRequest = 66, // 无效的请求
21 | NecessaryPackageNotImported = 67, // 缺少必要的包
22 | BusinessValidationFailed = 300 // 业务验证失败
23 | }
24 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/settings/loading.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Card, CardBody, Heading } from '@chakra-ui/react'
4 | import { FormInputSkeleton } from '../_components/skeleton'
5 |
6 | export default function SettingsLoading() {
7 | return (
8 |
9 |
10 | Apps
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/layout.module.scss:
--------------------------------------------------------------------------------
1 | .auth-layout {
2 | @apply h-[100vh] w-full flex z-0 justify-center relative;
3 |
4 | .go-back-button {
5 | @apply absolute top-5 left-5 px-5 py-2 hidden md:block;
6 | }
7 |
8 | &::before {
9 | @apply h-[100vh] w-full absolute top-0 left-0 z-0;
10 |
11 | content: ' '; // Required for pseudo element
12 | background: url('https://bingw.jasonzeng.dev/?resolution=UHD&index=random')
13 | no-repeat center center; // Bing random image
14 |
15 | background-size: cover;
16 | }
17 |
18 | .main {
19 | @apply w-[90%] md:w-1/2 xl:w-1/3 2xl:w-1/4 mx-auto mt-20 md:mt-24 z-[1];
20 |
21 | .logo {
22 | @apply w-20 h-20 mx-auto mb-10;
23 |
24 | .inner {
25 | @apply w-20 h-20;
26 | }
27 | }
28 |
29 | .container {
30 | @apply w-full bg-white rounded-3xl shadow-lg p-10;
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/utils/middlewares.ts:
--------------------------------------------------------------------------------
1 | import { URLPattern } from 'next/server'
2 |
3 | type URLPatternResult = Exclude, null>
4 |
5 | export type URLPatternParser = [
6 | URLPattern,
7 | (patternResult: URLPatternResult) => Record
8 | ]
9 |
10 | /**
11 | * Parse params from URL.
12 | * Fucking Next.js....
13 | * @param {string} url
14 | * @return {Record}
15 | */
16 | export const parseURLParams = (
17 | url: string,
18 | patterns: URLPatternParser[]
19 | ): Partial => {
20 | const input = url.split('?')[0]
21 | let result = {}
22 |
23 | for (const [pattern, handler] of patterns) {
24 | const patternResult = pattern.exec(input)
25 | if (patternResult !== null && 'pathname' in patternResult) {
26 | result = handler(patternResult)
27 | break
28 | }
29 | }
30 | return result
31 | }
32 |
--------------------------------------------------------------------------------
/components/ui/radio.tsx:
--------------------------------------------------------------------------------
1 | import { RadioGroup as ChakraRadioGroup } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface RadioProps extends ChakraRadioGroup.ItemProps {
5 | rootRef?: React.Ref
6 | inputProps?: React.InputHTMLAttributes
7 | }
8 |
9 | export const Radio = React.forwardRef(
10 | function Radio(props, ref) {
11 | const { children, inputProps, rootRef, ...rest } = props
12 | return (
13 |
14 |
15 |
16 | {children && (
17 | {children}
18 | )}
19 |
20 | )
21 | }
22 | )
23 |
24 | export const RadioGroup = ChakraRadioGroup.Root
25 |
--------------------------------------------------------------------------------
/components/ui/number-input.tsx:
--------------------------------------------------------------------------------
1 | import { NumberInput as ChakraNumberInput } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface NumberInputProps extends ChakraNumberInput.RootProps {}
5 |
6 | export const NumberInputRoot = React.forwardRef<
7 | HTMLDivElement,
8 | NumberInputProps
9 | >(function NumberInput(props, ref) {
10 | const { children, ...rest } = props
11 | return (
12 |
13 | {children}
14 |
15 |
16 |
17 |
18 |
19 | )
20 | })
21 |
22 | export const NumberInputField = ChakraNumberInput.Input
23 | export const NumberInputScrubber = ChakraNumberInput.Scrubber
24 | export const NumberInputLabel = ChakraNumberInput.Label
25 |
--------------------------------------------------------------------------------
/components/ui/status.tsx:
--------------------------------------------------------------------------------
1 | import type { ColorPalette } from '@chakra-ui/react'
2 | import { Status as ChakraStatus } from '@chakra-ui/react'
3 | import * as React from 'react'
4 |
5 | type StatusValue = 'success' | 'error' | 'warning' | 'info'
6 |
7 | export interface StatusProps extends ChakraStatus.RootProps {
8 | value?: StatusValue
9 | }
10 |
11 | const statusMap: Record = {
12 | success: 'green',
13 | error: 'red',
14 | warning: 'orange',
15 | info: 'blue'
16 | }
17 |
18 | export const Status = React.forwardRef(
19 | function Status(props, ref) {
20 | const { children, value = 'info', ...rest } = props
21 | const colorPalette = rest.colorPalette ?? statusMap[value]
22 | return (
23 |
24 |
25 | {children}
26 |
27 | )
28 | }
29 | )
30 |
--------------------------------------------------------------------------------
/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox as ChakraCheckbox } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface CheckboxProps extends ChakraCheckbox.RootProps {
5 | icon?: React.ReactNode
6 | inputProps?: React.InputHTMLAttributes
7 | rootRef?: React.Ref
8 | }
9 |
10 | export const Checkbox = React.forwardRef(
11 | function Checkbox(props, ref) {
12 | const { icon, children, inputProps, rootRef, ...rest } = props
13 | return (
14 |
15 |
16 |
17 | {icon || }
18 |
19 | {children != null && (
20 | {children}
21 | )}
22 |
23 | )
24 | }
25 | )
26 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/settings/@profile/page.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from '@/libs/auth'
2 | import { providers } from '@/libs/auth/providers'
3 | import client from '@/libs/prisma/client'
4 | import { User } from '@prisma/client'
5 | import Profiles from '../_components/profiles'
6 |
7 | async function getSSOs(userID: string) {
8 | const accounts = await client.account.findMany({
9 | where: {
10 | userId: userID
11 | }
12 | })
13 | return providers.map((provider) => {
14 | return {
15 | ...provider,
16 | connected: accounts.some((account) => account.provider === provider.id)
17 | }
18 | })
19 | }
20 |
21 | export default async function ProfilePage() {
22 | const session = await auth()
23 | if (!session) return null
24 | const user = await client.user.findUnique({
25 | where: {
26 | id: session.user.id
27 | }
28 | })
29 | const ssos = await getSSOs(session!.user.id)
30 | return
31 | }
32 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/v/[cuid]/_components/shiki/LineNumbers.tsx:
--------------------------------------------------------------------------------
1 | import { attrsToLines } from '@/libs/shiki'
2 | import { Box } from '@chakra-ui/react'
3 | import styles from '../CodePreview.module.scss'
4 | type Props = {
5 | lines: number
6 | highlightLines?: string
7 | }
8 |
9 | export default function LineNumbers(props: Props) {
10 | const { lines, highlightLines: linesAttrs } = props
11 | const highlightLines = linesAttrs ? attrsToLines(linesAttrs) : undefined
12 | const linesArray = Array.from({ length: lines }, (_, i) => i + 1)
13 | return (
14 |
15 | {linesArray.map((line) => (
16 |
24 | {line}
25 |
26 | ))}
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/components/ui/rating.tsx:
--------------------------------------------------------------------------------
1 | import { RatingGroup } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface RatingProps extends RatingGroup.RootProps {
5 | icon?: React.ReactElement
6 | count?: number
7 | label?: React.ReactNode
8 | }
9 |
10 | export const Rating = React.forwardRef(
11 | function Rating(props, ref) {
12 | const { icon, count = 5, label, ...rest } = props
13 | return (
14 |
15 | {label && {label}}
16 |
17 |
18 | {Array.from({ length: count }).map((_, index) => (
19 |
20 |
21 |
22 | ))}
23 |
24 |
25 | )
26 | }
27 | )
28 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/auth/error/_components/preview.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Alert } from '@/components/ui/alert'
4 | import { Button } from '@/components/ui/button'
5 | import { Stack } from '@chakra-ui/react'
6 |
7 | import { useSearchParams } from 'next/navigation'
8 |
9 | export default function ErrorPreview() {
10 | const searchParams = useSearchParams()
11 | const err = searchParams.get('error') || 'Unknown Error'
12 | return (
13 |
14 |
26 | {err}
27 |
28 |
29 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "esModuleInterop": true,
5 | "forceConsistentCasingInFileNames": true,
6 | "incremental": true,
7 | "isolatedModules": true,
8 | "jsx": "preserve",
9 | "lib": ["dom", "dom.iterable", "ESNext"],
10 | "module": "ESNext",
11 | "moduleResolution": "Bundler",
12 | "noEmit": true,
13 | "paths": {
14 | "@/*": ["./*"]
15 | },
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "resolveJsonModule": true,
22 | "skipDefaultLibCheck": true,
23 | "skipLibCheck": true,
24 | "strict": true,
25 | "target": "ESNext",
26 | "types": ["unplugin-icons/types/react"]
27 | },
28 | "exclude": ["node_modules"],
29 | "include": [
30 | "next-env.d.ts",
31 | "auto-imports.d.ts",
32 | "**/*.ts",
33 | "**/*.tsx",
34 | ".next/types/**/*.ts",
35 | "app/[locale]/(auth)/_components/layouts/go-back.-buttontsx",
36 | "scripts/tools/gen-oauth.mts"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/components/ui/blockquote.tsx:
--------------------------------------------------------------------------------
1 | import { Blockquote as ChakraBlockquote } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface BlockquoteProps extends ChakraBlockquote.RootProps {
5 | cite?: React.ReactNode
6 | citeUrl?: string
7 | icon?: React.ReactNode
8 | showDash?: boolean
9 | }
10 |
11 | export const Blockquote = React.forwardRef(
12 | function Blockquote(props, ref) {
13 | const { children, cite, citeUrl, showDash, icon, ...rest } = props
14 |
15 | return (
16 |
17 | {icon}
18 |
19 | {children}
20 |
21 | {cite && (
22 |
23 | {showDash ? <>—> : null} {cite}
24 |
25 | )}
26 |
27 | )
28 | }
29 | )
30 |
31 | export const BlockquoteIcon = ChakraBlockquote.Icon
32 |
--------------------------------------------------------------------------------
/components/ui/pin-input.tsx:
--------------------------------------------------------------------------------
1 | import { PinInput as ChakraPinInput, Group } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface PinInputProps extends ChakraPinInput.RootProps {
5 | rootRef?: React.Ref
6 | count?: number
7 | inputProps?: React.InputHTMLAttributes
8 | attached?: boolean
9 | }
10 |
11 | export const PinInput = React.forwardRef(
12 | function PinInput(props, ref) {
13 | const { count = 4, inputProps, rootRef, attached, ...rest } = props
14 | return (
15 |
16 |
17 |
18 |
19 | {Array.from({ length: count }).map((_, index) => (
20 |
21 | ))}
22 |
23 |
24 |
25 | )
26 | }
27 | )
28 |
--------------------------------------------------------------------------------
/components/ui/data-list.tsx:
--------------------------------------------------------------------------------
1 | import { DataList as ChakraDataList } from '@chakra-ui/react'
2 | import { InfoTip } from './toggle-tip'
3 | import * as React from 'react'
4 |
5 | export const DataListRoot = ChakraDataList.Root
6 |
7 | interface ItemProps extends ChakraDataList.ItemProps {
8 | label: React.ReactNode
9 | value: React.ReactNode
10 | info?: React.ReactNode
11 | grow?: boolean
12 | }
13 |
14 | export const DataListItem = React.forwardRef(
15 | function DataListItem(props, ref) {
16 | const { label, info, value, children, grow, ...rest } = props
17 | return (
18 |
19 |
20 | {label}
21 | {info && {info}}
22 |
23 |
24 | {value}
25 |
26 | {children}
27 |
28 | )
29 | }
30 | )
31 |
--------------------------------------------------------------------------------
/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import { Progress as ChakraProgress } from '@chakra-ui/react'
2 | import { InfoTip } from './toggle-tip'
3 | import * as React from 'react'
4 |
5 | export const ProgressBar = React.forwardRef<
6 | HTMLDivElement,
7 | ChakraProgress.TrackProps
8 | >(function ProgressBar(props, ref) {
9 | return (
10 |
11 |
12 |
13 | )
14 | })
15 |
16 | export interface ProgressLabelProps extends ChakraProgress.LabelProps {
17 | info?: React.ReactNode
18 | }
19 |
20 | export const ProgressLabel = React.forwardRef<
21 | HTMLDivElement,
22 | ProgressLabelProps
23 | >(function ProgressLabel(props, ref) {
24 | const { children, info, ...rest } = props
25 | return (
26 |
27 | {children}
28 | {info && {info}}
29 |
30 | )
31 | })
32 |
33 | export const ProgressRoot = ChakraProgress.Root
34 | export const ProgressValueText = ChakraProgress.ValueText
35 |
--------------------------------------------------------------------------------
/libs/validation/user.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export const UserProfileSchema = z.object({
4 | bio: z.string().optional(),
5 | website: z.string().url().optional(),
6 | name: z.string().min(1, {
7 | message: wrapTranslationKey('validation.user.name_too_short')
8 | })
9 | })
10 |
11 | export type UserProfile = z.infer
12 |
13 | export const ChangePasswordSchema = z
14 | .object({
15 | password: z.string({
16 | required_error: wrapTranslationKey(
17 | 'components.change_password_form.feedback.password.required'
18 | )
19 | }),
20 | password_confirmation: z.string({
21 | required_error: wrapTranslationKey(
22 | 'components.change_password_form.feedback.password_confirmation.required'
23 | )
24 | })
25 | })
26 | .refine((data) => data.password === data.password_confirmation, {
27 | message: wrapTranslationKey(
28 | 'components.change_password_form.feedback.password_confirmation.mismatch'
29 | ),
30 | path: ['password_confirmation'] // path of error
31 | })
32 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/auth/signin/_components/OAuthProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Stack } from '@chakra-ui/react'
4 | import { Button } from '@/components/ui/button'
5 | import { signIn } from 'next-auth/react'
6 | import { useTranslations } from 'next-intl'
7 | import React from 'react'
8 |
9 | type OAuthProviderProps = {
10 | providers: Array<{
11 | id: string
12 | name: string
13 | icon: React.ReactElement
14 | }>
15 | }
16 |
17 | export default function OAuthProvider(props: OAuthProviderProps) {
18 | const t = useTranslations()
19 | return (
20 |
21 | {props.providers.map((provider) => {
22 | return (
23 |
35 | )
36 | })}
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | import { HoverCard, Portal } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | interface HoverCardContentProps extends HoverCard.ContentProps {
5 | portalled?: boolean
6 | portalRef?: React.RefObject
7 | }
8 |
9 | export const HoverCardContent = React.forwardRef<
10 | HTMLDivElement,
11 | HoverCardContentProps
12 | >(function HoverCardContent(props, ref) {
13 | const { portalled = true, portalRef, ...rest } = props
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | )
22 | })
23 |
24 | export const HoverCardArrow = React.forwardRef<
25 | HTMLDivElement,
26 | HoverCard.ArrowProps
27 | >(function HoverCardArrow(props, ref) {
28 | return (
29 |
30 |
31 |
32 | )
33 | })
34 |
35 | export const HoverCardRoot = HoverCard.Root
36 | export const HoverCardTrigger = HoverCard.Trigger
37 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/settings/@profile/loading.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Card, Heading } from '@chakra-ui/react'
4 | import {
5 | BlockButtonSkeleton,
6 | FormInputSkeleton,
7 | XLargeAvatarSkeleton
8 | } from '../../_components/skeleton'
9 |
10 | export default function ProfilesLoading() {
11 | return (
12 |
13 |
14 | Profiles
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/components/ui/field.tsx:
--------------------------------------------------------------------------------
1 | import { Field as ChakraField } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface FieldProps extends Omit {
5 | label?: React.ReactNode
6 | helperText?: React.ReactNode
7 | errorText?: React.ReactNode
8 | optionalText?: React.ReactNode
9 | }
10 |
11 | export const Field = React.forwardRef(
12 | function Field(props, ref) {
13 | const { label, children, helperText, errorText, optionalText, ...rest } =
14 | props
15 | return (
16 |
17 | {label && (
18 |
19 | {label}
20 |
21 |
22 | )}
23 | {children}
24 | {helperText && (
25 | {helperText}
26 | )}
27 | {errorText && (
28 | {errorText}
29 | )}
30 |
31 | )
32 | }
33 | )
34 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/_components/home/Announcement.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | // AlertIcon,
5 | AlertTitle,
6 | AlertDescription,
7 | Box,
8 | useDisclosure
9 | } from '@chakra-ui/react'
10 | import { Alert } from '@/components/ui/alert'
11 | import { CloseButton } from '@/components/ui/close-button'
12 |
13 | export default function Announcement() {
14 | const {
15 | open: isVisible,
16 | onClose,
17 | onOpen
18 | } = useDisclosure({ defaultOpen: true })
19 |
20 | return (
21 | isVisible && (
22 |
23 |
24 | Success!
25 |
26 | Your application has been received. We will review your application
27 | and respond within the next 48 hours.
28 |
29 |
30 |
37 |
38 | )
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/components/ui/tag.tsx:
--------------------------------------------------------------------------------
1 | import { Tag as ChakraTag } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface TagProps extends ChakraTag.RootProps {
5 | startElement?: React.ReactNode
6 | endElement?: React.ReactNode
7 | onClose?: VoidFunction
8 | closable?: boolean
9 | }
10 |
11 | export const Tag = React.forwardRef(
12 | function Tag(props, ref) {
13 | const {
14 | startElement,
15 | endElement,
16 | onClose,
17 | closable = !!onClose,
18 | children,
19 | ...rest
20 | } = props
21 |
22 | return (
23 |
24 | {startElement && (
25 | {startElement}
26 | )}
27 | {children}
28 | {endElement && (
29 | {endElement}
30 | )}
31 | {closable && (
32 |
33 |
34 |
35 | )}
36 |
37 | )
38 | }
39 | )
40 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/_components/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Skeleton,
3 | SkeletonCircle,
4 | SkeletonText
5 | } from '@/components/ui/skeleton'
6 |
7 | export function H2Skeleton() {
8 | return
9 | }
10 |
11 | export function PSkeleton() {
12 | return
13 | }
14 |
15 | export function TextSkeleton(props: { lines?: number }) {
16 | return
17 | }
18 |
19 | export function BlockButtonSkeleton() {
20 | return (
21 |
22 |
23 |
24 | )
25 | }
26 |
27 | export function XLargeAvatarSkeleton() {
28 | return (
29 |
30 |
31 |
32 | )
33 | }
34 |
35 | export function FormInputSkeleton() {
36 | return (
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/prisma/postgresql/migrations/20231024065713_add_web_authn_fields/migration.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "users" ADD COLUMN "extra_fields" JSONB NOT NULL DEFAULT '{}'::jsonb;
2 |
3 | -- CreateTable
4 | CREATE TABLE "authenticators" (
5 | "id" BIGSERIAL NOT NULL,
6 | "credential_id" TEXT NOT NULL,
7 | "credential_public_key" BYTEA NOT NULL,
8 | "counter" INTEGER NOT NULL DEFAULT 0,
9 | "credential_device_type" TEXT NOT NULL,
10 | "credential_backed_up" BOOLEAN NOT NULL,
11 | "transports" TEXT,
12 | "fmt" TEXT NOT NULL,
13 | "user_id" TEXT NOT NULL,
14 | "updated_at" TIMESTAMP(3) NOT NULL,
15 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16 |
17 | CONSTRAINT "authenticators_pkey" PRIMARY KEY ("id")
18 | );
19 |
20 | -- CreateIndex
21 | CREATE INDEX "credential_id" ON "authenticators"("credential_id");
22 |
23 | -- CreateIndex
24 | CREATE INDEX "user_id" ON "authenticators"("user_id");
25 |
26 | -- CreateIndex
27 | CREATE UNIQUE INDEX "authenticators_id_key" ON "authenticators"("id");
28 |
29 | -- AddForeignKey
30 | ALTER TABLE "authenticators" ADD CONSTRAINT "authenticators_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
31 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | browser: true,
5 | node: true
6 | },
7 | extends: [
8 | 'next/core-web-vitals',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'prettier',
11 | 'plugin:prettier/recommended',
12 | './.eslintrc-auto-import.json'
13 | ],
14 | rules: {
15 | 'react/jsx-no-undef': 'off',
16 | '@typescript-eslint/no-empty-object-type': 'warn',
17 | 'no-console': [
18 | process.env.NODE_ENV === 'production' ? 'error' : 'off',
19 | { allow: ['warn', 'error'] }
20 | ],
21 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
22 | 'prettier/prettier': 'off' // turn off prettier rules due to conflict, and should be handled by prettier itself
23 | },
24 | overrides: [
25 | {
26 | files: ['*.ts', '*.tsx'],
27 | parser: '@typescript-eslint/parser',
28 | plugins: ['@typescript-eslint'],
29 | rules: {
30 | 'no-undef': 'off', // should be off for typescript
31 | '@typescript-eslint/no-unused-vars': 'warn'
32 | }
33 | }
34 | ],
35 | settings: {
36 | 'import/parsers': {
37 | '@typescript-eslint/parser': ['.ts', '.tsx']
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/settings/_components/app-settings.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Button } from '@/components/ui/button'
4 | import { Field } from '@/components/ui/field'
5 | import { Select } from 'chakra-react-select'
6 |
7 | export default function AppSettings() {
8 | return (
9 |
39 | )
40 | }
41 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/snippets/_components/button.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Button, useDisclosure } from '@chakra-ui/react'
3 | import { useMemoizedFn } from 'ahooks'
4 | import { useRouter } from 'next/navigation'
5 | import { CreateSnippetModal } from './modal'
6 | import { toaster } from '@/components/ui/toaster'
7 | import { open } from 'fs'
8 |
9 | export type AddButtonProps = {
10 | username?: string
11 | }
12 |
13 | export function AddButton({ username }: AddButtonProps) {
14 | const { open, onOpen, onClose } = useDisclosure()
15 | const router = useRouter()
16 | const onSuccess = useMemoizedFn((pasteID: string) => {
17 | toaster.create({
18 | title: 'Snippet created',
19 | description: `Your snippet has been created successfully. You can view it at ${pasteID}`,
20 | type: 'success',
21 | duration: 5000
22 | })
23 | router.refresh()
24 | onClose()
25 | })
26 | return (
27 |
28 |
31 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/libs/navigation.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createLocalizedPathnamesNavigation,
3 | Pathnames
4 | } from 'next-intl/navigation'
5 |
6 | export const config = {
7 | // A list of all locales that are supported
8 | locales: ['en', 'zh-CN'],
9 |
10 | // If this locale is matched, pathnames work without a prefix (e.g. `/about`)
11 | defaultLocale: 'en'
12 | }
13 |
14 | export const pathnames = {
15 | // If all locales use the same pathname, a
16 | // single external path can be provided.
17 | '/auth/signin': '/auth/signin',
18 | '/auth/signout': '/auth/signout',
19 | '/auth/error': '/auth/error',
20 | '/auth/signup': '/auth/signup',
21 | '/auth/password/reset': '/auth/password/reset',
22 | '/auth/password/reset/[token]': '/auth/password/reset/[token]',
23 | '/': '/',
24 | '/about': '/about',
25 | '/v/[uuid]': '/v/[uuid]',
26 | '/dashboard': '/dashboard',
27 | '/dashboard/notifications': '/dashboard/notifications',
28 | '/dashboard/snippets': '/dashboard/snippets',
29 | '/dashboard/settings': '/dashboard/settings'
30 | } satisfies Pathnames
31 |
32 | export const { Link, redirect, usePathname, useRouter, getPathname } =
33 | createLocalizedPathnamesNavigation({ locales: config.locales, pathnames })
34 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:base",
5 | "default:automergeMinor",
6 | "default:disableRateLimiting",
7 | "default:rebaseStalePrs",
8 | "default:pinVersions",
9 | "group:monorepos"
10 | ],
11 | "packageRules": [
12 | {
13 | "groupName": "Prisma packages",
14 | "matchPackagePatterns": ["prisma"]
15 | },
16 | {
17 | "groupName": "Next.js packages",
18 | "matchPackagePatterns": ["next"]
19 | },
20 | {
21 | "groupName": "TypeScript",
22 | "matchPackagePatterns": ["typescript"]
23 | },
24 | {
25 | "groupName": "Lint packages",
26 | "matchPackagePatterns": [
27 | "eslint",
28 | "prettier",
29 | "commitlint",
30 | "stylelint",
31 | "husky",
32 | "lint-staged"
33 | ]
34 | },
35 | {
36 | "groupName": "Testing packages",
37 | "matchPackagePatterns": ["vitest", "cypress"]
38 | },
39 | {
40 | "description": "Ignore nodejs",
41 | "matchPackageNames": ["node"],
42 | "matchManagers": ["npm"],
43 | "matchDepTypes": ["engines"],
44 | "enabled": false
45 | }
46 | ]
47 | }
48 |
--------------------------------------------------------------------------------
/components/ui/empty-state.tsx:
--------------------------------------------------------------------------------
1 | import { EmptyState as ChakraEmptyState, VStack } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface EmptyStateProps extends ChakraEmptyState.RootProps {
5 | title: string
6 | description?: string
7 | icon?: React.ReactNode
8 | }
9 |
10 | export const EmptyState = React.forwardRef(
11 | function EmptyState(props, ref) {
12 | const { title, description, icon, children, ...rest } = props
13 | return (
14 |
15 |
16 | {icon && (
17 | {icon}
18 | )}
19 | {description ? (
20 |
21 | {title}
22 |
23 | {description}
24 |
25 |
26 | ) : (
27 | {title}
28 | )}
29 | {children}
30 |
31 |
32 | )
33 | }
34 | )
35 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import type { ButtonProps as ChakraButtonProps } from '@chakra-ui/react'
2 | import {
3 | AbsoluteCenter,
4 | Button as ChakraButton,
5 | Span,
6 | Spinner
7 | } from '@chakra-ui/react'
8 | import * as React from 'react'
9 |
10 | interface ButtonLoadingProps {
11 | loading?: boolean
12 | loadingText?: React.ReactNode
13 | }
14 |
15 | export interface ButtonProps extends ChakraButtonProps, ButtonLoadingProps {}
16 |
17 | export const Button = React.forwardRef(
18 | function Button(props, ref) {
19 | const { loading, disabled, loadingText, children, ...rest } = props
20 | return (
21 |
22 | {loading && !loadingText ? (
23 | <>
24 |
25 |
26 |
27 | {children}
28 | >
29 | ) : loading && loadingText ? (
30 | <>
31 |
32 | {loadingText}
33 | >
34 | ) : (
35 | children
36 | )}
37 |
38 | )
39 | }
40 | )
41 |
--------------------------------------------------------------------------------
/components/ui/progress-circle.tsx:
--------------------------------------------------------------------------------
1 | import type { SystemStyleObject } from '@chakra-ui/react'
2 | import {
3 | AbsoluteCenter,
4 | ProgressCircle as ChakraProgressCircle
5 | } from '@chakra-ui/react'
6 | import * as React from 'react'
7 |
8 | interface ProgressCircleRingProps extends ChakraProgressCircle.CircleProps {
9 | trackColor?: SystemStyleObject['stroke']
10 | cap?: SystemStyleObject['strokeLinecap']
11 | }
12 |
13 | export const ProgressCircleRing = React.forwardRef<
14 | SVGSVGElement,
15 | ProgressCircleRingProps
16 | >(function ProgressCircleRing(props, ref) {
17 | const { trackColor, cap, color, ...rest } = props
18 | return (
19 |
20 |
21 |
22 |
23 | )
24 | })
25 |
26 | export const ProgressCircleValueText = React.forwardRef<
27 | HTMLDivElement,
28 | ChakraProgressCircle.ValueTextProps
29 | >(function ProgressCircleValueText(props, ref) {
30 | return (
31 |
32 |
33 |
34 | )
35 | })
36 |
37 | export const ProgressCircleRoot = ChakraProgressCircle.Root
38 |
--------------------------------------------------------------------------------
/libs/prisma/client.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client'
2 | import { createSoftDeleteExtension } from 'prisma-extension-soft-delete'
3 | const client = new PrismaClient()
4 |
5 | // use soft delete
6 | client.$extends(
7 | createSoftDeleteExtension({
8 | models: {
9 | User: {
10 | field: 'deletedAt',
11 | createValue: (deleted) => {
12 | if (deleted) return new Date()
13 | return null
14 | }
15 | },
16 | Announcement: {
17 | field: 'deletedAt',
18 | createValue: (deleted) => {
19 | if (deleted) return new Date()
20 | return null
21 | }
22 | },
23 | Paste: {
24 | field: 'deletedAt',
25 | createValue: (deleted) => {
26 | if (deleted) return new Date()
27 | return null
28 | }
29 | },
30 | Comment: {
31 | field: 'deletedAt',
32 | createValue: (deleted) => {
33 | if (deleted) return new Date()
34 | return null
35 | }
36 | },
37 | Notification: {
38 | field: 'deletedAt',
39 | createValue: (deleted) => {
40 | if (deleted) return new Date()
41 | return null
42 | }
43 | }
44 | }
45 | })
46 | )
47 |
48 | export default client
49 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Toaster as ChakraToaster,
5 | Portal,
6 | Spinner,
7 | Stack,
8 | Toast,
9 | createToaster
10 | } from '@chakra-ui/react'
11 |
12 | export const toaster = createToaster({
13 | placement: 'bottom-end',
14 | pauseOnPageIdle: true
15 | })
16 |
17 | export const Toaster = () => {
18 | return (
19 |
20 |
21 | {(toast) => (
22 |
23 | {toast.type === 'loading' ? (
24 |
25 | ) : (
26 |
27 | )}
28 |
29 | {toast.title && {toast.title}}
30 | {toast.description && (
31 | {toast.description}
32 | )}
33 |
34 | {toast.action && (
35 | {toast.action.label}
36 | )}
37 | {toast.meta?.closable && }
38 |
39 | )}
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/snippets/loading.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Card } from '@chakra-ui/react'
4 | import Header from '../_components/header'
5 | import Shell from '../_components/shell'
6 | import { H2Skeleton, PSkeleton, TextSkeleton } from '../_components/skeleton'
7 |
8 | export default function SnippetsLoading() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/page.tsx:
--------------------------------------------------------------------------------
1 | import { IntlClientProvider } from '@/components/server-provider'
2 | import { auth } from '@/libs/auth'
3 | import { Flex } from '@chakra-ui/react'
4 | import { pick } from 'lodash-es'
5 | import { useLocale, useMessages, type AbstractIntlMessages } from 'next-intl'
6 | import Announcement from './_components/home/Announcement'
7 | import { CreateSnippetForm } from './_components/home/form'
8 | type Props = {
9 | params: Promise<{ locale: string }>
10 | }
11 |
12 | function CreateSnippetIntlProvider({
13 | children
14 | }: {
15 | children: React.ReactNode
16 | }) {
17 | const locale = useLocale()
18 | const messages = useMessages()
19 | return (
20 |
24 | {children}
25 |
26 | )
27 | }
28 |
29 | // Home Page
30 | export default async function Home(props: Props) {
31 | const session = await auth()
32 | return (
33 |
34 |
35 |
36 |
40 |
41 |
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/components/ui/action-bar.tsx:
--------------------------------------------------------------------------------
1 | import { ActionBar, Portal } from '@chakra-ui/react'
2 | import { CloseButton } from './close-button'
3 | import * as React from 'react'
4 |
5 | interface ActionBarContentProps extends ActionBar.ContentProps {
6 | portalled?: boolean
7 | portalRef?: React.RefObject
8 | }
9 |
10 | export const ActionBarContent = React.forwardRef<
11 | HTMLDivElement,
12 | ActionBarContentProps
13 | >(function ActionBarContent(props, ref) {
14 | const { children, portalled = true, portalRef, ...rest } = props
15 |
16 | return (
17 |
18 |
19 |
20 | {children}
21 |
22 |
23 |
24 | )
25 | })
26 |
27 | export const ActionBarCloseTrigger = React.forwardRef<
28 | HTMLButtonElement,
29 | ActionBar.CloseTriggerProps
30 | >(function ActionBarCloseTrigger(props, ref) {
31 | return (
32 |
33 |
34 |
35 | )
36 | })
37 |
38 | export const ActionBarRoot = ActionBar.Root
39 | export const ActionBarSelectionTrigger = ActionBar.SelectionTrigger
40 | export const ActionBarSeparator = ActionBar.Separator
41 |
--------------------------------------------------------------------------------
/.stylelintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: [
3 | 'stylelint-config-standard',
4 | 'stylelint-config-recess-order',
5 | 'stylelint-config-html/vue'
6 | ],
7 | plugins: [
8 | 'stylelint-scss',
9 | 'stylelint-order',
10 | 'stylelint-declaration-block-no-ignored-properties'
11 | ],
12 | ignoreFiles: [
13 | 'node_modules/**/*',
14 | 'dist/**/*',
15 | '.next/**/*',
16 | '**/typings/**/*',
17 | 'public/css/**/*'
18 | ],
19 | rules: {
20 | 'at-rule-no-unknown': [
21 | true,
22 | {
23 | ignoreAtRules: [
24 | 'tailwind',
25 | 'unocss',
26 | 'layer',
27 | 'apply',
28 | 'variants',
29 | 'responsive',
30 | 'screen'
31 | ]
32 | }
33 | ]
34 | },
35 | overrides: [
36 | {
37 | files: ['**/*.scss', '*.scss'],
38 | customSyntax: require('postcss-scss'),
39 | rules: {
40 | 'at-rule-no-unknown': null,
41 | 'scss/at-rule-no-unknown': [
42 | true,
43 | {
44 | ignoreAtRules: [
45 | 'tailwind',
46 | 'unocss',
47 | 'layer',
48 | 'apply',
49 | 'variants',
50 | 'responsive',
51 | 'screen'
52 | ]
53 | }
54 | ]
55 | }
56 | }
57 | ]
58 | }
59 |
--------------------------------------------------------------------------------
/components/ui/segmented-control.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { For, SegmentGroup } from '@chakra-ui/react'
4 | import * as React from 'react'
5 |
6 | interface Item {
7 | value: string
8 | label: React.ReactNode
9 | disabled?: boolean
10 | }
11 |
12 | export interface SegmentedControlProps extends SegmentGroup.RootProps {
13 | items: Array
14 | }
15 |
16 | function normalize(items: Array): Item[] {
17 | return items.map((item) => {
18 | if (typeof item === 'string') return { value: item, label: item }
19 | return item
20 | })
21 | }
22 |
23 | export const SegmentedControl = React.forwardRef<
24 | HTMLDivElement,
25 | SegmentedControlProps
26 | >(function SegmentedControl(props, ref) {
27 | const { items, ...rest } = props
28 | const data = React.useMemo(() => normalize(items), [items])
29 |
30 | return (
31 |
32 |
33 |
34 | {(item) => (
35 |
40 | {item.label}
41 |
42 |
43 | )}
44 |
45 |
46 | )
47 | })
48 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/auth/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import { IntlClientProvider } from '@/components/server-provider'
2 | import { Stack } from '@chakra-ui/react'
3 | import { pick } from 'lodash-es'
4 | import { Metadata } from 'next'
5 | import { AbstractIntlMessages, useLocale, useMessages } from 'next-intl'
6 | import { getTranslations } from 'next-intl/server'
7 | import SignUpForm from './_components/form'
8 |
9 | type Props = {
10 | params: Promise<{
11 | locale: string
12 | }>
13 | }
14 |
15 | export async function generateMetadata({ params }: Props): Promise {
16 | const { locale } = await params
17 | const t = await getTranslations({ locale })
18 |
19 | return {
20 | title: `${t('auth.signup.title')} - ${t('app.name')}`
21 | }
22 | }
23 |
24 | function SignUpIntlProvider({ children }: { children: React.ReactNode }) {
25 | const messages = useMessages()
26 | const locale = useLocale()
27 |
28 | return (
29 |
35 | {children}
36 |
37 | )
38 | }
39 |
40 | export default async function SignUpPage(props: Props) {
41 | return (
42 |
43 |
44 |
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/components/ui/breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import { Breadcrumb, type SystemStyleObject } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface BreadcrumbRootProps extends Breadcrumb.RootProps {
5 | separator?: React.ReactNode
6 | separatorGap?: SystemStyleObject['gap']
7 | }
8 |
9 | export const BreadcrumbRoot = React.forwardRef<
10 | HTMLDivElement,
11 | BreadcrumbRootProps
12 | >(function BreadcrumbRoot(props, ref) {
13 | const { separator, separatorGap, children, ...rest } = props
14 |
15 | const validChildren = React.Children.toArray(children).filter(
16 | React.isValidElement
17 | )
18 |
19 | return (
20 |
21 |
22 | {validChildren.map((child, index) => {
23 | const last = index === validChildren.length - 1
24 | return (
25 |
26 | {child}
27 | {!last && (
28 | {separator}
29 | )}
30 |
31 | )
32 | })}
33 |
34 |
35 | )
36 | })
37 |
38 | export const BreadcrumbLink = Breadcrumb.Link
39 | export const BreadcrumbCurrentLink = Breadcrumb.CurrentLink
40 | export const BreadcrumbEllipsis = Breadcrumb.Ellipsis
41 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import type {
2 | SkeletonProps as ChakraSkeletonProps,
3 | CircleProps
4 | } from '@chakra-ui/react'
5 | import { Skeleton as ChakraSkeleton, Circle, Stack } from '@chakra-ui/react'
6 | import * as React from 'react'
7 |
8 | export interface SkeletonCircleProps extends ChakraSkeletonProps {
9 | size?: CircleProps['size']
10 | }
11 |
12 | export const SkeletonCircle = React.forwardRef<
13 | HTMLDivElement,
14 | SkeletonCircleProps
15 | >(function SkeletonCircle(props, ref) {
16 | const { size, ...rest } = props
17 | return (
18 |
19 |
20 |
21 | )
22 | })
23 |
24 | export interface SkeletonTextProps extends ChakraSkeletonProps {
25 | noOfLines?: number
26 | }
27 |
28 | export const SkeletonText = React.forwardRef(
29 | function SkeletonText(props, ref) {
30 | const { noOfLines = 3, gap, ...rest } = props
31 | return (
32 |
33 | {Array.from({ length: noOfLines }).map((_, index) => (
34 |
41 | ))}
42 |
43 | )
44 | }
45 | )
46 |
47 | export const Skeleton = ChakraSkeleton
48 |
--------------------------------------------------------------------------------
/utils/actions.ts:
--------------------------------------------------------------------------------
1 | import { ResponseCode } from '@/enums/response'
2 | import { cookies } from 'next/headers'
3 | import { ZodError } from 'zod'
4 |
5 | export interface State {
6 | error?: string
7 | issues?: ZodError['issues'] // Validation issues
8 | status: ResponseCode
9 | data?: E
10 | ts: number
11 | }
12 |
13 | export type ActionReturn = State
14 |
15 | export type BaseOptions = {
16 | cookiesJar?: Map
17 | }
18 |
19 | async function setCookies(cookiesJar: Map) {
20 | const cookies_jar = await cookies()
21 | for (const [key, value] of cookiesJar) {
22 | cookies_jar.set(key, value)
23 | }
24 | }
25 |
26 | export function ok(
27 | data?: E,
28 | options: BaseOptions = {}
29 | ): ActionReturn {
30 | if (options.cookiesJar) {
31 | setCookies(options.cookiesJar)
32 | }
33 | return {
34 | status: ResponseCode.OK,
35 | ts: Date.now(),
36 | data
37 | }
38 | }
39 |
40 | type NOkProps = {
41 | error?: string
42 | issues?: ZodError['issues'] // Validation issues
43 | data?: E
44 | }
45 |
46 | export function nok(
47 | status: ResponseCode,
48 | args: NOkProps = {},
49 | options: BaseOptions = {}
50 | ): ActionReturn {
51 | if (options.cookiesJar) {
52 | setCookies(options.cookiesJar)
53 | }
54 | return {
55 | status,
56 | ...args,
57 | ts: Date.now()
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip as ChakraTooltip, Portal } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface TooltipProps extends ChakraTooltip.RootProps {
5 | showArrow?: boolean
6 | portalled?: boolean
7 | portalRef?: React.RefObject
8 | content: React.ReactNode
9 | contentProps?: ChakraTooltip.ContentProps
10 | disabled?: boolean
11 | }
12 |
13 | export const Tooltip = React.forwardRef(
14 | function Tooltip(props, ref) {
15 | const {
16 | showArrow,
17 | children,
18 | disabled,
19 | portalled,
20 | content,
21 | contentProps,
22 | portalRef,
23 | ...rest
24 | } = props
25 |
26 | if (disabled) return children
27 |
28 | return (
29 |
30 | {children}
31 |
32 |
33 |
34 | {showArrow && (
35 |
36 |
37 |
38 | )}
39 | {content}
40 |
41 |
42 |
43 |
44 | )
45 | }
46 | )
47 |
--------------------------------------------------------------------------------
/utils/formatter.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 | import relativeTime from 'dayjs/plugin/relativeTime'
3 | import timezone from 'dayjs/plugin/timezone'
4 | import utc from 'dayjs/plugin/utc'
5 | import weekday from 'dayjs/plugin/weekday'
6 |
7 | // TODO: load locale files automatically, maybe use a script to generate the list?
8 | import { PasteType } from '@/enums/paste'
9 | import 'dayjs/locale/en'
10 | import 'dayjs/locale/zh-cn'
11 |
12 | dayjs.extend(utc)
13 | dayjs.extend(timezone)
14 | dayjs.extend(relativeTime)
15 | dayjs.extend(weekday)
16 |
17 | export type DayJSOptions = {
18 | locale?: string
19 | timeZone?: string
20 | }
21 |
22 | export function formatDateTime(
23 | date: string | number | Date | dayjs.Dayjs | null | undefined,
24 | { locale = 'zh-CN', timeZone = 'Asia/Shanghai' }: DayJSOptions = {}
25 | ) {
26 | return dayjs(date)
27 | .locale(locale.toLowerCase())
28 | .tz(timeZone)
29 | .format('YYYY-MM-DD HH:mm:ss')
30 | }
31 |
32 | export function newDayjs(
33 | date: string | number | Date | dayjs.Dayjs | null | undefined,
34 | { locale = 'zh-CN', timeZone = 'Asia/Shanghai' }: DayJSOptions = {}
35 | ) {
36 | return dayjs(date).locale(locale.toLowerCase()).tz(timeZone)
37 | }
38 |
39 | export function formatPasteType(type: PasteType) {
40 | switch (type) {
41 | case PasteType.Normal:
42 | return 'Normal'
43 | case PasteType.Gist:
44 | return 'Gist'
45 | default:
46 | return 'Unknown'
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/snippets/_components/modal.tsx:
--------------------------------------------------------------------------------
1 | import { CreateNormalSnippet } from '@/components/form'
2 | import {
3 | DialogBody,
4 | DialogCloseTrigger,
5 | DialogContent,
6 | DialogHeader,
7 | DialogRoot
8 | } from '@/components/ui/dialog'
9 | import { CloseButton } from '@/components/ui/close-button'
10 | import 'client-only'
11 |
12 | type ModalProps = {
13 | open: boolean
14 | onClose: () => void
15 | }
16 |
17 | export interface CreateSnippetModalProps extends ModalProps {
18 | nickname?: string
19 | onSuccess?: (pasteID: string) => void
20 | }
21 |
22 | export function CreateSnippetModal({
23 | onClose,
24 | open: isOpen,
25 | nickname,
26 | onSuccess
27 | }: CreateSnippetModalProps) {
28 | return (
29 |
34 |
43 | Create snippet
44 |
45 |
46 |
47 |
48 |
52 |
53 |
54 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import { Switch as ChakraSwitch } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface SwitchProps extends ChakraSwitch.RootProps {
5 | inputProps?: React.InputHTMLAttributes
6 | rootRef?: React.Ref
7 | trackLabel?: { on: React.ReactNode; off: React.ReactNode }
8 | thumbLabel?: { on: React.ReactNode; off: React.ReactNode }
9 | }
10 |
11 | export const Switch = React.forwardRef(
12 | function Switch(props, ref) {
13 | const { inputProps, children, rootRef, trackLabel, thumbLabel, ...rest } =
14 | props
15 |
16 | return (
17 |
18 |
19 |
20 |
21 | {thumbLabel && (
22 |
23 | {thumbLabel?.on}
24 |
25 | )}
26 |
27 | {trackLabel && (
28 |
29 | {trackLabel.on}
30 |
31 | )}
32 |
33 | {children != null && (
34 | {children}
35 | )}
36 |
37 | )
38 | }
39 | )
40 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { IntlClientProvider } from '@/components/server-provider'
2 | import { auth } from '@/libs/auth'
3 | import { omit, pick } from 'lodash-es'
4 | import type { Session } from 'next-auth'
5 | import { AbstractIntlMessages, useLocale, useMessages } from 'next-intl'
6 | import { ReactNode } from 'react'
7 | import { Header } from './_components/layout/Header'
8 |
9 | type Props = {
10 | children: ReactNode
11 | }
12 |
13 | function HeaderIntlProvider({ children }: Props) {
14 | const locale = useLocale()
15 | const messages = useMessages()
16 | return (
17 |
21 | {children}
22 |
23 | )
24 | }
25 |
26 | export default async function AppLayout({ children }: Props) {
27 | const session = await auth()
28 | // console.log(session)
29 | return (
30 | <>
31 |
32 | )
40 | : null
41 | }
42 | />
43 |
44 |
45 |
46 | {children}
47 |
48 |
49 | >
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/libs/i18n.tsx:
--------------------------------------------------------------------------------
1 | // import YAML from 'yaml'
2 | import { getRequestConfig } from 'next-intl/server'
3 | import { headers } from 'next/headers'
4 |
5 | export default getRequestConfig(async ({ locale }) => {
6 | const header = await headers()
7 | const now = header.get('x-now')
8 | const timeZone = header.get('x-time-zone') ?? 'Asia/Shanghai'
9 | return {
10 | now: now ? new Date(now) : undefined,
11 | timeZone,
12 | // messages: YAML.parse((await import(`./messages/${locale}.yml`)).default)
13 | messages: (await import(`@/messages/${locale}.yml`)).default,
14 | defaultTranslationValues: {
15 | globalString: 'Global string',
16 | highlight: (chunks) => {chunks}
17 | },
18 | formats: {
19 | dateTime: {
20 | medium: {
21 | dateStyle: 'medium',
22 | timeStyle: 'short',
23 | hour12: false
24 | }
25 | }
26 | },
27 | onError(error) {
28 | if (
29 | error.message ===
30 | (process.env.NODE_ENV === 'production'
31 | ? 'MISSING_MESSAGE'
32 | : 'MISSING_MESSAGE: Could not resolve `missing` in `Index`.')
33 | ) {
34 | // Do nothing, this error is triggered on purpose
35 | } else {
36 | console.error(JSON.stringify(error.message))
37 | }
38 | },
39 | getMessageFallback({ key, namespace }) {
40 | return (
41 | '`getMessageFallback` called for ' +
42 | [namespace, key].filter((part) => part != null).join('.')
43 | )
44 | }
45 | }
46 | })
47 |
--------------------------------------------------------------------------------
/app/[locale]/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from '@/libs/auth'
2 | import { Box } from '@chakra-ui/react'
3 | import { useTranslations } from 'next-intl'
4 | import { headers } from 'next/headers'
5 | import { redirect } from 'next/navigation'
6 | import Content from './_components/layouts/content'
7 | import GoBackButton from './_components/layouts/go-back-button'
8 | import Logo from './_components/layouts/logo'
9 | import styles from './layout.module.scss'
10 |
11 | type Props = {
12 | children: React.ReactNode
13 | }
14 |
15 | function GoBack() {
16 | const t = useTranslations()
17 |
18 | return (
19 |
20 | {t('auth.buttons.back')}
21 |
22 | )
23 | }
24 |
25 | export default async function AuthLayout(props: Props) {
26 | // lazyload image
27 | const header = await headers()
28 | const session = await auth()
29 | // console.log(session)
30 | if (session) {
31 | // if user is logged in, redirect to callback url or home
32 | const urlParams = new URLSearchParams(header.get('x-search-params') || '')
33 | const callbackUrl = urlParams.get('callbackUrl') || '/'
34 | redirect(callbackUrl)
35 | }
36 |
37 | return (
38 |
39 |
40 |
41 |
42 | {props.children}
43 |
44 |
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/components/ui/native-select.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { NativeSelect as Select } from '@chakra-ui/react'
4 | import * as React from 'react'
5 |
6 | interface NativeSelectRootProps extends Select.RootProps {
7 | icon?: React.ReactNode
8 | }
9 |
10 | export const NativeSelectRoot = React.forwardRef<
11 | HTMLDivElement,
12 | NativeSelectRootProps
13 | >(function NativeSelect(props, ref) {
14 | const { icon, children, ...rest } = props
15 | return (
16 |
17 | {children}
18 | {icon}
19 |
20 | )
21 | })
22 |
23 | interface NativeSelectItem {
24 | value: string
25 | label: string
26 | disabled?: boolean
27 | }
28 |
29 | interface NativeSelectField extends Select.FieldProps {
30 | items?: Array
31 | }
32 |
33 | export const NativeSelectField = React.forwardRef<
34 | HTMLSelectElement,
35 | NativeSelectField
36 | >(function NativeSelectField(props, ref) {
37 | const { items: itemsProp, children, ...rest } = props
38 |
39 | const items = React.useMemo(
40 | () =>
41 | itemsProp?.map((item) =>
42 | typeof item === 'string' ? { label: item, value: item } : item
43 | ),
44 | [itemsProp]
45 | )
46 |
47 | return (
48 |
49 | {children}
50 | {items?.map((item) => (
51 |
54 | ))}
55 |
56 | )
57 | })
58 |
--------------------------------------------------------------------------------
/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import { Alert as ChakraAlert } from '@chakra-ui/react'
2 | import { CloseButton } from './close-button'
3 | import * as React from 'react'
4 |
5 | export interface AlertProps extends Omit {
6 | startElement?: React.ReactNode
7 | endElement?: React.ReactNode
8 | title?: React.ReactNode
9 | icon?: React.ReactElement
10 | closable?: boolean
11 | onClose?: () => void
12 | }
13 |
14 | export const Alert = React.forwardRef(
15 | function Alert(props, ref) {
16 | const {
17 | title,
18 | children,
19 | icon,
20 | closable,
21 | onClose,
22 | startElement,
23 | endElement,
24 | ...rest
25 | } = props
26 | return (
27 |
28 | {startElement || {icon}}
29 | {children ? (
30 |
31 | {title}
32 | {children}
33 |
34 | ) : (
35 | {title}
36 | )}
37 | {endElement}
38 | {closable && (
39 |
47 | )}
48 |
49 | )
50 | }
51 | )
52 |
--------------------------------------------------------------------------------
/app/api/task/route.ts:
--------------------------------------------------------------------------------
1 | import { ResponseCode } from '@/enums/response'
2 | import { Role } from '@/enums/user'
3 | import { env } from '@/env.mjs'
4 | import client from '@/libs/prisma/client'
5 | import { NextRequest } from 'next/server'
6 |
7 | export async function GET(req: NextRequest) {
8 | if (
9 | env.CRON_TASK_TOKEN &&
10 | req.headers.get('Authorization') !== `Bearer ${env.CRON_TASK_TOKEN}`
11 | ) {
12 | return fail(ResponseCode.NotAuthorized, {})
13 | }
14 | try {
15 | // 1. Check if the first user is admin, if not, grant admin role to it.
16 | const firstUser = await client.user.findFirst({
17 | orderBy: {
18 | createdAt: 'asc'
19 | }
20 | })
21 | if (firstUser && firstUser.role !== Role.Admin) {
22 | await client.user.update({
23 | where: {
24 | id: firstUser.id
25 | },
26 | data: {
27 | role: Role.Admin
28 | }
29 | })
30 | }
31 |
32 | // 2. check whether the expired date is coming, if so, delete paste records
33 | const expiredPastes = await client.paste.findMany({
34 | where: {
35 | expiredAt: {
36 | lte: new Date()
37 | }
38 | }
39 | })
40 | if (expiredPastes && expiredPastes.length > 0) {
41 | await client.paste.deleteMany({
42 | where: {
43 | id: {
44 | in: expiredPastes.map((paste) => paste.id)
45 | }
46 | }
47 | })
48 | }
49 | } catch (error) {
50 | console.error(error)
51 | } finally {
52 | // always return success
53 | return success(null)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | import { Accordion, HStack } from '@chakra-ui/react'
2 | import * as React from 'react'
3 | import { LuChevronDown } from 'react-icons/lu'
4 |
5 | interface AccordionItemTriggerProps extends Accordion.ItemTriggerProps {
6 | indicatorPlacement?: 'start' | 'end'
7 | }
8 |
9 | export const AccordionItemTrigger = React.forwardRef<
10 | HTMLButtonElement,
11 | AccordionItemTriggerProps
12 | >(function AccordionItemTrigger(props, ref) {
13 | const { children, indicatorPlacement = 'end', ...rest } = props
14 | return (
15 |
16 | {indicatorPlacement === 'start' && (
17 |
18 |
19 |
20 | )}
21 |
22 | {children}
23 |
24 | {indicatorPlacement === 'end' && (
25 |
26 |
27 |
28 | )}
29 |
30 | )
31 | })
32 |
33 | interface AccordionItemContentProps extends Accordion.ItemContentProps {}
34 |
35 | export const AccordionItemContent = React.forwardRef<
36 | HTMLDivElement,
37 | AccordionItemContentProps
38 | >(function AccordionItemContent(props, ref) {
39 | return (
40 |
41 |
42 |
43 | )
44 | })
45 |
46 | export const AccordionRoot = Accordion.Root
47 | export const AccordionItem = Accordion.Item
48 |
--------------------------------------------------------------------------------
/app/api/auth/signup/_route.ts:
--------------------------------------------------------------------------------
1 | // import { ResponseCode } from '@/enums/response'
2 | // import { Role } from '@/enums/user'
3 | // import client from '@/libs/prisma/client'
4 | // import { createUser } from '@/libs/services/users/user'
5 | // import Joi from 'joi'
6 | // import { NextRequest } from 'next/server'
7 |
8 | // const userSignUpSchema = Joi.object({
9 | // email: Joi.string().email().required(),
10 | // name: Joi.string().min(2).max(32).required(),
11 | // password: Joi.string().min(8).max(100).required(),
12 | // passwordConfirm: Joi.string().valid(Joi.ref('password')).required()
13 | // })
14 |
15 | // type UserSignUp = {
16 | // email: string
17 | // name: string
18 | // password: string
19 | // passwordConfirm: string
20 | // }
21 |
22 | // export async function POST(req: NextRequest) {
23 | // const data = (await req.json()) as Partial
24 | // const { error } = userSignUpSchema.validate(data)
25 | // if (error) {
26 | // return fail(ResponseCode.ValidationFailed, {
27 | // data: error.details,
28 | // status: 400
29 | // })
30 | // }
31 | // const params = data // It just a type assertion
32 | // const user = await client.user.findFirst({
33 | // where: {
34 | // email: params.email
35 | // }
36 | // })
37 | // if (user) {
38 | // return fail(ResponseCode.OperationFailed, {
39 | // message: 'email already exists'
40 | // })
41 | // }
42 | // const newUser = await createUser(params.email, params.password, {
43 | // name: params.name,
44 | // role: Role.User
45 | // })
46 | // return success(newUser)
47 | // }
48 |
--------------------------------------------------------------------------------
/components/ui/stepper-input.tsx:
--------------------------------------------------------------------------------
1 | import { HStack, IconButton, NumberInput } from '@chakra-ui/react'
2 | import * as React from 'react'
3 | import { LuMinus, LuPlus } from 'react-icons/lu'
4 |
5 | export interface StepperInputProps extends NumberInput.RootProps {
6 | label?: React.ReactNode
7 | }
8 |
9 | export const StepperInput = React.forwardRef(
10 | function StepperInput(props, ref) {
11 | const { label, ...rest } = props
12 | return (
13 |
14 | {label && {label}}
15 |
16 |
17 |
18 |
19 |
20 |
21 | )
22 | }
23 | )
24 |
25 | const DecrementTrigger = React.forwardRef<
26 | HTMLButtonElement,
27 | NumberInput.DecrementTriggerProps
28 | >(function DecrementTrigger(props, ref) {
29 | return (
30 |
31 |
32 |
33 |
34 |
35 | )
36 | })
37 |
38 | const IncrementTrigger = React.forwardRef<
39 | HTMLButtonElement,
40 | NumberInput.IncrementTriggerProps
41 | >(function IncrementTrigger(props, ref) {
42 | return (
43 |
44 |
45 |
46 |
47 |
48 | )
49 | })
50 |
--------------------------------------------------------------------------------
/.vscode/i18n-ally-custom-framework.yml:
--------------------------------------------------------------------------------
1 | # .vscode/i18n-ally-custom-framework.yml
2 |
3 | # An array of strings which contain Language Ids defined by VS Code
4 | # You can check available language ids here: https://code.visualstudio.com/docs/languages/identifiers
5 | languageIds:
6 | - javascript
7 | - typescript
8 | - javascriptreact
9 | - typescriptreact
10 |
11 | # An array of RegExes to find the key usage. **The key should be captured in the first match group**.
12 | # You should unescape RegEx strings in order to fit in the YAML file
13 | # To help with this, you can use https://www.freeformatter.com/json-escape.html
14 | usageMatchRegex:
15 | # The following example shows how to detect `t("your.i18n.keys")`
16 | # the `{key}` will be placed by a proper keypath matching regex,
17 | # you can ignore it and use your own matching rules as well
18 | - "[^\\w\\d]wrapTranslationKey\\s*\\(\\s*['\"`]({key})['\"`]"
19 |
20 | # A RegEx to set a custom scope range. This scope will be used as a prefix when detecting keys
21 | # and works like how the i18next framework identifies the namespace scope from the
22 | # useTranslation() hook.
23 | # You should unescape RegEx strings in order to fit in the YAML file
24 | # To help with this, you can use https://www.freeformatter.com/json-escape.html
25 | scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]"
26 |
27 | # An array of strings containing refactor templates.
28 | # The "$1" will be replaced by the keypath specified.
29 | # Optional: uncomment the following two lines to use
30 |
31 | # refactorTemplates:
32 | # - i18n.get("$1")
33 |
34 | # If set to true, only enables this custom framework (will disable all built-in frameworks)
35 | monopoly: false
36 |
--------------------------------------------------------------------------------
/components/ui/toggle.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { ButtonProps } from '@chakra-ui/react'
4 | import {
5 | Button,
6 | Toggle as ChakraToggle,
7 | useToggleContext
8 | } from '@chakra-ui/react'
9 | import * as React from 'react'
10 |
11 | interface ToggleProps extends ChakraToggle.RootProps {
12 | variant?: keyof typeof variantMap
13 | size?: ButtonProps['size']
14 | }
15 |
16 | const variantMap = {
17 | solid: { on: 'solid', off: 'outline' },
18 | surface: { on: 'surface', off: 'outline' },
19 | subtle: { on: 'subtle', off: 'ghost' },
20 | ghost: { on: 'subtle', off: 'ghost' }
21 | } as const
22 |
23 | export const Toggle = React.forwardRef(
24 | function Toggle(props, ref) {
25 | const { variant = 'subtle', size, children, ...rest } = props
26 | const variantConfig = variantMap[variant]
27 |
28 | return (
29 |
30 |
31 | {children}
32 |
33 |
34 | )
35 | }
36 | )
37 |
38 | interface ToggleBaseButtonProps extends Omit {
39 | variant: Record<'on' | 'off', ButtonProps['variant']>
40 | }
41 |
42 | const ToggleBaseButton = React.forwardRef<
43 | HTMLButtonElement,
44 | ToggleBaseButtonProps
45 | >(function ToggleBaseButton(props, ref) {
46 | const toggle = useToggleContext()
47 | const { variant, ...rest } = props
48 | return (
49 |
54 | )
55 | })
56 |
57 | export const ToggleIndicator = ChakraToggle.Indicator
58 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/v/[cuid]/raw/route.ts:
--------------------------------------------------------------------------------
1 | import { PasteType } from '@/enums/paste'
2 | import client from '@/libs/prisma/client'
3 | import type { Content } from '@/libs/validation/paste'
4 | import { NextRequest } from 'next/server'
5 |
6 | export async function GET(
7 | req: NextRequest,
8 | { params }: { params: Promise<{ cuid: string }> }
9 | ) {
10 | const { cuid } = await params
11 | if (!cuid) return new Response('Not Found', { status: 404 })
12 | const result = await client.paste.findUnique({
13 | where: { id: cuid }
14 | })
15 | if (!result) return new Response('Not Found', { status: 404 })
16 | switch (result.type) {
17 | case PasteType.Gist:
18 | const filename = req.nextUrl.searchParams.get('filename')
19 | if (!filename)
20 | return new Response(
21 | `Not supported operation. You might want to view /v/${cuid}?filename=example.txt?`,
22 | { status: 400 }
23 | )
24 | const content = (result.content as Array).find(
25 | (content) => content.filename === filename
26 | ) as Content | undefined
27 | if (!content) return new Response('Not Found', { status: 404 })
28 | return new Response(content.content, {
29 | headers: {
30 | 'Content-Type': 'text/plain; charset=utf-8'
31 | }
32 | })
33 | case PasteType.Normal:
34 | return new Response((result.content as Array)[0].content, {
35 | headers: {
36 | 'Content-Type': 'text/plain; charset=utf-8'
37 | }
38 | })
39 | default:
40 | return new Response(
41 | `Not Supported paste type. Maybe you want to view /v/${cuid}/raw/filename`,
42 | { status: 400 }
43 | )
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/utils/cookies.ts:
--------------------------------------------------------------------------------
1 | import { env } from '@/env.mjs'
2 | import { sign, unsign } from 'cookie-signature'
3 | import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies'
4 | import { cookies } from 'next/headers'
5 | import 'server-only'
6 | import { z } from 'zod'
7 |
8 | export class CookieSignatureMismatchError extends Error {
9 | constructor() {
10 | super('Cookie signature mismatch')
11 | }
12 | }
13 |
14 | export async function setCookie(
15 | name: string,
16 | value: string,
17 | options: Partial = {}
18 | ) {
19 | const cookies_jar = await cookies()
20 | cookies_jar.set(
21 | name,
22 | options.sign ? sign(value, env.NEXTAUTH_SECRET) : value,
23 | options
24 | )
25 | }
26 |
27 | export async function getCookie(
28 | name: string,
29 | options: Partial<{ signed: boolean }> = {}
30 | ) {
31 | const cookies_jar = await cookies()
32 | const item = cookies_jar.get(name)
33 | if (options.signed) {
34 | if (!item) return null
35 | const unsigned = unsign(item.value, env.NEXTAUTH_SECRET)
36 | if (!unsigned) throw new CookieSignatureMismatchError()
37 | item.value = unsigned
38 | }
39 | return item
40 | }
41 |
42 | export async function checkTwiceSignedCookie() {
43 | const signedTwiceConfirmationToken = await getCookie('user.twice_confirmed', {
44 | signed: true
45 | })
46 | if (!signedTwiceConfirmationToken) throw new Error('No signed token found')
47 | const arr = signedTwiceConfirmationToken.value.split('.')
48 | if (arr.length !== 2) throw new Error('Invalid signed token')
49 | z.string().uuid().parse(arr[0]) // Check token
50 | if (Date.now() - Number(arr[1]) > 1000 * 60 * 15) {
51 | // 15 minutes
52 | throw new Error('Token expired')
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/utils/types.ts:
--------------------------------------------------------------------------------
1 | // expands object types one level deep
2 | export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never
3 |
4 | // expands object types recursively
5 | export type ExpandRecursively = T extends object
6 | ? T extends infer O
7 | ? { [K in keyof O]: ExpandRecursively }
8 | : never
9 | : T
10 | // distributive conditional types
11 | // ref: https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
13 | export type DistributiveOmit = T extends any
14 | ? Omit
15 | : never
16 |
17 | // Another implementation of Distributive Pick
18 | // Ref https://dev.to/safareli/pick-omit-and-union-types-in-typescript-4nd9
19 | export type Keys = keyof T
20 | export type DistributiveKeys = T extends unknown ? Keys : never
21 | export type DistributivePick<
22 | T,
23 | K extends DistributiveKeys
24 | > = T extends unknown ? Pick> : never
25 |
26 | // export type DistributiveOmit<
27 | // T,
28 | // K extends DistributiveKeys
29 | // > = T extends unknown ? Omit> : never
30 |
31 | // React related types
32 |
33 | export type ReducerAction = {
34 | [K in keyof O]: {
35 | type: U
36 | field: K
37 | value: O[K]
38 | }
39 | }[keyof O]
40 |
41 | export type ReducerActionBatch = {
42 | type: U
43 | state: Partial
44 | }
45 |
46 | export type ReducerDispatcher<
47 | O,
48 | U extends string,
49 | Batch extends boolean = false
50 | > = (
51 | state: O,
52 | action: Batch extends true ? ReducerActionBatch : ReducerAction
53 | ) => O
54 |
55 | export type Awaitable = T | Promise
56 |
--------------------------------------------------------------------------------
/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | import { Drawer as ChakraDrawer, Portal } from '@chakra-ui/react'
2 | import { CloseButton } from './close-button'
3 | import * as React from 'react'
4 |
5 | interface DrawerContentProps extends ChakraDrawer.ContentProps {
6 | portalled?: boolean
7 | portalRef?: React.RefObject
8 | offset?: ChakraDrawer.ContentProps['padding']
9 | }
10 |
11 | export const DrawerContent = React.forwardRef<
12 | HTMLDivElement,
13 | DrawerContentProps
14 | >(function DrawerContent(props, ref) {
15 | const { children, portalled = true, portalRef, offset, ...rest } = props
16 | return (
17 |
18 |
19 |
20 | {children}
21 |
22 |
23 |
24 | )
25 | })
26 |
27 | export const DrawerCloseTrigger = React.forwardRef<
28 | HTMLButtonElement,
29 | ChakraDrawer.CloseTriggerProps
30 | >(function DrawerCloseTrigger(props, ref) {
31 | return (
32 |
39 |
40 |
41 | )
42 | })
43 |
44 | export const DrawerTrigger = ChakraDrawer.Trigger
45 | export const DrawerRoot = ChakraDrawer.Root
46 | export const DrawerFooter = ChakraDrawer.Footer
47 | export const DrawerHeader = ChakraDrawer.Header
48 | export const DrawerBody = ChakraDrawer.Body
49 | export const DrawerBackdrop = ChakraDrawer.Backdrop
50 | export const DrawerDescription = ChakraDrawer.Description
51 | export const DrawerTitle = ChakraDrawer.Title
52 | export const DrawerActionTrigger = ChakraDrawer.ActionTrigger
53 |
--------------------------------------------------------------------------------
/hooks/requests/user.ts:
--------------------------------------------------------------------------------
1 | import type { APIAuthenticatorResponse } from '@/app/api/user/authenticators/route'
2 | import { ResponseCode } from '@/enums/response'
3 | import { ofetchClient as $fetch } from '@/libs/requests'
4 | import { R } from '@/utils/response'
5 | import {
6 | UseQueryOptions,
7 | UseQueryResult,
8 | useQuery
9 | } from '@tanstack/react-query'
10 | import { toaster } from '@/components/ui/toaster'
11 |
12 | export function useUserAuthenticatorsQuery(
13 | options?: Partial>
14 | ): UseQueryResult>, Error> {
15 | return useQuery({
16 | queryKey: ['user-authenticators'],
17 | queryFn: (): Promise>> =>
18 | $fetch>>('/api/user/authenticators', {
19 | credentials: 'include',
20 | onResponse({ response }) {
21 | if (
22 | response.headers.get('content-disposition') &&
23 | response.status === 200
24 | )
25 | return response
26 | if (response._data.code !== ResponseCode.OK) {
27 | toaster.error({
28 | title: 'Failed to get authenticators',
29 | description: response._data.message,
30 | duration: 5000
31 | })
32 | return Promise.reject(response._data)
33 | }
34 | return response._data
35 | },
36 | onResponseError({ response }) {
37 | toaster.create({
38 | title: 'Failed to get authenticators',
39 | description: response._data.message,
40 | duration: 5000
41 | })
42 | return Promise.reject(response?._data ?? null)
43 | }
44 | }),
45 | ...(options || {})
46 | }) as UseQueryResult>, Error>
47 | }
48 |
--------------------------------------------------------------------------------
/scripts/tools/gen-oauth.ts:
--------------------------------------------------------------------------------
1 | import pkg from '@/package.json'
2 | import { getRuntimeMode, loadEnv } from '@/utils/app'
3 | import chalk from 'chalk'
4 | import { execSync } from 'node:child_process'
5 | import fs from 'node:fs/promises'
6 | import path from 'path'
7 | const mode = getRuntimeMode()
8 | const rootDir = path.resolve(__dirname, '../../')
9 |
10 | loadEnv()
11 |
12 | async function main() {
13 | console.log(
14 | chalk.cyan(`[gen-oauth] Generating OAuth providers for ${mode}...`)
15 | )
16 | const imports = []
17 | const drivers = []
18 | const { authConfig } = await import('@/libs/auth/config')
19 | for (const provider of authConfig.providers) {
20 | console.log(
21 | chalk.gray('Detected provider:'),
22 | chalk.yellow(provider.name),
23 | provider.id === 'credentials' ? 'skipped.' : 'generating...'
24 | )
25 | if (provider.id === 'credentials') continue
26 | imports.push(
27 | `import ${provider.name}Icon from '~icons/mdi/${
28 | pkg?.oauth?.[provider.id as keyof typeof pkg.oauth] || provider.id
29 | }'`
30 | )
31 | drivers.push(`{
32 | id: '${provider.id}',
33 | name: '${provider.name}',
34 | icon: <${provider.name}Icon />
35 | }`)
36 | }
37 |
38 | const result =
39 | imports.join('\n') +
40 | '\n' +
41 | `export const providers = [${drivers.join(',\n')}]`
42 |
43 | console.log(chalk.green('[gen-oauth] Writing to file...'))
44 | const filePath = path.resolve(rootDir, 'libs/auth/providers.tsx')
45 | await fs.writeFile(filePath, result, 'utf-8')
46 |
47 | console.log('Do format and lint...')
48 | execSync(
49 | path.resolve(rootDir, 'node_modules/.bin/prettier --write ' + filePath)
50 | )
51 | execSync(path.resolve(rootDir, 'node_modules/.bin/eslint --fix ' + filePath))
52 | console.log(chalk.green('[gen-oauth] Done!'))
53 | }
54 |
55 | main()
56 |
--------------------------------------------------------------------------------
/utils/app.ts:
--------------------------------------------------------------------------------
1 | import { RuntimeMode } from '@/enums/app'
2 | import { config } from 'dotenv'
3 | import path from 'node:path'
4 |
5 | export function getRuntimeMode(): RuntimeMode {
6 | return !process.env.NODE_ENV
7 | ? RuntimeMode.Development
8 | : process.env.NODE_ENV.toLowerCase() === 'production'
9 | ? RuntimeMode.Production
10 | : process.env.NODE_ENV.toLowerCase() === 'test'
11 | ? RuntimeMode.Test
12 | : RuntimeMode.Development
13 | }
14 |
15 | /**
16 | * Load environment variables from .env files
17 | * This helpers is used in scripts.
18 | *
19 | * Load order, ref: https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables#environment-variable-load-order
20 | * @param {RuntimeMode|undefined} mode - runtime mode
21 | */
22 | export function loadEnv(mode?: RuntimeMode): void {
23 | if (!mode) mode = getRuntimeMode()
24 | const rootPath = path.resolve(__dirname, '../')
25 | // 1. process.env
26 | // 2. load .env.[mode].local
27 | switch (mode) {
28 | case RuntimeMode.Development:
29 | config({ path: path.join(rootPath, '.env.development.local') })
30 | case RuntimeMode.Production:
31 | config({ path: path.join(rootPath, '.env.production.local') })
32 | case RuntimeMode.Test:
33 | config({ path: path.join(rootPath, '.env.test.local') })
34 | }
35 | // 3. load .env.local
36 | config({ path: path.join(rootPath, '.env.local') })
37 | // 4. load .env.[mode]
38 | switch (mode) {
39 | case RuntimeMode.Development:
40 | config({ path: path.join(rootPath, '.env.development') })
41 | case RuntimeMode.Production:
42 | config({ path: path.join(rootPath, '.env.production') })
43 | case RuntimeMode.Test:
44 | config({ path: path.join(rootPath, '.env.test') })
45 | }
46 | // 5. load .env
47 | config({ path: path.join(rootPath, '.env') })
48 | }
49 |
--------------------------------------------------------------------------------
/components/ui/input-group.tsx:
--------------------------------------------------------------------------------
1 | import type { BoxProps, InputElementProps } from '@chakra-ui/react'
2 | import { Group, InputElement } from '@chakra-ui/react'
3 | import * as React from 'react'
4 |
5 | export interface InputGroupProps extends BoxProps {
6 | startElementProps?: InputElementProps
7 | endElementProps?: InputElementProps
8 | startElement?: React.ReactNode
9 | endElement?: React.ReactNode
10 | children: React.ReactElement
11 | startOffset?: InputElementProps['paddingStart']
12 | endOffset?: InputElementProps['paddingEnd']
13 | }
14 |
15 | export const InputGroup = React.forwardRef(
16 | function InputGroup(props, ref) {
17 | const {
18 | startElement,
19 | startElementProps,
20 | endElement,
21 | endElementProps,
22 | children,
23 | startOffset = '6px',
24 | endOffset = '6px',
25 | ...rest
26 | } = props
27 |
28 | const child =
29 | // @ts-expect-error we cannot handle the upstream error
30 | React.Children.only>(children)
31 |
32 | return (
33 |
34 | {startElement && (
35 |
36 | {startElement}
37 |
38 | )}
39 | {React.cloneElement(child, {
40 | ...(startElement && {
41 | ps: `calc(var(--input-height) - ${startOffset})`
42 | }),
43 | ...(endElement && { pe: `calc(var(--input-height) - ${endOffset})` }),
44 | // @ts-expect-error we cannot handle the upstream error
45 | ...children.props
46 | })}
47 | {endElement && (
48 |
49 | {endElement}
50 |
51 | )}
52 |
53 | )
54 | }
55 | )
56 |
--------------------------------------------------------------------------------
/app/api/user/authenticators/route.ts:
--------------------------------------------------------------------------------
1 | import { ResponseCode } from '@/enums/response'
2 | import { auth } from '@/libs/auth'
3 | import client from '@/libs/prisma/client'
4 | import { NextRequest, type NextResponse } from 'next/server'
5 | import { z } from 'zod'
6 |
7 | export type APIAuthenticatorResponse = {
8 | name?: string
9 | credentialID: string
10 | updatedAt: Date
11 | createdAt: Date
12 | }
13 |
14 | export const GET = auth(async function (req): Promise {
15 | if (!req.auth) return fail(ResponseCode.NotAuthorized, {})
16 | // Check signed token
17 | try {
18 | const signedTwiceConfirmationToken = await getCookie(
19 | 'user.twice_confirmed',
20 | {
21 | signed: true
22 | }
23 | )
24 | if (!signedTwiceConfirmationToken) throw new Error('No signed token found')
25 | const arr = signedTwiceConfirmationToken.value.split('.')
26 | if (arr.length !== 2) throw new Error('Invalid signed token')
27 | z.string().uuid().parse(arr[0]) // Check token
28 | if (Date.now() - Number(arr[1]) > 1000 * 60 * 15) {
29 | // 15 minutes
30 | throw new Error('Token expired')
31 | }
32 | } catch (e) {
33 | return fail(ResponseCode.NotAuthorized, {
34 | message: (e as Error).message
35 | })
36 | }
37 |
38 | // Get authenticators
39 | const authenticators = await client.authenticator.findMany({
40 | where: { userId: req.auth.user.id },
41 | orderBy: { createdAt: 'desc' }
42 | })
43 | return success(
44 | authenticators.map(
45 | (authenticator) =>
46 | ({
47 | name: authenticator.name,
48 | credentialID: authenticator.credentialID,
49 | updatedAt: authenticator.updatedAt, // Last used
50 | createdAt: authenticator.createdAt
51 | }) as APIAuthenticatorResponse
52 | )
53 | )
54 | }) as unknown as (req: NextRequest) => Promise
55 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import { Popover as ChakraPopover, Portal } from '@chakra-ui/react'
2 | import { CloseButton } from './close-button'
3 | import * as React from 'react'
4 |
5 | interface PopoverContentProps extends ChakraPopover.ContentProps {
6 | portalled?: boolean
7 | portalRef?: React.RefObject
8 | }
9 |
10 | export const PopoverContent = React.forwardRef<
11 | HTMLDivElement,
12 | PopoverContentProps
13 | >(function PopoverContent(props, ref) {
14 | const { portalled = true, portalRef, ...rest } = props
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | )
22 | })
23 |
24 | export const PopoverArrow = React.forwardRef<
25 | HTMLDivElement,
26 | ChakraPopover.ArrowProps
27 | >(function PopoverArrow(props, ref) {
28 | return (
29 |
30 |
31 |
32 | )
33 | })
34 |
35 | export const PopoverCloseTrigger = React.forwardRef<
36 | HTMLButtonElement,
37 | ChakraPopover.CloseTriggerProps
38 | >(function PopoverCloseTrigger(props, ref) {
39 | return (
40 |
48 |
49 |
50 | )
51 | })
52 |
53 | export const PopoverTitle = ChakraPopover.Title
54 | export const PopoverDescription = ChakraPopover.Description
55 | export const PopoverFooter = ChakraPopover.Footer
56 | export const PopoverHeader = ChakraPopover.Header
57 | export const PopoverRoot = ChakraPopover.Root
58 | export const PopoverBody = ChakraPopover.Body
59 | export const PopoverTrigger = ChakraPopover.Trigger
60 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import { Dialog as ChakraDialog, Portal } from '@chakra-ui/react'
2 | import { CloseButton } from './close-button'
3 | import * as React from 'react'
4 |
5 | interface DialogContentProps extends ChakraDialog.ContentProps {
6 | portalled?: boolean
7 | portalRef?: React.RefObject
8 | backdrop?: boolean
9 | }
10 |
11 | export const DialogContent = React.forwardRef<
12 | HTMLDivElement,
13 | DialogContentProps
14 | >(function DialogContent(props, ref) {
15 | const {
16 | children,
17 | portalled = true,
18 | portalRef,
19 | backdrop = true,
20 | ...rest
21 | } = props
22 |
23 | return (
24 |
25 | {backdrop && }
26 |
27 |
28 | {children}
29 |
30 |
31 |
32 | )
33 | })
34 |
35 | export const DialogCloseTrigger = React.forwardRef<
36 | HTMLButtonElement,
37 | ChakraDialog.CloseTriggerProps
38 | >(function DialogCloseTrigger(props, ref) {
39 | return (
40 |
47 |
48 | {props.children}
49 |
50 |
51 | )
52 | })
53 |
54 | export const DialogRoot = ChakraDialog.Root
55 | export const DialogFooter = ChakraDialog.Footer
56 | export const DialogHeader = ChakraDialog.Header
57 | export const DialogBody = ChakraDialog.Body
58 | export const DialogBackdrop = ChakraDialog.Backdrop
59 | export const DialogTitle = ChakraDialog.Title
60 | export const DialogDescription = ChakraDialog.Description
61 | export const DialogTrigger = ChakraDialog.Trigger
62 | export const DialogActionTrigger = ChakraDialog.ActionTrigger
63 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/v/[cuid]/_components/shiki/Header.tsx:
--------------------------------------------------------------------------------
1 | import { getDisplayNameByLanguageID } from '@/libs/shiki'
2 | import { Box } from '@chakra-ui/react'
3 | import { motion } from 'framer-motion'
4 | import styles from '../CodePreview.module.scss'
5 | type Props = {
6 | lang: string
7 | onCopyClick?: (e: MouseEvent | PointerEvent | TouchEvent) => void
8 | onShareClick?: (e: MouseEvent | PointerEvent | TouchEvent) => void
9 | }
10 |
11 | export default function ShikiHeader(props: Props) {
12 | return (
13 |
14 |
15 | {getDisplayNameByLanguageID(props.lang)}
16 |
17 |
18 | {
31 | if (props.onShareClick) props.onShareClick(e)
32 | }}
33 | >
34 |
35 |
36 | {
49 | if (props.onCopyClick) props.onCopyClick(e)
50 | }}
51 | >
52 |
53 |
54 |
55 |
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/components/ui/radio-card.tsx:
--------------------------------------------------------------------------------
1 | import { RadioCard } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | interface RadioCardItemProps extends RadioCard.ItemProps {
5 | icon?: React.ReactElement
6 | label?: React.ReactNode
7 | description?: React.ReactNode
8 | addon?: React.ReactNode
9 | indicator?: React.ReactNode | null
10 | indicatorPlacement?: 'start' | 'end' | 'inside'
11 | inputProps?: React.InputHTMLAttributes
12 | }
13 |
14 | export const RadioCardItem = React.forwardRef<
15 | HTMLInputElement,
16 | RadioCardItemProps
17 | >(function RadioCardItem(props, ref) {
18 | const {
19 | inputProps,
20 | label,
21 | description,
22 | addon,
23 | icon,
24 | indicator = ,
25 | indicatorPlacement = 'end',
26 | ...rest
27 | } = props
28 |
29 | const hasContent = label || description || icon
30 | const ContentWrapper = indicator ? RadioCard.ItemContent : React.Fragment
31 |
32 | return (
33 |
34 |
35 |
36 | {indicatorPlacement === 'start' && indicator}
37 | {hasContent && (
38 |
39 | {icon}
40 | {label && {label}}
41 | {description && (
42 |
43 | {description}
44 |
45 | )}
46 | {indicatorPlacement === 'inside' && indicator}
47 |
48 | )}
49 | {indicatorPlacement === 'end' && indicator}
50 |
51 | {addon && {addon}}
52 |
53 | )
54 | })
55 |
56 | export const RadioCardRoot = RadioCard.Root
57 | export const RadioCardLabel = RadioCard.Label
58 | export const RadioCardItemIndicator = RadioCard.ItemIndicator
59 |
--------------------------------------------------------------------------------
/components/ui/checkbox-card.tsx:
--------------------------------------------------------------------------------
1 | import { CheckboxCard as ChakraCheckboxCard } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface CheckboxCardProps extends ChakraCheckboxCard.RootProps {
5 | icon?: React.ReactElement
6 | label?: React.ReactNode
7 | description?: React.ReactNode
8 | addon?: React.ReactNode
9 | indicator?: React.ReactNode | null
10 | indicatorPlacement?: 'start' | 'end' | 'inside'
11 | inputProps?: React.InputHTMLAttributes
12 | }
13 |
14 | export const CheckboxCard = React.forwardRef<
15 | HTMLInputElement,
16 | CheckboxCardProps
17 | >(function CheckboxCard(props, ref) {
18 | const {
19 | inputProps,
20 | label,
21 | description,
22 | icon,
23 | addon,
24 | indicator = ,
25 | indicatorPlacement = 'end',
26 | ...rest
27 | } = props
28 |
29 | const hasContent = label || description || icon
30 | const ContentWrapper = indicator ? ChakraCheckboxCard.Content : React.Fragment
31 |
32 | return (
33 |
34 |
35 |
36 | {indicatorPlacement === 'start' && indicator}
37 | {hasContent && (
38 |
39 | {icon}
40 | {label && (
41 | {label}
42 | )}
43 | {description && (
44 |
45 | {description}
46 |
47 | )}
48 | {indicatorPlacement === 'inside' && indicator}
49 |
50 | )}
51 | {indicatorPlacement === 'end' && indicator}
52 |
53 | {addon && {addon}}
54 |
55 | )
56 | })
57 |
58 | export const CheckboxCardIndicator = ChakraCheckboxCard.Indicator
59 |
--------------------------------------------------------------------------------
/components/ui/stat.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Badge,
3 | type BadgeProps,
4 | Stat as ChakraStat,
5 | FormatNumber
6 | } from '@chakra-ui/react'
7 | import { InfoTip } from './toggle-tip'
8 | import * as React from 'react'
9 |
10 | interface StatLabelProps extends ChakraStat.LabelProps {
11 | info?: React.ReactNode
12 | }
13 |
14 | export const StatLabel = React.forwardRef(
15 | function StatLabel(props, ref) {
16 | const { info, children, ...rest } = props
17 | return (
18 |
19 | {children}
20 | {info && {info}}
21 |
22 | )
23 | }
24 | )
25 |
26 | interface StatValueTextProps extends ChakraStat.ValueTextProps {
27 | value?: number
28 | formatOptions?: Intl.NumberFormatOptions
29 | }
30 |
31 | export const StatValueText = React.forwardRef<
32 | HTMLDivElement,
33 | StatValueTextProps
34 | >(function StatValueText(props, ref) {
35 | const { value, formatOptions, children, ...rest } = props
36 | return (
37 |
38 | {children ||
39 | (value != null && )}
40 |
41 | )
42 | })
43 |
44 | export const StatUpTrend = React.forwardRef(
45 | function StatUpTrend(props, ref) {
46 | return (
47 |
48 |
49 | {props.children}
50 |
51 | )
52 | }
53 | )
54 |
55 | export const StatDownTrend = React.forwardRef(
56 | function StatDownTrend(props, ref) {
57 | return (
58 |
59 |
60 | {props.children}
61 |
62 | )
63 | }
64 | )
65 |
66 | export const StatRoot = ChakraStat.Root
67 | export const StatHelpText = ChakraStat.HelpText
68 | export const StatValueUnit = ChakraStat.ValueUnit
69 |
--------------------------------------------------------------------------------
/components/ui/toggle-tip.tsx:
--------------------------------------------------------------------------------
1 | import { Popover as ChakraPopover, IconButton, Portal } from '@chakra-ui/react'
2 | import * as React from 'react'
3 | import { HiOutlineInformationCircle } from 'react-icons/hi'
4 |
5 | export interface ToggleTipProps extends ChakraPopover.RootProps {
6 | showArrow?: boolean
7 | portalled?: boolean
8 | portalRef?: React.RefObject
9 | content?: React.ReactNode
10 | }
11 |
12 | export const ToggleTip = React.forwardRef(
13 | function ToggleTip(props, ref) {
14 | const {
15 | showArrow,
16 | children,
17 | portalled = true,
18 | content,
19 | portalRef,
20 | ...rest
21 | } = props
22 |
23 | return (
24 |
28 | {children}
29 |
30 |
31 |
39 | {showArrow && (
40 |
41 |
42 |
43 | )}
44 | {content}
45 |
46 |
47 |
48 |
49 | )
50 | }
51 | )
52 |
53 | export const InfoTip = React.forwardRef<
54 | HTMLDivElement,
55 | Partial
56 | >(function InfoTip(props, ref) {
57 | const { children, ...rest } = props
58 | return (
59 |
60 |
66 |
67 |
68 |
69 | )
70 | })
71 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/v/[cuid]/_components/CodePreview.module.scss:
--------------------------------------------------------------------------------
1 | .code-preview {
2 | @apply m-0 flex;
3 |
4 | &,
5 | :where(pre, code, kbd, samp) {
6 | @apply font-code;
7 |
8 | font-variant-ligatures: contextual;
9 | }
10 |
11 | .shiki-container {
12 | @apply flex-1 relative overflow-hidden flex;
13 |
14 | .header {
15 | @apply block text-right text-gray-400 select-none absolute top-0 right-0 w-full;
16 |
17 | .language-name {
18 | @apply whitespace-normal absolute right-8 top-1.5 text-xs;
19 | @apply duration-75 transition-all ease-linear opacity-100;
20 | }
21 |
22 | .actions-group {
23 | @apply grid grid-cols-2 gap-2 absolute right-5 top-3 text-xs w-fit h-10;
24 | @apply duration-75 transition-all ease-linear opacity-0 scale-0;
25 |
26 | .share-button,
27 | .copy-button {
28 | @apply flex items-center justify-center text-xs w-10 h-10 rounded-lg border border-solid border-gray-200 cursor-pointer bg-white opacity-80;
29 | }
30 | }
31 | }
32 |
33 | .content {
34 | @apply pl-5 py-3 flex-1 overflow-x-auto;
35 |
36 | scrollbar-width: none;
37 | }
38 | }
39 |
40 | &:hover {
41 | .shiki-container {
42 | .header {
43 | .language-name {
44 | @apply opacity-0;
45 | }
46 |
47 | .actions-group {
48 | @apply opacity-100 scale-100;
49 | }
50 | }
51 | }
52 | }
53 |
54 | .line-numbers-container {
55 | @apply block border-0 border-r border-solid border-gray-200 w-auto py-3;
56 |
57 | .line-number {
58 | @apply block text-right text-gray-400 select-none;
59 | @apply pl-2 pr-3;
60 |
61 | width: var(--line-numbers-width, 2em);
62 | }
63 | }
64 |
65 | // .shiki {
66 | // .line[data-line] {
67 | // &::before {
68 | // @apply inline-block text-right text-gray-400 select-none;
69 | // @apply pl-2 mr-5 pr-3 border-0 border-r-1 border-solid border-gray-200;
70 |
71 | // width: var(--line-numbers-width, 2em);
72 | // content: attr(data-line);
73 | // }
74 | // }
75 | // }
76 | }
77 |
--------------------------------------------------------------------------------
/components/ui/color-mode.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { IconButtonProps } from '@chakra-ui/react'
4 | import { ClientOnly, IconButton, Skeleton } from '@chakra-ui/react'
5 | import { ThemeProvider, useTheme } from 'next-themes'
6 | import type { ThemeProviderProps } from 'next-themes'
7 | import * as React from 'react'
8 | import { LuMoon, LuSun } from 'react-icons/lu'
9 |
10 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
11 | export interface ColorModeProviderProps extends ThemeProviderProps {}
12 |
13 | export function ColorModeProvider(props: ColorModeProviderProps) {
14 | return (
15 |
16 | )
17 | }
18 |
19 | export function useColorMode() {
20 | const { resolvedTheme, setTheme } = useTheme()
21 | const toggleColorMode = () => {
22 | setTheme(resolvedTheme === 'light' ? 'dark' : 'light')
23 | }
24 | return {
25 | colorMode: resolvedTheme,
26 | setColorMode: setTheme,
27 | toggleColorMode
28 | }
29 | }
30 |
31 | export function useColorModeValue(light: T, dark: T) {
32 | const { colorMode } = useColorMode()
33 | return colorMode === 'light' ? light : dark
34 | }
35 |
36 | export function ColorModeIcon() {
37 | const { colorMode } = useColorMode()
38 | return colorMode === 'light' ? :
39 | }
40 |
41 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
42 | interface ColorModeButtonProps extends Omit {}
43 |
44 | export const ColorModeButton = React.forwardRef<
45 | HTMLButtonElement,
46 | ColorModeButtonProps
47 | >(function ColorModeButton(props, ref) {
48 | const { toggleColorMode } = useColorMode()
49 | return (
50 | }>
51 |
65 |
66 |
67 |
68 | )
69 | })
70 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/_components/layout/header/Navigation.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Link, usePathname } from '@/libs/navigation'
3 | import { Disclosure } from '@headlessui/react'
4 | import { useTranslations } from 'next-intl'
5 | import { useMemo } from 'react'
6 | import styles from '../Header.module.scss'
7 |
8 | const items = [
9 | { key: 'home', href: '/', current: true },
10 | { key: 'about', href: '/about', current: false },
11 | {
12 | key: 'github',
13 | href: 'https://github.com/greenhat616/pastebin',
14 | current: false
15 | }
16 | ]
17 |
18 | type Props = {
19 | className?: string
20 | platform?: 'pc' | 'mobile'
21 | }
22 |
23 | export default function Navigation(props: Props) {
24 | const { platform = 'pc' } = props
25 | const pathname = usePathname()
26 | const t = useTranslations('app.nav.menu')
27 | const filteredItems = useMemo(() => {
28 | return items.map((item) => {
29 | return {
30 | ...item,
31 | current: item.href === pathname
32 | }
33 | })
34 | }, [pathname])
35 |
36 | return (
37 | <>
38 | {filteredItems.map((item) =>
39 | platform === 'pc' ? (
40 |
51 | {t(item.key)}
52 |
53 | ) : (
54 |
66 | {t(item.key)}
67 |
68 | )
69 | )}
70 | >
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/app/[locale]/layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | // Global CSS
4 | // import 'uno.css'
5 | import '@/styles/global.scss'
6 |
7 | // Lib
8 | import { Analytics } from '@vercel/analytics/react'
9 | // import { useLocale } from 'next-intl'
10 | import { notFound } from 'next/navigation'
11 | // import { Inter } from 'next/font/google'
12 |
13 | import { Metadata } from 'next'
14 | import { useLocale } from 'next-intl'
15 | import {
16 | getFormatter,
17 | getNow,
18 | getTimeZone,
19 | getTranslations
20 | } from 'next-intl/server'
21 |
22 | import { UnoCSSIndicator } from '@/components/uno-css-indicator'
23 | import { Fira_Code } from 'next/font/google'
24 | import { QueryClientProvider } from '@/components/provider'
25 | import { Toaster } from '@/components/ui/toaster'
26 | import { Provider } from '@/components/ui/provider'
27 | const firaCode = Fira_Code({
28 | variable: '--font-fira-code',
29 | subsets: ['latin', 'cyrillic']
30 | })
31 |
32 | export async function generateMetadata({
33 | params: { locale }
34 | }: Omit): Promise {
35 | const t = await getTranslations({
36 | locale: locale,
37 | namespace: 'app'
38 | })
39 | const formatter = await getFormatter({ locale })
40 | const now = await getNow({ locale })
41 | const timeZone = await getTimeZone({ locale })
42 |
43 | return {
44 | title: t('name'),
45 | description: t('description'),
46 | other: {
47 | currentYear: formatter.dateTime(now, { year: 'numeric' }),
48 | timeZone: timeZone || 'Asia/Shanghai'
49 | }
50 | }
51 | }
52 |
53 | type Props = {
54 | children: React.ReactNode
55 | params: { locale: string }
56 | }
57 |
58 | export default function LocaleLayout({ children, params }: Props) {
59 | const locale = useLocale()
60 | if (params.locale !== locale) notFound()
61 | return (
62 |
63 |
64 |
65 |
66 |
67 | {children}
68 |
69 |
70 |
71 |
72 |
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PasteBin
2 |
3 | A lightweight and modern paste bin and url shortener.
4 |
5 | ## Features
6 |
7 | :yum: Next.js 14 with `App Directory` support
8 |
9 | - `RSC` (React Server Component) for global state hold and data fetching
10 | - `React Server Actions` for forms mutation
11 | - A React style full stack solution, a alternative to `tRPC`
12 |
13 | :globe_with_meridians: I18n with `next-intl` 3
14 |
15 | :closed_lock_with_key: Auth with `next-auth` 5, including full OAuth support and basic credentials.
16 |
17 | - `next-auth` with `prisma` adapter, so that it is not support Edge environment in api route.
18 | - Credentials password hashed with `argon2`
19 |
20 | :smirk: Auto Imports with `unplugin-auto-import` and `unplugin-icons`
21 |
22 | - Necessary `Next.js` components, utils, hooks, and icons are auto imported, so that you don't need to import them manually.
23 |
24 | :shield: Validation with `zod`
25 |
26 | :gem: Database ORM with `prisma`
27 |
28 | - Upcoming multi-drivers support, including `PostgreSQL`, `MySQL`, `SQLite`, `SQL Server`, and `MongoDB`
29 |
30 | :atom_symbol: UI with `Chakra UI`
31 |
32 | :gear: CSS utils library ~~`UnoCSS`~~, use `Tailwind CSS` instead.
33 |
34 | - `UnoCSS` is a better choice for `Tailwind CSS`, but there are issues blocked the use in `webpack` or `postcss`, waiting for the fix.
35 |
36 | :screwdriver: Hooks library, provided by `react-use` and `ahooks`
37 |
38 | :package: Package management with `bun`
39 |
40 | :zap: Syntax highlight with `shikiji`
41 |
42 | :nazar_amulet: Environment variables providing and validating with `@t3-oss/env`
43 |
44 | :rainbow: `TypeScript` native support
45 |
46 | :policeman: Lints and CI process with `husky` and `lint-staged`, checking via `eslint`, `tsc`, `prettier`, and `stylelint`
47 |
48 | ## Installation
49 |
50 | You should define `database` related environment variables in `.env.local` file before running the app.
51 |
52 | It is required by `prisma` to generate database schema and types.
53 |
54 | ```bash
55 | bun i # Install dependencies and generate database schema and types
56 | ```
57 |
58 | ## Development
59 |
60 | ```bash
61 | bun dev
62 | ```
63 |
64 | ## Build
65 |
66 | ```bash
67 | bun run build
68 | ```
69 |
70 | ## Preview
71 |
72 | ```bash
73 | bun start
74 | ```
75 |
--------------------------------------------------------------------------------
/env.mjs:
--------------------------------------------------------------------------------
1 | import { createEnv } from '@t3-oss/env-nextjs'
2 | import { z } from 'zod'
3 |
4 | export const env = createEnv({
5 | /*
6 | * Serverside Environment variables, not available on the client.
7 | * Will throw if you access these variables on the client.
8 | */
9 | server: {
10 | DB_ADAPTER: z
11 | .enum(['postgresql', 'mongodb', 'mysql'])
12 | .default('postgresql'),
13 | PG_URL: z.string().url().optional(),
14 | PG_DIRECT_URL: z.string().url().optional(),
15 | CRON_TASK_TOKEN: z.string().optional(),
16 | NEXTAUTH_URL: z.string().url().optional(),
17 | NEXTAUTH_SECRET: z.string().min(1),
18 | AUTH_GITHUB_ID: z.string().min(1),
19 | AUTH_GITHUB_SECRET: z.string().min(1),
20 | AUTH_GOOGLE_ID: z.string().min(1),
21 | AUTH_GOOGLE_SECRET: z.string().min(1)
22 | },
23 | /*
24 | * Environment variables available on the client (and server).
25 | *
26 | * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
27 | */
28 | client: {
29 | NEXT_PUBLIC_APP_URL: z.string().min(1),
30 | NEXT_PUBLIC_AUTH_GRAVATAR_MIRROR: z
31 | .string()
32 | .url()
33 | .optional()
34 | .default('https://cravatar.cn/avatar/{hash}?d=mm&s=500')
35 | },
36 | /*
37 | * Due to how Next.js bundles environment variables on Edge and Client,
38 | * we need to manually destructure them to make sure all are included in bundle.
39 | *
40 | * 💡 You'll get type errors if not all variables from `server` & `client` are included here.
41 | */
42 | runtimeEnv: {
43 | PG_URL: process.env.PG_URL,
44 | PG_DIRECT_URL: process.env.PG_DIRECT_URL,
45 | NEXTAUTH_URL: process.env.NEXTAUTH_URL,
46 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
47 | AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
48 | AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
49 | AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
50 | AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
51 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
52 | NEXT_PUBLIC_AUTH_GRAVATAR_MIRROR:
53 | process.env.NEXT_PUBLIC_AUTH_GRAVATAR_MIRROR,
54 | CRON_TASK_TOKEN:
55 | process.env.CRON_SECRET || // This is provided by Vercel
56 | process.env.CRON_TASK_TOKEN // This is provided by us, to customize the token
57 | }
58 | })
59 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/_components/aside-nav.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { Link } from '@/libs/navigation'
3 | import { SidebarNavItem } from '@/types'
4 | import { Flex, Text } from '@chakra-ui/react'
5 | import { usePathname } from 'next/navigation'
6 |
7 | // function DropdownItem({
8 | // children,
9 | // item
10 | // }: {
11 | // children: React.ReactNode
12 | // item: SidebarNavItem
13 | // }) {
14 | // const [isOpen, setIsOpen] = useState(false)
15 | // return (
16 | // setIsOpen(!isOpen)}
25 | // >
26 | // {children}
27 | //
28 | // )
29 | // }
30 |
31 | function Item({ item, active }: { item: SidebarNavItem; active?: boolean }) {
32 | // if (item.items) {
33 | // return (
34 | //
35 | // {item.items.map((subItem, index) => {
36 | // return
37 | // })}
38 | //
39 | // )
40 | // }
41 |
42 | return (
43 |
52 | {item.icon && (
53 | {item.icon}
54 | )}
55 | {item.title}
56 |
57 | )
58 | }
59 |
60 | export default function AsideNav({ items }: { items: SidebarNavItem[] }) {
61 | const pathname = usePathname()
62 | if (!items.length) return null
63 |
64 | return (
65 |
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/utils/response.ts:
--------------------------------------------------------------------------------
1 | import { ResponseCode } from '@/enums/response'
2 | import { NextResponse } from 'next/server'
3 |
4 | type ResponseInit = Exclude[1], undefined>
5 |
6 | export type Page = {
7 | collection: T[]
8 | total: number
9 | page: number
10 | page_size: number
11 | }
12 |
13 | export type R = {
14 | code: ResponseCode
15 | message: string
16 | data: T
17 | ts: number
18 | }
19 |
20 | const responseCodeMessages: Map = new Map([
21 | [ResponseCode.OK, 'OK'],
22 | [ResponseCode.InternalError, '内部发生错误'],
23 | [ResponseCode.ValidationFailed, '数据验证失败'],
24 | [ResponseCode.DBOperationError, '数据库操作错误'],
25 | [ResponseCode.InvalidParameters, '当前操作给定的参数无效'],
26 | [ResponseCode.MissingParameter, '当前操作的参数缺失'],
27 | [ResponseCode.InvalidOperation, '函数不能这样使用'],
28 | [ResponseCode.InvalidConfiguration, '当前操作的配置无效'],
29 | [ResponseCode.MissingConfiguration, '当前操作缺少配置'],
30 | [ResponseCode.NotImplemented, '操作尚未执行'],
31 | [ResponseCode.NotSupported, '操作不支持'],
32 | [ResponseCode.OperationFailed, '我试过了,但是我不能给你想要的'],
33 | [ResponseCode.NotAuthorized, '未授权'],
34 | [ResponseCode.SecurityReason, '由于安全原因,操作被拒绝'],
35 | [ResponseCode.ServerBusy, '服务器繁忙'],
36 | [ResponseCode.UnknownError, '未知错误'],
37 | [ResponseCode.NotFound, '资源不存在'],
38 | [ResponseCode.InvalidRequest, '无效的请求'],
39 | [ResponseCode.NecessaryPackageNotImported, '缺少必要的包'],
40 | [ResponseCode.BusinessValidationFailed, '业务验证失败']
41 | ])
42 |
43 | export function getResponseCodeMessage(code: ResponseCode): string {
44 | return responseCodeMessages.get(code) || '未知错误'
45 | }
46 |
47 | export function success(data: T, init?: ResponseInit): NextResponse> {
48 | return NextResponse.json(
49 | {
50 | code: ResponseCode.OK,
51 | message: 'OK',
52 | data,
53 | ts: Date.now()
54 | },
55 | init
56 | )
57 | }
58 |
59 | interface FailParams extends ResponseInit {
60 | message?: string
61 | data?: T
62 | }
63 |
64 | export function fail(
65 | code: ResponseCode,
66 | { message, data = {} as T, ...init }: FailParams
67 | ): NextResponse> {
68 | return NextResponse.json(
69 | {
70 | code,
71 | message: message || getResponseCodeMessage(code),
72 | data,
73 | ts: Date.now()
74 | },
75 | init
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/uno.config.ts:
--------------------------------------------------------------------------------
1 | import presetRemToPx from '@unocss/preset-rem-to-px'
2 | import transformerCompileClass from '@unocss/transformer-compile-class'
3 | import transformerDirectives from '@unocss/transformer-directives'
4 | import transformerVariantGroup from '@unocss/transformer-variant-group'
5 | import { defineConfig, presetAttributify, presetIcons, presetUno } from 'unocss'
6 | export default defineConfig({
7 | content: {
8 | filesystem: ['**/*.{scss,css,html,js,ts,jsx,tsx,vue,svelte,astro}']
9 | // codes below are commented out because they're required by `@unocss/webpack` to process `@import` and `@apply` directives,
10 | // and the usage of `@unocss/webpack` is blocked by the issue https://github.com/unocss/unocss/issues/3198
11 | // pipeline: {
12 | // // include: [/src\/.*\.(s?css|[jt]sx?)$/],
13 | // include: [/\.([jt]sx|mdx?|html|s?css)($|\?)/],
14 | // exclude: []
15 | // }
16 | },
17 | presets: [
18 | presetUno({
19 | dark: {
20 | dark: '.dark-mode',
21 | light: '.light-mode'
22 | }
23 | }),
24 | presetIcons(),
25 | presetAttributify(),
26 | presetRemToPx()
27 | ],
28 | rules: [
29 | [
30 | /^bg-gradient-(\d+)$/,
31 | ([, d]) => ({
32 | '--un-gradient-shape': `${d}deg;`,
33 | '--un-gradient': 'var(--un-gradient-shape), var(--un-gradient-stops);',
34 | 'background-image': 'linear-gradient(var(--un-gradient));'
35 | })
36 | ],
37 | [
38 | /^-bg-gradient-(\d+)$/,
39 | ([, d]) => ({
40 | '--un-gradient-shape': `-${d}deg;`,
41 | '--un-gradient': 'var(--un-gradient-shape), var(--un-gradient-stops);',
42 | 'background-image': 'linear-gradient(var(--un-gradient));'
43 | })
44 | ],
45 | [
46 | 'font-noto-serif',
47 | {
48 | 'font-family': '"Noto Serif SC", "Noto Serif TC", serif'
49 | }
50 | ],
51 | [
52 | 'font-code',
53 | {
54 | 'font-family':
55 | '"Cascadia Code", var(--font-fira-code), SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
56 | }
57 | ]
58 | ],
59 | transformers: [
60 | transformerDirectives({
61 | enforce: 'pre'
62 | }),
63 | // transformerCompileClass({
64 | // classPrefix: 'ouo-',
65 | // // trigger: /(["'`]):ouo(?:-)?(?[^\s\1]+)?:\s([^\1]*?)\1/g
66 | // }),
67 | transformerVariantGroup()
68 | ]
69 | })
70 |
--------------------------------------------------------------------------------
/libs/services/users/user.ts:
--------------------------------------------------------------------------------
1 | import { hash, verify } from '@node-rs/argon2'
2 | import { User } from '@prisma/client'
3 |
4 | import prisma from '@/libs/prisma/client'
5 |
6 | /**
7 | * verify Plain Password
8 | * @param password - plain password
9 | * @param hash - hashed password
10 | * @return {Promise} - true if password is valid
11 | */
12 | export function verifyPassword(
13 | password: string,
14 | hash: string
15 | ): Promise {
16 | return verify(hash, password)
17 | }
18 |
19 | /**
20 | * Hash Password
21 | * @param password - plain password
22 | * @return {Promise} - hashed password
23 | */
24 | export function hashPassword(password: string): Promise {
25 | return hash(password)
26 | }
27 |
28 | export class UserIsSuspendedError extends Error {
29 | constructor() {
30 | super('User is suspended')
31 | }
32 | }
33 |
34 | /**
35 | * Login by Email
36 | * @param email - email
37 | * @param password - password
38 | * @returns {Promise} - user if login success
39 | * @throws {UserIsSuspendedError} - if user is suspended
40 | */
41 | export async function loginByEmail(
42 | email: string,
43 | password: string
44 | ): Promise {
45 | const user = await prisma.user.findFirst({
46 | where: {
47 | email
48 | }
49 | })
50 | if (!user) return null
51 | const isValid = await verifyPassword(password, user.password)
52 | if (!isValid) return null
53 | if (user.isSuspend) throw new UserIsSuspendedError()
54 | return user
55 | }
56 |
57 | /**
58 | * Create User
59 | * @param email - email
60 | * @param password - plain password
61 | * @param others - other fields
62 | * @return {Promise} - created user
63 | */
64 | export async function createUser(
65 | email: string,
66 | password: string,
67 | others: Partial = {}
68 | ): Promise {
69 | const hashedPassword = await hashPassword(password)
70 | return prisma.user.create({
71 | data: {
72 | email,
73 | password: hashedPassword,
74 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
75 | extraFields: {} as any,
76 | ...others
77 | }
78 | })
79 | }
80 |
81 | /**
82 | * Find User By Id
83 | * @param id - user id
84 | * @returns {Promise} - user if found
85 | */
86 | export function findUserById(id: string): Promise {
87 | return prisma.user.findUnique({
88 | where: {
89 | id
90 | }
91 | })
92 | }
93 |
--------------------------------------------------------------------------------
/actions/paste.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import { PasteType } from '@/enums/paste'
4 | import { ResponseCode } from '@/enums/response'
5 | import { auth } from '@/libs/auth'
6 | import client from '@/libs/prisma/client'
7 | import {
8 | Content,
9 | CreateNormalSnippetFormSchema,
10 | type CreateNormalSnippetForm
11 | } from '@/libs/validation/paste'
12 | import type { ActionReturn } from '@/utils/actions'
13 | import type { Session } from 'next-auth'
14 | import { isRedirectError } from 'next/dist/client/components/redirect-error'
15 | import { redirect } from 'next/navigation'
16 |
17 | // Type: normal
18 | export async function submitPasteNormalAction(
19 | prevState: T,
20 | formData: FormData
21 | ): Promise> {
22 | const result = await CreateNormalSnippetFormSchema.safeParseAsync({
23 | syntax: formData.get('syntax'),
24 | content: formData.get('content'),
25 | expiration: formData.get('expiration'),
26 | poster: formData.get('poster'),
27 | redirect: formData.get('redirect')
28 | })
29 | if (!result.success)
30 | return nok(ResponseCode.ValidationFailed, {
31 | error: wrapTranslationKey(
32 | 'components.code_form.feedback.validation_failed'
33 | ),
34 | issues: result.error.issues
35 | })
36 | const content = [
37 | {
38 | type: PasteType.Normal,
39 | syntax: result.data.syntax,
40 | filename: '',
41 | content: result.data.content
42 | }
43 | ] as Content[]
44 | const session = await auth()
45 | try {
46 | const newPaste = await client.paste.create({
47 | data: {
48 | title: '',
49 | description: '',
50 | syntax: result.data.syntax,
51 | type: PasteType.Normal,
52 | content,
53 | poster: result.data.poster,
54 | // TODO: remove this force cast, waiting for next-auth update
55 | userId: session ? (session as unknown as Session).user.id : null,
56 | expiredAt:
57 | result.data.expiration === -1 // -1 means never expired
58 | ? null
59 | : new Date(Date.now() + result.data.expiration * 1000)
60 | }
61 | })
62 | if (result.data.redirect) redirect(`/v/${newPaste.id}`)
63 | return ok({
64 | id: newPaste.id
65 | })
66 | } catch (error) {
67 | if (isRedirectError(error)) throw error
68 | console.error(error)
69 | return nok(ResponseCode.OperationFailed, {
70 | error: (error as Error).message
71 | })
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { GroupProps, SlotRecipeProps } from '@chakra-ui/react'
4 | import { Avatar as ChakraAvatar, Group } from '@chakra-ui/react'
5 | import * as React from 'react'
6 |
7 | type ImageProps = React.ImgHTMLAttributes
8 |
9 | export interface AvatarProps extends ChakraAvatar.RootProps {
10 | name?: string
11 | src?: string
12 | srcSet?: string
13 | loading?: ImageProps['loading']
14 | icon?: React.ReactElement
15 | fallback?: React.ReactNode
16 | }
17 |
18 | export const Avatar = React.forwardRef(
19 | function Avatar(props, ref) {
20 | const { name, src, srcSet, loading, icon, fallback, children, ...rest } =
21 | props
22 | return (
23 |
24 |
25 | {fallback}
26 |
27 |
28 | {children}
29 |
30 | )
31 | }
32 | )
33 |
34 | interface AvatarFallbackProps extends ChakraAvatar.FallbackProps {
35 | name?: string
36 | icon?: React.ReactElement
37 | }
38 |
39 | const AvatarFallback = React.forwardRef(
40 | function AvatarFallback(props, ref) {
41 | const { name, icon, children, ...rest } = props
42 | return (
43 |
44 | {children}
45 | {name != null && children == null && <>{getInitials(name)}>}
46 | {name == null && children == null && (
47 | {icon}
48 | )}
49 |
50 | )
51 | }
52 | )
53 |
54 | function getInitials(name: string) {
55 | const names = name.trim().split(' ')
56 | const firstName = names[0] != null ? names[0] : ''
57 | const lastName = names.length > 1 ? names[names.length - 1] : ''
58 | return firstName && lastName
59 | ? `${firstName.charAt(0)}${lastName.charAt(0)}`
60 | : firstName.charAt(0)
61 | }
62 |
63 | interface AvatarGroupProps extends GroupProps, SlotRecipeProps<'avatar'> {}
64 |
65 | export const AvatarGroup = React.forwardRef(
66 | function AvatarGroup(props, ref) {
67 | const { size, variant, borderless, ...rest } = props
68 | return (
69 |
70 |
71 |
72 | )
73 | }
74 | )
75 |
--------------------------------------------------------------------------------
/libs/auth/config/edge.ts:
--------------------------------------------------------------------------------
1 | import { Role } from '@/enums/user'
2 | import { NextAuthConfig, type DefaultSession } from 'next-auth'
3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
4 | import { type JWT } from 'next-auth/jwt'
5 | import { NextResponse } from 'next/server'
6 |
7 | export const protectedPathname = ['/admin', '/me']
8 |
9 | export type { Session } from 'next-auth'
10 |
11 | declare module 'next-auth/jwt' {
12 | interface JWT {
13 | /** The user's role. */
14 | id: string
15 | role: Role
16 | avatar: string | null
17 | isSuspended: boolean
18 | }
19 | }
20 |
21 | declare module 'next-auth' {
22 | interface Session {
23 | user: {
24 | id: string // It is a alias of id
25 | // id: string
26 | role: Role
27 | avatar: string | null
28 | isSuspended: boolean
29 | } & DefaultSession['user']
30 | }
31 | }
32 |
33 | // Note that it is a minimal config, that it is ensure it can exec in edge runtime.
34 | export const authConfig = {
35 | providers: [],
36 | session: {
37 | strategy: 'jwt'
38 | },
39 | callbacks: {
40 | // Note that: this hook only invoked in signed in user.
41 | // So that, we should reject the request in layout react server component.
42 | async authorized({ request, auth }) {
43 | if (!request.nextUrl) return true
44 | if (request.nextUrl.pathname.startsWith('/admin')) {
45 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
46 | // @ts-ignore ts(2339)
47 | if (!auth || auth?.user?.role !== Role.Admin) {
48 | return NextResponse.redirect(request.nextUrl.href)
49 | }
50 | return true
51 | }
52 | for (const path of protectedPathname) {
53 | if (request.nextUrl.pathname.startsWith(path) && !auth) {
54 | const redirectUrl = new URL('/auth/signin', request.nextUrl.origin)
55 | redirectUrl.searchParams.set('callbackUrl', request.nextUrl.href)
56 | return NextResponse.redirect(redirectUrl)
57 | }
58 | }
59 | return true
60 | },
61 | async session(params) {
62 | params.session = {
63 | ...params.session,
64 | user: {
65 | ...params.session.user,
66 | id: params.token.id,
67 | role: params.token.role,
68 | avatar: params.token.avatar,
69 | isSuspended: params.token.isSuspended
70 | }
71 | }
72 | return params.session
73 | }
74 | },
75 | pages: {
76 | signIn: '/auth/signin',
77 | signOut: '/auth/signout',
78 | error: '/auth/error'
79 | }
80 | } as NextAuthConfig
81 |
--------------------------------------------------------------------------------
/components/ui/steps.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Steps as ChakraSteps } from '@chakra-ui/react'
2 | import * as React from 'react'
3 | import { LuCheck } from 'react-icons/lu'
4 |
5 | interface StepInfoProps {
6 | title?: React.ReactNode
7 | description?: React.ReactNode
8 | }
9 |
10 | export interface StepsItemProps
11 | extends Omit,
12 | StepInfoProps {
13 | completedIcon?: React.ReactNode
14 | icon?: React.ReactNode
15 | }
16 |
17 | export const StepsItem = React.forwardRef(
18 | function StepsItem(props, ref) {
19 | const { title, description, completedIcon, icon, ...rest } = props
20 | return (
21 |
22 |
23 |
24 | }
26 | incomplete={icon || }
27 | />
28 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 | )
36 |
37 | const StepInfo = (props: StepInfoProps) => {
38 | const { title, description } = props
39 |
40 | if (title && description) {
41 | return (
42 |
43 | {title}
44 | {description}
45 |
46 | )
47 | }
48 |
49 | return (
50 | <>
51 | {title && {title}}
52 | {description && (
53 | {description}
54 | )}
55 | >
56 | )
57 | }
58 |
59 | interface StepsIndicatorProps {
60 | completedIcon: React.ReactNode
61 | icon?: React.ReactNode
62 | }
63 |
64 | export const StepsIndicator = React.forwardRef<
65 | HTMLDivElement,
66 | StepsIndicatorProps
67 | >(function StepsIndicator(props, ref) {
68 | const { icon = , completedIcon } = props
69 | return (
70 |
71 |
72 |
73 | )
74 | })
75 |
76 | export const StepsList = ChakraSteps.List
77 | export const StepsRoot = ChakraSteps.Root
78 | export const StepsContent = ChakraSteps.Content
79 | export const StepsCompletedContent = ChakraSteps.CompletedContent
80 |
81 | export const StepsNextTrigger = ChakraSteps.NextTrigger
82 | export const StepsPrevTrigger = ChakraSteps.PrevTrigger
83 |
--------------------------------------------------------------------------------
/hooks/toast.ts:
--------------------------------------------------------------------------------
1 | import { Awaitable } from '@/utils/types'
2 | import { toaster } from '@/components/ui/toaster'
3 | import { useMemoizedFn } from 'ahooks'
4 |
5 | type UseToastFeedbackProps = {
6 | fn: (e: E) => Awaitable
7 | messages: {
8 | [K in 'success' | 'info' | 'warning']?: {
9 | title: string
10 | description: string
11 | }
12 | } & {
13 | error?:
14 | | {
15 | title: string
16 | description: string
17 | }
18 | | ((e: Error) => {
19 | title: string
20 | description: string
21 | })
22 | }
23 | }
24 | type ToastMessages = {
25 | [K in 'success' | 'info' | 'warning' | 'error']?: {
26 | title: string
27 | description: string
28 | }
29 | }
30 |
31 | /**
32 | * useToastFeedback is a hook that wrap the function with toast feedback
33 | * @param {UseToastFeedbackProps} props - The props of the hook
34 | * @return {Function} - The wrapped function
35 | */
36 | export function useToastFeedback(
37 | props: UseToastFeedbackProps
38 | ) {
39 | const { messages } = props
40 |
41 | const toastFeedback = useMemoizedFn(
42 | (
43 | message: ToastMessages,
44 | status: 'success' | 'info' | 'warning' | 'error'
45 | ) => {
46 | toaster.create({
47 | title: message[status]?.title || 'No Title',
48 | description: message[status]?.description || undefined,
49 | type: status
50 | })
51 | }
52 | )
53 |
54 | return useMemoizedFn((e: E) => {
55 | let res = undefined as Awaitable
56 | try {
57 | res = props.fn(e)
58 | } catch (e) {
59 | const msg =
60 | typeof messages.error === 'function'
61 | ? messages.error(e as Error)
62 | : messages.error
63 | toastFeedback(
64 | {
65 | error: msg
66 | },
67 | 'error'
68 | )
69 | } finally {
70 | // judge the result of the function
71 | if (!res) {
72 | // It must be no promise returned
73 | toastFeedback(messages as ToastMessages, 'success')
74 | return
75 | }
76 | // Otherwise, it must be a promise returned
77 | res
78 | .then(() => {
79 | toastFeedback(messages as ToastMessages, 'success')
80 | })
81 | .catch((e) => {
82 | const msg =
83 | typeof messages.error === 'function'
84 | ? messages.error(e as Error)
85 | : messages.error
86 | toastFeedback(
87 | {
88 | error: msg
89 | },
90 | 'error'
91 | )
92 | })
93 | }
94 | })
95 | }
96 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/snippets/_components/snippet.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { PasteType } from '@/enums/paste'
4 | import { getDisplayNameByLanguageID } from '@/libs/shiki'
5 | import { Card, Flex } from '@chakra-ui/react'
6 | import { Tag } from '@/components/ui/tag'
7 | import { type Paste } from '@prisma/client'
8 |
9 | import { Link } from '@/libs/navigation'
10 | import { motion } from 'framer-motion'
11 | import styles from './snippet.module.scss'
12 |
13 | export type SnippetProps = {
14 | locale: string
15 | timeZone: string
16 | snippet: Paste
17 | }
18 |
19 | export default function Snippet({ locale, timeZone, snippet }: SnippetProps) {
20 | return (
21 |
26 | {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
27 |
28 |
34 |
35 |
36 |
37 | {snippet.title || 'Untitled Snippet'}
38 |
39 |
40 | {formatPasteType(snippet.type as PasteType)}
41 | {(snippet.type as PasteType) === PasteType.Normal && (
42 |
43 | {getDisplayNameByLanguageID(snippet.syntax || 'text')}
44 |
45 | )}
46 |
47 |
48 |
49 |
50 |
51 | {snippet.description || 'No description provided.'}
52 |
53 |
54 |
55 | Posted on:{' '}
56 | {newDayjs(snippet.createdAt, {
57 | timeZone,
58 | locale
59 | }).fromNow()}
60 |
61 |
62 | {!!snippet.expiredAt && (
63 |
64 | Expired{' '}
65 | {newDayjs(snippet.expiredAt, {
66 | timeZone,
67 | locale
68 | }).fromNow()}
69 |
70 | )}
71 |
72 |
73 |
74 |
75 |
76 | )
77 | }
78 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/settings/_components/button.tsx:
--------------------------------------------------------------------------------
1 | import { linkAccountAction, unlinkAccountAction } from '@/actions/user'
2 | import { ResponseCode } from '@/enums/response'
3 | import { Button } from '@/components/ui/button'
4 | import { useMemoizedFn } from 'ahooks'
5 | import { useRouter } from 'next/navigation'
6 | import { useTransition, type ReactNode } from 'react'
7 | import { toaster } from '@/components/ui/toaster'
8 |
9 | export type SSO = {
10 | id: string
11 | name: string
12 | icon: ReactNode
13 | } & {
14 | connected: boolean
15 | }
16 |
17 | export function SSOButton({ sso }: { sso: SSO }) {
18 | const router = useRouter()
19 | const [pending, startTransition] = useTransition()
20 |
21 | const onLink = useMemoizedFn(() => {
22 | startTransition(async () => {
23 | try {
24 | const res = await linkAccountAction(sso.id)
25 | if (res.status !== ResponseCode.OK) {
26 | throw new Error(res.error)
27 | }
28 | const win = window.open(res.data!.url, '_blank')
29 | const timer = setInterval(checkWinIsClosed, 500)
30 | function checkWinIsClosed() {
31 | if (win?.closed) {
32 | clearInterval(timer)
33 | router.refresh()
34 | }
35 | }
36 | } catch (e) {
37 | toaster.create({
38 | title: 'Link account failed.',
39 | description: e instanceof Error ? e.message : 'Unknown error',
40 | type: 'error',
41 | duration: 5000
42 | })
43 | }
44 | })
45 | })
46 |
47 | const onUnlink = useMemoizedFn(() => {
48 | startTransition(async () => {
49 | try {
50 | const res = await unlinkAccountAction(sso.id)
51 | if (res.status !== ResponseCode.OK) {
52 | throw new Error(res.error)
53 | }
54 | toaster.create({
55 | title: 'Account unlinked.',
56 | type: 'success',
57 | duration: 5000
58 | })
59 | router.refresh()
60 | } catch (e) {
61 | toaster.create({
62 | title: 'Link account failed.',
63 | description: e instanceof Error ? e.message : 'Unknown error',
64 | type: 'error',
65 | duration: 5000
66 | })
67 | }
68 | })
69 | })
70 |
71 | const text = `${sso.connected ? 'Unlink' : 'Link'} ${sso.name}`
72 |
73 | return (
74 |
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/scripts/prisma/migrate.ts:
--------------------------------------------------------------------------------
1 | import { RuntimeMode } from '@/enums/app'
2 | import { getRuntimeMode, loadEnv } from '@/utils/app'
3 | import { getDictionaries } from '@/utils/fs'
4 | import chalk from 'chalk'
5 | import { spawn } from 'node:child_process'
6 | import path from 'path'
7 |
8 | const mode = getRuntimeMode()
9 | const rootDir = path.resolve(__dirname, '../../')
10 | loadEnv()
11 |
12 | async function main() {
13 | // Get all supported drivers
14 |
15 | const drivers = await getDictionaries(path.join(rootDir, './prisma'))
16 | console.log(
17 | `${chalk.green('✓')} Found ${chalk.cyan(drivers.length)} drivers.`
18 | )
19 | // list all drivers
20 | console.log(chalk.cyan('Drivers:'))
21 | console.log(drivers.map((v) => ` - ` + v).join('\n'))
22 |
23 | // Get configured driver
24 | const configuredDriver = process.env.DB_ADAPTER || ''
25 | if (!drivers.includes(configuredDriver)) {
26 | console.error(
27 | `${chalk.red('✗')} ${chalk.cyan(
28 | configuredDriver
29 | )} is not a supported driver.`
30 | )
31 | console.error(
32 | `Please set the ${chalk.cyan(
33 | 'DB_ADAPTER'
34 | )} environment variable to one of the following:`
35 | )
36 | console.error(drivers.map((v) => ` - ` + v).join('\n'))
37 | process.exit(1)
38 | }
39 |
40 | let driversToMigrate = [configuredDriver]
41 | if (mode === RuntimeMode.Development && process.argv.includes('dev')) {
42 | console.log(
43 | `${chalk.yellow('!')} ${chalk.cyan(
44 | 'dev'
45 | )} mode detected, will migrate all drivers.`
46 | )
47 | driversToMigrate = drivers
48 | }
49 | // run prisma generate, and pipe the output to stdout, stderr, and stdin of the current process
50 | console.log(chalk.cyan('Migrating...'))
51 | const parentArgvs = process.argv.slice(2)
52 | for (const driver of driversToMigrate) {
53 | console.log(chalk.cyan(` - ${driver}`))
54 | const command = `./node_modules/.bin/prisma migrate ${parentArgvs.join(
55 | ' '
56 | )} --schema ./prisma/${configuredDriver}/schema.prisma`
57 | console.log(chalk.gray(` $ ${chalk.reset(command)}`))
58 | await new Promise((resolve, reject) => {
59 | const p = spawn(command, {
60 | shell: true,
61 | stdio: [process.stdin, process.stdout, process.stderr],
62 | cwd: rootDir,
63 | env: process.env
64 | })
65 | p.on('error', reject)
66 | p.on('exit', (code) => {
67 | if (code === 0) {
68 | resolve()
69 | } else {
70 | reject(new Error(`prisma migrate failed with code ${code}`))
71 | }
72 | })
73 | })
74 | }
75 | }
76 |
77 | main()
78 | .then(() => process.exit(0))
79 | .catch((err) => {
80 | console.error(err)
81 | process.exit(1)
82 | })
83 |
--------------------------------------------------------------------------------
/libs/shiki/extends.ts:
--------------------------------------------------------------------------------
1 | // extends mean it should do something in hast tree. not inject in hook.
2 | import type { Element, Root } from 'hast'
3 | import { getDisplayNameByLanguageID } from './'
4 |
5 | /**
6 | * This function appends a header to the root node.
7 | * It will add features like language name, copy button, etc.
8 | * Note that this function should be called after the function that wrapper the shiki root node.
9 | * @param {Root} root - RootHastNode
10 | * @param {string} lang - language id
11 | */
12 | export function appendHeader(root: Root, lang: string) {
13 | // Root -> [ShikiRootWrapper]
14 | // This force type cast is safe because we know the structure of the root node.
15 | ;(root.children[0]).children.unshift({
16 | type: 'element',
17 | tagName: 'div',
18 | properties: { class: 'header' },
19 | children: [
20 | {
21 | type: 'element',
22 | tagName: 'span',
23 | properties: {
24 | class: 'language-name'
25 | },
26 | children: [{ type: 'text', value: getDisplayNameByLanguageID(lang) }]
27 | },
28 | {
29 | type: 'element',
30 | tagName: 'div',
31 | properties: { class: 'copy-button' },
32 | children: []
33 | }
34 | ]
35 | })
36 | }
37 |
38 | /**
39 | * This function wraps the root node with a div.
40 | * @param {Root} root - RootHastNode
41 | */
42 | export function wrapperShikiRoot(root: Root) {
43 | // Root -> pre -> code
44 | // This force type cast is safe because we know the structure of the root node.
45 | const shikiRoot = root.children[0]
46 | root.children[0] = {
47 | type: 'element',
48 | tagName: 'div',
49 | properties: { class: 'shiki-container' },
50 | children: [shikiRoot]
51 | }
52 | }
53 |
54 | /**
55 | * This function append a lines block to the root node.
56 | * Please note that this function should be called after the functions that append the header and footer.
57 | * @param root RootHastNode
58 | */
59 | export function appendLineNumbersBlock(root: Root, lines: number) {
60 | root.children.unshift({
61 | type: 'element',
62 | tagName: 'div',
63 | properties: { class: 'line-numbers-container' },
64 | children: [
65 | {
66 | type: 'element',
67 | tagName: 'span',
68 | properties: { class: 'line block relative', 'data-line': ' ' },
69 | children: new Array(lines).fill(0).map((_, i) => {
70 | return {
71 | type: 'element',
72 | tagName: 'span',
73 | properties: { class: 'line-number' },
74 | children: [
75 | {
76 | type: 'text',
77 | value: (i + 1).toString()
78 | }
79 | ]
80 | }
81 | })
82 | }
83 | ]
84 | })
85 | }
86 |
--------------------------------------------------------------------------------
/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | import { Slider as ChakraSlider, For, HStack } from '@chakra-ui/react'
2 | import * as React from 'react'
3 |
4 | export interface SliderProps extends ChakraSlider.RootProps {
5 | marks?: Array
6 | label?: React.ReactNode
7 | showValue?: boolean
8 | }
9 |
10 | export const Slider = React.forwardRef(
11 | function Slider(props, ref) {
12 | const { marks: marksProp, label, showValue, ...rest } = props
13 | const value = props.defaultValue ?? props.value
14 |
15 | const marks = marksProp?.map((mark) => {
16 | if (typeof mark === 'number') return { value: mark, label: undefined }
17 | return mark
18 | })
19 |
20 | const hasMarkLabel = !!marks?.some((mark) => mark.label)
21 |
22 | return (
23 |
24 | {label && !showValue && (
25 | {label}
26 | )}
27 | {label && showValue && (
28 |
29 | {label}
30 |
31 |
32 | )}
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | )
42 | }
43 | )
44 |
45 | function SliderThumbs(props: { value?: number[] }) {
46 | const { value } = props
47 | return (
48 |
49 | {(_, index) => (
50 |
51 |
52 |
53 | )}
54 |
55 | )
56 | }
57 |
58 | interface SliderMarksProps {
59 | marks?: Array
60 | }
61 |
62 | const SliderMarks = React.forwardRef(
63 | function SliderMarks(props, ref) {
64 | const { marks } = props
65 | if (!marks?.length) return null
66 |
67 | return (
68 |
69 | {marks.map((mark, index) => {
70 | const value = typeof mark === 'number' ? mark : mark.value
71 | const label = typeof mark === 'number' ? undefined : mark.label
72 | return (
73 |
74 |
75 | {label}
76 |
77 | )
78 | })}
79 |
80 | )
81 | }
82 | )
83 |
--------------------------------------------------------------------------------
/scripts/prisma/db.ts:
--------------------------------------------------------------------------------
1 | import { RuntimeMode } from '@/enums/app'
2 | import { getRuntimeMode, loadEnv } from '@/utils/app'
3 | import { getDictionaries } from '@/utils/fs'
4 | import chalk from 'chalk'
5 | import { spawn } from 'node:child_process'
6 | import path from 'path'
7 |
8 | const mode = getRuntimeMode()
9 | const rootDir = path.resolve(__dirname, '../../')
10 | loadEnv()
11 |
12 | async function main() {
13 | // Get all supported drivers
14 | const drivers = await getDictionaries(path.join(rootDir, './prisma'))
15 | console.log(
16 | `${chalk.green('✓')} Found ${chalk.cyan(drivers.length)} drivers.`
17 | )
18 | // list all drivers
19 | console.log(chalk.cyan('Drivers:'))
20 | console.log(drivers.map((v) => ` - ` + v).join('\n'))
21 |
22 | // Get configured driver
23 | const configuredDriver = process.env.DB_ADAPTER || ''
24 | if (!drivers.includes(configuredDriver)) {
25 | console.error(
26 | `${chalk.red('✗')} ${chalk.cyan(
27 | configuredDriver
28 | )} is not a supported driver.`
29 | )
30 | console.error(
31 | `Please set the ${chalk.cyan(
32 | 'DB_ADAPTER'
33 | )} environment variable to one of the following:`
34 | )
35 | console.error(drivers.map((v) => ` - ` + v).join('\n'))
36 | process.exit(1)
37 | }
38 |
39 | let driversToMigrate = [configuredDriver]
40 | if (mode === RuntimeMode.Development && process.argv.includes('-A')) {
41 | console.log(
42 | `${chalk.yellow('!')} ${chalk.cyan(
43 | 'dev'
44 | )} mode detected, will exec db command on all drivers.`
45 | )
46 | driversToMigrate = drivers
47 | }
48 | // run prisma generate, and pipe the output to stdout, stderr, and stdin of the current process
49 | console.log(chalk.cyan('do db operation...'))
50 | const parentArgvs = process.argv.slice(2).filter((v) => !v.includes('-A'))
51 | for (const driver of driversToMigrate) {
52 | console.log(chalk.cyan(` - ${driver}`))
53 | const command = `./node_modules/.bin/prisma db ${parentArgvs.join(
54 | ' '
55 | )} --schema=./prisma/${configuredDriver}/schema.prisma`
56 | console.log(chalk.gray(` $ ${chalk.reset(command)}`))
57 | await new Promise((resolve, reject) => {
58 | const p = spawn(command, {
59 | shell: true,
60 | stdio: [process.stdin, process.stdout, process.stderr],
61 | cwd: rootDir,
62 | env: process.env
63 | })
64 | p.on('error', reject)
65 | p.on('exit', (code) => {
66 | if (code === 0) {
67 | resolve()
68 | } else {
69 | reject(new Error(`prisma migrate failed with code ${code}`))
70 | }
71 | })
72 | })
73 | }
74 | }
75 |
76 | main()
77 | .then(() => process.exit(0))
78 | .catch((err) => {
79 | console.error(err)
80 | process.exit(1)
81 | })
82 |
--------------------------------------------------------------------------------
/app/[locale]/(app)/dashboard/snippets/page.tsx:
--------------------------------------------------------------------------------
1 | import { IntlClientProvider } from '@/components/server-provider'
2 | import { auth } from '@/libs/auth'
3 | import client from '@/libs/prisma/client'
4 | import { Paste } from '@prisma/client'
5 | import { pick } from 'lodash-es'
6 | import { AbstractIntlMessages, useLocale, useMessages } from 'next-intl'
7 | import { getTimeZone } from 'next-intl/server'
8 | import Header from '../_components/header'
9 | import Shell from '../_components/shell'
10 | import { AddButton } from './_components/button'
11 | import Snippet from './_components/snippet'
12 | function getSnippets(userID: string) {
13 | return client.paste.findMany({
14 | where: {
15 | userId: userID
16 | },
17 | orderBy: {
18 | createdAt: 'desc'
19 | },
20 | select: {
21 | id: true,
22 | title: true,
23 | createdAt: true,
24 | expiredAt: true,
25 | description: true,
26 | syntax: true,
27 | type: true,
28 | content: false
29 | }
30 | })
31 | }
32 |
33 | function AddButtonIntlProvider({ children }: { children: React.ReactNode }) {
34 | const locale = useLocale()
35 | const messages = useMessages()
36 | return (
37 |
41 | {children}
42 |
43 | )
44 | }
45 |
46 | export default async function SnippetsPage() {
47 | // eslint-disable-next-line react-hooks/rules-of-hooks
48 | const locale = useLocale()
49 | const timeZone = await getTimeZone({ locale })
50 | const session = await auth()
51 | const snippets = await getSnippets(session!.user.id)
52 |
53 | return (
54 |
55 |
60 | {snippets.length > 0 ? (
61 |
62 | {snippets.map((snippet) => (
63 |
69 | ))}
70 |
71 | ) : (
72 |
73 |
74 |
79 |
80 |
81 |
82 | No snippets found.
83 |
84 |
85 | )}
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/.vscode/unocss.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1.1,
3 | "atDirectives": [
4 | {
5 | "name": "@tailwind",
6 | "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
7 | "references": [
8 | {
9 | "name": "Tailwind Documentation",
10 | "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
11 | }
12 | ]
13 | },
14 | {
15 | "name": "@unocss",
16 | "description": "Use the `@unocss` directive to insert UnoCSS's `base`, `components`, `utilities` and `screens` styles into your CSS.",
17 | "references": [
18 | {
19 | "name": "UnoCSS Documentation",
20 | "url": "https://unocss.dev/integrations/postcss#unocss"
21 | }
22 | ]
23 | },
24 | {
25 | "name": "@apply",
26 | "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.",
27 | "references": [
28 | {
29 | "name": "Tailwind Documentation",
30 | "url": "https://tailwindcss.com/docs/functions-and-directives#apply"
31 | }
32 | ]
33 | },
34 | {
35 | "name": "@responsive",
36 | "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
37 | "references": [
38 | {
39 | "name": "Tailwind Documentation",
40 | "url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
41 | }
42 | ]
43 | },
44 | {
45 | "name": "@screen",
46 | "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
47 | "references": [
48 | {
49 | "name": "Tailwind Documentation",
50 | "url": "https://tailwindcss.com/docs/functions-and-directives#screen"
51 | }
52 | ]
53 | },
54 | {
55 | "name": "@variants",
56 | "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
57 | "references": [
58 | {
59 | "name": "Tailwind Documentation",
60 | "url": "https://tailwindcss.com/docs/functions-and-directives#variants"
61 | }
62 | ]
63 | }
64 | ]
65 | }
66 |
--------------------------------------------------------------------------------
/.github/workflows/node.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 | push:
7 | branches: [main]
8 | jobs:
9 | test:
10 | name: Lint
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | node-version: [20.x]
15 | steps:
16 | - uses: actions/checkout@v4
17 | - name: Use Node.js ${{ matrix.node-version }}
18 | uses: actions/setup-node@v4
19 | with:
20 | node-version: ${{ matrix.node-version }}
21 | - uses: pnpm/action-setup@v2
22 | name: Install pnpm
23 | with:
24 | version: 9.15.9
25 | run_install: false
26 | - name: Get pnpm store directory
27 | shell: bash
28 | run: |
29 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
30 |
31 | - uses: actions/cache@v4
32 | name: Setup pnpm cache
33 | with:
34 | path: ${{ env.STORE_PATH }}
35 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
36 | restore-keys: |
37 | ${{ runner.os }}-pnpm-store-
38 |
39 | - name: Install dependencies
40 | run: DB_ADAPTER=postgresql pnpm install
41 |
42 | - name: Lint
43 | run: pnpm lint
44 |
45 | # build:
46 | # name: Build
47 | # runs-on: ubuntu-latest
48 | # strategy:
49 | # matrix:
50 | # node-version: [20.x]
51 | # steps:
52 | # - uses: actions/checkout@v3
53 | # - name: Use Node.js ${{ matrix.node-version }}
54 | # uses: actions/setup-node@v3
55 | # with:
56 | # node-version: ${{ matrix.node-version }}
57 | # - uses: pnpm/action-setup@v2
58 | # with:
59 | # version: 8
60 | # - name: Get pnpm store directory
61 | # shell: bash
62 | # run: |
63 | # echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
64 |
65 | # - uses: actions/cache@v3
66 | # name: Setup pnpm cache
67 | # with:
68 | # path: ${{ env.STORE_PATH }}
69 | # key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
70 | # restore-keys: |
71 | # ${{ runner.os }}-pnpm-store-
72 |
73 | # - name: Install dependencies
74 | # run: pnpm install
75 | # - name: Create .env file
76 | # uses: SpicyPizza/create-envfile@v2.0
77 | # with:
78 | # file_name: .env.production
79 | # envkey_HITOKOTO_COMMON_API_ENDPOINT: ${{ secrets.HITOKOTO_COMMON_API_ENDPOINT }}
80 | # envkey_HITOKOTO_REVIEWER_API_ENDPOINT: ${{ secrets.HITOKOTO_REVIEWER_API_ENDPOINT }}
81 | # envkey_HITOKOTO_SEARCH_API_ENDPOINT: ${{ secrets.HITOKOTO_SEARCH_API_ENDPOINT }}
82 | # envkey_HITOKOTO_SEARCH_API_PUBKEY: ${{ secrets.HITOKOTO_SEARCH_API_PUBKEY }}
83 | # envkey_COOKIES_ENCRYPT_KEY: ${{ secrets.COOKIES_ENCRYPT_KEY }}
84 | # - name: Build
85 | # run: pnpm build
86 |
--------------------------------------------------------------------------------
/hooks/actions.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ResponseCode } from '@/enums/response'
4 | import { ActionReturn, State } from '@/utils/actions'
5 | import { Awaitable } from '@/utils/types'
6 | import { useEffect, useRef, type MutableRefObject } from 'react'
7 |
8 | import { useFormState } from 'react-dom'
9 |
10 | /**
11 | * useSubmitForm is a wrapper to use React Server Actions with callbacks handled correctly.
12 | * Note that {ActionReturn} is a type alias of {State}.
13 | * @param serverAction - The server action to be invoked.
14 | * @param callbacks - Callbacks to be invoked when the server action is done.
15 | * @returns The state of the server action, the form reference and the action to be invoked.
16 | */
17 | export function useSubmitForm(
18 | serverAction: (
19 | preState: State,
20 | formData: FormData
21 | ) => Awaitable>,
22 | callbacks?: {
23 | onSuccess?: (
24 | state: State,
25 | ref: MutableRefObject
26 | ) => void
27 | onError?: (
28 | state: State,
29 | ref: MutableRefObject
30 | ) => void
31 | }
32 | ): {
33 | state: State
34 | ref: MutableRefObject
35 | action: (formData: FormData) => void
36 | }
37 |
38 | export function useSubmitForm(
39 | serverAction: (
40 | preState: State,
41 | formData: FormData
42 | ) => Awaitable>,
43 | callbacks: {
44 | onSuccess?: (
45 | state: State,
46 | ref: MutableRefObject
47 | ) => void
48 | onError?: (
49 | state: State,
50 | ref: MutableRefObject
51 | ) => void
52 | } = {}
53 | ) {
54 | const { onSuccess = () => {}, onError = () => {} } = callbacks
55 | const ref = useRef(null) as MutableRefObject
56 |
57 | const [state, formAction] = useFormState>(
58 | // TODO: use best practice to fix this type assertion
59 | // This type assertion is intended, because of react server actions is not giving a best practice.
60 | serverAction as unknown as (formData: State) => Promise>,
61 | {
62 | status: ResponseCode.NotInvoke
63 | } as State
64 | )
65 |
66 | useEffect(() => {
67 | if (
68 | (state?.status !== ResponseCode.OK &&
69 | state?.status !== ResponseCode.NotInvoke) ||
70 | state?.error
71 | ) {
72 | if (typeof onError === 'function') {
73 | onError(state, ref)
74 | }
75 | }
76 |
77 | if (state?.status == ResponseCode.OK) {
78 | if (typeof onSuccess === 'function') {
79 | onSuccess(state, ref)
80 | }
81 | }
82 | // eslint-disable-next-line react-hooks/exhaustive-deps
83 | }, [state, ref])
84 |
85 | return { state, ref, action: formAction }
86 | }
87 |
--------------------------------------------------------------------------------
/utils/strings.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * TrimChars returns a slice of the string s with all leading and trailing Unicode code points contained in cutset removed.
3 | * @param str raw string
4 | * @param cutset the char set to trim
5 | * @return {string}
6 | */
7 | export function trimChars(str: string, cutset: string) {
8 | let start = 0,
9 | end = str.length
10 |
11 | while (start < end && cutset.indexOf(str[start]) >= 0) ++start
12 |
13 | while (end > start && cutset.indexOf(str[end - 1]) >= 0) --end
14 |
15 | return start > 0 || end < str.length ? str.substring(start, end) : str
16 | }
17 |
18 | /**
19 | * TrimCharsLeft returns a slice of the string s with all leading Unicode code points contained in cutset removed.
20 | * @param str raw string
21 | * @param cutset the char set to trim
22 | * @return {string}
23 | */
24 | export function trimCharsLeft(str: string, cutset: string) {
25 | let start = 0
26 | const end = str.length
27 |
28 | while (start < end && cutset.indexOf(str[start]) >= 0) ++start
29 |
30 | return start > 0 || end < str.length ? str.substring(start, end) : str
31 | }
32 |
33 | /**
34 | * TrimCharsRight returns a slice of the string s, with all trailing Unicode code points contained in cutset removed.
35 | * @param str raw string
36 | * @param cutset the char set to trim
37 | * @return {string}
38 | */
39 | export function trimCharsRight(str: string, cutset: string) {
40 | let end = str.length
41 | const start = 0
42 |
43 | while (end > start && cutset.indexOf(str[end - 1]) >= 0) --end
44 |
45 | return start > 0 || end < str.length ? str.substring(start, end) : str
46 | }
47 |
48 | /**
49 | * return the number of lines in a string
50 | * @param {string} str - Raw string
51 | * @return {number} - Number of lines
52 | */
53 | export function getLines(str: string): number {
54 | return str.split('\n').length
55 | }
56 |
57 | export type TranslationKey = `[[${string}]]`
58 |
59 | /**
60 | * Judge whether the string is a translation key
61 | * @param str - Raw string
62 | * @returns {boolean} - Whether the string is a translation key
63 | */
64 | export function isTranslationKey(str: string): boolean {
65 | return /^\[\[.*\]\]$/.test(str)
66 | }
67 |
68 | /**
69 | * Unwrap the translation key
70 | * @param str - Raw string
71 | * @returns {string} - Unwrapped string
72 | */
73 | export function unwrapTranslationKey(str: TranslationKey): string {
74 | return str.slice(2, -2)
75 | }
76 |
77 | /**
78 | * Wrap the translation key
79 | * @param str - Raw string
80 | * @returns {TranslationKey} - Wrapped string
81 | */
82 | export function wrapTranslationKey(str: string): TranslationKey {
83 | return `[[${str}]]` as TranslationKey
84 | }
85 |
86 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
87 | export function translateIfKey(t: Function, str: string): string {
88 | return isTranslationKey(str)
89 | ? t(unwrapTranslationKey(str as TranslationKey))
90 | : str
91 | }
92 |
--------------------------------------------------------------------------------
/scripts/prisma/generate.ts:
--------------------------------------------------------------------------------
1 | import { loadEnv } from '@/utils/app'
2 | import { getDictionaries } from '@/utils/fs'
3 | import chalk from 'chalk'
4 | import { spawn } from 'node:child_process'
5 | import path from 'path'
6 |
7 | const rootDir = path.resolve(__dirname, '../../')
8 | loadEnv()
9 |
10 | async function main() {
11 | // Get all supported drivers
12 |
13 | const drivers = await getDictionaries(path.join(rootDir, './prisma'))
14 | console.log(
15 | `${chalk.green('✓')} Found ${chalk.cyan(drivers.length)} drivers.`
16 | )
17 | // list all drivers
18 | console.log(chalk.cyan('Drivers:'))
19 | console.log(drivers.map((v) => ` - ` + v).join('\n'))
20 |
21 | // Get configured driver
22 | const configuredDriver = process.env.DB_ADAPTER || 'postgresql'
23 | if (!drivers.includes(configuredDriver)) {
24 | console.error(
25 | `${chalk.red('✗')} ${chalk.cyan(
26 | configuredDriver
27 | )} is not a supported driver.`
28 | )
29 | console.error(
30 | `Please set the ${chalk.cyan(
31 | 'DB_ADAPTER'
32 | )} environment variable to one of the following:`
33 | )
34 | console.error(drivers.map((v) => ` - ` + v).join('\n'))
35 | process.exit(1)
36 | }
37 | // run prisma generate, and pipe the output to stdout, stderr, and stdin of the current process
38 | console.log(chalk.cyan('Generating...'))
39 | console.log(chalk.cyan(` - ${configuredDriver}`))
40 | const command = `./node_modules/.bin/prisma generate --schema=./prisma/${configuredDriver}/schema.prisma`
41 | console.log(chalk.gray(` $ ${chalk.reset(command)}`))
42 | await new Promise((resolve, reject) => {
43 | const p = spawn(command, {
44 | shell: true,
45 | stdio: [process.stdin, process.stdout, process.stderr],
46 | cwd: rootDir,
47 | env: process.env
48 | })
49 | p.on('error', reject)
50 | p.on('exit', (code) => {
51 | if (code === 0) {
52 | resolve()
53 | } else {
54 | reject(new Error(`prisma generate failed with code ${code}`))
55 | }
56 | })
57 | })
58 | // for (const driver of drivers) {
59 | // console.log(chalk.cyan(` - ${driver}`))
60 | // await new Promise((resolve, reject) => {
61 | // const p = spawn(
62 | // `pnpm prisma generate --schema=./prisma/${driver}/schema.prisma`,
63 | // {
64 | // shell: true,
65 | // stdio: [process.stdin, process.stdout, process.stderr],
66 | // cwd: rootDir,
67 | // env: process.env
68 | // }
69 | // )
70 | // p.on('error', reject)
71 | // p.on('exit', (code) => {
72 | // if (code === 0) {
73 | // resolve()
74 | // } else {
75 | // reject(new Error(`prisma generate failed with code ${code}`))
76 | // }
77 | // })
78 | // })
79 | // }
80 | }
81 |
82 | main()
83 | .then(() => process.exit(0))
84 | .catch((err) => {
85 | console.error(err)
86 | process.exit(1)
87 | })
88 |
--------------------------------------------------------------------------------
/styles/global.scss:
--------------------------------------------------------------------------------
1 | @import url('../node_modules/@unocss/reset/sanitize/sanitize.css');
2 | @import url('../node_modules/@unocss/reset/sanitize/assets.css');
3 |
4 | // /* style.css */
5 | // @unocss preflights;
6 | // @unocss default;
7 |
8 | // /*
9 | // Fallback layer. It's always recommended to include.
10 | // Only unused layers will be injected here.
11 | // */
12 | // @unocss;
13 |
14 | @tailwind base;
15 | @tailwind components;
16 | @tailwind utilities;
17 |
18 | @layer base {
19 | :root {
20 | // Background
21 | --background: 0 0% 100%;
22 | --foreground: 222.2 47.4% 11.2%;
23 | // Mute
24 | --muted: 210 40% 96.1%;
25 | --muted-foreground: 215.4 16.3% 46.9%;
26 | // Popover
27 | --popover: 0 0% 100%;
28 | --popover-foreground: 222.2 47.4% 11.2%;
29 | // Border
30 | --border: 214.3 31.8% 91.4%;
31 | --input: 214.3 31.8% 91.4%;
32 | // Card
33 | --card: 0 0% 100%;
34 | --card-foreground: 222.2 47.4% 11.2%;
35 | // Primary
36 | --primary: 222.2 47.4% 11.2%;
37 | --primary-foreground: 210 40% 98%;
38 | // Secondary
39 | --secondary: 210 40% 96.1%;
40 | --secondary-foreground: 222.2 47.4% 11.2%;
41 | // Accent
42 | --accent: 210 40% 96.1%;
43 | --accent-foreground: 222.2 47.4% 11.2%;
44 | // Destructive
45 | --destructive: 0 100% 50%;
46 | --destructive-foreground: 210 40% 98%;
47 | // Ring
48 | --ring: 215 20.2% 65.1%;
49 | // Radius
50 | --radius: 0.5rem;
51 | }
52 |
53 | .dark {
54 | // Background
55 | --background: 224 71% 4%;
56 | --foreground: 213 31% 91%;
57 | // Mute
58 | --muted: 223 47% 11%;
59 | --muted-foreground: 215.4 16.3% 56.9%;
60 | // Popover
61 | --accent: 216 34% 17%;
62 | --accent-foreground: 210 40% 98%;
63 | // Border
64 | --popover: 224 71% 4%;
65 | --popover-foreground: 215 20.2% 65.1%;
66 | // Card
67 | --border: 216 34% 17%;
68 | --input: 216 34% 17%;
69 | // Card
70 | --card: 224 71% 4%;
71 | --card-foreground: 213 31% 91%;
72 | // Primary
73 | --primary: 210 40% 98%;
74 | --primary-foreground: 222.2 47.4% 1.2%;
75 | // Secondary
76 | --secondary: 222.2 47.4% 11.2%;
77 | --secondary-foreground: 210 40% 98%;
78 | // Accent
79 | --destructive: 0 63% 31%;
80 | --destructive-foreground: 210 40% 98%;
81 | // Ring
82 | --ring: 216 34% 17%;
83 | // Radius
84 | --radius: 0.5rem;
85 | }
86 | }
87 |
88 | @layer base {
89 | * {
90 | @apply border-border;
91 |
92 | box-sizing: border-box;
93 | }
94 |
95 | body {
96 | @apply bg-background text-foreground;
97 |
98 | height: 100vh;
99 | font-feature-settings:
100 | 'rlig' 1,
101 | 'calt' 1;
102 | color: rgb(var(--foreground-rgb));
103 | background: linear-gradient(
104 | to bottom,
105 | transparent,
106 | rgb(var(--background-end-rgb))
107 | )
108 | rgb(var(--background-start-rgb));
109 | }
110 |
111 | button {
112 | @apply cursor-pointer border-0;
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/prisma/postgresql/migrations/20231026133156_recreate_next_auth_tables/migration.sql:
--------------------------------------------------------------------------------
1 |
2 | -- DropForeignKey
3 | ALTER TABLE "public"."accounts" DROP CONSTRAINT "accounts_user_id_fkey";
4 |
5 | -- DropForeignKey
6 | ALTER TABLE "public"."sessions" DROP CONSTRAINT "sessions_user_id_fkey";
7 |
8 | -- DropForeignKey
9 | ALTER TABLE "public"."user_password_resets" DROP CONSTRAINT "user_password_resets_user_id_fkey";
10 |
11 | -- DropTable
12 | DROP TABLE "public"."accounts";
13 |
14 | -- DropTable
15 | DROP TABLE "public"."sessions";
16 |
17 | -- DropTable
18 | DROP TABLE "public"."user_password_resets";
19 |
20 | -- DropTable
21 | DROP TABLE "public"."verification_requests";
22 |
23 | -- CreateTable
24 | CREATE TABLE "user_verification" (
25 | "id" TEXT NOT NULL,
26 | "user_id" TEXT NOT NULL,
27 | "token" TEXT NOT NULL,
28 | "type" INTEGER NOT NULL DEFAULT 1,
29 | "expired_at" TIMESTAMP(3) NOT NULL,
30 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
31 | "updated_at" TIMESTAMP(3) NOT NULL,
32 |
33 | CONSTRAINT "user_verification_pkey" PRIMARY KEY ("id")
34 | );
35 |
36 | -- CreateTable
37 | CREATE TABLE "accounts" (
38 | "id" TEXT NOT NULL,
39 | "user_id" TEXT NOT NULL,
40 | "type" TEXT NOT NULL,
41 | "provider" TEXT NOT NULL,
42 | "provider_account_id" TEXT NOT NULL,
43 | "refresh_token" TEXT,
44 | "access_token" TEXT,
45 | "expires_at" INTEGER,
46 | "token_type" TEXT,
47 | "scope" TEXT,
48 | "id_token" TEXT,
49 | "session_state" TEXT,
50 |
51 | CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
52 | );
53 |
54 | -- CreateTable
55 | CREATE TABLE "sessions" (
56 | "id" TEXT NOT NULL,
57 | "session_token" TEXT NOT NULL,
58 | "user_id" TEXT NOT NULL,
59 | "expires" TIMESTAMP(3) NOT NULL,
60 |
61 | CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
62 | );
63 |
64 | -- CreateTable
65 | CREATE TABLE "verification_tokens" (
66 | "identifier" TEXT NOT NULL,
67 | "token" TEXT NOT NULL,
68 | "expires" TIMESTAMP(3) NOT NULL
69 | );
70 |
71 | -- CreateIndex
72 | CREATE UNIQUE INDEX "user_verification_token_key" ON "user_verification"("token");
73 |
74 | -- CreateIndex
75 | CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id");
76 |
77 | -- CreateIndex
78 | CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token");
79 |
80 | -- CreateIndex
81 | CREATE UNIQUE INDEX "verification_tokens_token_key" ON "verification_tokens"("token");
82 |
83 | -- CreateIndex
84 | CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token");
85 |
86 | -- AddForeignKey
87 | ALTER TABLE "user_verification" ADD CONSTRAINT "user_verification_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
88 |
89 | -- AddForeignKey
90 | ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
91 |
92 | -- AddForeignKey
93 | ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
94 |
95 |
--------------------------------------------------------------------------------