Add cmdk, nested postcss
This commit is contained in:
parent
cc2215629d
commit
85f21bf505
15 changed files with 1272 additions and 35 deletions
|
@ -269,7 +269,8 @@ function Post({
|
|||
])
|
||||
}}
|
||||
style={{
|
||||
flex: 1
|
||||
flex: 1,
|
||||
minWidth: 120
|
||||
}}
|
||||
>
|
||||
Add a File
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
.buttons .rightButtons {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.buttons .rightButtons > * {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
165
src/app/components/cmdk/cmdk.module.css
Normal file
165
src/app/components/cmdk/cmdk.module.css
Normal 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);
|
||||
}
|
||||
}
|
16
src/app/components/cmdk/dialog.css
Normal file
16
src/app/components/cmdk/dialog.css
Normal 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;
|
||||
}
|
74
src/app/components/cmdk/index.tsx
Normal file
74
src/app/components/cmdk/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
27
src/app/components/cmdk/item.tsx
Normal file
27
src/app/components/cmdk/item.tsx
Normal 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>
|
||||
)
|
||||
}
|
64
src/app/components/cmdk/pages/home.tsx
Normal file
64
src/app/components/cmdk/pages/home.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
53
src/app/components/cmdk/pages/posts.tsx
Normal file
53
src/app/components/cmdk/pages/posts.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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" })
|
43
src/pages/api/user/[userId]/posts/index.ts
Normal file
43
src/pages/api/user/[userId]/posts/index.ts
Normal 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)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue