Add cmdk, nested postcss

This commit is contained in:
Max Leiter 2023-02-25 22:36:29 -08:00
parent cc2215629d
commit 85f21bf505
15 changed files with 1272 additions and 35 deletions

View file

@ -269,7 +269,8 @@ function Post({
])
}}
style={{
flex: 1
flex: 1,
minWidth: 120
}}
>
Add a File

View file

@ -37,7 +37,7 @@
.buttons .rightButtons {
flex-direction: column;
align-items: flex-end;
align-items: flex-start;
}
.buttons .rightButtons > * {

View file

@ -5,9 +5,13 @@ import { Toasts } from "@components/toasts"
import Header from "@components/header"
import { Inter } from "next/font/google"
import { getMetadata } from "src/app/lib/metadata"
import dynamic from "next/dynamic"
const inter = Inter({ subsets: ["latin"], variable: "--inter-font" })
// const CmdK = dynamic(() => import("@components/cmdk"), { ssr: false })
import CmdK from "@components/cmdk"
export default async function RootLayout({
children
}: {
@ -18,12 +22,13 @@ export default async function RootLayout({
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body>
<Toasts />
<Layout>
<Providers>
<Providers>
<Layout>
<CmdK />
<Header />
{children}
</Providers>
</Layout>
</Layout>
</Providers>
</body>
</html>
)

View file

@ -0,0 +1,165 @@
/** Based on https://github.com/pacocoursey/cmdk **/
.cmdk[cmdk-root] {
overflow: hidden;
font-family: var(--font-sans);
box-shadow: 0 0 0 1px var(--lighter-gray), 0 4px 16px rgba(0, 0, 0, 0.2);
transition: transform 100ms ease;
border-radius: var(--radius);
.dark & {
background: rgba(22, 22, 22, 0.7);
}
}
.cmdk {
/* centered */
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 999999;
/* size */
max-width: 640px;
width: 100%;
[cmdk-list] {
background: var(--bg);
height: 500px;
overflow: auto;
overscroll-behavior: contain;
}
[cmdk-input] {
font-family: var(--font-sans);
width: 100%;
font-size: 17px;
padding: 8px 8px 16px 8px;
outline: none;
/* background: var(--lightest-gray); */
color: var(--fg);
&::placeholder {
color: var(--gray);
}
}
[cmdk-badge] {
height: 20px;
background: var(--grayA3);
display: inline-flex;
align-items: center;
padding: 0 8px;
font-size: 12px;
color: var(--grayA11);
border-radius: 4px;
margin: 4px 0 4px 4px;
user-select: none;
text-transform: capitalize;
font-weight: 500;
}
[cmdk-item] {
content-visibility: auto;
cursor: pointer;
height: 48px;
border-radius: 8px;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
color: var(--darker-gray);
user-select: none;
will-change: background, color;
transition: all 150ms ease;
transition-property: none;
background: var(--bg);
&[aria-selected="true"] {
background: var(--lightest-gray);
color: var(--fg);
}
&[aria-disabled="true"] {
/* TODO: improve this */
color: var(--bg);
cursor: not-allowed;
}
&:active {
transition-property: background;
background: var(--bg);
}
& + [cmdk-item] {
margin-top: 4px;
}
svg {
width: 18px;
height: 18px;
}
}
[cmdk-list] {
max-height: 400px;
overflow: auto;
overscroll-behavior: contain;
transition: 100ms ease;
transition-property: height;
}
[cmdk-shortcuts] {
display: flex;
margin-left: auto;
gap: 8px;
kbd {
font-family: var(--font-sans);
font-size: 12px;
min-width: 20px;
padding: var(--gap-half);
height: 20px;
border-radius: 4px;
color: var(--fg);
background: var(--light-gray);
display: inline-flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
}
[cmdk-separator] {
height: 1px;
width: 100%;
background: var(--light-gray);
margin: 4px 0;
}
*:not([hidden]) + [cmdk-group] {
margin-top: var(--gap);
}
[cmdk-group-heading] {
user-select: none;
font-size: 12px;
color: var(--gray);
padding: 0 var(--gap);
display: flex;
align-items: center;
margin-bottom: var(--gap);
margin-top: var(--gap);
}
[cmdk-empty] {
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
height: 48px;
white-space: pre-wrap;
color: var(--gray);
}
}

View file

@ -0,0 +1,16 @@
body [cmdk-dialog] {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
/* backdrop-filter: blur(4px); */
transition: opacity 100ms ease;
pointer-events: none;
will-change: opacity;
}

View file

@ -0,0 +1,74 @@
"use client"
import FadeIn from "@components/fade-in"
import { Command } from "cmdk"
import { useEffect, useRef, useState } from "react"
import styles from "./cmdk.module.css"
import "./dialog.css"
import HomePage from "./pages/home"
import PostsPage from "./pages/posts"
export type CmdKPage = "home" | "posts"
export default function CmdK() {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement | null>(null)
const [page, setPage] = useState<CmdKPage>("home")
// Toggle the menu when ⌘K is pressed
useEffect(() => {
const openCloseListener = (e: KeyboardEvent) => {
if (e.key === "k" && e.metaKey) {
e.preventDefault()
setOpen((open) => !open)
} else if (e.key === "Escape") {
e.preventDefault()
if (page !== "home") {
setPage("home")
// TODO: this shouldn't be necessary?
setOpen(true)
} else {
setOpen(false)
}
}
}
document.addEventListener("keydown", openCloseListener)
return () => {
document.removeEventListener("keydown", openCloseListener)
}
}, [page])
function bounce() {
if (ref.current) {
ref.current.style.transform = "translate(-50%, -50%) scale(0.96)"
setTimeout(() => {
if (ref.current) {
ref.current.style.transform = ""
}
}, 100)
}
}
// bounce on page change
useEffect(() => {
bounce()
}, [page])
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Global Command Menu"
className={styles.cmdk}
ref={ref}
>
<Command.List>
<Command.Empty>No results found.</Command.Empty>
{page === "home" ? (
<HomePage setPage={setPage} setOpen={setOpen} />
) : null}
{page === "posts" ? <PostsPage setOpen={setOpen} /> : null}
</Command.List>
<Command.Input />
</Command.Dialog>
)
}

View file

@ -0,0 +1,27 @@
import { Command } from "cmdk"
export default function Item({
children,
shortcut,
onSelect,
icon
}: {
children: React.ReactNode
shortcut?: string
onSelect: (value: string) => void
icon: React.ReactNode
}): JSX.Element {
return (
<Command.Item onSelect={onSelect}>
{icon}
{children}
{shortcut ? (
<div cmdk-shortcuts="">
{shortcut.split(" ").map((key) => {
return <kbd key={key}>{key}</kbd>
})}
</div>
) : null}
</Command.Item>
)
}

View file

@ -0,0 +1,64 @@
import { Command } from "cmdk"
import { useTheme } from "next-themes"
import { useRouter } from "next/navigation"
import { FilePlus, Moon, Search, Settings, Sun } from "react-feather"
import { CmdKPage } from ".."
import Item from "../item"
export default function HomePage({
setOpen,
setPage
}: {
setOpen: (open: boolean) => void
setPage: (page: CmdKPage) => void
}) {
const router = useRouter()
const { setTheme, resolvedTheme } = useTheme()
return (
<>
<Command.Group heading="Posts">
<Item
shortcut="R P"
onSelect={() => {
setPage("posts")
}}
icon={<Search />}
>
Your Recent Posts
</Item>
<Item
shortcut="N P"
onSelect={() => {
router.push("/new")
setOpen(false)
}}
icon={<FilePlus />}
>
New Post
</Item>
</Command.Group>
<Command.Group heading="Settings">
<Item
shortcut="T"
onSelect={() => {
console.log("toggle theme", resolvedTheme)
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}}
icon={resolvedTheme === "dark" ? <Sun /> : <Moon />}
>
Toggle Theme
</Item>
<Item
shortcut="S"
onSelect={() => {
router.push("/settings")
setOpen(false)
}}
icon={<Settings />}
>
Go to Settings
</Item>
</Command.Group>
</>
)
}

View file

@ -0,0 +1,53 @@
import { Spinner } from "@components/spinner"
import { PostWithFiles } from "@lib/server/prisma"
import { useSessionSWR } from "@lib/use-session-swr"
import { useRouter } from "next/navigation"
import { useEffect, useState } from "react"
import { File } from "react-feather"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
import Item from "../item"
export default function PostsPage({ setOpen }: {
setOpen: (open: boolean) => void
}) {
const { session } = useSessionSWR()
const [posts, setPosts] = useState<PostWithFiles[]>()
const [isLoading, setLoading] = useState(true)
useEffect(() => {
async function getPosts() {
if (!session) return
const data = await fetchWithUser(`/api/user/${session?.user.id}/posts/`, {
method: "GET"
})
const posts = (await data.json()) as PostWithFiles[]
setPosts(posts)
setLoading(false)
}
getPosts()
}, [session])
const router = useRouter()
return (
<>
{isLoading && (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", height: 100 }}>
<Spinner />
</div>
)}
{posts?.map((post) => (
<Item
onSelect={() => {
router.push(`/post/${post.id}`)
setOpen(false)
}}
key={post.id}
icon={<File />}
>
{post.title}
</Item>
))}
</>
)
}

View file

@ -16,8 +16,9 @@
--gap-quarter-negative: calc(-1 * var(--gap-quarter));
/* Typography */
--font-sans: var(--inter-font), -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--font-sans: var(--inter-font), -apple-system, BlinkMacSystemFont, Segoe UI,
Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
sans-serif;
--font-mono: ui-monospace, "SFMono-Regular", "Consolas", "Liberation Mono",
"Menlo", monospace;
@ -69,11 +70,10 @@
--article-color: #212121;
--header-bg: rgba(255, 255, 255, 0.8);
--gray-alpha: rgba(19, 20, 21, 0.5);
--selection: var(0, 0, 0, .6);
--selection: var(0, 0, 0, 0.6);
color-scheme: light;
}
* {
box-sizing: border-box;
}
@ -207,7 +207,7 @@ h6 {
margin: 0;
}
h1,
h1,
h2,
h3,
h4,

View file

@ -1,33 +1,31 @@
import { parseQueryParam } from "@lib/server/parse-query-param"
import { getUserById } from "@lib/server/prisma"
import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from "src/lib/server/prisma"
import { withMethods } from "@lib/api-middleware/with-methods"
import { getSession } from "next-auth/react"
import { verifyApiUser } from "@lib/server/verify-api-user"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const id = parseQueryParam(req.query.id)
if (!id) {
return res.status(400).json({ error: "Missing id" })
const userId = await verifyApiUser(req, res)
if (!userId) {
return res.status(400).json({ error: "Missing userId or auth token" })
}
const user = await getUserById(id)
const currUser = (await getSession({ req }))?.user
const [session, user] = await Promise.all([
// TODO; this call is duplicated in veryfiyApiUser
getSession({ req }),
getUserById(userId)
])
if (!user) {
return res.status(404).json({ message: "User not found" })
}
if (user.id !== currUser?.id) {
return res.status(403).json({ message: "Unauthorized" })
}
const currUser = session?.user
switch (req.method) {
case "PUT": {
const { displayName } = req.body
const updatedUser = await prisma.user.update({
where: {
id
id: userId
},
data: {
displayName
@ -44,14 +42,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
case "GET":
return res.json({
...currUser,
displayName: user.displayName
displayName: user?.displayName
})
case "DELETE":
if (currUser?.role !== "admin" && currUser?.id !== id) {
if (currUser?.role !== "admin") {
return res.status(403).json({ message: "Unauthorized" })
}
await deleteUser(id)
await deleteUser(userId)
break
default:
return res.status(405).json({ message: "Method not allowed" })

View file

@ -0,0 +1,43 @@
import { verifyApiUser } from "@lib/server/verify-api-user"
import { NextApiRequest, NextApiResponse } from "next"
import { prisma } from "@lib/server/prisma"
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const userId = await verifyApiUser(req, res)
if (!userId) {
return res.status(400).json({ error: "Missing userId or auth token" })
}
const userPosts = await prisma.post.findMany({
where: {
authorId: userId
},
select: {
id: true,
title: true,
files: {
select: {
id: true,
content: true,
title: true,
sha: true,
updatedAt: true,
createdAt: true,
deletedAt: true
}
},
createdAt: true,
updatedAt: true,
deletedAt: true,
authorId: true,
parentId: true,
description: true,
visibility: true
}
})
res.status(200).json(userPosts)
}