lint, internally refactor header

This commit is contained in:
Max Leiter 2023-02-25 16:29:03 -08:00
parent aaf2761004
commit cc2215629d
22 changed files with 368 additions and 289 deletions

View file

@ -13,5 +13,5 @@ export default function SignInPage() {
} }
export const metadata = getMetadata({ export const metadata = getMetadata({
title: "Sign in", title: "Sign in"
}) })

View file

@ -25,5 +25,5 @@ export default async function SignUpPage() {
} }
export const metadata = getMetadata({ export const metadata = getMetadata({
title: "Sign up", title: "Sign up"
}) })

View file

@ -2,8 +2,10 @@ import { memo, useEffect, useState } from "react"
import styles from "./preview.module.css" import styles from "./preview.module.css"
import "@styles/markdown.css" import "@styles/markdown.css"
import "@styles/syntax.css" import "@styles/syntax.css"
import { Spinner } from "@components/spinner"
import { fetchWithUser } from "src/app/lib/fetch-with-user" import { fetchWithUser } from "src/app/lib/fetch-with-user"
import { Spinner } from "@components/spinner"
import React from "react"
import clsx from "clsx"
type Props = { type Props = {
height?: number | string height?: number | string
@ -12,11 +14,16 @@ type Props = {
children?: string children?: string
} }
function MarkdownPreview({ height = 500, fileId, title, children }: Props) { function MarkdownPreview({
const [preview, setPreview] = useState<string>(children || "") height = 500,
fileId,
title,
children: rawContent
}: Props) {
const [preview, setPreview] = useState<string>(rawContent || "")
const [isLoading, setIsLoading] = useState<boolean>(true) const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => { useEffect(() => {
async function fetchPost() { async function fetchHTML() {
// POST to avoid query string length limit // POST to avoid query string length limit
const method = fileId ? "GET" : "POST" const method = fileId ? "GET" : "POST"
const path = fileId ? `/api/file/html/${fileId}` : "/api/file/get-html" const path = fileId ? `/api/file/html/${fileId}` : "/api/file/get-html"
@ -24,7 +31,7 @@ function MarkdownPreview({ height = 500, fileId, title, children }: Props) {
? undefined ? undefined
: JSON.stringify({ : JSON.stringify({
title: title || "", title: title || "",
content: children content: rawContent
}) })
const resp = await fetchWithUser(path, { const resp = await fetchWithUser(path, {
@ -42,8 +49,8 @@ function MarkdownPreview({ height = 500, fileId, title, children }: Props) {
setIsLoading(false) setIsLoading(false)
} }
fetchPost() fetchHTML()
}, [children, fileId, title]) }, [rawContent, fileId, title])
return ( return (
<> <>
@ -75,3 +82,22 @@ export function StaticPreview({
/> />
) )
} }
export function StaticPreviewSkeleton({
children,
height = 500
}: {
children: string
height: string | number
}) {
return (
<div
className={clsx(styles.markdownPreview)}
style={{
height
}}
>
{children}
</div>
)
}

View file

@ -4,6 +4,12 @@
line-height: 1.75; line-height: 1.75;
} }
.skeletonPreview {
padding: var(--gap-half);
font-size: 18px;
line-height: 1.75;
}
.markdownPreview p { .markdownPreview p {
margin-top: var(--gap); margin-top: var(--gap);
margin-bottom: var(--gap); margin-bottom: var(--gap);

View file

@ -24,7 +24,7 @@ export default function DocumentTabs({
onPaste, onPaste,
title, title,
staticPreview: preview, staticPreview: preview,
children, children: rawContent,
...props ...props
}: Props) { }: Props) {
const codeEditorRef = useRef<TextareaMarkdownRef>(null) const codeEditorRef = useRef<TextareaMarkdownRef>(null)
@ -72,7 +72,7 @@ export default function DocumentTabs({
onPaste={onPaste ? onPaste : undefined} onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef} ref={codeEditorRef}
placeholder="" placeholder=""
value={children} value={rawContent}
onChange={handleOnContentChange} onChange={handleOnContentChange}
// TODO: Textarea should grow to fill parent if height == 100% // TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }} style={{ flex: 1, minHeight: 350 }}
@ -84,7 +84,7 @@ export default function DocumentTabs({
<RadixTabs.Content value="preview"> <RadixTabs.Content value="preview">
{isEditing ? ( {isEditing ? (
<Preview height={"100%"} title={title}> <Preview height={"100%"} title={title}>
{children} {rawContent}
</Preview> </Preview>
) : ( ) : (
<StaticPreview height={"100%"}>{preview || ""}</StaticPreview> <StaticPreview height={"100%"}>{preview || ""}</StaticPreview>

View file

@ -40,7 +40,7 @@ function Post({
}: { }: {
initialPost?: PostWithFiles initialPost?: PostWithFiles
newPostParent?: string newPostParent?: string
}): JSX.Element | null { }): JSX.Element {
const { isAuthenticated } = useSessionSWR() const { isAuthenticated } = useSessionSWR()
const { setToast } = useToasts() const { setToast } = useToasts()
@ -89,10 +89,10 @@ function Post({
}) })
if (res.ok) { if (res.ok) {
const json = await res.json() const json = (await res.json()) as { id: string }
router.push(`/post/${json.id}`) router.push(`/post/${json.id}`)
} else { } else {
const json = await res.json() const json = (await res.json()) as { error: string }
console.error(json) console.error(json)
setToast({ setToast({
id: "error", id: "error",
@ -179,7 +179,7 @@ function Post({
if (isAuthenticated === false) { if (isAuthenticated === false) {
router.push("/signin") router.push("/signin")
return null return <></>
} }
function onClosePasswordModal() { function onClosePasswordModal() {

View file

@ -11,4 +11,4 @@ export const dynamic = "force-static"
export const metadata = getMetadata({ export const metadata = getMetadata({
title: "New post", title: "New post",
hidden: true hidden: true
}) })

View file

@ -3,9 +3,9 @@
import { createContext, useContext } from "react" import { createContext, useContext } from "react"
import { getPost } from "./get-post" import { getPost } from "./get-post"
const PostContext = createContext< const PostContext = createContext<Awaited<ReturnType<typeof getPost>> | null>(
Awaited<ReturnType<typeof getPost>> | null null
>(null) )
export const PostProvider = PostContext.Provider export const PostProvider = PostContext.Provider

View file

@ -54,9 +54,9 @@ export const getPost = cache(async (id: string) => {
if (post.visibility === "protected") { if (post.visibility === "protected") {
return { return {
visibility: "protected", visibility: "protected",
authorId: post.authorId, authorId: post.authorId,
id: post.id id: post.id
} }
} }

View file

@ -48,7 +48,7 @@ export const generateMetadata = async ({
title: post.title, title: post.title,
description: post.description || undefined, description: post.description || undefined,
type: "website", type: "website",
siteName: "Drift", siteName: "Drift"
// TODO: og images // TODO: og images
} }
} }

View file

@ -21,4 +21,3 @@ export const metadata = getMetadata({
title: "Admin", title: "Admin",
hidden: true hidden: true
}) })

View file

@ -1,3 +1,3 @@
import HomePage from "../page"; import HomePage from "../page"
export default HomePage; export default HomePage

View file

@ -4,7 +4,6 @@ import Layout from "@components/layout"
import { Toasts } from "@components/toasts" import { Toasts } from "@components/toasts"
import Header from "@components/header" import Header from "@components/header"
import { Inter } from "next/font/google" import { Inter } from "next/font/google"
import type { Metadata } from 'next'
import { getMetadata } from "src/app/lib/metadata" import { getMetadata } from "src/app/lib/metadata"
const inter = Inter({ subsets: ["latin"], variable: "--inter-font" }) const inter = Inter({ subsets: ["latin"], variable: "--inter-font" })
@ -17,7 +16,6 @@ export default async function RootLayout({
return ( return (
// suppressHydrationWarning is required because of next-themes // suppressHydrationWarning is required because of next-themes
<html lang="en" className={inter.variable} suppressHydrationWarning> <html lang="en" className={inter.variable} suppressHydrationWarning>
<head />
<body> <body>
<Toasts /> <Toasts />
<Layout> <Layout>

View file

@ -100,4 +100,4 @@ async function PublicPostList() {
const clientPosts = posts.map((post) => serverPostToClientPost(post)) const clientPosts = posts.map((post) => serverPostToClientPost(post))
return <PostList initialPosts={clientPosts} hideActions hideSearch /> return <PostList initialPosts={clientPosts} hideActions hideSearch />
} }

View file

@ -56,7 +56,7 @@ const APIKeys = ({
setSubmitting(false) setSubmitting(false)
} }
} }
const onRevoke = (tokenId: string) => { const onRevoke = (tokenId: string) => {
expireToken(tokenId) expireToken(tokenId)
setToast({ setToast({
@ -112,10 +112,7 @@ const APIKeys = ({
<td>{token.name}</td> <td>{token.name}</td>
<td>{new Date(token.expiresAt).toDateString()}</td> <td>{new Date(token.expiresAt).toDateString()}</td>
<td> <td>
<Button <Button type="button" onClick={() => onRevoke(token.id)}>
type="button"
onClick={() => onRevoke(token.id)}
>
Revoke Revoke
</Button> </Button>
</td> </td>

View file

@ -51,11 +51,6 @@
color: var(--fg); color: var(--fg);
} }
.buttonGroup,
.mobile {
display: none;
}
.contentWrapper { .contentWrapper {
background: var(--bg); background: var(--bg);
margin-left: var(--gap); margin-left: var(--gap);
@ -70,39 +65,6 @@
display: none; 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 { .tabs {
display: none; display: none;
} }

View file

@ -6,11 +6,10 @@ import Link from "@components/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { signOut } from "next-auth/react" import { signOut } from "next-auth/react"
import Button from "@components/button" import Button from "@components/button"
import clsx from "clsx"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { import {
Home, Home,
Menu, Loader,
Moon, Moon,
PlusCircle, PlusCircle,
Settings, Settings,
@ -18,183 +17,198 @@ import {
User, User,
UserX UserX
} from "react-feather" } from "react-feather"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu" import { ReactNode, useEffect, useMemo, useState } from "react"
import buttonStyles from "@components/button/button.module.css"
import { useMemo } from "react"
import { useSessionSWR } from "@lib/use-session-swr" 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 = { type Tab = {
name: string name: string
icon: JSX.Element icon: ReactNode
value: string value: string
onClick?: () => void // onClick?: () => void
href?: string // href?: string
} width?: number
} & (
| {
onClick: () => void
href?: undefined
}
| {
onClick?: undefined
href: string
}
)
const Header = () => { const Header = () => {
const { isAdmin, isAuthenticated, isLoading, mutate } = useSessionSWR() const {
isAdmin,
isAuthenticated,
isLoading: isAuthLoading,
mutate: mutateSession
} = useSessionSWR()
const pathname = usePathname() const pathname = usePathname()
const { setTheme, resolvedTheme } = useTheme() const { setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const getButton = (tab: Tab) => { useEffect(() => setMounted(true), [])
const isActive = `${pathname}` === tab.href
const activeStyle = isActive ? styles.active : undefined // const buttons = pages.map(NavButton)
if (tab.onClick) {
return ( const buttons = useMemo(() => {
<Button const NavButton = (tab: Tab) => {
key={tab.value} const isActive = `${pathname}` === tab.href
iconLeft={tab.icon} const activeStyle = isActive ? styles.active : undefined
onClick={tab.onClick} if (tab.onClick) {
className={activeStyle} return (
aria-label={tab.name} <Button
aria-current={isActive ? "page" : undefined} key={tab.value}
data-tab={tab.value} iconLeft={tab.icon}
> onClick={tab.onClick}
{tab.name ? tab.name : undefined} className={activeStyle}
</Button> aria-label={tab.name}
) aria-current={isActive ? "page" : undefined}
} else if (tab.href) { data-tab={tab.value}
return ( width={tab.width}
<Link key={tab.value} href={tab.href} data-tab={tab.value}> >
<Button className={activeStyle} iconLeft={tab.icon}>
{tab.name ? tab.name : undefined} {tab.name ? tab.name : undefined}
</Button> </Button>
</Link> )
)
}
}
const pages = useMemo(() => {
const defaultPages: Tab[] = [
// {
// name: "GitHub",
// href: "https://github.com/maxleiter/drift",
// icon: <GitHub />,
// value: "github"
// }
]
if (isAdmin) {
defaultPages.push({
name: "Admin",
icon: <Settings />,
value: "admin",
href: "/admin"
})
}
defaultPages.push({
name: "Theme",
onClick: function () {
setTheme(resolvedTheme === "light" ? "dark" : "light")
},
icon: resolvedTheme === "light" ? <Moon /> : <Sun />,
value: "theme"
})
// the is loading case is handled in the JSX
if (!isLoading) {
if (isAuthenticated) {
defaultPages.push({
name: "Sign Out",
icon: <UserX />,
value: "signout",
onClick: () => {
mutate(undefined)
signOut({
callbackUrl: "/"
})
}
})
} else { } else {
defaultPages.push({ return (
name: "Sign in", <Link key={tab.value} href={tab.href} data-tab={tab.value}>
icon: <User />, <Button
value: "signin", className={activeStyle}
href: "/signin" iconLeft={tab.icon}
}) width={tab.width}
>
{tab.name ? tab.name : undefined}
</Button>
</Link>
)
} }
} }
return [ const NavButtonPlaceholder = ({ width }: { width: number }) => {
{ return (
name: "Home", <Button
icon: <Home />, key="placeholder"
value: "home", iconLeft={<></>}
href: "/home" aria-current={undefined}
}, aria-hidden
{ style={{ color: "transparent" }}
name: "New", width={width}
icon: <PlusCircle />,
value: "new",
href: "/new"
},
{
name: "Yours",
icon: <User />,
value: "yours",
href: "/mine"
},
{
name: "Settings",
icon: <Settings />,
value: "settings",
href: "/settings"
},
...defaultPages
]
}, [isAdmin, resolvedTheme, isLoading, setTheme, isAuthenticated, mutate])
const buttons = pages.map(getButton)
if (isLoading) {
buttons.push(
<Button iconLeft={<User />} key="loading">
Sign{" "}
<Skeleton
width={20}
height={15}
style={{ display: "inline-block", verticalAlign: "middle" }}
/> />
</Button> )
) }
}
// TODO: this is a hack to close the radix ui menu when a next link is clicked const getThemeIcon = () => {
const onClick = () => { if (!mounted) {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) return <Loader />
} }
return <FadeIn>{resolvedTheme === "light" ? <Moon /> : <Sun />}</FadeIn>
}
return [
<NavButton
key="home"
name="Home"
icon={<Home />}
value="home"
href="/home"
/>,
<NavButton
key="new"
name="New"
icon={<PlusCircle />}
value="new"
href="/new"
/>,
<NavButton
key="yours"
name="Yours"
icon={<User />}
value="yours"
href="/mine"
/>,
<NavButton
name="Settings"
icon={<Settings />}
value="settings"
href="/settings"
key="settings"
/>,
<NavButton
name="Theme"
icon={getThemeIcon()}
value="dark"
onClick={() => {
setTheme(resolvedTheme === "light" ? "dark" : "light")
}}
key="theme"
/>,
isAuthLoading ? (
<NavButtonPlaceholder width={SIGN_IN_WIDTH} key="signin" />
) : undefined,
!isAuthLoading ? (
isAuthenticated ? (
<FadeIn key="signout-fade">
<NavButton
name="Sign Out"
icon={<UserX />}
value="signout"
onClick={() => {
signOut()
mutateSession(undefined)
}}
width={SIGN_IN_WIDTH}
/>
</FadeIn>
) : (
<FadeIn key="signin-fade">
<NavButton
name="Sign In"
icon={<User />}
value="signin"
href="/signin"
width={SIGN_IN_WIDTH}
/>
</FadeIn>
)
) : undefined,
isAdmin ? (
<FadeIn>
<NavButton
name="Admin"
icon={<Settings />}
value="admin"
href="/admin"
/>
</FadeIn>
) : undefined
].filter(Boolean)
}, [
isAuthLoading,
isAuthenticated,
isAdmin,
pathname,
mounted,
resolvedTheme,
setTheme,
mutateSession
])
return ( return (
<header className={styles.header}> <header className={styles.header}>
<div className={styles.tabs}> <div className={styles.tabs}>
<div className={styles.buttons}>{buttons}</div> <div className={styles.buttons}>{buttons}</div>
</div> </div>
<DropdownMenu.Root> <MobileHeader buttons={buttons} />
<DropdownMenu.Trigger
className={clsx(buttonStyles.button, styles.mobile)}
asChild
>
<Button aria-label="Menu" height="auto">
<Menu />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className={styles.contentWrapper}>
{buttons.map((button) => (
<DropdownMenu.Item
key={button?.key}
className={styles.dropdownItem}
onClick={onClick}
>
{button}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</header> </header>
) )
} }

View file

@ -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;
}
}

View file

@ -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 (
<DropdownMenu.Root>
<DropdownMenu.Trigger
className={clsx(buttonStyles.button, styles.mobileTrigger)}
asChild
>
<Button aria-label="Menu" height="auto">
<Menu />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content>
{buttons.map((button) => (
<DropdownMenu.Item
key={`mobile-${button?.key}`}
className={styles.dropdownItem}
onClick={onClick}
>
{button}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}

View file

@ -9,6 +9,8 @@ type PageSeoProps = {
isPrivate?: boolean isPrivate?: boolean
} }
// TODO: remove once fully migrated to new metadata API
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const PageSeo = ({ const PageSeo = ({
title: pageTitle, title: pageTitle,
description = "A self-hostable clone of GitHub Gist", description = "A self-hostable clone of GitHub Gist",
@ -27,8 +29,6 @@ const PageSeo = ({
) )
} }
export default PageSeo
const ThemeAndIcons = () => ( const ThemeAndIcons = () => (
<> <>
<link /> <link />

View file

@ -2,7 +2,9 @@ import { ServerPost } from "./server/prisma"
// Visibilties for the webpages feature // Visibilties for the webpages feature
export const ALLOWED_VISIBILITIES_FOR_WEBPAGE = ["public", "unlisted"] 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) return ALLOWED_VISIBILITIES_FOR_WEBPAGE.includes(visibility)
} }

View file

@ -1,61 +1,44 @@
{ {
"compilerOptions": { "compilerOptions": {
"plugins": [ "plugins": [
{ {
"name": "typescript-plugin-css-modules" "name": "typescript-plugin-css-modules"
}, },
{ {
"name": "next" "name": "next"
} }
], ],
"target": "es2020", "target": "es2020",
"lib": [ "lib": ["dom", "dom.iterable", "esnext"],
"dom", "allowJs": true,
"dom.iterable", "skipLibCheck": true,
"esnext" "strict": true,
], "forceConsistentCasingInFileNames": true,
"allowJs": true, "noImplicitAny": true,
"skipLibCheck": true, "strictNullChecks": true,
"strict": true, "strictFunctionTypes": true,
"forceConsistentCasingInFileNames": true, "strictBindCallApply": true,
"noImplicitAny": true, "strictPropertyInitialization": true,
"strictNullChecks": true, "noImplicitThis": true,
"strictFunctionTypes": true, "alwaysStrict": true,
"strictBindCallApply": true, "noUnusedLocals": false,
"strictPropertyInitialization": true, "noUnusedParameters": true,
"noImplicitThis": true, "noEmit": true,
"alwaysStrict": true, "esModuleInterop": true,
"noUnusedLocals": false, "module": "esnext",
"noUnusedParameters": true, "moduleResolution": "node",
"noEmit": true, "allowSyntheticDefaultImports": true,
"esModuleInterop": true, "resolveJsonModule": true,
"module": "esnext", "isolatedModules": true,
"moduleResolution": "node", "jsx": "preserve",
"allowSyntheticDefaultImports": true, "incremental": true,
"resolveJsonModule": true, "baseUrl": ".",
"isolatedModules": true, "paths": {
"jsx": "preserve", "@components/*": ["src/app/components/*"],
"incremental": true, "@lib/*": ["src/lib/*"],
"baseUrl": ".", "@styles/*": ["src/app/styles/*"]
"paths": { }
"@components/*": [ },
"src/app/components/*" "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
], "exclude": ["node_modules"]
"@lib/*": [
"src/lib/*"
],
"@styles/*": [
"src/app/styles/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }