Adds shadcn, bumps dependencies, overhaults lots of code.
This commit is contained in:
Max Leiter 2023-07-20 18:04:47 -07:00 committed by GitHub
parent 702f59caf8
commit 5d5fd3182e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
114 changed files with 5340 additions and 3676 deletions

View file

@ -3,5 +3,6 @@
"trailingComma": "none", "trailingComma": "none",
"singleQuote": false, "singleQuote": false,
"printWidth": 80, "printWidth": 80,
"useTabs": true "useTabs": true,
"plugins": ["prettier-plugin-tailwindcss"]
} }

View file

@ -8,6 +8,8 @@ You can try a demo at https://drift.lol. The demo is built on main but has no da
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User). If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/).
<hr /> <hr />
**Contents:** **Contents:**
@ -48,6 +50,7 @@ You can change these to your liking.
- `NODE_ENV`: defaults to development, can be `production` - `NODE_ENV`: defaults to development, can be `production`
#### Auth environment variables #### Auth environment variables
**Note:** Only credential auth currently supports the registration password, so if you want to secure registration, you must use only credential auth. **Note:** Only credential auth currently supports the registration password, so if you want to secure registration, you must use only credential auth.
- `GITHUB_CLIENT_ID`: the client ID for GitHub OAuth. - `GITHUB_CLIENT_ID`: the client ID for GitHub OAuth.

16
components.json Normal file
View file

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@components",
"utils": "@utils"
}
}

View file

@ -11,6 +11,10 @@ const nextConfig = {
{ {
source: "/file/raw/:id", source: "/file/raw/:id",
destination: `/api/raw/:id` destination: `/api/raw/:id`
},
{
source: "/signout",
destination: `/api/auth/signout`
} }
] ]
}, },
@ -18,10 +22,25 @@ const nextConfig = {
domains: ["avatars.githubusercontent.com"] domains: ["avatars.githubusercontent.com"]
}, },
env: { env: {
NEXT_PUBLIC_DRIFT_URL: process.env.DRIFT_URL NEXT_PUBLIC_DRIFT_URL:
process.env.DRIFT_URL ||
(process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "http://localhost:3000")
},
eslint: {
ignoreDuringBuilds: process.env.VERCEL_ENV !== "production"
},
typescript: {
ignoreBuildErrors: process.env.VERCEL_ENV !== "production"
},
modularizeImports: {
"react-feather": {
transform: "react-feather/dist/icons/{{kebabCase member}}"
}
} }
} }
export default bundleAnalyzer({ enabled: process.env.ANALYZE === "true" })( export default process.env.ANALYZE === "true"
nextConfig ? bundleAnalyzer({ enabled: true })(nextConfig)
) : nextConfig

View file

@ -13,39 +13,48 @@
"jest": "jest" "jest": "jest"
}, },
"dependencies": { "dependencies": {
"@next-auth/prisma-adapter": "^1.0.6", "@next-auth/prisma-adapter": "^1.0.7",
"@next/eslint-plugin-next": "13.4.4-canary.0", "@next/eslint-plugin-next": "13.4.11-canary.0",
"@prisma/client": "^4.14.1", "@prisma/client": "^5.0.0",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.3", "@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-dropdown-menu": "^2.0.4",
"@radix-ui/react-navigation-menu": "^1.1.3",
"@radix-ui/react-popover": "^1.0.5", "@radix-ui/react-popover": "^1.0.5",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.5", "@radix-ui/react-tooltip": "^1.0.5",
"@tailwindcss/nesting": "0.0.0-insiders.565cd3e",
"@tailwindcss/typography": "^0.5.9",
"class-variance-authority": "^0.6.0",
"client-only": "^0.0.1", "client-only": "^0.0.1",
"client-zip": "2.3.1", "client-zip": "2.3.1",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"next": "13.4.5-canary.2", "next": "13.4.11-canary.1",
"next-auth": "^4.22.1", "next-auth": "^4.22.3",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"react": "18.2.0", "react": "18.2.0",
"react-cookie": "^4.1.1", "react-cookie": "^4.1.1",
"react-datepicker": "4.10.0", "react-datepicker": "4.10.0",
"react-day-picker": "^8.8.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-dropzone": "14.2.3", "react-dropzone": "14.2.3",
"react-error-boundary": "^4.0.4", "react-error-boundary": "^4.0.4",
"react-feather": "^2.0.10", "react-feather": "^2.0.10",
"react-hot-toast": "2.4.1", "react-hot-toast": "2.4.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"swr": "^2.1.5", "swr": "^2.2.0",
"tailwind-merge": "^1.13.0",
"tailwindcss-animate": "^1.0.5",
"textarea-markdown-editor": "1.0.4", "textarea-markdown-editor": "1.0.4",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
"uuid": "^9.0.0" "uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@next/bundle-analyzer": "13.4.5-canary.2", "@next/bundle-analyzer": "13.4.11-canary.0",
"@total-typescript/ts-reset": "^0.4.2", "@total-typescript/ts-reset": "^0.4.2",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/git-http-backend": "^1.0.1", "@types/git-http-backend": "^1.0.1",
@ -59,12 +68,13 @@
"@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/eslint-plugin": "^5.58.0",
"@typescript-eslint/parser": "^5.58.0", "@typescript-eslint/parser": "^5.58.0",
"@wcj/markdown-to-html": "^2.2.1", "@wcj/markdown-to-html": "^2.2.1",
"autoprefixer": "^10.4.14",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"csstype": "^3.1.2", "csstype": "^3.1.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "8.38.0", "eslint": "8.38.0",
"eslint-config-next": "13.4.5-canary.2", "eslint-config-next": "13.4.11-canary.1",
"jest-mock-extended": "^3.0.3", "jest-mock-extended": "^3.0.3",
"next-unused": "0.0.6", "next-unused": "0.0.6",
"postcss": "^8.4.21", "postcss": "^8.4.21",
@ -73,8 +83,10 @@
"postcss-nested": "^6.0.1", "postcss-nested": "^6.0.1",
"postcss-preset-env": "^8.4.1", "postcss-preset-env": "^8.4.1",
"prettier": "2.8.7", "prettier": "2.8.7",
"prisma": "^4.12.0", "prettier-plugin-tailwindcss": "^0.3.0",
"typescript": "5.0.4", "prisma": "^5.0.0",
"tailwindcss": "^3.3.2",
"typescript": "5.1.6",
"typescript-plugin-css-modules": "5.0.1" "typescript-plugin-css-modules": "5.0.1"
}, },
"optionalDependencies": { "optionalDependencies": {

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,7 @@
module.exports = { module.exports = {
plugins: [ plugins: {
"postcss-nested", "@tailwindcss/nesting": {},
"postcss-flexbugs-fixes", tailwindcss: {},
"postcss-hover-media-feature", autoprefixer: {}
[
"postcss-preset-env",
{
stage: 3,
features: {
"custom-media-queries": true,
"custom-properties": false
} }
}
]
]
} }

View file

@ -1,21 +1,8 @@
.container {
padding: 2rem 2rem;
border-radius: var(--radius);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
}
.form {
display: grid;
place-items: center;
}
.formGroup { .formGroup {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
place-items: center; place-items: center;
gap: 10px; gap: 10px;
max-width: 300px;
width: 100%;
} }
.formContentSpace { .formContentSpace {

View file

@ -4,9 +4,11 @@ import { useState } from "react"
import styles from "./auth.module.css" import styles from "./auth.module.css"
import Link from "../../../components/link" import Link from "../../../components/link"
import { signIn } from "next-auth/react" import { signIn } from "next-auth/react"
import Input from "@components/input" import { Input } from "@components/input"
import Button from "@components/button" import { Button } from "@components/button"
import { GitHub, Key, User } from "react-feather" import { Key, User } from "react-feather"
// @ts-expect-error - no types
import GitHub from "react-feather/dist/icons/github"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Note from "@components/note" import Note from "@components/note"
@ -52,7 +54,7 @@ function Auth({
}) })
setSubmitting(false) setSubmitting(false)
} else { } else {
router.push("/new") router.refresh()
} }
} }
@ -73,9 +75,9 @@ function Auth({
return ( return (
<div className={styles.container}> <div className={styles.container}>
<ErrorQueryParamsHandler /> <ErrorQueryParamsHandler />
<div className={styles.form}> <div className={"mx-auto w-[300px]"}>
<div className={styles.formContentSpace}> <div className={styles.formContentSpace}>
<h1>Sign {signText}</h1> <h1 className="text-3xl font-bold">Sign {signText}</h1>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
@ -92,7 +94,6 @@ function Auth({
onChange={handleChangeServerPassword} onChange={handleChangeServerPassword}
placeholder="Server Password" placeholder="Server Password"
required={true} required={true}
width="100%"
aria-label="Server Password" aria-label="Server Password"
/> />
<hr style={{ width: "100%" }} /> <hr style={{ width: "100%" }} />
@ -123,7 +124,7 @@ function Auth({
width="100%" width="100%"
aria-label="Password" aria-label="Password"
/> />
<Button width={"100%"} type="submit" loading={submitting}> <Button type="submit" loading={submitting}>
Sign {signText} Sign {signText}
</Button> </Button>
</> </>
@ -131,25 +132,27 @@ function Auth({
{authProviders?.length ? ( {authProviders?.length ? (
<> <>
<hr className="w-full" />
<p className="mt-2 p-0 text-center">
Or sign {signText.toLowerCase()} with one of the following
</p>
{authProviders?.map((provider) => { {authProviders?.map((provider) => {
return provider.enabled ? ( return provider.enabled ? (
<Button <Button
type="submit" type="submit"
width="100%"
key={provider.id + "-button"} key={provider.id + "-button"}
style={{
color: "var(--fg)"
}}
iconLeft={getProviderIcon(provider.id)}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
signIn(provider.id, { signIn(provider.id, {
callbackUrl: "/", callbackUrl: "/",
registration_password: serverPassword registration_password: serverPassword
}) })
router.refresh()
}} }}
className="my-2 flex w-full max-w-[250px] items-center justify-center"
> >
Sign {signText.toLowerCase()} with {provider.public_name} {getProviderIcon(provider.id)} Sign{" "}
{signText.toLowerCase()} with {provider.public_name}
</Button> </Button>
) : null ) : null
})} })}
@ -184,10 +187,10 @@ export default Auth
const getProviderIcon = (provider: string) => { const getProviderIcon = (provider: string) => {
switch (provider) { switch (provider) {
case "github": case "github":
return <GitHub /> return <GitHub className="mr-2 h-5 w-5" />
case "keycloak": case "keycloak":
return <Key /> return <Key className="mr-2 h-5 w-5" />
default: default:
return <User /> return <User className="mr-2 h-5 w-5" />
} }
} }

View file

@ -4,7 +4,7 @@ import { useToasts } from "@components/toasts"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { Suspense, useEffect } from "react" import { Suspense, useEffect } from "react"
function _ErrorQueryParamsHandler() { function InnerErrorQueryParamsHandler() {
const queryParams = useSearchParams() const queryParams = useSearchParams()
const { setToast } = useToasts() const { setToast } = useToasts()
@ -24,7 +24,7 @@ export function ErrorQueryParamsHandler() {
/* Suspense boundary because useSearchParams causes static bailout */ /* Suspense boundary because useSearchParams causes static bailout */
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<_ErrorQueryParamsHandler /> <InnerErrorQueryParamsHandler />
</Suspense> </Suspense>
) )
} }

View file

@ -2,14 +2,17 @@ import { getMetadata } from "src/app/lib/metadata"
import Auth from "../components" import Auth from "../components"
import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props" import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
import { PageWrapper } from "@components/page-wrapper"
export default function SignInPage() { export default function SignInPage() {
return ( return (
<PageWrapper>
<Auth <Auth
page="signin" page="signin"
credentialAuth={isCredentialEnabled()} credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()} authProviders={getAuthProviders()}
/> />
</PageWrapper>
) )
} }

View file

@ -1,21 +1,24 @@
import Auth from "../components" import Auth from "../components"
import { getRequiresPasscode } from "src/pages/api/auth/requires-passcode"
import { getMetadata } from "src/app/lib/metadata" import { getMetadata } from "src/app/lib/metadata"
import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props" import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
import { getRequiresPasscode } from "src/app/api/auth/requires-passcode/route"
import { PageWrapper } from "@components/page-wrapper"
async function getPasscode() { async function getPasscode() {
return await getRequiresPasscode() return getRequiresPasscode()
} }
export default async function SignUpPage() { export default async function SignUpPage() {
const requiresPasscode = await getPasscode() const requiresPasscode = await getPasscode()
return ( return (
<PageWrapper>
<Auth <Auth
page="signup" page="signup"
requiresServerPassword={requiresPasscode} requiresServerPassword={requiresPasscode}
credentialAuth={isCredentialEnabled()} credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()} authProviders={getAuthProviders()}
/> />
</PageWrapper>
) )
} }

View file

@ -1,13 +1,19 @@
"use client" "use client"
import * as RadixTabs from "@radix-ui/react-tabs"
import FormattingIcons from "src/app/(drift)/(posts)/new/components/edit-document-list/edit-document/formatting-icons" import FormattingIcons from "src/app/(drift)/(posts)/new/components/edit-document-list/edit-document/formatting-icons"
import { ChangeEvent, ClipboardEvent, useRef } from "react" import {
ChangeEvent,
ClipboardEvent,
ComponentProps,
useRef,
useState
} from "react"
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor" import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
import Preview, { StaticPreview } from "../preview" import Preview, { StaticPreview } from "../preview"
import styles from "./tabs.module.css" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/tabs"
import { Textarea } from "@components/textarea"
type Props = RadixTabs.TabsProps & { type Props = ComponentProps<typeof Tabs> & {
isEditing: boolean isEditing: boolean
defaultTab: "preview" | "edit" defaultTab: "preview" | "edit"
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
@ -28,37 +34,33 @@ export default function DocumentTabs({
...props ...props
}: Props) { }: Props) {
const codeEditorRef = useRef<TextareaMarkdownRef>(null) const codeEditorRef = useRef<TextareaMarkdownRef>(null)
const [activeTab, setActiveTab] = useState<"preview" | "edit">(defaultTab)
const handleTabChange = (newTab: string) => { const handleTabChange = (newTab: string) => {
if (newTab === "preview") { if (newTab === "preview") {
codeEditorRef.current?.focus() codeEditorRef.current?.focus()
} }
setActiveTab(newTab as "preview" | "edit")
} }
const formattingIconsVisibilityClass =
activeTab === "preview" ? "hidden" : "block"
return ( return (
<RadixTabs.Root <Tabs {...props} onValueChange={handleTabChange} defaultValue={defaultTab}>
{...props} <TabsList className="flex justify-between">
onValueChange={handleTabChange} <div>
className={styles.root} <TabsTrigger value="edit">{isEditing ? "Edit" : "Raw"}</TabsTrigger>
defaultValue={defaultTab} <TabsTrigger value="preview">
>
<RadixTabs.List className={styles.listWrapper}>
<div className={styles.list}>
<RadixTabs.Trigger value="edit" className={styles.trigger}>
{isEditing ? "Edit" : "Raw"}
</RadixTabs.Trigger>
<RadixTabs.Trigger value="preview" className={styles.trigger}>
{isEditing ? "Preview" : "Rendered"} {isEditing ? "Preview" : "Rendered"}
</RadixTabs.Trigger> </TabsTrigger>
</div> </div>
{isEditing && ( {isEditing && (
<FormattingIcons <FormattingIcons
className={styles.formattingIcons}
textareaRef={codeEditorRef} textareaRef={codeEditorRef}
className={`ml-auto ${formattingIconsVisibilityClass}`}
/> />
)} )}
</RadixTabs.List> </TabsList>
<RadixTabs.Content value="edit"> <TabsContent value="edit">
<div <div
style={{ style={{
marginTop: 6, marginTop: 6,
@ -67,7 +69,7 @@ export default function DocumentTabs({
}} }}
> >
<TextareaMarkdown.Wrapper ref={codeEditorRef}> <TextareaMarkdown.Wrapper ref={codeEditorRef}>
<textarea <Textarea
readOnly={!isEditing} readOnly={!isEditing}
onPaste={onPaste ? onPaste : undefined} onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef} ref={codeEditorRef}
@ -80,8 +82,8 @@ export default function DocumentTabs({
/> />
</TextareaMarkdown.Wrapper> </TextareaMarkdown.Wrapper>
</div> </div>
</RadixTabs.Content> </TabsContent>
<RadixTabs.Content value="preview"> <TabsContent value="preview">
{isEditing ? ( {isEditing ? (
<Preview height={"100%"} title={title}> <Preview height={"100%"} title={title}>
{rawContent} {rawContent}
@ -89,7 +91,7 @@ export default function DocumentTabs({
) : ( ) : (
<StaticPreview height={"100%"}>{preview || ""}</StaticPreview> <StaticPreview height={"100%"}>{preview || ""}</StaticPreview>
)} )}
</RadixTabs.Content> </TabsContent>
</RadixTabs.Root> </Tabs>
) )
} }

View file

@ -0,0 +1,26 @@
.root {
display: flex;
flex-direction: column;
}
.listWrapper {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
@media (max-width: 600px) {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
}
.list {
flex-shrink: 0;
display: flex;
}
.list .formattingIcons {
margin-left: auto;
}

View file

@ -23,36 +23,6 @@
margin: 0; margin: 0;
} }
.content li {
transition: var(--transition);
border-radius: var(--radius);
margin: 0;
padding: 0 0;
}
.contenthover,
.content li:focus {
background-color: var(--lighter-gray);
}
.content .listItem {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--dark-gray);
text-decoration: none;
padding: var(--gap-quarter) 0;
}
.content li .fileIcon {
display: inline-block;
margin-right: var(--gap-half);
}
.content li .fileTitle {
font-size: calc(0.875 * 16px);
}
.content li::before { .content li::before {
content: ""; content: "";
padding: 0; padding: 0;

View file

@ -1,12 +1,11 @@
import { Popover } from "@components/popover" import { Popover, PopoverContent, PopoverTrigger } from "@components/popover"
import { codeFileExtensions } from "@lib/constants" import { codeFileExtensions } from "@lib/constants"
import clsx from "clsx"
import type { PostWithFiles } from "src/lib/server/prisma" import type { PostWithFiles } from "src/lib/server/prisma"
import styles from "./dropdown.module.css" import styles from "./dropdown.module.css"
import buttonStyles from "@components/button/button.module.css"
import { ChevronDown, Code, File as FileIcon } from "react-feather" import { ChevronDown, Code, File as FileIcon } from "react-feather"
import { Spinner } from "@components/spinner" import { Spinner } from "@components/spinner"
import Link from "next/link" import Link from "next/link"
import { buttonVariants } from "@components/button"
function FileDropdown({ function FileDropdown({
files, files,
@ -18,11 +17,15 @@ function FileDropdown({
if (loading) { if (loading) {
return ( return (
<Popover> <Popover>
<Popover.Trigger className={buttonStyles.button}> <PopoverTrigger
className={buttonVariants({
variant: "link"
})}
>
<div style={{ minWidth: 125 }}> <div style={{ minWidth: 125 }}>
<Spinner /> <Spinner />
</div> </div>
</Popover.Trigger> </PopoverTrigger>
</Popover> </Popover>
) )
} }
@ -32,25 +35,26 @@ function FileDropdown({
if (codeFileExtensions.includes(extension || "")) { if (codeFileExtensions.includes(extension || "")) {
return { return {
...file, ...file,
icon: <Code /> icon: <Code className="h-4 w-4" />
} }
} else { } else {
return { return {
...file, ...file,
icon: <FileIcon /> icon: <FileIcon className="h-4 w-4" />
} }
} }
}) })
const content = ( const content = (
<ul className={styles.content}> <ul className="text-sm">
{items.map((item) => ( {items.map((item) => (
<li key={item.id}> <li key={item.id} className="flex">
<Link href={`#${item.title}`} className={styles.listItem}> <Link
<span className={styles.fileIcon}>{item.icon}</span> href={`#${item.title}`}
<span className={styles.fileTitle}> className="flex w-full items-center gap-3 hover:underline"
>
{item.icon}
{item.title ? item.title : "Untitled"} {item.title ? item.title : "Untitled"}
</span>
</Link> </Link>
</li> </li>
))} ))}
@ -59,23 +63,19 @@ function FileDropdown({
return ( return (
<Popover> <Popover>
<Popover.Trigger <PopoverTrigger
className={buttonStyles.button} className={buttonVariants({
style={{ height: 40, padding: 10 }} variant: "secondary"
> })}
<div
className={clsx(buttonStyles.icon, styles.chevron)}
style={{ marginRight: 6 }}
> >
<div className={styles.chevron} style={{ marginRight: 6 }}>
<ChevronDown /> <ChevronDown />
</div> </div>
<span> <span>
Jump to {files.length} {files.length === 1 ? "file" : "files"} Jump to {files.length} {files.length === 1 ? "file" : "files"}
</span> </span>
</Popover.Trigger> </PopoverTrigger>
<Popover.Content className={styles.contentWrapper}> <PopoverContent>{content}</PopoverContent>
{content}
</Popover.Content>
</Popover> </Popover>
) )
} }

View file

@ -1,18 +1,61 @@
.markdownPreview { .markdownPreview {
padding: var(--gap-quarter); padding: var(--gap-quarter);
font-size: 18px; font-size: 16px;
line-height: 1.75; line-height: 1.75;
color: hsl(var(--foreground));
} }
.skeletonPreview { .skeletonPreview {
padding: var(--gap-half); padding: var(--gap-half);
font-size: 18px; font-size: 16px;
line-height: 1.75; line-height: 1.75;
} }
.markdownPreview {
padding: var(--gap-quarter);
color: hsl(var(--foreground));
}
.skeletonPreview {
padding: var(--gap-half);
}
.markdownPreview h1,
.markdownPreview h2,
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
font-weight: 600;
}
.markdownPreview h1 {
font-size: 1.775rem;
}
.markdownPreview h2 {
font-size: 1.5rem;
}
.markdownPreview h3 {
font-size: 1.25rem;
}
.markdownPreview h4 {
font-size: 1.125rem;
}
.markdownPreview h5 {
font-size: 1.1rem;
}
.markdownPreview p { .markdownPreview p {
margin-top: var(--gap); margin-top: 1.25rem;
margin-bottom: var(--gap); margin-bottom: 1.25rem;
/*
&:not(:first-child) {
line-height: 1.75rem;
margin-top: 1.5rem;
} */
} }
.markdownPreview pre { .markdownPreview pre {
@ -26,31 +69,6 @@
word-wrap: break-word; word-wrap: break-word;
} }
.markdownPreview h1,
.markdownPreview h2,
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
margin-top: var(--gap);
margin-bottom: var(--gap-half);
}
.markdownPreview h1 {
color: var(--fg);
}
.markdownPreview h2 {
color: var(--darkest-gray);
}
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
color: var(--darker-gray);
}
/* Auto-linked headers */ /* Auto-linked headers */
.markdownPreview h1 a, .markdownPreview h1 a,
.markdownPreview h2 a, .markdownPreview h2 a,
@ -75,44 +93,49 @@
filter: opacity(0.5); filter: opacity(0.5);
} }
.markdownPreview h1 {
font-size: 2rem;
}
.markdownPreview h2 {
font-size: 1.5rem;
}
.markdownPreview h3 {
font-size: 1.25rem;
}
.markdownPreview h4 {
font-size: 1rem;
}
.markdownPreview h5 {
font-size: 1rem;
}
.markdownPreview h6 {
font-size: 0.875rem;
}
.markdownPreview ul { .markdownPreview ul {
list-style: inside; margin-bottom: 1.5rem;
margin-left: 1.5rem;
list-style-type: disc;
}
.markdownPreview ol {
margin-bottom: 1.5rem;
margin-left: 1.5rem;
list-style-type: decimal;
} }
.markdownPreview ul li::before { .markdownPreview ul li::before {
content: ""; content: "";
} }
.markdownPreview code::before, .markdownPreview code::before,
.markdownPreview code::after { .markdownPreview code::after {
content: ""; content: "";
} }
.markdownPreview blockquote {
padding-left: 1.5rem;
font-style: italic;
border-left-width: 2px;
}
.markdownPreview blockquote p {
margin-top: 0;
}
.markdownPreview table {
overflow-y: auto;
width: 100%;
}
.markdownPreview table th {
font-weight: 600;
padding: 0;
margin: 0;
border-top-width: 1px;
}
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
.markdownPreview h1 a::after, .markdownPreview h1 a::after,
.markdownPreview h2 a::after, .markdownPreview h2 a::after,

View file

@ -1,60 +0,0 @@
.root {
display: flex;
flex-direction: column;
}
.listWrapper {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
@media (max-width: 600px) {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
}
.list {
flex-shrink: 0;
display: flex;
}
.list .formattingIcons {
margin-left: auto;
}
.trigger {
width: 80px;
height: 30px;
margin: 4px 0;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
user-select: none;
cursor: pointer;
transition: color 0.1s ease;
margin-bottom: var(--gap-half);
}
.trigger:hover {
background-color: var(--lighter-gray);
color: var(--fg);
}
.trigger:first-child {
border-top-left-radius: 4px;
}
.trigger:last-child {
border-top-right-radius: 4px;
}
.trigger[data-state="active"] {
color: var(--darkest-gray);
box-shadow: inset 0 -1px 0 0 currentColor, 0 1px 0 0 currentColor;
}

View file

@ -1,7 +1,8 @@
import Input from "@components/input" import { Input } from "@components/input"
import { ChangeEvent } from "react" import { ChangeEvent } from "react"
import styles from "../post.module.css" import styles from "../post.module.css"
import clsx from "clsx"
type props = { type props = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void onChange: (e: ChangeEvent<HTMLInputElement>) => void
@ -10,7 +11,7 @@ type props = {
function Description({ onChange, description }: props) { function Description({ onChange, description }: props) {
return ( return (
<div className={styles.description}> <div className={clsx(styles.description, "pb-4")}>
<Input <Input
value={description || ""} value={description || ""}
onChange={onChange} onChange={onChange}

View file

@ -8,18 +8,6 @@
margin-top: var(--gap-double); margin-top: var(--gap-double);
} }
.dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
border-radius: 2px;
border: 2px dashed var(--border);
outline: none;
cursor: pointer;
}
.dropzone:focus { .dropzone:focus {
border-color: var(--gray); border-color: var(--gray);
} }

View file

@ -82,12 +82,11 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div {...getRootProps()} className={styles.dropzone}> <div {...getRootProps()}>
<input {...getInputProps()} /> <input {...getInputProps()} />
{!isDragActive && ( {!isDragActive && (
<p style={{ color: "var(--gray)" }}> <p className="cursor-pointer select-none rounded-md border-2 border-dashed p-4 text-sm text-muted-foreground">
Drag some files here, or <span className={styles.verb} /> to select Drag and drop files here, or click to select
files
</p> </p>
)} )}
{isDragActive && <p>Release to drop the files here</p>} {isDragActive && <p>Release to drop the files here</p>}

View file

@ -1,6 +1,5 @@
.card { .card {
padding: var(--gap); padding: var(--gap);
border: 1px solid var(--lighter-gray);
border-radius: var(--radius); border-radius: var(--radius);
} }

View file

@ -9,9 +9,10 @@ import {
import { RefObject, useMemo } from "react" import { RefObject, useMemo } from "react"
import styles from "./formatting-icons.module.css" import styles from "./formatting-icons.module.css"
import { TextareaMarkdownRef } from "textarea-markdown-editor" import { TextareaMarkdownRef } from "textarea-markdown-editor"
import Tooltip from "@components/tooltip" import { Tooltip } from "@components/tooltip"
import Button from "@components/button" import { Button } from "@components/button"
import clsx from "clsx" import clsx from "clsx"
import React from "react"
// TODO: clean up // TODO: clean up
function FormattingIcons({ function FormattingIcons({
@ -69,22 +70,18 @@ function FormattingIcons({
<Tooltip <Tooltip
content={name[0].toUpperCase() + name.slice(1).replace("-", " ")} content={name[0].toUpperCase() + name.slice(1).replace("-", " ")}
key={name} key={name}
delayDuration={100}
> >
<Button <Button
height={32}
style={{
fontSize: 14,
borderRight: "none",
borderLeft: "none",
borderTop: "none",
borderBottom: "none"
}}
aria-label={name} aria-label={name}
iconRight={icon}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={action} onClick={action}
buttonType="secondary" variant="ghost"
/> >
{React.cloneElement(icon, {
className: "h-4 w-4"
})}
</Button>
</Tooltip> </Tooltip>
))} ))}
</div> </div>

View file

@ -1,9 +1,10 @@
import { ChangeEvent, ClipboardEvent, useCallback } from "react" import { ChangeEvent, ClipboardEvent, useCallback } from "react"
import styles from "./document.module.css" import styles from "./document.module.css"
import Button from "@components/button" import { Button } from "@components/button"
import Input from "@components/input" import { Input } from "@components/input"
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs" import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
import { Trash } from "react-feather" import { Trash } from "react-feather"
import { Card, CardContent, CardHeader } from "@components/card"
type Props = { type Props = {
title?: string title?: string
@ -49,8 +50,8 @@ function Document({
) )
return ( return (
<> <Card className="min-h-[512px]">
<div className={styles.card}> <CardHeader>
<div className={styles.fileNameContainer}> <div className={styles.fileNameContainer}>
<Input <Input
placeholder="MyFile.md" placeholder="MyFile.md"
@ -65,21 +66,18 @@ function Document({
}} }}
/> />
{remove && ( {remove && (
// no left border
<Button <Button
iconLeft={<Trash />}
height={"39px"}
width={"48px"}
padding={0}
margin={0}
onClick={() => removeFile(remove)} onClick={() => removeFile(remove)}
style={{ variant="outline"
borderTopLeftRadius: 0, className="border-color-[var(--border)] rounded-l-none border-l-0"
borderBottomLeftRadius: 0 >
}} <Trash height={18} />
/> </Button>
)} )}
</div> </div>
<div className={styles.documentContainer}> </CardHeader>
<CardContent>
<DocumentTabs <DocumentTabs
isEditing={true} isEditing={true}
defaultTab={defaultTab} defaultTab={defaultTab}
@ -91,9 +89,8 @@ function Document({
> >
{content} {content}
</DocumentTabs> </DocumentTabs>
</div> </CardContent>
</div> </Card>
</>
) )
} }

View file

@ -3,26 +3,42 @@
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useCallback, useState, ClipboardEvent } from "react" import { useCallback, useState, ClipboardEvent } from "react"
import generateUUID from "@lib/generate-uuid" import generateUUID from "@lib/generate-uuid"
import styles from "./post.module.css"
import EditDocumentList from "./edit-document-list" import EditDocumentList from "./edit-document-list"
import { ChangeEvent } from "react" import { ChangeEvent } from "react"
import getTitleForPostCopy from "src/app/lib/get-title-for-post-copy" import getTitleForPostCopy from "src/app/lib/get-title-for-post-copy"
import Description from "./description" // import Description from "./description"
import { PostWithFiles } from "@lib/server/prisma" import { PostWithFiles } from "@lib/server/prisma"
import PasswordModal from "../../../../components/password-modal" import PasswordModal from "../../../../components/password-modal"
import Title from "./title" import Title from "./title"
import FileDropzone from "./drag-and-drop" import FileDropzone from "./drag-and-drop"
import Button from "@components/button" import { Button, buttonVariants } from "@components/button"
import Input from "@components/input"
import ButtonDropdown from "@components/button-dropdown"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { fetchWithUser } from "src/app/lib/fetch-with-user" import { fetchWithUser } from "src/app/lib/fetch-with-user"
import dynamic from "next/dynamic" import dynamic from "next/dynamic"
import ButtonDropdown from "@components/button-dropdown"
import clsx from "clsx"
import { Spinner } from "@components/spinner"
import { cn } from "@lib/cn"
import { Calendar as CalendarIcon } from "react-feather"
const DatePicker = dynamic(() => import("react-datepicker"), { const DatePicker = dynamic(
() => import("@components/date-picker").then((m) => m.DatePicker),
{
ssr: false, ssr: false,
loading: () => <Input label="Expires at" placeholder="Won't expire" width="100%" height="40px" /> loading: () => (
}) <Button
variant={"outline"}
className={cn(
"w-[280px] justify-start text-left font-normal",
"text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
<span>Won&apos;t expire</span>
</Button>
)
}
)
const emptyDoc = { const emptyDoc = {
title: "", title: "",
@ -48,7 +64,9 @@ function Post({
const [title, setTitle] = useState( const [title, setTitle] = useState(
getTitleForPostCopy(initialPost?.title) || "" getTitleForPostCopy(initialPost?.title) || ""
) )
const [description, setDescription] = useState(initialPost?.description || "") const [description /*, setDescription */] = useState(
initialPost?.description || ""
)
const [expiresAt, setExpiresAt] = useState<Date>() const [expiresAt, setExpiresAt] = useState<Date>()
const defaultDocs: Document[] = initialPost const defaultDocs: Document[] = initialPost
@ -131,7 +149,7 @@ function Post({
if (!docs.length) { if (!docs.length) {
setToast({ setToast({
message: "Please add at least one document", message: "Please add at least one file",
type: "error" type: "error"
}) })
hasErrored = true hasErrored = true
@ -170,13 +188,13 @@ function Post({
setTitle(e.target.value) setTitle(e.target.value)
}, []) }, [])
const onChangeDescription = useCallback( // const onChangeDescription = useCallback(
(e: ChangeEvent<HTMLInputElement>) => { // (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault() // e.preventDefault()
setDescription(e.target.value) // setDescription(e.target.value)
}, // },
[] // []
) // )
function onClosePasswordModal() { function onClosePasswordModal() {
setPasswordModalVisible(false) setPasswordModalVisible(false)
@ -187,10 +205,6 @@ function Post({
return onSubmit("protected", password) return onSubmit("protected", password)
} }
function onChangeExpiration(date: Date) {
return setExpiresAt(date)
}
function updateDocTitle(i: number) { function updateDocTitle(i: number) {
return (title: string) => { return (title: string) => {
setDocs((docs) => setDocs((docs) =>
@ -241,10 +255,9 @@ function Post({
} }
return ( return (
<div className={styles.root}> <div className="flex flex-1 flex-col gap-4">
<Title title={title} onChange={onChangeTitle} /> <Title title={title} onChange={onChangeTitle} className="py-4" />
<Description description={description} onChange={onChangeDescription} /> {/* <Description description={description} onChange={onChangeDescription} /> */}
<FileDropzone setDocs={uploadDocs} />
<EditDocumentList <EditDocumentList
onPaste={onPaste} onPaste={onPaste}
docs={docs} docs={docs}
@ -252,7 +265,10 @@ function Post({
updateDocContent={updateDocContent} updateDocContent={updateDocContent}
removeDoc={removeDoc} removeDoc={removeDoc}
/> />
<div className={styles.buttons}> <FileDropzone setDocs={uploadDocs} />
<div className="mt-4 flex items-center justify-between">
<span className="flex flex-1 gap-2">
<Button <Button
onClick={() => { onClick={() => {
setDocs([ setDocs([
@ -264,57 +280,46 @@ function Post({
} }
]) ])
}} }}
style={{ className="min-w-[120px] max-w-[200px] flex-1"
flex: 1, variant={"secondary"}
minWidth: 120
}}
> >
Add a File Add a File
</Button> </Button>
<div className={styles.rightButtons}> <DatePicker setExpiresAt={setExpiresAt} expiresAt={expiresAt} />
<DatePicker </span>
onChange={onChangeExpiration}
customInput={
<Input label="Expires at" width="100%" height="40px" />
}
placeholderText="Won't expire"
selected={expiresAt}
showTimeInput={true}
// @ts-expect-error fix time input type
customTimeInput={<CustomTimeInput />}
timeInputLabel="Time:"
dateFormat="MM/dd/yyyy h:mm aa"
className={styles.datePicker}
clearButtonTitle={"Clear"}
// TODO: investigate why this causes margin shift if true
enableTabLoop={false}
minDate={new Date()}
/>
<ButtonDropdown> <ButtonDropdown>
<Button <span
height={40} className={clsx(
width={251} "w-full cursor-pointer rounded-br-none rounded-tr-none",
buttonVariants({
variant: "default"
})
)}
onClick={() => onSubmit("unlisted")} onClick={() => onSubmit("unlisted")}
loading={isSubmitting}
> >
{isSubmitting ? <Spinner className="mr-2" /> : null}
Create Unlisted Create Unlisted
</Button> </span>
<Button height={40} width={300} onClick={() => onSubmit("private")}> <span
className={clsx("w-full cursor-pointer")}
onClick={() => onSubmit("private")}
>
Create Private Create Private
</Button> </span>
<Button height={40} width={300} onClick={() => onSubmit("public")}> <span
className={clsx("w-full cursor-pointer")}
onClick={() => onSubmit("public")}
>
Create Public Create Public
</Button> </span>
<Button <span
height={40} className={clsx("w-full cursor-pointer")}
width={300}
onClick={() => onSubmit("protected")} onClick={() => onSubmit("protected")}
> >
Create with Password Create with Password
</Button> </span>
</ButtonDropdown> </ButtonDropdown>
</div> </div>
</div>
<PasswordModal <PasswordModal
creating={true} creating={true}
isOpen={passwordModalVisible} isOpen={passwordModalVisible}
@ -327,30 +332,30 @@ function Post({
export default Post export default Post
function CustomTimeInput({ // function CustomTimeInput({
date, // date,
value, // value,
onChange // onChange
}: { // }: {
date: Date // date: Date
value: string // value: string
onChange: (date: string) => void // onChange: (date: string) => void
}) { // }) {
return ( // return (
<input // <input
type="time" // type="time"
value={value} // value={value}
onChange={(e) => { // onChange={(e) => {
if (!isNaN(date.getTime())) { // if (!isNaN(date.getTime())) {
onChange(e.target.value || date.toISOString().slice(11, 16)) // onChange(e.target.value || date.toISOString().slice(11, 16))
} // }
}} // }}
style={{ // style={{
backgroundColor: "var(--bg)", // backgroundColor: "var(--bg)",
border: "1px solid var(--light-gray)", // border: "1px solid var(--light-gray)",
borderRadius: "var(--radius)" // borderRadius: "var(--radius)"
}} // }}
required // required
/> // />
) // )
} // }

View file

@ -2,7 +2,7 @@
padding-bottom: 200px; padding-bottom: 200px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--gap); gap: var(--gap-half);
} }
.buttons { .buttons {
@ -19,9 +19,6 @@
align-items: center; align-items: center;
} }
.datePicker {
flex: 1;
}
.description { .description {
width: 100%; width: 100%;

View file

@ -1,372 +0,0 @@
.react-datepicker__year-read-view--down-arrow,
.react-datepicker__month-read-view--down-arrow,
.react-datepicker__month-year-read-view--down-arrow,
.react-datepicker__navigation-icon::before {
border-color: var(--light-gray);
border-style: solid;
border-width: 3px 3px 0 0;
content: "";
display: block;
height: 9px;
position: absolute;
top: 6px;
width: 9px;
}
.react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle,
.react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle {
margin-left: -4px;
position: absolute;
width: 0;
}
.react-datepicker-wrapper {
display: inline-block;
padding: 0;
border: 0;
}
.react-datepicker {
font-family: var(--font-sans);
font-size: 0.8rem;
background-color: var(--bg);
color: var(--fg);
border: 1px solid var(--gray);
border-radius: var(--radius);
display: inline-block;
position: relative;
}
.react-datepicker--time-only .react-datepicker__triangle {
left: 35px;
}
.react-datepicker--time-only .react-datepicker__time-container {
border-left: 0;
}
.react-datepicker--time-only .react-datepicker__time,
.react-datepicker--time-only .react-datepicker__time-box {
border-radius: var(--radius);
border-radius: var(--radius);
}
.react-datepicker__triangle {
position: absolute;
left: 50px;
}
.react-datepicker-popper {
z-index: 1;
}
.react-datepicker-popper[data-placement^="bottom"] {
padding-top: 10px;
}
.react-datepicker-popper[data-placement="bottom-end"]
.react-datepicker__triangle,
.react-datepicker-popper[data-placement="top-end"] .react-datepicker__triangle {
left: auto;
right: 50px;
}
.react-datepicker-popper[data-placement^="top"] {
padding-bottom: 10px;
}
.react-datepicker-popper[data-placement^="right"] {
padding-left: 8px;
}
.react-datepicker-popper[data-placement^="right"] .react-datepicker__triangle {
left: auto;
right: 42px;
}
.react-datepicker-popper[data-placement^="left"] {
padding-right: 8px;
}
.react-datepicker-popper[data-placement^="left"] .react-datepicker__triangle {
left: 42px;
right: auto;
}
.react-datepicker__header {
text-align: center;
background-color: var(--bg);
border-bottom: 1px solid var(--gray);
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
padding: 8px 0;
position: relative;
}
.react-datepicker__header--time {
padding-bottom: 8px;
padding-left: 5px;
padding-right: 5px;
}
.react-datepicker__year-dropdown-container--select,
.react-datepicker__month-dropdown-container--select,
.react-datepicker__month-year-dropdown-container--select,
.react-datepicker__year-dropdown-container--scroll,
.react-datepicker__month-dropdown-container--scroll,
.react-datepicker__month-year-dropdown-container--scroll {
display: inline-block;
margin: 0 2px;
}
.react-datepicker__current-month,
.react-datepicker-time__header,
.react-datepicker-year-header {
margin-top: 0;
font-weight: bold;
font-size: 0.944rem;
}
.react-datepicker-time__header {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.react-datepicker__navigation {
align-items: center;
background: none;
display: flex;
justify-content: center;
text-align: center;
cursor: pointer;
position: absolute;
top: 2px;
padding: 0;
border: none;
z-index: 1;
height: 32px;
width: 32px;
text-indent: -999em;
overflow: hidden;
}
.react-datepicker__navigation--previous {
left: 2px;
}
.react-datepicker__navigation--next {
right: 2px;
}
.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) {
right: 85px;
}
.react-datepicker__navigation--years {
position: relative;
top: 0;
display: block;
margin-left: auto;
margin-right: auto;
}
.react-datepicker__navigation--years-previous {
top: 4px;
}
.react-datepicker__navigation--years-upcoming {
top: -4px;
}
.react-datepicker__navigation:hover *::before {
border-color: var(--lighter-gray);
}
.react-datepicker__navigation-icon {
position: relative;
top: -1px;
font-size: 20px;
width: 0;
}
.react-datepicker__navigation-icon--next {
left: -2px;
}
.react-datepicker__navigation-icon--next::before {
transform: rotate(45deg);
left: -7px;
}
.react-datepicker__navigation-icon--previous {
right: -2px;
}
.react-datepicker__navigation-icon--previous::before {
transform: rotate(225deg);
right: -7px;
}
.react-datepicker__month-container {
float: left;
}
.react-datepicker__year {
margin: 0.4rem;
text-align: center;
}
.react-datepicker__year-wrapper {
display: flex;
flex-wrap: wrap;
max-width: 180px;
}
.react-datepicker__year .react-datepicker__year-text {
display: inline-block;
width: 4rem;
margin: 2px;
}
.react-datepicker__month {
margin: 0.4rem;
text-align: center;
}
.react-datepicker__month .react-datepicker__month-text,
.react-datepicker__month .react-datepicker__quarter-text {
display: inline-block;
width: 4rem;
margin: 2px;
}
.react-datepicker__input-time-container {
clear: both;
width: 100%;
float: left;
margin: 5px 0 10px 15px;
text-align: left;
}
.react-datepicker__input-time-container .react-datepicker-time__caption {
display: inline-block;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container {
display: inline-block;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input {
display: inline-block;
margin-left: 10px;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input {
width: auto;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input[type="time"]::-webkit-inner-spin-button,
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input[type="time"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input[type="time"] {
-moz-appearance: textfield;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__delimiter {
margin-left: 5px;
display: inline-block;
}
.react-datepicker__day-names,
.react-datepicker__week {
white-space: nowrap;
}
.react-datepicker__day-names {
margin-bottom: -8px;
}
.react-datepicker__day-name,
.react-datepicker__day,
.react-datepicker__time-name {
color: var(--fg);
display: inline-block;
width: 1.7rem;
line-height: 1.7rem;
text-align: center;
margin: 0.166rem;
}
.react-datepicker__day,
.react-datepicker__month-text,
.react-datepicker__quarter-text,
.react-datepicker__year-text {
cursor: pointer;
}
.react-datepicker__day:hover,
.react-datepicker__month-text:hover,
.react-datepicker__quarter-text:hover,
.react-datepicker__year-text:hover {
border-radius: 0.3rem;
background-color: var(--light-gray);
}
.react-datepicker__day--today,
.react-datepicker__month-text--today,
.react-datepicker__quarter-text--today,
.react-datepicker__year-text--today {
font-weight: bold;
}
.react-datepicker__day--highlighted,
.react-datepicker__month-text--highlighted,
.react-datepicker__quarter-text--highlighted,
.react-datepicker__year-text--highlighted {
border-radius: 0.3rem;
background-color: #3dcc4a;
color: var(--fg);
}
.react-datepicker__day--highlighted:hover,
.react-datepicker__month-text--highlighted:hover,
.react-datepicker__quarter-text--highlighted:hover,
.react-datepicker__year-text--highlighted:hover {
background-color: #32be3f;
}
.react-datepicker__day--selected,
.react-datepicker__day--in-selecting-range,
.react-datepicker__day--in-range,
.react-datepicker__month-text--selected,
.react-datepicker__month-text--in-selecting-range,
.react-datepicker__month-text--in-range,
.react-datepicker__quarter-text--selected,
.react-datepicker__quarter-text--in-selecting-range,
.react-datepicker__quarter-text--in-range,
.react-datepicker__year-text--selected,
.react-datepicker__year-text--in-selecting-range,
.react-datepicker__year-text--in-range {
border-radius: 0.3rem;
background-color: var(--light-gray);
color: var(--fg);
}
.react-datepicker__day--selected:hover {
background-color: var(--gray);
}
.react-datepicker__day--keyboard-selected,
.react-datepicker__month-text--keyboard-selected,
.react-datepicker__quarter-text--keyboard-selected,
.react-datepicker__year-text--keyboard-selected {
border-radius: 0.3rem;
background-color: var(--light-gray);
color: var(--fg);
}
.react-datepicker__day--keyboard-selected:hover {
background-color: var(--gray);
}
.react-datepicker__month--selecting-range
.react-datepicker__day--in-range:not(.react-datepicker__day--in-selecting-range, .react-datepicker__month-text--in-selecting-range, .react-datepicker__quarter-text--in-selecting-range, .react-datepicker__year-text--in-selecting-range) {
background-color: var(--bg);
color: var(--fg);
}
.react-datepicker {
transform: scale(1.15) translateY(-12px);
}
.react-datepicker__day--disabled {
color: var(--darker-gray);
}
.react-datepicker__day--disabled:hover {
background-color: transparent;
cursor: not-allowed;
}

View file

@ -1,7 +1,6 @@
import { ChangeEvent, memo } from "react" import { ChangeEvent, memo } from "react"
import Input from "@components/input" import { Input } from "@components/input"
import styles from "./title.module.css"
const titlePlaceholders = [ const titlePlaceholders = [
"How to...", "How to...",
@ -18,20 +17,17 @@ const placeholder = titlePlaceholders[3]
type props = { type props = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void onChange: (e: ChangeEvent<HTMLInputElement>) => void
title?: string title?: string
className?: string
} }
function Title({ onChange, title }: props) { function Title({ onChange, title, className }: props) {
return ( return (
<div className={styles.title}> <div className={className}>
<h1 style={{ margin: 0, padding: 0 }}>Drift</h1>
<Input <Input
placeholder={placeholder} placeholder={placeholder}
value={title} value={title}
onChange={onChange} onChange={onChange}
label="Title" label="Title"
className={styles.labelAndInput}
style={{ width: "100%" }}
labelClassName={styles.labelAndInput}
/> />
</div> </div>
) )

View file

@ -1,9 +1,17 @@
import { getMetadata } from "src/app/lib/metadata" import { getMetadata } from "src/app/lib/metadata"
import NewPost from "src/app/(drift)/(posts)/new/components/new" import NewPost from "src/app/(drift)/(posts)/new/components/new"
import "./components/react-datepicker.css" import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
export default function New() { export default function New() {
return <NewPost /> return (
<>
<PageTitle>New Post</PageTitle>
<PageWrapper>
<NewPost />
</PageWrapper>
</>
)
} }
export const metadata = getMetadata({ export const metadata = getMetadata({

View file

@ -1,9 +1,8 @@
"use client" "use client"
import Button from "@components/button" import { Button } from "@components/button"
import ButtonGroup from "@components/button-group" import ButtonGroup from "@components/button-group"
import FileDropdown from "src/app/(drift)/(posts)/components/file-dropdown" import FileDropdown from "src/app/(drift)/(posts)/components/file-dropdown"
import { Edit, ArrowUpCircle, Archive } from "react-feather"
import styles from "./post-buttons.module.css" import styles from "./post-buttons.module.css"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { PostWithFiles } from "@lib/server/prisma" import { PostWithFiles } from "@lib/server/prisma"
@ -48,15 +47,19 @@ export const PostButtons = ({
return ( return (
<span className={styles.buttons}> <span className={styles.buttons}>
<ButtonGroup verticalIfMobile> <ButtonGroup verticalIfMobile>
<Button iconLeft={<Edit />} onClick={editACopy}> <Button variant={"secondary"} onClick={editACopy} className="border-r">
Edit a Copy Edit a Copy
</Button> </Button>
{parentId && ( {parentId && (
<Button iconLeft={<ArrowUpCircle />} onClick={viewParentClick}> <Button variant={"secondary"} onClick={viewParentClick}>
View Parent View Parent
</Button> </Button>
)} )}
<Button onClick={downloadClick} iconLeft={<Archive />}> <Button
variant={"secondary"}
onClick={downloadClick}
className="border-r"
>
Download as ZIP Archive Download as ZIP Archive
</Button> </Button>
<FileDropdown loading={loading} files={files || []} /> <FileDropdown loading={loading} files={files || []} />

View file

@ -17,13 +17,9 @@ export const PostTitle = ({ post, loading }: TitleProps) => {
const displayName = author?.displayName const displayName = author?.displayName
return ( return (
<span className={styles.title}> <span className={styles.title}>
<h1 <h1 className="text-3xl font-bold">
style={{
fontSize: "1.175rem"
}}
>
{title}{" "} {title}{" "}
<span style={{ color: "var(--gray)" }}> <span className="text-2xl text-muted-foreground">
by {/* <Link colored href={`/author/${authorId}`}> */} by {/* <Link colored href={`/author/${authorId}`}> */}
{displayName || "anonymous"} {displayName || "anonymous"}
{/* </Link> */} {/* </Link> */}

View file

@ -1,4 +1,4 @@
.card header { /* .card header {
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
@ -18,7 +18,7 @@
border: 1px solid var(--lighter-gray); border: 1px solid var(--lighter-gray);
border-top: none; border-top: none;
border-radius: 0px 0px 8px 8px; border-radius: 0px 0px 8px 8px;
} } */
.textarea { .textarea {
height: 100%; height: 100%;

View file

@ -1,8 +1,8 @@
import Button from "@components/button" import { Button } from "@components/button"
import ButtonGroup from "@components/button-group" import ButtonGroup from "@components/button-group"
import Skeleton from "@components/skeleton" import Skeleton from "@components/skeleton"
import Tooltip from "@components/tooltip" import { Tooltip } from "@components/tooltip"
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs" import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
import Link from "next/link" import Link from "next/link"
import { memo } from "react" import { memo } from "react"
import { Download, ExternalLink, Globe } from "react-feather" import { Download, ExternalLink, Globe } from "react-feather"
@ -10,7 +10,7 @@ import styles from "./document.module.css"
import { getURLFriendlyTitle } from "src/app/lib/get-url-friendly-title" import { getURLFriendlyTitle } from "src/app/lib/get-url-friendly-title"
import { PostWithFiles, ServerPost } from "@lib/server/prisma" import { PostWithFiles, ServerPost } from "@lib/server/prisma"
import { isAllowedVisibilityForWebpage } from "@lib/constants" import { isAllowedVisibilityForWebpage } from "@lib/constants"
import { Card, CardContent, CardHeader } from "@components/card"
type SharedProps = { type SharedProps = {
initialTab: "edit" | "preview" initialTab: "edit" | "preview"
file?: PostWithFiles["files"][0] file?: PostWithFiles["files"][0]
@ -36,38 +36,50 @@ const DownloadButtons = ({
}) => { }) => {
return ( return (
<ButtonGroup> <ButtonGroup>
<Tooltip content="Download"> <Tooltip content="Download" delayDuration={200}>
<Link <Link
href={`${rawLink}?download=true`} href={`${rawLink}?download=true`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<Button <Button
iconRight={<Download color="var(--fg)" />}
aria-label="Download" aria-label="Download"
style={{ border: "none", background: "transparent" }} size="sm"
/> className="bg-transparent border-none"
variant={"ghost"}
>
<Download className="w-4 h-4 " />
<span className="sr-only">Download</span>
</Button>
</Link> </Link>
</Tooltip> </Tooltip>
{rawLink ? ( {rawLink ? (
<Tooltip content="Open raw in new tab"> <Tooltip content="Open raw in new tab" delayDuration={200}>
<Link href={rawLink || ""} target="_blank" rel="noopener noreferrer"> <Link href={rawLink || ""} target="_blank" rel="noopener noreferrer">
<Button <Button
iconLeft={<ExternalLink color="var(--fg)" />}
aria-label="Open raw file in new tab" aria-label="Open raw file in new tab"
style={{ border: "none", background: "transparent" }} className="bg-transparent border-none"
/> size="sm"
variant={"ghost"}
>
<ExternalLink className="w-4 h-4" />
<span className="sr-only">Open raw file in new tab</span>
</Button>
</Link> </Link>
</Tooltip> </Tooltip>
) : null} ) : null}
{siteLink ? ( {siteLink ? (
<Tooltip content="Open as webpage"> <Tooltip content="Open as webpage" delayDuration={200}>
<Link href={siteLink || ""} target="_blank" rel="noopener noreferrer"> <Link href={siteLink || ""} target="_blank" rel="noopener noreferrer">
<Button <Button
iconLeft={<Globe color="var(--fg)" />}
aria-label="Open as webpage" aria-label="Open as webpage"
style={{ border: "none", background: "transparent" }} className="bg-transparent border-none"
/> size="sm"
variant={"ghost"}
>
<Globe className="w-4 h-4" />
<span className="sr-only">Open as webpage</span>
</Button>
</Link> </Link>
</Tooltip> </Tooltip>
) : null} ) : null}
@ -109,18 +121,39 @@ const Document = ({ skeleton, ...props }: Props) => {
} }
} }
} }
/* .card header {
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
height: 40px;
line-height: 40px;
padding: 0 16px;
background: var(--lighter-gray);
border-radius: 8px 8px 0px 0px;
}
.documentContainer {
display: flex;
flex-direction: column;
overflow: auto;
padding: var(--gap);
border: 1px solid var(--lighter-gray);
border-top: none;
border-radius: 0px 0px 8px 8px;
} */
return ( return (
<> <>
<div className={styles.card}> <Card className="border-gray-200 dark:border-gray-900">
<header id={file?.title}> <CardHeader
id={file?.title}
className="flex flex-row items-center justify-between py-1 bg-gray-200 dark:bg-gray-900"
>
<Link <Link
href={`#${file?.title}`} href={`#${file?.title}`}
aria-label="File" aria-label="File"
style={{ // show an # when hovered avia :after
textDecoration: "none", className="text-gray-900 hover:after:ml-1 hover:after:content-[#] dark:text-gray-100"
color: "var(--fg)"
}}
> >
{file?.title} {file?.title}
</Link> </Link>
@ -134,8 +167,8 @@ const Document = ({ skeleton, ...props }: Props) => {
: undefined : undefined
} }
/> />
</header> </CardHeader>
<div className={styles.documentContainer}> <CardContent className="flex flex-col h-full pt-2">
<DocumentTabs <DocumentTabs
defaultTab={props.initialTab} defaultTab={props.initialTab}
staticPreview={file?.html} staticPreview={file?.html}
@ -143,8 +176,8 @@ const Document = ({ skeleton, ...props }: Props) => {
> >
{file?.content || ""} {file?.content || ""}
</DocumentTabs> </DocumentTabs>
</div> </CardContent>
</div> </Card>
</> </>
) )
} }

View file

@ -28,11 +28,7 @@ export default async function PostLayout({
{post.visibility !== "protected" && <PostButtons post={clientPost} />} {post.visibility !== "protected" && <PostButtons post={clientPost} />}
{post.visibility !== "protected" && <PostTitle post={clientPost} />} {post.visibility !== "protected" && <PostTitle post={clientPost} />}
</div> </div>
{post.description && ( {/* {post.description && <p className="pb-4 text-lg">{post.description}</p>} */}
<div>
<p>{post.description}</p>
</div>
)}
<ScrollToTop /> <ScrollToTop />
{children} {children}
</div> </div>

View file

@ -21,11 +21,13 @@ export default async function PostPage({
return ( return (
<> <>
<PostFiles post={clientPost} /> <PostFiles post={clientPost} />
<div className="mx-auto mb-4 mt-4">
<VisibilityControl <VisibilityControl
authorId={post.authorId} authorId={post.authorId}
postId={post.id} postId={post.id}
visibility={post.visibility} visibility={post.visibility}
/> />
</div>
</> </>
) )
} }

View file

@ -1,13 +1,3 @@
.table {
width: 100%;
display: block;
white-space: nowrap;
thead th {
font-weight: bold;
}
}
.id { .id {
width: 130px; width: 130px;
white-space: nowrap; white-space: nowrap;

View file

@ -1,6 +1,6 @@
"use client" "use client"
import Button from "@components/button" import { Button } from "@components/button"
import { Spinner } from "@components/spinner" import { Spinner } from "@components/spinner"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { ServerPostWithFilesAndAuthor, UserWithPosts } from "@lib/server/prisma" import { ServerPostWithFilesAndAuthor, UserWithPosts } from "@lib/server/prisma"
@ -18,7 +18,7 @@ export function UserTable({
id: string id: string
email: string | null email: string | null
role: string | null role: string | null
displayName: string | null username: string | null
}[] }[]
}) { }) {
const { setToast } = useToasts() const { setToast } = useToasts()
@ -26,6 +26,14 @@ export function UserTable({
const deleteUser = async (id: string) => { const deleteUser = async (id: string) => {
try { try {
const confirmed = confirm("Are you sure you want to delete this user?")
if (!confirmed) {
setToast({
message: "User not deleted",
type: "default"
})
return
}
const res = await fetchWithUser("/api/admin?action=delete-user", { const res = await fetchWithUser("/api/admin?action=delete-user", {
method: "DELETE", method: "DELETE",
headers: { headers: {
@ -53,8 +61,8 @@ export function UserTable({
} }
return ( return (
<table className={styles.table}> <table className="w-full overflow-x-auto">
<thead> <thead className="text-left">
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Email</th> <th>Email</th>
@ -73,14 +81,20 @@ export function UserTable({
) : null} ) : null}
{users?.map((user) => ( {users?.map((user) => (
<tr key={user.id}> <tr key={user.id}>
<td>{user.displayName ? user.displayName : "no name"}</td> <td>{user.username ? user.username : "no name"}</td>
<td>{user.email}</td> <td>{user.email}</td>
<td>{user.role}</td> <td>{user.role}</td>
<td className={styles.id} title={user.id}> <td className={styles.id} title={user.id}>
{user.id} {user.id}
</td> </td>
<td> <td>
<Button onClick={() => deleteUser(user.id)}>Delete</Button> <Button
variant={"destructive"}
onClick={() => deleteUser(user.id)}
size={"sm"}
>
Delete
</Button>
</td> </td>
</tr> </tr>
))} ))}
@ -90,7 +104,7 @@ export function UserTable({
} }
export function PostTable({ export function PostTable({
posts posts: initialPosts
}: { }: {
posts?: { posts?: {
createdAt: string createdAt: string
@ -100,15 +114,54 @@ export function PostTable({
visibility: string visibility: string
}[] }[]
}) { }) {
const [posts, setPosts] = useState<typeof initialPosts>(initialPosts)
const { setToast } = useToasts()
const deletePost = async (id: string) => {
try {
const confirmed = confirm("Are you sure you want to delete this post?")
if (!confirmed) {
setToast({
message: "Post not deleted",
type: "default"
})
return
}
const res = await fetchWithUser("/api/admin?action=delete-post", {
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
postId: id
})
})
if (res.status === 200) {
setToast({
message: "Post deleted",
type: "success"
})
setPosts(posts?.filter((post) => post.id !== id))
}
} catch (err) {
console.error(err)
setToast({
message: "Error deleting user",
type: "error"
})
}
}
return ( return (
<table className={styles.table}> <table className="w-full overflow-x-auto">
<thead> <thead className="text-left">
<tr> <tr>
<th>Title</th> <th>Title</th>
<th>Author</th> <th>Author</th>
<th>Created</th> <th>Created</th>
<th>Visibility</th> <th>Visibility</th>
<th className={styles.id}>Post ID</th> <th className={styles.id}>Post ID</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -130,6 +183,15 @@ export function PostTable({
<td>{new Date(post.createdAt).toLocaleDateString()}</td> <td>{new Date(post.createdAt).toLocaleDateString()}</td>
<td>{post.visibility}</td> <td>{post.visibility}</td>
<td>{post.id}</td> <td>{post.id}</td>
<td>
<Button
variant={"destructive"}
size={"sm"}
onClick={() => deletePost(post.id)}
>
Delete
</Button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View file

@ -1,12 +1,13 @@
import { TypographyH1, TypographyH2 } from "@components/typography"
import { PostTable, UserTable } from "./components/tables" import { PostTable, UserTable } from "./components/tables"
export default function AdminLoading() { export default function AdminLoading() {
return ( return (
<div> <div>
<h1>Admin</h1> <TypographyH1>Admin</TypographyH1>
<h2>Users</h2> <TypographyH2>Users</TypographyH2>
<UserTable /> <UserTable />
<h2>Posts</h2> <TypographyH2>Posts</TypographyH2>
<PostTable /> <PostTable />
</div> </div>
) )

View file

@ -6,15 +6,20 @@ import {
ServerPostWithFiles ServerPostWithFiles
} from "@lib/server/prisma" } from "@lib/server/prisma"
import { PostTable, UserTable } from "./components/tables" import { PostTable, UserTable } from "./components/tables"
import { PageWrapper } from "@components/page-wrapper"
import { PageTitle } from "@components/page-title"
export default async function AdminPage() { export default async function AdminPage() {
const usersPromise = getAllUsers({ const usersPromise = getAllUsers({
select: { select: {
id: true, id: true,
name: true, createdAt: true,
createdAt: true email: true,
role: true,
username: true
} }
}) })
const postsPromise = getAllPosts({ const postsPromise = getAllPosts({
select: { select: {
id: true, id: true,
@ -43,13 +48,15 @@ export default async function AdminPage() {
}) })
return ( return (
<div> <>
<h1>Admin</h1> <PageTitle>Admin</PageTitle>
<h2>Users</h2> <PageWrapper>
<h2 className="mb-4 mt-4 text-2xl font-bold">Users</h2>
{/* @ts-expect-error Type 'unknown' is not assignable to type */} {/* @ts-expect-error Type 'unknown' is not assignable to type */}
<UserTable users={serializedUsers as unknown} /> <UserTable users={serializedUsers as unknown} />
<h2>Posts</h2> <h2 className="mb-4 mt-4 text-2xl font-bold">Posts</h2>
<PostTable posts={serializedPosts} /> <PostTable posts={serializedPosts} />
</div> </PageWrapper>
</>
) )
} }

View file

@ -1,4 +1,5 @@
import PostList from "@components/post-list" import PostList from "@components/post-list"
import { TypographyH1 } from "@components/typography"
import { import {
getPostsByUser, getPostsByUser,
getUserById, getUserById,
@ -59,7 +60,9 @@ export default async function UserPage({
justifyContent: "space-between" justifyContent: "space-between"
}} }}
> >
<h1>Public posts by {user?.displayName || "Anonymous"}</h1> <TypographyH1>
Public posts by {user?.displayName || "Anonymous"}
</TypographyH1>
<Avatar /> <Avatar />
</div> </div>
<Suspense fallback={<PostList hideSearch skeleton initialPosts={[]} />}> <Suspense fallback={<PostList hideSearch skeleton initialPosts={[]} />}>

View file

@ -6,6 +6,8 @@ import Header from "@components/header"
import { Inter } from "next/font/google" import { Inter } from "next/font/google"
import { getMetadata } from "src/app/lib/metadata" import { getMetadata } from "src/app/lib/metadata"
import dynamic from "next/dynamic" import dynamic from "next/dynamic"
import clsx from "clsx"
import Link from "@components/link"
const inter = Inter({ subsets: ["latin"], variable: "--inter-font" }) const inter = Inter({ subsets: ["latin"], variable: "--inter-font" })
const CmdK = dynamic(() => import("@components/cmdk"), { ssr: false }) const CmdK = dynamic(() => import("@components/cmdk"), { ssr: false })
@ -17,14 +19,18 @@ 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={clsx(inter.variable, "mx-auto w-[var(--main-content)]")}
suppressHydrationWarning
>
<body> <body>
<Toasts /> <Toasts />
<Providers> <Providers>
<Layout> <Layout>
<CmdK /> <CmdK />
<Header /> <Header />
{children} <main>{children}</main>
</Layout> </Layout>
</Providers> </Providers>
</body> </body>

View file

@ -1,7 +1,15 @@
"use client" "use client"
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
import PostList from "@components/post-list" import PostList from "@components/post-list"
export default function Loading() { export default function Loading() {
return <PostList skeleton={true} initialPosts={[]} /> return (
<>
<PageTitle>Your Posts</PageTitle>
<PageWrapper></PageWrapper>
<PostList skeleton={true} initialPosts={[]} />
</>
)
} }

View file

@ -5,6 +5,8 @@ import { Suspense } from "react"
import ErrorBoundary from "@components/error/fallback" import ErrorBoundary from "@components/error/fallback"
import { getMetadata } from "src/app/lib/metadata" import { getMetadata } from "src/app/lib/metadata"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
export default async function Mine() { export default async function Mine() {
const userId = (await getCurrentUser())?.id const userId = (await getCurrentUser())?.id
@ -16,6 +18,9 @@ export default async function Mine() {
const posts = (await getPostsByUser(userId, true)).map(serverPostToClientPost) const posts = (await getPostsByUser(userId, true)).map(serverPostToClientPost)
return ( return (
<>
<PageTitle>Your Posts</PageTitle>
<PageWrapper>
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}> <Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}>
<PostList <PostList
@ -26,6 +31,8 @@ export default async function Mine() {
/> />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</PageWrapper>
</>
) )
} }

View file

@ -1,7 +1,5 @@
import Image from "next/image" import { Card, CardContent } from "@components/card"
import Card from "@components/card"
import { getWelcomeContent } from "src/pages/api/welcome" import { getWelcomeContent } from "src/pages/api/welcome"
import DocumentTabs from "./(posts)/components/tabs"
import { import {
getAllPosts, getAllPosts,
serverPostToClientPost, serverPostToClientPost,
@ -10,8 +8,8 @@ import {
import PostList, { NoPostsFound } from "@components/post-list" import PostList, { NoPostsFound } from "@components/post-list"
import { cache, Suspense } from "react" import { cache, Suspense } from "react"
import ErrorBoundary from "@components/error/fallback" import ErrorBoundary from "@components/error/fallback"
import { Stack } from "@components/stack" import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
import { PageWrapper } from "@components/page-wrapper"
export const revalidate = 300 export const revalidate = 300
const getWelcomeData = cache(async () => { const getWelcomeData = cache(async () => {
@ -20,23 +18,11 @@ const getWelcomeData = cache(async () => {
}) })
export default async function Page() { export default async function Page() {
const { title } = await getWelcomeData()
return ( return (
<Stack direction="column"> <PageWrapper>
<Stack direction="row" alignItems="center">
<Image
src={"/assets/logo.svg"}
width={48}
height={48}
alt=""
priority
/>
<h1 style={{ marginLeft: "var(--gap)" }}>{title}</h1>
</Stack>
{/* @ts-expect-error because of async RSC */} {/* @ts-expect-error because of async RSC */}
<WelcomePost /> <WelcomePost />
<h2>Recent public posts</h2> <h2 className="mt-4 text-2xl font-bold">Recent Public Posts</h2>
<ErrorBoundary> <ErrorBoundary>
<Suspense <Suspense
fallback={ fallback={
@ -47,14 +33,15 @@ export default async function Page() {
<PublicPostList /> <PublicPostList />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
</Stack> </PageWrapper>
) )
} }
async function WelcomePost() { async function WelcomePost() {
const { content, rendered, title } = await getWelcomeData() const { content, rendered, title } = await getWelcomeData()
return ( return (
<Card> <Card className="w-full">
<CardContent>
<DocumentTabs <DocumentTabs
defaultTab="preview" defaultTab="preview"
isEditing={false} isEditing={false}
@ -63,6 +50,7 @@ async function WelcomePost() {
> >
{content} {content}
</DocumentTabs> </DocumentTabs>
</CardContent>
</Card> </Card>
) )
} }
@ -79,6 +67,7 @@ async function PublicPostList() {
} }
}, },
visibility: true, visibility: true,
expiresAt: true,
files: { files: {
select: { select: {
id: true, id: true,

View file

@ -10,7 +10,12 @@ export function Providers({ children }: PropsWithChildren<unknown>) {
return ( return (
<SessionProvider> <SessionProvider>
<RadixTooltip.Provider delayDuration={200}> <RadixTooltip.Provider delayDuration={200}>
<ThemeProvider enableSystem defaultTheme="dark"> <ThemeProvider
attribute="class"
enableColorScheme
enableSystem
defaultTheme="dark"
>
<SWRProvider>{children}</SWRProvider> <SWRProvider>{children}</SWRProvider>
</ThemeProvider> </ThemeProvider>
</RadixTooltip.Provider> </RadixTooltip.Provider>

View file

@ -1,7 +1,7 @@
"use client" "use client"
import Button from "@components/button" import { Button } from "@components/button"
import Input from "@components/input" import { Input } from "@components/input"
import Note from "@components/note" import Note from "@components/note"
import { Spinner } from "@components/spinner" import { Spinner } from "@components/spinner"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
@ -13,6 +13,7 @@ import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
import { useState } from "react" import { useState } from "react"
import styles from "./api-keys.module.css" import styles from "./api-keys.module.css"
import { useSessionSWR } from "@lib/use-session-swr" import { useSessionSWR } from "@lib/use-session-swr"
import { TypographyH4 } from "@components/typography"
// need to pass in the accessToken // need to pass in the accessToken
const APIKeys = ({ const APIKeys = ({
@ -75,7 +76,7 @@ const APIKeys = ({
)} )}
{hasError && <Note type="error">{error?.message}</Note>} {hasError && <Note type="error">{error?.message}</Note>}
<form className={styles.form}> <form className={styles.form}>
<h5>Create new</h5> <TypographyH4>Create new</TypographyH4>
<fieldset className={styles.fieldset}> <fieldset className={styles.fieldset}>
<Input <Input
type="text" type="text"
@ -85,10 +86,9 @@ const APIKeys = ({
placeholder="Name" placeholder="Name"
/> />
<Button <Button
type="button"
onClick={onCreateTokenClick} onClick={onCreateTokenClick}
loading={submitting}
disabled={!newToken} disabled={!newToken}
loading={submitting}
> >
Submit Submit
</Button> </Button>
@ -121,7 +121,9 @@ const APIKeys = ({
</tbody> </tbody>
</table> </table>
) : ( ) : (
<p>You have no API keys.</p> <p className="p-4 text-center text-muted-foreground">
No API keys found.
</p>
) )
) : ( ) : (
<div style={{ marginTop: "var(--gap-quarter)" }}> <div style={{ marginTop: "var(--gap-quarter)" }}>

View file

@ -1,7 +1,7 @@
"use client" "use client"
import Button from "@components/button" import { Button } from "@components/button"
import Input from "@components/input" import { Input } from "@components/input"
import Note from "@components/note" import Note from "@components/note"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { useSessionSWR } from "@lib/use-session-swr" import { useSessionSWR } from "@lib/use-session-swr"
@ -142,8 +142,7 @@ function Profile() {
</div> </div>
</TooltipComponent> </TooltipComponent>
</div> */} </div> */}
<Button type="submit" disabled={!name} loading={submitting}>
<Button type="submit" loading={submitting}>
Submit Submit
</Button> </Button>
</form> </form>

View file

@ -1,22 +0,0 @@
export default function SettingsLayout({
children
}: {
children: React.ReactNode
}) {
return (
<>
<h1>Settings</h1>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--gap)",
marginBottom: "var(--gap)",
marginTop: "var(--gap)"
}}
>
{children}
</div>
</>
)
}

View file

@ -1,5 +1,15 @@
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
import SettingsGroup from "@components/settings-group" import SettingsGroup from "@components/settings-group"
export default function SettingsLoading() { export default function SettingsLoading() {
return <SettingsGroup skeleton /> return (
<>
<PageTitle>Settings</PageTitle>
<PageWrapper>
<SettingsGroup skeleton />
<SettingsGroup skeleton />
</PageWrapper>
</>
)
} }

View file

@ -2,16 +2,21 @@ import { getMetadata } from "src/app/lib/metadata"
import SettingsGroup from "../../components/settings-group" import SettingsGroup from "../../components/settings-group"
import APIKeys from "./components/sections/api-keys" import APIKeys from "./components/sections/api-keys"
import Profile from "./components/sections/profile" import Profile from "./components/sections/profile"
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
export default async function SettingsPage() { export default async function SettingsPage() {
return ( return (
<> <>
<PageTitle>Settings</PageTitle>
<PageWrapper>
<SettingsGroup title="Profile"> <SettingsGroup title="Profile">
<Profile /> <Profile />
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="API Keys"> <SettingsGroup title="API Keys">
<APIKeys /> <APIKeys />
</SettingsGroup> </SettingsGroup>
</PageWrapper>
</> </>
) )
} }

View file

@ -0,0 +1,6 @@
import { authOptions } from "@lib/server/auth"
import NextAuth from "next-auth"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View file

@ -0,0 +1,41 @@
import config from "@lib/config"
import { NextRequest } from "next/server"
export const getRequiresPasscode = async () => {
const requiresPasscode = Boolean(config.registration_password)
return requiresPasscode
}
export default async function GET(req: NextRequest) {
const searchParams = new URL(req.nextUrl).searchParams
const slug = searchParams.get("slug")
if (!slug || Array.isArray(slug)) {
return new Response(null, {
status: 400,
statusText: "Bad request"
})
}
if (slug === "requires-passcode") {
// return res.json({ requiresPasscode: await getRequiresPasscode() })
return new Response(
JSON.stringify({ requiresPasscode: await getRequiresPasscode() }),
{
status: 200,
statusText: "OK",
headers: {
"Content-Type": "application/json"
}
}
)
}
return new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
statusText: "Not found",
headers: {
"Content-Type": "application/json"
}
})
}

View file

@ -0,0 +1,150 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@lib/cn"
import { buttonVariants } from "@components/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = ({
className,
children,
...props
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
{children}
</div>
</AlertDialogPrimitive.Portal>
)
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-opacity animate-in fade-in",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel
}

View file

@ -1,21 +1,35 @@
import React from "react" import * as React from "react"
import styles from "./badge.module.css" import { cva, type VariantProps } from "class-variance-authority"
type BadgeProps = { import { cn } from "@lib/cn"
type: "primary" | "secondary" | "error" | "warning"
} & React.HTMLAttributes<HTMLDivElement>
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>( const badgeVariants = cva(
({ type, children, ...rest }: BadgeProps, ref) => { "inline-flex items-center border rounded-full font-medium px-2.5 py-0.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
return ( {
<div className={styles.container} {...rest}> variants: {
<div className={`${styles.badge} ${styles[type]}`} ref={ref}> variant: {
{children} default:
</div> "bg-primary hover:bg-primary/80 border-transparent text-primary-foreground",
</div> secondary:
) "bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground",
destructive:
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
outline: "text-foreground"
}
},
defaultVariants: {
variant: "default"
}
} }
) )
Badge.displayName = "Badge" export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export default Badge function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View file

@ -1,10 +1,10 @@
"use client" "use client"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import Tooltip from "@components/tooltip" import { Tooltip } from "@components/tooltip"
import { copyToClipboard } from "src/app/lib/copy-to-clipboard" import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
import { timeAgo } from "src/app/lib/time-ago" import { timeAgo } from "src/app/lib/time-ago"
import { useMemo, useState, useEffect } from "react" import { useMemo, useState, useEffect } from "react"
import Badge from "../badge" import { Badge } from "../badge"
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => { const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
const createdDate = useMemo(() => new Date(createdAt), [createdAt]) const createdDate = useMemo(() => new Date(createdAt), [createdAt])
@ -30,7 +30,7 @@ const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
return ( return (
// TODO: investigate tooltip not showing // TODO: investigate tooltip not showing
<Tooltip content={formattedTime}> <Tooltip content={formattedTime}>
<Badge type="secondary" onClick={onClick}> <Badge onClick={onClick} variant={"outline"} suppressHydrationWarning>
{" "} {" "}
<>{time}</> <>{time}</>
</Badge> </Badge>

View file

@ -1,9 +1,9 @@
"use client" "use client"
import Tooltip from "@components/tooltip" import { Tooltip } from "@components/tooltip"
import { timeUntil } from "src/app/lib/time-ago" import { timeUntil } from "src/app/lib/time-ago"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Badge from "../badge" import { Badge } from "../badge"
const ExpirationBadge = ({ const ExpirationBadge = ({
postExpirationDate postExpirationDate
@ -43,7 +43,7 @@ const ExpirationBadge = ({
const isExpired = expirationDate < new Date() const isExpired = expirationDate < new Date()
return ( return (
<Badge type={isExpired ? "error" : "warning"}> <Badge variant={isExpired ? "destructive" : "outline"}>
<Tooltip <Tooltip
content={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`} content={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}
> >

View file

@ -1,11 +1,11 @@
import Badge from "../badge" import { Badge } from "../badge"
type Props = { type Props = {
visibility: string visibility: string
} }
const VisibilityBadge = ({ visibility }: Props) => { const VisibilityBadge = ({ visibility }: Props) => {
return <Badge type={"primary"}>{visibility}</Badge> return <Badge variant={"outline"}>{visibility}</Badge>
} }
export default VisibilityBadge export default VisibilityBadge

View file

@ -3,9 +3,8 @@
import PasswordModal from "@components/password-modal" import PasswordModal from "@components/password-modal"
import { useCallback, useState } from "react" import { useCallback, useState } from "react"
import ButtonGroup from "@components/button-group" import ButtonGroup from "@components/button-group"
import Button from "@components/button" import { Button } from "@components/button"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { Spinner } from "@components/spinner"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useSessionSWR } from "@lib/use-session-swr" import { useSessionSWR } from "@lib/use-session-swr"
import { fetchWithUser } from "src/app/lib/fetch-with-user" import { fetchWithUser } from "src/app/lib/fetch-with-user"
@ -89,39 +88,40 @@ function VisibilityControl({
} }
return ( return (
<FadeIn> <FadeIn className="mt-8">
<ButtonGroup <ButtonGroup>
style={{
maxWidth: 600,
margin: "var(--gap) auto"
}}
>
<Button <Button
disabled={visibility === "private"} disabled={visibility === "private"}
variant={"outline"}
onClick={() => onSubmit("private")} onClick={() => onSubmit("private")}
loading={isSubmitting === "private"}
> >
{isSubmitting === "private" ? <Spinner /> : "Make Private"} Make Private
</Button> </Button>
<Button <Button
disabled={visibility === "public"} disabled={visibility === "public"}
variant={"outline"}
onClick={() => onSubmit("public")} onClick={() => onSubmit("public")}
loading={isSubmitting === "public"}
> >
{isSubmitting === "public" ? <Spinner /> : "Make Public"} Make Public
</Button> </Button>
<Button <Button
disabled={visibility === "unlisted"} disabled={visibility === "unlisted"}
variant={"outline"}
onClick={() => onSubmit("unlisted")} onClick={() => onSubmit("unlisted")}
loading={isSubmitting === "unlisted"}
> >
{isSubmitting === "unlisted" ? <Spinner /> : "Make Unlisted"} Make Unlisted
</Button> </Button>
<Button onClick={() => onSubmit("protected")}> <Button
{isSubmitting === "protected" ? ( onClick={() => onSubmit("protected")}
<Spinner /> variant={"outline"}
) : visibility === "protected" ? ( loading={isSubmitting === "protected"}
"Change Password" >
) : ( {visibility === "protected"
"Protect with Password" ? "Change Password"
)} : "Protect with Password"}
</Button> </Button>
</ButtonGroup> </ButtonGroup>
<PasswordModal <PasswordModal

View file

@ -1,52 +1,44 @@
import Button from "@components/button" import { Button } from "@components/button"
import React, { ReactNode } from "react" import React, { ComponentProps, ReactNode } from "react"
import styles from "./dropdown.module.css" import styles from "./dropdown.module.css"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu" import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuPortal,
DropdownMenuContent,
DropdownMenuItem
} from "@components/dropdown-menu"
import { ArrowDown } from "react-feather" import { ArrowDown } from "react-feather"
type Props = {
type?: "primary" | "secondary"
height?: number | string
}
type Attrs = Omit<React.HTMLAttributes<HTMLDivElement>, keyof Props> type ButtonDropdownProps = ComponentProps<typeof DropdownMenu>
type ButtonDropdownProps = Props & Attrs
const ButtonDropdown: React.FC< const ButtonDropdown: React.FC<React.PropsWithChildren<ButtonDropdownProps>> = (
React.PropsWithChildren<ButtonDropdownProps> props
> = ({ type, ...props }) => { ) => {
return ( return (
<DropdownMenu.Root> <DropdownMenu>
<div className={styles.dropdown} style={{ height: props.height }}> <div className={styles.dropdown}>
<> <>
{Array.isArray(props.children) ? props.children[0] : props.children} {Array.isArray(props.children) ? props.children[0] : props.children}
<DropdownMenu.Trigger <DropdownMenuTrigger asChild>
style={{ <Button>
display: "flex", <ArrowDown height={20} />
flexDirection: "row", </Button>
justifyContent: "flex-end" </DropdownMenuTrigger>
}}
asChild
>
<Button
iconLeft={<ArrowDown />}
buttonType={type}
className={styles.icon}
/>
</DropdownMenu.Trigger>
{Array.isArray(props.children) ? ( {Array.isArray(props.children) ? (
<DropdownMenu.Portal> <DropdownMenuPortal>
<DropdownMenu.Content align="end"> <DropdownMenuContent align="end">
{(props.children as ReactNode[]) {(props.children as ReactNode[])
?.slice(1) ?.slice(1)
.map((child, index) => ( .map((child, index) => (
<DropdownMenu.Item key={index}>{child}</DropdownMenu.Item> <DropdownMenuItem key={index}>{child}</DropdownMenuItem>
))} ))}
</DropdownMenu.Content> </DropdownMenuContent>
</DropdownMenu.Portal> </DropdownMenuPortal>
) : null} ) : null}
</> </>
</div> </div>
</DropdownMenu.Root> </DropdownMenu>
) )
} }

View file

@ -3,6 +3,7 @@
flex-direction: row; flex-direction: row;
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: flex-start; justify-content: flex-start;
margin-top: 0 !important;
} }
.button-group > * { .button-group > * {

View file

@ -1,56 +0,0 @@
.button {
user-select: none;
cursor: pointer;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.button:hover,
.button:focus {
color: var(--fg);
background: var(--bg);
border: 1px solid var(--darker-gray);
}
.button[disabled] {
cursor: not-allowed;
background: var(--lighter-gray);
color: var(--gray);
}
.button[disabled]:hover,
.button[disabled]:focus {
border: 1px solid currentColor;
}
.secondary {
background: var(--bg);
color: var(--fg);
}
.primary {
background: var(--fg);
color: var(--bg);
}
.icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: middle;
}
.iconRight {
margin-left: var(--gap-half);
}
.iconLeft {
margin-right: var(--gap-half);
}
.icon svg {
display: block;
width: 100%;
height: 100%;
transform: scale(1.2) translateY(-0.05em);
}

View file

@ -1,86 +1,65 @@
import styles from "./button.module.css" import * as React from "react"
import { forwardRef } from "react" import { Slot } from "@radix-ui/react-slot"
import clsx from "clsx" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/cn"
import { Spinner } from "@components/spinner" import { Spinner } from "@components/spinner"
type Props = React.DetailedHTMLProps< const buttonVariants = cva(
React.ButtonHTMLAttributes<HTMLButtonElement>, "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-75 disabled:pointer-events-none ring-offset-background",
HTMLButtonElement {
> & { variants: {
children?: React.ReactNode variant: {
buttonType?: "primary" | "secondary" default: "bg-primary/80 text-primary-foreground/80 hover:bg-primary/70",
className?: string destructive:
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void "bg-destructive text-destructive-foreground hover:bg-destructive/90",
iconRight?: React.ReactNode outline:
iconLeft?: React.ReactNode "border border-input hover:bg-accent hover:text-accent-foreground",
height?: string | number secondary:
width?: string | number "bg-secondary text-secondary-foreground hover:bg-secondary/80",
padding?: string | number ghost: "hover:bg-accent hover:text-accent-foreground",
margin?: string | number link: "underline-offset-4 hover:underline text-primary"
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3 rounded-md",
lg: "h-11 px-8 rounded-md"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean loading?: boolean
} }
// eslint-disable-next-line react/display-name const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Button = forwardRef<HTMLButtonElement, Props>(
( (
{ { className, variant, size, loading, children, asChild = false, ...props },
children,
onClick,
className,
buttonType = "secondary",
disabled = false,
iconRight,
iconLeft,
height = 40,
width,
padding = 10,
margin,
loading,
style,
...props
},
ref ref
) => { ) => {
const Comp = asChild ? Slot : "button"
return ( return (
<button <Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
className={clsx(styles.button, className, {
[styles.primary]: buttonType === "primary",
[styles.secondary]: buttonType === "secondary"
})}
disabled={disabled || loading}
onClick={onClick}
style={{ height, width, margin, padding, ...style }}
{...props} {...props}
> >
{children && iconLeft && ( {loading ? <Spinner className="mr-2" /> : null}
<span className={clsx(styles.icon, styles.iconLeft)}>{iconLeft}</span> {children}
)} </Comp>
{!loading &&
(children ? (
children
) : (
<span className={styles.icon}>{iconLeft || iconRight}</span>
))}
{loading && (
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
>
<Spinner />
</span>
)}
{children && iconRight && (
<span className={clsx(styles.icon, styles.iconRight)}>
{iconRight}
</span>
)}
</button>
) )
} }
) )
export default Button Button.displayName = "Button"
export { Button, buttonVariants }

View file

@ -0,0 +1,64 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "react-feather"
import { DayPicker } from "react-day-picker"
import { cn } from "@lib/cn"
import { buttonVariants } from "@components/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "text-muted-foreground opacity-50",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames
}}
components={{
IconLeft: ({}) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({}) => <ChevronRight className="h-4 w-4" />
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View file

@ -1,16 +1,78 @@
import styles from "./card.module.css" import * as React from "react"
import { cn } from "@lib/cn"
export default function Card({ const Card = React.forwardRef<
children, HTMLDivElement,
className, React.HTMLAttributes<HTMLDivElement>
...props >(({ className, ...props }, ref) => (
}: { <div
children?: React.ReactNode ref={ref}
className?: string className={cn(
} & React.ComponentProps<"div">) { "rounded-lg border bg-card text-card-foreground shadow-sm",
return ( className
<div className={`${styles.card} ${className || ""}`} {...props}> )}
<div className={styles.content}>{children}</div> {...props}
</div> />
) ))
} Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(" flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View file

@ -1,165 +0,0 @@
/** 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,163 @@
"use client"
import * as React from "react"
import { DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "react-feather"
import { cn } from "@lib/cn"
import { Dialog, DialogContent } from "@components/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
type CommandDialogProps = DialogProps
const CommandDialog = React.forwardRef<
React.ElementRef<typeof Dialog>,
CommandDialogProps
>(({ children, ...props }, ref) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-2xl">
<Command
ref={ref}
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
{children}
</Command>
</DialogContent>
</Dialog>
)
})
CommandDialog.displayName = Dialog.displayName
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator
}

View file

@ -1,16 +1,3 @@
body [cmdk-dialog] { 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

@ -1,8 +1,7 @@
"use client" "use client"
import { Command } from "cmdk" import { CommandDialog, CommandList, CommandInput, CommandEmpty } from "./cmdk"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import styles from "./cmdk.module.css"
import "./dialog.css" import "./dialog.css"
import HomePage from "./pages/home" import HomePage from "./pages/home"
import PostsPage from "./pages/posts" import PostsPage from "./pages/posts"
@ -10,7 +9,7 @@ import PostsPage from "./pages/posts"
export type CmdKPage = "home" | "posts" export type CmdKPage = "home" | "posts"
export default function CmdK() { export default function CmdK() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement | null>(null) const ref = useRef<HTMLDivElement>()
const [page, setPage] = useState<CmdKPage>("home") const [page, setPage] = useState<CmdKPage>("home")
// Toggle the menu when ⌘K is pressed // Toggle the menu when ⌘K is pressed
@ -53,21 +52,15 @@ export default function CmdK() {
}, [page]) }, [page])
return ( return (
<Command.Dialog <CommandDialog open={open} onOpenChange={setOpen}>
open={open} <CommandList>
onOpenChange={setOpen} <CommandEmpty>No results found.</CommandEmpty>
label="Global Command Menu"
className={styles.cmdk}
ref={ref}
>
<Command.List>
<Command.Empty>No results found.</Command.Empty>
{page === "home" ? ( {page === "home" ? (
<HomePage setPage={setPage} setOpen={setOpen} /> <HomePage setPage={setPage} setOpen={setOpen} />
) : null} ) : null}
{page === "posts" ? <PostsPage setOpen={setOpen} /> : null} {page === "posts" ? <PostsPage setOpen={setOpen} /> : null}
</Command.List> </CommandList>
<Command.Input /> <CommandInput />
</Command.Dialog> </CommandDialog>
) )
} }

View file

@ -1,4 +1,4 @@
import { Command } from "cmdk" import { CommandItem } from "@components/cmdk/cmdk"
export default function Item({ export default function Item({
children, children,
@ -12,7 +12,7 @@ export default function Item({
icon: React.ReactNode icon: React.ReactNode
}): JSX.Element { }): JSX.Element {
return ( return (
<Command.Item onSelect={onSelect}> <CommandItem onSelect={onSelect}>
{icon} {icon}
{children} {children}
{shortcut ? ( {shortcut ? (
@ -22,6 +22,6 @@ export default function Item({
})} })}
</div> </div>
) : null} ) : null}
</Command.Item> </CommandItem>
) )
} }

View file

@ -1,9 +1,9 @@
import { Command } from "cmdk"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { FilePlus, Moon, Search, Settings, Sun } from "react-feather" import { FilePlus, Moon, Search, Settings, Sun } from "react-feather"
import { CmdKPage } from ".." import { CmdKPage } from ".."
import Item from "../item" import Item from "../item"
import { CommandGroup } from "@components/cmdk/cmdk"
export default function HomePage({ export default function HomePage({
setOpen, setOpen,
@ -16,7 +16,7 @@ export default function HomePage({
const { setTheme, resolvedTheme } = useTheme() const { setTheme, resolvedTheme } = useTheme()
return ( return (
<> <>
<Command.Group heading="Posts"> <CommandGroup heading="Posts">
<Item <Item
shortcut="R P" shortcut="R P"
onSelect={() => { onSelect={() => {
@ -36,8 +36,8 @@ export default function HomePage({
> >
New Post New Post
</Item> </Item>
</Command.Group> </CommandGroup>
<Command.Group heading="Settings"> <CommandGroup heading="Settings">
<Item <Item
shortcut="T" shortcut="T"
onSelect={() => { onSelect={() => {
@ -57,7 +57,7 @@ export default function HomePage({
> >
Go to Settings Go to Settings
</Item> </Item>
</Command.Group> </CommandGroup>
</> </>
) )
} }

View file

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import { format } from "date-fns"
import { Calendar as CalendarIcon } from "react-feather"
import { cn } from "@lib/cn"
import { Button } from "@components/button"
import { Calendar } from "@components/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@components/popover"
export function DatePicker({
expiresAt,
setExpiresAt
}: {
expiresAt?: Date
setExpiresAt: React.Dispatch<React.SetStateAction<Date | undefined>>
}) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-[280px] justify-start text-left font-normal",
!expiresAt && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{expiresAt ? (
format(expiresAt, "PPP")
) : (
<span>Won&apos;t expire</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={expiresAt}
onSelect={(date) => {
setExpiresAt(date)
}}
initialFocus
fromDate={new Date()}
/>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,128 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "react-feather"
import { cn } from "@lib/cn"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = ({
className,
children,
...props
}: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal className={cn(className)} {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{children}
</div>
</DialogPrimitive.Portal>
)
DialogPortal.displayName = DialogPrimitive.Portal.displayName
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background p-6 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}

View file

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "react-feather"
import { cn } from "@lib/cn"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup
}

View file

@ -1,8 +1,9 @@
"use client" "use client"
import Button from "@components/button" import { Button } from "@components/button"
import Link from "@components/link" import Link from "@components/link"
import Note from "@components/note" import Note from "@components/note"
import { TypographyH3 } from "@components/typography"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
// an error fallback for react-error-boundary // an error fallback for react-error-boundary
@ -15,9 +16,12 @@ import {
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return ( return (
<Note type="error" style={{ width: "100%" }}> <Note type="error" style={{ width: "100%" }}>
<h3>Something went wrong:</h3> <TypographyH3>Something went wrong:</TypographyH3>
<pre>{error.message}</pre> <pre>{error.message}</pre>
<Link href="https://github.com/MaxLeiter/Drift/issues/new"> <Link
href="https://github.com/MaxLeiter/Drift/issues/new"
className="mr-2"
>
<Button>Report an issue</Button> <Button>Report an issue</Button>
</Link> </Link>
<Button onClick={resetErrorBoundary}>Try again</Button> <Button onClick={resetErrorBoundary}>Try again</Button>

View file

@ -1,4 +1,5 @@
// https://www.joshwcomeau.com/snippets/react-components/fade-in/ // https://www.joshwcomeau.com/snippets/react-components/fade-in/
import React from "react"
import styles from "./fade.module.css" import styles from "./fade.module.css"
function FadeIn({ function FadeIn({
@ -11,8 +12,18 @@ function FadeIn({
duration?: number duration?: number
delay?: number delay?: number
children: React.ReactNode children: React.ReactNode
as?: React.ElementType as?: React.ElementType | JSX.Element
} & React.HTMLAttributes<HTMLElement>) { } & React.HTMLAttributes<HTMLElement>) {
if (as !== null && typeof as === "object") {
return React.cloneElement(as, {
className: styles.fadeIn,
style: {
...(as.props.style || {}),
animationDuration: duration + "ms",
animationDelay: delay + "ms"
}
})
}
const Element = as || "div" const Element = as || "div"
return ( return (
<Element <Element

View file

@ -1,3 +0,0 @@
.active {
color: var(--fg) !important;
}

View file

@ -13,12 +13,13 @@ import {
UserX UserX
} from "react-feather" } from "react-feather"
import { signOut } from "next-auth/react" import { signOut } from "next-auth/react"
import Button from "@components/button" import { Button } from "@components/button"
import Link from "@components/link" import Link from "@components/link"
import { useSessionSWR } from "@lib/use-session-swr" import { useSessionSWR } from "@lib/use-session-swr"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import styles from "./buttons.module.css" import styles from "./buttons.module.css"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { cn } from "@lib/cn"
// constant width for sign in / sign out buttons to avoid CLS // constant width for sign in / sign out buttons to avoid CLS
const SIGN_IN_WIDTH = 110 const SIGN_IN_WIDTH = 110
@ -39,29 +40,37 @@ type Tab = {
} }
) )
function NavButton(tab: Tab) { function NavButton({ className, ...tab }: Tab & { className?: string }) {
const segment = useSelectedLayoutSegments().slice(-1)[0] const segment = useSelectedLayoutSegments().slice(-1)[0]
const isActive = segment === tab.value.toLowerCase() const isActive = segment === tab.value.toLowerCase()
const activeStyle = isActive ? styles.active : undefined const activeStyle = isActive ? "text-primary-500" : "text-gray-600"
if (tab.onClick) { if (tab.onClick) {
return ( return (
<Button <Button
key={tab.value} key={tab.value}
iconLeft={tab.icon}
onClick={tab.onClick} onClick={tab.onClick}
className={activeStyle} className={cn(activeStyle, "w-full md:w-auto", className)}
aria-label={tab.name} aria-label={tab.name}
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
data-tab={tab.value} data-tab={tab.value}
width={tab.width} variant={"ghost"}
> >
{tab.name ? tab.name : undefined} {tab.name ? tab.name : undefined}
</Button> </Button>
) )
} else { } else {
return ( return (
<Link key={tab.value} href={tab.href} data-tab={tab.value}> <Link
<Button className={activeStyle} iconLeft={tab.icon} width={tab.width}> key={tab.value}
href={tab.href}
data-tab={tab.value}
className="w-full"
>
<Button
className={cn(activeStyle, "w-full md:w-auto", className)}
aria-label={tab.name}
variant={"ghost"}
>
{tab.name ? tab.name : undefined} {tab.name ? tab.name : undefined}
</Button> </Button>
</Link> </Link>
@ -146,15 +155,14 @@ export function HeaderButtons(): JSX.Element {
/> />
<ThemeButton key="theme-button" /> <ThemeButton key="theme-button" />
{isAdmin && ( {isAdmin && (
<FadeIn>
<NavButton <NavButton
name="Admin" name="Admin"
key="admin" key="admin"
icon={<Settings />} icon={<Settings />}
value="admin" value="admin"
href="/admin" href="/admin"
className="transition-opacity duration-500"
/> />
</FadeIn>
)} )}
{isAuthenticated === true && ( {isAuthenticated === true && (
<NavButton <NavButton

View file

@ -1,16 +1,120 @@
import styles from "./header.module.css" "use client"
import { HeaderButtons } from "./buttons"
import Link from "next/link"
import { cn } from "@lib/cn"
import { useSessionSWR } from "@lib/use-session-swr"
import { PropsWithChildren, useEffect, useState } from "react"
import { useSelectedLayoutSegments } from "next/navigation"
import Image from "next/image"
import { useTheme } from "next-themes"
import { Moon, Sun } from "react-feather"
import FadeIn from "@components/fade-in"
import MobileHeader from "./mobile" import MobileHeader from "./mobile"
export default function Header() { export default function Header() {
const { isAdmin, isAuthenticated } = useSessionSWR()
const { resolvedTheme, setTheme } = useTheme()
const [isMounted, setIsMounted] = useState(false)
const toggleTheme = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
useEffect(() => {
setIsMounted(true)
}, [])
return ( return (
<header className={styles.header}> <header className="mt-4 flex h-16 items-center justify-start md:justify-between">
<div className={styles.tabs}> <span className="hidden items-center md:flex">
<div className={styles.buttons}> <Link href="/" className="mr-4 flex items-center">
<HeaderButtons /> <Image
</div> src={"/assets/logo.svg"}
</div> width={32}
height={32}
alt=""
priority
/>
<span className="bg-transparent pl-4 text-lg font-bold">Drift</span>
</Link>
<nav className="flex space-x-4 lg:space-x-6">
<ul className="flex justify-center space-x-4">
<NavLink href="/home">Home</NavLink>
<NavLink href="/new" disabled={!isAuthenticated}>
New
</NavLink>
<NavLink href="/mine" disabled={!isAuthenticated}>
Yours
</NavLink>
<NavLink href="/settings" disabled={!isAuthenticated}>
Settings
</NavLink>
{isAdmin && <NavLink href="/admin">Admin</NavLink>}
{isAuthenticated !== undefined && (
<>
{isAuthenticated === true && (
<NavLink href="/signout">Sign Out</NavLink>
)}
{isAuthenticated === false && (
<NavLink href="/signin">Sign In</NavLink>
)}
</>
)}
</ul>
</nav>
</span>
<span className="flex items-center justify-center md:hidden">
<MobileHeader /> <MobileHeader />
</span>
{isMounted && (
<FadeIn>
<button
aria-hidden
className="ml-4 flex h-8 w-8 cursor-pointer items-center justify-center font-medium text-muted-foreground transition-colors hover:text-primary md:ml-0"
onClick={toggleTheme}
title="Toggle theme"
>
{resolvedTheme === "dark" ? (
<Sun className="h-[16px] w-[16px]" />
) : (
<Moon className="h-[16px] w-[16px]" />
)}
</button>
</FadeIn>
)}
</header> </header>
) )
} }
type NavLinkProps = PropsWithChildren<{
href: string
disabled?: boolean
active?: boolean
}>
function NavLink({ href, disabled, children }: NavLinkProps) {
const baseClasses =
"text-sm text-muted-foreground font-medium transition-colors hover:text-primary"
const activeClasses = "text-primary border-primary"
const disabledClasses = "text-gray-600 hover:text-gray-400 cursor-not-allowed"
const segments = useSelectedLayoutSegments()
const activeSegment = segments[segments.length - 1]
const isActive =
activeSegment === href.slice(1) ||
// special case / because it's an alias of /home/page.tsx
(!activeSegment && href === "/home")
return (
<Link
href={href}
className={cn(
baseClasses,
isActive && activeClasses,
disabled && disabledClasses
)}
>
{children}
</Link>
)
}

View file

@ -1,53 +0,0 @@
.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

@ -1,12 +1,10 @@
"use client" "use client"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu" import { Button, buttonVariants } from "@components/button"
import buttonStyles from "@components/button/button.module.css"
import Button from "@components/button"
import { Menu } from "react-feather" import { Menu } from "react-feather"
import clsx from "clsx"
import styles from "./mobile.module.css"
import { HeaderButtons } from "./buttons" import { HeaderButtons } from "./buttons"
import * as DropdownMenu from "@components/dropdown-menu"
import React from "react"
export default function MobileHeader() { export default function MobileHeader() {
// TODO: this is a hack to close the radix ui menu when a next link is clicked // TODO: this is a hack to close the radix ui menu when a next link is clicked
@ -15,28 +13,27 @@ export default function MobileHeader() {
} }
return ( return (
<DropdownMenu.Root> <DropdownMenu.DropdownMenu>
<DropdownMenu.Trigger <DropdownMenu.DropdownMenuTrigger
className={clsx(buttonStyles.button, styles.mobileTrigger)} className={buttonVariants({ variant: "ghost" })}
asChild asChild
> >
<Button aria-label="Menu" height="auto"> <Button aria-label="Menu" variant={"ghost"}>
<Menu /> <Menu />
</Button> </Button>
</DropdownMenu.Trigger> </DropdownMenu.DropdownMenuTrigger>
<DropdownMenu.Portal> <DropdownMenu.DropdownMenuPortal>
<DropdownMenu.Content> <DropdownMenu.DropdownMenuContent>
{HeaderButtons().props.children.map((button: JSX.Element) => ( {HeaderButtons().props.children.map((button: JSX.Element) => (
<DropdownMenu.Item <DropdownMenu.DropdownMenuItem
key={`mobile-${button?.key}`} key={`mobile-${button?.key}`}
className={styles.dropdownItem}
onClick={onClick} onClick={onClick}
> >
{button} {button}
</DropdownMenu.Item> </DropdownMenu.DropdownMenuItem>
))} ))}
</DropdownMenu.Content> </DropdownMenu.DropdownMenuContent>
</DropdownMenu.Portal> </DropdownMenu.DropdownMenuPortal>
</DropdownMenu.Root> </DropdownMenu.DropdownMenu>
) )
} }

View file

@ -1,82 +1,51 @@
import clsx from "clsx" import * as React from "react"
import React from "react"
import styles from "./input.module.css"
type Props = React.HTMLProps<HTMLInputElement> & { import { cn } from "@lib/cn"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
label?: string label?: string
width?: number | string hideLabel?: boolean
height?: number | string
labelClassName?: string
} }
// we have two special rules on top of the props:
// if onChange or value is passed, we require both, unless `disabled`
// if label is passed, we forbid aria-label and vice versa
type InputProps = Omit<Props, "onChange" | "value" | "label" | "aria-label"> &
(
| {
onChange: Props["onChange"]
value: Props["value"]
}
| {
onChange?: never
value?: never
}
| {
value: Props["value"]
disabled: true
onChange?: never
}
) &
(
| {
label: Props["label"]
"aria-label"?: never
} // if label is passed, we forbid aria-label and vice versa
| {
label?: never
"aria-label": Props["aria-label"]
}
)
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
( ({ className, type, label, hideLabel, ...props }, ref) => {
{ label, className, required, width, height, labelClassName, ...props }, const id = React.useId()
ref
) => {
const labelId = label?.replace(/\s/g, "-").toLowerCase()
return ( return (
<div <span className="flex w-full flex-row items-center">
className={styles.wrapper} {label && !hideLabel ? (
style={{
width,
height
}}
>
{label && (
<label <label
htmlFor={labelId} htmlFor={id}
className={clsx(styles.label, labelClassName)} className={cn(
"h-10 rounded-md border border-input bg-transparent px-3 py-2 text-sm font-medium text-muted-foreground",
"rounded-br-none rounded-tr-none",
className
)}
> >
{label} {label}
</label> </label>
)} ) : null}
{label && hideLabel ? (
<label htmlFor={id} className="sr-only">
{label}
</label>
) : null}
<input <input
type={type}
className={cn(
"flex h-10 w-full border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
label && !hideLabel
? "rounded-bl-none rounded-tl-none border-l-0"
: "rounded-md",
className
)}
ref={ref} ref={ref}
id={labelId} id={id}
className={clsx(styles.input, label && styles.withLabel, className)}
required={required}
{...props} {...props}
style={{
width,
height,
...(props.style || {})
}}
/> />
</div> </span>
) )
} }
) )
Input.displayName = "Input" Input.displayName = "Input"
export default Input export { Input }

View file

@ -2,6 +2,7 @@
import clsx from "clsx" import clsx from "clsx"
import styles from "./page.module.css" import styles from "./page.module.css"
import Link from "@components/link"
export default function Layout({ export default function Layout({
children, children,
@ -12,7 +13,22 @@ export default function Layout({
}) { }) {
return ( return (
<div className={clsx(styles.page, forSites && styles.forSites)}> <div className={clsx(styles.page, forSites && styles.forSites)}>
{children} <div className="flex flex-col justify-between h-screen">
<div> {children}</div>
<footer className="h-16 py-4 text-sm text-center text-gray-500">
<p>
Drift is an open source project by{" "}
<Link colored href="https://twitter.com/Max_Leiter">
Max Leiter
</Link>
. You can view the source code on{" "}
<Link colored href="https://github.com/MaxLeiter/Drift">
GitHub
</Link>
.
</p>
</footer>
</div>
</div> </div>
) )
} }

View file

@ -1,5 +1,4 @@
.page { .page {
max-width: var(--main-content);
min-height: 100vh; min-height: 100vh;
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;

View file

@ -1,15 +1,15 @@
import NextLink from "next/link" import NextLink from "next/link"
import styles from "./link.module.css" import { cn } from "@lib/cn"
type LinkProps = { type LinkProps = {
colored?: boolean colored?: boolean
children: React.ReactNode children: React.ReactNode
} & React.ComponentProps<typeof NextLink> } & React.ComponentProps<typeof NextLink>
const Link = ({ colored, children, ...props }: LinkProps) => { const Link = ({ colored, className, children, ...props }: LinkProps) => {
const className = colored ? `${styles.link} ${styles.color}` : styles.link const classes = colored ? "text-blue-500 dark:text-blue-400 hover:underline" : "hover:underline"
return ( return (
<NextLink {...props} className={className}> <NextLink {...props} className={cn(classes, className)}>
{children} {children}
</NextLink> </NextLink>
) )

View file

@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "react-feather"
import { cn } from "@lib/cn"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:bg-accent focus:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none bg-background hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent/50 data-[active]:bg-accent/50 h-10 py-2 px-4 group w-max"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport
}

View file

@ -10,7 +10,10 @@ const Note = ({
type: "info" | "warning" | "error" type: "info" | "warning" | "error"
children: React.ReactNode children: React.ReactNode
} & React.ComponentProps<"div">) => ( } & React.ComponentProps<"div">) => (
<div className={clsx(className, styles.note, styles[type])} {...props}> <div
className={clsx(className, styles.note, styles[type], "text-sm")}
{...props}
>
{children} {children}
</div> </div>
) )

View file

@ -0,0 +1,14 @@
import { cn } from "@lib/cn"
import { PropsWithChildren } from "react"
export function PageTitle({
children,
className,
...props
}: PropsWithChildren<React.HTMLProps<HTMLHeadingElement>>) {
return (
<h1 className={cn("pb-2 pt-2 text-4xl font-bold", className)} {...props}>
{children}
</h1>
)
}

View file

@ -0,0 +1,14 @@
import { cn } from "@lib/cn"
import { PropsWithChildren } from "react"
export function PageWrapper({
children,
className,
...props
}: PropsWithChildren<React.HTMLProps<HTMLDivElement>>) {
return (
<div className={cn("mb-4 mt-4 flex flex-col gap-4", className)} {...props}>
{children}
</div>
)
}

View file

@ -1,9 +1,17 @@
import Button from "@components/button" import { Input } from "@components/input"
import Input from "@components/input"
import Note from "@components/note" import Note from "@components/note"
import * as Dialog from "@radix-ui/react-dialog" import { MouseEventHandler, useState } from "react"
import { useState } from "react"
import styles from "./modal.module.css" import styles from "./modal.module.css"
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogAction,
AlertDialogCancel,
AlertDialogFooter
} from "@components/alert-dialog"
type Props = { type Props = {
creating: boolean creating: boolean
@ -22,7 +30,8 @@ const PasswordModal = ({
const [confirmPassword, setConfirmPassword] = useState<string>("") const [confirmPassword, setConfirmPassword] = useState<string>("")
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const onSubmit = () => { const onSubmit: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault()
if (!password || (creating && !confirmPassword)) { if (!password || (creating && !confirmPassword)) {
setError("Please enter a password") setError("Please enter a password")
return return
@ -39,26 +48,24 @@ const PasswordModal = ({
return ( return (
<> <>
{ {
<Dialog.Root <AlertDialog
open={isOpen} open={isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) onClose() if (!open) onClose()
}} }}
> >
<Dialog.Portal> {/* <AlertDialogOverlay className={styles.overlay} /> */}
<Dialog.Overlay className={styles.overlay} /> <AlertDialogContent onEscapeKeyDown={onClose}>
<Dialog.Content <AlertDialogHeader>
className={styles.content} <AlertDialogTitle>
onEscapeKeyDown={onClose}
>
<Dialog.Title>
{creating ? "Add a password" : "Enter password"} {creating ? "Add a password" : "Enter password"}
</Dialog.Title> </AlertDialogTitle>
<Dialog.Description> <AlertDialogDescription>
{creating {creating
? "Enter a password to protect your post" ? "Enter a password to protect your post"
: "Enter the password to access the post"} : "Enter the password to access the post"}
</Dialog.Description> </AlertDialogDescription>
</AlertDialogHeader>
<fieldset className={styles.fieldset}> <fieldset className={styles.fieldset}>
{!error && creating && ( {!error && creating && (
<Note type="warning"> <Note type="warning">
@ -86,13 +93,12 @@ const PasswordModal = ({
/> />
)} )}
</fieldset> </fieldset>
<footer className={styles.footer}> <AlertDialogFooter>
<Button onClick={onClose}>Cancel</Button> <AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
<Button onClick={onSubmit}>Submit</Button> <AlertDialogAction onClick={onSubmit}>Submit</AlertDialogAction>
</footer> </AlertDialogFooter>
</Dialog.Content> </AlertDialogContent>
</Dialog.Portal> </AlertDialog>
</Dialog.Root>
} }
</> </>
) )

View file

@ -1,35 +1,31 @@
// largely from https://github.com/shadcn/taxonomy "use client"
import * as React from "react" import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover"
import clsx from "clsx"
import styles from "./popover.module.css"
type PopoverProps = PopoverPrimitive.PopoverProps import { cn } from "@lib/cn"
export function Popover({ ...props }: PopoverProps) { const Popover = PopoverPrimitive.Root
return <PopoverPrimitive.Root {...props} />
}
Popover.Trigger = React.forwardRef< const PopoverTrigger = PopoverPrimitive.Trigger
HTMLButtonElement,
PopoverPrimitive.PopoverTriggerProps
>(function PopoverTrigger({ ...props }, ref) {
return <PopoverPrimitive.Trigger {...props} ref={ref} />
})
Popover.Portal = PopoverPrimitive.Portal const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
Popover.Content = React.forwardRef< React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
HTMLDivElement, >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
PopoverPrimitive.PopoverContentProps <PopoverPrimitive.Portal>
>(function PopoverContent({ className, ...props }, ref) {
return (
<PopoverPrimitive.Content <PopoverPrimitive.Content
ref={ref} ref={ref}
align="end" align={align}
className={clsx(styles.root, className)} sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props} {...props}
/> />
) </PopoverPrimitive.Portal>
}) ))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View file

@ -4,7 +4,7 @@ import styles from "./post-list.module.css"
import ListItem from "./list-item" import ListItem from "./list-item"
import { ChangeEvent, useCallback, useState } from "react" import { ChangeEvent, useCallback, useState } from "react"
import type { PostWithFiles } from "@lib/server/prisma" import type { PostWithFiles } from "@lib/server/prisma"
import Input from "@components/input" import { Input } from "@components/input"
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { ListItemSkeleton } from "./list-item-skeleton" import { ListItemSkeleton } from "./list-item-skeleton"
import Link from "@components/link" import Link from "@components/link"
@ -83,7 +83,10 @@ const PostList = ({
}) })
if (!res?.ok) { if (!res?.ok) {
console.error(res) setToast({
message: "Failed to delete post",
type: "error"
})
return return
} else { } else {
setPosts((posts) => posts?.filter((post) => post.id !== postId)) setPosts((posts) => posts?.filter((post) => post.id !== postId))
@ -103,7 +106,7 @@ const PostList = ({
<Input <Input
placeholder="Search..." placeholder="Search..."
onChange={onSearchChange} onChange={onSearchChange}
disabled={!posts} disabled={!posts || posts.length === 0}
style={{ maxWidth: 300 }} style={{ maxWidth: 300 }}
aria-label="Search" aria-label="Search"
value={searchValue} value={searchValue}

View file

@ -1,11 +1,12 @@
import styles from "./list-item.module.css" import styles from "./list-item.module.css"
import Card from "@components/card" import { Card, CardContent, CardHeader } from "@components/card"
import Skeleton from "@components/skeleton" import Skeleton from "@components/skeleton"
export const ListItemSkeleton = () => ( export const ListItemSkeleton = () => (
<li> <li>
<Card style={{ overflowY: "scroll" }}> <Card style={{ overflowY: "scroll" }}>
{/* TODO: this is a bad way to do skeletons and is onlya ccurate on desktop */} {/* TODO: this is a bad way to do skeletons and is only accurate on desktop */}
<CardHeader>
<div style={{ display: "flex", gap: 16, marginBottom: 14 }}> <div style={{ display: "flex", gap: 16, marginBottom: 14 }}>
<div className={styles.title}> <div className={styles.title}>
{/* title */} {/* title */}
@ -18,7 +19,10 @@ export const ListItemSkeleton = () => (
<Skeleton width={60} height={32} /> <Skeleton width={60} height={32} />
</div> </div>
</div> </div>
</CardHeader>
<CardContent>
<Skeleton width={100} height={32} /> <Skeleton width={100} height={32} />
</CardContent>
</Card> </Card>
</li> </li>
) )

View file

@ -1,8 +1,3 @@
.title {
display: flex;
justify-content: space-between;
}
.titleText { .titleText {
display: flex; display: flex;
gap: var(--gap-half); gap: var(--gap-half);
@ -15,11 +10,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.buttons {
display: flex;
gap: var(--gap-half);
}
.oneline { .oneline {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -50,17 +40,10 @@
li { li {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--gap); gap: var(--gap-half);
padding: var(--gap-quarter); padding: var(--gap-quarter);
} }
li a {
display: flex;
align-items: center;
gap: var(--gap);
color: var(--darker-gray);
}
li a:hover { li a:hover {
color: var(--link); color: var(--link);
text-decoration: none; text-decoration: none;

View file

@ -6,20 +6,31 @@ import { useRouter } from "next/navigation"
import styles from "./list-item.module.css" import styles from "./list-item.module.css"
import Link from "@components/link" import Link from "@components/link"
import type { PostWithFiles } from "@lib/server/prisma" import type { PostWithFiles } from "@lib/server/prisma"
import Tooltip from "@components/tooltip" import { Badge } from "@components/badges/badge"
import Badge from "@components/badges/badge" import {
import Card from "@components/card" Card,
import Button from "@components/button" CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@components/card"
import { import {
ArrowUpCircle, ArrowUpCircle,
Code, Code,
Database, Database,
Edit, Edit,
FileText, FileText,
MoreVertical,
Terminal, Terminal,
Trash Trash
} from "react-feather" } from "react-feather"
import { codeFileExtensions } from "@lib/constants" import { codeFileExtensions } from "@lib/constants"
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuTrigger
} from "@components/dropdown-menu"
import { DropdownMenuContent } from "@radix-ui/react-dropdown-menu"
// TODO: isOwner should default to false so this can be used generically // TODO: isOwner should default to false so this can be used generically
const ListItem = ({ const ListItem = ({
@ -67,9 +78,9 @@ const ListItem = ({
return ( return (
<FadeIn key={post.id} as="li"> <FadeIn key={post.id} as="li">
<Card style={{ overflowY: "scroll" }}> <Card className="overflow-y-scroll h-42">
<> <CardHeader>
<div className={styles.title}> <CardTitle className="flex items-center justify-between gap-2">
<span className={styles.titleText}> <span className={styles.titleText}>
<h4 style={{ display: "inline-block", margin: 0 }}> <h4 style={{ display: "inline-block", margin: 0 }}>
<Link <Link
@ -82,7 +93,7 @@ const ListItem = ({
</h4> </h4>
<div className={styles.badges}> <div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} /> <VisibilityBadge visibility={post.visibility} />
<Badge type="secondary"> <Badge variant={"outline"}>
{post.files?.length === 1 {post.files?.length === 1
? "1 file" ? "1 file"
: `${post.files?.length || 0} files`} : `${post.files?.length || 0} files`}
@ -92,53 +103,62 @@ const ListItem = ({
</div> </div>
</span> </span>
{!hideActions ? ( {!hideActions ? (
<span className={styles.buttons}> <span className="flex gap-2">
{post.parentId && ( <DropdownMenu>
<Tooltip content={"View parent"}> <DropdownMenuTrigger asChild>
<Button <MoreVertical className="cursor-pointer" />
iconRight={<ArrowUpCircle />} </DropdownMenuTrigger>
onClick={viewParentClick} <DropdownMenuContent className="mt-2 border rounded-md shadow-sm border-border bg-background">
// TODO: not perfect on mobile <DropdownMenuItem
height={38} onSelect={() => {
/> editACopy()
</Tooltip> }}
)} className="cursor-pointer bg-background"
<Tooltip content={"Make a copy"}> >
<Button <Edit className="w-4 h-4 mr-2" /> Edit a copy
iconRight={<Edit />} </DropdownMenuItem>
onClick={editACopy}
height={38}
/>
</Tooltip>
{isOwner && ( {isOwner && (
<Tooltip content={"Delete"}> <DropdownMenuItem
<Button onSelect={() => {
iconRight={<Trash />} deletePost()
onClick={deletePost} }}
height={38} className="cursor-pointer bg-background"
/> >
</Tooltip> <Trash className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)} )}
{post.parentId && (
<DropdownMenuItem
onSelect={() => {
viewParentClick()
}}
>
<ArrowUpCircle className="w-4 h-4 mr-2" />
View parent
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</span> </span>
) : null} ) : null}
</div> </CardTitle>
{post.description && ( {post.description && (
<CardDescription>
<p className={styles.oneline}>{post.description}</p> <p className={styles.oneline}>{post.description}</p>
</CardDescription>
)} )}
</> </CardHeader>
<CardContent>
<ul className={styles.files}> <ul className={styles.files}>
{post?.files?.map( {post?.files?.map(
(file: Pick<PostWithFiles, "files">["files"][0]) => { (file: Pick<PostWithFiles, "files">["files"][0]) => {
return ( return (
<li key={file.id}> <li key={file.id} className="text-black">
<Link <Link
colored colored
href={`/post/${post.id}#${file.title}`} href={`/post/${post.id}#${file.title}`}
style={{ className="flex items-center gap-2 font-mono text-sm text-foreground"
display: "flex",
alignItems: "center"
}}
> >
{getIconFromFilename(file.title)} {getIconFromFilename(file.title)}
{file.title || "Untitled file"} {file.title || "Untitled file"}
@ -148,6 +168,7 @@ const ListItem = ({
} }
)} )}
</ul> </ul>
</CardContent>
</Card> </Card>
</FadeIn> </FadeIn>
) )

View file

@ -1,7 +1,7 @@
"use client" "use client"
import Button from "@components/button" import { Button } from "@components/button"
import Tooltip from "@components/tooltip" import { Tooltip } from "@components/tooltip"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { ChevronUp } from "react-feather" import { ChevronUp } from "react-feather"
import styles from "./scroll.module.css" import styles from "./scroll.module.css"
@ -39,8 +39,10 @@ const ScrollToTop = () => {
<Button <Button
aria-label="Scroll to Top" aria-label="Scroll to Top"
onClick={onClick} onClick={onClick}
iconLeft={<ChevronUp />} variant={"secondary"}
/> >
<ChevronUp />
</Button>
</Tooltip> </Tooltip>
</div> </div>
) )

View file

@ -1,4 +1,4 @@
import Card from "@components/card" import { Card, CardContent, CardHeader, CardTitle } from "@components/card"
import styles from "./settings-group.module.css" import styles from "./settings-group.module.css"
type Props = type Props =
@ -26,9 +26,13 @@ const SettingsGroup = ({ title, children, skeleton }: Props) => {
return ( return (
<Card> <Card>
<h4>{title}</h4> <CardHeader>
<hr /> <CardTitle>{title}</CardTitle>
</CardHeader>
<hr className="pb-4" />
<CardContent>
<div className={styles.content}>{children}</div> <div className={styles.content}>{children}</div>
</CardContent>
</Card> </Card>
) )
} }

View file

@ -1,4 +1,4 @@
import styles from "./skeleton.module.css" import { cn } from "@lib/cn"
export default function Skeleton({ export default function Skeleton({
width = 100, width = 100,
@ -13,8 +13,13 @@ export default function Skeleton({
}) { }) {
return ( return (
<div <div
className={styles.skeleton} className={cn("animate-pulse bg-gray-300 dark:bg-gray-800")}
style={{ width, height, borderRadius, ...style }} style={{
width,
height,
borderRadius,
...style
}}
/> />
) )
} }

View file

@ -1,4 +0,0 @@
.skeleton {
background-color: var(--lighter-gray);
border-radius: var(--radius);
}

View file

@ -1,3 +1,6 @@
import { cn } from "@lib/cn"
import styles from "./spinner.module.css" import styles from "./spinner.module.css"
export const Spinner = () => <div className={styles.spinner} /> export const Spinner = ({ className }: { className?: string }) => (
<div className={cn(styles.spinner, className)} />
)

View file

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@lib/cn"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-transparent p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-b-2 data-[state=active]:border-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

Some files were not shown because too many files have changed in this diff Show more