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"],
"ignorePatterns": ["node_modules/", "__tests__/"],
"ignorePatterns": [
"node_modules/",
"__tests__/",
"coverage/",
".next/",
"public"
],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "error"

1
.gitignore vendored
View file

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

View file

@ -3,5 +3,6 @@
"trailingComma": "none",
"singleQuote": false,
"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).
Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/).
<hr />
**Contents:**
@ -48,6 +50,7 @@ You can change these to your liking.
- `NODE_ENV`: defaults to development, can be `production`
#### 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.
- `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 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
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 = {
reactStrictMode: true,
experimental: {
// esmExternals: true,
appDir: true,
serverComponentsExternalPackages: ["prisma", "@prisma/client"],
appDir: true
},
rewrites() {
return [
@ -14,16 +12,35 @@ const nextConfig = {
source: "/file/raw/:id",
destination: `/api/raw/:id`
},
{
source: "/signout",
destination: `/api/auth/signout`
}
]
},
images: {
domains: ["avatars.githubusercontent.com"]
},
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" })(
nextConfig
)
export default process.env.ANALYZE === "true"
? bundleAnalyzer({ enabled: true })(nextConfig)
: nextConfig

View file

@ -13,73 +13,84 @@
"jest": "jest"
},
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.5",
"@next/eslint-plugin-next": "^13.2.4",
"@prisma/client": "^4.11.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@next/eslint-plugin-next": "13.4.11-canary.0",
"@prisma/client": "^5.0.0",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.3",
"@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-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.3",
"@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-zip": "2.3.0",
"client-zip": "2.3.1",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"jest": "^29.5.0",
"lodash.debounce": "^4.0.8",
"next": "^13.2.4",
"next-auth": "^4.20.1",
"next": "13.4.11-canary.1",
"next-auth": "^4.22.3",
"next-themes": "^0.2.1",
"react": "18.2.0",
"react-cookie": "^4.1.1",
"react-cookie": "^4.1.1",
"react-datepicker": "4.10.0",
"react-day-picker": "^8.8.0",
"react-dom": "18.2.0",
"react-dropzone": "14.2.3",
"react-error-boundary": "^3.1.4",
"react-error-boundary": "^4.0.4",
"react-feather": "^2.0.10",
"react-hot-toast": "2.4.0",
"react-hot-toast": "2.4.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",
"ts-jest": "^29.0.5",
"uuid": "^9.0.0",
"zlib": "^1.0.5"
"ts-jest": "^29.1.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@next/bundle-analyzer": "13.2.4",
"@next/bundle-analyzer": "13.4.11-canary.0",
"@total-typescript/ts-reset": "^0.4.2",
"@types/bcrypt": "^5.0.0",
"@types/git-http-backend": "^1.0.1",
"@types/jest": "^29.4.1",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "18.15.3",
"@types/react": "18.0.28",
"@types/node": "18.15.11",
"@types/react": "18.0.35",
"@types/react-datepicker": "4.10.0",
"@types/react-dom": "18.0.11",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0",
"@typescript-eslint/eslint-plugin": "^5.58.0",
"@typescript-eslint/parser": "^5.58.0",
"@wcj/markdown-to-html": "^2.2.1",
"autoprefixer": "^10.4.14",
"clsx": "^1.2.1",
"cross-env": "7.0.3",
"csstype": "^3.1.1",
"csstype": "^3.1.2",
"dotenv": "^16.0.3",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eslint": "8.38.0",
"eslint-config-next": "13.4.11-canary.1",
"jest-mock-extended": "^3.0.3",
"next-unused": "0.0.6",
"postcss": "^8.4.21",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-hover-media-feature": "^1.0.2",
"postcss-nested": "^6.0.1",
"postcss-preset-env": "^8.0.1",
"prettier": "2.8.4",
"prisma": "^4.11.0",
"typescript": "4.9.5",
"typescript-plugin-css-modules": "4.2.3"
"postcss-preset-env": "^8.4.1",
"prettier": "2.8.7",
"prettier-plugin-tailwindcss": "^0.3.0",
"prisma": "^5.0.0",
"tailwindcss": "^3.3.2",
"typescript": "5.1.6",
"typescript-plugin-css-modules": "5.0.1"
},
"optionalDependencies": {
"sharp": "^0.31.3"
"sharp": "^0.32.0"
},
"next-unused": {
"alias": {

File diff suppressed because it is too large Load diff

View file

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

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 {
display: flex;
flex-direction: column;
place-items: center;
gap: 10px;
max-width: 300px;
width: 100%;
}
.formContentSpace {

View file

@ -1,12 +1,14 @@
"use client"
import { startTransition, Suspense, useState } from "react"
import { useState } from "react"
import styles from "./auth.module.css"
import Link from "../../../components/link"
import { signIn } from "next-auth/react"
import Input from "@components/input"
import Button from "@components/button"
import { GitHub, Key, User } from "react-feather"
import { Input } from "@components/input"
import { Button } from "@components/button"
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 { useRouter } from "next/navigation"
import Note from "@components/note"
@ -52,10 +54,7 @@ function Auth({
})
setSubmitting(false)
} else {
startTransition(() => {
router.push("/new")
router.refresh()
})
router.refresh()
}
}
@ -75,13 +74,10 @@ function Auth({
return (
<div className={styles.container}>
{/* Suspense boundary because useSearchParams causes static bailout */}
<Suspense fallback={null}>
<ErrorQueryParamsHandler />
</Suspense>
<div className={styles.form}>
<ErrorQueryParamsHandler />
<div className={"mx-auto w-[300px]"}>
<div className={styles.formContentSpace}>
<h1>Sign {signText}</h1>
<h1 className="text-3xl font-bold">Sign {signText}</h1>
</div>
<form onSubmit={handleSubmit}>
<div className={styles.formGroup}>
@ -98,7 +94,6 @@ function Auth({
onChange={handleChangeServerPassword}
placeholder="Server Password"
required={true}
width="100%"
aria-label="Server Password"
/>
<hr style={{ width: "100%" }} />
@ -129,7 +124,7 @@ function Auth({
width="100%"
aria-label="Password"
/>
<Button width={"100%"} type="submit" loading={submitting}>
<Button type="submit" loading={submitting}>
Sign {signText}
</Button>
</>
@ -137,25 +132,27 @@ function Auth({
{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) => {
return provider.enabled ? (
<Button
type="submit"
width="100%"
key={provider.id + "-button"}
style={{
color: "var(--fg)"
}}
iconLeft={getProviderIcon(provider.id)}
onClick={(e) => {
e.preventDefault()
signIn(provider.id, {
callbackUrl: "/",
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>
) : null
})}
@ -190,10 +187,10 @@ export default Auth
const getProviderIcon = (provider: string) => {
switch (provider) {
case "github":
return <GitHub />
case "keycloak":
return <Key />
return <GitHub className="mr-2 h-5 w-5" />
case "keycloak":
return <Key className="mr-2 h-5 w-5" />
default:
return <User />
return <User className="mr-2 h-5 w-5" />
}
}

View file

@ -2,9 +2,9 @@
import { useToasts } from "@components/toasts"
import { useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { Suspense, useEffect } from "react"
export function ErrorQueryParamsHandler() {
function InnerErrorQueryParamsHandler() {
const queryParams = useSearchParams()
const { setToast } = useToasts()
@ -19,3 +19,12 @@ export function ErrorQueryParamsHandler() {
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 { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
import { PageWrapper } from "@components/page-wrapper"
export default function SignInPage() {
return (
<Auth
page="signin"
credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()}
/>
<PageWrapper>
<Auth
page="signin"
credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()}
/>
</PageWrapper>
)
}

View file

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

View file

@ -1,13 +1,19 @@
"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 { ChangeEvent, ClipboardEvent, useRef } from "react"
import {
ChangeEvent,
ClipboardEvent,
ComponentProps,
useRef,
useState
} from "react"
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
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
defaultTab: "preview" | "edit"
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
@ -28,37 +34,33 @@ export default function DocumentTabs({
...props
}: Props) {
const codeEditorRef = useRef<TextareaMarkdownRef>(null)
const [activeTab, setActiveTab] = useState<"preview" | "edit">(defaultTab)
const handleTabChange = (newTab: string) => {
if (newTab === "preview") {
codeEditorRef.current?.focus()
}
setActiveTab(newTab as "preview" | "edit")
}
return (
<RadixTabs.Root
{...props}
onValueChange={handleTabChange}
className={styles.root}
defaultValue={defaultTab}
>
<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}>
<Tabs {...props} onValueChange={handleTabChange} defaultValue={defaultTab}>
<TabsList className="flex flex-col items-start justify-start sm:flex-row sm:items-center sm:justify-between">
<div>
<TabsTrigger value="edit">{isEditing ? "Edit" : "Raw"}</TabsTrigger>
<TabsTrigger value="preview">
{isEditing ? "Preview" : "Rendered"}
</RadixTabs.Trigger>
</TabsTrigger>
</div>
{isEditing && (
<FormattingIcons
className={styles.formattingIcons}
textareaRef={codeEditorRef}
className={`ml-auto ${
activeTab === "preview" ? "hidden" : "hidden sm:block"
}`}
/>
)}
</RadixTabs.List>
<RadixTabs.Content value="edit">
</TabsList>
<TabsContent value="edit">
<div
style={{
marginTop: 6,
@ -66,8 +68,16 @@ export default function DocumentTabs({
flexDirection: "column"
}}
>
<FormattingIcons
textareaRef={codeEditorRef}
className={`ml-auto ${
activeTab === "preview"
? "hidden"
: "block text-muted-foreground sm:hidden"
}`}
/>
<TextareaMarkdown.Wrapper ref={codeEditorRef}>
<textarea
<Textarea
readOnly={!isEditing}
onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef}
@ -80,8 +90,8 @@ export default function DocumentTabs({
/>
</TextareaMarkdown.Wrapper>
</div>
</RadixTabs.Content>
<RadixTabs.Content value="preview">
</TabsContent>
<TabsContent value="preview">
{isEditing ? (
<Preview height={"100%"} title={title}>
{rawContent}
@ -89,7 +99,7 @@ export default function DocumentTabs({
) : (
<StaticPreview height={"100%"}>{preview || ""}</StaticPreview>
)}
</RadixTabs.Content>
</RadixTabs.Root>
</TabsContent>
</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;
}
.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: "";
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 clsx from "clsx"
import type { PostWithFiles } from "src/lib/server/prisma"
import styles from "./dropdown.module.css"
import buttonStyles from "@components/button/button.module.css"
import { ChevronDown, Code, File as FileIcon } from "react-feather"
import { Spinner } from "@components/spinner"
import Link from "next/link"
import { buttonVariants } from "@components/button"
function FileDropdown({
files,
@ -18,11 +17,15 @@ function FileDropdown({
if (loading) {
return (
<Popover>
<Popover.Trigger className={buttonStyles.button}>
<PopoverTrigger
className={buttonVariants({
variant: "link"
})}
>
<div style={{ minWidth: 125 }}>
<Spinner />
</div>
</Popover.Trigger>
</PopoverTrigger>
</Popover>
)
}
@ -32,25 +35,26 @@ function FileDropdown({
if (codeFileExtensions.includes(extension || "")) {
return {
...file,
icon: <Code />
icon: <Code className="h-4 w-4" />
}
} else {
return {
...file,
icon: <FileIcon />
icon: <FileIcon className="h-4 w-4" />
}
}
})
const content = (
<ul className={styles.content}>
<ul className="text-sm">
{items.map((item) => (
<li key={item.id}>
<Link href={`#${item.title}`} className={styles.listItem}>
<span className={styles.fileIcon}>{item.icon}</span>
<span className={styles.fileTitle}>
{item.title ? item.title : "Untitled"}
</span>
<li key={item.id} className="flex">
<Link
href={`#${item.title}`}
className="flex w-full items-center gap-3 hover:underline"
>
{item.icon}
{item.title ? item.title : "Untitled"}
</Link>
</li>
))}
@ -59,23 +63,19 @@ function FileDropdown({
return (
<Popover>
<Popover.Trigger
className={buttonStyles.button}
style={{ height: 40, padding: 10 }}
<PopoverTrigger
className={buttonVariants({
variant: "secondary"
})}
>
<div
className={clsx(buttonStyles.icon, styles.chevron)}
style={{ marginRight: 6 }}
>
<div className={styles.chevron} style={{ marginRight: 6 }}>
<ChevronDown />
</div>
<span>
Jump to {files.length} {files.length === 1 ? "file" : "files"}
</span>
</Popover.Trigger>
<Popover.Content className={styles.contentWrapper}>
{content}
</Popover.Content>
</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover>
)
}

View file

@ -1,18 +1,61 @@
.markdownPreview {
padding: var(--gap-quarter);
font-size: 18px;
font-size: 16px;
line-height: 1.75;
color: hsl(var(--foreground));
}
.skeletonPreview {
padding: var(--gap-half);
font-size: 18px;
font-size: 16px;
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 {
margin-top: var(--gap);
margin-bottom: var(--gap);
margin-top: 1.25rem;
margin-bottom: 1.25rem;
/*
&:not(:first-child) {
line-height: 1.75rem;
margin-top: 1.5rem;
} */
}
.markdownPreview pre {
@ -26,31 +69,6 @@
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 */
.markdownPreview h1 a,
.markdownPreview h2 a,
@ -75,44 +93,49 @@
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 {
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 {
content: "";
}
.markdownPreview code::before,
.markdownPreview code::after {
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) {
.markdownPreview h1 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 styles from "../post.module.css"
import clsx from "clsx"
type props = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void
@ -10,7 +11,7 @@ type props = {
function Description({ onChange, description }: props) {
return (
<div className={styles.description}>
<div className={clsx(styles.description, "pb-4")}>
<Input
value={description || ""}
onChange={onChange}

View file

@ -8,18 +8,6 @@
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 {
border-color: var(--gray);
}

View file

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

View file

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

View file

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

View file

@ -1,9 +1,10 @@
import { ChangeEvent, ClipboardEvent, useCallback } from "react"
import styles from "./document.module.css"
import Button from "@components/button"
import Input from "@components/input"
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs"
import { Button } from "@components/button"
import { Input } from "@components/input"
import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
import { Trash } from "react-feather"
import { Card, CardContent, CardHeader } from "@components/card"
type Props = {
title?: string
@ -49,8 +50,8 @@ function Document({
)
return (
<>
<div className={styles.card}>
<Card className="min-h-[512px]">
<CardHeader>
<div className={styles.fileNameContainer}>
<Input
placeholder="MyFile.md"
@ -65,35 +66,31 @@ function Document({
}}
/>
{remove && (
// no left border
<Button
iconLeft={<Trash />}
height={"39px"}
width={"48px"}
padding={0}
margin={0}
onClick={() => removeFile(remove)}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0
}}
/>
variant="outline"
className="border-color-[var(--border)] rounded-l-none border-l-0"
>
<Trash height={18} />
</Button>
)}
</div>
<div className={styles.documentContainer}>
<DocumentTabs
isEditing={true}
defaultTab={defaultTab}
handleOnContentChange={handleOnContentChange}
// TODO: solve types
// @ts-expect-error Type 'HTMLDivElement' is missing the following properties from type 'HTMLTextAreaElement': autocomplete, cols, defaultValue, dirName, and 26 more
onPaste={onPaste}
title={title}
>
{content}
</DocumentTabs>
</div>
</div>
</>
</CardHeader>
<CardContent>
<DocumentTabs
isEditing={true}
defaultTab={defaultTab}
handleOnContentChange={handleOnContentChange}
// TODO: solve types
// @ts-expect-error Type 'HTMLDivElement' is missing the following properties from type 'HTMLTextAreaElement': autocomplete, cols, defaultValue, dirName, and 26 more
onPaste={onPaste}
title={title}
>
{content}
</DocumentTabs>
</CardContent>
</Card>
)
}

View file

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

View file

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

View file

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

View file

@ -1,9 +1,17 @@
import { getMetadata } from "src/app/lib/metadata"
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() {
return <NewPost />
return (
<>
<PageTitle>New Post</PageTitle>
<PageWrapper>
<NewPost />
</PageWrapper>
</>
)
}
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"
import Button from "@components/button"
import { Button } from "@components/button"
import ButtonGroup from "@components/button-group"
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 { useRouter } from "next/navigation"
import { PostWithFiles } from "@lib/server/prisma"
@ -48,15 +47,19 @@ export const PostButtons = ({
return (
<span className={styles.buttons}>
<ButtonGroup verticalIfMobile>
<Button iconLeft={<Edit />} onClick={editACopy}>
<Button variant={"secondary"} onClick={editACopy} className="border-r">
Edit a Copy
</Button>
{parentId && (
<Button iconLeft={<ArrowUpCircle />} onClick={viewParentClick}>
<Button variant={"secondary"} onClick={viewParentClick}>
View Parent
</Button>
)}
<Button onClick={downloadClick} iconLeft={<Archive />}>
<Button
variant={"secondary"}
onClick={downloadClick}
className="border-r"
>
Download as ZIP Archive
</Button>
<FileDropdown loading={loading} files={files || []} />

View file

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

View file

@ -1,4 +1,4 @@
.card header {
/* .card header {
display: flex;
align-items: center;
flex-direction: row;
@ -18,7 +18,7 @@
border: 1px solid var(--lighter-gray);
border-top: none;
border-radius: 0px 0px 8px 8px;
}
} */
.textarea {
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 Skeleton from "@components/skeleton"
import Tooltip from "@components/tooltip"
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs"
import { Tooltip } from "@components/tooltip"
import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
import Link from "next/link"
import { memo } from "react"
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 { PostWithFiles, ServerPost } from "@lib/server/prisma"
import { isAllowedVisibilityForWebpage } from "@lib/constants"
import { Card, CardContent, CardHeader } from "@components/card"
type SharedProps = {
initialTab: "edit" | "preview"
file?: PostWithFiles["files"][0]
@ -36,38 +36,50 @@ const DownloadButtons = ({
}) => {
return (
<ButtonGroup>
<Tooltip content="Download">
<Tooltip content="Download" delayDuration={200}>
<Link
href={`${rawLink}?download=true`}
target="_blank"
rel="noopener noreferrer"
>
<Button
iconRight={<Download color="var(--fg)" />}
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>
</Tooltip>
{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">
<Button
iconLeft={<ExternalLink color="var(--fg)" />}
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>
</Tooltip>
) : null}
{siteLink ? (
<Tooltip content="Open as webpage">
<Tooltip content="Open as webpage" delayDuration={200}>
<Link href={siteLink || ""} target="_blank" rel="noopener noreferrer">
<Button
iconLeft={<Globe color="var(--fg)" />}
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>
</Tooltip>
) : 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 (
<>
<div className={styles.card}>
<header id={file?.title}>
<Card className="border-gray-200 dark:border-gray-900">
<CardHeader
id={file?.title}
className="flex flex-row items-center justify-between py-1 bg-gray-200 dark:bg-gray-900"
>
<Link
href={`#${file?.title}`}
aria-label="File"
style={{
textDecoration: "none",
color: "var(--fg)"
}}
// show an # when hovered avia :after
className="text-gray-900 hover:after:ml-1 hover:after:content-[#] dark:text-gray-100"
>
{file?.title}
</Link>
@ -134,8 +167,8 @@ const Document = ({ skeleton, ...props }: Props) => {
: undefined
}
/>
</header>
<div className={styles.documentContainer}>
</CardHeader>
<CardContent className="flex flex-col h-full pt-2">
<DocumentTabs
defaultTab={props.initialTab}
staticPreview={file?.html}
@ -143,8 +176,8 @@ const Document = ({ skeleton, ...props }: Props) => {
>
{file?.content || ""}
</DocumentTabs>
</div>
</div>
</CardContent>
</Card>
</>
)
}

View file

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

View file

@ -21,11 +21,13 @@ export default async function PostPage({
return (
<>
<PostFiles post={clientPost} />
<VisibilityControl
authorId={post.authorId}
postId={post.id}
visibility={post.visibility}
/>
<div className="mx-auto mb-4 mt-4">
<VisibilityControl
authorId={post.authorId}
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 {
width: 130px;
white-space: nowrap;

View file

@ -1,6 +1,6 @@
"use client"
import Button from "@components/button"
import { Button } from "@components/button"
import { Spinner } from "@components/spinner"
import { useToasts } from "@components/toasts"
import { ServerPostWithFilesAndAuthor, UserWithPosts } from "@lib/server/prisma"
@ -18,7 +18,7 @@ export function UserTable({
id: string
email: string | null
role: string | null
displayName: string | null
username: string | null
}[]
}) {
const { setToast } = useToasts()
@ -26,6 +26,14 @@ export function UserTable({
const deleteUser = async (id: string) => {
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", {
method: "DELETE",
headers: {
@ -53,8 +61,8 @@ export function UserTable({
}
return (
<table className={styles.table}>
<thead>
<table className="w-full overflow-x-auto">
<thead className="text-left">
<tr>
<th>Name</th>
<th>Email</th>
@ -73,14 +81,20 @@ export function UserTable({
) : null}
{users?.map((user) => (
<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.role}</td>
<td className={styles.id} title={user.id}>
{user.id}
</td>
<td>
<Button onClick={() => deleteUser(user.id)}>Delete</Button>
<Button
variant={"destructive"}
onClick={() => deleteUser(user.id)}
size={"sm"}
>
Delete
</Button>
</td>
</tr>
))}
@ -90,7 +104,7 @@ export function UserTable({
}
export function PostTable({
posts
posts: initialPosts
}: {
posts?: {
createdAt: string
@ -100,15 +114,54 @@ export function PostTable({
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 (
<table className={styles.table}>
<thead>
<table className="w-full overflow-x-auto">
<thead className="text-left">
<tr>
<th>Title</th>
<th>Author</th>
<th>Created</th>
<th>Visibility</th>
<th className={styles.id}>Post ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@ -130,6 +183,15 @@ export function PostTable({
<td>{new Date(post.createdAt).toLocaleDateString()}</td>
<td>{post.visibility}</td>
<td>{post.id}</td>
<td>
<Button
variant={"destructive"}
size={"sm"}
onClick={() => deletePost(post.id)}
>
Delete
</Button>
</td>
</tr>
))}
</tbody>

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
import HomePage from "../page"
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 { getMetadata } from "src/app/lib/metadata"
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" })
import { THEME_COOKIE, DEFAULT_THEME, SIGNED_IN_COOKIE } from "@lib/constants"
import { Suspense } from "react"
const CmdK = dynamic(() => import("@components/cmdk"), { ssr: false })
@ -18,22 +17,20 @@ export default async function RootLayout({
}: {
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 (
// 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>
<Toasts />
<Providers>
<Layout>
<CmdK />
<Suspense fallback={<>Loading...</>}>
<Header theme={theme} isAuthenticated={isAuthenticated} />
</Suspense>
{children}
<Header />
<main>{children}</main>
</Layout>
</Providers>
</body>

View file

@ -1,7 +1,15 @@
"use client"
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
import PostList from "@components/post-list"
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 { getMetadata } from "src/app/lib/metadata"
import { redirect } from "next/navigation"
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
export default async function Mine() {
const userId = (await getCurrentUser())?.id
@ -16,16 +18,21 @@ export default async function Mine() {
const posts = (await getPostsByUser(userId, true)).map(serverPostToClientPost)
return (
<ErrorBoundary>
<Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}>
<PostList
userId={userId}
initialPosts={posts}
isOwner={true}
hideSearch={false}
/>
</Suspense>
</ErrorBoundary>
<>
<PageTitle>Your Posts</PageTitle>
<PageWrapper>
<ErrorBoundary>
<Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}>
<PostList
userId={userId}
initialPosts={posts}
isOwner={true}
hideSearch={false}
/>
</Suspense>
</ErrorBoundary>
</PageWrapper>
</>
)
}

View file

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

View file

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

View file

@ -1,7 +1,7 @@
"use client"
import Button from "@components/button"
import Input from "@components/input"
import { Button } from "@components/button"
import { Input } from "@components/input"
import Note from "@components/note"
import { Spinner } from "@components/spinner"
import { useToasts } from "@components/toasts"
@ -13,6 +13,7 @@ import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
import { useState } from "react"
import styles from "./api-keys.module.css"
import { useSessionSWR } from "@lib/use-session-swr"
import { TypographyH4 } from "@components/typography"
// need to pass in the accessToken
const APIKeys = ({
@ -75,7 +76,7 @@ const APIKeys = ({
)}
{hasError && <Note type="error">{error?.message}</Note>}
<form className={styles.form}>
<h5>Create new</h5>
<TypographyH4>Create new</TypographyH4>
<fieldset className={styles.fieldset}>
<Input
type="text"
@ -85,10 +86,9 @@ const APIKeys = ({
placeholder="Name"
/>
<Button
type="button"
onClick={onCreateTokenClick}
loading={submitting}
disabled={!newToken}
loading={submitting}
>
Submit
</Button>
@ -121,7 +121,9 @@ const APIKeys = ({
</tbody>
</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)" }}>

View file

@ -1,7 +1,7 @@
"use client"
import Button from "@components/button"
import Input from "@components/input"
import { Button } from "@components/button"
import { Input } from "@components/input"
import Note from "@components/note"
import { useToasts } from "@components/toasts"
import { useSessionSWR } from "@lib/use-session-swr"
@ -142,8 +142,7 @@ function Profile() {
</div>
</TooltipComponent>
</div> */}
<Button type="submit" loading={submitting}>
<Button type="submit" disabled={!name} loading={submitting}>
Submit
</Button>
</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"
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 APIKeys from "./components/sections/api-keys"
import Profile from "./components/sections/profile"
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
export default async function SettingsPage() {
return (
<>
<SettingsGroup title="Profile">
<Profile />
</SettingsGroup>
<SettingsGroup title="API Keys">
<APIKeys />
</SettingsGroup>
<PageTitle>Settings</PageTitle>
<PageWrapper>
<SettingsGroup title="Profile">
<Profile />
</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 styles from "./badge.module.css"
type BadgeProps = {
type: "primary" | "secondary" | "error" | "warning"
} & React.HTMLAttributes<HTMLDivElement>
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/cn"
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ type, children, ...rest }: BadgeProps, ref) => {
return (
<div className={styles.container} {...rest}>
<div className={`${styles.badge} ${styles[type]}`} ref={ref}>
{children}
</div>
</div>
)
const badgeVariants = cva(
"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",
{
variants: {
variant: {
default:
"bg-primary hover:bg-primary/80 border-transparent text-primary-foreground",
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"
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 { timeAgo } from "src/app/lib/time-ago"
import { useMemo, useState, useEffect } from "react"
import Badge from "../badge"
import { Badge } from "../badge"
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
@ -30,7 +30,7 @@ const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
return (
// TODO: investigate tooltip not showing
<Tooltip content={formattedTime}>
<Badge type="secondary" onClick={onClick}>
<Badge onClick={onClick} variant={"outline"} suppressHydrationWarning>
{" "}
<>{time}</>
</Badge>

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
margin-top: 0 !important;
}
.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 { forwardRef } from "react"
import clsx from "clsx"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/cn"
import { Spinner } from "@components/spinner"
type Props = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> & {
children?: React.ReactNode
buttonType?: "primary" | "secondary"
className?: string
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
iconRight?: React.ReactNode
iconLeft?: React.ReactNode
height?: string | number
width?: string | number
padding?: string | number
margin?: string | number
const buttonVariants = cva(
"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",
{
variants: {
variant: {
default: "bg-primary/80 text-primary-foreground/80 hover:bg-primary/70",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
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
}
// eslint-disable-next-line react/display-name
const Button = forwardRef<HTMLButtonElement, Props>(
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
onClick,
className,
buttonType = "secondary",
disabled = false,
iconRight,
iconLeft,
height = 40,
width,
padding = 10,
margin,
loading,
style,
...props
},
{ className, variant, size, loading, children, asChild = false, ...props },
ref
) => {
const Comp = asChild ? Slot : "button"
return (
<button
<Comp
className={cn(buttonVariants({ variant, size, className }))}
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}
>
{children && iconLeft && (
<span className={clsx(styles.icon, styles.iconLeft)}>{iconLeft}</span>
)}
{!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>
{loading ? <Spinner className="mr-2" /> : null}
{children}
</Comp>
)
}
)
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({
children,
className,
...props
}: {
children?: React.ReactNode
className?: string
} & React.ComponentProps<"div">) {
return (
<div className={`${styles.card} ${className || ""}`} {...props}>
<div className={styles.content}>{children}</div>
</div>
)
}
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
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] {
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"
import { Command } from "cmdk"
import { CommandDialog, CommandList, CommandInput, CommandEmpty } from "./cmdk"
import { useEffect, useRef, useState } from "react"
import styles from "./cmdk.module.css"
import "./dialog.css"
import HomePage from "./pages/home"
import PostsPage from "./pages/posts"
@ -10,7 +9,7 @@ import PostsPage from "./pages/posts"
export type CmdKPage = "home" | "posts"
export default function CmdK() {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement | null>(null)
const ref = useRef<HTMLDivElement>()
const [page, setPage] = useState<CmdKPage>("home")
// Toggle the menu when ⌘K is pressed
@ -53,21 +52,15 @@ export default function CmdK() {
}, [page])
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Global Command Menu"
className={styles.cmdk}
ref={ref}
>
<Command.List>
<Command.Empty>No results found.</Command.Empty>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{page === "home" ? (
<HomePage setPage={setPage} setOpen={setOpen} />
) : null}
{page === "posts" ? <PostsPage setOpen={setOpen} /> : null}
</Command.List>
<Command.Input />
</Command.Dialog>
</CommandList>
<CommandInput />
</CommandDialog>
)
}

View file

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

View file

@ -1,10 +1,9 @@
import { Command } from "cmdk"
import { useTheme } from "next-themes"
import { useRouter } from "next/navigation"
import { FilePlus, Moon, Search, Settings, Sun } from "react-feather"
import { setDriftTheme } from "src/app/lib/set-theme"
import { CmdKPage } from ".."
import Item from "../item"
import { CommandGroup } from "@components/cmdk/cmdk"
export default function HomePage({
setOpen,
@ -17,7 +16,7 @@ export default function HomePage({
const { setTheme, resolvedTheme } = useTheme()
return (
<>
<Command.Group heading="Posts">
<CommandGroup heading="Posts">
<Item
shortcut="R P"
onSelect={() => {
@ -37,12 +36,12 @@ export default function HomePage({
>
New Post
</Item>
</Command.Group>
<Command.Group heading="Settings">
</CommandGroup>
<CommandGroup heading="Settings">
<Item
shortcut="T"
onSelect={() => {
setDriftTheme(resolvedTheme === "dark" ? "light" : "dark", setTheme)
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}}
icon={resolvedTheme === "dark" ? <Sun /> : <Moon />}
>
@ -58,7 +57,7 @@ export default function HomePage({
>
Go to Settings
</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"
import Button from "@components/button"
import { Button } from "@components/button"
import Link from "@components/link"
import Note from "@components/note"
import { TypographyH3 } from "@components/typography"
import { useRouter } from "next/navigation"
// an error fallback for react-error-boundary
@ -14,8 +16,14 @@ import {
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<Note type="error" style={{ width: "100%" }}>
<h3>Something went wrong:</h3>
<TypographyH3>Something went wrong:</TypographyH3>
<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>
</Note>
)

View file

@ -1,4 +1,5 @@
// https://www.joshwcomeau.com/snippets/react-components/fade-in/
import React from "react"
import styles from "./fade.module.css"
function FadeIn({
@ -11,8 +12,18 @@ function FadeIn({
duration?: number
delay?: number
children: React.ReactNode
as?: React.ElementType
as?: React.ElementType | JSX.Element
} & 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"
return (
<Element

View file

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

View file

@ -2,8 +2,8 @@
import { useSelectedLayoutSegments } from "next/navigation"
import FadeIn from "@components/fade-in"
import { setDriftTheme } from "src/app/lib/set-theme"
import {
Circle,
Home,
Moon,
PlusCircle,
@ -13,11 +13,13 @@ import {
UserX
} from "react-feather"
import { signOut } from "next-auth/react"
import Button from "@components/button"
import { Button } from "@components/button"
import Link from "@components/link"
import { useSessionSWR } from "@lib/use-session-swr"
import { useTheme } from "next-themes"
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
const SIGN_IN_WIDTH = 110
@ -38,48 +40,37 @@ type Tab = {
}
)
export function HeaderButtons({
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) {
function NavButton({ className, ...tab }: Tab & { className?: string }) {
const segment = useSelectedLayoutSegments().slice(-1)[0]
const isActive = segment === tab.value.toLowerCase()
const activeStyle = isActive ? styles.active : undefined
const activeStyle = isActive ? "text-primary-500" : "text-gray-600"
if (tab.onClick) {
return (
<Button
key={tab.value}
iconLeft={tab.icon}
onClick={tab.onClick}
className={activeStyle}
className={cn(activeStyle, "w-full md:w-auto", className)}
aria-label={tab.name}
aria-current={isActive ? "page" : undefined}
data-tab={tab.value}
width={tab.width}
variant={"ghost"}
>
{tab.name ? tab.name : undefined}
</Button>
)
} else {
return (
<Link key={tab.value} href={tab.href} data-tab={tab.value}>
<Button className={activeStyle} iconLeft={tab.icon} width={tab.width}>
<Link
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}
</Button>
</Link>
@ -87,101 +78,126 @@ function NavButton(tab: Tab) {
}
}
function ThemeButton({ theme }: { theme: string }) {
const { setTheme } = useTheme()
function ThemeButton() {
const { setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
return (
<NavButton
name="Theme"
icon={theme === "dark" ? <Sun /> : <Moon />}
value="dark"
onClick={() => {
setDriftTheme(theme === "dark" ? "light" : "dark", setTheme)
}}
key="theme"
/>
<>
{!mounted && (
<NavButton
name="Theme"
icon={<Circle opacity={0.3} />}
value="dark"
href=""
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 getButtons({
isAuthenticated,
theme,
// mutate: mutateSession,
isAdmin,
userId
}: {
isAuthenticated: boolean
theme: string
// 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 ? (
export function HeaderButtons(): JSX.Element {
const { isAdmin, isAuthenticated, userId } = useSessionSWR()
useEffect(() => {
if (isAuthenticated && !userId) {
signOut()
}
}, [isAuthenticated, userId])
return (
<>
<NavButton
name="Sign Out"
key="signout"
icon={<UserX />}
value="signout"
onClick={() => {
signOut({
callbackUrl: `/signedout${
userId ? "?userId=" + userId : ""
}`
})
}}
width={SIGN_IN_WIDTH}
key="home"
name="Home"
icon={<Home />}
value="home"
href="/home"
/>
) : undefined,
isAuthenticated === false ? (
<NavButton
name="Sign In"
key="signin"
key="new"
name="New"
icon={<PlusCircle />}
value="new"
href="/new"
/>
<NavButton
key="yours"
name="Yours"
icon={<User />}
value="signin"
href="/signin"
width={SIGN_IN_WIDTH}
value="mine"
href="/mine"
/>
) : undefined,
isAdmin ? (
<FadeIn>
<NavButton
name="Settings"
icon={<Settings />}
value="settings"
href="/settings"
key="settings"
/>
<ThemeButton key="theme-button" />
{isAdmin && (
<NavButton
name="Admin"
key="admin"
icon={<Settings />}
value="admin"
href="/admin"
className="transition-opacity duration-500"
/>
</FadeIn>
) : undefined
].filter(Boolean)
)}
{isAuthenticated === true && (
<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"
import { HeaderButtons } from "./buttons"
"use client"
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"
export default function Header({
theme,
isAuthenticated
}: {
theme: string
isAuthenticated: boolean
}) {
export default function Header() {
const { isAdmin, isAuthenticated } = useSessionSWR()
const { resolvedTheme, setTheme } = useTheme()
const [isMounted, setIsMounted] = useState(false)
const toggleTheme = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
useEffect(() => {
setIsMounted(true)
}, [])
return (
<header className={styles.header}>
<div className={styles.tabs}>
<div className={styles.buttons}>
<HeaderButtons isAuthenticated={isAuthenticated} theme={theme} />
</div>
</div>
<MobileHeader isAuthenticated={isAuthenticated} theme={theme} />
<header className="mt-4 flex h-16 items-center justify-start md:justify-between">
<span className="hidden items-center md:flex">
<Link href="/" className="mr-4 flex items-center">
<Image
src={"/assets/logo.svg"}
width={32}
height={32}
alt=""
priority
/>
<span className="bg-transparent pl-4 text-lg font-bold">Drift</span>
</Link>
<nav className="flex space-x-4 lg:space-x-6">
<ul className="flex justify-center space-x-4">
<NavLink href="/home">Home</NavLink>
<NavLink href="/new" disabled={!isAuthenticated}>
New
</NavLink>
<NavLink href="/mine" disabled={!isAuthenticated}>
Yours
</NavLink>
<NavLink href="/settings" disabled={!isAuthenticated}>
Settings
</NavLink>
{isAdmin && <NavLink href="/admin">Admin</NavLink>}
{isAuthenticated !== undefined && (
<>
{isAuthenticated === true && (
<NavLink href="/signout">Sign Out</NavLink>
)}
{isAuthenticated === false && (
<NavLink href="/signin">Sign In</NavLink>
)}
</>
)}
</ul>
</nav>
</span>
<span className="flex items-center justify-center md:hidden">
<MobileHeader />
</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>
)
}
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"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import buttonStyles from "@components/button/button.module.css"
import Button from "@components/button"
import { Button, buttonVariants } from "@components/button"
import { Menu } from "react-feather"
import clsx from "clsx"
import styles from "./mobile.module.css"
import { getButtons } from "./buttons"
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
})
import { HeaderButtons } from "./buttons"
import * as DropdownMenu from "@components/dropdown-menu"
import React from "react"
export default function MobileHeader() {
// TODO: this is a hack to close the radix ui menu when a next link is clicked
const onClick = () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
}
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger
className={clsx(buttonStyles.button, styles.mobileTrigger)}
<DropdownMenu.DropdownMenu>
<DropdownMenu.DropdownMenuTrigger
className={buttonVariants({ variant: "ghost" })}
asChild
>
<Button aria-label="Menu" height="auto">
<Button aria-label="Menu" variant={"ghost"}>
<Menu />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content>
{buttons.map((button) => (
<DropdownMenu.Item
</DropdownMenu.DropdownMenuTrigger>
<DropdownMenu.DropdownMenuPortal>
<DropdownMenu.DropdownMenuContent>
{HeaderButtons().props.children.map((button: JSX.Element) => (
<DropdownMenu.DropdownMenuItem
key={`mobile-${button?.key}`}
className={styles.dropdownItem}
onClick={onClick}
>
{button}
</DropdownMenu.Item>
</DropdownMenu.DropdownMenuItem>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</DropdownMenu.DropdownMenuContent>
</DropdownMenu.DropdownMenuPortal>
</DropdownMenu.DropdownMenu>
)
}

View file

@ -1,82 +1,51 @@
import clsx from "clsx"
import React from "react"
import styles from "./input.module.css"
import * as React from "react"
type Props = React.HTMLProps<HTMLInputElement> & {
import { cn } from "@lib/cn"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
label?: string
width?: number | string
height?: number | string
labelClassName?: string
hideLabel?: boolean
}
// 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>(
(
{ label, className, required, width, height, labelClassName, ...props },
ref
) => {
const labelId = label?.replace(/\s/g, "-").toLowerCase()
({ className, type, label, hideLabel, ...props }, ref) => {
const id = React.useId()
return (
<div
className={styles.wrapper}
style={{
width,
height
}}
>
{label && (
<span className="flex w-full flex-row items-center">
{label && !hideLabel ? (
<label
htmlFor={labelId}
className={clsx(styles.label, labelClassName)}
htmlFor={id}
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>
)}
) : null}
{label && hideLabel ? (
<label htmlFor={id} className="sr-only">
{label}
</label>
) : null}
<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}
id={labelId}
className={clsx(styles.input, label && styles.withLabel, className)}
required={required}
id={id}
{...props}
style={{
width,
height,
...(props.style || {})
}}
/>
</div>
</span>
)
}
)
Input.displayName = "Input"
export default Input
export { Input }

View file

@ -2,6 +2,7 @@
import clsx from "clsx"
import styles from "./page.module.css"
import Link from "@components/link"
export default function Layout({
children,
@ -12,7 +13,22 @@ export default function Layout({
}) {
return (
<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>
)
}

View file

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

View file

@ -1,15 +1,15 @@
import NextLink from "next/link"
import styles from "./link.module.css"
import { cn } from "@lib/cn"
type LinkProps = {
colored?: boolean
children: React.ReactNode
} & React.ComponentProps<typeof NextLink>
const Link = ({ colored, children, ...props }: LinkProps) => {
const className = colored ? `${styles.link} ${styles.color}` : styles.link
const Link = ({ colored, className, children, ...props }: LinkProps) => {
const classes = colored ? "text-blue-500 dark:text-blue-400 hover:underline" : "hover:underline"
return (
<NextLink {...props} className={className}>
<NextLink {...props} className={cn(classes, className)}>
{children}
</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"
children: React.ReactNode
} & React.ComponentProps<"div">) => (
<div className={clsx(className, styles.note, styles[type])} {...props}>
<div
className={clsx(className, styles.note, styles[type], "text-sm")}
{...props}
>
{children}
</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 * as Dialog from "@radix-ui/react-dialog"
import { useState } from "react"
import { MouseEventHandler, useState } from "react"
import styles from "./modal.module.css"
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogAction,
AlertDialogCancel,
AlertDialogFooter
} from "@components/alert-dialog"
type Props = {
creating: boolean
@ -22,7 +30,8 @@ const PasswordModal = ({
const [confirmPassword, setConfirmPassword] = useState<string>("")
const [error, setError] = useState<string>()
const onSubmit = () => {
const onSubmit: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault()
if (!password || (creating && !confirmPassword)) {
setError("Please enter a password")
return
@ -39,60 +48,57 @@ const PasswordModal = ({
return (
<>
{
<Dialog.Root
<AlertDialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay} />
<Dialog.Content
className={styles.content}
onEscapeKeyDown={onClose}
>
<Dialog.Title>
{/* <AlertDialogOverlay className={styles.overlay} /> */}
<AlertDialogContent onEscapeKeyDown={onClose}>
<AlertDialogHeader>
<AlertDialogTitle>
{creating ? "Add a password" : "Enter password"}
</Dialog.Title>
<Dialog.Description>
</AlertDialogTitle>
<AlertDialogDescription>
{creating
? "Enter a password to protect your post"
: "Enter the password to access the post"}
</Dialog.Description>
<fieldset className={styles.fieldset}>
{!error && creating && (
<Note type="warning">
This doesn&apos;t protect your post from the server
administrator.
</Note>
)}
{error && <Note type="error">{error}</Note>}
</AlertDialogDescription>
</AlertDialogHeader>
<fieldset className={styles.fieldset}>
{!error && creating && (
<Note type="warning">
This doesn&apos;t protect your post from the server
administrator.
</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
width={"100%"}
label="Password"
label="Confirm"
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
/>
{creating && (
<Input
width={"100%"}
label="Confirm"
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
/>
)}
</fieldset>
<footer className={styles.footer}>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={onSubmit}>Submit</Button>
</footer>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)}
</fieldset>
<AlertDialogFooter>
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onSubmit}>Submit</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
</>
)

View file

@ -1,35 +1,31 @@
// largely from https://github.com/shadcn/taxonomy
"use client"
import * as React from "react"
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) {
return <PopoverPrimitive.Root {...props} />
}
const Popover = PopoverPrimitive.Root
Popover.Trigger = React.forwardRef<
HTMLButtonElement,
PopoverPrimitive.PopoverTriggerProps
>(function PopoverTrigger({ ...props }, ref) {
return <PopoverPrimitive.Trigger {...props} ref={ref} />
})
const PopoverTrigger = PopoverPrimitive.Trigger
Popover.Portal = PopoverPrimitive.Portal
Popover.Content = React.forwardRef<
HTMLDivElement,
PopoverPrimitive.PopoverContentProps
>(function PopoverContent({ className, ...props }, ref) {
return (
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align="end"
className={clsx(styles.root, className)}
align={align}
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}
/>
)
})
</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 { ChangeEvent, useCallback, useState } from "react"
import type { PostWithFiles } from "@lib/server/prisma"
import Input from "@components/input"
import { Input } from "@components/input"
import { useToasts } from "@components/toasts"
import { ListItemSkeleton } from "./list-item-skeleton"
import Link from "@components/link"
@ -83,7 +83,10 @@ const PostList = ({
})
if (!res?.ok) {
console.error(res)
setToast({
message: "Failed to delete post",
type: "error"
})
return
} else {
setPosts((posts) => posts?.filter((post) => post.id !== postId))
@ -103,7 +106,7 @@ const PostList = ({
<Input
placeholder="Search..."
onChange={onSearchChange}
disabled={!posts}
disabled={!posts || posts.length === 0}
style={{ maxWidth: 300 }}
aria-label="Search"
value={searchValue}
@ -132,6 +135,7 @@ const PostList = ({
})}
</ul>
) : null}
{!showSkeleton && posts && posts.length === 0 && <NoPostsFound />}
</Stack>
)
}

View file

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

View file

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

View file

@ -6,20 +6,31 @@ import { useRouter } from "next/navigation"
import styles from "./list-item.module.css"
import Link from "@components/link"
import type { PostWithFiles } from "@lib/server/prisma"
import Tooltip from "@components/tooltip"
import Badge from "@components/badges/badge"
import Card from "@components/card"
import Button from "@components/button"
import { Badge } from "@components/badges/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@components/card"
import {
ArrowUpCircle,
Code,
Database,
Edit,
FileText,
MoreVertical,
Terminal,
Trash
} from "react-feather"
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
const ListItem = ({
@ -66,80 +77,88 @@ const ListItem = ({
}
return (
<FadeIn key={post.id}>
<li>
<Card style={{ overflowY: "scroll" }}>
<>
<div className={styles.title}>
<span className={styles.titleText}>
<h4 style={{ display: "inline-block", margin: 0 }}>
<Link
colored
style={{ marginRight: "var(--gap)" }}
href={`/post/${post.id}`}
>
{post.title}
</Link>
</h4>
<div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} />
<Badge type="secondary">
{post.files?.length === 1
? "1 file"
: `${post.files?.length || 0} files`}
</Badge>
<CreatedAgoBadge createdAt={post.createdAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} />
</div>
<FadeIn key={post.id} as="li">
<Card className="overflow-y-scroll h-42">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2">
<span className={styles.titleText}>
<h4 style={{ display: "inline-block", margin: 0 }}>
<Link
colored
style={{ marginRight: "var(--gap)" }}
href={`/post/${post.id}`}
>
{post.title}
</Link>
</h4>
<div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} />
<Badge variant={"outline"}>
{post.files?.length === 1
? "1 file"
: `${post.files?.length || 0} files`}
</Badge>
<CreatedAgoBadge createdAt={post.createdAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} />
</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>
{!hideActions ? (
<span className={styles.buttons}>
{post.parentId && (
<Tooltip content={"View parent"}>
<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 && (
) : null}
</CardTitle>
{post.description && (
<CardDescription>
<p className={styles.oneline}>{post.description}</p>
)}
</>
</CardDescription>
)}
</CardHeader>
<CardContent>
<ul className={styles.files}>
{post?.files?.map(
(file: Pick<PostWithFiles, "files">["files"][0]) => {
return (
<li key={file.id}>
<li key={file.id} className="text-black">
<Link
colored
href={`/post/${post.id}#${file.title}`}
style={{
display: "flex",
alignItems: "center"
}}
className="flex items-center gap-2 font-mono text-sm text-foreground"
>
{getIconFromFilename(file.title)}
{file.title || "Untitled file"}
@ -149,8 +168,8 @@ const ListItem = ({
}
)}
</ul>
</Card>
</li>
</CardContent>
</Card>
</FadeIn>
)
}

View file

@ -1,7 +1,7 @@
"use client"
import Button from "@components/button"
import Tooltip from "@components/tooltip"
import { Button } from "@components/button"
import { Tooltip } from "@components/tooltip"
import { useEffect, useState } from "react"
import { ChevronUp } from "react-feather"
import styles from "./scroll.module.css"
@ -39,8 +39,10 @@ const ScrollToTop = () => {
<Button
aria-label="Scroll to Top"
onClick={onClick}
iconLeft={<ChevronUp />}
/>
variant={"secondary"}
>
<ChevronUp />
</Button>
</Tooltip>
</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"
type Props =
@ -26,9 +26,13 @@ const SettingsGroup = ({ title, children, skeleton }: Props) => {
return (
<Card>
<h4>{title}</h4>
<hr />
<div className={styles.content}>{children}</div>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<hr className="pb-4" />
<CardContent>
<div className={styles.content}>{children}</div>
</CardContent>
</Card>
)
}

View file

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

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