Compare commits

...

14 commits

Author SHA1 Message Date
Max Leiter
0c20460c13 /new page responsiveness 2023-07-20 20:48:02 -07:00
Max Leiter
563136fdb3 fix markdown checkbox colors 2023-07-20 20:24:10 -07:00
dependabot[bot]
bff0dbea38
Bump word-wrap from 1.2.3 to 1.2.4 (#151)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-20 19:13:41 -07:00
Max Leiter
5d5fd3182e
Shadify (#150)
Adds shadcn, bumps dependencies, overhaults lots of code.
2023-07-20 18:04:47 -07:00
PeGaSuS
702f59caf8
Add systemd units (#148)
Added systemd units, both for use as root and normal user
2023-06-10 12:02:24 -07:00
Max Leiter
41e72ba04c Bump next 2023-05-30 13:12:28 -07:00
Max Leiter
0df85776a5 Bump next 2023-05-26 13:34:40 -04:00
Max Leiter
7f4745ade1 Add loading fallback state for date picker 2023-05-21 14:06:51 -07:00
Max Leiter
504d2742f4 post-list <li> a11y improvements 2023-05-20 15:50:57 -07:00
Max Leiter
69ca511cc2 revalidate home page 2023-05-20 15:49:11 -07:00
Max Leiter
a1fa7dbb8a remove @vercel/og, use bundled package 2023-05-20 15:47:37 -07:00
Max Leiter
c416f5d5e8 migrate header info back to client-side 2023-05-20 15:42:21 -07:00
Max Leiter
dc11f8eb0c bump next and deps, fix header buttons 2023-05-20 15:16:50 -07:00
David Schultz
5e4ecbb803
Fix isAdmin check to be by role and not uid (#147)
* isAdmin should be based on role, not uid

* move admin link before sign in/out
2023-05-20 12:35:33 -07:00
122 changed files with 6025 additions and 4348 deletions

View file

@ -1,6 +1,12 @@
{ {
"extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"], "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"],
"ignorePatterns": ["node_modules/", "__tests__/"], "ignorePatterns": [
"node_modules/",
"__tests__/",
"coverage/",
".next/",
"public"
],
"rules": { "rules": {
"@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "error" "@typescript-eslint/no-explicit-any": "error"

1
.gitignore vendored
View file

@ -4,6 +4,7 @@
/node_modules /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
analyze
# testing # testing
/coverage /coverage

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.
@ -68,6 +71,55 @@ Refer to pm2's docs or `pm2 help` for more information.
## Running with Docker ## Running with Docker
## Running with systemd
_**NOTE:** We assume that you know how to enable user lingering if you don't want to use the systemd unit as root_
- As root
- Place the following systemd unit in ___/etc/systemd/system___ and name it _drift.service_
- Replace any occurrence of ___`$USERNAME`___ with the shell username of the user that will be running the Drift server
```
##########
# Drift Systemd Unit (Global)
##########
[Unit]
Description=Drift Server (Global)
After=default.target
[Service]
User=$USERNAME
Group=$USERNAME
Type=simple
WorkingDirectory=/home/$USERNAME/Drift
ExecStart=/usr/bin/pnpm start
Restart=on-failure
[Install]
WantedBy=default.target
```
- As a nomal user
- Place the following systemd unit inside ___/home/user/.config/systemd/user___ and name it _drift_user.service_
- Replace any occurrence of ___`$USERNAME`___ with the shell username of the user that will be running the Drift server
```
##########
# Drift Systemd Unit (User)
##########
[Unit]
Description=Drift Server (User)
After=default.target
[Service]
Type=simple
WorkingDirectory=/home/$USERNAME/Drift
ExecStart=/usr/bin/pnpm start
Restart=on-failure
[Install]
WantedBy=default.target
```
## Current status ## Current status
Drift is a work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist. Drift is a work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist.

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

@ -4,9 +4,7 @@ import bundleAnalyzer from "@next/bundle-analyzer"
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
experimental: { experimental: {
// esmExternals: true, appDir: true
appDir: true,
serverComponentsExternalPackages: ["prisma", "@prisma/client"],
}, },
rewrites() { rewrites() {
return [ return [
@ -14,16 +12,35 @@ const nextConfig = {
source: "/file/raw/:id", source: "/file/raw/:id",
destination: `/api/raw/:id` destination: `/api/raw/:id`
}, },
{
source: "/signout",
destination: `/api/auth/signout`
}
] ]
}, },
images: { images: {
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,73 +13,84 @@
"jest": "jest" "jest": "jest"
}, },
"dependencies": { "dependencies": {
"@next-auth/prisma-adapter": "^1.0.5", "@next-auth/prisma-adapter": "^1.0.7",
"@next/eslint-plugin-next": "^13.2.4", "@next/eslint-plugin-next": "13.4.11-canary.0",
"@prisma/client": "^4.11.0", "@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",
"@vercel/og": "^0.4.0", "@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.0", "client-zip": "2.3.1",
"cmdk": "^0.2.0", "cmdk": "^0.2.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.2.4", "next": "13.4.11-canary.1",
"next-auth": "^4.20.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": "^3.1.4", "react-error-boundary": "^4.0.4",
"react-feather": "^2.0.10", "react-feather": "^2.0.10",
"react-hot-toast": "2.4.0", "react-hot-toast": "2.4.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"swr": "^2.1.0", "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.0.5", "ts-jest": "^29.1.0",
"uuid": "^9.0.0", "uuid": "^9.0.0"
"zlib": "^1.0.5"
}, },
"devDependencies": { "devDependencies": {
"@next/bundle-analyzer": "13.2.4", "@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",
"@types/jest": "^29.4.1", "@types/jest": "^29.4.1",
"@types/lodash.debounce": "^4.0.7", "@types/lodash.debounce": "^4.0.7",
"@types/node": "18.15.3", "@types/node": "18.15.11",
"@types/react": "18.0.28", "@types/react": "18.0.35",
"@types/react-datepicker": "4.10.0", "@types/react-datepicker": "4.10.0",
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/eslint-plugin": "^5.58.0",
"@typescript-eslint/parser": "^5.55.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.1", "csstype": "^3.1.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "8.36.0", "eslint": "8.38.0",
"eslint-config-next": "13.2.4", "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",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-hover-media-feature": "^1.0.2", "postcss-hover-media-feature": "^1.0.2",
"postcss-nested": "^6.0.1", "postcss-nested": "^6.0.1",
"postcss-preset-env": "^8.0.1", "postcss-preset-env": "^8.4.1",
"prettier": "2.8.4", "prettier": "2.8.7",
"prisma": "^4.11.0", "prettier-plugin-tailwindcss": "^0.3.0",
"typescript": "4.9.5", "prisma": "^5.0.0",
"typescript-plugin-css-modules": "4.2.3" "tailwindcss": "^3.3.2",
"typescript": "5.1.6",
"typescript-plugin-css-modules": "5.0.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"sharp": "^0.31.3" "sharp": "^0.32.0"
}, },
"next-unused": { "next-unused": {
"alias": { "alias": {

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

@ -1,12 +1,14 @@
"use client" "use client"
import { startTransition, Suspense, useState } from "react" 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,10 +54,7 @@ function Auth({
}) })
setSubmitting(false) setSubmitting(false)
} else { } else {
startTransition(() => { router.refresh()
router.push("/new")
router.refresh()
})
} }
} }
@ -75,13 +74,10 @@ function Auth({
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Suspense boundary because useSearchParams causes static bailout */} <ErrorQueryParamsHandler />
<Suspense fallback={null}> <div className={"mx-auto w-[300px]"}>
<ErrorQueryParamsHandler />
</Suspense>
<div className={styles.form}>
<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}>
@ -98,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%" }} />
@ -129,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>
</> </>
@ -137,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
})} })}
@ -190,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

@ -2,9 +2,9 @@
import { useToasts } from "@components/toasts" import { useToasts } from "@components/toasts"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
import { useEffect } from "react" import { Suspense, useEffect } from "react"
export function ErrorQueryParamsHandler() { function InnerErrorQueryParamsHandler() {
const queryParams = useSearchParams() const queryParams = useSearchParams()
const { setToast } = useToasts() const { setToast } = useToasts()
@ -19,3 +19,12 @@ export function ErrorQueryParamsHandler() {
return null return null
} }
export function ErrorQueryParamsHandler() {
/* Suspense boundary because useSearchParams causes static bailout */
return (
<Suspense fallback={null}>
<InnerErrorQueryParamsHandler />
</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 (
<Auth <PageWrapper>
page="signin" <Auth
credentialAuth={isCredentialEnabled()} page="signin"
authProviders={getAuthProviders()} credentialAuth={isCredentialEnabled()}
/> 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 (
<Auth <PageWrapper>
page="signup" <Auth
requiresServerPassword={requiresPasscode} page="signup"
credentialAuth={isCredentialEnabled()} requiresServerPassword={requiresPasscode}
authProviders={getAuthProviders()} credentialAuth={isCredentialEnabled()}
/> 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")
} }
return ( return (
<RadixTabs.Root <Tabs {...props} onValueChange={handleTabChange} defaultValue={defaultTab}>
{...props} <TabsList className="flex flex-col items-start justify-start sm:flex-row sm:items-center sm: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 ${
activeTab === "preview" ? "hidden" : "hidden sm:block"
}`}
/> />
)} )}
</RadixTabs.List> </TabsList>
<RadixTabs.Content value="edit"> <TabsContent value="edit">
<div <div
style={{ style={{
marginTop: 6, marginTop: 6,
@ -66,8 +68,16 @@ export default function DocumentTabs({
flexDirection: "column" flexDirection: "column"
}} }}
> >
<FormattingIcons
textareaRef={codeEditorRef}
className={`ml-auto ${
activeTab === "preview"
? "hidden"
: "block text-muted-foreground sm:hidden"
}`}
/>
<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 +90,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 +99,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.title ? item.title : "Untitled"} >
</span> {item.icon}
{item.title ? item.title : "Untitled"}
</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 <div className={styles.chevron} style={{ marginRight: 6 }}>
className={clsx(buttonStyles.icon, 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,35 +66,31 @@ 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>
<DocumentTabs <CardContent>
isEditing={true} <DocumentTabs
defaultTab={defaultTab} isEditing={true}
handleOnContentChange={handleOnContentChange} defaultTab={defaultTab}
// TODO: solve types handleOnContentChange={handleOnContentChange}
// @ts-expect-error Type 'HTMLDivElement' is missing the following properties from type 'HTMLTextAreaElement': autocomplete, cols, defaultValue, dirName, and 26 more // TODO: solve types
onPaste={onPaste} // @ts-expect-error Type 'HTMLDivElement' is missing the following properties from type 'HTMLTextAreaElement': autocomplete, cols, defaultValue, dirName, and 26 more
title={title} onPaste={onPaste}
> title={title}
{content} >
</DocumentTabs> {content}
</div> </DocumentTabs>
</div> </CardContent>
</> </Card>
) )
} }

View file

@ -3,23 +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"
const DatePicker = dynamic(() => import("react-datepicker")) 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("@components/date-picker").then((m) => m.DatePicker),
{
ssr: false,
loading: () => (
<Button
variant={"outline"}
className={cn(
"w-[280px] justify-start text-left font-normal",
"text-muted-foreground"
)}
>
<CalendarIcon className="w-4 h-4 mr-2" />
<span>Won&apos;t expire</span>
</Button>
)
}
)
const emptyDoc = { const emptyDoc = {
title: "", title: "",
@ -45,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
@ -128,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
@ -167,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)
@ -184,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) =>
@ -238,10 +255,9 @@ function Post({
} }
return ( return (
<div className={styles.root}> <div className="flex flex-col flex-1 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}
@ -249,68 +265,60 @@ function Post({
updateDocContent={updateDocContent} updateDocContent={updateDocContent}
removeDoc={removeDoc} removeDoc={removeDoc}
/> />
<div className={styles.buttons}> <FileDropzone setDocs={uploadDocs} />
<Button
onClick={() => { <div className="flex flex-col items-end justify-between gap-4 mt-4 sm:flex-row sm:items-center">
setDocs([ <span className="flex flex-1 gap-2">
...docs, <Button
{ onClick={() => {
title: "", setDocs([
content: "", ...docs,
id: generateUUID() {
} title: "",
]) content: "",
}} id: generateUUID()
style={{ }
flex: 1, ])
minWidth: 120 }}
}} className="min-w-[120px] max-w-[200px] flex-1"
> variant={"secondary"}
Add a File >
</Button> Add a File
<div className={styles.rightButtons}> </Button>
<DatePicker <DatePicker setExpiresAt={setExpiresAt} expiresAt={expiresAt} />
onChange={onChangeExpiration} </span>
customInput={ <ButtonDropdown>
<Input label="Expires at" width="100%" height="40px" /> <span
} className={clsx(
placeholderText="Won't expire" "w-full cursor-pointer rounded-br-none rounded-tr-none",
selected={expiresAt} buttonVariants({
showTimeInput={true} variant: "default"
// @ts-expect-error fix time input type })
customTimeInput={<CustomTimeInput />} )}
timeInputLabel="Time:" onClick={() => onSubmit("unlisted")}
dateFormat="MM/dd/yyyy h:mm aa" >
className={styles.datePicker} {isSubmitting ? <Spinner className="mr-2" /> : null}
clearButtonTitle={"Clear"} Create Unlisted
// TODO: investigate why this causes margin shift if true </span>
enableTabLoop={false} <span
minDate={new Date()} className={clsx("w-full cursor-pointer")}
/> onClick={() => onSubmit("private")}
<ButtonDropdown> >
<Button Create Private
height={40} </span>
width={251} <span
onClick={() => onSubmit("unlisted")} className={clsx("w-full cursor-pointer")}
loading={isSubmitting} onClick={() => onSubmit("public")}
> >
Create Unlisted Create Public
</Button> </span>
<Button height={40} width={300} onClick={() => onSubmit("private")}> <span
Create Private className={clsx("w-full cursor-pointer")}
</Button> onClick={() => onSubmit("protected")}
<Button height={40} width={300} onClick={() => onSubmit("public")}> >
Create Public Create with Password
</Button> </span>
<Button </ButtonDropdown>
height={40}
width={300}
onClick={() => onSubmit("protected")}
>
Create with Password
</Button>
</ButtonDropdown>
</div>
</div> </div>
<PasswordModal <PasswordModal
creating={true} creating={true}
@ -324,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,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 "./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,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,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} />
<VisibilityControl <div className="mx-auto mb-4 mt-4">
authorId={post.authorId} <VisibilityControl
postId={post.id} authorId={post.authorId}
visibility={post.visibility} postId={post.id}
/> 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>
{/* @ts-expect-error Type 'unknown' is not assignable to type */} <h2 className="mb-4 mt-4 text-2xl font-bold">Users</h2>
<UserTable users={serializedUsers as unknown} /> {/* @ts-expect-error Type 'unknown' is not assignable to type */}
<h2>Posts</h2> <UserTable users={serializedUsers as unknown} />
<PostTable posts={serializedPosts} /> <h2 className="mb-4 mt-4 text-2xl font-bold">Posts</h2>
</div> <PostTable posts={serializedPosts} />
</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

@ -1,3 +1,5 @@
import HomePage from "../page" import HomePage from "../page"
export default HomePage export default HomePage
export const revalidate = 300

View file

@ -6,10 +6,9 @@ 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 { cookies } from "next/headers" 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" })
import { THEME_COOKIE, DEFAULT_THEME, SIGNED_IN_COOKIE } from "@lib/constants"
import { Suspense } from "react"
const CmdK = dynamic(() => import("@components/cmdk"), { ssr: false }) const CmdK = dynamic(() => import("@components/cmdk"), { ssr: false })
@ -18,22 +17,20 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const cookiesList = cookies()
const theme = cookiesList.get(THEME_COOKIE)?.value || DEFAULT_THEME
const isAuthenticated = Boolean(cookiesList.get(SIGNED_IN_COOKIE)?.value)
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 />
<Suspense fallback={<>Loading...</>}> <Header />
<Header theme={theme} isAuthenticated={isAuthenticated} /> <main>{children}</main>
</Suspense>
{children}
</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,16 +18,21 @@ export default async function Mine() {
const posts = (await getPostsByUser(userId, true)).map(serverPostToClientPost) const posts = (await getPostsByUser(userId, true)).map(serverPostToClientPost)
return ( return (
<ErrorBoundary> <>
<Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}> <PageTitle>Your Posts</PageTitle>
<PostList <PageWrapper>
userId={userId} <ErrorBoundary>
initialPosts={posts} <Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}>
isOwner={true} <PostList
hideSearch={false} userId={userId}
/> initialPosts={posts}
</Suspense> isOwner={true}
</ErrorBoundary> hideSearch={false}
/>
</Suspense>
</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,7 +8,9 @@ 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
const getWelcomeData = cache(async () => { const getWelcomeData = cache(async () => {
const welcomeContent = await getWelcomeContent() const welcomeContent = await getWelcomeContent()
@ -18,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={
@ -45,22 +33,24 @@ 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">
<DocumentTabs <CardContent>
defaultTab="preview" <DocumentTabs
isEditing={false} defaultTab="preview"
staticPreview={rendered as string} isEditing={false}
title={title} staticPreview={rendered as string}
> title={title}
{content} >
</DocumentTabs> {content}
</DocumentTabs>
</CardContent>
</Card> </Card>
) )
} }
@ -77,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 (
<> <>
<SettingsGroup title="Profile"> <PageTitle>Settings</PageTitle>
<Profile /> <PageWrapper>
</SettingsGroup> <SettingsGroup title="Profile">
<SettingsGroup title="API Keys"> <Profile />
<APIKeys /> </SettingsGroup>
</SettingsGroup> <SettingsGroup title="API Keys">
<APIKeys />
</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
@ -39,11 +39,11 @@ const ExpirationBadge = ({
if (!expirationDate) { if (!expirationDate) {
return null return null
} }
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,10 +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 { setDriftTheme } from "src/app/lib/set-theme"
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,
@ -17,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={() => {
@ -37,12 +36,12 @@ 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={() => {
setDriftTheme(resolvedTheme === "dark" ? "light" : "dark", setTheme) setTheme(resolvedTheme === "dark" ? "light" : "dark")
}} }}
icon={resolvedTheme === "dark" ? <Sun /> : <Moon />} icon={resolvedTheme === "dark" ? <Sun /> : <Moon />}
> >
@ -58,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="w-4 h-4 mr-2" />
{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,7 +1,9 @@
"use client" "use client"
import Button from "@components/button" import { Button } from "@components/button"
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
@ -14,8 +16,14 @@ 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"
className="mr-2"
>
<Button>Report an issue</Button>
</Link>
<Button onClick={resetErrorBoundary}>Try again</Button> <Button onClick={resetErrorBoundary}>Try again</Button>
</Note> </Note>
) )

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

@ -2,8 +2,8 @@
import { useSelectedLayoutSegments } from "next/navigation" import { useSelectedLayoutSegments } from "next/navigation"
import FadeIn from "@components/fade-in" import FadeIn from "@components/fade-in"
import { setDriftTheme } from "src/app/lib/set-theme"
import { import {
Circle,
Home, Home,
Moon, Moon,
PlusCircle, PlusCircle,
@ -13,11 +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 { 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
@ -38,48 +40,37 @@ type Tab = {
} }
) )
export function HeaderButtons({ function NavButton({ className, ...tab }: Tab & { className?: string }) {
isAuthenticated,
theme: initialTheme
}: {
isAuthenticated: boolean
theme: string
}) {
const { isAdmin, userId } = useSessionSWR()
const { resolvedTheme } = useTheme();
return <>
{getButtons({
isAuthenticated,
theme: resolvedTheme ? resolvedTheme : initialTheme,
isAdmin,
userId
})}
</>
}
function NavButton(tab: Tab) {
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>
@ -87,101 +78,126 @@ function NavButton(tab: Tab) {
} }
} }
function ThemeButton({ theme }: { theme: string }) { function ThemeButton() {
const { setTheme } = useTheme() const { setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return ( return (
<NavButton <>
name="Theme" {!mounted && (
icon={theme === "dark" ? <Sun /> : <Moon />} <NavButton
value="dark" name="Theme"
onClick={() => { icon={<Circle opacity={0.3} />}
setDriftTheme(theme === "dark" ? "light" : "dark", setTheme) value="dark"
}} href=""
key="theme" key="theme"
/> />
)}
{mounted && (
<NavButton
name="Theme"
icon={
<FadeIn>{resolvedTheme === "dark" ? <Sun /> : <Moon />}</FadeIn>
}
value="dark"
onClick={() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}}
key="theme"
/>
)}
</>
) )
} }
/** For use by mobile */ export function HeaderButtons(): JSX.Element {
export function getButtons({ const { isAdmin, isAuthenticated, userId } = useSessionSWR()
isAuthenticated,
theme, useEffect(() => {
// mutate: mutateSession, if (isAuthenticated && !userId) {
isAdmin, signOut()
userId }
}: { }, [isAuthenticated, userId])
isAuthenticated: boolean
theme: string return (
// mutate: KeyedMutator<Session> <>
isAdmin?: boolean,
userId?: string
}) {
return [
<NavButton
key="home"
name="Home"
icon={<Home />}
value="home"
href="/home"
/>,
<NavButton
key="new"
name="New"
icon={<PlusCircle />}
value="new"
href="/new"
/>,
<NavButton
key="yours"
name="Yours"
icon={<User />}
value="mine"
href="/mine"
/>,
<NavButton
name="Settings"
icon={<Settings />}
value="settings"
href="/settings"
key="settings"
/>,
<ThemeButton key="theme-button" theme={theme} />,
isAuthenticated === true ? (
<NavButton <NavButton
name="Sign Out" key="home"
key="signout" name="Home"
icon={<UserX />} icon={<Home />}
value="signout" value="home"
onClick={() => { href="/home"
signOut({
callbackUrl: `/signedout${
userId ? "?userId=" + userId : ""
}`
})
}}
width={SIGN_IN_WIDTH}
/> />
) : undefined,
isAuthenticated === false ? (
<NavButton <NavButton
name="Sign In" key="new"
key="signin" name="New"
icon={<PlusCircle />}
value="new"
href="/new"
/>
<NavButton
key="yours"
name="Yours"
icon={<User />} icon={<User />}
value="signin" value="mine"
href="/signin" href="/mine"
width={SIGN_IN_WIDTH}
/> />
) : undefined, <NavButton
isAdmin ? ( name="Settings"
<FadeIn> icon={<Settings />}
value="settings"
href="/settings"
key="settings"
/>
<ThemeButton key="theme-button" />
{isAdmin && (
<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> )}
) : undefined {isAuthenticated === true && (
].filter(Boolean) <NavButton
name="Sign Out"
key="signout"
icon={<UserX />}
value="signout"
onClick={() => {
signOut({
callbackUrl: `/signedout${userId ? "?userId=" + userId : ""}`
})
}}
width={SIGN_IN_WIDTH}
/>
)}
{isAuthenticated === false && (
<NavButton
name="Sign In"
key="signin"
icon={<User />}
value="signin"
href="/signin"
width={SIGN_IN_WIDTH}
/>
)}
{isAuthenticated === undefined && (
<NavButton
name="Sign"
key="signin"
icon={<User />}
value="signin"
href="/signin"
width={SIGN_IN_WIDTH}
/>
)}
</>
)
} }

View file

@ -1,22 +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() {
theme, const { isAdmin, isAuthenticated } = useSessionSWR()
isAuthenticated const { resolvedTheme, setTheme } = useTheme()
}: { const [isMounted, setIsMounted] = useState(false)
theme: string const toggleTheme = () => {
isAuthenticated: boolean 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 isAuthenticated={isAuthenticated} theme={theme} /> <Image
</div> src={"/assets/logo.svg"}
</div> width={32}
<MobileHeader isAuthenticated={isAuthenticated} theme={theme} /> 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 />
</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,56 +1,39 @@
"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 { HeaderButtons } from "./buttons"
import styles from "./mobile.module.css" import * as DropdownMenu from "@components/dropdown-menu"
import { getButtons } from "./buttons" import React from "react"
import { useSessionSWR } from "@lib/use-session-swr"
export default function MobileHeader({
isAuthenticated,
theme
}: {
isAuthenticated: boolean
theme: string
}) {
const { isAdmin } = useSessionSWR()
const buttons = getButtons({
isAuthenticated,
theme,
isAdmin
})
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
const onClick = () => { const onClick = () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })) document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
} }
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>
{buttons.map((button) => ( {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="mx-auto h-4 max-w-[var(--main-content)] text-center text-sm 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,60 +48,57 @@ 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>
<fieldset className={styles.fieldset}> </AlertDialogHeader>
{!error && creating && ( <fieldset className={styles.fieldset}>
<Note type="warning"> {!error && creating && (
This doesn&apos;t protect your post from the server <Note type="warning">
administrator. This doesn&apos;t protect your post from the server
</Note> administrator.
)} </Note>
{error && <Note type="error">{error}</Note>} )}
{error && <Note type="error">{error}</Note>}
<Input
width={"100%"}
label="Password"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
{creating && (
<Input <Input
width={"100%"} width={"100%"}
label="Password" label="Confirm"
type="password" type="password"
placeholder="Password" placeholder="Confirm Password"
value={password} value={confirmPassword}
onChange={(e) => setPassword(e.currentTarget.value)} onChange={(e) => setConfirmPassword(e.currentTarget.value)}
/> />
{creating && ( )}
<Input </fieldset>
width={"100%"} <AlertDialogFooter>
label="Confirm" <AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
type="password" <AlertDialogAction onClick={onSubmit}>Submit</AlertDialogAction>
placeholder="Confirm Password" </AlertDialogFooter>
value={confirmPassword} </AlertDialogContent>
onChange={(e) => setConfirmPassword(e.currentTarget.value)} </AlertDialog>
/>
)}
</fieldset>
<footer className={styles.footer}>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onSubmit}>Submit</Button>
</footer>
</Dialog.Content>
</Dialog.Portal>
</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}
@ -132,6 +135,7 @@ const PostList = ({
})} })}
</ul> </ul>
) : null} ) : null}
{!showSkeleton && posts && posts.length === 0 && <NoPostsFound />}
</Stack> </Stack>
) )
} }

View file

@ -1,24 +1,28 @@
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 */}
<div style={{ display: "flex", gap: 16, marginBottom: 14 }}> <CardHeader>
<div className={styles.title}> <div style={{ display: "flex", gap: 16, marginBottom: 14 }}>
{/* title */} <div className={styles.title}>
<Skeleton width={80} height={32} /> {/* title */}
</div> <Skeleton width={80} height={32} />
</div>
<div className={styles.badges}> <div className={styles.badges}>
<Skeleton width={60} height={32} /> <Skeleton width={60} height={32} />
<Skeleton width={60} height={32} /> <Skeleton width={60} height={32} />
<Skeleton width={60} height={32} /> <Skeleton width={60} height={32} />
</div>
</div> </div>
</div> </CardHeader>
<Skeleton width={100} height={32} /> <CardContent>
<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 = ({
@ -66,80 +77,88 @@ const ListItem = ({
} }
return ( return (
<FadeIn key={post.id}> <FadeIn key={post.id} as="li">
<li> <Card className="overflow-y-scroll h-42">
<Card style={{ overflowY: "scroll" }}> <CardHeader>
<> <CardTitle className="flex items-center justify-between gap-2">
<div className={styles.title}> <span className={styles.titleText}>
<span className={styles.titleText}> <h4 style={{ display: "inline-block", margin: 0 }}>
<h4 style={{ display: "inline-block", margin: 0 }}> <Link
<Link colored
colored style={{ marginRight: "var(--gap)" }}
style={{ marginRight: "var(--gap)" }} href={`/post/${post.id}`}
href={`/post/${post.id}`} >
> {post.title}
{post.title} </Link>
</Link> </h4>
</h4> <div className={styles.badges}>
<div className={styles.badges}> <VisibilityBadge visibility={post.visibility} />
<VisibilityBadge visibility={post.visibility} /> <Badge variant={"outline"}>
<Badge type="secondary"> {post.files?.length === 1
{post.files?.length === 1 ? "1 file"
? "1 file" : `${post.files?.length || 0} files`}
: `${post.files?.length || 0} files`} </Badge>
</Badge> <CreatedAgoBadge createdAt={post.createdAt} />
<CreatedAgoBadge createdAt={post.createdAt} /> <ExpirationBadge postExpirationDate={post.expiresAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} /> </div>
</div> </span>
{!hideActions ? (
<span className="flex gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<MoreVertical className="cursor-pointer" />
</DropdownMenuTrigger>
<DropdownMenuContent className="mt-2 border rounded-md shadow-sm border-border bg-background">
<DropdownMenuItem
onSelect={() => {
editACopy()
}}
className="cursor-pointer bg-background"
>
<Edit className="w-4 h-4 mr-2" /> Edit a copy
</DropdownMenuItem>
{isOwner && (
<DropdownMenuItem
onSelect={() => {
deletePost()
}}
className="cursor-pointer bg-background"
>
<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>
{!hideActions ? ( ) : null}
<span className={styles.buttons}> </CardTitle>
{post.parentId && ( {post.description && (
<Tooltip content={"View parent"}> <CardDescription>
<Button
iconRight={<ArrowUpCircle />}
onClick={viewParentClick}
// TODO: not perfect on mobile
height={38}
/>
</Tooltip>
)}
<Tooltip content={"Make a copy"}>
<Button
iconRight={<Edit />}
onClick={editACopy}
height={38}
/>
</Tooltip>
{isOwner && (
<Tooltip content={"Delete"}>
<Button
iconRight={<Trash />}
onClick={deletePost}
height={38}
/>
</Tooltip>
)}
</span>
) : null}
</div>
{post.description && (
<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"}
@ -149,8 +168,8 @@ const ListItem = ({
} }
)} )}
</ul> </ul>
</Card> </CardContent>
</li> </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>
<div className={styles.content}>{children}</div> </CardHeader>
<hr className="pb-4" />
<CardContent>
<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
}}
/> />
) )
} }

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