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