diff --git a/.vscode/settings.json b/.vscode/settings.json index 413c47a3..b337c43c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "typescript.preferences.importModuleSpecifier": "non-relative", + "typescript.workspaceSymbols.excludeLibrarySymbols": true, "typescript.tsdk": "node_modules/typescript/lib", "editor.defaultFormatter": "biomejs.biome", "[typescript]": { diff --git a/package.json b/package.json index b79c5c4f..6718beb9 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@t3-oss/env-nextjs": "^0.6.1", "@tanstack/react-query": "^5.0.0", "bright": "^0.8.4", + "client-only": "^0.0.1", "cmdk": "^0.2.0", "dayjs": "^1.11.9", "drizzle-orm": "^0.28.6", @@ -40,8 +41,9 @@ "eslint-config-next": "14.0.0", "geist": "^1.0.0", "isbot": "^3.6.13", + "js-md5": "^0.8.3", "nanoid": "^4.0.2", - "next": "14.0.4", + "next": "14.0.5-canary.16", "nextjs-toploader": "^1.6.4", "nprogress": "^0.2.0", "pg": "^8.11.3", @@ -53,6 +55,7 @@ "rehype-raw": "^7.0.0", "rehype-slug": "^5.1.0", "remark": "^15.0.1", + "remark-breaks": "^4.0.0", "remark-gemoji": "^8.0.0", "remark-gfm": "^4.0.0", "remark-github": "^12.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cf9434a..f8b42ee8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,7 +18,7 @@ dependencies: version: 3.0.0 '@neshca/cache-handler': specifier: ^0.6.1 - version: 0.6.1(next@14.0.4)(redis@4.6.12) + version: 0.6.1(next@14.0.5-canary.16)(redis@4.6.12) '@neshca/json-replacer-reviver': specifier: ^1.1.0 version: 1.1.0 @@ -49,6 +49,9 @@ dependencies: bright: specifier: ^0.8.4 version: 0.8.4(react@18.2.0) + client-only: + specifier: ^0.0.1 + version: 0.0.1 cmdk: specifier: ^0.2.0 version: 0.2.0(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) @@ -69,19 +72,22 @@ dependencies: version: 14.0.0(eslint@8.48.0)(typescript@5.3.3) geist: specifier: ^1.0.0 - version: 1.2.0(next@14.0.4) + version: 1.2.0(next@14.0.5-canary.16) isbot: specifier: ^3.6.13 version: 3.7.1 + js-md5: + specifier: ^0.8.3 + version: 0.8.3 nanoid: specifier: ^4.0.2 version: 4.0.2 next: - specifier: 14.0.4 - version: 14.0.4(react-dom@18.2.0)(react@18.2.0) + specifier: 14.0.5-canary.16 + version: 14.0.5-canary.16(react-dom@18.2.0)(react@18.2.0) nextjs-toploader: specifier: ^1.6.4 - version: 1.6.4(next@14.0.4)(react-dom@18.2.0)(react@18.2.0) + version: 1.6.4(next@14.0.5-canary.16)(react-dom@18.2.0)(react@18.2.0) nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -112,6 +118,9 @@ dependencies: remark: specifier: ^15.0.1 version: 15.0.1 + remark-breaks: + specifier: ^4.0.0 + version: 4.0.0 remark-gemoji: specifier: ^8.0.0 version: 8.0.0 @@ -597,7 +606,7 @@ packages: - supports-color dev: false - /@neshca/cache-handler@0.6.1(next@14.0.4)(redis@4.6.12): + /@neshca/cache-handler@0.6.1(next@14.0.5-canary.16)(redis@4.6.12): resolution: {integrity: sha512-fd7tDtTz6kRsTKELw2ceVAkeFX9ygFY4/TSbVBEunTXyupBUSETy/++n/QBpcHU3Ook4vlD6CdPuotJZrWgrvQ==} peerDependencies: next: '>=13.5.1' @@ -605,7 +614,7 @@ packages: dependencies: '@neshca/json-replacer-reviver': 1.1.0 lru-cache: 10.1.0 - next: 14.0.4(react-dom@18.2.0)(react@18.2.0) + next: 14.0.5-canary.16(react-dom@18.2.0)(react@18.2.0) redis: 4.6.12 dev: false @@ -613,8 +622,8 @@ packages: resolution: {integrity: sha512-2WU0fd15k+IAJdNB0yK4VxPbjcbwDKGLW9qfehm5u8Bavhg+QGphn4YBiKtO6yfnGK2b+ihmNK8laWIvL/l44w==} dev: false - /@next/env@14.0.4: - resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==} + /@next/env@14.0.5-canary.16: + resolution: {integrity: sha512-+z7fWD2Sq7iSBXCrYVi7sldEEqV0KRpxiuyyPVwgcI5zppqrbEvMjS7A/qEfuAqTwtGla0T4m3/u9zXLGYTltQ==} dev: false /@next/eslint-plugin-next@14.0.0: @@ -623,8 +632,8 @@ packages: glob: 7.1.7 dev: false - /@next/swc-darwin-arm64@14.0.4: - resolution: {integrity: sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==} + /@next/swc-darwin-arm64@14.0.5-canary.16: + resolution: {integrity: sha512-u0rspxGAVbzDs+ZAlYB0GL482tOo0vsXhB9nmifEf/QO2lpNE0yj469Wp3bUbIO5eF2f2P/soqCcBRHQFd3Dpw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -632,8 +641,8 @@ packages: dev: false optional: true - /@next/swc-darwin-x64@14.0.4: - resolution: {integrity: sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==} + /@next/swc-darwin-x64@14.0.5-canary.16: + resolution: {integrity: sha512-Q1enxh+5atCdIJTpVDGakUoK5g0Ie6E2mJ5FrSg8eq/NkL/NuAQ0EvXnOIfEV4Qe4RHJGwiE95OZFZ3JXkLWIg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -641,8 +650,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-gnu@14.0.4: - resolution: {integrity: sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==} + /@next/swc-linux-arm64-gnu@14.0.5-canary.16: + resolution: {integrity: sha512-q5sazKe5M3hdk753HJENTR9BAK0oY+Ho03KY3LQek1HiQXCzhkOeLI6cv7tG14pZS9dKEPEoEJNc2T2Cz1iTsQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -650,8 +659,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-musl@14.0.4: - resolution: {integrity: sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==} + /@next/swc-linux-arm64-musl@14.0.5-canary.16: + resolution: {integrity: sha512-aFeKTcJfXoWSdBmMC6xrhLlc/dfqtdlMhn9yqTWGSVhrY4Uvedvsf4yinRtXa5qwMLjyV6uWr5Vtz0P7dY70dA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -659,8 +668,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-gnu@14.0.4: - resolution: {integrity: sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==} + /@next/swc-linux-x64-gnu@14.0.5-canary.16: + resolution: {integrity: sha512-iQ3DwbzgpCg37kNVG/Cr3bQKYo34fU7bwfbzh2km0fPp60cAqaAdPylBJK1yAq0AeC0ssyLwUQbbtHNUu3iOEg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -668,8 +677,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-musl@14.0.4: - resolution: {integrity: sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==} + /@next/swc-linux-x64-musl@14.0.5-canary.16: + resolution: {integrity: sha512-Vgn+7mIT5gHT0QEK+Yi/Pho45z8pFxu+8A1frQS0PJW9kjApKOyd3rx9qUKnEx0pUWzwPgIFPAaqYV7wHrvogA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -677,8 +686,8 @@ packages: dev: false optional: true - /@next/swc-win32-arm64-msvc@14.0.4: - resolution: {integrity: sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==} + /@next/swc-win32-arm64-msvc@14.0.5-canary.16: + resolution: {integrity: sha512-zqGcXia1vbHt+VTOJALCJxonkU9MnbkA1XrgHp8pohgteNXPNUAsIwmrb59K4PSzAY+PLeMDEoxfAJ7N9BaHiQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -686,8 +695,8 @@ packages: dev: false optional: true - /@next/swc-win32-ia32-msvc@14.0.4: - resolution: {integrity: sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==} + /@next/swc-win32-ia32-msvc@14.0.5-canary.16: + resolution: {integrity: sha512-lZDvViKKpuhlFdMsEnU1JOD2LvLbPwbr1QsuUpdiTDF/WAEJrZwgu7JV8YyQN0JDpTdWvtKrvFVBcmHefIl77Q==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] @@ -695,8 +704,8 @@ packages: dev: false optional: true - /@next/swc-win32-x64-msvc@14.0.4: - resolution: {integrity: sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==} + /@next/swc-win32-x64-msvc@14.0.5-canary.16: + resolution: {integrity: sha512-8CAojXeDz4N4MHAqYC4u+wAg3Cwm5DCz/irGBFFOUu35a/8kbyV5GI6Jg38s6SEYFc3gyVT3DJZDRLb1ExMgmA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3203,12 +3212,12 @@ packages: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: false - /geist@1.2.0(next@14.0.4): + /geist@1.2.0(next@14.0.5-canary.16): resolution: {integrity: sha512-RZsgCkGnSi1IV1Ozg3s6Ou4r/jzLff9+47ChjpJ5yX8ncEC/RwdStGwhdFzDcnSv0xU0+9J/fTX5Kht0NajTXA==} peerDependencies: next: ^13.2 || ^14 dependencies: - next: 14.0.4(react-dom@18.2.0)(react@18.2.0) + next: 14.0.5-canary.16(react-dom@18.2.0)(react@18.2.0) dev: false /gemoji@8.1.0: @@ -3824,6 +3833,10 @@ packages: hasBin: true dev: true + /js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4118,6 +4131,13 @@ packages: - supports-color dev: false + /mdast-util-newline-to-break@2.0.0: + resolution: {integrity: sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==} + dependencies: + '@types/mdast': 4.0.3 + mdast-util-find-and-replace: 3.0.1 + dev: false + /mdast-util-phrasing@4.0.0: resolution: {integrity: sha512-xadSsJayQIucJ9n053dfQwVu1kuXg7jCTdYsMK8rqzKZh52nLfSH/k0sAxE0u+pj/zKZX+o5wB+ML5mRayOxFA==} dependencies: @@ -4585,8 +4605,8 @@ packages: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} dev: true - /next@14.0.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==} + /next@14.0.5-canary.16(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-jVdjZqBZZW3dNkgUswgcEaXXBs+LKlQHfrtH2BM26Fqldz+fU4gfgbMoLBm4n9WG6iUNyH0BJqpGTziS4vW6JA==} engines: {node: '>=18.17.0'} hasBin: true peerDependencies: @@ -4600,7 +4620,7 @@ packages: sass: optional: true dependencies: - '@next/env': 14.0.4 + '@next/env': 14.0.5-canary.16 '@swc/helpers': 0.5.2 busboy: 1.6.0 caniuse-lite: 1.0.30001570 @@ -4611,21 +4631,21 @@ packages: styled-jsx: 5.1.1(react@18.2.0) watchpack: 2.4.0 optionalDependencies: - '@next/swc-darwin-arm64': 14.0.4 - '@next/swc-darwin-x64': 14.0.4 - '@next/swc-linux-arm64-gnu': 14.0.4 - '@next/swc-linux-arm64-musl': 14.0.4 - '@next/swc-linux-x64-gnu': 14.0.4 - '@next/swc-linux-x64-musl': 14.0.4 - '@next/swc-win32-arm64-msvc': 14.0.4 - '@next/swc-win32-ia32-msvc': 14.0.4 - '@next/swc-win32-x64-msvc': 14.0.4 + '@next/swc-darwin-arm64': 14.0.5-canary.16 + '@next/swc-darwin-x64': 14.0.5-canary.16 + '@next/swc-linux-arm64-gnu': 14.0.5-canary.16 + '@next/swc-linux-arm64-musl': 14.0.5-canary.16 + '@next/swc-linux-x64-gnu': 14.0.5-canary.16 + '@next/swc-linux-x64-musl': 14.0.5-canary.16 + '@next/swc-win32-arm64-msvc': 14.0.5-canary.16 + '@next/swc-win32-ia32-msvc': 14.0.5-canary.16 + '@next/swc-win32-x64-msvc': 14.0.5-canary.16 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros dev: false - /nextjs-toploader@1.6.4(next@14.0.4)(react-dom@18.2.0)(react@18.2.0): + /nextjs-toploader@1.6.4(next@14.0.5-canary.16)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-KYLQ+0MvGdFk9JwOQfRtaYBAsyuX67Ca5QTa51RGNO4gQx64KLSE+ryHjUQ5LcDczHotp0l32GgksQW9vucUkw==} peerDependencies: next: '>= 6.0.0' @@ -4633,7 +4653,7 @@ packages: react-dom: '>= 16.0.0' dependencies: '@types/nprogress': 0.2.3 - next: 14.0.4(react-dom@18.2.0)(react@18.2.0) + next: 14.0.5-canary.16(react-dom@18.2.0)(react@18.2.0) nprogress: 0.2.0 prop-types: 15.8.1 react: 18.2.0 @@ -5379,6 +5399,14 @@ packages: unist-util-visit: 4.1.2 dev: false + /remark-breaks@4.0.0: + resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==} + dependencies: + '@types/mdast': 4.0.3 + mdast-util-newline-to-break: 2.0.0 + unified: 11.0.4 + dev: false + /remark-gemoji@8.0.0: resolution: {integrity: sha512-/fL9rc72FYwFGtOKcT+QeQdx9Q9t5v4N6KLXSDOTEgaedzK85I9judBqB2eqz+g4b0ERMejlwSOuPK+wket6aA==} dependencies: diff --git a/src/actions/auth.action.ts b/src/actions/auth.action.ts index 2c976793..e5203506 100644 --- a/src/actions/auth.action.ts +++ b/src/actions/auth.action.ts @@ -105,18 +105,20 @@ export const getSession = cache(async function getSession(): Promise { return session; }); -export const redirectIfNotAuthed = cache(async function getUserOrRedirect( +export const getUserOrRedirect = cache(async function getUserOrRedirect( redirectToPath?: string ) { - const session = await getSession(); + const user = await getAuthedUser(); - if (!session.user) { + if (!user) { const searchParams = new URLSearchParams(); if (redirectToPath) { searchParams.set("nextUrl", redirectToPath); } redirect("/login?" + searchParams.toString()); } + + return user; }); export const getAuthedUser = cache(async function getUser() { diff --git a/src/actions/github.action.ts b/src/actions/github.action.ts index c2c7c774..eaa7eb87 100644 --- a/src/actions/github.action.ts +++ b/src/actions/github.action.ts @@ -133,9 +133,7 @@ export const getGithubRepoData = cache(async () => { while (hasNextPage) { const { - repository: { - stargazers: { pageInfo, edges } - } + repository: { stargazers: { pageInfo, edges } } } = await fetchFromGithubAPI(stargazersQuery, { cursor: nextCursor, repoName: GITHUB_REPOSITORY_NAME, diff --git a/src/actions/markdown.action.tsx b/src/actions/markdown.action.tsx new file mode 100644 index 00000000..0e46964f --- /dev/null +++ b/src/actions/markdown.action.tsx @@ -0,0 +1,13 @@ +"use server"; + +import { Markdown } from "~/components/markdown/markdown"; +import { renderRSCtoString } from "~/components/custom-rsc-renderer/render-rsc-to-string"; + +export async function getMarkdownPreview( + content: string, + repositoryPath: `${string}/${string}` +) { + return await renderRSCtoString( + + ); +} diff --git a/src/actions/user.action.ts b/src/actions/user.action.ts index 12c27cc8..39f70faa 100644 --- a/src/actions/user.action.ts +++ b/src/actions/user.action.ts @@ -10,38 +10,40 @@ import { import type { FormState } from "~/lib/types"; -export const updateUserProfile = withAuth(async ( - _previousState: FormState | AuthError, - formData: FormData, - { session, currentUser }: AuthState -): Promise> => { - const result = updateUserProfileInfosInputValidator.safeParse( - Object.fromEntries(formData) - ); +export const updateUserProfile = withAuth( + async ( + _previousState: FormState | AuthError, + formData: FormData, + { session, currentUser }: AuthState + ): Promise> => { + const result = updateUserProfileInfosInputValidator.safeParse( + Object.fromEntries(formData) + ); - if (!result.success) { - return { - type: "error" as const, - fieldErrors: result.error.flatten().fieldErrors, - formData: { - name: formData.get("name")?.toString() ?? null, - bio: formData.get("bio")?.toString() ?? null, - company: formData.get("company")?.toString() ?? null, - location: formData.get("company")?.toString() ?? null - } - }; - } + if (!result.success) { + return { + type: "error" as const, + fieldErrors: result.error.flatten().fieldErrors, + formData: { + name: formData.get("name")?.toString() ?? null, + bio: formData.get("bio")?.toString() ?? null, + company: formData.get("company")?.toString() ?? null, + location: formData.get("company")?.toString() ?? null + } + }; + } - await updateUserInfos(result.data, currentUser.id); + await updateUserInfos(result.data, currentUser.id); - await session.addFlash({ - type: "success", - message: "Profile updated successfully" - }); + await session.addFlash({ + type: "success", + message: "Profile updated successfully" + }); - revalidatePath(`/`); - return { - type: "success" as const, - message: "Success" - }; -}); + revalidatePath(`/`); + return { + type: "success" as const, + message: "Success" + }; + } +); diff --git a/src/app/(app)/[user]/[repository]/issues/new/page.tsx b/src/app/(app)/[user]/[repository]/issues/new/page.tsx index cf5e3738..8e26b364 100644 --- a/src/app/(app)/[user]/[repository]/issues/new/page.tsx +++ b/src/app/(app)/[user]/[repository]/issues/new/page.tsx @@ -1,3 +1,41 @@ -export default function NewIssuePage() { - return <>; +// components +import { NewIssueForm } from "~/components/issues/new-issue-form"; + +// utils +import { notFound } from "next/navigation"; +import { getUserOrRedirect } from "~/actions/auth.action"; +import { getRepositoryByOwnerAndName } from "~/models/repository"; + +// types +import type { Metadata } from "next"; +import type { PageProps } from "~/lib/types"; + +type NewIssuePageProps = PageProps<{ + user: string; + repository: string; +}>; + +export const metadata: Metadata = { + title: "New Issue" +}; + +export default async function NewIssuePage(props: NewIssuePageProps) { + const repository = await getRepositoryByOwnerAndName( + props.params.user, + props.params.repository + ); + + if (!repository) { + notFound(); + } + + const currentUser = await getUserOrRedirect( + `/${props.params.user}/${props.params.repository}/issues/new` + ); + return ( + + ); } diff --git a/src/app/(app)/[user]/[repository]/layout.tsx b/src/app/(app)/[user]/[repository]/layout.tsx index 56562e92..5dffd1b7 100644 --- a/src/app/(app)/[user]/[repository]/layout.tsx +++ b/src/app/(app)/[user]/[repository]/layout.tsx @@ -22,8 +22,8 @@ export async function generateMetadata( return { title: { - template: `%s · ${repository.name}`, - default: `${repository.name} · ${repository.description}` + template: `%s · ${repository.owner.username}/${repository.name}`, + default: `${repository.owner.username}/${repository.name} · ${repository.description}` } }; } diff --git a/src/app/(app)/not-found.tsx b/src/app/(app)/not-found.tsx deleted file mode 100644 index e9947ae1..00000000 --- a/src/app/(app)/not-found.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import { HomeIcon } from "@primer/octicons-react"; -import { Button } from "~/components/button"; - -export default function AppNotFound() { - return ( - <> -
-
- 404 “This is not the web page you are looking for” - -
- - - -
- -
- - -
-
- - -
- - ); -} diff --git a/src/app/(app)/settings/account/page.tsx b/src/app/(app)/settings/account/page.tsx index c6e1f7d1..87d62d19 100644 --- a/src/app/(app)/settings/account/page.tsx +++ b/src/app/(app)/settings/account/page.tsx @@ -5,7 +5,7 @@ import { UpdateUserInfosForm } from "~/components/update-user-infos-form"; import { Button } from "~/components/button"; // utils -import { getAuthedUser, redirectIfNotAuthed } from "~/actions/auth.action"; +import { getAuthedUser, getUserOrRedirect } from "~/actions/auth.action"; import { updateUserProfileInfosInputValidator } from "~/models/dto/update-profile-info-input-validator"; // types @@ -16,9 +16,7 @@ export const metadata: Metadata = { }; export default async function Page() { - await redirectIfNotAuthed("/settings/account"); - - const user = (await getAuthedUser())!; + const user = await getUserOrRedirect("/settings/account"); return (
diff --git a/src/app/(app)/settings/appearance/page.tsx b/src/app/(app)/settings/appearance/page.tsx index ea5326dd..c2a269a5 100644 --- a/src/app/(app)/settings/appearance/page.tsx +++ b/src/app/(app)/settings/appearance/page.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { ThemeForm } from "~/components/theme-form"; // utils -import { redirectIfNotAuthed } from "~/actions/auth.action"; +import { getUserOrRedirect } from "~/actions/auth.action"; import { getTheme } from "~/actions/theme.action"; // types @@ -15,7 +15,7 @@ export const metadata: Metadata = { }; export default async function Page() { - await redirectIfNotAuthed("/settings/appearance"); + await getUserOrRedirect("/settings/appearance"); const theme = await getTheme(); return (
diff --git a/src/app/(app)/settings/layout.tsx b/src/app/(app)/settings/layout.tsx index 467f7a4b..17eac1c1 100644 --- a/src/app/(app)/settings/layout.tsx +++ b/src/app/(app)/settings/layout.tsx @@ -4,7 +4,7 @@ import { Avatar } from "~/components/avatar"; import { VerticalNavlist } from "~/components/vertical-navlist"; // utils -import { getAuthedUser, redirectIfNotAuthed } from "~/actions/auth.action"; +import { getAuthedUser, getUserOrRedirect } from "~/actions/auth.action"; import { clsx } from "~/lib/shared/utils.shared"; export default async function SettingsLayout({ @@ -12,8 +12,7 @@ export default async function SettingsLayout({ }: { children: React.ReactNode; }) { - await redirectIfNotAuthed("/settings/account"); - const user = (await getAuthedUser())!; + const user = await getUserOrRedirect("/settings/account"); return ( <>
+ {/* + FIXME: this is a fix because nextjs considers the metadata on the children layouts + over the metadata set here, even though this is on top of the layout + So this way we manually overwrites the title above with a title inside + */} + Page not found · gh-next +
void; + icon: React.ComponentType<{ className?: string }>; +}; + +export type ActionToolbarItemGroups = ActionToolbarItem[]; + +export type ActionToolbarProps = { + itemGroups: Array; + className?: string; + title: string; + showItems?: boolean; +}; + +export const ActionToolbar = React.forwardRef< + React.ElementRef, + ActionToolbarProps +>(function ActionToolbar( + { itemGroups, className, title, showItems = true }, + ref +) { + const [visibleItemGroups, setVisibleItemGroups] = React.useState(itemGroups); + const [hiddenItemGroups, setHiddenItemGroups] = React.useState< + Array + >([]); + const [hasComputedSize, setHasComputedSize] = React.useState(false); + const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); + + const toolbarRef = React.useRef>(null); + + React.useEffect(() => { + const observer = new ResizeObserver(([entry]) => { + const TOOLBAR_PADDING = 8; // defined in the class `px-2` + const ITEM_SIZE = 32; + + const toolbarElement = entry.target as React.ElementRef< + typeof Toolbar.Root + >; + const width = toolbarElement.offsetWidth; + const maxNumberOfItemsVisible = Math.floor( + (width - TOOLBAR_PADDING * 2) / ITEM_SIZE // we multiply the padding x2 to account for the left & right position + ); + + const [visible, hidden] = getVisibleAndHiddenToolbarItemGroups( + itemGroups, + // we remove 1 because of the dropdown itself which will take one seat + // and we remove another just to have some margin + maxNumberOfItemsVisible - 2 + ); + + setHiddenItemGroups(hidden); + setVisibleItemGroups(visible); + setHasComputedSize((value) => { + if (!value) return true; + return value; + }); + }); + + const toolbar = toolbarRef?.current; + if (toolbar) { + observer.observe(toolbar); + return () => { + observer.unobserve(toolbar); + }; + } + }, [itemGroups]); + + return ( +
+ 0 + })} + > + {visibleItemGroups.map((items, index) => ( + + {items.map((item) => ( + + ))} + + {/* Don't show the separator for the last group */} + {index < visibleItemGroups.length - 1 && ( + + )} + + ))} + + {hiddenItemGroups.length > 0 && showItems && ( + + + + + + + + + {hiddenItemGroups.map((items, index) => ( + + {items.map((item) => ( + + ))} + + {/* Don't show the separator for the last group */} + {index < hiddenItemGroups.length - 1 && } + + ))} + + + )} + +
+ ); +}); + +type ActionToolbarButtonProps = React.ComponentProps & + ActionToolbarItem; + +const ActionToolbarButton = React.forwardRef< + React.ElementRef, + ActionToolbarButtonProps +>(function ActionToolbarButton( + { label, onClick, icon: Icon, className, id, ...props }, + ref +) { + return ( + + + + + + ); +}); + +export function getVisibleAndHiddenToolbarItemGroups( + itemGroups: Array, + maxNumberOfItemsVisible: number +): [ + visible: Array, + hidden: Array +] { + const visible: Array = []; + const hidden: Array = []; + let visibleItemCount = 0; + + itemGroups.forEach((group) => { + if (visibleItemCount + group.length <= maxNumberOfItemsVisible) { + // If the entire group fits within the visible limit + visible.push(group); + visibleItemCount += group.length; + } else if (visibleItemCount < maxNumberOfItemsVisible) { + // Split the group between visible and hidden + const itemsRemaining = maxNumberOfItemsVisible - visibleItemCount; + visible.push(group.slice(0, itemsRemaining)); + hidden.push(group.slice(itemsRemaining)); + visibleItemCount = maxNumberOfItemsVisible; + } else { + // Add the entire group to hidden + hidden.push(group); + } + }); + + return [visible, hidden]; +} diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx index b1b58918..e0e431b3 100644 --- a/src/components/avatar.tsx +++ b/src/components/avatar.tsx @@ -25,8 +25,8 @@ export function Avatar({ { "h-3.5 w-3.5": size === "x-small", "h-5 w-5": size === "small", - "h-16 w-16": size === "large", - "h-8 w-8": size === "medium" + "h-14 w-14": size === "large", + "h-10 w-10": size === "medium" }, className )} diff --git a/src/components/cache/cache-context.tsx b/src/components/cache/cache-context.tsx deleted file mode 100644 index 153131fa..00000000 --- a/src/components/cache/cache-context.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use client"; -import * as React from "react"; - -export function RSCCacheProvider({ children }: { children: React.ReactNode }) { - const [cache] = React.useState(() => new Map()); - return ( - - {children} - - ); -} - -export type RSCCacheContextType = Map< - string, - Promise -> | null; - -export const RSCCacheContext = React.createContext(null); - -export function useRSCCacheContext() { - const cache = React.use(RSCCacheContext); - - if (!cache) { - throw new Error("Cache is null, this should never arrives"); - } - - return cache; -} diff --git a/src/components/hovercard/user-hovercard-contents.tsx b/src/components/hovercard/user-hovercard-contents.tsx index 1b60fe4e..94d77d54 100644 --- a/src/components/hovercard/user-hovercard-contents.tsx +++ b/src/components/hovercard/user-hovercard-contents.tsx @@ -1,6 +1,6 @@ import * as React from "react"; // components -import { Avatar } from "../avatar"; +import { Avatar } from "~/components/avatar"; import { LocationIcon } from "@primer/octicons-react"; import Link from "next/link"; diff --git a/src/components/input.tsx b/src/components/input.tsx index c4eb0360..bb102883 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -5,13 +5,14 @@ import { AlertFillIcon, CheckCircleFillIcon } from "@primer/octicons-react"; export type InputProps = Omit< React.InputHTMLAttributes, - "size" | "defaultValue" | "value" + "size" | "defaultValue" | "value" | "name" > & { label: React.ReactNode; defaultValue?: string | number | ReadonlyArray | undefined | null; value?: string | number | ReadonlyArray | undefined | null; inputClassName?: string; helpText?: string; + name: string; renderLeadingIcon?: (classNames: string) => JSX.Element; renderTrailingIcon?: (classNames: string) => JSX.Element; size?: "small" | "medium" | "large"; diff --git a/src/components/issues/issue-assignee-filter-action-list.tsx b/src/components/issues/issue-assignee-filter-action-list.tsx index 181408ff..078d684d 100644 --- a/src/components/issues/issue-assignee-filter-action-list.tsx +++ b/src/components/issues/issue-assignee-filter-action-list.tsx @@ -137,6 +137,7 @@ export function IssueAssigneeFilterActionList({ title="Filter by who’s assigned" header={ setInputQuery(e.target.value)} label="name or username" diff --git a/src/components/issues/issue-author-filter-action-list.tsx b/src/components/issues/issue-author-filter-action-list.tsx index d1ef9977..44bf681e 100644 --- a/src/components/issues/issue-author-filter-action-list.tsx +++ b/src/components/issues/issue-author-filter-action-list.tsx @@ -80,6 +80,7 @@ export function IssueAuthorFilterActionList({ title="Filter by author" header={ setInputQuery(e.target.value)} label="Author name or username" diff --git a/src/components/issues/issue-label-filter-action-list.tsx b/src/components/issues/issue-label-filter-action-list.tsx index a37e3c7a..d74c741a 100644 --- a/src/components/issues/issue-label-filter-action-list.tsx +++ b/src/components/issues/issue-label-filter-action-list.tsx @@ -127,6 +127,7 @@ export function IssueLabelFilterActionList({ title="Filter by label" header={ setInputQuery(e.target.value)} label="Author name or username" diff --git a/src/components/issues/issue-list-search-input.tsx b/src/components/issues/issue-list-search-input.tsx index f1f415b2..d58699ea 100644 --- a/src/components/issues/issue-list-search-input.tsx +++ b/src/components/issues/issue-list-search-input.tsx @@ -1,4 +1,4 @@ -"use client"; +import "client-only"; import * as React from "react"; // components import { Command as CommandPrimitive } from "cmdk"; diff --git a/src/components/issues/new-issue-form.tsx b/src/components/issues/new-issue-form.tsx new file mode 100644 index 00000000..474b0270 --- /dev/null +++ b/src/components/issues/new-issue-form.tsx @@ -0,0 +1,151 @@ +"use client"; +import * as React from "react"; +// components +import { GearIcon, InfoIcon } from "@primer/octicons-react"; +import { Input } from "~/components/input"; +import { MarkdownEditor } from "~/components/markdown-editor/markdown-editor"; +import { SubmitButton } from "~/components/submit-button"; +import { Avatar } from "~/components/avatar"; + +// utils +import { clsx } from "~/lib/shared/utils.shared"; + +// types +export type NewIssueFormProps = { + currentUserUsername: string; + currentUserAvatarUrl: string; +}; + +export function NewIssueForm({ + currentUserAvatarUrl, + currentUserUsername +}: NewIssueFormProps) { + return ( +
+
+
+
+ +
+
+ + + +
+
+ + + +

+ Remember, contributions to this repository should follow the  + + GitHub Community Guidelines + + . +

+
+ + + +
+ + Submit new issue + +
+
+
+ ); +} + +function AssigneeFormInput() { + return ( +
+ +
+ No one - + +
+
+ ); +} +function LabelFormInput() { + return ( +
+ +
None yet
+
+ ); +} diff --git a/src/components/label-badge.tsx b/src/components/label-badge.tsx index 0957c838..aeb4003a 100644 --- a/src/components/label-badge.tsx +++ b/src/components/label-badge.tsx @@ -22,7 +22,7 @@ export function LabelBadge({ title, color, className }: LabelBadgeProps) { return ( + ( +
+ + Error rendering preview : + + + {error.toString()} + +
+ )} + > + + loading preview... + + + + + +
+ } + > + + + + + ); +} + +const loadMarkdownPreview = fnCache(getMarkdownPreview); + +export async function prerenderMarkdownPreview( + content: string, + repositoryPath: `${string}/${string}` +) { + React.startTransition(() => { + renderPayloadOrPromiseToJSX( + loadMarkdownPreview(content, repositoryPath), + false + ); + }); +} diff --git a/src/components/markdown-editor/markdown-editor-toolbar.tsx b/src/components/markdown-editor/markdown-editor-toolbar.tsx new file mode 100644 index 00000000..40a8af01 --- /dev/null +++ b/src/components/markdown-editor/markdown-editor-toolbar.tsx @@ -0,0 +1,336 @@ +import * as React from "react"; +// components +import { + HeadingIcon, + BoldIcon, + ItalicIcon, + QuoteIcon, + CodeIcon, + LinkIcon, + ListOrderedIcon, + ListUnorderedIcon, + TasklistIcon, + MentionIcon, + CrossReferenceIcon, + DiffIgnoredIcon +} from "@primer/octicons-react"; +import { ActionToolbar } from "~/components/action-toolbar"; + +// types +import type { ActionToolbarItemGroups } from "~/components/action-toolbar"; + +type MarkdownEditorToolbarProps = { + textAreaRef: React.RefObject>; + onTextContentChange: (newText: string) => void; + textContent: string; + showItems?: boolean; +}; + +export const MarkdownEditorToolbar = React.forwardRef< + React.ElementRef, + MarkdownEditorToolbarProps +>(function MarkdownTextAreaToolbar( + { textAreaRef, onTextContentChange, textContent, showItems = true }, + ref +) { + function addOrRemoveHeading() { + const textArea = textAreaRef.current; + if (textArea) { + const selectionStart = textArea.selectionStart; + const selectionEnd = textArea.selectionEnd; + + const untilSelectionStart = textContent.slice(0, selectionStart); + const fromSelectionStart = textContent.slice(selectionStart); + + const allPreviousLines = untilSelectionStart.split("\n"); + const currentLine = allPreviousLines.pop(); + + const headingRegex = /^(#+ ?)/; + const match = (currentLine ?? "").match(headingRegex); + const isHeading = !!match; + + if (isHeading) { + const totalMatchedChars = match[0].length; + const newLineWithoutHeading = (currentLine ?? "").replace( + /^(#+ ?)/, + "" + ); + + const result = + [...allPreviousLines, newLineWithoutHeading].join("\n") + + fromSelectionStart; + + onTextContentChange(result); + textArea.setSelectionRange( + selectionStart - totalMatchedChars, + selectionEnd - totalMatchedChars + ); + } else { + const newLineWithHeading = "### " + (currentLine ?? ""); + + const result = + [...allPreviousLines, newLineWithHeading].join("\n") + + fromSelectionStart; + + onTextContentChange(result); + textArea.setSelectionRange(selectionStart + 4, selectionEnd + 4); + } + textArea.focus(); + } + } + + function addOrRemoveSurroundingChars(chars: string) { + const textArea = textAreaRef.current; + if (textArea) { + const selectionStart = textArea.selectionStart; + const selectionEnd = textArea.selectionEnd; + + const isSelectingMultipleChars = selectionEnd - selectionStart > 0; + + if (isSelectingMultipleChars) { + const selectedContentBolded = textContent.slice( + selectionStart - chars.length, + selectionEnd + chars.length + ); + + const isTextAlreadyBolded = + selectedContentBolded.startsWith(chars) && + selectedContentBolded.endsWith(chars); + + if (isTextAlreadyBolded) { + onTextContentChange( + stripSurroundingText({ + text: textContent, + startIndex: selectionStart - chars.length, + endIndex: selectionEnd + chars.length, + surroundingText: chars + }) + ); + // keep the selection considering the bold stars removed + textArea.setSelectionRange( + selectionStart - chars.length, + selectionEnd - chars.length + ); + } else { + onTextContentChange( + surroundTextWith({ + text: textContent, + startIndex: selectionStart, + endIndex: selectionEnd, + beforeText: chars + }) + ); + // keep the selection considering the bold stars added + textArea.setSelectionRange( + selectionStart + chars.length, + selectionEnd + chars.length + ); + } + } else { + const { start, end } = getSelectionStartAndSelectionEnd( + textContent, + selectionEnd + ); + + const selectedContent = textContent.slice(start, end); + const isTextAlreadyBolded = + selectedContent.startsWith(chars) && selectedContent.endsWith(chars); + + if (isTextAlreadyBolded) { + onTextContentChange( + stripSurroundingText({ + text: textContent, + startIndex: start, + endIndex: end, + surroundingText: chars + }) + ); + + // keep the selection considering the bold stars removed + textArea.setSelectionRange( + selectionEnd - chars.length, + selectionEnd - chars.length + ); + } else { + onTextContentChange( + surroundTextWith({ + text: textContent, + startIndex: start, + endIndex: end, + beforeText: chars + }) + ); + + // keep the selection considering the bold stars added + textArea.setSelectionRange( + selectionEnd + chars.length, + selectionEnd + chars.length + ); + } + } + textArea.focus(); + } + } + + const itemGroups = [ + [ + { + id: "header", + label: "Header", + icon: HeadingIcon, + onClick: addOrRemoveHeading + }, + { + id: "bold", + label: "Bold", + onClick: () => addOrRemoveSurroundingChars("**"), + icon: BoldIcon + }, + { + id: "italic", + label: "Italic", + onClick: () => addOrRemoveSurroundingChars("_"), + icon: ItalicIcon + }, + { + id: "quote", + label: "Quote", + onClick: () => {}, + icon: QuoteIcon + }, + { + id: "code", + label: "Code", + onClick: () => addOrRemoveSurroundingChars("`"), + icon: CodeIcon + }, + { + id: "link", + label: "Link", + onClick: () => {}, + icon: LinkIcon + } + ], + [ + { + id: "ordered-list", + label: "Numbered list", + onClick: () => {}, + icon: ListOrderedIcon + }, + { + id: "unordered-list", + label: "Unordered list", + onClick: () => {}, + icon: ListUnorderedIcon + }, + { + id: "task-list", + label: "Task list", + onClick: () => {}, + icon: TasklistIcon + } + ], + [ + { + id: "mentions", + label: "Mentions", + onClick: () => {}, + icon: MentionIcon + }, + { + id: "references", + label: "References", + onClick: () => {}, + icon: CrossReferenceIcon + }, + { + id: "commands", + label: "Slash commands", + onClick: () => {}, + icon: DiffIgnoredIcon + } + ] + ] satisfies Array; + + return ( + + ); +}); + +function getSelectionStartAndSelectionEnd( + text: string, + cursorPosition: number +) { + const nextWord = text.slice(cursorPosition).split(/[ \n]/)[0]; + const previousWord = text + .slice(0, cursorPosition) + .split(/[ \n]/) + .reverse()[0]; + + const start = cursorPosition - previousWord.length; + const end = cursorPosition + nextWord.length; + + return { start, end }; +} + +type SurroundTextWithArgs = { + text: string; + startIndex: number; + endIndex: number; + beforeText: string; + afterText?: string; +}; + +function surroundTextWith({ + text, + startIndex, + endIndex, + beforeText, + afterText = beforeText +}: SurroundTextWithArgs) { + const untilSelectionStart = text.slice(0, startIndex); + const fromSelectionEnd = text.slice(endIndex); + const selectedContent = text.slice(startIndex, endIndex); + + return ( + untilSelectionStart + + beforeText + + selectedContent + + afterText + + fromSelectionEnd + ); +} + +type StripSurroundTextWithArgs = { + text: string; + startIndex: number; + endIndex: number; + surroundingText: string; +}; + +function stripSurroundingText({ + text, + startIndex, + endIndex, + surroundingText +}: StripSurroundTextWithArgs) { + const untilSelectionStart = text.slice(0, startIndex); + const fromSelectionEnd = text.slice(endIndex); + const selectedContent = text.slice(startIndex, endIndex); + + return ( + untilSelectionStart + + selectedContent.slice( + surroundingText.length, + selectedContent.length - surroundingText.length + ) + + fromSelectionEnd + ); +} diff --git a/src/components/markdown-editor/markdown-editor.tsx b/src/components/markdown-editor/markdown-editor.tsx new file mode 100644 index 00000000..828a3782 --- /dev/null +++ b/src/components/markdown-editor/markdown-editor.tsx @@ -0,0 +1,209 @@ +"use client"; +import * as React from "react"; +// components +import * as Tabs from "@radix-ui/react-tabs"; +import { Textarea } from "~/components/textarea"; +import { MarkdownIcon } from "@primer/octicons-react"; +import { Button } from "~/components/button"; + +import { MarkdownEditorPreview } from "~/components/markdown-editor/markdown-editor-preview"; +import { MarkdownEditorToolbar } from "~/components/markdown-editor/markdown-editor-toolbar"; + +// utils +import { clsx } from "~/lib/shared/utils.shared"; +import { z } from "zod"; +import { useTypedParams } from "~/lib/client/hooks/use-typed-params"; +import { flushSync } from "react-dom"; +import { prerenderMarkdownPreview } from "~/components/markdown-editor/markdown-editor-preview"; + +// types +import type { TextareaProps } from "~/components/textarea"; + +export type MarkdownEditorProps = Omit; + +const TABS = { + PREVIEW: "PREVIEW", + EDITOR: "EDITOR" +} as const; +type TabValue = (typeof TABS)[keyof typeof TABS]; + +const paramsSchema = z.object({ + user: z.string(), + repository: z.string() +}); + +export function MarkdownEditor({ + label, + defaultValue, + ...props +}: MarkdownEditorProps) { + const params = useTypedParams( + paramsSchema, + "This component should be used within a repository path" + ); + + const [textContent, setTextContent] = React.useState(defaultValue ?? ""); + const [selectedTab, setSelectedTab] = React.useState(TABS.EDITOR); + const [textAreaHeight, setTextAreaHeight] = React.useState(0); + const textAreaRef = React.useRef>(null); + const lastTextareaSelectionRange = React.useRef({ start: -1, end: -1 }); + + React.useEffect(() => { + const observer = new ResizeObserver(([entry]) => { + const height = (entry.target as HTMLTextAreaElement).offsetHeight; + // height becomes 0 when switching tabs + if (height > 0) { + setTextAreaHeight(height); + } + }); + + const textAreaElement = textAreaRef.current; + if (textAreaElement) { + setTextAreaHeight(textAreaElement.offsetHeight); + observer.observe(textAreaElement); + + return () => { + observer.unobserve(textAreaElement); + }; + } + }, [selectedTab]); + + return ( + <> +
+ +
+ { + setSelectedTab(tab as TabValue); + if (textAreaRef.current) { + lastTextareaSelectionRange.current = { + start: textAreaRef.current.selectionStart, + end: textAreaRef.current.selectionEnd + }; + } + }} + > + + { + textAreaRef.current?.focus(); + const { start, end } = lastTextareaSelectionRange.current; + textAreaRef.current?.setSelectionRange(start, end); + }} + > + Write + + { + if (textContent.trim().length > 0) { + prerenderMarkdownPreview( + textContent, + `${params.user}/${params.repository}` + ); + } + }} + className={clsx( + "px-3 py-2 border-b border-neutral", + "aria-[selected=true]:rounded-t-md aria-[selected=true]:border", + "aria-[selected=true]:border-b-0", + "aria-[selected=true]:bg-backdrop" + )} + > + Preview + + + + flushSync(() => setTextContent(newValue)) + } + /> + + +
+ +