Compare commits

..

2 commits

Author SHA1 Message Date
Max Leiter
77533aa4c8 Merge remote-tracking branch 'origin/refactor' into max/dep-updates 2023-05-18 18:30:36 -07:00
Max Leiter
11ac185f86 lint fixes, minor copy adjustments, dep updates 2023-05-18 18:25:11 -07:00
125 changed files with 4068 additions and 5639 deletions

View file

@ -23,7 +23,8 @@ GITHUB_CLIENT_SECRET=
# Optional: if you want Keycloak oauth. Currently incompatible with the registration password
KEYCLOAK_ID=
KEYCLOAK_SECRET=
KEYCLOAK_ISSUER= # keycloak path including realm
# keycloak path including realm
KEYCLOAK_ISSUER=
KEYCLOAK_NAME=
# Optional: if you want to support credential auth (username/password, supports registration password)
@ -31,5 +32,5 @@ KEYCLOAK_NAME=
CREDENTIAL_AUTH=true
# Optional:
WELCOME_CONTENT=
WELCOME_TITLE=
WELCOME_CONTENT="## Drift is a self-hostable clone of GitHub Gist. \nIt is a simple way to share code and text snippets. It supportsthe following:\n \n - Render GitHub Extended Markdown (including images)\n - User authentication\n - Private, public, and password protected posts\n - Markdown is rendered and stored on the server\n - Syntax highlighting and automatic language detection\n - Drag-and-drop file uploading\n\n If you want to signup, you can join at [/signup](/signup) as long as you have a passcode provided by the administrator (which you don\'t need for this demo). **This demo is on a memory-only database, so accounts and pastes can be deleted at any time.** \n\nYou can find the source code on [GitHub](https://github.com/MaxLeiter/drift)."
WELCOME_TITLE="Welcome to Drift"

View file

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

1
.gitignore vendored
View file

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

View file

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

View file

@ -8,8 +8,6 @@ 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:**
@ -50,7 +48,6 @@ 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.
@ -71,55 +68,6 @@ 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.

View file

@ -1,16 +0,0 @@
{
"$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,7 +4,9 @@ import bundleAnalyzer from "@next/bundle-analyzer"
const nextConfig = {
reactStrictMode: true,
experimental: {
appDir: true
// esmExternals: true,
appDir: true,
serverComponentsExternalPackages: ["prisma", "@prisma/client"],
},
rewrites() {
return [
@ -12,35 +14,16 @@ 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 ||
(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}}"
}
NEXT_PUBLIC_DRIFT_URL: process.env.DRIFT_URL
}
}
export default process.env.ANALYZE === "true"
? bundleAnalyzer({ enabled: true })(nextConfig)
: nextConfig
export default bundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(
nextConfig
)

View file

@ -13,48 +13,41 @@
"jest": "jest"
},
"dependencies": {
"@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",
"@next-auth/prisma-adapter": "^1.0.5",
"@next/eslint-plugin-next": "13.3.1-canary.6",
"@prisma/client": "^4.12.0",
"@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",
"@tailwindcss/nesting": "0.0.0-insiders.565cd3e",
"@tailwindcss/typography": "^0.5.9",
"class-variance-authority": "^0.6.0",
"@vercel/og": "^0.5.2",
"client-only": "^0.0.1",
"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.4.11-canary.1",
"next-auth": "^4.22.3",
"next": "13.4.3-canary.2",
"next-auth": "^4.22.0",
"next-themes": "^0.2.1",
"react": "18.2.0",
"react-cookie": "^4.1.1",
"react-datepicker": "4.10.0",
"react-day-picker": "^8.8.0",
"react-datepicker": "4.11.0",
"react-dom": "18.2.0",
"react-dropzone": "14.2.3",
"react-error-boundary": "^4.0.4",
"react-error-boundary": "^4.0.3",
"react-feather": "^2.0.10",
"react-hot-toast": "2.4.1",
"react-hot-toast": "2.4.0",
"server-only": "^0.0.1",
"swr": "^2.2.0",
"tailwind-merge": "^1.13.0",
"tailwindcss-animate": "^1.0.5",
"swr": "^2.1.3",
"textarea-markdown-editor": "1.0.4",
"ts-jest": "^29.1.0",
"uuid": "^9.0.0"
"ts-morph": "^18.0.0",
"uuid": "^9.0.0",
"zlib": "^1.0.5"
},
"devDependencies": {
"@next/bundle-analyzer": "13.4.11-canary.0",
"@next/bundle-analyzer": "13.4.3-canary.2",
"@total-typescript/ts-reset": "^0.4.2",
"@types/bcrypt": "^5.0.0",
"@types/git-http-backend": "^1.0.1",
@ -68,25 +61,22 @@
"@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.2",
"dotenv": "^16.0.3",
"eslint": "8.38.0",
"eslint-config-next": "13.4.11-canary.1",
"eslint-config-next": "13.4.3-canary.2",
"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.4.1",
"postcss-preset-env": "^8.3.1",
"prettier": "2.8.7",
"prettier-plugin-tailwindcss": "^0.3.0",
"prisma": "^5.0.0",
"tailwindcss": "^3.3.2",
"typescript": "5.1.6",
"prisma": "^4.12.0",
"typescript": "5.0.4",
"typescript-plugin-css-modules": "5.0.1"
},
"optionalDependencies": {

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,8 +1,21 @@
.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,14 +1,12 @@
"use client"
import { useState } from "react"
import { startTransition, Suspense, 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 { Key, User } from "react-feather"
// @ts-expect-error - no types
import GitHub from "react-feather/dist/icons/github"
import Input from "@components/input"
import Button from "@components/button"
import { GitHub, Key, User } from "react-feather"
import { useToasts } from "@components/toasts"
import { useRouter } from "next/navigation"
import Note from "@components/note"
@ -34,18 +32,16 @@ function Auth({
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false)
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setSubmitting(true)
const res = await signIn("credentials", {
username,
password,
registration_password: serverPassword,
redirect: false,
// callbackUrl: "/signin",
signingIn: signingIn
signingIn
})
if (res?.error) {
setToast({
@ -54,7 +50,10 @@ function Auth({
})
setSubmitting(false)
} else {
router.refresh()
startTransition(() => {
router.push("/new")
router.refresh()
})
}
}
@ -74,10 +73,13 @@ function Auth({
return (
<div className={styles.container}>
<ErrorQueryParamsHandler />
<div className={"mx-auto w-[300px]"}>
{/* Suspense boundary because useSearchParams causes static bailout */}
<Suspense fallback={null}>
<ErrorQueryParamsHandler />
</Suspense>
<div className={styles.form}>
<div className={styles.formContentSpace}>
<h1 className="text-3xl font-bold">Sign {signText}</h1>
<h1>Sign {signText}</h1>
</div>
<form onSubmit={handleSubmit}>
<div className={styles.formGroup}>
@ -94,6 +96,7 @@ function Auth({
onChange={handleChangeServerPassword}
placeholder="Server Password"
required={true}
width="100%"
aria-label="Server Password"
/>
<hr style={{ width: "100%" }} />
@ -124,7 +127,7 @@ function Auth({
width="100%"
aria-label="Password"
/>
<Button type="submit" loading={submitting}>
<Button width={"100%"} type="submit" loading={submitting}>
Sign {signText}
</Button>
</>
@ -132,27 +135,25 @@ 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"
>
{getProviderIcon(provider.id)} Sign{" "}
{signText.toLowerCase()} with {provider.public_name}
Sign {signText.toLowerCase()} with {provider.public_name}
</Button>
) : null
})}
@ -187,10 +188,10 @@ export default Auth
const getProviderIcon = (provider: string) => {
switch (provider) {
case "github":
return <GitHub className="mr-2 h-5 w-5" />
return <GitHub />
case "keycloak":
return <Key className="mr-2 h-5 w-5" />
return <Key />
default:
return <User className="mr-2 h-5 w-5" />
return <User />
}
}

View file

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

View file

@ -2,17 +2,14 @@ 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 (
<PageWrapper>
<Auth
page="signin"
credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()}
/>
</PageWrapper>
<Auth
page="signin"
credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()}
/>
)
}

View file

@ -1,24 +1,21 @@
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 getRequiresPasscode()
return await getRequiresPasscode()
}
export default async function SignUpPage() {
const requiresPasscode = await getPasscode()
return (
<PageWrapper>
<Auth
page="signup"
requiresServerPassword={requiresPasscode}
credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()}
/>
</PageWrapper>
<Auth
page="signup"
requiresServerPassword={requiresPasscode}
credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()}
/>
)
}

View file

@ -1,26 +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;
}

View file

@ -23,6 +23,36 @@
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,11 +1,12 @@
import { Popover, PopoverContent, PopoverTrigger } from "@components/popover"
import { Popover } 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,
@ -17,15 +18,11 @@ function FileDropdown({
if (loading) {
return (
<Popover>
<PopoverTrigger
className={buttonVariants({
variant: "link"
})}
>
<Popover.Trigger className={buttonStyles.button}>
<div style={{ minWidth: 125 }}>
<Spinner />
</div>
</PopoverTrigger>
</Popover.Trigger>
</Popover>
)
}
@ -35,26 +32,25 @@ function FileDropdown({
if (codeFileExtensions.includes(extension || "")) {
return {
...file,
icon: <Code className="h-4 w-4" />
icon: <Code />
}
} else {
return {
...file,
icon: <FileIcon className="h-4 w-4" />
icon: <FileIcon />
}
}
})
const content = (
<ul className="text-sm">
<ul className={styles.content}>
{items.map((item) => (
<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"}
<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>
</Link>
</li>
))}
@ -63,19 +59,23 @@ function FileDropdown({
return (
<Popover>
<PopoverTrigger
className={buttonVariants({
variant: "secondary"
})}
<Popover.Trigger
className={buttonStyles.button}
style={{ height: 40, padding: 10 }}
>
<div className={styles.chevron} style={{ marginRight: 6 }}>
<div
className={clsx(buttonStyles.icon, styles.chevron)}
style={{ marginRight: 6 }}
>
<ChevronDown />
</div>
<span>
Jump to {files.length} {files.length === 1 ? "file" : "files"}
</span>
</PopoverTrigger>
<PopoverContent>{content}</PopoverContent>
</Popover.Trigger>
<Popover.Content className={styles.contentWrapper}>
{content}
</Popover.Content>
</Popover>
)
}

View file

@ -1,61 +1,18 @@
.markdownPreview {
padding: var(--gap-quarter);
font-size: 16px;
font-size: 18px;
line-height: 1.75;
color: hsl(var(--foreground));
}
.skeletonPreview {
padding: var(--gap-half);
font-size: 16px;
font-size: 18px;
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: 1.25rem;
margin-bottom: 1.25rem;
/*
&:not(:first-child) {
line-height: 1.75rem;
margin-top: 1.5rem;
} */
margin-top: var(--gap);
margin-bottom: var(--gap);
}
.markdownPreview pre {
@ -69,6 +26,31 @@
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,
@ -93,49 +75,44 @@
filter: opacity(0.5);
}
.markdownPreview ul {
margin-bottom: 1.5rem;
margin-left: 1.5rem;
list-style-type: disc;
.markdownPreview h1 {
font-size: 2rem;
}
.markdownPreview ol {
margin-bottom: 1.5rem;
margin-left: 1.5rem;
list-style-type: decimal;
.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;
}
.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,19 +1,13 @@
"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,
ComponentProps,
useRef,
useState
} from "react"
import { ChangeEvent, ClipboardEvent, useRef } from "react"
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
import Preview, { StaticPreview } from "../preview"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/tabs"
import { Textarea } from "@components/textarea"
import styles from "./tabs.module.css"
type Props = ComponentProps<typeof Tabs> & {
type Props = RadixTabs.TabsProps & {
isEditing: boolean
defaultTab: "preview" | "edit"
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
@ -34,33 +28,37 @@ 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 (
<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">
<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}>
{isEditing ? "Preview" : "Rendered"}
</TabsTrigger>
</RadixTabs.Trigger>
</div>
{isEditing && (
<FormattingIcons
className={styles.formattingIcons}
textareaRef={codeEditorRef}
className={`ml-auto ${
activeTab === "preview" ? "hidden" : "hidden sm:block"
}`}
/>
)}
</TabsList>
<TabsContent value="edit">
</RadixTabs.List>
<RadixTabs.Content value="edit">
<div
style={{
marginTop: 6,
@ -68,16 +66,8 @@ 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}
@ -90,8 +80,8 @@ export default function DocumentTabs({
/>
</TextareaMarkdown.Wrapper>
</div>
</TabsContent>
<TabsContent value="preview">
</RadixTabs.Content>
<RadixTabs.Content value="preview">
{isEditing ? (
<Preview height={"100%"} title={title}>
{rawContent}
@ -99,7 +89,7 @@ export default function DocumentTabs({
) : (
<StaticPreview height={"100%"}>{preview || ""}</StaticPreview>
)}
</TabsContent>
</Tabs>
</RadixTabs.Content>
</RadixTabs.Root>
)
}

View file

@ -0,0 +1,60 @@
.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,8 +1,7 @@
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
@ -11,7 +10,7 @@ type props = {
function Description({ onChange, description }: props) {
return (
<div className={clsx(styles.description, "pb-4")}>
<div className={styles.description}>
<Input
value={description || ""}
onChange={onChange}

View file

@ -8,6 +8,18 @@
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,11 +82,12 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
return (
<div className={styles.container}>
<div {...getRootProps()}>
<div {...getRootProps()} className={styles.dropzone}>
<input {...getInputProps()} />
{!isDragActive && (
<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 style={{ color: "var(--gray)" }}>
Drag some files here, or <span className={styles.verb} /> to select
files
</p>
)}
{isDragActive && <p>Release to drop the files here</p>}

View file

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

View file

@ -9,10 +9,9 @@ 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({
@ -70,18 +69,22 @@ 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}
variant="ghost"
>
{React.cloneElement(icon, {
className: "h-4 w-4"
})}
</Button>
buttonType="secondary"
/>
</Tooltip>
))}
</div>

View file

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

View file

@ -3,42 +3,23 @@
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, buttonVariants } from "@components/button"
import Button from "@components/button"
import Input from "@components/input"
import ButtonDropdown from "@components/button-dropdown"
import { useToasts } from "@components/toasts"
import { fetchWithUser } from "src/app/lib/fetch-with-user"
import dynamic from "next/dynamic"
import ButtonDropdown from "@components/button-dropdown"
import clsx from "clsx"
import { Spinner } from "@components/spinner"
import { cn } from "@lib/cn"
import { Calendar as CalendarIcon } from "react-feather"
const DatePicker = dynamic(
() => import("@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>
)
}
)
import dynamic from "next/dynamic"
const DatePicker = dynamic(() => import("react-datepicker"))
const emptyDoc = {
title: "",
@ -64,9 +45,7 @@ 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
@ -149,7 +128,7 @@ function Post({
if (!docs.length) {
setToast({
message: "Please add at least one file",
message: "Please add at least one document",
type: "error"
})
hasErrored = true
@ -188,13 +167,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)
@ -205,6 +184,10 @@ function Post({
return onSubmit("protected", password)
}
function onChangeExpiration(date: Date) {
return setExpiresAt(date)
}
function updateDocTitle(i: number) {
return (title: string) => {
setDocs((docs) =>
@ -255,9 +238,10 @@ function Post({
}
return (
<div className="flex flex-col flex-1 gap-4">
<Title title={title} onChange={onChangeTitle} className="py-4" />
{/* <Description description={description} onChange={onChangeDescription} /> */}
<div className={styles.root}>
<Title title={title} onChange={onChangeTitle} />
<Description description={description} onChange={onChangeDescription} />
<FileDropzone setDocs={uploadDocs} />
<EditDocumentList
onPaste={onPaste}
docs={docs}
@ -265,60 +249,68 @@ function Post({
updateDocContent={updateDocContent}
removeDoc={removeDoc}
/>
<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 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>
</div>
<PasswordModal
creating={true}
@ -332,30 +324,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-half);
gap: var(--gap);
}
.buttons {
@ -19,6 +19,9 @@
align-items: center;
}
.datePicker {
flex: 1;
}
.description {
width: 100%;

View file

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

View file

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

View file

@ -0,0 +1,372 @@
.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,8 +1,9 @@
"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"
@ -47,19 +48,15 @@ export const PostButtons = ({
return (
<span className={styles.buttons}>
<ButtonGroup verticalIfMobile>
<Button variant={"secondary"} onClick={editACopy} className="border-r">
<Button iconLeft={<Edit />} onClick={editACopy}>
Edit a Copy
</Button>
{parentId && (
<Button variant={"secondary"} onClick={viewParentClick}>
<Button iconLeft={<ArrowUpCircle />} onClick={viewParentClick}>
View Parent
</Button>
)}
<Button
variant={"secondary"}
onClick={downloadClick}
className="border-r"
>
<Button onClick={downloadClick} iconLeft={<Archive />}>
Download as ZIP Archive
</Button>
<FileDropdown loading={loading} files={files || []} />

View file

@ -17,9 +17,13 @@ export const PostTitle = ({ post, loading }: TitleProps) => {
const displayName = author?.displayName
return (
<span className={styles.title}>
<h1 className="text-3xl font-bold">
<h1
style={{
fontSize: "1.175rem"
}}
>
{title}{" "}
<span className="text-2xl text-muted-foreground">
<span style={{ color: "var(--gray)" }}>
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/document-tabs"
import Tooltip from "@components/tooltip"
import DocumentTabs from "src/app/(drift)/(posts)/components/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,50 +36,38 @@ const DownloadButtons = ({
}) => {
return (
<ButtonGroup>
<Tooltip content="Download" delayDuration={200}>
<Tooltip content="Download">
<Link
href={`${rawLink}?download=true`}
target="_blank"
rel="noopener noreferrer"
>
<Button
iconRight={<Download color="var(--fg)" />}
aria-label="Download"
size="sm"
className="bg-transparent border-none"
variant={"ghost"}
>
<Download className="w-4 h-4 " />
<span className="sr-only">Download</span>
</Button>
style={{ border: "none", background: "transparent" }}
/>
</Link>
</Tooltip>
{rawLink ? (
<Tooltip content="Open raw in new tab" delayDuration={200}>
<Tooltip content="Open raw in new tab">
<Link href={rawLink || ""} target="_blank" rel="noopener noreferrer">
<Button
iconLeft={<ExternalLink color="var(--fg)" />}
aria-label="Open raw file in new tab"
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>
style={{ border: "none", background: "transparent" }}
/>
</Link>
</Tooltip>
) : null}
{siteLink ? (
<Tooltip content="Open as webpage" delayDuration={200}>
<Tooltip content="Open as webpage">
<Link href={siteLink || ""} target="_blank" rel="noopener noreferrer">
<Button
iconLeft={<Globe color="var(--fg)" />}
aria-label="Open as webpage"
className="bg-transparent border-none"
size="sm"
variant={"ghost"}
>
<Globe className="w-4 h-4" />
<span className="sr-only">Open as webpage</span>
</Button>
style={{ border: "none", background: "transparent" }}
/>
</Link>
</Tooltip>
) : null}
@ -121,39 +109,18 @@ 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 (
<>
<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"
>
<div className={styles.card}>
<header id={file?.title}>
<Link
href={`#${file?.title}`}
aria-label="File"
// show an # when hovered avia :after
className="text-gray-900 hover:after:ml-1 hover:after:content-[#] dark:text-gray-100"
style={{
textDecoration: "none",
color: "var(--fg)"
}}
>
{file?.title}
</Link>
@ -167,8 +134,8 @@ const Document = ({ skeleton, ...props }: Props) => {
: undefined
}
/>
</CardHeader>
<CardContent className="flex flex-col h-full pt-2">
</header>
<div className={styles.documentContainer}>
<DocumentTabs
defaultTab={props.initialTab}
staticPreview={file?.html}
@ -176,8 +143,8 @@ const Document = ({ skeleton, ...props }: Props) => {
>
{file?.content || ""}
</DocumentTabs>
</CardContent>
</Card>
</div>
</div>
</>
)
}

View file

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

View file

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

View file

@ -1,3 +1,13 @@
.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
username: string | null
displayName: string | null
}[]
}) {
const { setToast } = useToasts()
@ -26,14 +26,6 @@ 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: {
@ -61,8 +53,8 @@ export function UserTable({
}
return (
<table className="w-full overflow-x-auto">
<thead className="text-left">
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
@ -81,20 +73,14 @@ export function UserTable({
) : null}
{users?.map((user) => (
<tr key={user.id}>
<td>{user.username ? user.username : "no name"}</td>
<td>{user.displayName ? user.displayName : "no name"}</td>
<td>{user.email}</td>
<td>{user.role}</td>
<td className={styles.id} title={user.id}>
{user.id}
</td>
<td>
<Button
variant={"destructive"}
onClick={() => deleteUser(user.id)}
size={"sm"}
>
Delete
</Button>
<Button onClick={() => deleteUser(user.id)}>Delete</Button>
</td>
</tr>
))}
@ -104,7 +90,7 @@ export function UserTable({
}
export function PostTable({
posts: initialPosts
posts
}: {
posts?: {
createdAt: string
@ -114,54 +100,15 @@ 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="w-full overflow-x-auto">
<thead className="text-left">
<table className={styles.table}>
<thead>
<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>
@ -183,15 +130,6 @@ 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,13 +1,12 @@
import { TypographyH1, TypographyH2 } from "@components/typography"
import { PostTable, UserTable } from "./components/tables"
export default function AdminLoading() {
return (
<div>
<TypographyH1>Admin</TypographyH1>
<TypographyH2>Users</TypographyH2>
<h1>Admin</h1>
<h2>Users</h2>
<UserTable />
<TypographyH2>Posts</TypographyH2>
<h2>Posts</h2>
<PostTable />
</div>
)

View file

@ -6,20 +6,15 @@ 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,
createdAt: true,
email: true,
role: true,
username: true
name: true,
createdAt: true
}
})
const postsPromise = getAllPosts({
select: {
id: true,
@ -48,15 +43,13 @@ export default async function AdminPage() {
})
return (
<>
<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>
</>
<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>
)
}

View file

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

View file

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

View file

@ -6,9 +6,10 @@ import Header from "@components/header"
import { Inter } from "next/font/google"
import { getMetadata } from "src/app/lib/metadata"
import dynamic from "next/dynamic"
import clsx from "clsx"
import Link from "@components/link"
import { cookies } from "next/headers"
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 })
@ -17,20 +18,22 @@ 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={clsx(inter.variable, "mx-auto w-[var(--main-content)]")}
suppressHydrationWarning
>
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body>
<Toasts />
<Providers>
<Layout>
<CmdK />
<Header />
<main>{children}</main>
<Suspense fallback={<>Loading...</>}>
<Header theme={theme} isAuthenticated={isAuthenticated} />
</Suspense>
{children}
</Layout>
</Providers>
</body>

View file

@ -1,15 +1,7 @@
"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 (
<>
<PageTitle>Your Posts</PageTitle>
<PageWrapper></PageWrapper>
<PostList skeleton={true} initialPosts={[]} />
</>
)
return <PostList skeleton={true} initialPosts={[]} />
}

View file

@ -5,8 +5,6 @@ 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
@ -18,21 +16,16 @@ export default async function Mine() {
const posts = (await getPostsByUser(userId, true)).map(serverPostToClientPost)
return (
<>
<PageTitle>Your Posts</PageTitle>
<PageWrapper>
<ErrorBoundary>
<Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}>
<PostList
userId={userId}
initialPosts={posts}
isOwner={true}
hideSearch={false}
/>
</Suspense>
</ErrorBoundary>
</PageWrapper>
</>
<ErrorBoundary>
<Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}>
<PostList
userId={userId}
initialPosts={posts}
isOwner={true}
hideSearch={false}
/>
</Suspense>
</ErrorBoundary>
)
}

View file

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

View file

@ -10,12 +10,7 @@ export function Providers({ children }: PropsWithChildren<unknown>) {
return (
<SessionProvider>
<RadixTooltip.Provider delayDuration={200}>
<ThemeProvider
attribute="class"
enableColorScheme
enableSystem
defaultTheme="dark"
>
<ThemeProvider 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,7 +13,6 @@ 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 = ({
@ -76,7 +75,7 @@ const APIKeys = ({
)}
{hasError && <Note type="error">{error?.message}</Note>}
<form className={styles.form}>
<TypographyH4>Create new</TypographyH4>
<h5>Create new</h5>
<fieldset className={styles.fieldset}>
<Input
type="text"
@ -86,9 +85,10 @@ const APIKeys = ({
placeholder="Name"
/>
<Button
type="button"
onClick={onCreateTokenClick}
disabled={!newToken}
loading={submitting}
disabled={!newToken}
>
Submit
</Button>
@ -121,9 +121,7 @@ const APIKeys = ({
</tbody>
</table>
) : (
<p className="p-4 text-center text-muted-foreground">
No API keys found.
</p>
<p>You have no API keys.</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,7 +142,8 @@ function Profile() {
</div>
</TooltipComponent>
</div> */}
<Button type="submit" disabled={!name} loading={submitting}>
<Button type="submit" loading={submitting}>
Submit
</Button>
</form>

View file

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

View file

@ -2,21 +2,16 @@ 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 (
<>
<PageTitle>Settings</PageTitle>
<PageWrapper>
<SettingsGroup title="Profile">
<Profile />
</SettingsGroup>
<SettingsGroup title="API Keys">
<APIKeys />
</SettingsGroup>
</PageWrapper>
<SettingsGroup title="Profile">
<Profile />
</SettingsGroup>
<SettingsGroup title="API Keys">
<APIKeys />
</SettingsGroup>
</>
)
}

View file

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

View file

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

@ -1,150 +0,0 @@
"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,35 +1,21 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/cn"
import React from "react"
import styles from "./badge.module.css"
type BadgeProps = {
type: "primary" | "secondary" | "error" | "warning"
} & React.HTMLAttributes<HTMLDivElement>
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"
}
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>
)
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
Badge.displayName = "Badge"
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
export default Badge

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 onClick={onClick} variant={"outline"} suppressHydrationWarning>
<Badge type="secondary" onClick={onClick}>
{" "}
<>{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
@ -43,7 +43,7 @@ const ExpirationBadge = ({
const isExpired = expirationDate < new Date()
return (
<Badge variant={isExpired ? "destructive" : "outline"}>
<Badge type={isExpired ? "error" : "warning"}>
<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 variant={"outline"}>{visibility}</Badge>
return <Badge type={"primary"}>{visibility}</Badge>
}
export default VisibilityBadge

View file

@ -3,8 +3,9 @@
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"
@ -88,40 +89,39 @@ function VisibilityControl({
}
return (
<FadeIn className="mt-8">
<ButtonGroup>
<FadeIn>
<ButtonGroup
style={{
maxWidth: 600,
margin: "var(--gap) auto"
}}
>
<Button
disabled={visibility === "private"}
variant={"outline"}
onClick={() => onSubmit("private")}
loading={isSubmitting === "private"}
>
Make Private
{isSubmitting === "private" ? <Spinner /> : "Make Private"}
</Button>
<Button
disabled={visibility === "public"}
variant={"outline"}
onClick={() => onSubmit("public")}
loading={isSubmitting === "public"}
>
Make Public
{isSubmitting === "public" ? <Spinner /> : "Make Public"}
</Button>
<Button
disabled={visibility === "unlisted"}
variant={"outline"}
onClick={() => onSubmit("unlisted")}
loading={isSubmitting === "unlisted"}
>
Make Unlisted
{isSubmitting === "unlisted" ? <Spinner /> : "Make Unlisted"}
</Button>
<Button
onClick={() => onSubmit("protected")}
variant={"outline"}
loading={isSubmitting === "protected"}
>
{visibility === "protected"
? "Change Password"
: "Protect with Password"}
<Button onClick={() => onSubmit("protected")}>
{isSubmitting === "protected" ? (
<Spinner />
) : visibility === "protected" ? (
"Change Password"
) : (
"Protect with Password"
)}
</Button>
</ButtonGroup>
<PasswordModal

View file

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

View file

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

View file

@ -0,0 +1,56 @@
.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,65 +1,86 @@
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 styles from "./button.module.css"
import { forwardRef } from "react"
import clsx from "clsx"
import { Spinner } from "@components/spinner"
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
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
loading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
// eslint-disable-next-line react/display-name
const Button = forwardRef<HTMLButtonElement, Props>(
(
{ className, variant, size, loading, children, asChild = false, ...props },
{
children,
onClick,
className,
buttonType = "secondary",
disabled = false,
iconRight,
iconLeft,
height = 40,
width,
padding = 10,
margin,
loading,
style,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
<button
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}
>
{loading ? <Spinner className="mr-2" /> : null}
{children}
</Comp>
{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>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
export default Button

View file

@ -1,64 +0,0 @@
"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,78 +1,16 @@
import * as React from "react"
import { cn } from "@lib/cn"
import styles from "./card.module.css"
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 }
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>
)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,10 @@
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,
@ -16,7 +17,7 @@ export default function HomePage({
const { setTheme, resolvedTheme } = useTheme()
return (
<>
<CommandGroup heading="Posts">
<Command.Group heading="Posts">
<Item
shortcut="R P"
onSelect={() => {
@ -36,12 +37,12 @@ export default function HomePage({
>
New Post
</Item>
</CommandGroup>
<CommandGroup heading="Settings">
</Command.Group>
<Command.Group heading="Settings">
<Item
shortcut="T"
onSelect={() => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
setDriftTheme(resolvedTheme === "dark" ? "light" : "dark", setTheme)
}}
icon={resolvedTheme === "dark" ? <Sun /> : <Moon />}
>
@ -57,7 +58,7 @@ export default function HomePage({
>
Go to Settings
</Item>
</CommandGroup>
</Command.Group>
</>
)
}

View file

@ -1,50 +0,0 @@
"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

@ -1,128 +0,0 @@
"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

@ -1,200 +0,0 @@
"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,9 +1,7 @@
"use client"
import { Button } from "@components/button"
import Link from "@components/link"
import Button from "@components/button"
import Note from "@components/note"
import { TypographyH3 } from "@components/typography"
import { useRouter } from "next/navigation"
// an error fallback for react-error-boundary
@ -16,14 +14,8 @@ import {
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<Note type="error" style={{ width: "100%" }}>
<TypographyH3>Something went wrong:</TypographyH3>
<h3>Something went wrong:</h3>
<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,5 +1,4 @@
// https://www.joshwcomeau.com/snippets/react-components/fade-in/
import React from "react"
import styles from "./fade.module.css"
function FadeIn({
@ -12,18 +11,8 @@ function FadeIn({
duration?: number
delay?: number
children: React.ReactNode
as?: React.ElementType | JSX.Element
as?: React.ElementType
} & 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

@ -0,0 +1,3 @@
.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,13 +13,11 @@ 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
@ -40,37 +38,50 @@ type Tab = {
}
)
function NavButton({ className, ...tab }: Tab & { className?: string }) {
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) {
const segment = useSelectedLayoutSegments().slice(-1)[0]
const isActive = segment === tab.value.toLowerCase()
const activeStyle = isActive ? "text-primary-500" : "text-gray-600"
const activeStyle = isActive ? styles.active : undefined
if (tab.onClick) {
return (
<Button
key={tab.value}
iconLeft={tab.icon}
onClick={tab.onClick}
className={cn(activeStyle, "w-full md:w-auto", className)}
className={activeStyle}
aria-label={tab.name}
aria-current={isActive ? "page" : undefined}
data-tab={tab.value}
variant={"ghost"}
width={tab.width}
>
{tab.name ? tab.name : undefined}
</Button>
)
} else {
return (
<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"}
>
<Link key={tab.value} href={tab.href} data-tab={tab.value}>
<Button className={activeStyle} iconLeft={tab.icon} width={tab.width}>
{tab.name ? tab.name : undefined}
</Button>
</Link>
@ -78,126 +89,99 @@ function NavButton({ className, ...tab }: Tab & { className?: string }) {
}
}
function ThemeButton() {
const { setTheme, resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
function ThemeButton({ theme }: { theme: string }) {
const { setTheme } = useTheme()
return (
<>
{!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"
/>
)}
</>
<NavButton
name="Theme"
icon={theme === "dark" ? <Sun /> : <Moon />}
value="dark"
onClick={() => {
setDriftTheme(theme === "dark" ? "light" : "dark", setTheme)
}}
key="theme"
/>
)
}
export function HeaderButtons(): JSX.Element {
const { isAdmin, isAuthenticated, userId } = useSessionSWR()
useEffect(() => {
if (isAuthenticated && !userId) {
signOut()
}
}, [isAuthenticated, userId])
return (
<>
/** 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 ? (
<NavButton
key="home"
name="Home"
icon={<Home />}
value="home"
href="/home"
name="Sign Out"
key="signout"
icon={<UserX />}
value="signout"
onClick={() => {
signOut({
callbackUrl: `/signedout${userId ? "?userId=" + userId : ""}`
})
}}
width={SIGN_IN_WIDTH}
/>
) : undefined,
isAuthenticated === false ? (
<NavButton
key="new"
name="New"
icon={<PlusCircle />}
value="new"
href="/new"
/>
<NavButton
key="yours"
name="Yours"
name="Sign In"
key="signin"
icon={<User />}
value="mine"
href="/mine"
value="signin"
href="/signin"
width={SIGN_IN_WIDTH}
/>
<NavButton
name="Settings"
icon={<Settings />}
value="settings"
href="/settings"
key="settings"
/>
<ThemeButton key="theme-button" />
{isAdmin && (
) : undefined,
isAdmin ? (
<FadeIn>
<NavButton
name="Admin"
key="admin"
icon={<Settings />}
value="admin"
href="/admin"
className="transition-opacity duration-500"
/>
)}
{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}
/>
)}
</>
)
</FadeIn>
) : undefined
].filter(Boolean)
}

View file

@ -1,120 +1,22 @@
"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 styles from "./header.module.css"
import { HeaderButtons } from "./buttons"
import MobileHeader from "./mobile"
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)
}, [])
export default function Header({
theme,
isAuthenticated
}: {
theme: string
isAuthenticated: boolean
}) {
return (
<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 className={styles.header}>
<div className={styles.tabs}>
<div className={styles.buttons}>
<HeaderButtons isAuthenticated={isAuthenticated} theme={theme} />
</div>
</div>
<MobileHeader isAuthenticated={isAuthenticated} theme={theme} />
</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

@ -0,0 +1,53 @@
.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,39 +1,56 @@
"use client"
import { Button, buttonVariants } from "@components/button"
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
import buttonStyles from "@components/button/button.module.css"
import Button from "@components/button"
import { Menu } from "react-feather"
import { HeaderButtons } from "./buttons"
import * as DropdownMenu from "@components/dropdown-menu"
import React from "react"
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
})
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.DropdownMenu>
<DropdownMenu.DropdownMenuTrigger
className={buttonVariants({ variant: "ghost" })}
<DropdownMenu.Root>
<DropdownMenu.Trigger
className={clsx(buttonStyles.button, styles.mobileTrigger)}
asChild
>
<Button aria-label="Menu" variant={"ghost"}>
<Button aria-label="Menu" height="auto">
<Menu />
</Button>
</DropdownMenu.DropdownMenuTrigger>
<DropdownMenu.DropdownMenuPortal>
<DropdownMenu.DropdownMenuContent>
{HeaderButtons().props.children.map((button: JSX.Element) => (
<DropdownMenu.DropdownMenuItem
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content>
{buttons.map((button) => (
<DropdownMenu.Item
key={`mobile-${button?.key}`}
className={styles.dropdownItem}
onClick={onClick}
>
{button}
</DropdownMenu.DropdownMenuItem>
</DropdownMenu.Item>
))}
</DropdownMenu.DropdownMenuContent>
</DropdownMenu.DropdownMenuPortal>
</DropdownMenu.DropdownMenu>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}

View file

@ -1,51 +1,82 @@
import * as React from "react"
import clsx from "clsx"
import React from "react"
import styles from "./input.module.css"
import { cn } from "@lib/cn"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
type Props = React.HTMLProps<HTMLInputElement> & {
label?: string
hideLabel?: boolean
width?: number | string
height?: number | string
labelClassName?: string
}
// we have two special rules on top of the props:
// if onChange or value is passed, we require both, unless `disabled`
// if label is passed, we forbid aria-label and vice versa
type InputProps = Omit<Props, "onChange" | "value" | "label" | "aria-label"> &
(
| {
onChange: Props["onChange"]
value: Props["value"]
}
| {
onChange?: never
value?: never
}
| {
value: Props["value"]
disabled: true
onChange?: never
}
) &
(
| {
label: Props["label"]
"aria-label"?: never
} // if label is passed, we forbid aria-label and vice versa
| {
label?: never
"aria-label": Props["aria-label"]
}
)
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, hideLabel, ...props }, ref) => {
const id = React.useId()
(
{ label, className, required, width, height, labelClassName, ...props },
ref
) => {
const labelId = label?.replace(/\s/g, "-").toLowerCase()
return (
<span className="flex w-full flex-row items-center">
{label && !hideLabel ? (
<div
className={styles.wrapper}
style={{
width,
height
}}
>
{label && (
<label
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
)}
htmlFor={labelId}
className={clsx(styles.label, labelClassName)}
>
{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={id}
id={labelId}
className={clsx(styles.input, label && styles.withLabel, className)}
required={required}
{...props}
style={{
width,
height,
...(props.style || {})
}}
/>
</span>
</div>
)
}
)
Input.displayName = "Input"
export { Input }
export default Input

View file

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

View file

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

View file

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

View file

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

@ -1,14 +0,0 @@
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,17 +1,9 @@
import { Input } from "@components/input"
import Button from "@components/button"
import Input from "@components/input"
import Note from "@components/note"
import { MouseEventHandler, useState } from "react"
import * as Dialog from "@radix-ui/react-dialog"
import { 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
@ -30,8 +22,7 @@ const PasswordModal = ({
const [confirmPassword, setConfirmPassword] = useState<string>("")
const [error, setError] = useState<string>()
const onSubmit: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault()
const onSubmit = () => {
if (!password || (creating && !confirmPassword)) {
setError("Please enter a password")
return
@ -48,57 +39,60 @@ const PasswordModal = ({
return (
<>
{
<AlertDialog
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
{/* <AlertDialogOverlay className={styles.overlay} /> */}
<AlertDialogContent onEscapeKeyDown={onClose}>
<AlertDialogHeader>
<AlertDialogTitle>
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay} />
<Dialog.Content
className={styles.content}
onEscapeKeyDown={onClose}
>
<Dialog.Title>
{creating ? "Add a password" : "Enter password"}
</AlertDialogTitle>
<AlertDialogDescription>
</Dialog.Title>
<Dialog.Description>
{creating
? "Enter a password to protect your post"
: "Enter the password to access the post"}
</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 && (
</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>}
<Input
width={"100%"}
label="Confirm"
label="Password"
type="password"
placeholder="Confirm Password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
)}
</fieldset>
<AlertDialogFooter>
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onSubmit}>Submit</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{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>
}
</>
)

View file

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

View file

@ -1,28 +1,24 @@
import styles from "./list-item.module.css"
import { Card, CardContent, CardHeader } from "@components/card"
import Card 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 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>
{/* 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>
</CardHeader>
<CardContent>
<Skeleton width={100} height={32} />
</CardContent>
<div className={styles.badges}>
<Skeleton width={60} height={32} />
<Skeleton width={60} height={32} />
<Skeleton width={60} height={32} />
</div>
</div>
<Skeleton width={100} height={32} />
</Card>
</li>
)

View file

@ -1,3 +1,8 @@
.title {
display: flex;
justify-content: space-between;
}
.titleText {
display: flex;
gap: var(--gap-half);
@ -10,6 +15,11 @@
flex-wrap: wrap;
}
.buttons {
display: flex;
gap: var(--gap-half);
}
.oneline {
white-space: nowrap;
overflow: hidden;
@ -40,10 +50,17 @@
li {
display: flex;
align-items: center;
gap: var(--gap-half);
gap: var(--gap);
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,31 +6,20 @@ 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 { Badge } from "@components/badges/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@components/card"
import Tooltip from "@components/tooltip"
import Badge from "@components/badges/badge"
import Card from "@components/card"
import Button from "@components/button"
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 = ({
@ -77,88 +66,80 @@ const ListItem = ({
}
return (
<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>
<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>
</span>
) : null}
</CardTitle>
{post.description && (
<CardDescription>
{!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 && (
<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} className="text-black">
<li key={file.id}>
<Link
colored
href={`/post/${post.id}#${file.title}`}
className="flex items-center gap-2 font-mono text-sm text-foreground"
style={{
display: "flex",
alignItems: "center"
}}
>
{getIconFromFilename(file.title)}
{file.title || "Untitled file"}
@ -168,8 +149,8 @@ const ListItem = ({
}
)}
</ul>
</CardContent>
</Card>
</Card>
</li>
</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,10 +39,8 @@ const ScrollToTop = () => {
<Button
aria-label="Scroll to Top"
onClick={onClick}
variant={"secondary"}
>
<ChevronUp />
</Button>
iconLeft={<ChevronUp />}
/>
</Tooltip>
</div>
)

View file

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

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