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({
title: "Sign in",
title: "Sign in"
})

View file

@ -25,5 +25,5 @@ export default async function SignUpPage() {
}
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/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<string>(children || "")
function MarkdownPreview({
height = 500,
fileId,
title,
children: rawContent
}: Props) {
const [preview, setPreview] = useState<string>(rawContent || "")
const [isLoading, setIsLoading] = useState<boolean>(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 (
<div
className={clsx(styles.markdownPreview)}
style={{
height
}}
>
{children}
</div>
)
}

View file

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

View file

@ -24,7 +24,7 @@ export default function DocumentTabs({
onPaste,
title,
staticPreview: preview,
children,
children: rawContent,
...props
}: Props) {
const codeEditorRef = useRef<TextareaMarkdownRef>(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({
<RadixTabs.Content value="preview">
{isEditing ? (
<Preview height={"100%"} title={title}>
{children}
{rawContent}
</Preview>
) : (
<StaticPreview height={"100%"}>{preview || ""}</StaticPreview>

View file

@ -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() {

View file

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

View file

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

View file

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

View file

@ -21,4 +21,3 @@ export const metadata = getMetadata({
title: "Admin",
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 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
<html lang="en" className={inter.variable} suppressHydrationWarning>
<head />
<body>
<Toasts />
<Layout>

View file

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

View file

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

View file

@ -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 (
<Button
key={tab.value}
iconLeft={tab.icon}
onClick={tab.onClick}
className={activeStyle}
aria-label={tab.name}
aria-current={isActive ? "page" : undefined}
data-tab={tab.value}
>
{tab.name ? tab.name : undefined}
</Button>
)
} else if (tab.href) {
return (
<Link key={tab.value} href={tab.href} data-tab={tab.value}>
<Button className={activeStyle} iconLeft={tab.icon}>
useEffect(() => setMounted(true), [])
// const buttons = pages.map(NavButton)
const buttons = useMemo(() => {
const NavButton = (tab: Tab) => {
const isActive = `${pathname}` === tab.href
const activeStyle = isActive ? styles.active : undefined
if (tab.onClick) {
return (
<Button
key={tab.value}
iconLeft={tab.icon}
onClick={tab.onClick}
className={activeStyle}
aria-label={tab.name}
aria-current={isActive ? "page" : undefined}
data-tab={tab.value}
width={tab.width}
>
{tab.name ? tab.name : undefined}
</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 {
defaultPages.push({
name: "Sign in",
icon: <User />,
value: "signin",
href: "/signin"
})
return (
<Link key={tab.value} href={tab.href} data-tab={tab.value}>
<Button
className={activeStyle}
iconLeft={tab.icon}
width={tab.width}
>
{tab.name ? tab.name : undefined}
</Button>
</Link>
)
}
}
return [
{
name: "Home",
icon: <Home />,
value: "home",
href: "/home"
},
{
name: "New",
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" }}
const NavButtonPlaceholder = ({ width }: { width: number }) => {
return (
<Button
key="placeholder"
iconLeft={<></>}
aria-current={undefined}
aria-hidden
style={{ color: "transparent" }}
width={width}
/>
</Button>
)
}
)
}
// 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 <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 (
<header className={styles.header}>
<div className={styles.tabs}>
<div className={styles.buttons}>{buttons}</div>
</div>
<DropdownMenu.Root>
<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>
<MobileHeader buttons={buttons} />
</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
}
// 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 = () => (
<>
<link />

View file

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

View file

@ -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"]
}