diff --git a/src/app/(drift)/(auth)/signin/page.tsx b/src/app/(drift)/(auth)/signin/page.tsx index 669d9ae6..c656c9d8 100644 --- a/src/app/(drift)/(auth)/signin/page.tsx +++ b/src/app/(drift)/(auth)/signin/page.tsx @@ -13,5 +13,5 @@ export default function SignInPage() { } export const metadata = getMetadata({ - title: "Sign in", + title: "Sign in" }) diff --git a/src/app/(drift)/(auth)/signup/page.tsx b/src/app/(drift)/(auth)/signup/page.tsx index dda0be28..6cdd7bf4 100644 --- a/src/app/(drift)/(auth)/signup/page.tsx +++ b/src/app/(drift)/(auth)/signup/page.tsx @@ -25,5 +25,5 @@ export default async function SignUpPage() { } export const metadata = getMetadata({ - title: "Sign up", + title: "Sign up" }) diff --git a/src/app/(drift)/(posts)/components/preview/index.tsx b/src/app/(drift)/(posts)/components/preview/index.tsx index e74657e7..18c4d109 100644 --- a/src/app/(drift)/(posts)/components/preview/index.tsx +++ b/src/app/(drift)/(posts)/components/preview/index.tsx @@ -2,8 +2,10 @@ import { memo, useEffect, useState } from "react" import styles from "./preview.module.css" import "@styles/markdown.css" import "@styles/syntax.css" -import { Spinner } from "@components/spinner" import { fetchWithUser } from "src/app/lib/fetch-with-user" +import { Spinner } from "@components/spinner" +import React from "react" +import clsx from "clsx" type Props = { height?: number | string @@ -12,11 +14,16 @@ type Props = { children?: string } -function MarkdownPreview({ height = 500, fileId, title, children }: Props) { - const [preview, setPreview] = useState(children || "") +function MarkdownPreview({ + height = 500, + fileId, + title, + children: rawContent +}: Props) { + const [preview, setPreview] = useState(rawContent || "") const [isLoading, setIsLoading] = useState(true) useEffect(() => { - async function fetchPost() { + async function fetchHTML() { // POST to avoid query string length limit const method = fileId ? "GET" : "POST" const path = fileId ? `/api/file/html/${fileId}` : "/api/file/get-html" @@ -24,7 +31,7 @@ function MarkdownPreview({ height = 500, fileId, title, children }: Props) { ? undefined : JSON.stringify({ title: title || "", - content: children + content: rawContent }) const resp = await fetchWithUser(path, { @@ -42,8 +49,8 @@ function MarkdownPreview({ height = 500, fileId, title, children }: Props) { setIsLoading(false) } - fetchPost() - }, [children, fileId, title]) + fetchHTML() + }, [rawContent, fileId, title]) return ( <> @@ -75,3 +82,22 @@ export function StaticPreview({ /> ) } + +export function StaticPreviewSkeleton({ + children, + height = 500 +}: { + children: string + height: string | number +}) { + return ( +
+ {children} +
+ ) +} diff --git a/src/app/(drift)/(posts)/components/preview/preview.module.css b/src/app/(drift)/(posts)/components/preview/preview.module.css index 7db3dc3b..75cbbf4a 100644 --- a/src/app/(drift)/(posts)/components/preview/preview.module.css +++ b/src/app/(drift)/(posts)/components/preview/preview.module.css @@ -4,6 +4,12 @@ line-height: 1.75; } +.skeletonPreview { + padding: var(--gap-half); + font-size: 18px; + line-height: 1.75; +} + .markdownPreview p { margin-top: var(--gap); margin-bottom: var(--gap); diff --git a/src/app/(drift)/(posts)/components/tabs/index.tsx b/src/app/(drift)/(posts)/components/tabs/index.tsx index 3b5bb3e1..7940f096 100644 --- a/src/app/(drift)/(posts)/components/tabs/index.tsx +++ b/src/app/(drift)/(posts)/components/tabs/index.tsx @@ -24,7 +24,7 @@ export default function DocumentTabs({ onPaste, title, staticPreview: preview, - children, + children: rawContent, ...props }: Props) { const codeEditorRef = useRef(null) @@ -72,7 +72,7 @@ export default function DocumentTabs({ onPaste={onPaste ? onPaste : undefined} ref={codeEditorRef} placeholder="" - value={children} + value={rawContent} onChange={handleOnContentChange} // TODO: Textarea should grow to fill parent if height == 100% style={{ flex: 1, minHeight: 350 }} @@ -84,7 +84,7 @@ export default function DocumentTabs({ {isEditing ? ( - {children} + {rawContent} ) : ( {preview || ""} diff --git a/src/app/(drift)/(posts)/new/components/new.tsx b/src/app/(drift)/(posts)/new/components/new.tsx index 77a6139a..5c5f74a5 100644 --- a/src/app/(drift)/(posts)/new/components/new.tsx +++ b/src/app/(drift)/(posts)/new/components/new.tsx @@ -40,7 +40,7 @@ function Post({ }: { initialPost?: PostWithFiles newPostParent?: string -}): JSX.Element | null { +}): JSX.Element { const { isAuthenticated } = useSessionSWR() const { setToast } = useToasts() @@ -89,10 +89,10 @@ function Post({ }) if (res.ok) { - const json = await res.json() + const json = (await res.json()) as { id: string } router.push(`/post/${json.id}`) } else { - const json = await res.json() + const json = (await res.json()) as { error: string } console.error(json) setToast({ id: "error", @@ -179,7 +179,7 @@ function Post({ if (isAuthenticated === false) { router.push("/signin") - return null + return <> } function onClosePasswordModal() { diff --git a/src/app/(drift)/(posts)/new/page.tsx b/src/app/(drift)/(posts)/new/page.tsx index 4aa5a7f2..2e121476 100644 --- a/src/app/(drift)/(posts)/new/page.tsx +++ b/src/app/(drift)/(posts)/new/page.tsx @@ -11,4 +11,4 @@ export const dynamic = "force-static" export const metadata = getMetadata({ title: "New post", hidden: true -}) \ No newline at end of file +}) diff --git a/src/app/(drift)/(posts)/post/[id]/context.tsx b/src/app/(drift)/(posts)/post/[id]/context.tsx index 2a17a1f8..98f3ab4e 100644 --- a/src/app/(drift)/(posts)/post/[id]/context.tsx +++ b/src/app/(drift)/(posts)/post/[id]/context.tsx @@ -3,9 +3,9 @@ import { createContext, useContext } from "react" import { getPost } from "./get-post" -const PostContext = createContext< - Awaited> | null ->(null) +const PostContext = createContext> | null>( + null +) export const PostProvider = PostContext.Provider diff --git a/src/app/(drift)/(posts)/post/[id]/get-post.tsx b/src/app/(drift)/(posts)/post/[id]/get-post.tsx index b6435fed..ee243da3 100644 --- a/src/app/(drift)/(posts)/post/[id]/get-post.tsx +++ b/src/app/(drift)/(posts)/post/[id]/get-post.tsx @@ -54,9 +54,9 @@ export const getPost = cache(async (id: string) => { if (post.visibility === "protected") { return { - visibility: "protected", - authorId: post.authorId, - id: post.id + visibility: "protected", + authorId: post.authorId, + id: post.id } } diff --git a/src/app/(drift)/(posts)/post/[id]/page.tsx b/src/app/(drift)/(posts)/post/[id]/page.tsx index d7e87fa5..17d7e667 100644 --- a/src/app/(drift)/(posts)/post/[id]/page.tsx +++ b/src/app/(drift)/(posts)/post/[id]/page.tsx @@ -48,7 +48,7 @@ export const generateMetadata = async ({ title: post.title, description: post.description || undefined, type: "website", - siteName: "Drift", + siteName: "Drift" // TODO: og images } } diff --git a/src/app/(drift)/admin/layout.tsx b/src/app/(drift)/admin/layout.tsx index 317998a0..8df46c00 100644 --- a/src/app/(drift)/admin/layout.tsx +++ b/src/app/(drift)/admin/layout.tsx @@ -21,4 +21,3 @@ export const metadata = getMetadata({ title: "Admin", hidden: true }) - diff --git a/src/app/(drift)/home/page.tsx b/src/app/(drift)/home/page.tsx index 5b871a8f..77b26e48 100644 --- a/src/app/(drift)/home/page.tsx +++ b/src/app/(drift)/home/page.tsx @@ -1,3 +1,3 @@ -import HomePage from "../page"; +import HomePage from "../page" -export default HomePage; +export default HomePage diff --git a/src/app/(drift)/layout.tsx b/src/app/(drift)/layout.tsx index 8cd06298..e3c6ce56 100644 --- a/src/app/(drift)/layout.tsx +++ b/src/app/(drift)/layout.tsx @@ -4,7 +4,6 @@ import Layout from "@components/layout" import { Toasts } from "@components/toasts" import Header from "@components/header" import { Inter } from "next/font/google" -import type { Metadata } from 'next' import { getMetadata } from "src/app/lib/metadata" const inter = Inter({ subsets: ["latin"], variable: "--inter-font" }) @@ -17,7 +16,6 @@ export default async function RootLayout({ return ( // suppressHydrationWarning is required because of next-themes - diff --git a/src/app/(drift)/page.tsx b/src/app/(drift)/page.tsx index 8c104707..02d83200 100644 --- a/src/app/(drift)/page.tsx +++ b/src/app/(drift)/page.tsx @@ -100,4 +100,4 @@ async function PublicPostList() { const clientPosts = posts.map((post) => serverPostToClientPost(post)) return -} \ No newline at end of file +} diff --git a/src/app/(drift)/settings/components/sections/api-keys.tsx b/src/app/(drift)/settings/components/sections/api-keys.tsx index b2def338..d49da72e 100644 --- a/src/app/(drift)/settings/components/sections/api-keys.tsx +++ b/src/app/(drift)/settings/components/sections/api-keys.tsx @@ -56,7 +56,7 @@ const APIKeys = ({ setSubmitting(false) } } - + const onRevoke = (tokenId: string) => { expireToken(tokenId) setToast({ @@ -112,10 +112,7 @@ const APIKeys = ({ {token.name} {new Date(token.expiresAt).toDateString()} - diff --git a/src/app/components/header/header.module.css b/src/app/components/header/header.module.css index 51238c75..2edc1b75 100644 --- a/src/app/components/header/header.module.css +++ b/src/app/components/header/header.module.css @@ -51,11 +51,6 @@ color: var(--fg); } -.buttonGroup, -.mobile { - display: none; -} - .contentWrapper { background: var(--bg); margin-left: var(--gap); @@ -70,39 +65,6 @@ display: none; } - .mobile { - margin-top: var(--gap); - margin-bottom: var(--gap); - display: flex; - } - - .buttonGroup { - display: flex; - flex-direction: column; - } - - .dropdownItem:not(:first-child):not(:last-child) :global(button) { - border-top-left-radius: 0; - border-top-right-radius: 0; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - - .dropdownItem:first-child :global(button) { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - - .dropdownItem:last-child :global(button) { - border-top-left-radius: 0; - border-top-right-radius: 0; - } - - .dropdownItem a, - .dropdownItem button { - width: 100%; - } - .tabs { display: none; } diff --git a/src/app/components/header/index.tsx b/src/app/components/header/index.tsx index 7a29d153..c37952ea 100644 --- a/src/app/components/header/index.tsx +++ b/src/app/components/header/index.tsx @@ -6,11 +6,10 @@ import Link from "@components/link" import { usePathname } from "next/navigation" import { signOut } from "next-auth/react" import Button from "@components/button" -import clsx from "clsx" import { useTheme } from "next-themes" import { Home, - Menu, + Loader, Moon, PlusCircle, Settings, @@ -18,183 +17,198 @@ import { User, UserX } from "react-feather" -import * as DropdownMenu from "@radix-ui/react-dropdown-menu" -import buttonStyles from "@components/button/button.module.css" -import { useMemo } from "react" +import { ReactNode, useEffect, useMemo, useState } from "react" import { useSessionSWR } from "@lib/use-session-swr" -import Skeleton from "@components/skeleton" +import FadeIn from "@components/fade-in" +import MobileHeader from "./mobile" + +// constant width for sign in / sign out buttons to avoid CLS +const SIGN_IN_WIDTH = 110 type Tab = { name: string - icon: JSX.Element + icon: ReactNode value: string - onClick?: () => void - href?: string -} + // onClick?: () => void + // href?: string + width?: number +} & ( + | { + onClick: () => void + href?: undefined + } + | { + onClick?: undefined + href: string + } +) const Header = () => { - const { isAdmin, isAuthenticated, isLoading, mutate } = useSessionSWR() + const { + isAdmin, + isAuthenticated, + isLoading: isAuthLoading, + mutate: mutateSession + } = useSessionSWR() const pathname = usePathname() const { setTheme, resolvedTheme } = useTheme() + const [mounted, setMounted] = useState(false) - const getButton = (tab: Tab) => { - const isActive = `${pathname}` === tab.href - const activeStyle = isActive ? styles.active : undefined - if (tab.onClick) { - return ( - - ) - } else if (tab.href) { - return ( - - - - ) - } - } - - const pages = useMemo(() => { - const defaultPages: Tab[] = [ - // { - // name: "GitHub", - // href: "https://github.com/maxleiter/drift", - // icon: , - // value: "github" - // } - ] - - if (isAdmin) { - defaultPages.push({ - name: "Admin", - icon: , - value: "admin", - href: "/admin" - }) - } - - defaultPages.push({ - name: "Theme", - onClick: function () { - setTheme(resolvedTheme === "light" ? "dark" : "light") - }, - icon: resolvedTheme === "light" ? : , - value: "theme" - }) - - // the is loading case is handled in the JSX - if (!isLoading) { - if (isAuthenticated) { - defaultPages.push({ - name: "Sign Out", - icon: , - value: "signout", - onClick: () => { - mutate(undefined) - signOut({ - callbackUrl: "/" - }) - } - }) + ) } else { - defaultPages.push({ - name: "Sign in", - icon: , - value: "signin", - href: "/signin" - }) + return ( + + + + ) } } - return [ - { - name: "Home", - icon: , - value: "home", - href: "/home" - }, - { - name: "New", - icon: , - value: "new", - href: "/new" - }, - { - name: "Yours", - icon: , - value: "yours", - href: "/mine" - }, - { - name: "Settings", - icon: , - value: "settings", - href: "/settings" - }, - ...defaultPages - ] - }, [isAdmin, resolvedTheme, isLoading, setTheme, isAuthenticated, mutate]) - - const buttons = pages.map(getButton) - - if (isLoading) { - buttons.push( - - ) - } + ) + } - // TODO: this is a hack to close the radix ui menu when a next link is clicked - const onClick = () => { - document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) - } + const getThemeIcon = () => { + if (!mounted) { + return + } + + return {resolvedTheme === "light" ? : } + } + + return [ + } + value="home" + href="/home" + />, + } + value="new" + href="/new" + />, + } + value="yours" + href="/mine" + />, + } + value="settings" + href="/settings" + key="settings" + />, + { + setTheme(resolvedTheme === "light" ? "dark" : "light") + }} + key="theme" + />, + isAuthLoading ? ( + + ) : undefined, + !isAuthLoading ? ( + isAuthenticated ? ( + + } + value="signout" + onClick={() => { + signOut() + mutateSession(undefined) + }} + width={SIGN_IN_WIDTH} + /> + + ) : ( + + } + value="signin" + href="/signin" + width={SIGN_IN_WIDTH} + /> + + ) + ) : undefined, + isAdmin ? ( + + } + value="admin" + href="/admin" + /> + + ) : undefined + ].filter(Boolean) + }, [ + isAuthLoading, + isAuthenticated, + isAdmin, + pathname, + mounted, + resolvedTheme, + setTheme, + mutateSession + ]) return (
{buttons}
- - - - - - - {buttons.map((button) => ( - - {button} - - ))} - - - +
) } diff --git a/src/app/components/header/mobile.module.css b/src/app/components/header/mobile.module.css new file mode 100644 index 00000000..39dc7f57 --- /dev/null +++ b/src/app/components/header/mobile.module.css @@ -0,0 +1,53 @@ +.mobileTrigger { + display: none; +} + +@media only screen and (max-width: 768px) { + .header { + opacity: 1; + } + + .wrapper [data-tab="github"] { + display: none; + } + + .mobileTrigger { + margin-top: var(--gap); + margin-bottom: var(--gap); + display: flex; + align-items: center; + } + + .mobileTrigger button { + display: none; + } + + .dropdownItem a, + .dropdownItem button { + width: 100%; + text-align: left; + white-space: nowrap; + display: block; + } + + .dropdownItem:not(:first-child):not(:last-child) :global(button) { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + .dropdownItem:first-child :global(button) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + .dropdownItem:last-child :global(button) { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + .tabs { + display: none; + } +} diff --git a/src/app/components/header/mobile.tsx b/src/app/components/header/mobile.tsx new file mode 100644 index 00000000..0a2977f8 --- /dev/null +++ b/src/app/components/header/mobile.tsx @@ -0,0 +1,39 @@ +import * as DropdownMenu from "@radix-ui/react-dropdown-menu" +import buttonStyles from "@components/button/button.module.css" +import Button from "@components/button" +import { Menu } from "react-feather" +import clsx from "clsx" +import styles from "./mobile.module.css" + +export default function MobileHeader({ buttons }: { buttons: JSX.Element[] }) { + // TODO: this is a hack to close the radix ui menu when a next link is clicked + const onClick = () => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) + } + + return ( + + + + + + + {buttons.map((button) => ( + + {button} + + ))} + + + + ) +} diff --git a/src/app/lib/metadata.tsx b/src/app/lib/metadata.tsx index 06fd7812..123198f4 100644 --- a/src/app/lib/metadata.tsx +++ b/src/app/lib/metadata.tsx @@ -9,6 +9,8 @@ type PageSeoProps = { isPrivate?: boolean } +// TODO: remove once fully migrated to new metadata API +// eslint-disable-next-line @typescript-eslint/no-unused-vars const PageSeo = ({ title: pageTitle, description = "A self-hostable clone of GitHub Gist", @@ -27,8 +29,6 @@ const PageSeo = ({ ) } -export default PageSeo - const ThemeAndIcons = () => ( <> diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7ff90772..b1c52a39 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -2,7 +2,9 @@ import { ServerPost } from "./server/prisma" // Visibilties for the webpages feature export const ALLOWED_VISIBILITIES_FOR_WEBPAGE = ["public", "unlisted"] -export function isAllowedVisibilityForWebpage(visibility: ServerPost["visibility"]) { +export function isAllowedVisibilityForWebpage( + visibility: ServerPost["visibility"] +) { return ALLOWED_VISIBILITIES_FOR_WEBPAGE.includes(visibility) } diff --git a/tsconfig.json b/tsconfig.json index eb09d3d7..964083ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,61 +1,44 @@ { - "compilerOptions": { - "plugins": [ - { - "name": "typescript-plugin-css-modules" - }, - { - "name": "next" - } - ], - "target": "es2020", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "forceConsistentCasingInFileNames": true, - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "strictBindCallApply": true, - "strictPropertyInitialization": true, - "noImplicitThis": true, - "alwaysStrict": true, - "noUnusedLocals": false, - "noUnusedParameters": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "baseUrl": ".", - "paths": { - "@components/*": [ - "src/app/components/*" - ], - "@lib/*": [ - "src/lib/*" - ], - "@styles/*": [ - "src/app/styles/*" - ] - } - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] + "compilerOptions": { + "plugins": [ + { + "name": "typescript-plugin-css-modules" + }, + { + "name": "next" + } + ], + "target": "es2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": ".", + "paths": { + "@components/*": ["src/app/components/*"], + "@lib/*": ["src/lib/*"], + "@styles/*": ["src/app/styles/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] }