Shadify (#150)
Adds shadcn, bumps dependencies, overhaults lots of code.
This commit is contained in:
parent
702f59caf8
commit
5d5fd3182e
114 changed files with 5340 additions and 3676 deletions
|
@ -3,5 +3,6 @@
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"useTabs": true
|
"useTabs": true,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@ You can try a demo at https://drift.lol. The demo is built on main but has no da
|
||||||
|
|
||||||
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
|
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
|
||||||
|
|
||||||
|
Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/).
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
**Contents:**
|
**Contents:**
|
||||||
|
@ -48,6 +50,7 @@ You can change these to your liking.
|
||||||
- `NODE_ENV`: defaults to development, can be `production`
|
- `NODE_ENV`: defaults to development, can be `production`
|
||||||
|
|
||||||
#### Auth environment variables
|
#### Auth environment variables
|
||||||
|
|
||||||
**Note:** Only credential auth currently supports the registration password, so if you want to secure registration, you must use only credential auth.
|
**Note:** Only credential auth currently supports the registration password, so if you want to secure registration, you must use only credential auth.
|
||||||
|
|
||||||
- `GITHUB_CLIENT_ID`: the client ID for GitHub OAuth.
|
- `GITHUB_CLIENT_ID`: the client ID for GitHub OAuth.
|
||||||
|
|
16
components.json
Normal file
16
components.json
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@components",
|
||||||
|
"utils": "@utils"
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,10 @@ const nextConfig = {
|
||||||
{
|
{
|
||||||
source: "/file/raw/:id",
|
source: "/file/raw/:id",
|
||||||
destination: `/api/raw/:id`
|
destination: `/api/raw/:id`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/signout",
|
||||||
|
destination: `/api/auth/signout`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -18,10 +22,25 @@ const nextConfig = {
|
||||||
domains: ["avatars.githubusercontent.com"]
|
domains: ["avatars.githubusercontent.com"]
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_DRIFT_URL: process.env.DRIFT_URL
|
NEXT_PUBLIC_DRIFT_URL:
|
||||||
|
process.env.DRIFT_URL ||
|
||||||
|
(process.env.VERCEL_URL
|
||||||
|
? `https://${process.env.VERCEL_URL}`
|
||||||
|
: "http://localhost:3000")
|
||||||
|
},
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: process.env.VERCEL_ENV !== "production"
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: process.env.VERCEL_ENV !== "production"
|
||||||
|
},
|
||||||
|
modularizeImports: {
|
||||||
|
"react-feather": {
|
||||||
|
transform: "react-feather/dist/icons/{{kebabCase member}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default bundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(
|
export default process.env.ANALYZE === "true"
|
||||||
nextConfig
|
? bundleAnalyzer({ enabled: true })(nextConfig)
|
||||||
)
|
: nextConfig
|
||||||
|
|
32
package.json
32
package.json
|
@ -13,39 +13,48 @@
|
||||||
"jest": "jest"
|
"jest": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next-auth/prisma-adapter": "^1.0.6",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
"@next/eslint-plugin-next": "13.4.4-canary.0",
|
"@next/eslint-plugin-next": "13.4.11-canary.0",
|
||||||
"@prisma/client": "^4.14.1",
|
"@prisma/client": "^5.0.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-dialog": "^1.0.3",
|
"@radix-ui/react-dialog": "^1.0.3",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.1.3",
|
||||||
"@radix-ui/react-popover": "^1.0.5",
|
"@radix-ui/react-popover": "^1.0.5",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
"@radix-ui/react-tabs": "^1.0.3",
|
"@radix-ui/react-tabs": "^1.0.3",
|
||||||
"@radix-ui/react-tooltip": "^1.0.5",
|
"@radix-ui/react-tooltip": "^1.0.5",
|
||||||
|
"@tailwindcss/nesting": "0.0.0-insiders.565cd3e",
|
||||||
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
|
"class-variance-authority": "^0.6.0",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"client-zip": "2.3.1",
|
"client-zip": "2.3.1",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"next": "13.4.5-canary.2",
|
"next": "13.4.11-canary.1",
|
||||||
"next-auth": "^4.22.1",
|
"next-auth": "^4.22.3",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-cookie": "^4.1.1",
|
"react-cookie": "^4.1.1",
|
||||||
"react-datepicker": "4.10.0",
|
"react-datepicker": "4.10.0",
|
||||||
|
"react-day-picker": "^8.8.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-dropzone": "14.2.3",
|
"react-dropzone": "14.2.3",
|
||||||
"react-error-boundary": "^4.0.4",
|
"react-error-boundary": "^4.0.4",
|
||||||
"react-feather": "^2.0.10",
|
"react-feather": "^2.0.10",
|
||||||
"react-hot-toast": "2.4.1",
|
"react-hot-toast": "2.4.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"swr": "^2.1.5",
|
"swr": "^2.2.0",
|
||||||
|
"tailwind-merge": "^1.13.0",
|
||||||
|
"tailwindcss-animate": "^1.0.5",
|
||||||
"textarea-markdown-editor": "1.0.4",
|
"textarea-markdown-editor": "1.0.4",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^29.1.0",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@next/bundle-analyzer": "13.4.5-canary.2",
|
"@next/bundle-analyzer": "13.4.11-canary.0",
|
||||||
"@total-typescript/ts-reset": "^0.4.2",
|
"@total-typescript/ts-reset": "^0.4.2",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
"@types/git-http-backend": "^1.0.1",
|
"@types/git-http-backend": "^1.0.1",
|
||||||
|
@ -59,12 +68,13 @@
|
||||||
"@typescript-eslint/eslint-plugin": "^5.58.0",
|
"@typescript-eslint/eslint-plugin": "^5.58.0",
|
||||||
"@typescript-eslint/parser": "^5.58.0",
|
"@typescript-eslint/parser": "^5.58.0",
|
||||||
"@wcj/markdown-to-html": "^2.2.1",
|
"@wcj/markdown-to-html": "^2.2.1",
|
||||||
|
"autoprefixer": "^10.4.14",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"csstype": "^3.1.2",
|
"csstype": "^3.1.2",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"eslint": "8.38.0",
|
"eslint": "8.38.0",
|
||||||
"eslint-config-next": "13.4.5-canary.2",
|
"eslint-config-next": "13.4.11-canary.1",
|
||||||
"jest-mock-extended": "^3.0.3",
|
"jest-mock-extended": "^3.0.3",
|
||||||
"next-unused": "0.0.6",
|
"next-unused": "0.0.6",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
|
@ -73,8 +83,10 @@
|
||||||
"postcss-nested": "^6.0.1",
|
"postcss-nested": "^6.0.1",
|
||||||
"postcss-preset-env": "^8.4.1",
|
"postcss-preset-env": "^8.4.1",
|
||||||
"prettier": "2.8.7",
|
"prettier": "2.8.7",
|
||||||
"prisma": "^4.12.0",
|
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||||
"typescript": "5.0.4",
|
"prisma": "^5.0.0",
|
||||||
|
"tailwindcss": "^3.3.2",
|
||||||
|
"typescript": "5.1.6",
|
||||||
"typescript-plugin-css-modules": "5.0.1"
|
"typescript-plugin-css-modules": "5.0.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
|
4048
pnpm-lock.yaml
4048
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -1,17 +1,7 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: [
|
plugins: {
|
||||||
"postcss-nested",
|
"@tailwindcss/nesting": {},
|
||||||
"postcss-flexbugs-fixes",
|
tailwindcss: {},
|
||||||
"postcss-hover-media-feature",
|
autoprefixer: {}
|
||||||
[
|
}
|
||||||
"postcss-preset-env",
|
|
||||||
{
|
|
||||||
stage: 3,
|
|
||||||
features: {
|
|
||||||
"custom-media-queries": true,
|
|
||||||
"custom-properties": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,8 @@
|
||||||
.container {
|
|
||||||
padding: 2rem 2rem;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.formGroup {
|
.formGroup {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
max-width: 300px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.formContentSpace {
|
.formContentSpace {
|
||||||
|
|
|
@ -4,9 +4,11 @@ import { useState } from "react"
|
||||||
import styles from "./auth.module.css"
|
import styles from "./auth.module.css"
|
||||||
import Link from "../../../components/link"
|
import Link from "../../../components/link"
|
||||||
import { signIn } from "next-auth/react"
|
import { signIn } from "next-auth/react"
|
||||||
import Input from "@components/input"
|
import { Input } from "@components/input"
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import { GitHub, Key, User } from "react-feather"
|
import { Key, User } from "react-feather"
|
||||||
|
// @ts-expect-error - no types
|
||||||
|
import GitHub from "react-feather/dist/icons/github"
|
||||||
import { useToasts } from "@components/toasts"
|
import { useToasts } from "@components/toasts"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Note from "@components/note"
|
import Note from "@components/note"
|
||||||
|
@ -52,7 +54,7 @@ function Auth({
|
||||||
})
|
})
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
} else {
|
} else {
|
||||||
router.push("/new")
|
router.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,9 +75,9 @@ function Auth({
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<ErrorQueryParamsHandler />
|
<ErrorQueryParamsHandler />
|
||||||
<div className={styles.form}>
|
<div className={"mx-auto w-[300px]"}>
|
||||||
<div className={styles.formContentSpace}>
|
<div className={styles.formContentSpace}>
|
||||||
<h1>Sign {signText}</h1>
|
<h1 className="text-3xl font-bold">Sign {signText}</h1>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
|
@ -92,7 +94,6 @@ function Auth({
|
||||||
onChange={handleChangeServerPassword}
|
onChange={handleChangeServerPassword}
|
||||||
placeholder="Server Password"
|
placeholder="Server Password"
|
||||||
required={true}
|
required={true}
|
||||||
width="100%"
|
|
||||||
aria-label="Server Password"
|
aria-label="Server Password"
|
||||||
/>
|
/>
|
||||||
<hr style={{ width: "100%" }} />
|
<hr style={{ width: "100%" }} />
|
||||||
|
@ -123,7 +124,7 @@ function Auth({
|
||||||
width="100%"
|
width="100%"
|
||||||
aria-label="Password"
|
aria-label="Password"
|
||||||
/>
|
/>
|
||||||
<Button width={"100%"} type="submit" loading={submitting}>
|
<Button type="submit" loading={submitting}>
|
||||||
Sign {signText}
|
Sign {signText}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
@ -131,25 +132,27 @@ function Auth({
|
||||||
|
|
||||||
{authProviders?.length ? (
|
{authProviders?.length ? (
|
||||||
<>
|
<>
|
||||||
|
<hr className="w-full" />
|
||||||
|
<p className="mt-2 p-0 text-center">
|
||||||
|
Or sign {signText.toLowerCase()} with one of the following
|
||||||
|
</p>
|
||||||
{authProviders?.map((provider) => {
|
{authProviders?.map((provider) => {
|
||||||
return provider.enabled ? (
|
return provider.enabled ? (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
width="100%"
|
|
||||||
key={provider.id + "-button"}
|
key={provider.id + "-button"}
|
||||||
style={{
|
|
||||||
color: "var(--fg)"
|
|
||||||
}}
|
|
||||||
iconLeft={getProviderIcon(provider.id)}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
signIn(provider.id, {
|
signIn(provider.id, {
|
||||||
callbackUrl: "/",
|
callbackUrl: "/",
|
||||||
registration_password: serverPassword
|
registration_password: serverPassword
|
||||||
})
|
})
|
||||||
|
router.refresh()
|
||||||
}}
|
}}
|
||||||
|
className="my-2 flex w-full max-w-[250px] items-center justify-center"
|
||||||
>
|
>
|
||||||
Sign {signText.toLowerCase()} with {provider.public_name}
|
{getProviderIcon(provider.id)} Sign{" "}
|
||||||
|
{signText.toLowerCase()} with {provider.public_name}
|
||||||
</Button>
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
})}
|
})}
|
||||||
|
@ -184,10 +187,10 @@ export default Auth
|
||||||
const getProviderIcon = (provider: string) => {
|
const getProviderIcon = (provider: string) => {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "github":
|
case "github":
|
||||||
return <GitHub />
|
return <GitHub className="mr-2 h-5 w-5" />
|
||||||
case "keycloak":
|
case "keycloak":
|
||||||
return <Key />
|
return <Key className="mr-2 h-5 w-5" />
|
||||||
default:
|
default:
|
||||||
return <User />
|
return <User className="mr-2 h-5 w-5" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useToasts } from "@components/toasts"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import { Suspense, useEffect } from "react"
|
import { Suspense, useEffect } from "react"
|
||||||
|
|
||||||
function _ErrorQueryParamsHandler() {
|
function InnerErrorQueryParamsHandler() {
|
||||||
const queryParams = useSearchParams()
|
const queryParams = useSearchParams()
|
||||||
const { setToast } = useToasts()
|
const { setToast } = useToasts()
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ export function ErrorQueryParamsHandler() {
|
||||||
/* Suspense boundary because useSearchParams causes static bailout */
|
/* Suspense boundary because useSearchParams causes static bailout */
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<_ErrorQueryParamsHandler />
|
<InnerErrorQueryParamsHandler />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,14 +2,17 @@ import { getMetadata } from "src/app/lib/metadata"
|
||||||
|
|
||||||
import Auth from "../components"
|
import Auth from "../components"
|
||||||
import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
|
import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
|
||||||
|
import { PageWrapper } from "@components/page-wrapper"
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
return (
|
return (
|
||||||
<Auth
|
<PageWrapper>
|
||||||
page="signin"
|
<Auth
|
||||||
credentialAuth={isCredentialEnabled()}
|
page="signin"
|
||||||
authProviders={getAuthProviders()}
|
credentialAuth={isCredentialEnabled()}
|
||||||
/>
|
authProviders={getAuthProviders()}
|
||||||
|
/>
|
||||||
|
</PageWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,21 +1,24 @@
|
||||||
import Auth from "../components"
|
import Auth from "../components"
|
||||||
import { getRequiresPasscode } from "src/pages/api/auth/requires-passcode"
|
|
||||||
import { getMetadata } from "src/app/lib/metadata"
|
import { getMetadata } from "src/app/lib/metadata"
|
||||||
import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
|
import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
|
||||||
|
import { getRequiresPasscode } from "src/app/api/auth/requires-passcode/route"
|
||||||
|
import { PageWrapper } from "@components/page-wrapper"
|
||||||
|
|
||||||
async function getPasscode() {
|
async function getPasscode() {
|
||||||
return await getRequiresPasscode()
|
return getRequiresPasscode()
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SignUpPage() {
|
export default async function SignUpPage() {
|
||||||
const requiresPasscode = await getPasscode()
|
const requiresPasscode = await getPasscode()
|
||||||
return (
|
return (
|
||||||
<Auth
|
<PageWrapper>
|
||||||
page="signup"
|
<Auth
|
||||||
requiresServerPassword={requiresPasscode}
|
page="signup"
|
||||||
credentialAuth={isCredentialEnabled()}
|
requiresServerPassword={requiresPasscode}
|
||||||
authProviders={getAuthProviders()}
|
credentialAuth={isCredentialEnabled()}
|
||||||
/>
|
authProviders={getAuthProviders()}
|
||||||
|
/>
|
||||||
|
</PageWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,19 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as RadixTabs from "@radix-ui/react-tabs"
|
|
||||||
import FormattingIcons from "src/app/(drift)/(posts)/new/components/edit-document-list/edit-document/formatting-icons"
|
import FormattingIcons from "src/app/(drift)/(posts)/new/components/edit-document-list/edit-document/formatting-icons"
|
||||||
import { ChangeEvent, ClipboardEvent, useRef } from "react"
|
import {
|
||||||
|
ChangeEvent,
|
||||||
|
ClipboardEvent,
|
||||||
|
ComponentProps,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from "react"
|
||||||
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
|
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
|
||||||
import Preview, { StaticPreview } from "../preview"
|
import Preview, { StaticPreview } from "../preview"
|
||||||
import styles from "./tabs.module.css"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/tabs"
|
||||||
|
import { Textarea } from "@components/textarea"
|
||||||
|
|
||||||
type Props = RadixTabs.TabsProps & {
|
type Props = ComponentProps<typeof Tabs> & {
|
||||||
isEditing: boolean
|
isEditing: boolean
|
||||||
defaultTab: "preview" | "edit"
|
defaultTab: "preview" | "edit"
|
||||||
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
|
||||||
|
@ -28,37 +34,33 @@ export default function DocumentTabs({
|
||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const codeEditorRef = useRef<TextareaMarkdownRef>(null)
|
const codeEditorRef = useRef<TextareaMarkdownRef>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState<"preview" | "edit">(defaultTab)
|
||||||
const handleTabChange = (newTab: string) => {
|
const handleTabChange = (newTab: string) => {
|
||||||
if (newTab === "preview") {
|
if (newTab === "preview") {
|
||||||
codeEditorRef.current?.focus()
|
codeEditorRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
setActiveTab(newTab as "preview" | "edit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formattingIconsVisibilityClass =
|
||||||
|
activeTab === "preview" ? "hidden" : "block"
|
||||||
return (
|
return (
|
||||||
<RadixTabs.Root
|
<Tabs {...props} onValueChange={handleTabChange} defaultValue={defaultTab}>
|
||||||
{...props}
|
<TabsList className="flex justify-between">
|
||||||
onValueChange={handleTabChange}
|
<div>
|
||||||
className={styles.root}
|
<TabsTrigger value="edit">{isEditing ? "Edit" : "Raw"}</TabsTrigger>
|
||||||
defaultValue={defaultTab}
|
<TabsTrigger value="preview">
|
||||||
>
|
|
||||||
<RadixTabs.List className={styles.listWrapper}>
|
|
||||||
<div className={styles.list}>
|
|
||||||
<RadixTabs.Trigger value="edit" className={styles.trigger}>
|
|
||||||
{isEditing ? "Edit" : "Raw"}
|
|
||||||
</RadixTabs.Trigger>
|
|
||||||
<RadixTabs.Trigger value="preview" className={styles.trigger}>
|
|
||||||
{isEditing ? "Preview" : "Rendered"}
|
{isEditing ? "Preview" : "Rendered"}
|
||||||
</RadixTabs.Trigger>
|
</TabsTrigger>
|
||||||
</div>
|
</div>
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<FormattingIcons
|
<FormattingIcons
|
||||||
className={styles.formattingIcons}
|
|
||||||
textareaRef={codeEditorRef}
|
textareaRef={codeEditorRef}
|
||||||
|
className={`ml-auto ${formattingIconsVisibilityClass}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</RadixTabs.List>
|
</TabsList>
|
||||||
<RadixTabs.Content value="edit">
|
<TabsContent value="edit">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
|
@ -67,7 +69,7 @@ export default function DocumentTabs({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextareaMarkdown.Wrapper ref={codeEditorRef}>
|
<TextareaMarkdown.Wrapper ref={codeEditorRef}>
|
||||||
<textarea
|
<Textarea
|
||||||
readOnly={!isEditing}
|
readOnly={!isEditing}
|
||||||
onPaste={onPaste ? onPaste : undefined}
|
onPaste={onPaste ? onPaste : undefined}
|
||||||
ref={codeEditorRef}
|
ref={codeEditorRef}
|
||||||
|
@ -80,8 +82,8 @@ export default function DocumentTabs({
|
||||||
/>
|
/>
|
||||||
</TextareaMarkdown.Wrapper>
|
</TextareaMarkdown.Wrapper>
|
||||||
</div>
|
</div>
|
||||||
</RadixTabs.Content>
|
</TabsContent>
|
||||||
<RadixTabs.Content value="preview">
|
<TabsContent value="preview">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<Preview height={"100%"} title={title}>
|
<Preview height={"100%"} title={title}>
|
||||||
{rawContent}
|
{rawContent}
|
||||||
|
@ -89,7 +91,7 @@ export default function DocumentTabs({
|
||||||
) : (
|
) : (
|
||||||
<StaticPreview height={"100%"}>{preview || ""}</StaticPreview>
|
<StaticPreview height={"100%"}>{preview || ""}</StaticPreview>
|
||||||
)}
|
)}
|
||||||
</RadixTabs.Content>
|
</TabsContent>
|
||||||
</RadixTabs.Root>
|
</Tabs>
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listWrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list .formattingIcons {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
|
@ -23,36 +23,6 @@
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content li {
|
|
||||||
transition: var(--transition);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contenthover,
|
|
||||||
.content li:focus {
|
|
||||||
background-color: var(--lighter-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content .listItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
color: var(--dark-gray);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: var(--gap-quarter) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content li .fileIcon {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: var(--gap-half);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content li .fileTitle {
|
|
||||||
font-size: calc(0.875 * 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content li::before {
|
.content li::before {
|
||||||
content: "";
|
content: "";
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { Popover } from "@components/popover"
|
import { Popover, PopoverContent, PopoverTrigger } from "@components/popover"
|
||||||
import { codeFileExtensions } from "@lib/constants"
|
import { codeFileExtensions } from "@lib/constants"
|
||||||
import clsx from "clsx"
|
|
||||||
import type { PostWithFiles } from "src/lib/server/prisma"
|
import type { PostWithFiles } from "src/lib/server/prisma"
|
||||||
import styles from "./dropdown.module.css"
|
import styles from "./dropdown.module.css"
|
||||||
import buttonStyles from "@components/button/button.module.css"
|
|
||||||
import { ChevronDown, Code, File as FileIcon } from "react-feather"
|
import { ChevronDown, Code, File as FileIcon } from "react-feather"
|
||||||
import { Spinner } from "@components/spinner"
|
import { Spinner } from "@components/spinner"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { buttonVariants } from "@components/button"
|
||||||
|
|
||||||
function FileDropdown({
|
function FileDropdown({
|
||||||
files,
|
files,
|
||||||
|
@ -18,11 +17,15 @@ function FileDropdown({
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<Popover.Trigger className={buttonStyles.button}>
|
<PopoverTrigger
|
||||||
|
className={buttonVariants({
|
||||||
|
variant: "link"
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div style={{ minWidth: 125 }}>
|
<div style={{ minWidth: 125 }}>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
</Popover.Trigger>
|
</PopoverTrigger>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -32,25 +35,26 @@ function FileDropdown({
|
||||||
if (codeFileExtensions.includes(extension || "")) {
|
if (codeFileExtensions.includes(extension || "")) {
|
||||||
return {
|
return {
|
||||||
...file,
|
...file,
|
||||||
icon: <Code />
|
icon: <Code className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
...file,
|
...file,
|
||||||
icon: <FileIcon />
|
icon: <FileIcon className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<ul className={styles.content}>
|
<ul className="text-sm">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id} className="flex">
|
||||||
<Link href={`#${item.title}`} className={styles.listItem}>
|
<Link
|
||||||
<span className={styles.fileIcon}>{item.icon}</span>
|
href={`#${item.title}`}
|
||||||
<span className={styles.fileTitle}>
|
className="flex w-full items-center gap-3 hover:underline"
|
||||||
{item.title ? item.title : "Untitled"}
|
>
|
||||||
</span>
|
{item.icon}
|
||||||
|
{item.title ? item.title : "Untitled"}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
@ -59,23 +63,19 @@ function FileDropdown({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<Popover.Trigger
|
<PopoverTrigger
|
||||||
className={buttonStyles.button}
|
className={buttonVariants({
|
||||||
style={{ height: 40, padding: 10 }}
|
variant: "secondary"
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div className={styles.chevron} style={{ marginRight: 6 }}>
|
||||||
className={clsx(buttonStyles.icon, styles.chevron)}
|
|
||||||
style={{ marginRight: 6 }}
|
|
||||||
>
|
|
||||||
<ChevronDown />
|
<ChevronDown />
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
Jump to {files.length} {files.length === 1 ? "file" : "files"}
|
Jump to {files.length} {files.length === 1 ? "file" : "files"}
|
||||||
</span>
|
</span>
|
||||||
</Popover.Trigger>
|
</PopoverTrigger>
|
||||||
<Popover.Content className={styles.contentWrapper}>
|
<PopoverContent>{content}</PopoverContent>
|
||||||
{content}
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,61 @@
|
||||||
.markdownPreview {
|
.markdownPreview {
|
||||||
padding: var(--gap-quarter);
|
padding: var(--gap-quarter);
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeletonPreview {
|
.skeletonPreview {
|
||||||
padding: var(--gap-half);
|
padding: var(--gap-half);
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
.markdownPreview {
|
||||||
|
padding: var(--gap-quarter);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeletonPreview {
|
||||||
|
padding: var(--gap-half);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h1,
|
||||||
|
.markdownPreview h2,
|
||||||
|
.markdownPreview h3,
|
||||||
|
.markdownPreview h4,
|
||||||
|
.markdownPreview h5,
|
||||||
|
.markdownPreview h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h1 {
|
||||||
|
font-size: 1.775rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h4 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h5 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.markdownPreview p {
|
.markdownPreview p {
|
||||||
margin-top: var(--gap);
|
margin-top: 1.25rem;
|
||||||
margin-bottom: var(--gap);
|
margin-bottom: 1.25rem;
|
||||||
|
/*
|
||||||
|
&:not(:first-child) {
|
||||||
|
line-height: 1.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
} */
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdownPreview pre {
|
.markdownPreview pre {
|
||||||
|
@ -26,31 +69,6 @@
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdownPreview h1,
|
|
||||||
.markdownPreview h2,
|
|
||||||
.markdownPreview h3,
|
|
||||||
.markdownPreview h4,
|
|
||||||
.markdownPreview h5,
|
|
||||||
.markdownPreview h6 {
|
|
||||||
margin-top: var(--gap);
|
|
||||||
margin-bottom: var(--gap-half);
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview h1 {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview h2 {
|
|
||||||
color: var(--darkest-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview h3,
|
|
||||||
.markdownPreview h4,
|
|
||||||
.markdownPreview h5,
|
|
||||||
.markdownPreview h6 {
|
|
||||||
color: var(--darker-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Auto-linked headers */
|
/* Auto-linked headers */
|
||||||
.markdownPreview h1 a,
|
.markdownPreview h1 a,
|
||||||
.markdownPreview h2 a,
|
.markdownPreview h2 a,
|
||||||
|
@ -75,44 +93,49 @@
|
||||||
filter: opacity(0.5);
|
filter: opacity(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdownPreview h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview h3 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview h4 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview h5 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview h6 {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview ul {
|
.markdownPreview ul {
|
||||||
list-style: inside;
|
margin-bottom: 1.5rem;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview ol {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
list-style-type: decimal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdownPreview ul li::before {
|
.markdownPreview ul li::before {
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.markdownPreview code::before,
|
.markdownPreview code::before,
|
||||||
.markdownPreview code::after {
|
.markdownPreview code::after {
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdownPreview blockquote {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
font-style: italic;
|
||||||
|
border-left-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview blockquote p {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview table {
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview table th {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-top-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 800px) {
|
@media screen and (max-width: 800px) {
|
||||||
.markdownPreview h1 a::after,
|
.markdownPreview h1 a::after,
|
||||||
.markdownPreview h2 a::after,
|
.markdownPreview h2 a::after,
|
||||||
|
|
|
@ -1,60 +0,0 @@
|
||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listWrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list .formattingIcons {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger {
|
|
||||||
width: 80px;
|
|
||||||
height: 30px;
|
|
||||||
margin: 4px 0;
|
|
||||||
font-family: inherit;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1;
|
|
||||||
user-select: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.1s ease;
|
|
||||||
margin-bottom: var(--gap-half);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger:hover {
|
|
||||||
background-color: var(--lighter-gray);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger:first-child {
|
|
||||||
border-top-left-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger:last-child {
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger[data-state="active"] {
|
|
||||||
color: var(--darkest-gray);
|
|
||||||
box-shadow: inset 0 -1px 0 0 currentColor, 0 1px 0 0 currentColor;
|
|
||||||
}
|
|
|
@ -1,7 +1,8 @@
|
||||||
import Input from "@components/input"
|
import { Input } from "@components/input"
|
||||||
import { ChangeEvent } from "react"
|
import { ChangeEvent } from "react"
|
||||||
|
|
||||||
import styles from "../post.module.css"
|
import styles from "../post.module.css"
|
||||||
|
import clsx from "clsx"
|
||||||
|
|
||||||
type props = {
|
type props = {
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void
|
onChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||||
|
@ -10,7 +11,7 @@ type props = {
|
||||||
|
|
||||||
function Description({ onChange, description }: props) {
|
function Description({ onChange, description }: props) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.description}>
|
<div className={clsx(styles.description, "pb-4")}>
|
||||||
<Input
|
<Input
|
||||||
value={description || ""}
|
value={description || ""}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|
|
@ -8,18 +8,6 @@
|
||||||
margin-top: var(--gap-double);
|
margin-top: var(--gap-double);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropzone {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 2px;
|
|
||||||
border: 2px dashed var(--border);
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropzone:focus {
|
.dropzone:focus {
|
||||||
border-color: var(--gray);
|
border-color: var(--gray);
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,12 +82,11 @@ function FileDropzone({ setDocs }: { setDocs: (docs: Document[]) => void }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div {...getRootProps()} className={styles.dropzone}>
|
<div {...getRootProps()}>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
{!isDragActive && (
|
{!isDragActive && (
|
||||||
<p style={{ color: "var(--gray)" }}>
|
<p className="cursor-pointer select-none rounded-md border-2 border-dashed p-4 text-sm text-muted-foreground">
|
||||||
Drag some files here, or <span className={styles.verb} /> to select
|
Drag and drop files here, or click to select
|
||||||
files
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{isDragActive && <p>Release to drop the files here</p>}
|
{isDragActive && <p>Release to drop the files here</p>}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
.card {
|
.card {
|
||||||
padding: var(--gap);
|
padding: var(--gap);
|
||||||
border: 1px solid var(--lighter-gray);
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,9 +9,10 @@ import {
|
||||||
import { RefObject, useMemo } from "react"
|
import { RefObject, useMemo } from "react"
|
||||||
import styles from "./formatting-icons.module.css"
|
import styles from "./formatting-icons.module.css"
|
||||||
import { TextareaMarkdownRef } from "textarea-markdown-editor"
|
import { TextareaMarkdownRef } from "textarea-markdown-editor"
|
||||||
import Tooltip from "@components/tooltip"
|
import { Tooltip } from "@components/tooltip"
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
|
import React from "react"
|
||||||
// TODO: clean up
|
// TODO: clean up
|
||||||
|
|
||||||
function FormattingIcons({
|
function FormattingIcons({
|
||||||
|
@ -69,22 +70,18 @@ function FormattingIcons({
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={name[0].toUpperCase() + name.slice(1).replace("-", " ")}
|
content={name[0].toUpperCase() + name.slice(1).replace("-", " ")}
|
||||||
key={name}
|
key={name}
|
||||||
|
delayDuration={100}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
height={32}
|
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
borderRight: "none",
|
|
||||||
borderLeft: "none",
|
|
||||||
borderTop: "none",
|
|
||||||
borderBottom: "none"
|
|
||||||
}}
|
|
||||||
aria-label={name}
|
aria-label={name}
|
||||||
iconRight={icon}
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={action}
|
onClick={action}
|
||||||
buttonType="secondary"
|
variant="ghost"
|
||||||
/>
|
>
|
||||||
|
{React.cloneElement(icon, {
|
||||||
|
className: "h-4 w-4"
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { ChangeEvent, ClipboardEvent, useCallback } from "react"
|
import { ChangeEvent, ClipboardEvent, useCallback } from "react"
|
||||||
import styles from "./document.module.css"
|
import styles from "./document.module.css"
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import Input from "@components/input"
|
import { Input } from "@components/input"
|
||||||
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs"
|
import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
|
||||||
import { Trash } from "react-feather"
|
import { Trash } from "react-feather"
|
||||||
|
import { Card, CardContent, CardHeader } from "@components/card"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title?: string
|
title?: string
|
||||||
|
@ -49,8 +50,8 @@ function Document({
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Card className="min-h-[512px]">
|
||||||
<div className={styles.card}>
|
<CardHeader>
|
||||||
<div className={styles.fileNameContainer}>
|
<div className={styles.fileNameContainer}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="MyFile.md"
|
placeholder="MyFile.md"
|
||||||
|
@ -65,35 +66,31 @@ function Document({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{remove && (
|
{remove && (
|
||||||
|
// no left border
|
||||||
<Button
|
<Button
|
||||||
iconLeft={<Trash />}
|
|
||||||
height={"39px"}
|
|
||||||
width={"48px"}
|
|
||||||
padding={0}
|
|
||||||
margin={0}
|
|
||||||
onClick={() => removeFile(remove)}
|
onClick={() => removeFile(remove)}
|
||||||
style={{
|
variant="outline"
|
||||||
borderTopLeftRadius: 0,
|
className="border-color-[var(--border)] rounded-l-none border-l-0"
|
||||||
borderBottomLeftRadius: 0
|
>
|
||||||
}}
|
<Trash height={18} />
|
||||||
/>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.documentContainer}>
|
</CardHeader>
|
||||||
<DocumentTabs
|
<CardContent>
|
||||||
isEditing={true}
|
<DocumentTabs
|
||||||
defaultTab={defaultTab}
|
isEditing={true}
|
||||||
handleOnContentChange={handleOnContentChange}
|
defaultTab={defaultTab}
|
||||||
// TODO: solve types
|
handleOnContentChange={handleOnContentChange}
|
||||||
// @ts-expect-error Type 'HTMLDivElement' is missing the following properties from type 'HTMLTextAreaElement': autocomplete, cols, defaultValue, dirName, and 26 more
|
// TODO: solve types
|
||||||
onPaste={onPaste}
|
// @ts-expect-error Type 'HTMLDivElement' is missing the following properties from type 'HTMLTextAreaElement': autocomplete, cols, defaultValue, dirName, and 26 more
|
||||||
title={title}
|
onPaste={onPaste}
|
||||||
>
|
title={title}
|
||||||
{content}
|
>
|
||||||
</DocumentTabs>
|
{content}
|
||||||
</div>
|
</DocumentTabs>
|
||||||
</div>
|
</CardContent>
|
||||||
</>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,26 +3,42 @@
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useCallback, useState, ClipboardEvent } from "react"
|
import { useCallback, useState, ClipboardEvent } from "react"
|
||||||
import generateUUID from "@lib/generate-uuid"
|
import generateUUID from "@lib/generate-uuid"
|
||||||
import styles from "./post.module.css"
|
|
||||||
import EditDocumentList from "./edit-document-list"
|
import EditDocumentList from "./edit-document-list"
|
||||||
import { ChangeEvent } from "react"
|
import { ChangeEvent } from "react"
|
||||||
import getTitleForPostCopy from "src/app/lib/get-title-for-post-copy"
|
import getTitleForPostCopy from "src/app/lib/get-title-for-post-copy"
|
||||||
import Description from "./description"
|
// import Description from "./description"
|
||||||
import { PostWithFiles } from "@lib/server/prisma"
|
import { PostWithFiles } from "@lib/server/prisma"
|
||||||
import PasswordModal from "../../../../components/password-modal"
|
import PasswordModal from "../../../../components/password-modal"
|
||||||
import Title from "./title"
|
import Title from "./title"
|
||||||
import FileDropzone from "./drag-and-drop"
|
import FileDropzone from "./drag-and-drop"
|
||||||
import Button from "@components/button"
|
import { Button, buttonVariants } from "@components/button"
|
||||||
import Input from "@components/input"
|
|
||||||
import ButtonDropdown from "@components/button-dropdown"
|
|
||||||
import { useToasts } from "@components/toasts"
|
import { useToasts } from "@components/toasts"
|
||||||
import { fetchWithUser } from "src/app/lib/fetch-with-user"
|
import { fetchWithUser } from "src/app/lib/fetch-with-user"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
|
import ButtonDropdown from "@components/button-dropdown"
|
||||||
|
import clsx from "clsx"
|
||||||
|
import { Spinner } from "@components/spinner"
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
import { Calendar as CalendarIcon } from "react-feather"
|
||||||
|
|
||||||
const DatePicker = dynamic(() => import("react-datepicker"), {
|
const DatePicker = dynamic(
|
||||||
ssr: false,
|
() => import("@components/date-picker").then((m) => m.DatePicker),
|
||||||
loading: () => <Input label="Expires at" placeholder="Won't expire" width="100%" height="40px" />
|
{
|
||||||
})
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
className={cn(
|
||||||
|
"w-[280px] justify-start text-left font-normal",
|
||||||
|
"text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
<span>Won't expire</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const emptyDoc = {
|
const emptyDoc = {
|
||||||
title: "",
|
title: "",
|
||||||
|
@ -48,7 +64,9 @@ function Post({
|
||||||
const [title, setTitle] = useState(
|
const [title, setTitle] = useState(
|
||||||
getTitleForPostCopy(initialPost?.title) || ""
|
getTitleForPostCopy(initialPost?.title) || ""
|
||||||
)
|
)
|
||||||
const [description, setDescription] = useState(initialPost?.description || "")
|
const [description /*, setDescription */] = useState(
|
||||||
|
initialPost?.description || ""
|
||||||
|
)
|
||||||
const [expiresAt, setExpiresAt] = useState<Date>()
|
const [expiresAt, setExpiresAt] = useState<Date>()
|
||||||
|
|
||||||
const defaultDocs: Document[] = initialPost
|
const defaultDocs: Document[] = initialPost
|
||||||
|
@ -131,7 +149,7 @@ function Post({
|
||||||
|
|
||||||
if (!docs.length) {
|
if (!docs.length) {
|
||||||
setToast({
|
setToast({
|
||||||
message: "Please add at least one document",
|
message: "Please add at least one file",
|
||||||
type: "error"
|
type: "error"
|
||||||
})
|
})
|
||||||
hasErrored = true
|
hasErrored = true
|
||||||
|
@ -170,13 +188,13 @@ function Post({
|
||||||
setTitle(e.target.value)
|
setTitle(e.target.value)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onChangeDescription = useCallback(
|
// const onChangeDescription = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
// (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
e.preventDefault()
|
// e.preventDefault()
|
||||||
setDescription(e.target.value)
|
// setDescription(e.target.value)
|
||||||
},
|
// },
|
||||||
[]
|
// []
|
||||||
)
|
// )
|
||||||
|
|
||||||
function onClosePasswordModal() {
|
function onClosePasswordModal() {
|
||||||
setPasswordModalVisible(false)
|
setPasswordModalVisible(false)
|
||||||
|
@ -187,10 +205,6 @@ function Post({
|
||||||
return onSubmit("protected", password)
|
return onSubmit("protected", password)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onChangeExpiration(date: Date) {
|
|
||||||
return setExpiresAt(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDocTitle(i: number) {
|
function updateDocTitle(i: number) {
|
||||||
return (title: string) => {
|
return (title: string) => {
|
||||||
setDocs((docs) =>
|
setDocs((docs) =>
|
||||||
|
@ -241,10 +255,9 @@ function Post({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className="flex flex-1 flex-col gap-4">
|
||||||
<Title title={title} onChange={onChangeTitle} />
|
<Title title={title} onChange={onChangeTitle} className="py-4" />
|
||||||
<Description description={description} onChange={onChangeDescription} />
|
{/* <Description description={description} onChange={onChangeDescription} /> */}
|
||||||
<FileDropzone setDocs={uploadDocs} />
|
|
||||||
<EditDocumentList
|
<EditDocumentList
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
docs={docs}
|
docs={docs}
|
||||||
|
@ -252,68 +265,60 @@ function Post({
|
||||||
updateDocContent={updateDocContent}
|
updateDocContent={updateDocContent}
|
||||||
removeDoc={removeDoc}
|
removeDoc={removeDoc}
|
||||||
/>
|
/>
|
||||||
<div className={styles.buttons}>
|
<FileDropzone setDocs={uploadDocs} />
|
||||||
<Button
|
|
||||||
onClick={() => {
|
<div className="mt-4 flex items-center justify-between">
|
||||||
setDocs([
|
<span className="flex flex-1 gap-2">
|
||||||
...docs,
|
<Button
|
||||||
{
|
onClick={() => {
|
||||||
title: "",
|
setDocs([
|
||||||
content: "",
|
...docs,
|
||||||
id: generateUUID()
|
{
|
||||||
}
|
title: "",
|
||||||
])
|
content: "",
|
||||||
}}
|
id: generateUUID()
|
||||||
style={{
|
}
|
||||||
flex: 1,
|
])
|
||||||
minWidth: 120
|
}}
|
||||||
}}
|
className="min-w-[120px] max-w-[200px] flex-1"
|
||||||
>
|
variant={"secondary"}
|
||||||
Add a File
|
>
|
||||||
</Button>
|
Add a File
|
||||||
<div className={styles.rightButtons}>
|
</Button>
|
||||||
<DatePicker
|
<DatePicker setExpiresAt={setExpiresAt} expiresAt={expiresAt} />
|
||||||
onChange={onChangeExpiration}
|
</span>
|
||||||
customInput={
|
<ButtonDropdown>
|
||||||
<Input label="Expires at" width="100%" height="40px" />
|
<span
|
||||||
}
|
className={clsx(
|
||||||
placeholderText="Won't expire"
|
"w-full cursor-pointer rounded-br-none rounded-tr-none",
|
||||||
selected={expiresAt}
|
buttonVariants({
|
||||||
showTimeInput={true}
|
variant: "default"
|
||||||
// @ts-expect-error fix time input type
|
})
|
||||||
customTimeInput={<CustomTimeInput />}
|
)}
|
||||||
timeInputLabel="Time:"
|
onClick={() => onSubmit("unlisted")}
|
||||||
dateFormat="MM/dd/yyyy h:mm aa"
|
>
|
||||||
className={styles.datePicker}
|
{isSubmitting ? <Spinner className="mr-2" /> : null}
|
||||||
clearButtonTitle={"Clear"}
|
Create Unlisted
|
||||||
// TODO: investigate why this causes margin shift if true
|
</span>
|
||||||
enableTabLoop={false}
|
<span
|
||||||
minDate={new Date()}
|
className={clsx("w-full cursor-pointer")}
|
||||||
/>
|
onClick={() => onSubmit("private")}
|
||||||
<ButtonDropdown>
|
>
|
||||||
<Button
|
Create Private
|
||||||
height={40}
|
</span>
|
||||||
width={251}
|
<span
|
||||||
onClick={() => onSubmit("unlisted")}
|
className={clsx("w-full cursor-pointer")}
|
||||||
loading={isSubmitting}
|
onClick={() => onSubmit("public")}
|
||||||
>
|
>
|
||||||
Create Unlisted
|
Create Public
|
||||||
</Button>
|
</span>
|
||||||
<Button height={40} width={300} onClick={() => onSubmit("private")}>
|
<span
|
||||||
Create Private
|
className={clsx("w-full cursor-pointer")}
|
||||||
</Button>
|
onClick={() => onSubmit("protected")}
|
||||||
<Button height={40} width={300} onClick={() => onSubmit("public")}>
|
>
|
||||||
Create Public
|
Create with Password
|
||||||
</Button>
|
</span>
|
||||||
<Button
|
</ButtonDropdown>
|
||||||
height={40}
|
|
||||||
width={300}
|
|
||||||
onClick={() => onSubmit("protected")}
|
|
||||||
>
|
|
||||||
Create with Password
|
|
||||||
</Button>
|
|
||||||
</ButtonDropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<PasswordModal
|
<PasswordModal
|
||||||
creating={true}
|
creating={true}
|
||||||
|
@ -327,30 +332,30 @@ function Post({
|
||||||
|
|
||||||
export default Post
|
export default Post
|
||||||
|
|
||||||
function CustomTimeInput({
|
// function CustomTimeInput({
|
||||||
date,
|
// date,
|
||||||
value,
|
// value,
|
||||||
onChange
|
// onChange
|
||||||
}: {
|
// }: {
|
||||||
date: Date
|
// date: Date
|
||||||
value: string
|
// value: string
|
||||||
onChange: (date: string) => void
|
// onChange: (date: string) => void
|
||||||
}) {
|
// }) {
|
||||||
return (
|
// return (
|
||||||
<input
|
// <input
|
||||||
type="time"
|
// type="time"
|
||||||
value={value}
|
// value={value}
|
||||||
onChange={(e) => {
|
// onChange={(e) => {
|
||||||
if (!isNaN(date.getTime())) {
|
// if (!isNaN(date.getTime())) {
|
||||||
onChange(e.target.value || date.toISOString().slice(11, 16))
|
// onChange(e.target.value || date.toISOString().slice(11, 16))
|
||||||
}
|
// }
|
||||||
}}
|
// }}
|
||||||
style={{
|
// style={{
|
||||||
backgroundColor: "var(--bg)",
|
// backgroundColor: "var(--bg)",
|
||||||
border: "1px solid var(--light-gray)",
|
// border: "1px solid var(--light-gray)",
|
||||||
borderRadius: "var(--radius)"
|
// borderRadius: "var(--radius)"
|
||||||
}}
|
// }}
|
||||||
required
|
// required
|
||||||
/>
|
// />
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
padding-bottom: 200px;
|
padding-bottom: 200px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--gap);
|
gap: var(--gap-half);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
|
@ -19,9 +19,6 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datePicker {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
@ -1,372 +0,0 @@
|
||||||
.react-datepicker__year-read-view--down-arrow,
|
|
||||||
.react-datepicker__month-read-view--down-arrow,
|
|
||||||
.react-datepicker__month-year-read-view--down-arrow,
|
|
||||||
.react-datepicker__navigation-icon::before {
|
|
||||||
border-color: var(--light-gray);
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 3px 3px 0 0;
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
height: 9px;
|
|
||||||
position: absolute;
|
|
||||||
top: 6px;
|
|
||||||
width: 9px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle,
|
|
||||||
.react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle {
|
|
||||||
margin-left: -4px;
|
|
||||||
position: absolute;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-wrapper {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--gray);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker--time-only .react-datepicker__triangle {
|
|
||||||
left: 35px;
|
|
||||||
}
|
|
||||||
.react-datepicker--time-only .react-datepicker__time-container {
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
.react-datepicker--time-only .react-datepicker__time,
|
|
||||||
.react-datepicker--time-only .react-datepicker__time-box {
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__triangle {
|
|
||||||
position: absolute;
|
|
||||||
left: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-popper {
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="bottom"] {
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement="bottom-end"]
|
|
||||||
.react-datepicker__triangle,
|
|
||||||
.react-datepicker-popper[data-placement="top-end"] .react-datepicker__triangle {
|
|
||||||
left: auto;
|
|
||||||
right: 50px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="top"] {
|
|
||||||
padding-bottom: 10px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="right"] {
|
|
||||||
padding-left: 8px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="right"] .react-datepicker__triangle {
|
|
||||||
left: auto;
|
|
||||||
right: 42px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="left"] {
|
|
||||||
padding-right: 8px;
|
|
||||||
}
|
|
||||||
.react-datepicker-popper[data-placement^="left"] .react-datepicker__triangle {
|
|
||||||
left: 42px;
|
|
||||||
right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__header {
|
|
||||||
text-align: center;
|
|
||||||
background-color: var(--bg);
|
|
||||||
border-bottom: 1px solid var(--gray);
|
|
||||||
border-top-left-radius: var(--radius);
|
|
||||||
border-top-right-radius: var(--radius);
|
|
||||||
padding: 8px 0;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__header--time {
|
|
||||||
padding-bottom: 8px;
|
|
||||||
padding-left: 5px;
|
|
||||||
padding-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__year-dropdown-container--select,
|
|
||||||
.react-datepicker__month-dropdown-container--select,
|
|
||||||
.react-datepicker__month-year-dropdown-container--select,
|
|
||||||
.react-datepicker__year-dropdown-container--scroll,
|
|
||||||
.react-datepicker__month-dropdown-container--scroll,
|
|
||||||
.react-datepicker__month-year-dropdown-container--scroll {
|
|
||||||
display: inline-block;
|
|
||||||
margin: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__current-month,
|
|
||||||
.react-datepicker-time__header,
|
|
||||||
.react-datepicker-year-header {
|
|
||||||
margin-top: 0;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 0.944rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker-time__header {
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation {
|
|
||||||
align-items: center;
|
|
||||||
background: none;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
z-index: 1;
|
|
||||||
height: 32px;
|
|
||||||
width: 32px;
|
|
||||||
text-indent: -999em;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--previous {
|
|
||||||
left: 2px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--next {
|
|
||||||
right: 2px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) {
|
|
||||||
right: 85px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--years {
|
|
||||||
position: relative;
|
|
||||||
top: 0;
|
|
||||||
display: block;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--years-previous {
|
|
||||||
top: 4px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation--years-upcoming {
|
|
||||||
top: -4px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation:hover *::before {
|
|
||||||
border-color: var(--lighter-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__navigation-icon {
|
|
||||||
position: relative;
|
|
||||||
top: -1px;
|
|
||||||
font-size: 20px;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation-icon--next {
|
|
||||||
left: -2px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation-icon--next::before {
|
|
||||||
transform: rotate(45deg);
|
|
||||||
left: -7px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation-icon--previous {
|
|
||||||
right: -2px;
|
|
||||||
}
|
|
||||||
.react-datepicker__navigation-icon--previous::before {
|
|
||||||
transform: rotate(225deg);
|
|
||||||
right: -7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month-container {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__year {
|
|
||||||
margin: 0.4rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.react-datepicker__year-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
max-width: 180px;
|
|
||||||
}
|
|
||||||
.react-datepicker__year .react-datepicker__year-text {
|
|
||||||
display: inline-block;
|
|
||||||
width: 4rem;
|
|
||||||
margin: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month {
|
|
||||||
margin: 0.4rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.react-datepicker__month .react-datepicker__month-text,
|
|
||||||
.react-datepicker__month .react-datepicker__quarter-text {
|
|
||||||
display: inline-block;
|
|
||||||
width: 4rem;
|
|
||||||
margin: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__input-time-container {
|
|
||||||
clear: both;
|
|
||||||
width: 100%;
|
|
||||||
float: left;
|
|
||||||
margin: 5px 0 10px 15px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container .react-datepicker-time__caption {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input
|
|
||||||
input {
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input
|
|
||||||
input[type="time"]::-webkit-inner-spin-button,
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input
|
|
||||||
input[type="time"]::-webkit-outer-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__input
|
|
||||||
input[type="time"] {
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
.react-datepicker__input-time-container
|
|
||||||
.react-datepicker-time__input-container
|
|
||||||
.react-datepicker-time__delimiter {
|
|
||||||
margin-left: 5px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-names,
|
|
||||||
.react-datepicker__week {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-names {
|
|
||||||
margin-bottom: -8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day-name,
|
|
||||||
.react-datepicker__day,
|
|
||||||
.react-datepicker__time-name {
|
|
||||||
color: var(--fg);
|
|
||||||
display: inline-block;
|
|
||||||
width: 1.7rem;
|
|
||||||
line-height: 1.7rem;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0.166rem;
|
|
||||||
}
|
|
||||||
.react-datepicker__day,
|
|
||||||
.react-datepicker__month-text,
|
|
||||||
.react-datepicker__quarter-text,
|
|
||||||
.react-datepicker__year-text {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.react-datepicker__day:hover,
|
|
||||||
.react-datepicker__month-text:hover,
|
|
||||||
.react-datepicker__quarter-text:hover,
|
|
||||||
.react-datepicker__year-text:hover {
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
background-color: var(--light-gray);
|
|
||||||
}
|
|
||||||
.react-datepicker__day--today,
|
|
||||||
.react-datepicker__month-text--today,
|
|
||||||
.react-datepicker__quarter-text--today,
|
|
||||||
.react-datepicker__year-text--today {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.react-datepicker__day--highlighted,
|
|
||||||
.react-datepicker__month-text--highlighted,
|
|
||||||
.react-datepicker__quarter-text--highlighted,
|
|
||||||
.react-datepicker__year-text--highlighted {
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
background-color: #3dcc4a;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.react-datepicker__day--highlighted:hover,
|
|
||||||
.react-datepicker__month-text--highlighted:hover,
|
|
||||||
.react-datepicker__quarter-text--highlighted:hover,
|
|
||||||
.react-datepicker__year-text--highlighted:hover {
|
|
||||||
background-color: #32be3f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--selected,
|
|
||||||
.react-datepicker__day--in-selecting-range,
|
|
||||||
.react-datepicker__day--in-range,
|
|
||||||
.react-datepicker__month-text--selected,
|
|
||||||
.react-datepicker__month-text--in-selecting-range,
|
|
||||||
.react-datepicker__month-text--in-range,
|
|
||||||
.react-datepicker__quarter-text--selected,
|
|
||||||
.react-datepicker__quarter-text--in-selecting-range,
|
|
||||||
.react-datepicker__quarter-text--in-range,
|
|
||||||
.react-datepicker__year-text--selected,
|
|
||||||
.react-datepicker__year-text--in-selecting-range,
|
|
||||||
.react-datepicker__year-text--in-range {
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
background-color: var(--light-gray);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.react-datepicker__day--selected:hover {
|
|
||||||
background-color: var(--gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--keyboard-selected,
|
|
||||||
.react-datepicker__month-text--keyboard-selected,
|
|
||||||
.react-datepicker__quarter-text--keyboard-selected,
|
|
||||||
.react-datepicker__year-text--keyboard-selected {
|
|
||||||
border-radius: 0.3rem;
|
|
||||||
background-color: var(--light-gray);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.react-datepicker__day--keyboard-selected:hover {
|
|
||||||
background-color: var(--gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__month--selecting-range
|
|
||||||
.react-datepicker__day--in-range:not(.react-datepicker__day--in-selecting-range, .react-datepicker__month-text--in-selecting-range, .react-datepicker__quarter-text--in-selecting-range, .react-datepicker__year-text--in-selecting-range) {
|
|
||||||
background-color: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker {
|
|
||||||
transform: scale(1.15) translateY(-12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--disabled {
|
|
||||||
color: var(--darker-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-datepicker__day--disabled:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { ChangeEvent, memo } from "react"
|
import { ChangeEvent, memo } from "react"
|
||||||
|
|
||||||
import Input from "@components/input"
|
import { Input } from "@components/input"
|
||||||
import styles from "./title.module.css"
|
|
||||||
|
|
||||||
const titlePlaceholders = [
|
const titlePlaceholders = [
|
||||||
"How to...",
|
"How to...",
|
||||||
|
@ -18,20 +17,17 @@ const placeholder = titlePlaceholders[3]
|
||||||
type props = {
|
type props = {
|
||||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void
|
onChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||||
title?: string
|
title?: string
|
||||||
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function Title({ onChange, title }: props) {
|
function Title({ onChange, title, className }: props) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.title}>
|
<div className={className}>
|
||||||
<h1 style={{ margin: 0, padding: 0 }}>Drift</h1>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
label="Title"
|
label="Title"
|
||||||
className={styles.labelAndInput}
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
labelClassName={styles.labelAndInput}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,9 +1,17 @@
|
||||||
import { getMetadata } from "src/app/lib/metadata"
|
import { getMetadata } from "src/app/lib/metadata"
|
||||||
import NewPost from "src/app/(drift)/(posts)/new/components/new"
|
import NewPost from "src/app/(drift)/(posts)/new/components/new"
|
||||||
import "./components/react-datepicker.css"
|
import { PageTitle } from "@components/page-title"
|
||||||
|
import { PageWrapper } from "@components/page-wrapper"
|
||||||
|
|
||||||
export default function New() {
|
export default function New() {
|
||||||
return <NewPost />
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle>New Post</PageTitle>
|
||||||
|
<PageWrapper>
|
||||||
|
<NewPost />
|
||||||
|
</PageWrapper>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = getMetadata({
|
export const metadata = getMetadata({
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import ButtonGroup from "@components/button-group"
|
import ButtonGroup from "@components/button-group"
|
||||||
import FileDropdown from "src/app/(drift)/(posts)/components/file-dropdown"
|
import FileDropdown from "src/app/(drift)/(posts)/components/file-dropdown"
|
||||||
import { Edit, ArrowUpCircle, Archive } from "react-feather"
|
|
||||||
import styles from "./post-buttons.module.css"
|
import styles from "./post-buttons.module.css"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { PostWithFiles } from "@lib/server/prisma"
|
import { PostWithFiles } from "@lib/server/prisma"
|
||||||
|
@ -48,15 +47,19 @@ export const PostButtons = ({
|
||||||
return (
|
return (
|
||||||
<span className={styles.buttons}>
|
<span className={styles.buttons}>
|
||||||
<ButtonGroup verticalIfMobile>
|
<ButtonGroup verticalIfMobile>
|
||||||
<Button iconLeft={<Edit />} onClick={editACopy}>
|
<Button variant={"secondary"} onClick={editACopy} className="border-r">
|
||||||
Edit a Copy
|
Edit a Copy
|
||||||
</Button>
|
</Button>
|
||||||
{parentId && (
|
{parentId && (
|
||||||
<Button iconLeft={<ArrowUpCircle />} onClick={viewParentClick}>
|
<Button variant={"secondary"} onClick={viewParentClick}>
|
||||||
View Parent
|
View Parent
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button onClick={downloadClick} iconLeft={<Archive />}>
|
<Button
|
||||||
|
variant={"secondary"}
|
||||||
|
onClick={downloadClick}
|
||||||
|
className="border-r"
|
||||||
|
>
|
||||||
Download as ZIP Archive
|
Download as ZIP Archive
|
||||||
</Button>
|
</Button>
|
||||||
<FileDropdown loading={loading} files={files || []} />
|
<FileDropdown loading={loading} files={files || []} />
|
||||||
|
|
|
@ -17,13 +17,9 @@ export const PostTitle = ({ post, loading }: TitleProps) => {
|
||||||
const displayName = author?.displayName
|
const displayName = author?.displayName
|
||||||
return (
|
return (
|
||||||
<span className={styles.title}>
|
<span className={styles.title}>
|
||||||
<h1
|
<h1 className="text-3xl font-bold">
|
||||||
style={{
|
|
||||||
fontSize: "1.175rem"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}{" "}
|
{title}{" "}
|
||||||
<span style={{ color: "var(--gray)" }}>
|
<span className="text-2xl text-muted-foreground">
|
||||||
by {/* <Link colored href={`/author/${authorId}`}> */}
|
by {/* <Link colored href={`/author/${authorId}`}> */}
|
||||||
{displayName || "anonymous"}
|
{displayName || "anonymous"}
|
||||||
{/* </Link> */}
|
{/* </Link> */}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
.card header {
|
/* .card header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
@ -18,7 +18,7 @@
|
||||||
border: 1px solid var(--lighter-gray);
|
border: 1px solid var(--lighter-gray);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-radius: 0px 0px 8px 8px;
|
border-radius: 0px 0px 8px 8px;
|
||||||
}
|
} */
|
||||||
|
|
||||||
.textarea {
|
.textarea {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import ButtonGroup from "@components/button-group"
|
import ButtonGroup from "@components/button-group"
|
||||||
import Skeleton from "@components/skeleton"
|
import Skeleton from "@components/skeleton"
|
||||||
import Tooltip from "@components/tooltip"
|
import { Tooltip } from "@components/tooltip"
|
||||||
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs"
|
import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
import { Download, ExternalLink, Globe } from "react-feather"
|
import { Download, ExternalLink, Globe } from "react-feather"
|
||||||
|
@ -10,7 +10,7 @@ import styles from "./document.module.css"
|
||||||
import { getURLFriendlyTitle } from "src/app/lib/get-url-friendly-title"
|
import { getURLFriendlyTitle } from "src/app/lib/get-url-friendly-title"
|
||||||
import { PostWithFiles, ServerPost } from "@lib/server/prisma"
|
import { PostWithFiles, ServerPost } from "@lib/server/prisma"
|
||||||
import { isAllowedVisibilityForWebpage } from "@lib/constants"
|
import { isAllowedVisibilityForWebpage } from "@lib/constants"
|
||||||
|
import { Card, CardContent, CardHeader } from "@components/card"
|
||||||
type SharedProps = {
|
type SharedProps = {
|
||||||
initialTab: "edit" | "preview"
|
initialTab: "edit" | "preview"
|
||||||
file?: PostWithFiles["files"][0]
|
file?: PostWithFiles["files"][0]
|
||||||
|
@ -36,38 +36,50 @@ const DownloadButtons = ({
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<Tooltip content="Download">
|
<Tooltip content="Download" delayDuration={200}>
|
||||||
<Link
|
<Link
|
||||||
href={`${rawLink}?download=true`}
|
href={`${rawLink}?download=true`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
iconRight={<Download color="var(--fg)" />}
|
|
||||||
aria-label="Download"
|
aria-label="Download"
|
||||||
style={{ border: "none", background: "transparent" }}
|
size="sm"
|
||||||
/>
|
className="bg-transparent border-none"
|
||||||
|
variant={"ghost"}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 " />
|
||||||
|
<span className="sr-only">Download</span>
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{rawLink ? (
|
{rawLink ? (
|
||||||
<Tooltip content="Open raw in new tab">
|
<Tooltip content="Open raw in new tab" delayDuration={200}>
|
||||||
<Link href={rawLink || ""} target="_blank" rel="noopener noreferrer">
|
<Link href={rawLink || ""} target="_blank" rel="noopener noreferrer">
|
||||||
<Button
|
<Button
|
||||||
iconLeft={<ExternalLink color="var(--fg)" />}
|
|
||||||
aria-label="Open raw file in new tab"
|
aria-label="Open raw file in new tab"
|
||||||
style={{ border: "none", background: "transparent" }}
|
className="bg-transparent border-none"
|
||||||
/>
|
size="sm"
|
||||||
|
variant={"ghost"}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
<span className="sr-only">Open raw file in new tab</span>
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
{siteLink ? (
|
{siteLink ? (
|
||||||
<Tooltip content="Open as webpage">
|
<Tooltip content="Open as webpage" delayDuration={200}>
|
||||||
<Link href={siteLink || ""} target="_blank" rel="noopener noreferrer">
|
<Link href={siteLink || ""} target="_blank" rel="noopener noreferrer">
|
||||||
<Button
|
<Button
|
||||||
iconLeft={<Globe color="var(--fg)" />}
|
|
||||||
aria-label="Open as webpage"
|
aria-label="Open as webpage"
|
||||||
style={{ border: "none", background: "transparent" }}
|
className="bg-transparent border-none"
|
||||||
/>
|
size="sm"
|
||||||
|
variant={"ghost"}
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
<span className="sr-only">Open as webpage</span>
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -109,18 +121,39 @@ const Document = ({ skeleton, ...props }: Props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* .card header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--lighter-gray);
|
||||||
|
border-radius: 8px 8px 0px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
padding: var(--gap);
|
||||||
|
border: 1px solid var(--lighter-gray);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0px 0px 8px 8px;
|
||||||
|
} */
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.card}>
|
<Card className="border-gray-200 dark:border-gray-900">
|
||||||
<header id={file?.title}>
|
<CardHeader
|
||||||
|
id={file?.title}
|
||||||
|
className="flex flex-row items-center justify-between py-1 bg-gray-200 dark:bg-gray-900"
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`#${file?.title}`}
|
href={`#${file?.title}`}
|
||||||
aria-label="File"
|
aria-label="File"
|
||||||
style={{
|
// show an # when hovered avia :after
|
||||||
textDecoration: "none",
|
className="text-gray-900 hover:after:ml-1 hover:after:content-[#] dark:text-gray-100"
|
||||||
color: "var(--fg)"
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{file?.title}
|
{file?.title}
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -134,8 +167,8 @@ const Document = ({ skeleton, ...props }: Props) => {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</header>
|
</CardHeader>
|
||||||
<div className={styles.documentContainer}>
|
<CardContent className="flex flex-col h-full pt-2">
|
||||||
<DocumentTabs
|
<DocumentTabs
|
||||||
defaultTab={props.initialTab}
|
defaultTab={props.initialTab}
|
||||||
staticPreview={file?.html}
|
staticPreview={file?.html}
|
||||||
|
@ -143,8 +176,8 @@ const Document = ({ skeleton, ...props }: Props) => {
|
||||||
>
|
>
|
||||||
{file?.content || ""}
|
{file?.content || ""}
|
||||||
</DocumentTabs>
|
</DocumentTabs>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,11 +28,7 @@ export default async function PostLayout({
|
||||||
{post.visibility !== "protected" && <PostButtons post={clientPost} />}
|
{post.visibility !== "protected" && <PostButtons post={clientPost} />}
|
||||||
{post.visibility !== "protected" && <PostTitle post={clientPost} />}
|
{post.visibility !== "protected" && <PostTitle post={clientPost} />}
|
||||||
</div>
|
</div>
|
||||||
{post.description && (
|
{/* {post.description && <p className="pb-4 text-lg">{post.description}</p>} */}
|
||||||
<div>
|
|
||||||
<p>{post.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -21,11 +21,13 @@ export default async function PostPage({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PostFiles post={clientPost} />
|
<PostFiles post={clientPost} />
|
||||||
<VisibilityControl
|
<div className="mx-auto mb-4 mt-4">
|
||||||
authorId={post.authorId}
|
<VisibilityControl
|
||||||
postId={post.id}
|
authorId={post.authorId}
|
||||||
visibility={post.visibility}
|
postId={post.id}
|
||||||
/>
|
visibility={post.visibility}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,3 @@
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
thead th {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.id {
|
.id {
|
||||||
width: 130px;
|
width: 130px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import { Spinner } from "@components/spinner"
|
import { Spinner } from "@components/spinner"
|
||||||
import { useToasts } from "@components/toasts"
|
import { useToasts } from "@components/toasts"
|
||||||
import { ServerPostWithFilesAndAuthor, UserWithPosts } from "@lib/server/prisma"
|
import { ServerPostWithFilesAndAuthor, UserWithPosts } from "@lib/server/prisma"
|
||||||
|
@ -18,7 +18,7 @@ export function UserTable({
|
||||||
id: string
|
id: string
|
||||||
email: string | null
|
email: string | null
|
||||||
role: string | null
|
role: string | null
|
||||||
displayName: string | null
|
username: string | null
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
const { setToast } = useToasts()
|
const { setToast } = useToasts()
|
||||||
|
@ -26,6 +26,14 @@ export function UserTable({
|
||||||
|
|
||||||
const deleteUser = async (id: string) => {
|
const deleteUser = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
const confirmed = confirm("Are you sure you want to delete this user?")
|
||||||
|
if (!confirmed) {
|
||||||
|
setToast({
|
||||||
|
message: "User not deleted",
|
||||||
|
type: "default"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
const res = await fetchWithUser("/api/admin?action=delete-user", {
|
const res = await fetchWithUser("/api/admin?action=delete-user", {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -53,8 +61,8 @@ export function UserTable({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={styles.table}>
|
<table className="w-full overflow-x-auto">
|
||||||
<thead>
|
<thead className="text-left">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
@ -73,14 +81,20 @@ export function UserTable({
|
||||||
) : null}
|
) : null}
|
||||||
{users?.map((user) => (
|
{users?.map((user) => (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
<td>{user.displayName ? user.displayName : "no name"}</td>
|
<td>{user.username ? user.username : "no name"}</td>
|
||||||
<td>{user.email}</td>
|
<td>{user.email}</td>
|
||||||
<td>{user.role}</td>
|
<td>{user.role}</td>
|
||||||
<td className={styles.id} title={user.id}>
|
<td className={styles.id} title={user.id}>
|
||||||
{user.id}
|
{user.id}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Button onClick={() => deleteUser(user.id)}>Delete</Button>
|
<Button
|
||||||
|
variant={"destructive"}
|
||||||
|
onClick={() => deleteUser(user.id)}
|
||||||
|
size={"sm"}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
@ -90,7 +104,7 @@ export function UserTable({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PostTable({
|
export function PostTable({
|
||||||
posts
|
posts: initialPosts
|
||||||
}: {
|
}: {
|
||||||
posts?: {
|
posts?: {
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
@ -100,15 +114,54 @@ export function PostTable({
|
||||||
visibility: string
|
visibility: string
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
|
const [posts, setPosts] = useState<typeof initialPosts>(initialPosts)
|
||||||
|
const { setToast } = useToasts()
|
||||||
|
const deletePost = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const confirmed = confirm("Are you sure you want to delete this post?")
|
||||||
|
if (!confirmed) {
|
||||||
|
setToast({
|
||||||
|
message: "Post not deleted",
|
||||||
|
type: "default"
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetchWithUser("/api/admin?action=delete-post", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
postId: id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
setToast({
|
||||||
|
message: "Post deleted",
|
||||||
|
type: "success"
|
||||||
|
})
|
||||||
|
setPosts(posts?.filter((post) => post.id !== id))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setToast({
|
||||||
|
message: "Error deleting user",
|
||||||
|
type: "error"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className={styles.table}>
|
<table className="w-full overflow-x-auto">
|
||||||
<thead>
|
<thead className="text-left">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Author</th>
|
<th>Author</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Visibility</th>
|
<th>Visibility</th>
|
||||||
<th className={styles.id}>Post ID</th>
|
<th className={styles.id}>Post ID</th>
|
||||||
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -130,6 +183,15 @@ export function PostTable({
|
||||||
<td>{new Date(post.createdAt).toLocaleDateString()}</td>
|
<td>{new Date(post.createdAt).toLocaleDateString()}</td>
|
||||||
<td>{post.visibility}</td>
|
<td>{post.visibility}</td>
|
||||||
<td>{post.id}</td>
|
<td>{post.id}</td>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
variant={"destructive"}
|
||||||
|
size={"sm"}
|
||||||
|
onClick={() => deletePost(post.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
|
import { TypographyH1, TypographyH2 } from "@components/typography"
|
||||||
import { PostTable, UserTable } from "./components/tables"
|
import { PostTable, UserTable } from "./components/tables"
|
||||||
|
|
||||||
export default function AdminLoading() {
|
export default function AdminLoading() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Admin</h1>
|
<TypographyH1>Admin</TypographyH1>
|
||||||
<h2>Users</h2>
|
<TypographyH2>Users</TypographyH2>
|
||||||
<UserTable />
|
<UserTable />
|
||||||
<h2>Posts</h2>
|
<TypographyH2>Posts</TypographyH2>
|
||||||
<PostTable />
|
<PostTable />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,15 +6,20 @@ import {
|
||||||
ServerPostWithFiles
|
ServerPostWithFiles
|
||||||
} from "@lib/server/prisma"
|
} from "@lib/server/prisma"
|
||||||
import { PostTable, UserTable } from "./components/tables"
|
import { PostTable, UserTable } from "./components/tables"
|
||||||
|
import { PageWrapper } from "@components/page-wrapper"
|
||||||
|
import { PageTitle } from "@components/page-title"
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const usersPromise = getAllUsers({
|
const usersPromise = getAllUsers({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
createdAt: true,
|
||||||
createdAt: true
|
email: true,
|
||||||
|
role: true,
|
||||||
|
username: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const postsPromise = getAllPosts({
|
const postsPromise = getAllPosts({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -43,13 +48,15 @@ export default async function AdminPage() {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1>Admin</h1>
|
<PageTitle>Admin</PageTitle>
|
||||||
<h2>Users</h2>
|
<PageWrapper>
|
||||||
{/* @ts-expect-error Type 'unknown' is not assignable to type */}
|
<h2 className="mb-4 mt-4 text-2xl font-bold">Users</h2>
|
||||||
<UserTable users={serializedUsers as unknown} />
|
{/* @ts-expect-error Type 'unknown' is not assignable to type */}
|
||||||
<h2>Posts</h2>
|
<UserTable users={serializedUsers as unknown} />
|
||||||
<PostTable posts={serializedPosts} />
|
<h2 className="mb-4 mt-4 text-2xl font-bold">Posts</h2>
|
||||||
</div>
|
<PostTable posts={serializedPosts} />
|
||||||
|
</PageWrapper>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import PostList from "@components/post-list"
|
import PostList from "@components/post-list"
|
||||||
|
import { TypographyH1 } from "@components/typography"
|
||||||
import {
|
import {
|
||||||
getPostsByUser,
|
getPostsByUser,
|
||||||
getUserById,
|
getUserById,
|
||||||
|
@ -59,7 +60,9 @@ export default async function UserPage({
|
||||||
justifyContent: "space-between"
|
justifyContent: "space-between"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h1>Public posts by {user?.displayName || "Anonymous"}</h1>
|
<TypographyH1>
|
||||||
|
Public posts by {user?.displayName || "Anonymous"}
|
||||||
|
</TypographyH1>
|
||||||
<Avatar />
|
<Avatar />
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={<PostList hideSearch skeleton initialPosts={[]} />}>
|
<Suspense fallback={<PostList hideSearch skeleton initialPosts={[]} />}>
|
||||||
|
|
|
@ -6,6 +6,8 @@ import Header from "@components/header"
|
||||||
import { Inter } from "next/font/google"
|
import { Inter } from "next/font/google"
|
||||||
import { getMetadata } from "src/app/lib/metadata"
|
import { getMetadata } from "src/app/lib/metadata"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
|
import clsx from "clsx"
|
||||||
|
import Link from "@components/link"
|
||||||
const inter = Inter({ subsets: ["latin"], variable: "--inter-font" })
|
const inter = Inter({ subsets: ["latin"], variable: "--inter-font" })
|
||||||
|
|
||||||
const CmdK = dynamic(() => import("@components/cmdk"), { ssr: false })
|
const CmdK = dynamic(() => import("@components/cmdk"), { ssr: false })
|
||||||
|
@ -17,14 +19,18 @@ export default async function RootLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
// suppressHydrationWarning is required because of next-themes
|
// suppressHydrationWarning is required because of next-themes
|
||||||
<html lang="en" className={inter.variable} suppressHydrationWarning>
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={clsx(inter.variable, "mx-auto w-[var(--main-content)]")}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<body>
|
<body>
|
||||||
<Toasts />
|
<Toasts />
|
||||||
<Providers>
|
<Providers>
|
||||||
<Layout>
|
<Layout>
|
||||||
<CmdK />
|
<CmdK />
|
||||||
<Header />
|
<Header />
|
||||||
{children}
|
<main>{children}</main>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { PageTitle } from "@components/page-title"
|
||||||
|
import { PageWrapper } from "@components/page-wrapper"
|
||||||
import PostList from "@components/post-list"
|
import PostList from "@components/post-list"
|
||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return <PostList skeleton={true} initialPosts={[]} />
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle>Your Posts</PageTitle>
|
||||||
|
<PageWrapper></PageWrapper>
|
||||||
|
<PostList skeleton={true} initialPosts={[]} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { Suspense } from "react"
|
||||||
import ErrorBoundary from "@components/error/fallback"
|
import ErrorBoundary from "@components/error/fallback"
|
||||||
import { getMetadata } from "src/app/lib/metadata"
|
import { getMetadata } from "src/app/lib/metadata"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
import { PageTitle } from "@components/page-title"
|
||||||
|
import { PageWrapper } from "@components/page-wrapper"
|
||||||
|
|
||||||
export default async function Mine() {
|
export default async function Mine() {
|
||||||
const userId = (await getCurrentUser())?.id
|
const userId = (await getCurrentUser())?.id
|
||||||
|
@ -16,16 +18,21 @@ export default async function Mine() {
|
||||||
|
|
||||||
const posts = (await getPostsByUser(userId, true)).map(serverPostToClientPost)
|
const posts = (await getPostsByUser(userId, true)).map(serverPostToClientPost)
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<>
|
||||||
<Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}>
|
<PageTitle>Your Posts</PageTitle>
|
||||||
<PostList
|
<PageWrapper>
|
||||||
userId={userId}
|
<ErrorBoundary>
|
||||||
initialPosts={posts}
|
<Suspense fallback={<PostList skeleton={true} initialPosts={[]} />}>
|
||||||
isOwner={true}
|
<PostList
|
||||||
hideSearch={false}
|
userId={userId}
|
||||||
/>
|
initialPosts={posts}
|
||||||
</Suspense>
|
isOwner={true}
|
||||||
</ErrorBoundary>
|
hideSearch={false}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</PageWrapper>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import Image from "next/image"
|
import { Card, CardContent } from "@components/card"
|
||||||
import Card from "@components/card"
|
|
||||||
import { getWelcomeContent } from "src/pages/api/welcome"
|
import { getWelcomeContent } from "src/pages/api/welcome"
|
||||||
import DocumentTabs from "./(posts)/components/tabs"
|
|
||||||
import {
|
import {
|
||||||
getAllPosts,
|
getAllPosts,
|
||||||
serverPostToClientPost,
|
serverPostToClientPost,
|
||||||
|
@ -10,8 +8,8 @@ import {
|
||||||
import PostList, { NoPostsFound } from "@components/post-list"
|
import PostList, { NoPostsFound } from "@components/post-list"
|
||||||
import { cache, Suspense } from "react"
|
import { cache, Suspense } from "react"
|
||||||
import ErrorBoundary from "@components/error/fallback"
|
import ErrorBoundary from "@components/error/fallback"
|
||||||
import { Stack } from "@components/stack"
|
import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
|
||||||
|
import { PageWrapper } from "@components/page-wrapper"
|
||||||
export const revalidate = 300
|
export const revalidate = 300
|
||||||
|
|
||||||
const getWelcomeData = cache(async () => {
|
const getWelcomeData = cache(async () => {
|
||||||
|
@ -20,23 +18,11 @@ const getWelcomeData = cache(async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const { title } = await getWelcomeData()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack direction="column">
|
<PageWrapper>
|
||||||
<Stack direction="row" alignItems="center">
|
|
||||||
<Image
|
|
||||||
src={"/assets/logo.svg"}
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
alt=""
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<h1 style={{ marginLeft: "var(--gap)" }}>{title}</h1>
|
|
||||||
</Stack>
|
|
||||||
{/* @ts-expect-error because of async RSC */}
|
{/* @ts-expect-error because of async RSC */}
|
||||||
<WelcomePost />
|
<WelcomePost />
|
||||||
<h2>Recent public posts</h2>
|
<h2 className="mt-4 text-2xl font-bold">Recent Public Posts</h2>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
|
@ -47,22 +33,24 @@ export default async function Page() {
|
||||||
<PublicPostList />
|
<PublicPostList />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Stack>
|
</PageWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function WelcomePost() {
|
async function WelcomePost() {
|
||||||
const { content, rendered, title } = await getWelcomeData()
|
const { content, rendered, title } = await getWelcomeData()
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="w-full">
|
||||||
<DocumentTabs
|
<CardContent>
|
||||||
defaultTab="preview"
|
<DocumentTabs
|
||||||
isEditing={false}
|
defaultTab="preview"
|
||||||
staticPreview={rendered as string}
|
isEditing={false}
|
||||||
title={title}
|
staticPreview={rendered as string}
|
||||||
>
|
title={title}
|
||||||
{content}
|
>
|
||||||
</DocumentTabs>
|
{content}
|
||||||
|
</DocumentTabs>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -79,6 +67,7 @@ async function PublicPostList() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
visibility: true,
|
visibility: true,
|
||||||
|
expiresAt: true,
|
||||||
files: {
|
files: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
|
@ -10,7 +10,12 @@ export function Providers({ children }: PropsWithChildren<unknown>) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<RadixTooltip.Provider delayDuration={200}>
|
<RadixTooltip.Provider delayDuration={200}>
|
||||||
<ThemeProvider enableSystem defaultTheme="dark">
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
enableColorScheme
|
||||||
|
enableSystem
|
||||||
|
defaultTheme="dark"
|
||||||
|
>
|
||||||
<SWRProvider>{children}</SWRProvider>
|
<SWRProvider>{children}</SWRProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</RadixTooltip.Provider>
|
</RadixTooltip.Provider>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import Input from "@components/input"
|
import { Input } from "@components/input"
|
||||||
import Note from "@components/note"
|
import Note from "@components/note"
|
||||||
import { Spinner } from "@components/spinner"
|
import { Spinner } from "@components/spinner"
|
||||||
import { useToasts } from "@components/toasts"
|
import { useToasts } from "@components/toasts"
|
||||||
|
@ -13,6 +13,7 @@ import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import styles from "./api-keys.module.css"
|
import styles from "./api-keys.module.css"
|
||||||
import { useSessionSWR } from "@lib/use-session-swr"
|
import { useSessionSWR } from "@lib/use-session-swr"
|
||||||
|
import { TypographyH4 } from "@components/typography"
|
||||||
|
|
||||||
// need to pass in the accessToken
|
// need to pass in the accessToken
|
||||||
const APIKeys = ({
|
const APIKeys = ({
|
||||||
|
@ -75,7 +76,7 @@ const APIKeys = ({
|
||||||
)}
|
)}
|
||||||
{hasError && <Note type="error">{error?.message}</Note>}
|
{hasError && <Note type="error">{error?.message}</Note>}
|
||||||
<form className={styles.form}>
|
<form className={styles.form}>
|
||||||
<h5>Create new</h5>
|
<TypographyH4>Create new</TypographyH4>
|
||||||
<fieldset className={styles.fieldset}>
|
<fieldset className={styles.fieldset}>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -85,10 +86,9 @@ const APIKeys = ({
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
onClick={onCreateTokenClick}
|
onClick={onCreateTokenClick}
|
||||||
loading={submitting}
|
|
||||||
disabled={!newToken}
|
disabled={!newToken}
|
||||||
|
loading={submitting}
|
||||||
>
|
>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -121,7 +121,9 @@ const APIKeys = ({
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
) : (
|
||||||
<p>You have no API keys.</p>
|
<p className="p-4 text-center text-muted-foreground">
|
||||||
|
No API keys found.
|
||||||
|
</p>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div style={{ marginTop: "var(--gap-quarter)" }}>
|
<div style={{ marginTop: "var(--gap-quarter)" }}>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import Input from "@components/input"
|
import { Input } from "@components/input"
|
||||||
import Note from "@components/note"
|
import Note from "@components/note"
|
||||||
import { useToasts } from "@components/toasts"
|
import { useToasts } from "@components/toasts"
|
||||||
import { useSessionSWR } from "@lib/use-session-swr"
|
import { useSessionSWR } from "@lib/use-session-swr"
|
||||||
|
@ -142,8 +142,7 @@ function Profile() {
|
||||||
</div>
|
</div>
|
||||||
</TooltipComponent>
|
</TooltipComponent>
|
||||||
</div> */}
|
</div> */}
|
||||||
|
<Button type="submit" disabled={!name} loading={submitting}>
|
||||||
<Button type="submit" loading={submitting}>
|
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
export default function SettingsLayout({
|
|
||||||
children
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1>Settings</h1>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "var(--gap)",
|
|
||||||
marginBottom: "var(--gap)",
|
|
||||||
marginTop: "var(--gap)"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -1,5 +1,15 @@
|
||||||
|
import { PageTitle } from "@components/page-title"
|
||||||
|
import { PageWrapper } from "@components/page-wrapper"
|
||||||
import SettingsGroup from "@components/settings-group"
|
import SettingsGroup from "@components/settings-group"
|
||||||
|
|
||||||
export default function SettingsLoading() {
|
export default function SettingsLoading() {
|
||||||
return <SettingsGroup skeleton />
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle>Settings</PageTitle>
|
||||||
|
<PageWrapper>
|
||||||
|
<SettingsGroup skeleton />
|
||||||
|
<SettingsGroup skeleton />
|
||||||
|
</PageWrapper>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,16 +2,21 @@ import { getMetadata } from "src/app/lib/metadata"
|
||||||
import SettingsGroup from "../../components/settings-group"
|
import SettingsGroup from "../../components/settings-group"
|
||||||
import APIKeys from "./components/sections/api-keys"
|
import APIKeys from "./components/sections/api-keys"
|
||||||
import Profile from "./components/sections/profile"
|
import Profile from "./components/sections/profile"
|
||||||
|
import { PageTitle } from "@components/page-title"
|
||||||
|
import { PageWrapper } from "@components/page-wrapper"
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsGroup title="Profile">
|
<PageTitle>Settings</PageTitle>
|
||||||
<Profile />
|
<PageWrapper>
|
||||||
</SettingsGroup>
|
<SettingsGroup title="Profile">
|
||||||
<SettingsGroup title="API Keys">
|
<Profile />
|
||||||
<APIKeys />
|
</SettingsGroup>
|
||||||
</SettingsGroup>
|
<SettingsGroup title="API Keys">
|
||||||
|
<APIKeys />
|
||||||
|
</SettingsGroup>
|
||||||
|
</PageWrapper>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { authOptions } from "@lib/server/auth"
|
||||||
|
import NextAuth from "next-auth"
|
||||||
|
|
||||||
|
const handler = NextAuth(authOptions)
|
||||||
|
|
||||||
|
export { handler as GET, handler as POST }
|
41
src/app/api/auth/requires-passcode/route.ts
Normal file
41
src/app/api/auth/requires-passcode/route.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import config from "@lib/config"
|
||||||
|
import { NextRequest } from "next/server"
|
||||||
|
|
||||||
|
export const getRequiresPasscode = async () => {
|
||||||
|
const requiresPasscode = Boolean(config.registration_password)
|
||||||
|
return requiresPasscode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GET(req: NextRequest) {
|
||||||
|
const searchParams = new URL(req.nextUrl).searchParams
|
||||||
|
const slug = searchParams.get("slug")
|
||||||
|
|
||||||
|
if (!slug || Array.isArray(slug)) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 400,
|
||||||
|
statusText: "Bad request"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug === "requires-passcode") {
|
||||||
|
// return res.json({ requiresPasscode: await getRequiresPasscode() })
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ requiresPasscode: await getRequiresPasscode() }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ error: "Not found" }), {
|
||||||
|
status: 404,
|
||||||
|
statusText: "Not found",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
150
src/app/components/alert-dialog.tsx
Normal file
150
src/app/components/alert-dialog.tsx
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
import { buttonVariants } from "@components/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
|
||||||
|
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AlertDialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-opacity animate-in fade-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel
|
||||||
|
}
|
|
@ -1,21 +1,35 @@
|
||||||
import React from "react"
|
import * as React from "react"
|
||||||
import styles from "./badge.module.css"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
type BadgeProps = {
|
import { cn } from "@lib/cn"
|
||||||
type: "primary" | "secondary" | "error" | "warning"
|
|
||||||
} & React.HTMLAttributes<HTMLDivElement>
|
|
||||||
|
|
||||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
const badgeVariants = cva(
|
||||||
({ type, children, ...rest }: BadgeProps, ref) => {
|
"inline-flex items-center border rounded-full font-medium px-2.5 py-0.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
return (
|
{
|
||||||
<div className={styles.container} {...rest}>
|
variants: {
|
||||||
<div className={`${styles.badge} ${styles[type]}`} ref={ref}>
|
variant: {
|
||||||
{children}
|
default:
|
||||||
</div>
|
"bg-primary hover:bg-primary/80 border-transparent text-primary-foreground",
|
||||||
</div>
|
secondary:
|
||||||
)
|
"bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
|
||||||
|
outline: "text-foreground"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Badge.displayName = "Badge"
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
export default Badge
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
"use client"
|
"use client"
|
||||||
import { useToasts } from "@components/toasts"
|
import { useToasts } from "@components/toasts"
|
||||||
import Tooltip from "@components/tooltip"
|
import { Tooltip } from "@components/tooltip"
|
||||||
import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
|
import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
|
||||||
import { timeAgo } from "src/app/lib/time-ago"
|
import { timeAgo } from "src/app/lib/time-ago"
|
||||||
import { useMemo, useState, useEffect } from "react"
|
import { useMemo, useState, useEffect } from "react"
|
||||||
import Badge from "../badge"
|
import { Badge } from "../badge"
|
||||||
|
|
||||||
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
|
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
|
||||||
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
|
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
|
||||||
|
@ -30,7 +30,7 @@ const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
|
||||||
return (
|
return (
|
||||||
// TODO: investigate tooltip not showing
|
// TODO: investigate tooltip not showing
|
||||||
<Tooltip content={formattedTime}>
|
<Tooltip content={formattedTime}>
|
||||||
<Badge type="secondary" onClick={onClick}>
|
<Badge onClick={onClick} variant={"outline"} suppressHydrationWarning>
|
||||||
{" "}
|
{" "}
|
||||||
<>{time}</>
|
<>{time}</>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Tooltip from "@components/tooltip"
|
import { Tooltip } from "@components/tooltip"
|
||||||
import { timeUntil } from "src/app/lib/time-ago"
|
import { timeUntil } from "src/app/lib/time-ago"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Badge from "../badge"
|
import { Badge } from "../badge"
|
||||||
|
|
||||||
const ExpirationBadge = ({
|
const ExpirationBadge = ({
|
||||||
postExpirationDate
|
postExpirationDate
|
||||||
|
@ -43,7 +43,7 @@ const ExpirationBadge = ({
|
||||||
const isExpired = expirationDate < new Date()
|
const isExpired = expirationDate < new Date()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge type={isExpired ? "error" : "warning"}>
|
<Badge variant={isExpired ? "destructive" : "outline"}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}
|
content={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import Badge from "../badge"
|
import { Badge } from "../badge"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visibility: string
|
visibility: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const VisibilityBadge = ({ visibility }: Props) => {
|
const VisibilityBadge = ({ visibility }: Props) => {
|
||||||
return <Badge type={"primary"}>{visibility}</Badge>
|
return <Badge variant={"outline"}>{visibility}</Badge>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default VisibilityBadge
|
export default VisibilityBadge
|
||||||
|
|
|
@ -3,9 +3,8 @@
|
||||||
import PasswordModal from "@components/password-modal"
|
import PasswordModal from "@components/password-modal"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import ButtonGroup from "@components/button-group"
|
import ButtonGroup from "@components/button-group"
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import { useToasts } from "@components/toasts"
|
import { useToasts } from "@components/toasts"
|
||||||
import { Spinner } from "@components/spinner"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useSessionSWR } from "@lib/use-session-swr"
|
import { useSessionSWR } from "@lib/use-session-swr"
|
||||||
import { fetchWithUser } from "src/app/lib/fetch-with-user"
|
import { fetchWithUser } from "src/app/lib/fetch-with-user"
|
||||||
|
@ -89,39 +88,40 @@ function VisibilityControl({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeIn>
|
<FadeIn className="mt-8">
|
||||||
<ButtonGroup
|
<ButtonGroup>
|
||||||
style={{
|
|
||||||
maxWidth: 600,
|
|
||||||
margin: "var(--gap) auto"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
disabled={visibility === "private"}
|
disabled={visibility === "private"}
|
||||||
|
variant={"outline"}
|
||||||
onClick={() => onSubmit("private")}
|
onClick={() => onSubmit("private")}
|
||||||
|
loading={isSubmitting === "private"}
|
||||||
>
|
>
|
||||||
{isSubmitting === "private" ? <Spinner /> : "Make Private"}
|
Make Private
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={visibility === "public"}
|
disabled={visibility === "public"}
|
||||||
|
variant={"outline"}
|
||||||
onClick={() => onSubmit("public")}
|
onClick={() => onSubmit("public")}
|
||||||
|
loading={isSubmitting === "public"}
|
||||||
>
|
>
|
||||||
{isSubmitting === "public" ? <Spinner /> : "Make Public"}
|
Make Public
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={visibility === "unlisted"}
|
disabled={visibility === "unlisted"}
|
||||||
|
variant={"outline"}
|
||||||
onClick={() => onSubmit("unlisted")}
|
onClick={() => onSubmit("unlisted")}
|
||||||
|
loading={isSubmitting === "unlisted"}
|
||||||
>
|
>
|
||||||
{isSubmitting === "unlisted" ? <Spinner /> : "Make Unlisted"}
|
Make Unlisted
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => onSubmit("protected")}>
|
<Button
|
||||||
{isSubmitting === "protected" ? (
|
onClick={() => onSubmit("protected")}
|
||||||
<Spinner />
|
variant={"outline"}
|
||||||
) : visibility === "protected" ? (
|
loading={isSubmitting === "protected"}
|
||||||
"Change Password"
|
>
|
||||||
) : (
|
{visibility === "protected"
|
||||||
"Protect with Password"
|
? "Change Password"
|
||||||
)}
|
: "Protect with Password"}
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
<PasswordModal
|
<PasswordModal
|
||||||
|
|
|
@ -1,52 +1,44 @@
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import React, { ReactNode } from "react"
|
import React, { ComponentProps, ReactNode } from "react"
|
||||||
import styles from "./dropdown.module.css"
|
import styles from "./dropdown.module.css"
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem
|
||||||
|
} from "@components/dropdown-menu"
|
||||||
import { ArrowDown } from "react-feather"
|
import { ArrowDown } from "react-feather"
|
||||||
type Props = {
|
|
||||||
type?: "primary" | "secondary"
|
|
||||||
height?: number | string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Attrs = Omit<React.HTMLAttributes<HTMLDivElement>, keyof Props>
|
type ButtonDropdownProps = ComponentProps<typeof DropdownMenu>
|
||||||
type ButtonDropdownProps = Props & Attrs
|
|
||||||
|
|
||||||
const ButtonDropdown: React.FC<
|
const ButtonDropdown: React.FC<React.PropsWithChildren<ButtonDropdownProps>> = (
|
||||||
React.PropsWithChildren<ButtonDropdownProps>
|
props
|
||||||
> = ({ type, ...props }) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu>
|
||||||
<div className={styles.dropdown} style={{ height: props.height }}>
|
<div className={styles.dropdown}>
|
||||||
<>
|
<>
|
||||||
{Array.isArray(props.children) ? props.children[0] : props.children}
|
{Array.isArray(props.children) ? props.children[0] : props.children}
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenuTrigger asChild>
|
||||||
style={{
|
<Button>
|
||||||
display: "flex",
|
<ArrowDown height={20} />
|
||||||
flexDirection: "row",
|
</Button>
|
||||||
justifyContent: "flex-end"
|
</DropdownMenuTrigger>
|
||||||
}}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
iconLeft={<ArrowDown />}
|
|
||||||
buttonType={type}
|
|
||||||
className={styles.icon}
|
|
||||||
/>
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
{Array.isArray(props.children) ? (
|
{Array.isArray(props.children) ? (
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenu.Content align="end">
|
<DropdownMenuContent align="end">
|
||||||
{(props.children as ReactNode[])
|
{(props.children as ReactNode[])
|
||||||
?.slice(1)
|
?.slice(1)
|
||||||
.map((child, index) => (
|
.map((child, index) => (
|
||||||
<DropdownMenu.Item key={index}>{child}</DropdownMenu.Item>
|
<DropdownMenuItem key={index}>{child}</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenuPortal>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
margin-top: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-group > * {
|
.button-group > * {
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
.button {
|
|
||||||
user-select: none;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover,
|
|
||||||
.button:focus {
|
|
||||||
color: var(--fg);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--darker-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button[disabled] {
|
|
||||||
cursor: not-allowed;
|
|
||||||
background: var(--lighter-gray);
|
|
||||||
color: var(--gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button[disabled]:hover,
|
|
||||||
.button[disabled]:focus {
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
background: var(--fg);
|
|
||||||
color: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
display: inline-block;
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconRight {
|
|
||||||
margin-left: var(--gap-half);
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconLeft {
|
|
||||||
margin-right: var(--gap-half);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
transform: scale(1.2) translateY(-0.05em);
|
|
||||||
}
|
|
|
@ -1,86 +1,65 @@
|
||||||
import styles from "./button.module.css"
|
import * as React from "react"
|
||||||
import { forwardRef } from "react"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import clsx from "clsx"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
import { Spinner } from "@components/spinner"
|
import { Spinner } from "@components/spinner"
|
||||||
|
|
||||||
type Props = React.DetailedHTMLProps<
|
const buttonVariants = cva(
|
||||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-75 disabled:pointer-events-none ring-offset-background",
|
||||||
HTMLButtonElement
|
{
|
||||||
> & {
|
variants: {
|
||||||
children?: React.ReactNode
|
variant: {
|
||||||
buttonType?: "primary" | "secondary"
|
default: "bg-primary/80 text-primary-foreground/80 hover:bg-primary/70",
|
||||||
className?: string
|
destructive:
|
||||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
iconRight?: React.ReactNode
|
outline:
|
||||||
iconLeft?: React.ReactNode
|
"border border-input hover:bg-accent hover:text-accent-foreground",
|
||||||
height?: string | number
|
secondary:
|
||||||
width?: string | number
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
padding?: string | number
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
margin?: string | number
|
link: "underline-offset-4 hover:underline text-primary"
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 py-2 px-4",
|
||||||
|
sm: "h-9 px-3 rounded-md",
|
||||||
|
lg: "h-11 px-8 rounded-md"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react/display-name
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
const Button = forwardRef<HTMLButtonElement, Props>(
|
|
||||||
(
|
(
|
||||||
{
|
{ className, variant, size, loading, children, asChild = false, ...props },
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
buttonType = "secondary",
|
|
||||||
disabled = false,
|
|
||||||
iconRight,
|
|
||||||
iconLeft,
|
|
||||||
height = 40,
|
|
||||||
width,
|
|
||||||
padding = 10,
|
|
||||||
margin,
|
|
||||||
loading,
|
|
||||||
style,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={clsx(styles.button, className, {
|
|
||||||
[styles.primary]: buttonType === "primary",
|
|
||||||
[styles.secondary]: buttonType === "secondary"
|
|
||||||
})}
|
|
||||||
disabled={disabled || loading}
|
|
||||||
onClick={onClick}
|
|
||||||
style={{ height, width, margin, padding, ...style }}
|
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children && iconLeft && (
|
{loading ? <Spinner className="mr-2" /> : null}
|
||||||
<span className={clsx(styles.icon, styles.iconLeft)}>{iconLeft}</span>
|
{children}
|
||||||
)}
|
</Comp>
|
||||||
{!loading &&
|
|
||||||
(children ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<span className={styles.icon}>{iconLeft || iconRight}</span>
|
|
||||||
))}
|
|
||||||
{loading && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Spinner />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{children && iconRight && (
|
|
||||||
<span className={clsx(styles.icon, styles.iconRight)}>
|
|
||||||
{iconRight}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export default Button
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
|
64
src/app/components/calendar.tsx
Normal file
64
src/app/components/calendar.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ChevronLeft, ChevronRight } from "react-feather"
|
||||||
|
import { DayPicker } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
import { buttonVariants } from "@components/button"
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||||
|
month: "space-y-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "space-x-1 flex items-center",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-y-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
|
||||||
|
),
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside: "text-muted-foreground opacity-50",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft: ({}) => <ChevronLeft className="h-4 w-4" />,
|
||||||
|
IconRight: ({}) => <ChevronRight className="h-4 w-4" />
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar"
|
||||||
|
|
||||||
|
export { Calendar }
|
|
@ -1,16 +1,78 @@
|
||||||
import styles from "./card.module.css"
|
import * as React from "react"
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
export default function Card({
|
const Card = React.forwardRef<
|
||||||
children,
|
HTMLDivElement,
|
||||||
className,
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
...props
|
>(({ className, ...props }, ref) => (
|
||||||
}: {
|
<div
|
||||||
children?: React.ReactNode
|
ref={ref}
|
||||||
className?: string
|
className={cn(
|
||||||
} & React.ComponentProps<"div">) {
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
return (
|
className
|
||||||
<div className={`${styles.card} ${className || ""}`} {...props}>
|
)}
|
||||||
<div className={styles.content}>{children}</div>
|
{...props}
|
||||||
</div>
|
/>
|
||||||
)
|
))
|
||||||
}
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(" flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
|
|
|
@ -1,165 +0,0 @@
|
||||||
/** Based on https://github.com/pacocoursey/cmdk **/
|
|
||||||
.cmdk[cmdk-root] {
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
box-shadow: 0 0 0 1px var(--lighter-gray), 0 4px 16px rgba(0, 0, 0, 0.2);
|
|
||||||
transition: transform 100ms ease;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
|
|
||||||
.dark & {
|
|
||||||
background: rgba(22, 22, 22, 0.7);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmdk {
|
|
||||||
/* centered */
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 999999;
|
|
||||||
/* size */
|
|
||||||
max-width: 640px;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
[cmdk-list] {
|
|
||||||
background: var(--bg);
|
|
||||||
height: 500px;
|
|
||||||
overflow: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
[cmdk-input] {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
width: 100%;
|
|
||||||
font-size: 17px;
|
|
||||||
padding: 8px 8px 16px 8px;
|
|
||||||
outline: none;
|
|
||||||
/* background: var(--lightest-gray); */
|
|
||||||
color: var(--fg);
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: var(--gray);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[cmdk-badge] {
|
|
||||||
height: 20px;
|
|
||||||
background: var(--grayA3);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--grayA11);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 4px 0 4px 4px;
|
|
||||||
user-select: none;
|
|
||||||
text-transform: capitalize;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
[cmdk-item] {
|
|
||||||
content-visibility: auto;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 0 16px;
|
|
||||||
color: var(--darker-gray);
|
|
||||||
user-select: none;
|
|
||||||
will-change: background, color;
|
|
||||||
transition: all 150ms ease;
|
|
||||||
transition-property: none;
|
|
||||||
background: var(--bg);
|
|
||||||
|
|
||||||
&[aria-selected="true"] {
|
|
||||||
background: var(--lightest-gray);
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
|
|
||||||
&[aria-disabled="true"] {
|
|
||||||
/* TODO: improve this */
|
|
||||||
color: var(--bg);
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transition-property: background;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
& + [cmdk-item] {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[cmdk-list] {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
transition: 100ms ease;
|
|
||||||
transition-property: height;
|
|
||||||
}
|
|
||||||
|
|
||||||
[cmdk-shortcuts] {
|
|
||||||
display: flex;
|
|
||||||
margin-left: auto;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
kbd {
|
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: 12px;
|
|
||||||
min-width: 20px;
|
|
||||||
padding: var(--gap-half);
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--fg);
|
|
||||||
background: var(--light-gray);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[cmdk-separator] {
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--light-gray);
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
*:not([hidden]) + [cmdk-group] {
|
|
||||||
margin-top: var(--gap);
|
|
||||||
}
|
|
||||||
|
|
||||||
[cmdk-group-heading] {
|
|
||||||
user-select: none;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--gray);
|
|
||||||
padding: 0 var(--gap);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: var(--gap);
|
|
||||||
margin-top: var(--gap);
|
|
||||||
}
|
|
||||||
|
|
||||||
[cmdk-empty] {
|
|
||||||
font-size: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 48px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
color: var(--gray);
|
|
||||||
}
|
|
||||||
}
|
|
163
src/app/components/cmdk/cmdk.tsx
Normal file
163
src/app/components/cmdk/cmdk.tsx
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { DialogProps } from "@radix-ui/react-dialog"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { Search } from "react-feather"
|
||||||
|
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
import { Dialog, DialogContent } from "@components/dialog"
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Command.displayName = CommandPrimitive.displayName
|
||||||
|
|
||||||
|
type CommandDialogProps = DialogProps
|
||||||
|
|
||||||
|
const CommandDialog = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Dialog>,
|
||||||
|
CommandDialogProps
|
||||||
|
>(({ children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
||||||
|
<Command
|
||||||
|
ref={ref}
|
||||||
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
CommandDialog.displayName = Dialog.displayName
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CommandShortcut.displayName = "CommandShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator
|
||||||
|
}
|
|
@ -1,16 +1,3 @@
|
||||||
body [cmdk-dialog] {
|
body [cmdk-dialog] {
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 999999;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
/* backdrop-filter: blur(4px); */
|
|
||||||
transition: opacity 100ms ease;
|
|
||||||
pointer-events: none;
|
|
||||||
will-change: opacity;
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Command } from "cmdk"
|
import { CommandDialog, CommandList, CommandInput, CommandEmpty } from "./cmdk"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import styles from "./cmdk.module.css"
|
|
||||||
import "./dialog.css"
|
import "./dialog.css"
|
||||||
import HomePage from "./pages/home"
|
import HomePage from "./pages/home"
|
||||||
import PostsPage from "./pages/posts"
|
import PostsPage from "./pages/posts"
|
||||||
|
@ -10,7 +9,7 @@ import PostsPage from "./pages/posts"
|
||||||
export type CmdKPage = "home" | "posts"
|
export type CmdKPage = "home" | "posts"
|
||||||
export default function CmdK() {
|
export default function CmdK() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement | null>(null)
|
const ref = useRef<HTMLDivElement>()
|
||||||
const [page, setPage] = useState<CmdKPage>("home")
|
const [page, setPage] = useState<CmdKPage>("home")
|
||||||
|
|
||||||
// Toggle the menu when ⌘K is pressed
|
// Toggle the menu when ⌘K is pressed
|
||||||
|
@ -53,21 +52,15 @@ export default function CmdK() {
|
||||||
}, [page])
|
}, [page])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command.Dialog
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
open={open}
|
<CommandList>
|
||||||
onOpenChange={setOpen}
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
label="Global Command Menu"
|
|
||||||
className={styles.cmdk}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<Command.List>
|
|
||||||
<Command.Empty>No results found.</Command.Empty>
|
|
||||||
{page === "home" ? (
|
{page === "home" ? (
|
||||||
<HomePage setPage={setPage} setOpen={setOpen} />
|
<HomePage setPage={setPage} setOpen={setOpen} />
|
||||||
) : null}
|
) : null}
|
||||||
{page === "posts" ? <PostsPage setOpen={setOpen} /> : null}
|
{page === "posts" ? <PostsPage setOpen={setOpen} /> : null}
|
||||||
</Command.List>
|
</CommandList>
|
||||||
<Command.Input />
|
<CommandInput />
|
||||||
</Command.Dialog>
|
</CommandDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Command } from "cmdk"
|
import { CommandItem } from "@components/cmdk/cmdk"
|
||||||
|
|
||||||
export default function Item({
|
export default function Item({
|
||||||
children,
|
children,
|
||||||
|
@ -12,7 +12,7 @@ export default function Item({
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Command.Item onSelect={onSelect}>
|
<CommandItem onSelect={onSelect}>
|
||||||
{icon}
|
{icon}
|
||||||
{children}
|
{children}
|
||||||
{shortcut ? (
|
{shortcut ? (
|
||||||
|
@ -22,6 +22,6 @@ export default function Item({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Command.Item>
|
</CommandItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Command } from "cmdk"
|
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { FilePlus, Moon, Search, Settings, Sun } from "react-feather"
|
import { FilePlus, Moon, Search, Settings, Sun } from "react-feather"
|
||||||
import { CmdKPage } from ".."
|
import { CmdKPage } from ".."
|
||||||
import Item from "../item"
|
import Item from "../item"
|
||||||
|
import { CommandGroup } from "@components/cmdk/cmdk"
|
||||||
|
|
||||||
export default function HomePage({
|
export default function HomePage({
|
||||||
setOpen,
|
setOpen,
|
||||||
|
@ -16,7 +16,7 @@ export default function HomePage({
|
||||||
const { setTheme, resolvedTheme } = useTheme()
|
const { setTheme, resolvedTheme } = useTheme()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Command.Group heading="Posts">
|
<CommandGroup heading="Posts">
|
||||||
<Item
|
<Item
|
||||||
shortcut="R P"
|
shortcut="R P"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
@ -36,8 +36,8 @@ export default function HomePage({
|
||||||
>
|
>
|
||||||
New Post
|
New Post
|
||||||
</Item>
|
</Item>
|
||||||
</Command.Group>
|
</CommandGroup>
|
||||||
<Command.Group heading="Settings">
|
<CommandGroup heading="Settings">
|
||||||
<Item
|
<Item
|
||||||
shortcut="T"
|
shortcut="T"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
|
@ -57,7 +57,7 @@ export default function HomePage({
|
||||||
>
|
>
|
||||||
Go to Settings
|
Go to Settings
|
||||||
</Item>
|
</Item>
|
||||||
</Command.Group>
|
</CommandGroup>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
50
src/app/components/date-picker.tsx
Normal file
50
src/app/components/date-picker.tsx
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import { Calendar as CalendarIcon } from "react-feather"
|
||||||
|
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
import { Button } from "@components/button"
|
||||||
|
import { Calendar } from "@components/calendar"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@components/popover"
|
||||||
|
|
||||||
|
export function DatePicker({
|
||||||
|
expiresAt,
|
||||||
|
setExpiresAt
|
||||||
|
}: {
|
||||||
|
expiresAt?: Date
|
||||||
|
setExpiresAt: React.Dispatch<React.SetStateAction<Date | undefined>>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
className={cn(
|
||||||
|
"w-[280px] justify-start text-left font-normal",
|
||||||
|
!expiresAt && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{expiresAt ? (
|
||||||
|
format(expiresAt, "PPP")
|
||||||
|
) : (
|
||||||
|
<span>Won'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>
|
||||||
|
)
|
||||||
|
}
|
128
src/app/components/dialog.tsx
Normal file
128
src/app/components/dialog.tsx
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "react-feather"
|
||||||
|
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.DialogPortalProps) => (
|
||||||
|
<DialogPrimitive.Portal className={cn(className)} {...props}>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
DialogPortal.displayName = DialogPrimitive.Portal.displayName
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background p-6 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription
|
||||||
|
}
|
200
src/app/components/dropdown-menu/index.tsx
Normal file
200
src/app/components/dropdown-menu/index.tsx
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "react-feather"
|
||||||
|
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import Link from "@components/link"
|
import Link from "@components/link"
|
||||||
import Note from "@components/note"
|
import Note from "@components/note"
|
||||||
|
import { TypographyH3 } from "@components/typography"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
// an error fallback for react-error-boundary
|
// an error fallback for react-error-boundary
|
||||||
|
|
||||||
|
@ -15,9 +16,12 @@ import {
|
||||||
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||||
return (
|
return (
|
||||||
<Note type="error" style={{ width: "100%" }}>
|
<Note type="error" style={{ width: "100%" }}>
|
||||||
<h3>Something went wrong:</h3>
|
<TypographyH3>Something went wrong:</TypographyH3>
|
||||||
<pre>{error.message}</pre>
|
<pre>{error.message}</pre>
|
||||||
<Link href="https://github.com/MaxLeiter/Drift/issues/new">
|
<Link
|
||||||
|
href="https://github.com/MaxLeiter/Drift/issues/new"
|
||||||
|
className="mr-2"
|
||||||
|
>
|
||||||
<Button>Report an issue</Button>
|
<Button>Report an issue</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Button onClick={resetErrorBoundary}>Try again</Button>
|
<Button onClick={resetErrorBoundary}>Try again</Button>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// https://www.joshwcomeau.com/snippets/react-components/fade-in/
|
// https://www.joshwcomeau.com/snippets/react-components/fade-in/
|
||||||
|
import React from "react"
|
||||||
import styles from "./fade.module.css"
|
import styles from "./fade.module.css"
|
||||||
|
|
||||||
function FadeIn({
|
function FadeIn({
|
||||||
|
@ -11,8 +12,18 @@ function FadeIn({
|
||||||
duration?: number
|
duration?: number
|
||||||
delay?: number
|
delay?: number
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
as?: React.ElementType
|
as?: React.ElementType | JSX.Element
|
||||||
} & React.HTMLAttributes<HTMLElement>) {
|
} & React.HTMLAttributes<HTMLElement>) {
|
||||||
|
if (as !== null && typeof as === "object") {
|
||||||
|
return React.cloneElement(as, {
|
||||||
|
className: styles.fadeIn,
|
||||||
|
style: {
|
||||||
|
...(as.props.style || {}),
|
||||||
|
animationDuration: duration + "ms",
|
||||||
|
animationDelay: delay + "ms"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
const Element = as || "div"
|
const Element = as || "div"
|
||||||
return (
|
return (
|
||||||
<Element
|
<Element
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
.active {
|
|
||||||
color: var(--fg) !important;
|
|
||||||
}
|
|
|
@ -13,12 +13,13 @@ import {
|
||||||
UserX
|
UserX
|
||||||
} from "react-feather"
|
} from "react-feather"
|
||||||
import { signOut } from "next-auth/react"
|
import { signOut } from "next-auth/react"
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import Link from "@components/link"
|
import Link from "@components/link"
|
||||||
import { useSessionSWR } from "@lib/use-session-swr"
|
import { useSessionSWR } from "@lib/use-session-swr"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import styles from "./buttons.module.css"
|
import styles from "./buttons.module.css"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
// constant width for sign in / sign out buttons to avoid CLS
|
// constant width for sign in / sign out buttons to avoid CLS
|
||||||
const SIGN_IN_WIDTH = 110
|
const SIGN_IN_WIDTH = 110
|
||||||
|
@ -39,29 +40,37 @@ type Tab = {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function NavButton(tab: Tab) {
|
function NavButton({ className, ...tab }: Tab & { className?: string }) {
|
||||||
const segment = useSelectedLayoutSegments().slice(-1)[0]
|
const segment = useSelectedLayoutSegments().slice(-1)[0]
|
||||||
const isActive = segment === tab.value.toLowerCase()
|
const isActive = segment === tab.value.toLowerCase()
|
||||||
const activeStyle = isActive ? styles.active : undefined
|
const activeStyle = isActive ? "text-primary-500" : "text-gray-600"
|
||||||
if (tab.onClick) {
|
if (tab.onClick) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
iconLeft={tab.icon}
|
|
||||||
onClick={tab.onClick}
|
onClick={tab.onClick}
|
||||||
className={activeStyle}
|
className={cn(activeStyle, "w-full md:w-auto", className)}
|
||||||
aria-label={tab.name}
|
aria-label={tab.name}
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
data-tab={tab.value}
|
data-tab={tab.value}
|
||||||
width={tab.width}
|
variant={"ghost"}
|
||||||
>
|
>
|
||||||
{tab.name ? tab.name : undefined}
|
{tab.name ? tab.name : undefined}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<Link key={tab.value} href={tab.href} data-tab={tab.value}>
|
<Link
|
||||||
<Button className={activeStyle} iconLeft={tab.icon} width={tab.width}>
|
key={tab.value}
|
||||||
|
href={tab.href}
|
||||||
|
data-tab={tab.value}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className={cn(activeStyle, "w-full md:w-auto", className)}
|
||||||
|
aria-label={tab.name}
|
||||||
|
variant={"ghost"}
|
||||||
|
>
|
||||||
{tab.name ? tab.name : undefined}
|
{tab.name ? tab.name : undefined}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -146,15 +155,14 @@ export function HeaderButtons(): JSX.Element {
|
||||||
/>
|
/>
|
||||||
<ThemeButton key="theme-button" />
|
<ThemeButton key="theme-button" />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<FadeIn>
|
<NavButton
|
||||||
<NavButton
|
name="Admin"
|
||||||
name="Admin"
|
key="admin"
|
||||||
key="admin"
|
icon={<Settings />}
|
||||||
icon={<Settings />}
|
value="admin"
|
||||||
value="admin"
|
href="/admin"
|
||||||
href="/admin"
|
className="transition-opacity duration-500"
|
||||||
/>
|
/>
|
||||||
</FadeIn>
|
|
||||||
)}
|
)}
|
||||||
{isAuthenticated === true && (
|
{isAuthenticated === true && (
|
||||||
<NavButton
|
<NavButton
|
||||||
|
|
|
@ -1,16 +1,120 @@
|
||||||
import styles from "./header.module.css"
|
"use client"
|
||||||
import { HeaderButtons } from "./buttons"
|
|
||||||
|
import Link from "next/link"
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
|
import { useSessionSWR } from "@lib/use-session-swr"
|
||||||
|
import { PropsWithChildren, useEffect, useState } from "react"
|
||||||
|
import { useSelectedLayoutSegments } from "next/navigation"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Moon, Sun } from "react-feather"
|
||||||
|
import FadeIn from "@components/fade-in"
|
||||||
import MobileHeader from "./mobile"
|
import MobileHeader from "./mobile"
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
|
const { isAdmin, isAuthenticated } = useSessionSWR()
|
||||||
|
const { resolvedTheme, setTheme } = useTheme()
|
||||||
|
const [isMounted, setIsMounted] = useState(false)
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className="mt-4 flex h-16 items-center justify-start md:justify-between">
|
||||||
<div className={styles.tabs}>
|
<span className="hidden items-center md:flex">
|
||||||
<div className={styles.buttons}>
|
<Link href="/" className="mr-4 flex items-center">
|
||||||
<HeaderButtons />
|
<Image
|
||||||
</div>
|
src={"/assets/logo.svg"}
|
||||||
</div>
|
width={32}
|
||||||
<MobileHeader />
|
height={32}
|
||||||
|
alt=""
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<span className="bg-transparent pl-4 text-lg font-bold">Drift</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex space-x-4 lg:space-x-6">
|
||||||
|
<ul className="flex justify-center space-x-4">
|
||||||
|
<NavLink href="/home">Home</NavLink>
|
||||||
|
<NavLink href="/new" disabled={!isAuthenticated}>
|
||||||
|
New
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/mine" disabled={!isAuthenticated}>
|
||||||
|
Yours
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/settings" disabled={!isAuthenticated}>
|
||||||
|
Settings
|
||||||
|
</NavLink>
|
||||||
|
{isAdmin && <NavLink href="/admin">Admin</NavLink>}
|
||||||
|
{isAuthenticated !== undefined && (
|
||||||
|
<>
|
||||||
|
{isAuthenticated === true && (
|
||||||
|
<NavLink href="/signout">Sign Out</NavLink>
|
||||||
|
)}
|
||||||
|
{isAuthenticated === false && (
|
||||||
|
<NavLink href="/signin">Sign In</NavLink>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center justify-center md:hidden">
|
||||||
|
<MobileHeader />
|
||||||
|
</span>
|
||||||
|
{isMounted && (
|
||||||
|
<FadeIn>
|
||||||
|
<button
|
||||||
|
aria-hidden
|
||||||
|
className="ml-4 flex h-8 w-8 cursor-pointer items-center justify-center font-medium text-muted-foreground transition-colors hover:text-primary md:ml-0"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title="Toggle theme"
|
||||||
|
>
|
||||||
|
{resolvedTheme === "dark" ? (
|
||||||
|
<Sun className="h-[16px] w-[16px]" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-[16px] w-[16px]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</FadeIn>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NavLinkProps = PropsWithChildren<{
|
||||||
|
href: string
|
||||||
|
disabled?: boolean
|
||||||
|
active?: boolean
|
||||||
|
}>
|
||||||
|
|
||||||
|
function NavLink({ href, disabled, children }: NavLinkProps) {
|
||||||
|
const baseClasses =
|
||||||
|
"text-sm text-muted-foreground font-medium transition-colors hover:text-primary"
|
||||||
|
const activeClasses = "text-primary border-primary"
|
||||||
|
const disabledClasses = "text-gray-600 hover:text-gray-400 cursor-not-allowed"
|
||||||
|
|
||||||
|
const segments = useSelectedLayoutSegments()
|
||||||
|
const activeSegment = segments[segments.length - 1]
|
||||||
|
const isActive =
|
||||||
|
activeSegment === href.slice(1) ||
|
||||||
|
// special case / because it's an alias of /home/page.tsx
|
||||||
|
(!activeSegment && href === "/home")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
baseClasses,
|
||||||
|
isActive && activeClasses,
|
||||||
|
disabled && disabledClasses
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
.mobileTrigger {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
|
||||||
.header {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper [data-tab="github"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileTrigger {
|
|
||||||
margin-top: var(--gap);
|
|
||||||
margin-bottom: var(--gap);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileTrigger button {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownItem a,
|
|
||||||
.dropdownItem button {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownItem:not(:first-child):not(:last-child) :global(button) {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownItem:first-child :global(button) {
|
|
||||||
border-bottom-left-radius: 0;
|
|
||||||
border-bottom-right-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdownItem:last-child :global(button) {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +1,10 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"
|
import { Button, buttonVariants } from "@components/button"
|
||||||
import buttonStyles from "@components/button/button.module.css"
|
|
||||||
import Button from "@components/button"
|
|
||||||
import { Menu } from "react-feather"
|
import { Menu } from "react-feather"
|
||||||
import clsx from "clsx"
|
|
||||||
import styles from "./mobile.module.css"
|
|
||||||
import { HeaderButtons } from "./buttons"
|
import { HeaderButtons } from "./buttons"
|
||||||
|
import * as DropdownMenu from "@components/dropdown-menu"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
export default function MobileHeader() {
|
export default function MobileHeader() {
|
||||||
// TODO: this is a hack to close the radix ui menu when a next link is clicked
|
// TODO: this is a hack to close the radix ui menu when a next link is clicked
|
||||||
|
@ -15,28 +13,27 @@ export default function MobileHeader() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.DropdownMenu>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.DropdownMenuTrigger
|
||||||
className={clsx(buttonStyles.button, styles.mobileTrigger)}
|
className={buttonVariants({ variant: "ghost" })}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Button aria-label="Menu" height="auto">
|
<Button aria-label="Menu" variant={"ghost"}>
|
||||||
<Menu />
|
<Menu />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.DropdownMenuTrigger>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.DropdownMenuPortal>
|
||||||
<DropdownMenu.Content>
|
<DropdownMenu.DropdownMenuContent>
|
||||||
{HeaderButtons().props.children.map((button: JSX.Element) => (
|
{HeaderButtons().props.children.map((button: JSX.Element) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.DropdownMenuItem
|
||||||
key={`mobile-${button?.key}`}
|
key={`mobile-${button?.key}`}
|
||||||
className={styles.dropdownItem}
|
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{button}
|
{button}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.DropdownMenuContent>
|
||||||
</DropdownMenu.Portal>
|
</DropdownMenu.DropdownMenuPortal>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.DropdownMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,82 +1,51 @@
|
||||||
import clsx from "clsx"
|
import * as React from "react"
|
||||||
import React from "react"
|
|
||||||
import styles from "./input.module.css"
|
|
||||||
|
|
||||||
type Props = React.HTMLProps<HTMLInputElement> & {
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||||
label?: string
|
label?: string
|
||||||
width?: number | string
|
hideLabel?: boolean
|
||||||
height?: number | string
|
|
||||||
labelClassName?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// we have two special rules on top of the props:
|
|
||||||
// if onChange or value is passed, we require both, unless `disabled`
|
|
||||||
// if label is passed, we forbid aria-label and vice versa
|
|
||||||
type InputProps = Omit<Props, "onChange" | "value" | "label" | "aria-label"> &
|
|
||||||
(
|
|
||||||
| {
|
|
||||||
onChange: Props["onChange"]
|
|
||||||
value: Props["value"]
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
onChange?: never
|
|
||||||
value?: never
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
value: Props["value"]
|
|
||||||
disabled: true
|
|
||||||
onChange?: never
|
|
||||||
}
|
|
||||||
) &
|
|
||||||
(
|
|
||||||
| {
|
|
||||||
label: Props["label"]
|
|
||||||
"aria-label"?: never
|
|
||||||
} // if label is passed, we forbid aria-label and vice versa
|
|
||||||
| {
|
|
||||||
label?: never
|
|
||||||
"aria-label": Props["aria-label"]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
(
|
({ className, type, label, hideLabel, ...props }, ref) => {
|
||||||
{ label, className, required, width, height, labelClassName, ...props },
|
const id = React.useId()
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const labelId = label?.replace(/\s/g, "-").toLowerCase()
|
|
||||||
return (
|
return (
|
||||||
<div
|
<span className="flex w-full flex-row items-center">
|
||||||
className={styles.wrapper}
|
{label && !hideLabel ? (
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
height
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label && (
|
|
||||||
<label
|
<label
|
||||||
htmlFor={labelId}
|
htmlFor={id}
|
||||||
className={clsx(styles.label, labelClassName)}
|
className={cn(
|
||||||
|
"h-10 rounded-md border border-input bg-transparent px-3 py-2 text-sm font-medium text-muted-foreground",
|
||||||
|
"rounded-br-none rounded-tr-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
) : null}
|
||||||
|
{label && hideLabel ? (
|
||||||
|
<label htmlFor={id} className="sr-only">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<input
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
label && !hideLabel
|
||||||
|
? "rounded-bl-none rounded-tl-none border-l-0"
|
||||||
|
: "rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={labelId}
|
id={id}
|
||||||
className={clsx(styles.input, label && styles.withLabel, className)}
|
|
||||||
required={required}
|
|
||||||
{...props}
|
{...props}
|
||||||
style={{
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
...(props.style || {})
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Input.displayName = "Input"
|
Input.displayName = "Input"
|
||||||
|
|
||||||
export default Input
|
export { Input }
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import styles from "./page.module.css"
|
import styles from "./page.module.css"
|
||||||
|
import Link from "@components/link"
|
||||||
|
|
||||||
export default function Layout({
|
export default function Layout({
|
||||||
children,
|
children,
|
||||||
|
@ -12,7 +13,22 @@ export default function Layout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.page, forSites && styles.forSites)}>
|
<div className={clsx(styles.page, forSites && styles.forSites)}>
|
||||||
{children}
|
<div className="flex flex-col justify-between h-screen">
|
||||||
|
<div> {children}</div>
|
||||||
|
<footer className="h-16 py-4 text-sm text-center text-gray-500">
|
||||||
|
<p>
|
||||||
|
Drift is an open source project by{" "}
|
||||||
|
<Link colored href="https://twitter.com/Max_Leiter">
|
||||||
|
Max Leiter
|
||||||
|
</Link>
|
||||||
|
. You can view the source code on{" "}
|
||||||
|
<Link colored href="https://github.com/MaxLeiter/Drift">
|
||||||
|
GitHub
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
.page {
|
.page {
|
||||||
max-width: var(--main-content);
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import NextLink from "next/link"
|
import NextLink from "next/link"
|
||||||
import styles from "./link.module.css"
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
type LinkProps = {
|
type LinkProps = {
|
||||||
colored?: boolean
|
colored?: boolean
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
} & React.ComponentProps<typeof NextLink>
|
} & React.ComponentProps<typeof NextLink>
|
||||||
|
|
||||||
const Link = ({ colored, children, ...props }: LinkProps) => {
|
const Link = ({ colored, className, children, ...props }: LinkProps) => {
|
||||||
const className = colored ? `${styles.link} ${styles.color}` : styles.link
|
const classes = colored ? "text-blue-500 dark:text-blue-400 hover:underline" : "hover:underline"
|
||||||
return (
|
return (
|
||||||
<NextLink {...props} className={className}>
|
<NextLink {...props} className={cn(classes, className)}>
|
||||||
{children}
|
{children}
|
||||||
</NextLink>
|
</NextLink>
|
||||||
)
|
)
|
||||||
|
|
128
src/app/components/navigation-menu.tsx
Normal file
128
src/app/components/navigation-menu.tsx
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||||
|
import { cva } from "class-variance-authority"
|
||||||
|
import { ChevronDown } from "react-feather"
|
||||||
|
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
|
const NavigationMenu = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 flex flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<NavigationMenuViewport />
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
))
|
||||||
|
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const NavigationMenuList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||||
|
|
||||||
|
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:bg-accent focus:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none bg-background hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent/50 data-[active]:bg-accent/50 h-10 py-2 px-4 group w-max"
|
||||||
|
)
|
||||||
|
|
||||||
|
const NavigationMenuTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDown
|
||||||
|
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const NavigationMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||||
|
|
||||||
|
const NavigationMenuViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
NavigationMenuViewport.displayName =
|
||||||
|
NavigationMenuPrimitive.Viewport.displayName
|
||||||
|
|
||||||
|
const NavigationMenuIndicator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
))
|
||||||
|
NavigationMenuIndicator.displayName =
|
||||||
|
NavigationMenuPrimitive.Indicator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport
|
||||||
|
}
|
|
@ -10,7 +10,10 @@ const Note = ({
|
||||||
type: "info" | "warning" | "error"
|
type: "info" | "warning" | "error"
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
} & React.ComponentProps<"div">) => (
|
} & React.ComponentProps<"div">) => (
|
||||||
<div className={clsx(className, styles.note, styles[type])} {...props}>
|
<div
|
||||||
|
className={clsx(className, styles.note, styles[type], "text-sm")}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
14
src/app/components/page-title.tsx
Normal file
14
src/app/components/page-title.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
export function PageTitle({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<React.HTMLProps<HTMLHeadingElement>>) {
|
||||||
|
return (
|
||||||
|
<h1 className={cn("pb-2 pt-2 text-4xl font-bold", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
)
|
||||||
|
}
|
14
src/app/components/page-wrapper.tsx
Normal file
14
src/app/components/page-wrapper.tsx
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
import { PropsWithChildren } from "react"
|
||||||
|
|
||||||
|
export function PageWrapper({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<React.HTMLProps<HTMLDivElement>>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("mb-4 mt-4 flex flex-col gap-4", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,9 +1,17 @@
|
||||||
import Button from "@components/button"
|
import { Input } from "@components/input"
|
||||||
import Input from "@components/input"
|
|
||||||
import Note from "@components/note"
|
import Note from "@components/note"
|
||||||
import * as Dialog from "@radix-ui/react-dialog"
|
import { MouseEventHandler, useState } from "react"
|
||||||
import { useState } from "react"
|
|
||||||
import styles from "./modal.module.css"
|
import styles from "./modal.module.css"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogFooter
|
||||||
|
} from "@components/alert-dialog"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
creating: boolean
|
creating: boolean
|
||||||
|
@ -22,7 +30,8 @@ const PasswordModal = ({
|
||||||
const [confirmPassword, setConfirmPassword] = useState<string>("")
|
const [confirmPassword, setConfirmPassword] = useState<string>("")
|
||||||
const [error, setError] = useState<string>()
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
const onSubmit = () => {
|
const onSubmit: MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
if (!password || (creating && !confirmPassword)) {
|
if (!password || (creating && !confirmPassword)) {
|
||||||
setError("Please enter a password")
|
setError("Please enter a password")
|
||||||
return
|
return
|
||||||
|
@ -39,60 +48,57 @@ const PasswordModal = ({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{
|
{
|
||||||
<Dialog.Root
|
<AlertDialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) onClose()
|
if (!open) onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Dialog.Portal>
|
{/* <AlertDialogOverlay className={styles.overlay} /> */}
|
||||||
<Dialog.Overlay className={styles.overlay} />
|
<AlertDialogContent onEscapeKeyDown={onClose}>
|
||||||
<Dialog.Content
|
<AlertDialogHeader>
|
||||||
className={styles.content}
|
<AlertDialogTitle>
|
||||||
onEscapeKeyDown={onClose}
|
|
||||||
>
|
|
||||||
<Dialog.Title>
|
|
||||||
{creating ? "Add a password" : "Enter password"}
|
{creating ? "Add a password" : "Enter password"}
|
||||||
</Dialog.Title>
|
</AlertDialogTitle>
|
||||||
<Dialog.Description>
|
<AlertDialogDescription>
|
||||||
{creating
|
{creating
|
||||||
? "Enter a password to protect your post"
|
? "Enter a password to protect your post"
|
||||||
: "Enter the password to access the post"}
|
: "Enter the password to access the post"}
|
||||||
</Dialog.Description>
|
</AlertDialogDescription>
|
||||||
<fieldset className={styles.fieldset}>
|
</AlertDialogHeader>
|
||||||
{!error && creating && (
|
<fieldset className={styles.fieldset}>
|
||||||
<Note type="warning">
|
{!error && creating && (
|
||||||
This doesn't protect your post from the server
|
<Note type="warning">
|
||||||
administrator.
|
This doesn't protect your post from the server
|
||||||
</Note>
|
administrator.
|
||||||
)}
|
</Note>
|
||||||
{error && <Note type="error">{error}</Note>}
|
)}
|
||||||
|
{error && <Note type="error">{error}</Note>}
|
||||||
|
<Input
|
||||||
|
width={"100%"}
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{creating && (
|
||||||
<Input
|
<Input
|
||||||
width={"100%"}
|
width={"100%"}
|
||||||
label="Password"
|
label="Confirm"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
placeholder="Confirm Password"
|
||||||
value={password}
|
value={confirmPassword}
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
{creating && (
|
)}
|
||||||
<Input
|
</fieldset>
|
||||||
width={"100%"}
|
<AlertDialogFooter>
|
||||||
label="Confirm"
|
<AlertDialogCancel onClick={onClose}>Cancel</AlertDialogCancel>
|
||||||
type="password"
|
<AlertDialogAction onClick={onSubmit}>Submit</AlertDialogAction>
|
||||||
placeholder="Confirm Password"
|
</AlertDialogFooter>
|
||||||
value={confirmPassword}
|
</AlertDialogContent>
|
||||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
</AlertDialog>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
<footer className={styles.footer}>
|
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
|
||||||
<Button onClick={onSubmit}>Submit</Button>
|
|
||||||
</footer>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,35 +1,31 @@
|
||||||
// largely from https://github.com/shadcn/taxonomy
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
import clsx from "clsx"
|
|
||||||
import styles from "./popover.module.css"
|
|
||||||
|
|
||||||
type PopoverProps = PopoverPrimitive.PopoverProps
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
export function Popover({ ...props }: PopoverProps) {
|
const Popover = PopoverPrimitive.Root
|
||||||
return <PopoverPrimitive.Root {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
Popover.Trigger = React.forwardRef<
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
HTMLButtonElement,
|
|
||||||
PopoverPrimitive.PopoverTriggerProps
|
|
||||||
>(function PopoverTrigger({ ...props }, ref) {
|
|
||||||
return <PopoverPrimitive.Trigger {...props} ref={ref} />
|
|
||||||
})
|
|
||||||
|
|
||||||
Popover.Portal = PopoverPrimitive.Portal
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
Popover.Content = React.forwardRef<
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
HTMLDivElement,
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
PopoverPrimitive.PopoverContentProps
|
<PopoverPrimitive.Portal>
|
||||||
>(function PopoverContent({ className, ...props }, ref) {
|
|
||||||
return (
|
|
||||||
<PopoverPrimitive.Content
|
<PopoverPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
align="end"
|
align={align}
|
||||||
className={clsx(styles.root, className)}
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
</PopoverPrimitive.Portal>
|
||||||
})
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
|
|
|
@ -4,7 +4,7 @@ import styles from "./post-list.module.css"
|
||||||
import ListItem from "./list-item"
|
import ListItem from "./list-item"
|
||||||
import { ChangeEvent, useCallback, useState } from "react"
|
import { ChangeEvent, useCallback, useState } from "react"
|
||||||
import type { PostWithFiles } from "@lib/server/prisma"
|
import type { PostWithFiles } from "@lib/server/prisma"
|
||||||
import Input from "@components/input"
|
import { Input } from "@components/input"
|
||||||
import { useToasts } from "@components/toasts"
|
import { useToasts } from "@components/toasts"
|
||||||
import { ListItemSkeleton } from "./list-item-skeleton"
|
import { ListItemSkeleton } from "./list-item-skeleton"
|
||||||
import Link from "@components/link"
|
import Link from "@components/link"
|
||||||
|
@ -83,7 +83,10 @@ const PostList = ({
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res?.ok) {
|
if (!res?.ok) {
|
||||||
console.error(res)
|
setToast({
|
||||||
|
message: "Failed to delete post",
|
||||||
|
type: "error"
|
||||||
|
})
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
setPosts((posts) => posts?.filter((post) => post.id !== postId))
|
setPosts((posts) => posts?.filter((post) => post.id !== postId))
|
||||||
|
@ -103,7 +106,7 @@ const PostList = ({
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
onChange={onSearchChange}
|
onChange={onSearchChange}
|
||||||
disabled={!posts}
|
disabled={!posts || posts.length === 0}
|
||||||
style={{ maxWidth: 300 }}
|
style={{ maxWidth: 300 }}
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
value={searchValue}
|
value={searchValue}
|
||||||
|
|
|
@ -1,24 +1,28 @@
|
||||||
import styles from "./list-item.module.css"
|
import styles from "./list-item.module.css"
|
||||||
import Card from "@components/card"
|
import { Card, CardContent, CardHeader } from "@components/card"
|
||||||
import Skeleton from "@components/skeleton"
|
import Skeleton from "@components/skeleton"
|
||||||
|
|
||||||
export const ListItemSkeleton = () => (
|
export const ListItemSkeleton = () => (
|
||||||
<li>
|
<li>
|
||||||
<Card style={{ overflowY: "scroll" }}>
|
<Card style={{ overflowY: "scroll" }}>
|
||||||
{/* TODO: this is a bad way to do skeletons and is onlya ccurate on desktop */}
|
{/* TODO: this is a bad way to do skeletons and is only accurate on desktop */}
|
||||||
<div style={{ display: "flex", gap: 16, marginBottom: 14 }}>
|
<CardHeader>
|
||||||
<div className={styles.title}>
|
<div style={{ display: "flex", gap: 16, marginBottom: 14 }}>
|
||||||
{/* title */}
|
<div className={styles.title}>
|
||||||
<Skeleton width={80} height={32} />
|
{/* title */}
|
||||||
</div>
|
<Skeleton width={80} height={32} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.badges}>
|
<div className={styles.badges}>
|
||||||
<Skeleton width={60} height={32} />
|
<Skeleton width={60} height={32} />
|
||||||
<Skeleton width={60} height={32} />
|
<Skeleton width={60} height={32} />
|
||||||
<Skeleton width={60} height={32} />
|
<Skeleton width={60} height={32} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
<Skeleton width={100} height={32} />
|
<CardContent>
|
||||||
|
<Skeleton width={100} height={32} />
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.titleText {
|
.titleText {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--gap-half);
|
gap: var(--gap-half);
|
||||||
|
@ -15,11 +10,6 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--gap-half);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oneline {
|
.oneline {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
@ -50,17 +40,10 @@
|
||||||
li {
|
li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--gap);
|
gap: var(--gap-half);
|
||||||
padding: var(--gap-quarter);
|
padding: var(--gap-quarter);
|
||||||
}
|
}
|
||||||
|
|
||||||
li a {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--gap);
|
|
||||||
color: var(--darker-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
li a:hover {
|
li a:hover {
|
||||||
color: var(--link);
|
color: var(--link);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
|
@ -6,20 +6,31 @@ import { useRouter } from "next/navigation"
|
||||||
import styles from "./list-item.module.css"
|
import styles from "./list-item.module.css"
|
||||||
import Link from "@components/link"
|
import Link from "@components/link"
|
||||||
import type { PostWithFiles } from "@lib/server/prisma"
|
import type { PostWithFiles } from "@lib/server/prisma"
|
||||||
import Tooltip from "@components/tooltip"
|
import { Badge } from "@components/badges/badge"
|
||||||
import Badge from "@components/badges/badge"
|
import {
|
||||||
import Card from "@components/card"
|
Card,
|
||||||
import Button from "@components/button"
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@components/card"
|
||||||
import {
|
import {
|
||||||
ArrowUpCircle,
|
ArrowUpCircle,
|
||||||
Code,
|
Code,
|
||||||
Database,
|
Database,
|
||||||
Edit,
|
Edit,
|
||||||
FileText,
|
FileText,
|
||||||
|
MoreVertical,
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash
|
Trash
|
||||||
} from "react-feather"
|
} from "react-feather"
|
||||||
import { codeFileExtensions } from "@lib/constants"
|
import { codeFileExtensions } from "@lib/constants"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@components/dropdown-menu"
|
||||||
|
import { DropdownMenuContent } from "@radix-ui/react-dropdown-menu"
|
||||||
|
|
||||||
// TODO: isOwner should default to false so this can be used generically
|
// TODO: isOwner should default to false so this can be used generically
|
||||||
const ListItem = ({
|
const ListItem = ({
|
||||||
|
@ -67,9 +78,9 @@ const ListItem = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeIn key={post.id} as="li">
|
<FadeIn key={post.id} as="li">
|
||||||
<Card style={{ overflowY: "scroll" }}>
|
<Card className="overflow-y-scroll h-42">
|
||||||
<>
|
<CardHeader>
|
||||||
<div className={styles.title}>
|
<CardTitle className="flex items-center justify-between gap-2">
|
||||||
<span className={styles.titleText}>
|
<span className={styles.titleText}>
|
||||||
<h4 style={{ display: "inline-block", margin: 0 }}>
|
<h4 style={{ display: "inline-block", margin: 0 }}>
|
||||||
<Link
|
<Link
|
||||||
|
@ -82,7 +93,7 @@ const ListItem = ({
|
||||||
</h4>
|
</h4>
|
||||||
<div className={styles.badges}>
|
<div className={styles.badges}>
|
||||||
<VisibilityBadge visibility={post.visibility} />
|
<VisibilityBadge visibility={post.visibility} />
|
||||||
<Badge type="secondary">
|
<Badge variant={"outline"}>
|
||||||
{post.files?.length === 1
|
{post.files?.length === 1
|
||||||
? "1 file"
|
? "1 file"
|
||||||
: `${post.files?.length || 0} files`}
|
: `${post.files?.length || 0} files`}
|
||||||
|
@ -92,62 +103,72 @@ const ListItem = ({
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
{!hideActions ? (
|
{!hideActions ? (
|
||||||
<span className={styles.buttons}>
|
<span className="flex gap-2">
|
||||||
{post.parentId && (
|
<DropdownMenu>
|
||||||
<Tooltip content={"View parent"}>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<MoreVertical className="cursor-pointer" />
|
||||||
iconRight={<ArrowUpCircle />}
|
</DropdownMenuTrigger>
|
||||||
onClick={viewParentClick}
|
<DropdownMenuContent className="mt-2 border rounded-md shadow-sm border-border bg-background">
|
||||||
// TODO: not perfect on mobile
|
<DropdownMenuItem
|
||||||
height={38}
|
onSelect={() => {
|
||||||
/>
|
editACopy()
|
||||||
</Tooltip>
|
}}
|
||||||
)}
|
className="cursor-pointer bg-background"
|
||||||
<Tooltip content={"Make a copy"}>
|
>
|
||||||
<Button
|
<Edit className="w-4 h-4 mr-2" /> Edit a copy
|
||||||
iconRight={<Edit />}
|
</DropdownMenuItem>
|
||||||
onClick={editACopy}
|
{isOwner && (
|
||||||
height={38}
|
<DropdownMenuItem
|
||||||
/>
|
onSelect={() => {
|
||||||
</Tooltip>
|
deletePost()
|
||||||
{isOwner && (
|
}}
|
||||||
<Tooltip content={"Delete"}>
|
className="cursor-pointer bg-background"
|
||||||
<Button
|
>
|
||||||
iconRight={<Trash />}
|
<Trash className="w-4 h-4 mr-2" />
|
||||||
onClick={deletePost}
|
Delete
|
||||||
height={38}
|
</DropdownMenuItem>
|
||||||
/>
|
)}
|
||||||
</Tooltip>
|
{post.parentId && (
|
||||||
)}
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
viewParentClick()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowUpCircle className="w-4 h-4 mr-2" />
|
||||||
|
View parent
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</CardTitle>
|
||||||
|
|
||||||
{post.description && (
|
{post.description && (
|
||||||
<p className={styles.oneline}>{post.description}</p>
|
<CardDescription>
|
||||||
|
<p className={styles.oneline}>{post.description}</p>
|
||||||
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
</>
|
</CardHeader>
|
||||||
<ul className={styles.files}>
|
<CardContent>
|
||||||
{post?.files?.map(
|
<ul className={styles.files}>
|
||||||
(file: Pick<PostWithFiles, "files">["files"][0]) => {
|
{post?.files?.map(
|
||||||
return (
|
(file: Pick<PostWithFiles, "files">["files"][0]) => {
|
||||||
<li key={file.id}>
|
return (
|
||||||
<Link
|
<li key={file.id} className="text-black">
|
||||||
colored
|
<Link
|
||||||
href={`/post/${post.id}#${file.title}`}
|
colored
|
||||||
style={{
|
href={`/post/${post.id}#${file.title}`}
|
||||||
display: "flex",
|
className="flex items-center gap-2 font-mono text-sm text-foreground"
|
||||||
alignItems: "center"
|
>
|
||||||
}}
|
{getIconFromFilename(file.title)}
|
||||||
>
|
{file.title || "Untitled file"}
|
||||||
{getIconFromFilename(file.title)}
|
</Link>
|
||||||
{file.title || "Untitled file"}
|
</li>
|
||||||
</Link>
|
)
|
||||||
</li>
|
}
|
||||||
)
|
)}
|
||||||
}
|
</ul>
|
||||||
)}
|
</CardContent>
|
||||||
</ul>
|
|
||||||
</Card>
|
</Card>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Button from "@components/button"
|
import { Button } from "@components/button"
|
||||||
import Tooltip from "@components/tooltip"
|
import { Tooltip } from "@components/tooltip"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { ChevronUp } from "react-feather"
|
import { ChevronUp } from "react-feather"
|
||||||
import styles from "./scroll.module.css"
|
import styles from "./scroll.module.css"
|
||||||
|
@ -39,8 +39,10 @@ const ScrollToTop = () => {
|
||||||
<Button
|
<Button
|
||||||
aria-label="Scroll to Top"
|
aria-label="Scroll to Top"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
iconLeft={<ChevronUp />}
|
variant={"secondary"}
|
||||||
/>
|
>
|
||||||
|
<ChevronUp />
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import Card from "@components/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@components/card"
|
||||||
import styles from "./settings-group.module.css"
|
import styles from "./settings-group.module.css"
|
||||||
|
|
||||||
type Props =
|
type Props =
|
||||||
|
@ -26,9 +26,13 @@ const SettingsGroup = ({ title, children, skeleton }: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<h4>{title}</h4>
|
<CardHeader>
|
||||||
<hr />
|
<CardTitle>{title}</CardTitle>
|
||||||
<div className={styles.content}>{children}</div>
|
</CardHeader>
|
||||||
|
<hr className="pb-4" />
|
||||||
|
<CardContent>
|
||||||
|
<div className={styles.content}>{children}</div>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import styles from "./skeleton.module.css"
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
export default function Skeleton({
|
export default function Skeleton({
|
||||||
width = 100,
|
width = 100,
|
||||||
|
@ -13,8 +13,13 @@ export default function Skeleton({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.skeleton}
|
className={cn("animate-pulse bg-gray-300 dark:bg-gray-800")}
|
||||||
style={{ width, height, borderRadius, ...style }}
|
style={{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius,
|
||||||
|
...style
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
.skeleton {
|
|
||||||
background-color: var(--lighter-gray);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
}
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
import styles from "./spinner.module.css"
|
import styles from "./spinner.module.css"
|
||||||
|
|
||||||
export const Spinner = () => <div className={styles.spinner} />
|
export const Spinner = ({ className }: { className?: string }) => (
|
||||||
|
<div className={cn(styles.spinner, className)} />
|
||||||
|
)
|
||||||
|
|
55
src/app/components/tabs.tsx
Normal file
55
src/app/components/tabs.tsx
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@lib/cn"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-transparent p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-b-2 data-[state=active]:border-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue