Adds shadcn, bumps dependencies, overhaults lots of code.
This commit is contained in:
Max Leiter 2023-07-20 18:04:47 -07:00 committed by GitHub
parent 702f59caf8
commit 5d5fd3182e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
114 changed files with 5340 additions and 3676 deletions

View file

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

View file

@ -8,6 +8,8 @@ You can try a demo at https://drift.lol. The demo is built on main but has no da
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/).
<hr />
**Contents:**
@ -48,6 +50,7 @@ You can change these to your liking.
- `NODE_ENV`: defaults to development, can be `production`
#### Auth environment variables
**Note:** Only credential auth currently supports the registration password, so if you want to secure registration, you must use only credential auth.
- `GITHUB_CLIENT_ID`: the client ID for GitHub OAuth.

16
components.json Normal file
View file

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@components",
"utils": "@utils"
}
}

View file

@ -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

View file

@ -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": {

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,21 +1,8 @@
.container {
padding: 2rem 2rem;
border-radius: var(--radius);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
}
.form {
display: grid;
place-items: center;
}
.formGroup {
display: flex;
flex-direction: column;
place-items: center;
gap: 10px;
max-width: 300px;
width: 100%;
}
.formContentSpace {

View file

@ -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" />
}
}

View file

@ -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>
)
}

View file

@ -2,14 +2,17 @@ import { getMetadata } from "src/app/lib/metadata"
import Auth from "../components"
import { getAuthProviders, isCredentialEnabled } from "@lib/server/auth-props"
import { PageWrapper } from "@components/page-wrapper"
export default function SignInPage() {
return (
<PageWrapper>
<Auth
page="signin"
credentialAuth={isCredentialEnabled()}
authProviders={getAuthProviders()}
/>
</PageWrapper>
)
}

View file

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

View file

@ -1,13 +1,19 @@
"use client"
import * as RadixTabs from "@radix-ui/react-tabs"
import FormattingIcons from "src/app/(drift)/(posts)/new/components/edit-document-list/edit-document/formatting-icons"
import { ChangeEvent, ClipboardEvent, useRef } from "react"
import {
ChangeEvent,
ClipboardEvent,
ComponentProps,
useRef,
useState
} from "react"
import TextareaMarkdown, { TextareaMarkdownRef } from "textarea-markdown-editor"
import Preview, { StaticPreview } from "../preview"
import styles from "./tabs.module.css"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/tabs"
import { Textarea } from "@components/textarea"
type Props = RadixTabs.TabsProps & {
type Props = ComponentProps<typeof Tabs> & {
isEditing: boolean
defaultTab: "preview" | "edit"
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
@ -28,37 +34,33 @@ export default function DocumentTabs({
...props
}: Props) {
const codeEditorRef = useRef<TextareaMarkdownRef>(null)
const [activeTab, setActiveTab] = useState<"preview" | "edit">(defaultTab)
const handleTabChange = (newTab: string) => {
if (newTab === "preview") {
codeEditorRef.current?.focus()
}
setActiveTab(newTab as "preview" | "edit")
}
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>
)
}

View file

@ -0,0 +1,26 @@
.root {
display: flex;
flex-direction: column;
}
.listWrapper {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
@media (max-width: 600px) {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
}
.list {
flex-shrink: 0;
display: flex;
}
.list .formattingIcons {
margin-left: auto;
}

View file

@ -23,36 +23,6 @@
margin: 0;
}
.content li {
transition: var(--transition);
border-radius: var(--radius);
margin: 0;
padding: 0 0;
}
.contenthover,
.content li:focus {
background-color: var(--lighter-gray);
}
.content .listItem {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--dark-gray);
text-decoration: none;
padding: var(--gap-quarter) 0;
}
.content li .fileIcon {
display: inline-block;
margin-right: var(--gap-half);
}
.content li .fileTitle {
font-size: calc(0.875 * 16px);
}
.content li::before {
content: "";
padding: 0;

View file

@ -1,12 +1,11 @@
import { Popover } from "@components/popover"
import { Popover, PopoverContent, PopoverTrigger } from "@components/popover"
import { codeFileExtensions } from "@lib/constants"
import clsx from "clsx"
import type { PostWithFiles } from "src/lib/server/prisma"
import styles from "./dropdown.module.css"
import buttonStyles from "@components/button/button.module.css"
import { ChevronDown, Code, File as FileIcon } from "react-feather"
import { Spinner } from "@components/spinner"
import Link from "next/link"
import { buttonVariants } from "@components/button"
function FileDropdown({
files,
@ -18,11 +17,15 @@ function FileDropdown({
if (loading) {
return (
<Popover>
<Popover.Trigger className={buttonStyles.button}>
<PopoverTrigger
className={buttonVariants({
variant: "link"
})}
>
<div style={{ minWidth: 125 }}>
<Spinner />
</div>
</Popover.Trigger>
</PopoverTrigger>
</Popover>
)
}
@ -32,25 +35,26 @@ function FileDropdown({
if (codeFileExtensions.includes(extension || "")) {
return {
...file,
icon: <Code />
icon: <Code className="h-4 w-4" />
}
} else {
return {
...file,
icon: <FileIcon />
icon: <FileIcon className="h-4 w-4" />
}
}
})
const content = (
<ul className={styles.content}>
<ul className="text-sm">
{items.map((item) => (
<li key={item.id}>
<Link href={`#${item.title}`} className={styles.listItem}>
<span className={styles.fileIcon}>{item.icon}</span>
<span className={styles.fileTitle}>
<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>
)
}

View file

@ -1,18 +1,61 @@
.markdownPreview {
padding: var(--gap-quarter);
font-size: 18px;
font-size: 16px;
line-height: 1.75;
color: hsl(var(--foreground));
}
.skeletonPreview {
padding: var(--gap-half);
font-size: 18px;
font-size: 16px;
line-height: 1.75;
}
.markdownPreview {
padding: var(--gap-quarter);
color: hsl(var(--foreground));
}
.skeletonPreview {
padding: var(--gap-half);
}
.markdownPreview h1,
.markdownPreview h2,
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
font-weight: 600;
}
.markdownPreview h1 {
font-size: 1.775rem;
}
.markdownPreview h2 {
font-size: 1.5rem;
}
.markdownPreview h3 {
font-size: 1.25rem;
}
.markdownPreview h4 {
font-size: 1.125rem;
}
.markdownPreview h5 {
font-size: 1.1rem;
}
.markdownPreview p {
margin-top: var(--gap);
margin-bottom: var(--gap);
margin-top: 1.25rem;
margin-bottom: 1.25rem;
/*
&:not(:first-child) {
line-height: 1.75rem;
margin-top: 1.5rem;
} */
}
.markdownPreview pre {
@ -26,31 +69,6 @@
word-wrap: break-word;
}
.markdownPreview h1,
.markdownPreview h2,
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
margin-top: var(--gap);
margin-bottom: var(--gap-half);
}
.markdownPreview h1 {
color: var(--fg);
}
.markdownPreview h2 {
color: var(--darkest-gray);
}
.markdownPreview h3,
.markdownPreview h4,
.markdownPreview h5,
.markdownPreview h6 {
color: var(--darker-gray);
}
/* Auto-linked headers */
.markdownPreview h1 a,
.markdownPreview h2 a,
@ -75,44 +93,49 @@
filter: opacity(0.5);
}
.markdownPreview h1 {
font-size: 2rem;
}
.markdownPreview h2 {
font-size: 1.5rem;
}
.markdownPreview h3 {
font-size: 1.25rem;
}
.markdownPreview h4 {
font-size: 1rem;
}
.markdownPreview h5 {
font-size: 1rem;
}
.markdownPreview h6 {
font-size: 0.875rem;
}
.markdownPreview ul {
list-style: inside;
margin-bottom: 1.5rem;
margin-left: 1.5rem;
list-style-type: disc;
}
.markdownPreview ol {
margin-bottom: 1.5rem;
margin-left: 1.5rem;
list-style-type: decimal;
}
.markdownPreview ul li::before {
content: "";
}
.markdownPreview code::before,
.markdownPreview code::after {
content: "";
}
.markdownPreview blockquote {
padding-left: 1.5rem;
font-style: italic;
border-left-width: 2px;
}
.markdownPreview blockquote p {
margin-top: 0;
}
.markdownPreview table {
overflow-y: auto;
width: 100%;
}
.markdownPreview table th {
font-weight: 600;
padding: 0;
margin: 0;
border-top-width: 1px;
}
@media screen and (max-width: 800px) {
.markdownPreview h1 a::after,
.markdownPreview h2 a::after,

View file

@ -1,60 +0,0 @@
.root {
display: flex;
flex-direction: column;
}
.listWrapper {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
@media (max-width: 600px) {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
}
.list {
flex-shrink: 0;
display: flex;
}
.list .formattingIcons {
margin-left: auto;
}
.trigger {
width: 80px;
height: 30px;
margin: 4px 0;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
line-height: 1;
user-select: none;
cursor: pointer;
transition: color 0.1s ease;
margin-bottom: var(--gap-half);
}
.trigger:hover {
background-color: var(--lighter-gray);
color: var(--fg);
}
.trigger:first-child {
border-top-left-radius: 4px;
}
.trigger:last-child {
border-top-right-radius: 4px;
}
.trigger[data-state="active"] {
color: var(--darkest-gray);
box-shadow: inset 0 -1px 0 0 currentColor, 0 1px 0 0 currentColor;
}

View file

@ -1,7 +1,8 @@
import Input from "@components/input"
import { Input } from "@components/input"
import { ChangeEvent } from "react"
import styles from "../post.module.css"
import clsx from "clsx"
type props = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void
@ -10,7 +11,7 @@ type props = {
function Description({ onChange, description }: props) {
return (
<div className={styles.description}>
<div className={clsx(styles.description, "pb-4")}>
<Input
value={description || ""}
onChange={onChange}

View file

@ -8,18 +8,6 @@
margin-top: var(--gap-double);
}
.dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
border-radius: 2px;
border: 2px dashed var(--border);
outline: none;
cursor: pointer;
}
.dropzone:focus {
border-color: var(--gray);
}

View file

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

View file

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

View file

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

View file

@ -1,9 +1,10 @@
import { ChangeEvent, ClipboardEvent, useCallback } from "react"
import styles from "./document.module.css"
import Button from "@components/button"
import Input from "@components/input"
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs"
import { Button } from "@components/button"
import { Input } from "@components/input"
import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
import { Trash } from "react-feather"
import { Card, CardContent, CardHeader } from "@components/card"
type Props = {
title?: string
@ -49,8 +50,8 @@ function Document({
)
return (
<>
<div className={styles.card}>
<Card className="min-h-[512px]">
<CardHeader>
<div className={styles.fileNameContainer}>
<Input
placeholder="MyFile.md"
@ -65,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>
)
}

View file

@ -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&apos;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
// />
// )
// }

View file

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

View file

@ -1,372 +0,0 @@
.react-datepicker__year-read-view--down-arrow,
.react-datepicker__month-read-view--down-arrow,
.react-datepicker__month-year-read-view--down-arrow,
.react-datepicker__navigation-icon::before {
border-color: var(--light-gray);
border-style: solid;
border-width: 3px 3px 0 0;
content: "";
display: block;
height: 9px;
position: absolute;
top: 6px;
width: 9px;
}
.react-datepicker-popper[data-placement^="top"] .react-datepicker__triangle,
.react-datepicker-popper[data-placement^="bottom"] .react-datepicker__triangle {
margin-left: -4px;
position: absolute;
width: 0;
}
.react-datepicker-wrapper {
display: inline-block;
padding: 0;
border: 0;
}
.react-datepicker {
font-family: var(--font-sans);
font-size: 0.8rem;
background-color: var(--bg);
color: var(--fg);
border: 1px solid var(--gray);
border-radius: var(--radius);
display: inline-block;
position: relative;
}
.react-datepicker--time-only .react-datepicker__triangle {
left: 35px;
}
.react-datepicker--time-only .react-datepicker__time-container {
border-left: 0;
}
.react-datepicker--time-only .react-datepicker__time,
.react-datepicker--time-only .react-datepicker__time-box {
border-radius: var(--radius);
border-radius: var(--radius);
}
.react-datepicker__triangle {
position: absolute;
left: 50px;
}
.react-datepicker-popper {
z-index: 1;
}
.react-datepicker-popper[data-placement^="bottom"] {
padding-top: 10px;
}
.react-datepicker-popper[data-placement="bottom-end"]
.react-datepicker__triangle,
.react-datepicker-popper[data-placement="top-end"] .react-datepicker__triangle {
left: auto;
right: 50px;
}
.react-datepicker-popper[data-placement^="top"] {
padding-bottom: 10px;
}
.react-datepicker-popper[data-placement^="right"] {
padding-left: 8px;
}
.react-datepicker-popper[data-placement^="right"] .react-datepicker__triangle {
left: auto;
right: 42px;
}
.react-datepicker-popper[data-placement^="left"] {
padding-right: 8px;
}
.react-datepicker-popper[data-placement^="left"] .react-datepicker__triangle {
left: 42px;
right: auto;
}
.react-datepicker__header {
text-align: center;
background-color: var(--bg);
border-bottom: 1px solid var(--gray);
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
padding: 8px 0;
position: relative;
}
.react-datepicker__header--time {
padding-bottom: 8px;
padding-left: 5px;
padding-right: 5px;
}
.react-datepicker__year-dropdown-container--select,
.react-datepicker__month-dropdown-container--select,
.react-datepicker__month-year-dropdown-container--select,
.react-datepicker__year-dropdown-container--scroll,
.react-datepicker__month-dropdown-container--scroll,
.react-datepicker__month-year-dropdown-container--scroll {
display: inline-block;
margin: 0 2px;
}
.react-datepicker__current-month,
.react-datepicker-time__header,
.react-datepicker-year-header {
margin-top: 0;
font-weight: bold;
font-size: 0.944rem;
}
.react-datepicker-time__header {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.react-datepicker__navigation {
align-items: center;
background: none;
display: flex;
justify-content: center;
text-align: center;
cursor: pointer;
position: absolute;
top: 2px;
padding: 0;
border: none;
z-index: 1;
height: 32px;
width: 32px;
text-indent: -999em;
overflow: hidden;
}
.react-datepicker__navigation--previous {
left: 2px;
}
.react-datepicker__navigation--next {
right: 2px;
}
.react-datepicker__navigation--next--with-time:not(.react-datepicker__navigation--next--with-today-button) {
right: 85px;
}
.react-datepicker__navigation--years {
position: relative;
top: 0;
display: block;
margin-left: auto;
margin-right: auto;
}
.react-datepicker__navigation--years-previous {
top: 4px;
}
.react-datepicker__navigation--years-upcoming {
top: -4px;
}
.react-datepicker__navigation:hover *::before {
border-color: var(--lighter-gray);
}
.react-datepicker__navigation-icon {
position: relative;
top: -1px;
font-size: 20px;
width: 0;
}
.react-datepicker__navigation-icon--next {
left: -2px;
}
.react-datepicker__navigation-icon--next::before {
transform: rotate(45deg);
left: -7px;
}
.react-datepicker__navigation-icon--previous {
right: -2px;
}
.react-datepicker__navigation-icon--previous::before {
transform: rotate(225deg);
right: -7px;
}
.react-datepicker__month-container {
float: left;
}
.react-datepicker__year {
margin: 0.4rem;
text-align: center;
}
.react-datepicker__year-wrapper {
display: flex;
flex-wrap: wrap;
max-width: 180px;
}
.react-datepicker__year .react-datepicker__year-text {
display: inline-block;
width: 4rem;
margin: 2px;
}
.react-datepicker__month {
margin: 0.4rem;
text-align: center;
}
.react-datepicker__month .react-datepicker__month-text,
.react-datepicker__month .react-datepicker__quarter-text {
display: inline-block;
width: 4rem;
margin: 2px;
}
.react-datepicker__input-time-container {
clear: both;
width: 100%;
float: left;
margin: 5px 0 10px 15px;
text-align: left;
}
.react-datepicker__input-time-container .react-datepicker-time__caption {
display: inline-block;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container {
display: inline-block;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input {
display: inline-block;
margin-left: 10px;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input {
width: auto;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input[type="time"]::-webkit-inner-spin-button,
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input[type="time"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__input
input[type="time"] {
-moz-appearance: textfield;
}
.react-datepicker__input-time-container
.react-datepicker-time__input-container
.react-datepicker-time__delimiter {
margin-left: 5px;
display: inline-block;
}
.react-datepicker__day-names,
.react-datepicker__week {
white-space: nowrap;
}
.react-datepicker__day-names {
margin-bottom: -8px;
}
.react-datepicker__day-name,
.react-datepicker__day,
.react-datepicker__time-name {
color: var(--fg);
display: inline-block;
width: 1.7rem;
line-height: 1.7rem;
text-align: center;
margin: 0.166rem;
}
.react-datepicker__day,
.react-datepicker__month-text,
.react-datepicker__quarter-text,
.react-datepicker__year-text {
cursor: pointer;
}
.react-datepicker__day:hover,
.react-datepicker__month-text:hover,
.react-datepicker__quarter-text:hover,
.react-datepicker__year-text:hover {
border-radius: 0.3rem;
background-color: var(--light-gray);
}
.react-datepicker__day--today,
.react-datepicker__month-text--today,
.react-datepicker__quarter-text--today,
.react-datepicker__year-text--today {
font-weight: bold;
}
.react-datepicker__day--highlighted,
.react-datepicker__month-text--highlighted,
.react-datepicker__quarter-text--highlighted,
.react-datepicker__year-text--highlighted {
border-radius: 0.3rem;
background-color: #3dcc4a;
color: var(--fg);
}
.react-datepicker__day--highlighted:hover,
.react-datepicker__month-text--highlighted:hover,
.react-datepicker__quarter-text--highlighted:hover,
.react-datepicker__year-text--highlighted:hover {
background-color: #32be3f;
}
.react-datepicker__day--selected,
.react-datepicker__day--in-selecting-range,
.react-datepicker__day--in-range,
.react-datepicker__month-text--selected,
.react-datepicker__month-text--in-selecting-range,
.react-datepicker__month-text--in-range,
.react-datepicker__quarter-text--selected,
.react-datepicker__quarter-text--in-selecting-range,
.react-datepicker__quarter-text--in-range,
.react-datepicker__year-text--selected,
.react-datepicker__year-text--in-selecting-range,
.react-datepicker__year-text--in-range {
border-radius: 0.3rem;
background-color: var(--light-gray);
color: var(--fg);
}
.react-datepicker__day--selected:hover {
background-color: var(--gray);
}
.react-datepicker__day--keyboard-selected,
.react-datepicker__month-text--keyboard-selected,
.react-datepicker__quarter-text--keyboard-selected,
.react-datepicker__year-text--keyboard-selected {
border-radius: 0.3rem;
background-color: var(--light-gray);
color: var(--fg);
}
.react-datepicker__day--keyboard-selected:hover {
background-color: var(--gray);
}
.react-datepicker__month--selecting-range
.react-datepicker__day--in-range:not(.react-datepicker__day--in-selecting-range, .react-datepicker__month-text--in-selecting-range, .react-datepicker__quarter-text--in-selecting-range, .react-datepicker__year-text--in-selecting-range) {
background-color: var(--bg);
color: var(--fg);
}
.react-datepicker {
transform: scale(1.15) translateY(-12px);
}
.react-datepicker__day--disabled {
color: var(--darker-gray);
}
.react-datepicker__day--disabled:hover {
background-color: transparent;
cursor: not-allowed;
}

View file

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

View file

@ -1,9 +1,17 @@
import { getMetadata } from "src/app/lib/metadata"
import NewPost from "src/app/(drift)/(posts)/new/components/new"
import "./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({

View file

@ -1,9 +1,8 @@
"use client"
import Button from "@components/button"
import { Button } from "@components/button"
import ButtonGroup from "@components/button-group"
import FileDropdown from "src/app/(drift)/(posts)/components/file-dropdown"
import { Edit, ArrowUpCircle, Archive } from "react-feather"
import styles from "./post-buttons.module.css"
import { useRouter } from "next/navigation"
import { PostWithFiles } from "@lib/server/prisma"
@ -48,15 +47,19 @@ export const PostButtons = ({
return (
<span className={styles.buttons}>
<ButtonGroup verticalIfMobile>
<Button iconLeft={<Edit />} onClick={editACopy}>
<Button variant={"secondary"} onClick={editACopy} className="border-r">
Edit a Copy
</Button>
{parentId && (
<Button iconLeft={<ArrowUpCircle />} onClick={viewParentClick}>
<Button variant={"secondary"} onClick={viewParentClick}>
View Parent
</Button>
)}
<Button onClick={downloadClick} iconLeft={<Archive />}>
<Button
variant={"secondary"}
onClick={downloadClick}
className="border-r"
>
Download as ZIP Archive
</Button>
<FileDropdown loading={loading} files={files || []} />

View file

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

View file

@ -1,4 +1,4 @@
.card header {
/* .card header {
display: flex;
align-items: center;
flex-direction: row;
@ -18,7 +18,7 @@
border: 1px solid var(--lighter-gray);
border-top: none;
border-radius: 0px 0px 8px 8px;
}
} */
.textarea {
height: 100%;

View file

@ -1,8 +1,8 @@
import Button from "@components/button"
import { Button } from "@components/button"
import ButtonGroup from "@components/button-group"
import Skeleton from "@components/skeleton"
import Tooltip from "@components/tooltip"
import DocumentTabs from "src/app/(drift)/(posts)/components/tabs"
import { Tooltip } from "@components/tooltip"
import DocumentTabs from "src/app/(drift)/(posts)/components/document-tabs"
import Link from "next/link"
import { memo } from "react"
import { Download, ExternalLink, Globe } from "react-feather"
@ -10,7 +10,7 @@ import styles from "./document.module.css"
import { getURLFriendlyTitle } from "src/app/lib/get-url-friendly-title"
import { PostWithFiles, ServerPost } from "@lib/server/prisma"
import { isAllowedVisibilityForWebpage } from "@lib/constants"
import { Card, CardContent, CardHeader } from "@components/card"
type SharedProps = {
initialTab: "edit" | "preview"
file?: PostWithFiles["files"][0]
@ -36,38 +36,50 @@ const DownloadButtons = ({
}) => {
return (
<ButtonGroup>
<Tooltip content="Download">
<Tooltip content="Download" delayDuration={200}>
<Link
href={`${rawLink}?download=true`}
target="_blank"
rel="noopener noreferrer"
>
<Button
iconRight={<Download color="var(--fg)" />}
aria-label="Download"
style={{ border: "none", background: "transparent" }}
/>
size="sm"
className="bg-transparent border-none"
variant={"ghost"}
>
<Download className="w-4 h-4 " />
<span className="sr-only">Download</span>
</Button>
</Link>
</Tooltip>
{rawLink ? (
<Tooltip content="Open raw in new tab">
<Tooltip content="Open raw in new tab" delayDuration={200}>
<Link href={rawLink || ""} target="_blank" rel="noopener noreferrer">
<Button
iconLeft={<ExternalLink color="var(--fg)" />}
aria-label="Open raw file in new tab"
style={{ border: "none", background: "transparent" }}
/>
className="bg-transparent border-none"
size="sm"
variant={"ghost"}
>
<ExternalLink className="w-4 h-4" />
<span className="sr-only">Open raw file in new tab</span>
</Button>
</Link>
</Tooltip>
) : null}
{siteLink ? (
<Tooltip content="Open as webpage">
<Tooltip content="Open as webpage" delayDuration={200}>
<Link href={siteLink || ""} target="_blank" rel="noopener noreferrer">
<Button
iconLeft={<Globe color="var(--fg)" />}
aria-label="Open as webpage"
style={{ border: "none", background: "transparent" }}
/>
className="bg-transparent border-none"
size="sm"
variant={"ghost"}
>
<Globe className="w-4 h-4" />
<span className="sr-only">Open as webpage</span>
</Button>
</Link>
</Tooltip>
) : null}
@ -109,18 +121,39 @@ const Document = ({ skeleton, ...props }: Props) => {
}
}
}
/* .card header {
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
height: 40px;
line-height: 40px;
padding: 0 16px;
background: var(--lighter-gray);
border-radius: 8px 8px 0px 0px;
}
.documentContainer {
display: flex;
flex-direction: column;
overflow: auto;
padding: var(--gap);
border: 1px solid var(--lighter-gray);
border-top: none;
border-radius: 0px 0px 8px 8px;
} */
return (
<>
<div className={styles.card}>
<header id={file?.title}>
<Card className="border-gray-200 dark:border-gray-900">
<CardHeader
id={file?.title}
className="flex flex-row items-center justify-between py-1 bg-gray-200 dark:bg-gray-900"
>
<Link
href={`#${file?.title}`}
aria-label="File"
style={{
textDecoration: "none",
color: "var(--fg)"
}}
// show an # when hovered avia :after
className="text-gray-900 hover:after:ml-1 hover:after:content-[#] dark:text-gray-100"
>
{file?.title}
</Link>
@ -134,8 +167,8 @@ const Document = ({ skeleton, ...props }: Props) => {
: undefined
}
/>
</header>
<div className={styles.documentContainer}>
</CardHeader>
<CardContent className="flex flex-col h-full pt-2">
<DocumentTabs
defaultTab={props.initialTab}
staticPreview={file?.html}
@ -143,8 +176,8 @@ const Document = ({ skeleton, ...props }: Props) => {
>
{file?.content || ""}
</DocumentTabs>
</div>
</div>
</CardContent>
</Card>
</>
)
}

View file

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

View file

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

View file

@ -1,13 +1,3 @@
.table {
width: 100%;
display: block;
white-space: nowrap;
thead th {
font-weight: bold;
}
}
.id {
width: 130px;
white-space: nowrap;

View file

@ -1,6 +1,6 @@
"use client"
import Button from "@components/button"
import { Button } from "@components/button"
import { Spinner } from "@components/spinner"
import { useToasts } from "@components/toasts"
import { ServerPostWithFilesAndAuthor, UserWithPosts } from "@lib/server/prisma"
@ -18,7 +18,7 @@ export function UserTable({
id: string
email: string | null
role: string | null
displayName: string | null
username: string | null
}[]
}) {
const { setToast } = useToasts()
@ -26,6 +26,14 @@ export function UserTable({
const deleteUser = async (id: string) => {
try {
const confirmed = confirm("Are you sure you want to delete this user?")
if (!confirmed) {
setToast({
message: "User not deleted",
type: "default"
})
return
}
const res = await fetchWithUser("/api/admin?action=delete-user", {
method: "DELETE",
headers: {
@ -53,8 +61,8 @@ export function UserTable({
}
return (
<table className={styles.table}>
<thead>
<table className="w-full overflow-x-auto">
<thead className="text-left">
<tr>
<th>Name</th>
<th>Email</th>
@ -73,14 +81,20 @@ export function UserTable({
) : null}
{users?.map((user) => (
<tr key={user.id}>
<td>{user.displayName ? user.displayName : "no name"}</td>
<td>{user.username ? user.username : "no name"}</td>
<td>{user.email}</td>
<td>{user.role}</td>
<td className={styles.id} title={user.id}>
{user.id}
</td>
<td>
<Button onClick={() => deleteUser(user.id)}>Delete</Button>
<Button
variant={"destructive"}
onClick={() => deleteUser(user.id)}
size={"sm"}
>
Delete
</Button>
</td>
</tr>
))}
@ -90,7 +104,7 @@ export function UserTable({
}
export function PostTable({
posts
posts: initialPosts
}: {
posts?: {
createdAt: string
@ -100,15 +114,54 @@ export function PostTable({
visibility: string
}[]
}) {
const [posts, setPosts] = useState<typeof initialPosts>(initialPosts)
const { setToast } = useToasts()
const deletePost = async (id: string) => {
try {
const confirmed = confirm("Are you sure you want to delete this post?")
if (!confirmed) {
setToast({
message: "Post not deleted",
type: "default"
})
return
}
const res = await fetchWithUser("/api/admin?action=delete-post", {
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
postId: id
})
})
if (res.status === 200) {
setToast({
message: "Post deleted",
type: "success"
})
setPosts(posts?.filter((post) => post.id !== id))
}
} catch (err) {
console.error(err)
setToast({
message: "Error deleting user",
type: "error"
})
}
}
return (
<table className={styles.table}>
<thead>
<table className="w-full overflow-x-auto">
<thead className="text-left">
<tr>
<th>Title</th>
<th>Author</th>
<th>Created</th>
<th>Visibility</th>
<th className={styles.id}>Post ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@ -130,6 +183,15 @@ export function PostTable({
<td>{new Date(post.createdAt).toLocaleDateString()}</td>
<td>{post.visibility}</td>
<td>{post.id}</td>
<td>
<Button
variant={"destructive"}
size={"sm"}
onClick={() => deletePost(post.id)}
>
Delete
</Button>
</td>
</tr>
))}
</tbody>

View file

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

View file

@ -6,15 +6,20 @@ import {
ServerPostWithFiles
} from "@lib/server/prisma"
import { PostTable, UserTable } from "./components/tables"
import { PageWrapper } from "@components/page-wrapper"
import { PageTitle } from "@components/page-title"
export default async function AdminPage() {
const usersPromise = getAllUsers({
select: {
id: true,
name: true,
createdAt: true
createdAt: true,
email: true,
role: true,
username: true
}
})
const postsPromise = getAllPosts({
select: {
id: true,
@ -43,13 +48,15 @@ export default async function AdminPage() {
})
return (
<div>
<h1>Admin</h1>
<h2>Users</h2>
<>
<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>
</>
)
}

View file

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

View file

@ -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>

View file

@ -1,7 +1,15 @@
"use client"
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
import PostList from "@components/post-list"
export default function Loading() {
return <PostList skeleton={true} initialPosts={[]} />
return (
<>
<PageTitle>Your Posts</PageTitle>
<PageWrapper></PageWrapper>
<PostList skeleton={true} initialPosts={[]} />
</>
)
}

View file

@ -5,6 +5,8 @@ import { Suspense } from "react"
import ErrorBoundary from "@components/error/fallback"
import { getMetadata } from "src/app/lib/metadata"
import { redirect } from "next/navigation"
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
export default async function Mine() {
const userId = (await getCurrentUser())?.id
@ -16,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>
</>
)
}

View file

@ -1,7 +1,5 @@
import Image from "next/image"
import Card from "@components/card"
import { Card, CardContent } from "@components/card"
import { getWelcomeContent } from "src/pages/api/welcome"
import DocumentTabs from "./(posts)/components/tabs"
import {
getAllPosts,
serverPostToClientPost,
@ -10,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,

View file

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

View file

@ -1,7 +1,7 @@
"use client"
import Button from "@components/button"
import Input from "@components/input"
import { Button } from "@components/button"
import { Input } from "@components/input"
import Note from "@components/note"
import { Spinner } from "@components/spinner"
import { useToasts } from "@components/toasts"
@ -13,6 +13,7 @@ import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
import { useState } from "react"
import styles from "./api-keys.module.css"
import { useSessionSWR } from "@lib/use-session-swr"
import { TypographyH4 } from "@components/typography"
// need to pass in the accessToken
const APIKeys = ({
@ -75,7 +76,7 @@ const APIKeys = ({
)}
{hasError && <Note type="error">{error?.message}</Note>}
<form className={styles.form}>
<h5>Create new</h5>
<TypographyH4>Create new</TypographyH4>
<fieldset className={styles.fieldset}>
<Input
type="text"
@ -85,10 +86,9 @@ const APIKeys = ({
placeholder="Name"
/>
<Button
type="button"
onClick={onCreateTokenClick}
loading={submitting}
disabled={!newToken}
loading={submitting}
>
Submit
</Button>
@ -121,7 +121,9 @@ const APIKeys = ({
</tbody>
</table>
) : (
<p>You have no API keys.</p>
<p className="p-4 text-center text-muted-foreground">
No API keys found.
</p>
)
) : (
<div style={{ marginTop: "var(--gap-quarter)" }}>

View file

@ -1,7 +1,7 @@
"use client"
import Button from "@components/button"
import Input from "@components/input"
import { Button } from "@components/button"
import { Input } from "@components/input"
import Note from "@components/note"
import { useToasts } from "@components/toasts"
import { useSessionSWR } from "@lib/use-session-swr"
@ -142,8 +142,7 @@ function Profile() {
</div>
</TooltipComponent>
</div> */}
<Button type="submit" loading={submitting}>
<Button type="submit" disabled={!name} loading={submitting}>
Submit
</Button>
</form>

View file

@ -1,22 +0,0 @@
export default function SettingsLayout({
children
}: {
children: React.ReactNode
}) {
return (
<>
<h1>Settings</h1>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "var(--gap)",
marginBottom: "var(--gap)",
marginTop: "var(--gap)"
}}
>
{children}
</div>
</>
)
}

View file

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

View file

@ -2,16 +2,21 @@ import { getMetadata } from "src/app/lib/metadata"
import SettingsGroup from "../../components/settings-group"
import APIKeys from "./components/sections/api-keys"
import Profile from "./components/sections/profile"
import { PageTitle } from "@components/page-title"
import { PageWrapper } from "@components/page-wrapper"
export default async function SettingsPage() {
return (
<>
<PageTitle>Settings</PageTitle>
<PageWrapper>
<SettingsGroup title="Profile">
<Profile />
</SettingsGroup>
<SettingsGroup title="API Keys">
<APIKeys />
</SettingsGroup>
</PageWrapper>
</>
)
}

View file

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

View file

@ -0,0 +1,41 @@
import config from "@lib/config"
import { NextRequest } from "next/server"
export const getRequiresPasscode = async () => {
const requiresPasscode = Boolean(config.registration_password)
return requiresPasscode
}
export default async function GET(req: NextRequest) {
const searchParams = new URL(req.nextUrl).searchParams
const slug = searchParams.get("slug")
if (!slug || Array.isArray(slug)) {
return new Response(null, {
status: 400,
statusText: "Bad request"
})
}
if (slug === "requires-passcode") {
// return res.json({ requiresPasscode: await getRequiresPasscode() })
return new Response(
JSON.stringify({ requiresPasscode: await getRequiresPasscode() }),
{
status: 200,
statusText: "OK",
headers: {
"Content-Type": "application/json"
}
}
)
}
return new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
statusText: "Not found",
headers: {
"Content-Type": "application/json"
}
})
}

View file

@ -0,0 +1,150 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@lib/cn"
import { buttonVariants } from "@components/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = ({
className,
children,
...props
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
{children}
</div>
</AlertDialogPrimitive.Portal>
)
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-opacity animate-in fade-in",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full max-w-lg scale-100 gap-4 border bg-background p-6 opacity-100 shadow-lg animate-in fade-in-90 slide-in-from-bottom-10 sm:rounded-lg sm:zoom-in-90 sm:slide-in-from-bottom-0 md:w-full",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel
}

View file

@ -1,21 +1,35 @@
import React from "react"
import styles from "./badge.module.css"
type BadgeProps = {
type: "primary" | "secondary" | "error" | "warning"
} & React.HTMLAttributes<HTMLDivElement>
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/cn"
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ type, children, ...rest }: BadgeProps, ref) => {
return (
<div className={styles.container} {...rest}>
<div className={`${styles.badge} ${styles[type]}`} ref={ref}>
{children}
</div>
</div>
)
const badgeVariants = cva(
"inline-flex items-center border rounded-full font-medium px-2.5 py-0.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"bg-primary hover:bg-primary/80 border-transparent text-primary-foreground",
secondary:
"bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground",
destructive:
"bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground",
outline: "text-foreground"
}
},
defaultVariants: {
variant: "default"
}
}
)
Badge.displayName = "Badge"
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export default Badge
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View file

@ -1,10 +1,10 @@
"use client"
import { useToasts } from "@components/toasts"
import Tooltip from "@components/tooltip"
import { Tooltip } from "@components/tooltip"
import { copyToClipboard } from "src/app/lib/copy-to-clipboard"
import { timeAgo } from "src/app/lib/time-ago"
import { useMemo, useState, useEffect } from "react"
import Badge from "../badge"
import { Badge } from "../badge"
const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
@ -30,7 +30,7 @@ const CreatedAgoBadge = ({ createdAt }: { createdAt: string | Date }) => {
return (
// TODO: investigate tooltip not showing
<Tooltip content={formattedTime}>
<Badge type="secondary" onClick={onClick}>
<Badge onClick={onClick} variant={"outline"} suppressHydrationWarning>
{" "}
<>{time}</>
</Badge>

View file

@ -1,9 +1,9 @@
"use client"
import Tooltip from "@components/tooltip"
import { Tooltip } from "@components/tooltip"
import { timeUntil } from "src/app/lib/time-ago"
import { useEffect, useMemo, useState } from "react"
import Badge from "../badge"
import { Badge } from "../badge"
const ExpirationBadge = ({
postExpirationDate
@ -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()}`}
>

View file

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

View file

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

View file

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

View file

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

View file

@ -1,56 +0,0 @@
.button {
user-select: none;
cursor: pointer;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.button:hover,
.button:focus {
color: var(--fg);
background: var(--bg);
border: 1px solid var(--darker-gray);
}
.button[disabled] {
cursor: not-allowed;
background: var(--lighter-gray);
color: var(--gray);
}
.button[disabled]:hover,
.button[disabled]:focus {
border: 1px solid currentColor;
}
.secondary {
background: var(--bg);
color: var(--fg);
}
.primary {
background: var(--fg);
color: var(--bg);
}
.icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: middle;
}
.iconRight {
margin-left: var(--gap-half);
}
.iconLeft {
margin-right: var(--gap-half);
}
.icon svg {
display: block;
width: 100%;
height: 100%;
transform: scale(1.2) translateY(-0.05em);
}

View file

@ -1,86 +1,65 @@
import styles from "./button.module.css"
import { forwardRef } from "react"
import clsx from "clsx"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/cn"
import { Spinner } from "@components/spinner"
type Props = React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> & {
children?: React.ReactNode
buttonType?: "primary" | "secondary"
className?: string
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
iconRight?: React.ReactNode
iconLeft?: React.ReactNode
height?: string | number
width?: string | number
padding?: string | number
margin?: string | number
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-75 disabled:pointer-events-none ring-offset-background",
{
variants: {
variant: {
default: "bg-primary/80 text-primary-foreground/80 hover:bg-primary/70",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "underline-offset-4 hover:underline text-primary"
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3 rounded-md",
lg: "h-11 px-8 rounded-md"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
loading?: boolean
}
// eslint-disable-next-line react/display-name
const Button = forwardRef<HTMLButtonElement, Props>(
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
children,
onClick,
className,
buttonType = "secondary",
disabled = false,
iconRight,
iconLeft,
height = 40,
width,
padding = 10,
margin,
loading,
style,
...props
},
{ className, variant, size, loading, children, asChild = false, ...props },
ref
) => {
const Comp = asChild ? Slot : "button"
return (
<button
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
className={clsx(styles.button, className, {
[styles.primary]: buttonType === "primary",
[styles.secondary]: buttonType === "secondary"
})}
disabled={disabled || loading}
onClick={onClick}
style={{ height, width, margin, padding, ...style }}
{...props}
>
{children && iconLeft && (
<span className={clsx(styles.icon, styles.iconLeft)}>{iconLeft}</span>
)}
{!loading &&
(children ? (
children
) : (
<span className={styles.icon}>{iconLeft || iconRight}</span>
))}
{loading && (
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
>
<Spinner />
</span>
)}
{children && iconRight && (
<span className={clsx(styles.icon, styles.iconRight)}>
{iconRight}
</span>
)}
</button>
{loading ? <Spinner className="mr-2" /> : null}
{children}
</Comp>
)
}
)
export default Button
Button.displayName = "Button"
export { Button, buttonVariants }

View file

@ -0,0 +1,64 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "react-feather"
import { DayPicker } from "react-day-picker"
import { cn } from "@lib/cn"
import { buttonVariants } from "@components/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "text-muted-foreground opacity-50",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames
}}
components={{
IconLeft: ({}) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({}) => <ChevronRight className="h-4 w-4" />
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View file

@ -1,16 +1,78 @@
import styles from "./card.module.css"
import * as React from "react"
import { cn } from "@lib/cn"
export default function Card({
children,
className,
...props
}: {
children?: React.ReactNode
className?: string
} & React.ComponentProps<"div">) {
return (
<div className={`${styles.card} ${className || ""}`} {...props}>
<div className={styles.content}>{children}</div>
</div>
)
}
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(" flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View file

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

View file

@ -0,0 +1,163 @@
"use client"
import * as React from "react"
import { DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "react-feather"
import { cn } from "@lib/cn"
import { Dialog, DialogContent } from "@components/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
type CommandDialogProps = DialogProps
const CommandDialog = React.forwardRef<
React.ElementRef<typeof Dialog>,
CommandDialogProps
>(({ children, ...props }, ref) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-2xl">
<Command
ref={ref}
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
{children}
</Command>
</DialogContent>
</Dialog>
)
})
CommandDialog.displayName = Dialog.displayName
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator
}

View file

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

View file

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

View file

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

View file

@ -1,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>
</>
)
}

View file

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import { format } from "date-fns"
import { Calendar as CalendarIcon } from "react-feather"
import { cn } from "@lib/cn"
import { Button } from "@components/button"
import { Calendar } from "@components/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@components/popover"
export function DatePicker({
expiresAt,
setExpiresAt
}: {
expiresAt?: Date
setExpiresAt: React.Dispatch<React.SetStateAction<Date | undefined>>
}) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-[280px] justify-start text-left font-normal",
!expiresAt && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{expiresAt ? (
format(expiresAt, "PPP")
) : (
<span>Won&apos;t expire</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={expiresAt}
onSelect={(date) => {
setExpiresAt(date)
}}
initialFocus
fromDate={new Date()}
/>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,128 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "react-feather"
import { cn } from "@lib/cn"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = ({
className,
children,
...props
}: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal className={cn(className)} {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">
{children}
</div>
</DialogPrimitive.Portal>
)
DialogPortal.displayName = DialogPrimitive.Portal.displayName
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm transition-all duration-100 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 grid w-full gap-4 rounded-b-lg border bg-background p-6 shadow-lg animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:max-w-lg sm:rounded-lg sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}

View file

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "react-feather"
import { cn } from "@lib/cn"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup
}

View file

@ -1,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>

View file

@ -1,4 +1,5 @@
// https://www.joshwcomeau.com/snippets/react-components/fade-in/
import React from "react"
import styles from "./fade.module.css"
function FadeIn({
@ -11,8 +12,18 @@ function FadeIn({
duration?: number
delay?: number
children: React.ReactNode
as?: React.ElementType
as?: React.ElementType | JSX.Element
} & React.HTMLAttributes<HTMLElement>) {
if (as !== null && typeof as === "object") {
return React.cloneElement(as, {
className: styles.fadeIn,
style: {
...(as.props.style || {}),
animationDuration: duration + "ms",
animationDelay: delay + "ms"
}
})
}
const Element = as || "div"
return (
<Element

View file

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

View file

@ -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

View file

@ -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>
)
}

View file

@ -1,53 +0,0 @@
.mobileTrigger {
display: none;
}
@media only screen and (max-width: 768px) {
.header {
opacity: 1;
}
.wrapper [data-tab="github"] {
display: none;
}
.mobileTrigger {
margin-top: var(--gap);
margin-bottom: var(--gap);
display: flex;
align-items: center;
}
.mobileTrigger button {
display: none;
}
.dropdownItem a,
.dropdownItem button {
width: 100%;
text-align: left;
white-space: nowrap;
display: block;
}
.dropdownItem:not(:first-child):not(:last-child) :global(button) {
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.dropdownItem:first-child :global(button) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.dropdownItem:last-child :global(button) {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.tabs {
display: none;
}
}

View file

@ -1,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>
)
}

View file

@ -1,82 +1,51 @@
import clsx from "clsx"
import React from "react"
import styles from "./input.module.css"
import * as React from "react"
type Props = React.HTMLProps<HTMLInputElement> & {
import { cn } from "@lib/cn"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
label?: string
width?: number | string
height?: number | string
labelClassName?: string
hideLabel?: boolean
}
// we have two special rules on top of the props:
// if onChange or value is passed, we require both, unless `disabled`
// if label is passed, we forbid aria-label and vice versa
type InputProps = Omit<Props, "onChange" | "value" | "label" | "aria-label"> &
(
| {
onChange: Props["onChange"]
value: Props["value"]
}
| {
onChange?: never
value?: never
}
| {
value: Props["value"]
disabled: true
onChange?: never
}
) &
(
| {
label: Props["label"]
"aria-label"?: never
} // if label is passed, we forbid aria-label and vice versa
| {
label?: never
"aria-label": Props["aria-label"]
}
)
const Input = React.forwardRef<HTMLInputElement, InputProps>(
(
{ label, className, required, width, height, labelClassName, ...props },
ref
) => {
const labelId = label?.replace(/\s/g, "-").toLowerCase()
({ className, type, label, hideLabel, ...props }, ref) => {
const id = React.useId()
return (
<div
className={styles.wrapper}
style={{
width,
height
}}
>
{label && (
<span className="flex w-full flex-row items-center">
{label && !hideLabel ? (
<label
htmlFor={labelId}
className={clsx(styles.label, labelClassName)}
htmlFor={id}
className={cn(
"h-10 rounded-md border border-input bg-transparent px-3 py-2 text-sm font-medium text-muted-foreground",
"rounded-br-none rounded-tr-none",
className
)}
>
{label}
</label>
)}
) : null}
{label && hideLabel ? (
<label htmlFor={id} className="sr-only">
{label}
</label>
) : null}
<input
type={type}
className={cn(
"flex h-10 w-full border border-input bg-transparent px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
label && !hideLabel
? "rounded-bl-none rounded-tl-none border-l-0"
: "rounded-md",
className
)}
ref={ref}
id={labelId}
className={clsx(styles.input, label && styles.withLabel, className)}
required={required}
id={id}
{...props}
style={{
width,
height,
...(props.style || {})
}}
/>
</div>
</span>
)
}
)
Input.displayName = "Input"
export default Input
export { Input }

View file

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

View file

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

View file

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

View file

@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "react-feather"
import { cn } from "@lib/cn"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:bg-accent focus:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none bg-background hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent/50 data-[active]:bg-accent/50 h-10 py-2 px-4 group w-max"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport
}

View file

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

View file

@ -0,0 +1,14 @@
import { cn } from "@lib/cn"
import { PropsWithChildren } from "react"
export function PageTitle({
children,
className,
...props
}: PropsWithChildren<React.HTMLProps<HTMLHeadingElement>>) {
return (
<h1 className={cn("pb-2 pt-2 text-4xl font-bold", className)} {...props}>
{children}
</h1>
)
}

View file

@ -0,0 +1,14 @@
import { cn } from "@lib/cn"
import { PropsWithChildren } from "react"
export function PageWrapper({
children,
className,
...props
}: PropsWithChildren<React.HTMLProps<HTMLDivElement>>) {
return (
<div className={cn("mb-4 mt-4 flex flex-col gap-4", className)} {...props}>
{children}
</div>
)
}

View file

@ -1,9 +1,17 @@
import Button from "@components/button"
import Input from "@components/input"
import { Input } from "@components/input"
import Note from "@components/note"
import * as Dialog from "@radix-ui/react-dialog"
import { useState } from "react"
import { MouseEventHandler, useState } from "react"
import styles from "./modal.module.css"
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogDescription,
AlertDialogTitle,
AlertDialogAction,
AlertDialogCancel,
AlertDialogFooter
} from "@components/alert-dialog"
type Props = {
creating: boolean
@ -22,7 +30,8 @@ const PasswordModal = ({
const [confirmPassword, setConfirmPassword] = useState<string>("")
const [error, setError] = useState<string>()
const onSubmit = () => {
const onSubmit: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault()
if (!password || (creating && !confirmPassword)) {
setError("Please enter a password")
return
@ -39,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>
}
</>
)

View file

@ -1,35 +1,31 @@
// largely from https://github.com/shadcn/taxonomy
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import clsx from "clsx"
import styles from "./popover.module.css"
type PopoverProps = PopoverPrimitive.PopoverProps
import { cn } from "@lib/cn"
export function Popover({ ...props }: PopoverProps) {
return <PopoverPrimitive.Root {...props} />
}
const Popover = PopoverPrimitive.Root
Popover.Trigger = React.forwardRef<
HTMLButtonElement,
PopoverPrimitive.PopoverTriggerProps
>(function PopoverTrigger({ ...props }, ref) {
return <PopoverPrimitive.Trigger {...props} ref={ref} />
})
const PopoverTrigger = PopoverPrimitive.Trigger
Popover.Portal = PopoverPrimitive.Portal
Popover.Content = React.forwardRef<
HTMLDivElement,
PopoverPrimitive.PopoverContentProps
>(function PopoverContent({ className, ...props }, ref) {
return (
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align="end"
className={clsx(styles.root, className)}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
)
})
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View file

@ -4,7 +4,7 @@ import styles from "./post-list.module.css"
import ListItem from "./list-item"
import { ChangeEvent, useCallback, useState } from "react"
import type { PostWithFiles } from "@lib/server/prisma"
import Input from "@components/input"
import { Input } from "@components/input"
import { useToasts } from "@components/toasts"
import { ListItemSkeleton } from "./list-item-skeleton"
import Link from "@components/link"
@ -83,7 +83,10 @@ const PostList = ({
})
if (!res?.ok) {
console.error(res)
setToast({
message: "Failed to delete post",
type: "error"
})
return
} else {
setPosts((posts) => posts?.filter((post) => post.id !== postId))
@ -103,7 +106,7 @@ const PostList = ({
<Input
placeholder="Search..."
onChange={onSearchChange}
disabled={!posts}
disabled={!posts || posts.length === 0}
style={{ maxWidth: 300 }}
aria-label="Search"
value={searchValue}

View file

@ -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 onlya ccurate on desktop */}
{/* 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>
)

View file

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

View file

@ -6,20 +6,31 @@ import { useRouter } from "next/navigation"
import styles from "./list-item.module.css"
import Link from "@components/link"
import type { PostWithFiles } from "@lib/server/prisma"
import Tooltip from "@components/tooltip"
import Badge from "@components/badges/badge"
import Card from "@components/card"
import Button from "@components/button"
import { Badge } from "@components/badges/badge"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@components/card"
import {
ArrowUpCircle,
Code,
Database,
Edit,
FileText,
MoreVertical,
Terminal,
Trash
} from "react-feather"
import { codeFileExtensions } from "@lib/constants"
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuTrigger
} from "@components/dropdown-menu"
import { DropdownMenuContent } from "@radix-ui/react-dropdown-menu"
// TODO: isOwner should default to false so this can be used generically
const ListItem = ({
@ -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>
)

View file

@ -1,7 +1,7 @@
"use client"
import Button from "@components/button"
import Tooltip from "@components/tooltip"
import { Button } from "@components/button"
import { Tooltip } from "@components/tooltip"
import { useEffect, useState } from "react"
import { ChevronUp } from "react-feather"
import styles from "./scroll.module.css"
@ -39,8 +39,10 @@ const ScrollToTop = () => {
<Button
aria-label="Scroll to Top"
onClick={onClick}
iconLeft={<ChevronUp />}
/>
variant={"secondary"}
>
<ChevronUp />
</Button>
</Tooltip>
</div>
)

View file

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

View file

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

View file

@ -1,4 +0,0 @@
.skeleton {
background-color: var(--lighter-gray);
border-radius: var(--radius);
}

View file

@ -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)} />
)

View 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