Compare commits

..

No commits in common. "refactor" and "dupePosts" have entirely different histories.

354 changed files with 15023 additions and 20542 deletions

View file

@ -1,35 +0,0 @@
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
# Optional if you use Vercel (defaults to VERCEL_URL).
# Necessary in development unless you use the vercel CLI (`vc dev`)
DRIFT_URL=http://localhost:3000
# Optional: The first user becomes an admin. Defaults to false
ENABLE_ADMIN=false
# Required: Next auth secret is a required valid JWT secret. You can generate one with `openssl rand -hex 32`
NEXTAUTH_SECRET=7f8b8b5c5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5e5f5f5
# Required: but unnecessary if you use a supported host like Vercel
NEXTAUTH_URL=http://localhost:3000
# Optional: for locking your instance
REGISTRATION_PASSWORD=
# Optional: for if you want GitHub oauth. Currently incompatible with the registration password
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# Optional: if you want Keycloak oauth. Currently incompatible with the registration password
KEYCLOAK_ID=
KEYCLOAK_SECRET=
KEYCLOAK_ISSUER= # keycloak path including realm
KEYCLOAK_NAME=
# Optional: if you want to support credential auth (username/password, supports registration password)
# Defaults to true
CREDENTIAL_AUTH=true
# Optional:
WELCOME_CONTENT=
WELCOME_TITLE=

View file

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

View file

@ -1,26 +0,0 @@
name: Server CI
on:
push:
paths:
- 'server/**'
- '.github/workflows/**'
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v3
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install deps
run: yarn
- name: Run tests
run: yarn test

36
.gitignore vendored
View file

@ -1,36 +1,2 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
analyze
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# production env
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
drift.sqlite

View file

@ -1,5 +0,0 @@
{
"typescript.tsdk": "node_modules/.pnpm/typescript@4.9.4/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"dotenv.enableAutocloaking": false
}

145
README.md
View file

@ -1,147 +1,80 @@
# <img src="src/public/assets/logo.png" height="32px" alt="" /> Drift
# <img src="client/public/assets/logo.png" height="32px" alt="" /> Drift
> **Note:** This branch is where all work is being done to refactor to the Next.js 13 app directory and React Server Components.
Drift is a self-hostable Gist clone. It's also a major work-in-progress, but is completely functional.
Drift is a self-hostable Gist clone. It's in beta, but is completely functional.
You can try a demo at https://drift.lol. The demo is built on main but has no database, so files and accounts can be wiped at any time.
You can try a demo at https://drift.maxleiter.com. The demo is built on master but has no database, so files and accounts can be wiped at any time.
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:**
- [Setup](#setup)
- [Development](#development)
- [Production](#production)
- [Environment variables](#environment-variables)
- [Running with pm2](#running-with-pm2)
- [Running with Docker](#running-with-docker)
- [Current status](#current-status)
## Setup
### Development
In the root directory, run `pnpm i`. If you need `pnpm`, you can download it [here](https://pnpm.io/installation).
You can run `pnpm dev` in `client` for file watching and live reloading.
In both `server` and `client`, run `yarn` (if you need yarn, you can download it [here](https://yarnpkg.com/).)
You can run `yarn dev` in either / both folders to start the server and client with file watching / live reloading.
To work with [prisma](prisma.io/), you can use `pnpm prisma` or `pnpm exec prisma` to interact with the database.
To migrate the sqlite database in development, you can use `yarn migrate` to see a list of options.
### Production
`pnpm build` will produce production code. `pnpm start` will start the Next.js server.
`yarn build` in both `client/` and `server/` will produce production code for the client and server respectively. The client and server each also have Dockerfiles which you can use with a docker-compose (an example compose will be provided in the near future).
If you're deploying the front-end to something like Vercel, you'll need to set the root folder to `client/`.
In production the sqlite database will be automatically migrated to the latest version.
### Environment Variables
You can change these to your liking.
`.env`:
`client/.env`:
- `DRIFT_URL`: the URL of the drift instance.
- `DATABASE_URL`: the URL to connect to your postgres instance. For example, `postgresql://user:password@localhost:5432/drift`.
- `API_URL`: defaults to localhost:3001, but allows you to host the front-end separately from the backend on a service like Vercel or Netlify
- `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser
`server/.env`:
- `PORT`: the default port to start the server on (3000 by default)
- `NODE_ENV`: defaults to development, can be `production`
- `JWT_SECRET`: a secure token for JWT tokens. You can generate one [here](https://www.grc.com/passwords.htm).
- `MEMORY_DB`: if `true`, a sqlite database will not be created and changes will only exist in memory. Mainly for the demo.
- `REGISTRATION_PASSWORD`: if MEMORY_DB is not `true`, the user will be required to provide this password to sign-up, in addition to their username and account password. If it's not set, no password will be required.
- `SECRET_KEY`: the same secret key as the client
- `WELCOME_CONTENT`: a markdown string that's rendered on the home page
- `WELCOME_TITLE`: the file title for the post on the homepage.
- `ENABLE_ADMIN`: the first account created is an administrator account
- `REGISTRATION_PASSWORD`: the password required to register an account. If not set, no password is required.
- `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.
- `GITHUB_CLIENT_SECRET`: the client secret for GitHub OAuth.
- `NEXTAUTH_URL`: the URL of the drift instance. Not required if hosting on Vercel.
- `CREDENTIAL_AUTH`: whether to allow username/password authentication. Defaults to `true`.
- `DRIFT_HOME`: defaults to ~/.drift, the directory for storing the database and eventually images
## Running with pm2
It's easy to start Drift using [pm2](https://pm2.keymetrics.io/).
First, add the `.env` file with your values (see the above section for the required options).
First, add `.env` files to `client/` and `server/` with the values you want (see the above section for possible values).
Then, use the following commands to start the client and server:
Then, use the following command to start the server:
- `cd server && yarn build && pm2 start yarn --name drift-server --interpreter bash -- start`
- `cd ..`
- `cd client && yarn build && pm2 start yarn --name drift-client --interpreter bash -- start`
- `pnpm build && pm2 start pnpm --name drift --interpreter bash -- start`
You now use `pm2 ls` to see their statuses. Refer to pm2's docs or `pm2 help` for more information.
Refer to pm2's docs or `pm2 help` for more information.
## Running with Docker
## Running with systemd
_**NOTE:** We assume that you know how to enable user lingering if you don't want to use the systemd unit as root_
- As root
- Place the following systemd unit in ___/etc/systemd/system___ and name it _drift.service_
- Replace any occurrence of ___`$USERNAME`___ with the shell username of the user that will be running the Drift server
```
##########
# Drift Systemd Unit (Global)
##########
[Unit]
Description=Drift Server (Global)
After=default.target
[Service]
User=$USERNAME
Group=$USERNAME
Type=simple
WorkingDirectory=/home/$USERNAME/Drift
ExecStart=/usr/bin/pnpm start
Restart=on-failure
[Install]
WantedBy=default.target
```
- As a nomal user
- Place the following systemd unit inside ___/home/user/.config/systemd/user___ and name it _drift_user.service_
- Replace any occurrence of ___`$USERNAME`___ with the shell username of the user that will be running the Drift server
```
##########
# Drift Systemd Unit (User)
##########
[Unit]
Description=Drift Server (User)
After=default.target
[Service]
Type=simple
WorkingDirectory=/home/$USERNAME/Drift
ExecStart=/usr/bin/pnpm start
Restart=on-failure
[Install]
WantedBy=default.target
```
## Current status
Drift is a work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist.
Drift is a major work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist.
- [x] Next.js 13 `app` directory
- [x] creating and sharing private, public, password-protected, and unlisted posts
- [x] syntax highlighting
- [x] expiring posts
- [x] creating and sharing private, public, unlisted posts
- [x] syntax highlighting (detected by file extension)
- [x] multiple files per post
- [x] uploading files via drag-and-drop
- [x] responsive UI
- [x] user auth
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
- [x] SSO via GitHub OAuth
- [x] downloading files (individually and entire posts)
- [x] password protected posts
- [x] postgres database
- [x] administrator account / settings
- [x] docker-compose (PRs: [#13](https://github.com/MaxLeiter/Drift/pull/13), [#75](https://github.com/MaxLeiter/Drift/pull/75))
- [x] sqlite database
- [ ] administrator account / settings
- [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13))
- [ ] publish docker builds
- [ ] user settings
- [ ] works enough with JavaScript disabled
- [ ] in-depth documentation
- [x] documentation
- [x] customizable homepage, so the demo can exist as-is but other instances can be built from the same source. Environment variable for the file contents?
- [ ] fleshed out API
- [ ] Swappable database backends
- [ ] More OAuth providers

2
client/.env.local Normal file
View file

@ -0,0 +1,2 @@
API_URL=http://localhost:3000
SECRET_KEY=secret

3
client/.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

35
client/.gitignore vendored Normal file
View file

@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# production env
.env
# vercel
.vercel
# typescript
*.tsbuildinfo

View file

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

58
client/Dockerfile Normal file
View file

@ -0,0 +1,58 @@
# Install dependencies only when needed
FROM node:16-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# If using npm with a `package-lock.json` comment out above and use below instead
# COPY package.json package-lock.json ./
# RUN npm ci
# Rebuild the source code only when needed
FROM node:16-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
ARG API_URL http://localhost:3000
ARG SECRET_KEY secret
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM node:16-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# You only need to copy next.config.js if you are NOT using the default configuration
COPY --from=builder /app/next.config.mjs ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3001
ENV PORT 3001
CMD ["node", "server.js"]

34
client/README.md Normal file
View file

@ -0,0 +1,34 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View file

@ -0,0 +1,12 @@
import type { LinkProps } from "@geist-ui/core"
import { Link as GeistLink } from "@geist-ui/core"
import { useRouter } from "next/router";
const Link = (props: LinkProps) => {
const { basePath } = useRouter();
const propHrefWithoutLeadingSlash = props.href && props.href.startsWith("/") ? props.href.substring(1) : props.href;
const href = basePath ? `${basePath}/${propHrefWithoutLeadingSlash}` : props.href;
return <GeistLink {...props} href={href} />
}
export default Link

View file

@ -0,0 +1,25 @@
.adminWrapper table {
width: 100%;
border-spacing: 0;
border: 1px solid var(--gray);
border-radius: var(--radius);
padding: var(--gap-half);
}
.adminWrapper table th {
text-align: left;
background: var(--gray-light);
color: var(--gray-dark);
font-weight: bold;
}
.postModal details {
border-radius: var(--radius);
padding: var(--gap);
border-radius: var(--radius);
}
.postModal summary {
cursor: pointer;
outline: none;
}

View file

@ -0,0 +1,100 @@
import { Text, Fieldset, Spacer, Link } from '@geist-ui/core'
import { Post, User } from '@lib/types'
import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import useSWR from 'swr'
import styles from './admin.module.css'
import PostModal from './post-modal-link'
export const adminFetcher = (url: string) => fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get('drift-token')}`,
}
}).then(res => res.json())
const Admin = () => {
const { data: posts, error: postsError } = useSWR<Post[]>('/server-api/admin/posts', adminFetcher)
const { data: users, error: usersError } = useSWR<User[]>('/server-api/admin/users', adminFetcher)
const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({})
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100
useEffect(() => {
if (posts) {
// sum the sizes of each file per post
const sizes = posts.reduce((acc, post) => {
const size = post.files?.reduce((acc, file) => acc + file.html.length, 0) || 0
return { ...acc, [post.id]: byteToMB(size) }
}, {})
setPostSizes(sizes)
}
}, [posts])
return (
<div className={styles.adminWrapper}>
<Text h2>Administration</Text>
<Fieldset>
<Fieldset.Title>Users</Fieldset.Title>
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{usersError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
{users && <table>
<thead>
<tr>
<th>Username</th>
<th>Posts</th>
<th>Created</th>
<th>Role</th>
</tr>
</thead>
<tbody>
{users?.map(user => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.posts?.length}</td>
<td>{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleTimeString()}</td>
<td>{user.role}</td>
</tr>
))}
</tbody>
</table>}
</Fieldset>
<Spacer height={1} />
<Fieldset>
<Fieldset.Title>Posts</Fieldset.Title>
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
{posts && <table>
<thead>
<tr>
<th>Title</th>
<th>Visibility</th>
<th>Created</th>
<th>Author</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{posts?.map((post) => (
<tr key={post.id}>
<td><PostModal id={post.id} /></td>
<td>{post.visibility}</td>
<td>{new Date(post.createdAt).toLocaleDateString()} {new Date(post.createdAt).toLocaleTimeString()}</td>
<td>{post.users?.length ? post.users[0].username : <i>Deleted</i>}</td>
<td>{postSizes[post.id] ? `${postSizes[post.id]} MB` : ''}</td>
</tr>
))}
</tbody>
</table>}
{Object.keys(postSizes).length && <div style={{ float: 'right' }}>
<Text>Total size: {Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB</Text>
</div>}
</Fieldset>
</div >
)
}
export default Admin

View file

@ -0,0 +1,51 @@
import { Link, Modal, useModal } from "@geist-ui/core";
import { Post } from "@lib/types";
import Cookies from "js-cookie";
import useSWR from "swr";
import { adminFetcher } from ".";
import styles from './admin.module.css'
const PostModal = ({ id }: {
id: string,
}) => {
const { visible, setVisible, bindings } = useModal()
const { data: post, error } = useSWR<Post>(`/server-api/admin/post/${id}`, adminFetcher)
if (error) return <Modal>failed to load</Modal>
if (!post) return <Modal>loading...</Modal>
const deletePost = async () => {
await fetch(`/server-api/admin/post/${id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
}
})
setVisible(false)
}
return (
<>
<Link href="#" color onClick={() => setVisible(true)}>{post.title}</Link>
<Modal width={'var(--main-content)'} {...bindings}>
<Modal.Title>{post.title}</Modal.Title>
<Modal.Subtitle>Click an item to expand</Modal.Subtitle>
{post.files?.map((file) => (
<div key={file.id} className={styles.postModal}>
<Modal.Content>
<details>
<summary>{file.title}</summary>
<div dangerouslySetInnerHTML={{ __html: file.html }}>
</div>
</details>
</Modal.Content>
</div>
)
)}
<Modal.Action type="warning" onClick={deletePost}>Delete</Modal.Action>
<Modal.Action passive onClick={() => setVisible(false)}>Close</Modal.Action>
</Modal>
</>)
}
export default PostModal

View file

@ -0,0 +1,61 @@
import { GeistProvider, CssBaseline, Themes } from "@geist-ui/core"
import type { NextComponentType, NextPageContext } from "next"
import { SkeletonTheme } from "react-loading-skeleton"
import { useTheme } from 'next-themes'
const App = ({
Component,
pageProps,
}: {
Component: NextComponentType<NextPageContext, any, any>
pageProps: any
}) => {
const skeletonBaseColor = 'var(--light-gray)'
const skeletonHighlightColor = 'var(--lighter-gray)'
const customTheme = Themes.createFromLight(
{
type: "custom",
palette: {
background: 'var(--bg)',
foreground: 'var(--fg)',
accents_1: 'var(--lightest-gray)',
accents_2: 'var(--lighter-gray)',
accents_3: 'var(--light-gray)',
accents_4: 'var(--gray)',
accents_5: 'var(--darker-gray)',
accents_6: 'var(--darker-gray)',
accents_7: 'var(--darkest-gray)',
accents_8: 'var(--darkest-gray)',
border: 'var(--light-gray)',
warning: 'var(--warning)'
},
expressiveness: {
dropdownBoxShadow: '0 0 0 1px var(--light-gray)',
shadowSmall: '0 0 0 1px var(--light-gray)',
shadowLarge: '0 0 0 1px var(--light-gray)',
shadowMedium: '0 0 0 1px var(--light-gray)',
},
layout: {
gap: 'var(--gap)',
gapHalf: 'var(--gap-half)',
gapQuarter: 'var(--gap-quarter)',
gapNegative: 'var(--gap-negative)',
gapHalfNegative: 'var(--gap-half-negative)',
gapQuarterNegative: 'var(--gap-quarter-negative)',
radius: 'var(--radius)',
},
font: {
mono: 'var(--font-mono)',
sans: 'var(--font-sans)',
}
}
)
return (<GeistProvider themes={[customTheme]} themeType={"custom"} >
<SkeletonTheme baseColor={skeletonBaseColor} highlightColor={skeletonHighlightColor}>
<CssBaseline />
<Component {...pageProps} />
</SkeletonTheme>
</GeistProvider >)
}
export default App

View file

@ -0,0 +1,22 @@
.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;
}
.formContentSpace {
margin-bottom: 1rem;
text-align: center;
}

View file

@ -0,0 +1,131 @@
import { FormEvent, useEffect, useState } from 'react'
import { Button, Input, Text, Note } from '@geist-ui/core'
import styles from './auth.module.css'
import { useRouter } from 'next/router'
import Link from '../Link'
import Cookies from "js-cookie";
import useSignedIn from '@lib/hooks/use-signed-in'
const NO_EMPTY_SPACE_REGEX = /^\S*$/;
const ERROR_MESSAGE = "Provide a non empty username and a password with at least 6 characters";
const Auth = ({ page }: { page: "signup" | "signin" }) => {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [serverPassword, setServerPassword] = useState('');
const [errorMsg, setErrorMsg] = useState('');
const [requiresServerPassword, setRequiresServerPassword] = useState(false);
const signingIn = page === 'signin'
const { signin } = useSignedIn();
useEffect(() => {
async function fetchRequiresPass() {
if (!signingIn) {
const resp = await fetch("/server-api/auth/requires-passcode", {
method: "GET",
})
if (resp.ok) {
const res = await resp.json()
setRequiresServerPassword(res.requiresPasscode)
} else {
setErrorMsg("Something went wrong.")
}
}
}
fetchRequiresPass()
}, [page, signingIn])
const handleJson = (json: any) => {
signin(json.token)
Cookies.set('drift-userid', json.userId);
router.push('/new')
}
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!signingIn && (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6)) return setErrorMsg(ERROR_MESSAGE)
if (!signingIn && requiresServerPassword && !NO_EMPTY_SPACE_REGEX.test(serverPassword)) return setErrorMsg(ERROR_MESSAGE)
else setErrorMsg('');
const reqOpts = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password, serverPassword })
}
try {
const signUrl = signingIn ? '/server-api/auth/signin' : '/server-api/auth/signup';
const resp = await fetch(signUrl, reqOpts);
const json = await resp.json();
if (!resp.ok) throw new Error(json.error.message);
handleJson(json)
} catch (err: any) {
setErrorMsg(err.message ?? "Something went wrong")
}
}
return (
<div className={styles.container}>
<div className={styles.form}>
<div className={styles.formContentSpace}>
<h1>{signingIn ? 'Sign In' : 'Sign Up'}</h1>
</div>
<form onSubmit={handleSubmit}>
<div className={styles.formGroup}>
<Input
htmlType="text"
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="Username"
required
scale={4 / 3}
/>
<Input
htmlType='password'
id="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Password"
required
scale={4 / 3}
/>
{requiresServerPassword && <Input
htmlType='password'
id="server-password"
value={serverPassword}
onChange={(event) => setServerPassword(event.target.value)}
placeholder="Server Password"
required
scale={4 / 3}
/>}
<Button type="success" htmlType="submit">{signingIn ? 'Sign In' : 'Sign Up'}</Button>
</div>
<div className={styles.formContentSpace}>
{signingIn ? (
<Text>
Don&apos;t have an account?{" "}
<Link color href="/signup">Sign up</Link>
</Text>
) : (
<Text>
Already have an account?{" "}
<Link color href="/signin">Sign in</Link>
</Text>
)}
</div>
{errorMsg && <Note scale={0.75} type='error'>{errorMsg}</Note>}
</form>
</div>
</div >
)
}
export default Auth

View file

@ -0,0 +1,22 @@
import { Badge, Tooltip } from "@geist-ui/core";
import { timeAgo } from "@lib/time-ago";
import { useMemo, useState, useEffect } from "react";
const CreatedAgoBadge = ({ createdAt }: {
createdAt: string | Date;
}) => {
const createdDate = useMemo(() => new Date(createdAt), [createdAt])
const [time, setTimeAgo] = useState(timeAgo(createdDate))
useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(timeAgo(createdDate))
}, 1000)
return () => clearInterval(interval)
}, [createdDate])
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
return (<Badge type="secondary"> <Tooltip hideArrow text={formattedTime}>Created {time}</Tooltip></Badge>)
}
export default CreatedAgoBadge

View file

@ -0,0 +1,60 @@
import { Badge, Tooltip } from "@geist-ui/core";
import { timeUntil } from "@lib/time-ago";
import { useCallback, useEffect, useMemo, useState } from "react";
const ExpirationBadge = ({
postExpirationDate,
// onExpires
}: {
postExpirationDate: Date | string | null
onExpires?: () => void
}) => {
const expirationDate = useMemo(() => postExpirationDate ? new Date(postExpirationDate) : null, [postExpirationDate])
const [timeUntilString, setTimeUntil] = useState<string | null>(expirationDate ? timeUntil(expirationDate) : null);
useEffect(() => {
let interval: NodeJS.Timer | null = null;
if (expirationDate) {
interval = setInterval(() => {
if (expirationDate) {
setTimeUntil(timeUntil(expirationDate))
}
}, 1000)
}
return () => {
if (interval) {
clearInterval(interval)
}
}
}, [expirationDate])
const isExpired = useMemo(() => {
return timeUntilString && timeUntilString === "in 0 seconds"
}, [timeUntilString])
// useEffect(() => {
// // check if expired every
// if (isExpired) {
// if (onExpires) {
// onExpires();
// }
// }
// }, [isExpired, onExpires])
if (!expirationDate) {
return null;
}
return (
<Badge type={isExpired ? "error" : "warning"}>
<Tooltip
text={`${expirationDate.toLocaleDateString()} ${expirationDate.toLocaleTimeString()}`}>
{isExpired ? "Expired" : `Expires ${timeUntilString}`}
hideArrow
</Tooltip>
</Badge>
)
}
export default ExpirationBadge

View file

@ -0,0 +1,23 @@
import { Badge } from "@geist-ui/core"
import type { PostVisibility } from "@lib/types"
type Props = {
visibility: PostVisibility
}
const VisibilityBadge = ({ visibility }: Props) => {
const getBadgeType = () => {
switch (visibility) {
case "public":
return "success"
case "private":
return "warning"
case "unlisted":
return "default"
}
}
return (<Badge type={getBadgeType()}>{visibility}</Badge>)
}
export default VisibilityBadge

View file

@ -0,0 +1,26 @@
.main {
margin-bottom: 2rem;
}
.dropdown {
position: relative;
display: inline-block;
vertical-align: middle;
cursor: pointer;
padding: 0;
border: 0;
background: transparent;
}
.dropdownContent {
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.25rem;
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -0,0 +1,116 @@
import Button from "@components/button"
import React, { useCallback, useEffect } from "react"
import { useState } from "react"
import styles from './dropdown.module.css'
import DownIcon from '@geist-ui/icons/arrowDown'
type Props = {
type?: "primary" | "secondary"
loading?: boolean
disabled?: boolean
className?: string
iconHeight?: number
}
type Attrs = Omit<React.HTMLAttributes<any>, keyof Props>
type ButtonDropdownProps = Props & Attrs
const ButtonDropdown: React.FC<React.PropsWithChildren<ButtonDropdownProps>> = ({
type,
className,
disabled,
loading,
iconHeight = 24,
...props
}) => {
const [visible, setVisible] = useState(false)
const [dropdown, setDropdown] = useState<HTMLDivElement | null>(null)
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setVisible(!visible)
}
const onBlur = () => {
setVisible(false)
}
const onMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}
const onMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}
const onMouseLeave = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setVisible(false)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
setVisible(false)
}
}
const onClickOutside = useCallback(() => (e: React.MouseEvent<HTMLDivElement>) => {
if (dropdown && !dropdown.contains(e.target as Node)) {
setVisible(false)
}
}, [dropdown])
useEffect(() => {
if (visible) {
document.addEventListener("mousedown", onClickOutside)
} else {
document.removeEventListener("mousedown", onClickOutside)
}
return () => {
document.removeEventListener("mousedown", onClickOutside)
}
}, [visible, onClickOutside])
if (!Array.isArray(props.children)) {
return null
}
return (
<div
className={`${styles.main} ${className}`}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
onKeyDown={onKeyDown}
onBlur={onBlur}
>
<div style={{ display: 'flex', flexDirection: 'row', justifyContent: 'flex-end' }}>
{props.children[0]}
<Button style={{ height: iconHeight, width: iconHeight }} className={styles.icon} onClick={() => setVisible(!visible)}><DownIcon /></Button>
</div>
{
visible && (
<div
className={`${styles.dropdown}`}
>
<div
className={`${styles.dropdownContent}`}
>
{props.children.slice(1)}
</div>
</div>
)
}
</div >
)
}
export default ButtonDropdown

View file

@ -0,0 +1,40 @@
.button {
user-select: none;
cursor: pointer;
border-radius: var(--radius);
color: var(--input-fg);
font-weight: 400;
font-size: 1.1rem;
background: var(--input-bg);
border: var(--input-border);
height: 2rem;
display: flex;
align-items: center;
padding: var(--gap-quarter) var(--gap-half);
transition: background-color var(--transition), color var(--transition);
width: 100%;
height: var(--input-height);
}
.button:hover,
.button:focus {
outline: none;
background: var(--input-bg-hover);
border: var(--input-border-focus);
}
.button[disabled] {
cursor: not-allowed;
background: var(--lighter-gray);
color: var(--gray);
}
.secondary {
background: var(--bg);
color: var(--fg);
}
.primary {
background: var(--fg);
color: var(--bg);
}

View file

@ -0,0 +1,28 @@
import styles from './button.module.css'
import { forwardRef, Ref } from 'react'
type Props = React.HTMLProps<HTMLButtonElement> & {
children: React.ReactNode
buttonType?: 'primary' | 'secondary'
className?: string
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
}
// eslint-disable-next-line react/display-name
const Button = forwardRef<HTMLButtonElement, Props>(
({ children, onClick, className, buttonType = 'primary', type = 'button', disabled = false, ...props }, ref) => {
return (
<button
ref={ref}
className={`${styles.button} ${styles[type]} ${className}`}
disabled={disabled}
onClick={onClick}
{...props}
>
{children}
</button>
)
}
)
export default Button

View file

@ -0,0 +1,35 @@
import type { Document } from "@lib/types"
import DocumentComponent from "@components/edit-document"
import { ChangeEvent, memo, useCallback } from "react"
const DocumentList = ({ docs, removeDoc, updateDocContent, updateDocTitle, onPaste }: {
docs: Document[],
updateDocTitle: (i: number) => (title: string) => void
updateDocContent: (i: number) => (content: string) => void
removeDoc: (i: number) => () => void
onPaste: (e: any) => void
}) => {
const handleOnChange = useCallback((i) => (e: ChangeEvent<HTMLTextAreaElement>) => {
updateDocContent(i)(e.target.value)
}, [updateDocContent])
return (<>{
docs.map(({ content, id, title }, i) => {
return (
<DocumentComponent
onPaste={onPaste}
key={id}
remove={removeDoc(i)}
setContent={updateDocContent(i)}
setTitle={updateDocTitle(i)}
handleOnContentChange={handleOnChange(i)}
content={content}
title={title}
/>
)
})
}
</>)
}
export default memo(DocumentList)

View file

@ -0,0 +1,40 @@
.card {
margin: var(--gap) auto;
padding: var(--gap);
border: 1px solid var(--light-gray);
border-radius: var(--radius);
}
.input {
background: #efefef;
}
.descriptionContainer {
display: flex;
flex-direction: column;
min-height: 400px;
overflow: auto;
}
.fileNameContainer {
display: flex;
}
.fileNameContainer > div {
/* Override geist-ui styling */
margin: 0 !important;
}
.textarea {
height: 100%;
}
.actionWrapper {
position: relative;
z-index: 1;
}
.actionWrapper .actions {
position: absolute;
right: 0;
}

View file

@ -0,0 +1,131 @@
import Bold from '@geist-ui/icons/bold'
import Italic from '@geist-ui/icons/italic'
import Link from '@geist-ui/icons/link'
import ImageIcon from '@geist-ui/icons/image'
import { RefObject, useCallback, useMemo } from "react"
import styles from '../document.module.css'
import { Button, ButtonGroup } from "@geist-ui/core"
// TODO: clean up
const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTMLTextAreaElement>, setText?: (text: string) => void }) => {
// const { textBefore, textAfter, selectedText } = useMemo(() => {
// if (textareaRef && textareaRef.current) {
// const textarea = textareaRef.current
// const text = textareaRef.current.value
// const selectionStart = textarea.selectionStart
// const selectionEnd = textarea.selectionEnd
// const textBefore = text.substring(0, selectionStart)
// const textAfter = text.substring(selectionEnd)
// const selectedText = text.substring(selectionStart, selectionEnd)
// return { textBefore, textAfter, selectedText }
// }
// return { textBefore: '', textAfter: '' }
// }, [textareaRef,])
const handleBoldClick = useCallback(() => {
if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd
const text = textareaRef.current.value
const before = text.substring(0, selectionStart)
const after = text.substring(selectionEnd)
const selectedText = text.substring(selectionStart, selectionEnd)
const newText = `${before}**${selectedText}**${after}`
setText(newText)
}
}, [setText, textareaRef])
const handleItalicClick = useCallback(() => {
if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd
const text = textareaRef.current.value
const before = text.substring(0, selectionStart)
const after = text.substring(selectionEnd)
const selectedText = text.substring(selectionStart, selectionEnd)
const newText = `${before}*${selectedText}*${after}`
setText(newText)
}
}, [setText, textareaRef])
const handleLinkClick = useCallback(() => {
if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd
const text = textareaRef.current.value
const before = text.substring(0, selectionStart)
const after = text.substring(selectionEnd)
const selectedText = text.substring(selectionStart, selectionEnd)
let formattedText = '';
if (selectedText.includes('http')) {
formattedText = `[](${selectedText})`
} else {
formattedText = `[${selectedText}](https://)`
}
const newText = `${before}${formattedText}${after}`
setText(newText)
}
}, [setText, textareaRef])
const handleImageClick = useCallback(() => {
if (textareaRef?.current && setText) {
const selectionStart = textareaRef.current.selectionStart
const selectionEnd = textareaRef.current.selectionEnd
const text = textareaRef.current.value
const before = text.substring(0, selectionStart)
const after = text.substring(selectionEnd)
const selectedText = text.substring(selectionStart, selectionEnd)
let formattedText = '';
if (selectedText.includes('http')) {
formattedText = `![](${selectedText})`
} else {
formattedText = `![${selectedText}](https://)`
}
const newText = `${before}${formattedText}${after}`
setText(newText)
}
}, [setText, textareaRef])
const formattingActions = useMemo(() => [
{
icon: <Bold />,
name: 'bold',
action: handleBoldClick
},
{
icon: <Italic />,
name: 'italic',
action: handleItalicClick
},
// {
// icon: <Underline />,
// name: 'underline',
// action: handleUnderlineClick
// },
{
icon: <Link />,
name: 'hyperlink',
action: handleLinkClick
},
{
icon: <ImageIcon />,
name: 'image',
action: handleImageClick
}
], [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick])
return (
<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}>
{formattingActions.map(({ icon, name, action }) => (
<Button auto scale={2 / 3} px={0.6} aria-label={name} key={name} icon={icon} onMouseDown={(e) => e.preventDefault()} onClick={action} />
))}
</ButtonGroup>
</div>
)
}
export default FormattingIcons

View file

@ -0,0 +1,118 @@
import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react"
import styles from './document.module.css'
import Trash from '@geist-ui/icons/trash'
import FormattingIcons from "./formatting-icons"
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
import Preview from "@components/preview"
// import Link from "next/link"
type Props = {
title?: string
content?: string
setTitle?: (title: string) => void
setContent?: (content: string) => void
handleOnContentChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void
initialTab?: "edit" | "preview"
remove?: () => void
onPaste?: (e: any) => void
}
const Document = ({ onPaste, remove, title, content, setTitle, setContent, initialTab = 'edit', handleOnContentChange }: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%'
const height = "100%";
const handleTabChange = (newTab: string) => {
if (newTab === 'edit') {
codeEditorRef.current?.focus()
}
setTab(newTab as 'edit' | 'preview')
}
const onTitleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => setTitle ? setTitle(event.target.value) : null, [setTitle])
const removeFile = useCallback((remove?: () => void) => {
if (remove) {
if (content && content.trim().length > 0) {
const confirmed = window.confirm("Are you sure you want to remove this file?")
if (confirmed) {
remove()
}
} else {
remove()
}
}
}, [content])
// if (skeleton) {
// return <>
// <Spacer height={1} />
// <div className={styles.card}>
// <div className={styles.fileNameContainer}>
// <Skeleton width={275} height={36} />
// {remove && <Skeleton width={36} height={36} />}
// </div>
// <div className={styles.descriptionContainer}>
// <div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
// <Skeleton width={'100%'} height={350} />
// </div >
// </div>
// </>
// }
return (
<>
<Spacer height={1} />
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Input
placeholder="MyFile.md"
value={title}
onChange={onTitleChange}
marginTop="var(--gap-double)"
size={1.2}
font={1.2}
label="Filename"
width={"100%"}
id={title}
/>
{remove && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />}
</div>
<div className={styles.descriptionContainer}>
{tab === 'edit' && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
<Tabs.Item label={"Edit"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
<Textarea
onPaste={onPaste ? onPaste : undefined}
ref={codeEditorRef}
placeholder=""
value={content}
onChange={handleOnContentChange}
width="100%"
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
resize="vertical"
className={styles.textarea}
/>
</div>
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: 'var(--gap-half)', }}>
<Preview height={height} title={title} content={content} />
</div>
</Tabs.Item>
</Tabs>
</div >
</div >
</>
)
}
export default memo(Document)

View file

@ -0,0 +1,15 @@
@media (prefers-reduced-motion: no-preference) {
.fadeIn {
animation-name: fadeInAnimation;
animation-fill-mode: backwards;
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View file

@ -0,0 +1,30 @@
// https://www.joshwcomeau.com/snippets/react-components/fade-in/
import styles from './fade.module.css';
const FadeIn = ({
duration = 300,
delay = 0,
children,
...delegated
}: {
duration?: number;
delay?: number;
children: React.ReactNode;
[key: string]: any;
}) => {
return (
<div
{...delegated}
className={styles.fadeIn}
style={{
...(delegated.style || {}),
animationDuration: duration + 'ms',
animationDelay: delay + 'ms',
}}
>
{children}
</div>
);
};
export default FadeIn

View file

@ -0,0 +1,72 @@
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
}
.dropdownButton {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
}
.content {
list-style: none;
width: 100%;
padding: 0 var(--gap);
margin: 0;
}
.content li {
transition: var(--transition);
border-radius: var(--radius);
margin: 0;
padding: 0 0;
}
.content li:hover,
.content li:focus {
background-color: var(--lighter-gray);
}
.content li a {
display: flex;
align-items: center;
text-align: center;
padding: var(--gap-half) var(--gap);
color: var(--dark-gray);
}
.button {
border-radius: none !important;
}
.content li .fileIcon {
display: inline-block;
margin-right: var(--gap-half);
}
.content li .fileTitle {
/* from Geist */
font-size: calc(0.875 * 16px);
}
.content li::before {
content: "";
padding: 0;
margin: 0;
}
.cardContent {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: var(--gap-half);
padding-top: 200px;
}
@media screen and (max-width: 82rem) {
}

View file

@ -0,0 +1,83 @@
import { Button, Link, Text, Popover } from '@geist-ui/core'
import FileIcon from '@geist-ui/icons/fileText'
import CodeIcon from '@geist-ui/icons/fileFunction'
import styles from './dropdown.module.css'
import { useCallback, useEffect, useRef, useState } from "react"
import { codeFileExtensions } from "@lib/constants"
import ChevronDown from '@geist-ui/icons/chevronDown'
import ShiftBy from "@components/shift-by"
import type { File } from '@lib/types'
type Item = File & {
icon: JSX.Element
}
const FileDropdown = ({
files,
isMobile
}: {
files: File[],
isMobile: boolean
}) => {
const [expanded, setExpanded] = useState(false)
const [items, setItems] = useState<Item[]>([])
const changeHandler = (next: boolean) => {
setExpanded(next)
}
const onOpen = useCallback(() => {
setExpanded(true)
}, [])
const onClose = useCallback(() => {
setExpanded(false)
// contentRef.current?.focus()
}, [])
useEffect(() => {
const newItems = files.map(file => {
const extension = file.title.split('.').pop()
if (codeFileExtensions.includes(extension || '')) {
return {
...file,
icon: <CodeIcon />
}
} else {
return {
...file,
icon: <FileIcon />
}
}
})
setItems(newItems)
}, [files])
const content = useCallback(() => (<ul className={styles.content}>
{items.map(item => (
<li key={item.id} onClick={onClose}>
<a href={`#${item.title}`}>
<ShiftBy y={5}><span className={styles.fileIcon}>
{item.icon}</span></ShiftBy>
<span className={styles.fileTitle}>{item.title ? item.title : 'Untitled'}</span>
</a>
</li>
))}
</ul>
), [items, onClose])
// a list of files with an icon and a title
return (
<>
<Button auto onClick={onOpen} className={styles.button} iconRight={<ChevronDown />} style={{ textTransform: 'none' }} >
Jump to {files.length} {files.length === 1 ? 'file' : 'files'}
</Button>
<Popover
style={{ transform: isMobile ? "translateX(110px)" : "translateX(-75px)" }}
onVisibleChange={changeHandler}
content={content} visible={expanded} hideArrow={true} onClick={onClose} />
</>
)
}
export default FileDropdown

View file

@ -0,0 +1,109 @@
.fileTreeWrapper {
display: block;
position: absolute;
left: 0;
height: 100%;
}
.fileTreeWrapper h5 {
text-align: center;
}
.fileTree {
list-style: none;
width: 100%;
}
.fileTree li {
transition: var(--transition);
border-radius: var(--radius);
margin: 0;
padding: var(--gap-half) 0;
}
.fileTree li a {
margin: 0px;
display: block;
width: 100%;
height: 100%;
padding: var(--gap-half);
}
.fileTree li:hover,
.fileTree li:focus,
.fileTree li:active {
background: var(--lighter-gray);
}
.fileTree li .fileTreeIcon {
display: inline-block;
padding-right: var(--gap-half);
padding-left: var(--gap-half);
}
.fileTree li .fileTreeTitle {
font-size: 1.1rem;
}
.fileTree li::before {
content: "";
padding: 0;
margin: 0;
}
.card {
top: 0;
overflow-y: scroll;
overflow-x: hidden;
position: fixed;
}
.cardContent {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: var(--gap-half);
padding-top: 200px;
}
@media screen and (max-width: 82rem) {
.fileTreeWrapper {
position: relative;
width: 100%;
height: auto;
margin-top: var(--gap);
}
.card {
position: relative;
padding: 0;
}
.cardContent {
margin: var(--gap);
padding: 0;
}
.fileTree {
width: 100%;
}
.fileTree li {
padding: 0.5rem 0;
}
.fileTree li .fileTreeIcon {
margin-right: var(--gap-half);
}
.fileTree li .fileTreeTitle {
font-size: 1.2rem;
}
.fileTree li::before {
content: "";
padding: 0;
margin: 0;
}
}

View file

@ -0,0 +1,61 @@
import { File } from "@lib/types"
import { Card, Link, Text } from '@geist-ui/core'
import FileIcon from '@geist-ui/icons/fileText'
import CodeIcon from '@geist-ui/icons/fileLambda'
import styles from './file-tree.module.css'
import ShiftBy from "@components/shift-by"
import { useEffect, useState } from "react"
import { codeFileExtensions } from "@lib/constants"
type Item = File & {
icon: JSX.Element
}
const FileTree = ({
files
}: {
files: File[]
}) => {
const [items, setItems] = useState<Item[]>([])
useEffect(() => {
const newItems = files.map(file => {
const extension = file.title.split('.').pop()
if (codeFileExtensions.includes(extension || '')) {
return {
...file,
icon: <CodeIcon />
}
} else {
return {
...file,
icon: <FileIcon />
}
}
})
setItems(newItems)
}, [files])
// a list of files with an icon and a title
return (
<div className={styles.fileTreeWrapper}>
<Card height={'100%'} className={styles.card}>
<div className={styles.cardContent}>
<Text h4>Files</Text>
<ul className={styles.fileTree}>
{items.map(({ id, title, icon }) => (
<li key={id}>
<Link color={false} href={`#${title}`}>
<ShiftBy y={5}><span className={styles.fileTreeIcon}>
{icon}</span></ShiftBy>
<span className={styles.fileTreeTitle}>{title}</span>
</Link>
</li>
))}
</ul>
</div>
</Card>
</div >
)
}
export default FileTree

View file

@ -0,0 +1,27 @@
import Head from "next/head";
import React from "react";
type PageSeoProps = {
title?: string;
description?: string;
isLoading?: boolean;
isPrivate?: boolean
};
const PageSeo = ({
title = 'Drift',
description = "A self-hostable clone of GitHub Gist",
isPrivate = false
}: PageSeoProps) => {
return (
<>
<Head>
<title>{title}</title>
{!isPrivate && <meta name="description" content={description} />}
</Head>
</>
);
};
export default PageSeo;

View file

@ -0,0 +1,46 @@
import React, { useEffect, useState } from 'react'
import MoonIcon from '@geist-ui/icons/moon'
import SunIcon from '@geist-ui/icons/sun'
// import { useAllThemes, useTheme } from '@geist-ui/core'
import styles from './header.module.css'
import { Select } from '@geist-ui/core'
import { useTheme } from 'next-themes'
const Controls = () => {
const [mounted, setMounted] = useState(false)
const { resolvedTheme, setTheme } = useTheme()
useEffect(() => setMounted(true), [])
if (!mounted) return null
const switchThemes = () => {
if (resolvedTheme === 'dark') {
setTheme('light')
} else {
setTheme('dark')
}
}
return (
<div className={styles.wrapper}>
<Select
scale={0.5}
h="28px"
pure
onChange={switchThemes}
value={resolvedTheme}
>
<Select.Option value="light">
<span className={styles.selectContent}>
<SunIcon size={14} /> Light
</span>
</Select.Option>
<Select.Option value="dark">
<span className={styles.selectContent}>
<MoonIcon size={14} /> Dark
</span>
</Select.Option>
</Select>
</div >
)
}
export default React.memo(Controls);

View file

@ -0,0 +1,43 @@
.tabs {
flex: 1 1;
padding: 0 var(--gap);
}
.mobile {
position: relative;
z-index: 2;
}
.controls {
display: none !important;
}
@media only screen and (max-width: 650px) {
.tabs {
display: none;
}
.controls {
display: block !important;
}
}
.controls button:active,
.controls button:focus,
.controls button:hover {
outline: 1px solid rgba(0, 0, 0, 0.2);
}
.wrapper {
display: flex;
align-items: center;
width: min-content;
}
.selectContent {
width: auto;
height: 18px;
display: flex;
justify-content: space-between;
align-items: center;
}

View file

@ -0,0 +1,197 @@
import { ButtonGroup, Page, Spacer, Tabs, useBodyScroll, useMediaQuery, } from "@geist-ui/core";
import { useCallback, useEffect, useState } from "react";
import styles from './header.module.css';
import { useRouter } from "next/router";
import useSignedIn from "../../lib/hooks/use-signed-in";
import HomeIcon from '@geist-ui/icons/home';
import MenuIcon from '@geist-ui/icons/menu';
import GitHubIcon from '@geist-ui/icons/github';
import SignOutIcon from '@geist-ui/icons/userX';
import SignInIcon from '@geist-ui/icons/user';
import SignUpIcon from '@geist-ui/icons/userPlus';
import NewIcon from '@geist-ui/icons/plusCircle';
import YourIcon from '@geist-ui/icons/list'
import MoonIcon from '@geist-ui/icons/moon';
import SettingsIcon from '@geist-ui/icons/settings';
import SunIcon from '@geist-ui/icons/sun';
import { useTheme } from "next-themes"
import { Button } from "@geist-ui/core";
import useUserData from "@lib/hooks/use-user-data";
type Tab = {
name: string
icon: JSX.Element
value: string
onClick?: () => void
href?: string
}
const Header = () => {
const router = useRouter();
const [selectedTab, setSelectedTab] = useState<string>(router.pathname === '/' ? 'home' : router.pathname.split('/')[1]);
const [expanded, setExpanded] = useState<boolean>(false)
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
const isMobile = useMediaQuery('xs', { match: 'down' })
const { signedIn: isSignedIn, signout } = useSignedIn()
const userData = useUserData();
const [pages, setPages] = useState<Tab[]>([])
const { setTheme, resolvedTheme } = useTheme()
useEffect(() => {
setBodyHidden(expanded)
}, [expanded, setBodyHidden])
useEffect(() => {
if (!isMobile) {
setExpanded(false)
}
}, [isMobile])
useEffect(() => {
const defaultPages: Tab[] = [
{
name: isMobile ? "GitHub" : "",
href: "https://github.com/maxleiter/drift",
icon: <GitHubIcon />,
value: "github"
},
{
name: isMobile ? "Change theme" : "",
onClick: function () {
if (typeof window !== 'undefined')
setTheme(resolvedTheme === 'light' ? 'dark' : 'light');
},
icon: resolvedTheme === 'light' ? <MoonIcon /> : <SunIcon />,
value: "theme",
}
]
if (isSignedIn)
setPages([
{
name: 'new',
icon: <NewIcon />,
value: 'new',
href: '/new'
},
{
name: 'yours',
icon: <YourIcon />,
value: 'yours',
href: '/mine'
},
// {
// name: 'settings',
// icon: <SettingsIcon />,
// value: 'settings',
// href: '/settings'
// },
{
name: 'sign out',
icon: <SignOutIcon />,
value: 'signout',
onClick: signout
},
...defaultPages
])
else
setPages([
{
name: 'home',
icon: <HomeIcon />,
value: 'home',
href: '/'
},
{
name: 'Sign in',
icon: <SignInIcon />,
value: 'signin',
href: '/signin'
},
{
name: 'Sign up',
icon: <SignUpIcon />,
value: 'signup',
href: '/signup'
},
...defaultPages
])
if (userData?.role === "admin") {
setPages((pages) => [
...pages,
{
name: 'admin',
icon: <SettingsIcon />,
value: 'admin',
href: '/admin'
}
])
}
// TODO: investigate deps causing infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile, isSignedIn, resolvedTheme, userData])
const onTabChange = useCallback((tab: string) => {
if (typeof window === 'undefined') return
const match = pages.find(page => page.value === tab)
if (match?.onClick) {
match.onClick()
} else {
router.push(match?.href || '/')
}
}, [pages, router])
return (
<Page.Header height={'var(--page-nav-height)'} marginBottom={2}>
<div className={styles.tabs}>
<Tabs
value={selectedTab}
leftSpace={0}
align="center"
hideDivider
hideBorder
onChange={onTabChange}>
{pages.map((tab) => {
return <Tabs.Item
font="14px"
label={<>{tab.icon} {tab.name}</>}
value={tab.value}
key={`${tab.value}`}
/>
})}
</Tabs>
</div>
<div className={styles.controls}>
<Button
auto
type="abort"
onClick={() => setExpanded(!expanded)}
aria-label="Menu"
>
<Spacer height={5 / 6} width={0} />
<MenuIcon />
</Button>
</div>
{isMobile && expanded && (<div className={styles.mobile}>
<ButtonGroup vertical>
{pages.map((tab, index) => {
return <Button
key={`${tab.name}-${index}`}
onClick={() => onTabChange(tab.value)}
icon={tab.icon}
>
{tab.name}
</Button>
})}
</ButtonGroup>
</div>)}
</Page.Header >
)
}
export default Header

View file

@ -0,0 +1,10 @@
import dynamic from 'next/dynamic'
const Header = dynamic(import('./header'), {
// ssr: false,
// loading: () => <MenuSkeleton />,
})
export default Header

View file

@ -0,0 +1,3 @@
.textarea {
height: 100%;
}

View file

@ -0,0 +1,43 @@
import ShiftBy from "@components/shift-by"
import { Spacer, Tabs, Card, Textarea, Text } from "@geist-ui/core"
import Image from 'next/image'
import styles from './home.module.css'
import markdownStyles from '@components/preview/preview.module.css'
const Home = ({ introTitle, introContent, rendered }: {
introTitle: string
introContent: string
rendered: string
}) => {
return (<><div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
<ShiftBy y={-2}><Image src={'/assets/logo-optimized.svg'} width={'48px'} height={'48px'} alt="" /></ShiftBy>
<Spacer />
<Text style={{ display: 'inline' }} h1>{introTitle}</Text>
</div>
<Card>
<Tabs initialValue={'preview'} hideDivider leftSpace={0}>
<Tabs.Item label={"Raw"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
<Textarea
readOnly
value={introContent}
width="100%"
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
resize="vertical"
className={styles.textarea}
/>
</div>
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: 'var(--gap-half)', }}>
<article className={markdownStyles.markdownPreview} dangerouslySetInnerHTML={{ __html: rendered }} style={{
height: "100%"
}} />
</div>
</Tabs.Item>
</Tabs>
</Card></>)
}
export default Home

View file

@ -0,0 +1,24 @@
import React from 'react'
import styles from './input.module.css'
type Props = React.HTMLProps<HTMLInputElement> & {
label?: string
fontSize?: number | string
}
// eslint-disable-next-line react/display-name
const Input = React.forwardRef<HTMLInputElement, Props>(({ label, className, ...props }, ref) => {
return (<div className={styles.wrapper}>
{label && <label className={styles.label}>{label}</label>}
<input
ref={ref}
className={className ? `${styles.input} ${className}` : styles.input}
{...props}
/>
</div>
)
})
export default Input

View file

@ -0,0 +1,57 @@
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: 100%;
font-size: 1rem;
}
.input {
height: 2.5rem;
border-radius: var(--inline-radius);
background: var(--bg);
color: var(--fg);
border: 1px solid var(--light-gray);
padding: 0 var(--gap-half);
outline: none;
transition: border-color var(--transition);
display: flex;
justify-content: center;
margin: 0;
width: 100%;
}
.input::placeholder {
font-size: 1.5rem;
}
.input:focus {
border-color: var(--input-border-focus);
}
.label {
display: inline-flex;
width: initial;
height: 100%;
align-items: center;
pointer-events: none;
margin: 0;
padding: 0 var(--gap-half);
color: var(--fg);
background-color: var(--light-gray);
border-top-left-radius: var(--radius);
border-bottom-left-radius: var(--radius);
border-top: 1px solid var(--input-border);
border-left: 1px solid var(--input-border);
border-bottom: 1px solid var(--input-border);
font-size: inherit;
line-height: 1;
white-space: nowrap;
}
@media screen and (max-width: 768px) {
.wrapper {
margin-bottom: var(--gap);
}
}

View file

@ -0,0 +1,13 @@
import type { Post } from "@lib/types"
import PostList from "../post-list"
const MyPosts = ({ posts, error, morePosts }:
{
posts: Post[],
error: boolean,
morePosts: boolean
}) => {
return <PostList morePosts={morePosts} initialPosts={posts} error={error} />
}
export default MyPosts

View file

@ -0,0 +1,41 @@
.container {
display: flex;
flex-direction: column;
}
.container ul {
margin: 0;
margin-top: var(--gap-double);
}
.dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
border-width: 2px;
border-radius: 2px;
border-style: dashed;
outline: none;
transition: all 0.24s ease-in-out;
cursor: pointer;
}
.dropzone:focus {
box-shadow: 0 0 4px 1px rgba(124, 124, 124, 0.5);
}
.error {
color: red;
font-size: 0.8rem;
transition: border 0.24s ease-in-out;
border: 2px solid red;
border-radius: 2px;
padding: 20px;
}
.error ul {
margin: 0;
padding-left: var(--gap-double);
}

View file

@ -0,0 +1,89 @@
import { Text, useTheme, useToasts } from '@geist-ui/core'
import { memo } from 'react'
import { useDropzone } from 'react-dropzone'
import styles from './drag-and-drop.module.css'
import type { Document } from '@lib/types'
import generateUUID from '@lib/generate-uuid'
import { allowedFileTypes, allowedFileNames, allowedFileExtensions } from '@lib/constants'
function FileDropzone({ setDocs }: { setDocs: ((docs: Document[]) => void) }) {
const { palette } = useTheme()
const { setToast } = useToasts()
const onDrop = async (acceptedFiles: File[]) => {
const newDocs = await Promise.all(acceptedFiles.map((file) => {
return new Promise<Document>((resolve) => {
const reader = new FileReader()
reader.onabort = () => setToast({ text: 'File reading was aborted', type: 'error' })
reader.onerror = () => setToast({ text: 'File reading failed', type: 'error' })
reader.onload = () => {
const content = reader.result as string
resolve({
title: file.name,
content,
id: generateUUID()
})
}
reader.readAsText(file)
})
}))
setDocs(newDocs)
}
const validator = (file: File) => {
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100
// TODO: make this configurable
const maxFileSize = 50000000;
if (file.size > maxFileSize) {
return {
code: 'file-too-big',
message: 'File is too big. Maximum file size is ' + byteToMB(maxFileSize) + ' MB.',
}
}
// We initially try to use the browser provided mime type, and then fall back to file names and finally extensions
if (allowedFileTypes.includes(file.type) || allowedFileNames.includes(file.name) || allowedFileExtensions.includes(file.name?.split('.').pop() || '')) {
return null
} else {
return {
code: "not-plain-text",
message: `Only plain text files are allowed.`
};
}
}
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, validator })
const fileRejectionItems = fileRejections.map(({ file, errors }) => (
<li key={file.name}>
{file.name}:
<ul>
{errors.map(e => (
<li key={e.code}><Text>{e.message}</Text></li>
))}
</ul>
</li>
));
return (
<div className={styles.container}>
<div {...getRootProps()} className={styles.dropzone} style={{
borderColor: palette.accents_3,
}}>
<input {...getInputProps()} />
{!isDragActive && <Text p>Drag some files here, or click to select files</Text>}
{isDragActive && <Text p>Release to drop the files here</Text>}
</div>
{fileRejections.length > 0 && <ul className={styles.error}>
{/* <Button style={{ float: 'right' }} type="abort" onClick={() => fileRejections.splice(0, fileRejections.length)} auto iconRight={<XCircle />}></Button> */}
<Text h5>There was a problem with one or more of your files.</Text>
{fileRejectionItems}
</ul>}
</div>
)
}
export default memo(FileDropzone)

View file

@ -0,0 +1,278 @@
import { Button, useToasts, ButtonDropdown, Toggle, Input, useClickAway } from '@geist-ui/core'
import { useRouter } from 'next/router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import generateUUID from '@lib/generate-uuid';
import FileDropzone from './drag-and-drop';
import styles from './post.module.css'
import Title from './title';
import Cookies from 'js-cookie'
import type { Post as PostType, PostVisibility, Document as DocumentType } from '@lib/types';
import PasswordModal from './password-modal';
import getPostPath from '@lib/get-post-path';
import EditDocumentList from '@components/edit-document-list';
import { ChangeEvent } from 'react';
import DatePicker from 'react-datepicker';
const Post = ({
initialPost,
newPostParent
}: {
initialPost?: PostType,
newPostParent?: string
}) => {
const { setToast } = useToasts()
const router = useRouter();
const [title, setTitle] = useState<string>()
const [expiresAt, setExpiresAt] = useState<Date | null>(null)
const emptyDoc = useMemo(() => [{
title: '',
content: '',
id: generateUUID()
}], [])
const [docs, setDocs] = useState<DocumentType[]>(emptyDoc)
// the /new/from/{id} route fetches an initial post
useEffect(() => {
if (initialPost) {
setTitle(`Copy of ${initialPost.title}`)
setDocs(initialPost.files?.map(doc => ({
title: doc.title,
content: doc.content,
id: doc.id
})) || emptyDoc)
}
}, [emptyDoc, initialPost])
const [passwordModalVisible, setPasswordModalVisible] = useState(false)
const sendRequest = useCallback(async (url: string, data:
{
expiresAt: Date | null,
visibility?: PostVisibility,
title?: string,
files?: DocumentType[],
password?: string,
userId: string,
parentId?: string
}) => {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get('drift-token')}`
},
body: JSON.stringify({
title,
files: docs,
...data,
})
})
if (res.ok) {
const json = await res.json()
router.push(getPostPath(json.visibility, json.id))
} else {
const json = await res.json()
setToast({
text: json.error.message || 'Please fill out all fields',
type: 'error'
})
setPasswordModalVisible(false)
setSubmitting(false)
}
}, [docs, router, setToast, title])
const [isSubmitting, setSubmitting] = useState(false)
const onSubmit = useCallback(async (visibility: PostVisibility, password?: string) => {
if (visibility === 'protected' && !password) {
setPasswordModalVisible(true)
return
}
setPasswordModalVisible(false)
setSubmitting(true)
let hasErrored = false
if (!title) {
setToast({
text: 'Please fill out the post title',
type: 'error'
})
hasErrored = true
}
if (!docs.length) {
setToast({
text: 'Please add at least one document',
type: 'error'
})
hasErrored = true
}
for (const doc of docs) {
if (!doc.title) {
setToast({
text: 'Please fill out all the document titles',
type: 'error'
})
hasErrored = true
}
}
if (hasErrored) {
setSubmitting(false)
return
}
await sendRequest('/server-api/posts/create', {
title,
files: docs,
visibility,
password,
userId: Cookies.get('drift-userid') || '',
expiresAt,
parentId: newPostParent
})
}, [docs, expiresAt, newPostParent, sendRequest, setToast, title])
const onClosePasswordModal = () => {
setPasswordModalVisible(false)
setSubmitting(false)
}
const submitPassword = useCallback((password) => onSubmit('protected', password), [onSubmit])
const onChangeExpiration = useCallback((date) => setExpiresAt(date), [])
const onChangeTitle = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value)
}, [setTitle])
const updateDocTitle = useCallback((i: number) => (title: string) => {
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, title } : doc))
}, [setDocs])
const updateDocContent = useCallback((i: number) => (content: string) => {
setDocs((docs) => docs.map((doc, index) => i === index ? { ...doc, content } : doc))
}, [setDocs])
const removeDoc = useCallback((i: number) => () => {
setDocs((docs) => docs.filter((_, index) => i !== index))
}, [setDocs])
const uploadDocs = useCallback((files: DocumentType[]) => {
// if no title is set and the only document is empty,
const isFirstDocEmpty = docs.length <= 1 && (docs.length ? docs[0].title === '' : true)
const shouldSetTitle = !title && isFirstDocEmpty
if (shouldSetTitle) {
if (files.length === 1) {
setTitle(files[0].title)
} else if (files.length > 1) {
setTitle('Uploaded files')
}
}
if (isFirstDocEmpty) setDocs(files)
else setDocs((docs) => [...docs, ...files])
}, [docs, title])
// pasted files
// const files = e.clipboardData.files as File[]
// if (files.length) {
// const docs = Array.from(files).map((file) => ({
// title: file.name,
// content: '',
// id: generateUUID()
// }))
// }
const onPaste = useCallback((e: any) => {
const pastedText = (e.clipboardData).getData('text')
if (pastedText) {
if (!title) {
setTitle("Pasted text")
}
}
}, [title])
const CustomTimeInput = ({ date, value, onChange }: {
date: Date,
value: string,
onChange: (date: string) => void
}) => (
<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
/>
);
return (
<div style={{ paddingBottom: 150 }}>
<Title title={title} onChange={onChangeTitle} />
<FileDropzone setDocs={uploadDocs} />
<EditDocumentList onPaste={onPaste} docs={docs} updateDocTitle={updateDocTitle} updateDocContent={updateDocContent} removeDoc={removeDoc} />
<div className={styles.buttons}>
<Button
className={styles.button}
onClick={() => {
setDocs([...docs, {
title: '',
content: '',
id: generateUUID()
}])
}}
type="default"
>
Add a File
</Button>
<div className={styles.rightButtons}>
{<DatePicker
onChange={onChangeExpiration}
customInput={<Input label="Expires at" clearable width="100%" height="40px" />}
placeholderText="Won't expire"
selected={expiresAt}
showTimeInput={true}
// @ts-ignore
customTimeInput={<CustomTimeInput />}
timeInputLabel="Time:"
dateFormat="MM/dd/yyyy h:mm aa"
className={styles.datePicker}
clearButtonTitle={"Clear"}
// TODO: investigate why this causes margin shift if true
enableTabLoop={false}
minDate={new Date()}
/>}
<ButtonDropdown loading={isSubmitting} type="success">
<ButtonDropdown.Item main onClick={() => onSubmit('private')}>Create Private</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('public')} >Create Public</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('unlisted')} >Create Unlisted</ButtonDropdown.Item>
<ButtonDropdown.Item onClick={() => onSubmit('protected')} >Create with Password</ButtonDropdown.Item>
</ButtonDropdown>
</div>
</div>
<PasswordModal creating={true} isOpen={passwordModalVisible} onClose={onClosePasswordModal} onSubmit={submitPassword} />
</div>
)
}
export default Post

View file

@ -0,0 +1,54 @@
import { Modal, Note, Spacer, Input } from "@geist-ui/core"
import { useState } from "react"
type Props = {
creating: boolean
isOpen: boolean
onClose: () => void
onSubmit: (password: string) => void
}
const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify, creating }: Props) => {
const [password, setPassword] = useState<string>()
const [confirmPassword, setConfirmPassword] = useState<string>()
const [error, setError] = useState<string>()
const onSubmit = () => {
if (!password || (creating && !confirmPassword)) {
setError('Please enter a password')
return
}
if (password !== confirmPassword && creating) {
setError("Passwords do not match")
return
}
onSubmitAfterVerify(password)
}
return (<>
{/* TODO: investigate disableBackdropClick not updating state? */}
{<Modal visible={isOpen} disableBackdropClick={true} >
<Modal.Title>Enter a password</Modal.Title>
<Modal.Content>
{!error && creating && <Note type="warning" label='Warning'>
This doesn&apos;t protect your post from the server administrator.
</Note>}
{error && <Note type="error" label='Error'>
{error}
</Note>}
<Spacer />
<Input width={"100%"} label="Password" marginBottom={1} htmlType="password" placeholder="Password" onChange={(e) => setPassword(e.target.value)} />
{creating && <Input width={"100%"} label="Confirm" htmlType="password" placeholder="Confirm Password" onChange={(e) => setConfirmPassword(e.target.value)} />}
</Modal.Content>
<Modal.Action passive onClick={onClose}>Cancel</Modal.Action>
<Modal.Action onClick={onSubmit}>Submit</Modal.Action>
</Modal>}
</>)
}
export default PasswordModal

View file

@ -1,15 +1,9 @@
.root {
padding-bottom: 200px;
display: flex;
flex-direction: column;
gap: var(--gap-half);
}
.buttons {
position: relative;
display: flex;
justify-content: space-between;
width: 100%;
margin-top: var(--gap-double);
gap: var(--gap);
}
@ -19,12 +13,24 @@
align-items: center;
}
.datePicker {
flex: 1;
}
.description {
width: 100%;
.title {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: var(--gap);
}
@media screen and (max-width: 650px) {
.title {
align-items: flex-start;
flex-direction: column;
}
.buttons {
flex-direction: column;
margin: 0;
@ -34,7 +40,7 @@
.buttons .rightButtons {
flex-direction: column;
align-items: flex-start;
align-items: flex-end;
}
.buttons .rightButtons > * {

View file

@ -0,0 +1,45 @@
import { ChangeEvent, memo, useEffect, useState } from 'react'
import { Text } from '@geist-ui/core'
import ShiftBy from '@components/shift-by'
import styles from '../post.module.css'
import { Input } from '@geist-ui/core'
const titlePlaceholders = [
"How to...",
"Status update for ...",
"My new project",
"My new idea",
"Let's talk about...",
"What's up with ...",
"I'm thinking about ...",
]
type props = {
onChange: (e: ChangeEvent<HTMLInputElement>) => void
title?: string
}
const Title = ({ onChange, title }: props) => {
const [placeholder, setPlaceholder] = useState(titlePlaceholders[0])
useEffect(() => {
// set random placeholder on load
setPlaceholder(titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)])
}, [])
return (<div className={styles.title}>
<Text h1 width={"150px"} className={styles.drift}>Drift</Text>
<ShiftBy y={-3}>
<Input
placeholder={placeholder}
value={title || ""}
onChange={onChange}
height={"55px"}
font={1.5}
label="Post title"
style={{ width: "100%" }}
/>
</ShiftBy>
</div>)
}
export default memo(Title)

View file

@ -0,0 +1,27 @@
import Head from "next/head";
import React from "react";
type PageSeoProps = {
title?: string;
description?: string;
isLoading?: boolean;
isPrivate?: boolean
};
const PageSeo = ({
title = 'Drift',
description = "A self-hostable clone of GitHub Gist",
isPrivate = false
}: PageSeoProps) => {
return (
<>
<Head>
<title>{title}</title>
{!isPrivate && <meta name="description" content={description} />}
</Head>
</>
);
};
export default PageSeo;

View file

@ -0,0 +1,138 @@
import { Button, Code, Dot, Input, Note, Text } from "@geist-ui/core"
import NextLink from "next/link"
import Link from '../Link'
import styles from './post-list.module.css'
import ListItemSkeleton from "./list-item-skeleton"
import ListItem from "./list-item"
import { Post } from "@lib/types"
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react"
import debounce from "lodash.debounce"
import Cookies from "js-cookie"
type Props = {
initialPosts: Post[]
error: boolean
morePosts: boolean
}
const PostList = ({ morePosts, initialPosts, error }: Props) => {
const [search, setSearchValue] = useState('')
const [posts, setPosts] = useState<Post[]>(initialPosts)
const [searching, setSearching] = useState(false)
const [hasMorePosts, setHasMorePosts] = useState(morePosts)
const loadMoreClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
if (hasMorePosts) {
async function fetchPosts() {
const res = await fetch(`/server-api/posts/mine`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get('drift-token')}`,
"x-page": `${posts.length / 10 + 1}`,
}
}
)
const json = await res.json()
setPosts([...posts, ...json.posts])
setHasMorePosts(json.morePosts)
}
fetchPosts()
}
}, [posts, hasMorePosts])
// update posts on search
useEffect(() => {
if (search) {
// fetch results from /server-api/posts/search
const fetchResults = async () => {
setSearching(true)
//encode search
const res = await fetch(`/server-api/posts/search?q=${encodeURIComponent(search)}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
// "tok": process.env.SECRET_KEY || ''
}
})
const data = await res.json()
setPosts(data)
setSearching(false)
}
fetchResults()
} else {
setPosts(initialPosts)
}
}, [initialPosts, search])
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value)
}
const debouncedSearchHandler = useMemo(
() => debounce(handleSearchChange, 300)
, []);
useEffect(() => {
return () => {
debouncedSearchHandler.cancel();
}
}, [debouncedSearchHandler]);
const deletePost = useCallback((postId: string) => async () => {
const res = await fetch(`/server-api/posts/${postId}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Cookies.get("drift-token")}`
},
})
if (!res.ok) {
console.error(res)
return
} else {
setPosts((posts) => posts.filter(post => post.id !== postId))
}
}, [])
return (
<div className={styles.container}>
<div className={styles.searchContainer}>
<Input scale={3 / 2}
clearable
placeholder="Search..."
onChange={debouncedSearchHandler} />
</div>
{error && <Text type='error'>Failed to load.</Text>}
{!posts.length && searching && <ul>
<li>
<ListItemSkeleton />
</li>
<li>
<ListItemSkeleton />
</li>
</ul>}
{posts?.length === 0 && !error && <Text type='secondary'>No posts found. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
{
posts?.length > 0 && <div>
<ul>
{posts.map((post) => {
return <ListItem deletePost={deletePost(post.id)} post={post} key={post.id} />
})}
</ul>
</div>
}
{hasMorePosts && !setSearchValue && <div className={styles.moreContainer}>
<Button width={"100%"} onClick={loadMoreClick}>
Load more
</Button>
</div>}
</div>
)
}
export default PostList

View file

@ -0,0 +1,21 @@
import Skeleton from "react-loading-skeleton";
import { Card, Divider, Grid, Spacer } from "@geist-ui/core";
const ListItemSkeleton = () => (<Card>
<Spacer height={1 / 2} />
<Grid.Container justify={'space-between'} marginBottom={1 / 2}>
<Grid xs={8} paddingLeft={1 / 2}><Skeleton width={150} /></Grid>
<Grid xs={7}><Skeleton width={100} /></Grid>
<Grid xs={4}><Skeleton width={70} /></Grid>
</Grid.Container>
<Divider h="1px" my={0} />
<Card.Content >
<Skeleton width={200} />
</Card.Content>
</Card>)
export default ListItemSkeleton

View file

@ -0,0 +1,30 @@
.title {
display: flex;
justify-content: space-between;
}
.badges {
display: flex;
gap: var(--gap-half);
}
.buttons {
display: flex;
gap: var(--gap-half);
}
@media screen and (max-width: 700px) {
.badges {
flex-direction: column;
align-items: flex-start;
}
.badges > * {
width: min-content;
}
.title {
flex-direction: column;
gap: var(--gap);
}
}

View file

@ -0,0 +1,74 @@
import NextLink from "next/link"
import VisibilityBadge from "../badges/visibility-badge"
import getPostPath from "@lib/get-post-path"
import { Link, Text, Card, Tooltip, Divider, Badge, Button } from "@geist-ui/core"
import { File, Post } from "@lib/types"
import FadeIn from "@components/fade-in"
import Trash from "@geist-ui/icons/trash"
import ExpirationBadge from "@components/badges/expiration-badge"
import CreatedAgoBadge from "@components/badges/created-ago-badge"
import Edit from "@geist-ui/icons/edit"
import { useRouter } from "next/router"
import Parent from '@geist-ui/icons/arrowUpCircle'
import styles from "./list-item.module.css"
// TODO: isOwner should default to false so this can be used generically
const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?: boolean, deletePost: () => void }) => {
const router = useRouter()
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
return (<FadeIn><li key={post.id}>
<Card style={{ overflowY: 'scroll' }}>
<Card.Body>
<Text h3 className={styles.title}>
<NextLink passHref={true} href={getPostPath(post.visibility, post.id)}>
<Link color marginRight={'var(--gap)'}>
{post.title}
</Link>
</NextLink>
{isOwner && <span className={styles.buttons}>
{post.parent && <Tooltip text={"View parent"} hideArrow>
<Button
auto
icon={<Parent />}
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))}
/>
</Tooltip>}
<Tooltip text={"Make a copy"} hideArrow>
<Button
auto
iconRight={<Edit />}
onClick={editACopy} />
</Tooltip>
<Tooltip text={"Delete"} hideArrow><Button iconRight={<Trash />} onClick={deletePost} auto /></Tooltip>
</span>}
</Text>
<div className={styles.badges}>
<VisibilityBadge visibility={post.visibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
<Badge type="secondary">{post.files?.length === 1 ? "1 file" : `${post.files?.length || 0} files`}</Badge>
<ExpirationBadge postExpirationDate={post.expiresAt} />
</div>
</Card.Body>
<Divider h="1px" my={0} />
<Card.Content>
{post.files?.map((file: File) => {
return <div key={file.id}>
<Link color href={`${getPostPath(post.visibility, post.id)}#${file.title}`}>
{file.title || 'Untitled file'}
</Link></div>
})}
</Card.Content>
</Card>
</li> </FadeIn>)
}
export default ListItem

View file

@ -0,0 +1,34 @@
.container ul {
list-style: none;
padding: 0;
margin: 0;
}
.container ul li {
padding: 0.5rem 0;
}
.container ul li::before {
content: "";
padding: 0;
margin: 0;
}
.postHeader {
display: flex;
justify-content: space-between;
padding: var(--gap);
align-items: center;
position: sticky;
top: 0;
z-index: 1;
background: inherit;
}
.searchContainer {
display: flex;
align-items: center;
flex-direction: column-reverse;
justify-content: center;
margin-bottom: var(--gap-double);
}

View file

@ -0,0 +1,146 @@
import Header from "@components/header/header"
import PageSeo from "@components/page-seo"
import VisibilityBadge from "@components/badges/visibility-badge"
import DocumentComponent from '@components/view-document'
import styles from './post-page.module.css'
import homeStyles from '@styles/Home.module.css'
import type { File, Post } from "@lib/types"
import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core"
import { useEffect, useState } from "react"
import Archive from '@geist-ui/icons/archive'
import Edit from '@geist-ui/icons/edit'
import Parent from '@geist-ui/icons/arrowUpCircle'
import FileDropdown from "@components/file-dropdown"
import ScrollToTop from "@components/scroll-to-top"
import { useRouter } from "next/router"
import ExpirationBadge from "@components/badges/expiration-badge"
import CreatedAgoBadge from "@components/badges/created-ago-badge"
import Cookies from "js-cookie"
import getPostPath from "@lib/get-post-path"
type Props = {
post: Post
}
const PostPage = ({ post }: Props) => {
const router = useRouter()
const isMobile = useMediaQuery("mobile")
const [isExpired, setIsExpired] = useState(post.expiresAt ? new Date(post.expiresAt) < new Date() : null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (isExpired) {
router.push("/expired")
}
const isOwner = post.users ? post.users[0].id === Cookies.get("drift-userid") : false
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
if (!isOwner && expirationDate < new Date()) {
router.push("/expired")
} else {
setIsLoading(false)
}
let interval: NodeJS.Timer | null = null;
if (post.expiresAt) {
interval = setInterval(() => {
const expirationDate = new Date(post.expiresAt ? post.expiresAt : "")
setIsExpired(expirationDate < new Date())
}, 4000)
}
return () => {
if (interval) clearInterval(interval)
}
}, [isExpired, post.expiresAt, post.users, router])
const download = async () => {
if (!post.files) return
const downloadZip = (await import("client-zip")).downloadZip
const blob = await downloadZip(post.files.map((file: any) => {
return {
name: file.title,
input: file.content,
lastModified: new Date(file.updatedAt)
}
})).blob()
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${post.title}.zip`
link.click()
link.remove()
}
const editACopy = () => {
router.push(`/new/from/${post.id}`)
}
if (isLoading) {
return <></>
}
return (
<Page width={"100%"}>
<PageSeo
title={`${post.title} - Drift`}
description={post.description}
isPrivate={false}
/>
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={homeStyles.main}>
<div className={styles.header}>
<span className={styles.title}>
<Text h3>{post.title}</Text>
<span className={styles.badges}>
<VisibilityBadge visibility={post.visibility} />
<CreatedAgoBadge createdAt={post.createdAt} />
<ExpirationBadge postExpirationDate={post.expiresAt} />
</span>
</span>
<span className={styles.buttons}>
<ButtonGroup vertical={isMobile}>
<Button auto onClick={download} icon={<Archive />} style={{ textTransform: 'none' }}>
Download as ZIP Archive
</Button>
<Button
auto
icon={<Edit />}
onClick={editACopy}
style={{ textTransform: 'none' }}>
Edit a Copy
</Button>
{console.log(post)}
{post.parent && <Button
auto
icon={<Parent />}
onClick={() => router.push(getPostPath(post.parent!.visibility, post.parent!.id))}
>
View Parent
</Button>}
<FileDropdown isMobile={isMobile} files={post.files || []} />
</ButtonGroup>
</span>
</div>
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
{post.files?.map(({ id, content, title }: File) => (
<DocumentComponent
key={id}
title={title}
initialTab={'preview'}
id={id}
content={content}
/>
))}
<ScrollToTop />
</Page.Content>
</Page >
)
}
export default PostPage

View file

@ -0,0 +1,51 @@
.header .title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: var(--gap);
}
.header .title .badges {
display: flex;
gap: var(--gap-half);
}
.header .title h3 {
margin: 0;
padding: 0;
display: inline-block;
}
.header .buttons {
display: flex;
justify-content: flex-end;
}
@media screen and (max-width: 900px) {
.header {
flex-direction: column;
gap: var(--gap);
}
}
@media screen and (max-width: 700px) {
.header .title {
flex-direction: column;
gap: var(--gap-half);
}
.header .title .badges {
flex-direction: column;
align-items: center;
}
.header .title .badges > * {
width: min-content;
}
.header .buttons {
display: flex;
justify-content: center;
}
}

View file

@ -0,0 +1,55 @@
import { memo, useEffect, useState } from "react"
import styles from './preview.module.css'
type Props = {
height?: number | string
fileId?: string
content?: string
title?: string
// file extensions we can highlight
}
const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
const [preview, setPreview] = useState<string>(content || "")
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
async function fetchPost() {
if (fileId) {
const resp = await fetch(`/api/html/${fileId}`, {
method: "GET",
})
if (resp.ok) {
const res = await resp.text()
setPreview(res)
setIsLoading(false)
}
} else if (content) {
const resp = await fetch(`/api/render-markdown`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
content,
}),
})
if (resp.ok) {
const res = await resp.text()
setPreview(res)
setIsLoading(false)
}
}
setIsLoading(false)
}
fetchPost()
}, [content, fileId, title])
return (<>
{isLoading ? <div>Loading...</div> : <article className={styles.markdownPreview} dangerouslySetInnerHTML={{ __html: preview }} style={{
height
}} />}
</>)
}
export default memo(MarkdownPreview)

View file

@ -1,63 +1,3 @@
.markdownPreview {
padding: var(--gap-quarter);
font-size: 16px;
line-height: 1.75;
color: hsl(var(--foreground));
}
.skeletonPreview {
padding: var(--gap-half);
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: 1.25rem;
margin-bottom: 1.25rem;
/*
&:not(:first-child) {
line-height: 1.75rem;
margin-top: 1.5rem;
} */
}
.markdownPreview pre {
border-radius: 3px;
font-family: "Courier New", Courier, monospace;
@ -69,6 +9,35 @@
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);
}
.markdownPreview a {
color: #0070f3;
}
/* Auto-linked headers */
.markdownPreview h1 a,
.markdownPreview h2 a,
@ -93,49 +62,50 @@
filter: opacity(0.5);
}
.markdownPreview ul {
margin-bottom: 1.5rem;
margin-left: 1.5rem;
list-style-type: disc;
.markdownPreview h1 {
font-size: 2rem;
}
.markdownPreview ol {
margin-bottom: 1.5rem;
margin-left: 1.5rem;
list-style-type: decimal;
.markdownPreview h2 {
font-size: 1.5rem;
}
.markdownPreview h3 {
font-size: 1.25rem;
}
.markdownPreview h4 {
font-size: 1rem;
}
.markdownPreview h5 {
font-size: 1rem;
}
.markdownPreview h6 {
font-size: 0.875rem;
}
.markdownPreview ul {
list-style: inside;
}
.markdownPreview ul li::before {
content: "";
}
.markdownPreview code {
border-radius: 3px;
white-space: pre-wrap;
word-wrap: break-word;
color: inherit !important;
}
.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

@ -0,0 +1,36 @@
import { Tooltip, Button, Spacer } from '@geist-ui/core'
import ChevronUp from '@geist-ui/icons/chevronUpCircleFill'
import { useEffect, useState } from 'react'
import styles from './scroll.module.css'
const ScrollToTop = () => {
const [shouldShow, setShouldShow] = useState(false)
useEffect(() => {
// if user is scrolled, set visible
const handleScroll = () => {
setShouldShow(window.scrollY > 100)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const isReducedMotion = typeof window !== 'undefined' ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false
const onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// blur the button
e.currentTarget.blur()
window.scrollTo({ top: 0, behavior: isReducedMotion ? 'auto' : 'smooth' })
}
return (
<div style={{ display: 'flex', flexDirection: 'row', width: '100%', height: 24, justifyContent: 'flex-end' }}>
<Tooltip hideArrow text="Scroll to Top" className={`${styles['scroll-up']} ${shouldShow ? styles['scroll-up-shown'] : ''}`}>
<Button aria-label='Scroll to Top' onClick={onClick} style={{ background: 'var(--light-gray)' }} auto >
<Spacer height={2 / 3} inline width={0} />
<ChevronUp />
</Button>
</Tooltip>
</div>
)
}
export default ScrollToTop

View file

@ -1,10 +1,3 @@
.root {
display: flex;
flex-direction: row;
width: 100%;
justify-content: flex-end;
}
.scroll-up {
position: fixed;
z-index: 2;

View file

@ -0,0 +1,20 @@
// https://www.joshwcomeau.com/snippets/react-components/shift-by/
type Props = {
x?: number
y?: number
children: React.ReactNode
}
function ShiftBy({ x = 0, y = 0, children }: Props) {
return (
<div
style={{
transform: `translate(${x}px, ${y}px)`,
display: 'inline-block'
}}
>
{children}
</div>
)
}
export default ShiftBy

View file

@ -0,0 +1,49 @@
.card {
margin: var(--gap) auto;
padding: var(--gap);
border: 1px solid var(--light-gray);
border-radius: var(--radius);
}
.descriptionContainer {
display: flex;
flex-direction: column;
min-height: 400px;
overflow: auto;
}
.fileNameContainer {
font-family: var(--font-mono) !important;
border-radius: var(--radius) !important;
margin-bottom: var(--gap-half) !important;
width: 100% !important;
}
.fileNameContainer span {
transition: background-color var(--transition) !important;
border-color: var(--light-gray) !important;
}
.fileNameContainer span:target,
.fileNameContainer span:hover {
background-color: var(--lighter-gray) !important;
}
.fileNameContainer > div {
/* Override geist-ui styling */
margin: 0 !important;
}
.actionWrapper {
position: relative;
z-index: 1;
}
.actionWrapper .actions {
position: absolute;
right: 0;
}
.textarea {
height: 100%;
}

View file

@ -0,0 +1,125 @@
import { memo, useRef, useState } from "react"
import styles from './document.module.css'
import Download from '@geist-ui/icons/download'
import ExternalLink from '@geist-ui/icons/externalLink'
import Skeleton from "react-loading-skeleton"
import { Button, Text, ButtonGroup, Spacer, Tabs, Textarea, Tooltip, Link, Tag } from "@geist-ui/core"
import HtmlPreview from "@components/preview"
import FadeIn from "@components/fade-in"
// import Link from "next/link"
type Props = {
title: string
initialTab?: "edit" | "preview"
skeleton?: boolean
id: string
content: string
}
const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
return (<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}>
<Tooltip hideArrow text="Download">
<a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer">
<Button
scale={2 / 3} px={0.6}
icon={<Download />}
auto
aria-label="Download"
/>
</a>
</Tooltip>
<Tooltip hideArrow text="Open raw in new tab">
<a href={rawLink} target="_blank" rel="noopener noreferrer">
<Button
scale={2 / 3} px={0.6}
icon={<ExternalLink />}
auto
aria-label="Open raw file in new tab"
/>
</a>
</Tooltip>
</ButtonGroup>
</div>)
}
const Document = ({ content, title, initialTab = 'edit', skeleton, id }: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab)
// const height = editable ? "500px" : '100%'
const height = "100%";
const handleTabChange = (newTab: string) => {
if (newTab === 'edit') {
codeEditorRef.current?.focus()
}
setTab(newTab as 'edit' | 'preview')
}
const rawLink = () => {
if (id) {
return `/file/raw/${id}`
}
}
if (skeleton) {
return <>
<Spacer height={1} />
<div className={styles.card}>
<div className={styles.fileNameContainer}>
<Skeleton width={275} height={36} />
</div>
<div className={styles.descriptionContainer}>
<div style={{ flexDirection: 'row', display: 'flex' }}><Skeleton width={125} height={36} /></div>
<Skeleton width={'100%'} height={350} />
</div >
</div>
</>
}
return (
<FadeIn>
<Spacer height={1} />
<div className={styles.card}>
<Link href={`#${title}`} className={styles.fileNameContainer}>
<Tag height={"100%"} id={`${title}`} width={"100%"} style={{ borderRadius: 0 }}>
{title || 'Untitled'}
</Tag>
</Link>
<div className={styles.descriptionContainer}>
<DownloadButton rawLink={rawLink()} />
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
<Tabs.Item label={"Raw"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
<div style={{ marginTop: 'var(--gap-half)', display: 'flex', flexDirection: 'column' }}>
<Textarea
readOnly
ref={codeEditorRef}
value={content}
width="100%"
// TODO: Textarea should grow to fill parent if height == 100%
style={{ flex: 1, minHeight: 350 }}
resize="vertical"
className={styles.textarea}
/>
</div>
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: 'var(--gap-half)', }}>
<HtmlPreview height={height} fileId={id} content={content} title={title} />
</div>
</Tabs.Item>
</Tabs>
</div>
</div>
</FadeIn>
)
}
export default memo(Document)

View file

@ -1,17 +1,3 @@
import { ServerPost } from "./server/prisma"
// Visibilties for the webpages feature
export const ALLOWED_VISIBILITIES_FOR_WEBPAGE = ["public", "unlisted"]
export function isAllowedVisibilityForWebpage(
visibility: ServerPost["visibility"]
) {
return ALLOWED_VISIBILITIES_FOR_WEBPAGE.includes(visibility)
}
export const DEFAULT_THEME = "dark"
export const SIGNED_IN_COOKIE = "next-auth.session-token"
// Code files for uploading with drag and drop and syntax highlighting
export const allowedFileTypes = [
"application/json",
"application/x-javascript",
@ -84,8 +70,6 @@ export const codeFileExtensions = [
"cxx",
"go",
"h",
"m",
"ha",
"hpp",
"htm",
"html",
@ -109,7 +93,6 @@ export const codeFileExtensions = [
"rb",
"rs",
"s",
"sh",
"sass",
"scala",
"scss",
@ -126,7 +109,7 @@ export const codeFileExtensions = [
"xml",
"y",
"yaml",
"fish"
"zig"
]
export const allowedFileExtensions = [

View file

@ -0,0 +1,16 @@
import type { PostVisibility } from "./types"
export default function getPostPath(visibility: PostVisibility, id: string) {
switch (visibility) {
case "private":
return `/post/private/${id}`
case "protected":
return `/post/protected/${id}`
case "unlisted":
case "public":
return `/post/${id}`
default:
console.error(`Unknown visibility: ${visibility}`)
return `/post/${id}`
}
}

View file

@ -1,6 +1,7 @@
// useDebounce.js
import { useState, useEffect } from "react"
export default function useDebounce<T>(value: T, delay: number) {
export default function useDebounce(value: any, delay: number) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {

View file

@ -0,0 +1,11 @@
import useSWR from "swr"
// https://2020.paco.me/blog/shared-hook-state-with-swr
const useSharedState = <T>(key: string, initial?: T) => {
const { data: state, mutate: setState } = useSWR(key, {
fallbackData: initial
})
return [state, setState] as const
}
export default useSharedState

View file

@ -0,0 +1,35 @@
import Cookies from "js-cookie"
import { useRouter } from "next/router"
import { useEffect, useState } from "react"
import useSharedState from "./use-shared-state"
const useSignedIn = () => {
const [signedIn, setSignedIn] = useSharedState(
"signedIn",
typeof window === "undefined" ? false : !!Cookies.get("drift-token")
)
const token = Cookies.get("drift-token")
const router = useRouter()
const signin = (token: string) => {
setSignedIn(true)
Cookies.set("drift-token", token)
}
const signout = () => {
setSignedIn(false)
Cookies.remove("drift-token")
router.push("/")
}
useEffect(() => {
if (token) {
setSignedIn(true)
} else {
setSignedIn(false)
}
}, [setSignedIn, token])
return { signedIn, signin, token, signout }
}
export default useSignedIn

View file

@ -1,6 +1,6 @@
import { useRef, useEffect } from "react"
function useTraceUpdate(props: { [key: string]: unknown }) {
function useTraceUpdate(props: { [key: string]: any }) {
const prev = useRef(props)
useEffect(() => {
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
@ -8,7 +8,7 @@ function useTraceUpdate(props: { [key: string]: unknown }) {
ps[k] = [prev.current[k], v]
}
return ps
}, {} as { [key: string]: unknown })
}, {} as { [key: string]: any })
if (Object.keys(changedProps).length > 0) {
console.log("Changed props:", changedProps)
}

View file

@ -0,0 +1,43 @@
import { User } from "@lib/types"
import Cookies from "js-cookie"
import { useRouter } from "next/router"
import { useEffect, useMemo, useState } from "react"
const useUserData = () => {
const [authToken, setAuthToken] = useState<string>(
Cookies.get("drift-token") || ""
)
const [user, setUser] = useState<User>()
const router = useRouter()
useEffect(() => {
const token = Cookies.get("drift-token")
if (token) {
setAuthToken(token)
}
}, [setAuthToken])
useEffect(() => {
if (authToken) {
const fetchUser = async () => {
const response = await fetch(`/server-api/users/self`, {
headers: {
Authorization: `Bearer ${authToken}`
}
})
if (response.ok) {
const user = await response.json()
setUser(user)
} else {
Cookies.remove("drift-token")
setAuthToken("")
router.push("/")
}
}
fetchUser()
}
}, [authToken, router])
return user
}
export default useUserData

View file

@ -0,0 +1,152 @@
import { marked } from 'marked'
import Highlight, { defaultProps, Language, } from 'prism-react-renderer'
import { renderToStaticMarkup } from 'react-dom/server'
// // image sizes. DDoS Safe?
// const imageSizeLink = /^!?\[((?:\[[^\[\]]*\]|\\[\[\]]?|`[^`]*`|[^\[\]\\])*?)\]\(\s*(<(?:\\[<>]?|[^\s<>\\])*>|(?:\\[()]?|\([^\s\x00-\x1f()\\]*\)|[^\s\x00-\x1f()\\])*?(?:\s+=(?:[\w%]+)?x(?:[\w%]+)?)?)(?:\s+("(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)))?\s*\)/;
// //@ts-ignore
// Lexer.rules.inline.normal.link = imageSizeLink;
// //@ts-ignore
// Lexer.rules.inline.gfm.link = imageSizeLink;
// //@ts-ignore
// Lexer.rules.inline.breaks.link = imageSizeLink;
//@ts-ignore
delete defaultProps.theme
// import linkStyles from '../components/link/link.module.css'
const renderer = new marked.Renderer()
renderer.heading = (text, level, _, slugger) => {
const id = slugger.slug(text)
const Component = `h${level}`
return renderToStaticMarkup(
//@ts-ignore
<Component>
<a href={`#${id}`} id={id} style={{ color: "inherit" }} dangerouslySetInnerHTML={{ __html: (text) }} >
</a>
</Component>
)
}
// renderer.link = (href, _, text) => {
// const isHrefLocal = href?.startsWith('/') || href?.startsWith('#')
// if (isHrefLocal) {
// return renderToStaticMarkup(
// <a href={href || ''}>
// {text}
// </a>
// )
// }
// // dirty hack
// // if text contains elements, render as html
// return <a href={href || ""} target="_blank" rel="noopener noreferrer" dangerouslySetInnerHTML={{ __html: convertHtmlEntities(text) }} ></a>
// }
renderer.image = function (href, _, text) {
return `<Image loading="lazy" src="${href}" alt="${text}" layout="fill" />`
}
renderer.checkbox = () => ''
renderer.listitem = (text, task, checked) => {
if (task) {
return `<li class="reset"><span class="check">&#8203;<input type="checkbox" disabled ${checked ? 'checked' : ''
} /></span><span>${text}</span></li>`
}
return `<li>${text}</li>`
}
renderer.code = (code: string, language: string) => {
return renderToStaticMarkup(
<pre>
{/* {title && <code>{title} </code>} */}
{/* {language && title && <code style={{}}> {language} </code>} */}
<Code
language={language}
// title={title}
code={code}
// highlight={highlight}
/>
</pre>
)
}
marked.setOptions({
gfm: true,
breaks: true,
headerIds: true,
renderer,
})
const markdown = (markdown: string) => marked(markdown)
export default markdown
const Code = ({ code, language, highlight, title, ...props }: {
code: string,
language: string,
highlight?: string,
title?: string,
}) => {
if (!language)
return (
<>
<code {...props} dangerouslySetInnerHTML={{ __html: code }} />
</>
)
const highlightedLines = highlight
//@ts-ignore
? highlight.split(',').reduce((lines, h) => {
if (h.includes('-')) {
// Expand ranges like 3-5 into [3,4,5]
const [start, end] = h.split('-').map(Number)
const x = Array(end - start + 1)
.fill(undefined)
.map((_, i) => i + start)
return [...lines, ...x]
}
return [...lines, Number(h)]
}, [])
: ''
// https://mdxjs.com/guides/syntax-harkedighlighting#all-together
return (
<>
<Highlight {...defaultProps} code={code.trim()} language={language as Language} >
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<code className={className} style={{ ...style }}>
{
tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line, key: i })}
style={
//@ts-ignore
highlightedLines.includes((i + 1).toString())
? {
background: 'var(--highlight)',
margin: '0 -1rem',
padding: '0 1rem',
}
: undefined
}
>
{
line.map((token, key) => (
<span key={key} {...getTokenProps({ token, key })} />
))
}
</div>
))}
</code>
)}
</Highlight>
</>
)
}

View file

@ -10,8 +10,9 @@ const epochs = [
["second", 1]
] as const
// Get duration
const getDuration = (timeAgoInSeconds: number) => {
for (const [name, seconds] of epochs) {
for (let [name, seconds] of epochs) {
const interval = Math.floor(timeAgoInSeconds / seconds)
if (interval >= 1) {

37
client/lib/types.d.ts vendored Normal file
View file

@ -0,0 +1,37 @@
export type PostVisibility = "unlisted" | "private" | "public" | "protected"
export type Document = {
title: string
content: string
id: string
}
export type File = {
id: string
title: string
content: string
html: string
createdAt: string
}
type Files = File[]
export type Post = {
id: string
title: string
description: string
visibility: PostVisibility
files?: Files
createdAt: string
users?: User[]
parent?: Pick<Post, "id" | "title" | "visibility" | "createdAt">
expiresAt: Date | string | null
}
type User = {
id: string
username: string
posts?: Post[]
role: "admin" | "user" | ""
createdAt: string
}

View file

@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

39
client/next.config.mjs Normal file
View file

@ -0,0 +1,39 @@
import dotenv from "dotenv"
import bundleAnalyzer from "@next/bundle-analyzer"
dotenv.config()
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
outputStandalone: true,
esmExternals: true
},
webpack: (config, { dev, isServer }) => {
if (!dev && !isServer) {
Object.assign(config.resolve.alias, {
react: "preact/compat",
"react-dom/test-utils": "preact/test-utils",
"react-dom": "preact/compat"
})
}
return config
},
async rewrites() {
return [
{
source: "/server-api/:path*",
destination: `${process.env.API_URL}/:path*`
},
{
source: "/file/raw/:id",
destination: `/api/raw/:id`
}
]
}
}
export default bundleAnalyzer({ enabled: process.env.ANALYZE === "true" })(
nextConfig
)

74
client/package.json Normal file
View file

@ -0,0 +1,74 @@
{
"name": "drift",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start",
"lint": "next lint && prettier --config .prettierrc '{components,lib,pages}/**/*.ts' --write",
"analyze": "cross-env ANALYZE=true next build",
"find:unused": "next-unused"
},
"dependencies": {
"@geist-ui/core": "^2.3.5",
"@geist-ui/icons": "^1.0.1",
"@types/cookie": "^0.4.1",
"@types/js-cookie": "^3.0.1",
"client-zip": "^2.0.0",
"cookie": "^0.4.2",
"dotenv": "^16.0.0",
"js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8",
"marked": "^4.0.12",
"next": "^12.1.1-canary.15",
"next-themes": "^0.1.1",
"postcss": "^8.4.12",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-hover-media-feature": "^1.0.2",
"postcss-preset-env": "^7.4.3",
"preact": "^10.6.6",
"prism-react-renderer": "^1.3.1",
"react": "17.0.2",
"react-datepicker": "^4.7.0",
"react-dom": "17.0.2",
"react-dropzone": "^12.0.4",
"react-loading-skeleton": "^3.0.3",
"react-markdown": "^8.0.0",
"react-syntax-highlighter": "^15.4.5",
"rehype-autolink-headings": "^6.1.1",
"rehype-raw": "^6.1.1",
"rehype-slug": "^5.0.1",
"remark-gfm": "^3.0.1",
"swr": "^1.2.2"
},
"devDependencies": {
"@next/bundle-analyzer": "^12.1.0",
"@types/lodash.debounce": "^4.0.6",
"@types/marked": "^4.0.3",
"@types/node": "17.0.21",
"@types/nprogress": "^0.2.0",
"@types/react": "17.0.39",
"@types/react-datepicker": "^4.3.4",
"@types/react-dom": "^17.0.14",
"@types/react-syntax-highlighter": "^13.5.2",
"cross-env": "^7.0.3",
"eslint": "8.10.0",
"eslint-config-next": "^12.1.1-canary.16",
"next-unused": "^0.0.6",
"prettier": "^2.6.0",
"typescript": "4.6.2",
"typescript-plugin-css-modules": "^3.4.0"
},
"next-unused": {
"alias": {
"@components": "components/",
"@lib": "lib/",
"@styles": "styles/"
},
"include": [
"components",
"lib"
]
}
}

41
client/pages/_app.tsx Normal file
View file

@ -0,0 +1,41 @@
import '@styles/globals.css'
import type { AppProps as NextAppProps } from "next/app";
import 'react-loading-skeleton/dist/skeleton.css'
import { SkeletonTheme } from 'react-loading-skeleton';
import Head from 'next/head';
import { CssBaseline, GeistProvider, Themes } from '@geist-ui/core';
import { useTheme, ThemeProvider } from 'next-themes'
import { useEffect } from 'react';
import App from '@components/app';
type AppProps<P = any> = {
pageProps: P;
} & Omit<NextAppProps<P>, "pageProps">;
function MyApp({ Component, pageProps }: AppProps) {
return (
<div>
<Head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/assets/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="apple-mobile-web-app-title" content="Drift" />
<meta name="application-name" content="Drift" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#ffffff" />
<title>Drift</title>
</Head>
<ThemeProvider defaultTheme="system" disableTransitionOnChange>
<App Component={Component} pageProps={pageProps} />
</ThemeProvider>
</div>
)
}
export default MyApp

View file

@ -0,0 +1,31 @@
import { CssBaseline } from '@geist-ui/core'
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const initialProps = await Document.getInitialProps(ctx)
const styles = CssBaseline.flush()
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{styles}
</>
)
}
}
render() {
return (<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>)
}
}
export default MyDocument

View file

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
const PUBLIC_FILE = /.(.*)$/
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname
const signedIn = req.cookies['drift-token']
const getURL = (pageName: string) => new URL(`/${pageName}`, req.url).href
const isPageRequest =
!PUBLIC_FILE.test(req.nextUrl.pathname) &&
!req.nextUrl.pathname.startsWith('/api') &&
// header added when next/link pre-fetches a route
!req.headers.get('x-middleware-preflight')
if (isPageRequest) {
if (pathname === '/signout') {
// If you're signed in we remove the cookie and redirect to the home page
// If you're not signed in we redirect to the home page
if (signedIn) {
const resp = NextResponse.redirect(getURL(''));
resp.clearCookie('drift-token');
resp.clearCookie('drift-userid');
return resp
}
} else if (pathname === '/') {
if (signedIn) {
return NextResponse.redirect(getURL('new'))
}
// If you're not signed in we redirect the new post page to the home page
} else if (pathname === '/new') {
if (!signedIn) {
return NextResponse.redirect(getURL('signin'))
}
// If you're signed in we redirect the sign in page to the home page (which is the new page)
} else if (pathname === '/signin' || pathname === '/signup') {
if (signedIn) {
return NextResponse.redirect(getURL(''))
}
}
}
return NextResponse.next()
}

58
client/pages/admin.tsx Normal file
View file

@ -0,0 +1,58 @@
import styles from '@styles/Home.module.css'
import Header from '@components/header'
import { Page } from '@geist-ui/core';
import { useEffect } from 'react';
import Admin from '@components/admin';
import useSignedIn from '@lib/hooks/use-signed-in';
import { useRouter } from 'next/router';
import { GetServerSideProps } from 'next';
import cookie from "cookie";
const AdminPage = () => {
const { signedIn } = useSignedIn()
const router = useRouter()
useEffect(() => {
if (typeof window === 'undefined') return
if (!signedIn) {
router.push('/')
}
}, [router, signedIn])
return (
<Page className={styles.wrapper}>
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={styles.main}>
<Admin />
</Page.Content>
</Page>
)
}
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`]
const res = await fetch(`${process.env.API_URL}/admin/is-admin`, {
headers: {
'Authorization': `Bearer ${driftToken}`,
'x-secret-key': process.env.SECRET_KEY || ''
}
})
if (res.ok) {
return {
props: {
signedIn: true
}
}
} else {
return {
redirect: {
destination: '/',
permanent: false
}
}
}
}
export default AdminPage

View file

@ -0,0 +1,24 @@
import { NextApiRequest, NextApiResponse } from "next"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
const { id } = req.query
const file = await fetch(`${process.env.API_URL}/files/html/${id}`, {
headers: {
"x-secret-key": process.env.SECRET_KEY || "",
Authorization: `Bearer ${req.cookies["drift-token"]}`
}
})
if (file.ok) {
const json = await file.text()
const data = json
// serve the file raw as plain text
res.setHeader("Content-Type", "text/plain; charset=utf-8")
res.setHeader("Cache-Control", "s-maxage=86400")
res.status(200).write(data, "utf-8")
res.end()
} else {
res.status(404).send("File not found")
}
}
export default getRawFile

View file

@ -0,0 +1,57 @@
import type { NextApiHandler } from "next"
import markdown from "@lib/render-markdown"
const renderMarkdown: NextApiHandler = async (req, res) => {
const { id } = req.query
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`, {
headers: {
Accept: "text/plain",
"x-secret-key": process.env.SECRET_KEY || "",
Authorization: `Bearer ${req.cookies["drift-token"]}`
}
})
if (file.status !== 200) {
return res.status(404).json({ error: "File not found" })
}
const json = await file.json()
const { content, title } = json
const renderAsMarkdown = [
"markdown",
"md",
"mdown",
"mkdn",
"mkd",
"mdwn",
"mdtxt",
"mdtext",
"text",
""
]
const fileType = () => {
const pathParts = title.split(".")
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : ""
return language
}
const type = fileType()
let contentToRender: string = "\n" + content
if (!renderAsMarkdown.includes(type)) {
contentToRender = `~~~${type}
${content}
~~~`
}
if (typeof contentToRender !== "string") {
res.status(400).send("content must be a string")
return
}
res.setHeader("Content-Type", "text/plain")
res.setHeader("Cache-Control", "public, max-age=4800")
res.status(200).write(markdown(contentToRender))
res.end()
}
export default renderMarkdown

View file

@ -0,0 +1,34 @@
import { NextApiRequest, NextApiResponse } from "next"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
const { id, download } = req.query
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`, {
headers: {
Accept: "text/plain",
"x-secret-key": process.env.SECRET_KEY || "",
Authorization: `Bearer ${req.cookies["drift-token"]}`
}
})
res.setHeader("Content-Type", "text/plain; charset=utf-8")
res.setHeader("Cache-Control", "s-maxage=86400")
if (file.ok) {
const json = await file.json()
const data = json
const { title, content } = data
// serve the file raw as plain text
if (download) {
res.setHeader("Content-Disposition", `attachment; filename="${title}"`)
} else {
res.setHeader("Content-Disposition", `inline; filename="${title}"`)
}
res.status(200).write(content, "utf-8")
res.end()
} else {
res.status(404).send("File not found")
}
}
export default getRawFile

View file

@ -1,14 +1,9 @@
import markdown from "@wcj/markdown-to-html"
/**
* returns rendered HTML from a Drift file
*/
export async function getHtmlFromFile({
content,
title
}: {
content: string
title: string
}) {
import type { NextApiHandler } from "next"
import markdown from "@lib/render-markdown"
const renderMarkdown: NextApiHandler = async (req, res) => {
const { content, title } = req.body
const renderAsMarkdown = [
"markdown",
"md",
@ -29,17 +24,19 @@ export async function getHtmlFromFile({
const type = fileType()
let contentToRender: string = content || ""
if (!renderAsMarkdown.includes(type)) {
contentToRender = `
~~~${type}
contentToRender = `~~~${type}
${content}
~~~
`
~~~`
} else {
contentToRender = "\n" + content
}
const html = markdown(contentToRender, {
showLineNumbers: false
})
return html
if (typeof contentToRender !== "string") {
res.status(400).send("content must be a string")
return
}
res.status(200).write(markdown(contentToRender))
res.end()
}
export default renderMarkdown

19
client/pages/expired.tsx Normal file
View file

@ -0,0 +1,19 @@
import Header from "@components/header"
import { Note, Page, Text } from "@geist-ui/core"
import styles from '@styles/Home.module.css'
const Expired = () => {
return (
<Page>
<Header />
<Page.Content className={styles.main}>
<Note type="error" label={false}>
<Text h4>Error: The Drift you&apos;re trying to view has expired.</Text>
</Note>
</Page.Content>
</Page>
)
}
export default Expired

47
client/pages/index.tsx Normal file
View file

@ -0,0 +1,47 @@
import styles from '@styles/Home.module.css'
import Header from '@components/header'
import PageSeo from '@components/page-seo'
import HomeComponent from '@components/home'
import { Page, Text, Spacer, Tabs, Textarea, Card } from '@geist-ui/core'
export async function getStaticProps() {
const resp = await fetch(process.env.API_URL + `/welcome`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"x-secret-key": process.env.SECRET_KEY || ''
}
})
const { title, content, rendered } = await resp.json()
return {
props: {
introContent: content || null,
rendered: rendered || null,
introTitle: title || null,
}
}
}
type Props = {
introContent: string
introTitle: string
rendered: string
}
const Home = ({ rendered, introContent, introTitle }: Props) => {
return (
<Page className={styles.wrapper}>
<PageSeo />
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={styles.main}>
<HomeComponent rendered={rendered} introContent={introContent} introTitle={introTitle} />
</Page.Content>
</Page>
)
}
export default Home

63
client/pages/mine.tsx Normal file
View file

@ -0,0 +1,63 @@
import styles from '@styles/Home.module.css'
import Header from '@components/header'
import MyPosts from '@components/my-posts'
import cookie from "cookie";
import type { GetServerSideProps } from 'next';
import { Post } from '@lib/types';
import { Page } from '@geist-ui/core';
const Home = ({ morePosts, posts, error }: { morePosts: boolean, posts: Post[]; error: boolean; }) => {
return (
<Page className={styles.wrapper}>
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={styles.main}>
<MyPosts morePosts={morePosts} error={error} posts={posts} />
</Page.Content>
</Page >
)
}
// get server side props
export const getServerSideProps: GetServerSideProps = async ({ req }) => {
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`]
if (!driftToken) {
return {
redirect: {
destination: '/',
permanent: false,
}
}
}
const posts = await fetch(process.env.API_URL + `/posts/mine`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || ''
}
})
if (!posts.ok) {
console.error(await posts.json())
return {
redirect: {
destination: '/',
permanent: false,
}
}
}
const data = await posts.json()
return {
props: {
posts: data.posts,
error: posts.status !== 200,
morePosts: data.hasMore,
}
}
}
export default Home

View file

@ -0,0 +1,81 @@
import styles from '@styles/Home.module.css'
import NewPost from '@components/new-post'
import Header from '@components/header'
import PageSeo from '@components/page-seo'
import { Page } from '@geist-ui/core'
import Head from 'next/head'
import { GetServerSideProps } from 'next'
import { Post } from '@lib/types'
import cookie from 'cookie'
const NewFromExisting = ({
post,
parentId
}: {
post: Post,
parentId: string
}) => {
console.log(parentId, post)
return (
<Page className={styles.wrapper}>
<PageSeo title="Create a new Drift" />
<Head>
{/* TODO: solve this. */}
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/css/react-datepicker.css" />
</Head>
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={styles.main}>
<NewPost initialPost={post} newPostParent={parentId} />
</Page.Content>
</Page >
)
}
export const getServerSideProps: GetServerSideProps = async ({ req, params }) => {
const id = params?.id
const redirect = {
redirect: {
destination: '/new',
permanent: false,
}
}
if (!id) {
return redirect
}
const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`]
const post = await fetch(`${process.env.API_URL}/posts/${id}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || ""
}
})
if (!post.ok) {
return redirect
}
const data = await post.json()
if (!data) {
return redirect
}
return {
props: {
post: data,
parentId: id
}
}
}
export default NewFromExisting

View file

@ -0,0 +1,28 @@
import styles from '@styles/Home.module.css'
import NewPost from '@components/new-post'
import Header from '@components/header'
import PageSeo from '@components/page-seo'
import { Page } from '@geist-ui/core'
import Head from 'next/head'
const New = () => {
return (
<Page className={styles.wrapper}>
<PageSeo title="Create a new Drift" />
<Head>
{/* TODO: solve this. */}
{/* eslint-disable-next-line @next/next/no-css-tags */}
<link rel="stylesheet" href="/css/react-datepicker.css" />
</Head>
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={styles.main}>
<NewPost />
</Page.Content>
</Page >
)
}
export default New

View file

@ -0,0 +1,60 @@
import type { GetStaticPaths, GetStaticProps } from "next";
import type { Post } from "@lib/types";
import PostPage from "@components/post-page";
export type PostProps = {
post: Post
}
const PostView = ({ post }: PostProps) => {
return <PostPage post={post} />
}
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await fetch(process.env.API_URL + `/posts/`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-secret-key": process.env.SECRET_KEY || "",
}
})
const json = await posts.json()
const filtered = json.filter((post: Post) => post.visibility === "public" || post.visibility === "unlisted")
const paths = filtered.map((post: Post) => ({
params: { id: post.id }
}))
return { paths, fallback: 'blocking' }
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"x-secret-key": process.env.SECRET_KEY || "",
}
})
if (!post.ok) {
return {
redirect: {
destination: "/404",
},
props: {
post: null
}
}
}
return {
props: {
post: await post.json()
},
}
}
export default PostView

View file

@ -0,0 +1,58 @@
import cookie from "cookie";
import type { GetServerSideProps } from "next";
import { Post } from "@lib/types";
import PostPage from "@components/post-page";
export type PostProps = {
post: Post
}
const Post = ({ post, }: PostProps) => {
return (<PostPage post={post} />)
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const headers = context.req.headers
const host = headers.host
const driftToken = cookie.parse(headers.cookie || '')[`drift-token`]
if (context.query.id) {
const post = await fetch('http://' + host + `/server-api/posts/${context.query.id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${driftToken}`,
"x-secret-key": process.env.SECRET_KEY || "",
}
})
if (!post.ok || post.status !== 200) {
return {
redirect: {
destination: '/',
permanent: false,
},
}
}
try {
const json = await post.json();
return {
props: {
post: json
}
}
} catch (e) {
console.log(e)
}
}
return {
props: {
post: null
}
}
}
export default Post

View file

@ -0,0 +1,83 @@
import { Page, useToasts } from '@geist-ui/core';
import type { Post } from "@lib/types";
import PasswordModal from "@components/new-post/password-modal";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import Cookies from "js-cookie";
import PostPage from "@components/post-page";
const Post = () => {
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true);
const [post, setPost] = useState<Post>()
const router = useRouter()
const { setToast } = useToasts()
useEffect(() => {
if (router.isReady) {
const fetchPostWithAuth = async () => {
const resp = await fetch(`/server-api/posts/${router.query.id}`, {
headers: {
Authorization: `Bearer ${Cookies.get('drift-token')}`
}
})
if (!resp.ok) return
const post = await resp.json()
if (!post) return
setPost(post)
}
fetchPostWithAuth()
}
}, [router.isReady, router.query.id])
const onSubmit = async (password: string) => {
const res = await fetch(`/server-api/posts/${router.query.id}?password=${password}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
}
})
if (!res.ok) {
setToast({
type: "error",
text: "Wrong password"
})
return
}
const data = await res.json()
if (data) {
if (data.error) {
setToast({
text: data.error,
type: "error"
})
} else {
setPost(data)
setIsPasswordModalOpen(false)
}
}
}
const onClose = () => {
setIsPasswordModalOpen(false);
router.push("/");
}
if (!router.isReady) {
return <></>
}
if (!post) {
return <Page>
<PasswordModal creating={false} onClose={onClose} onSubmit={onSubmit} isOpen={isPasswordModalOpen} />
</Page>
}
return (<PostPage post={post} />)
}
export default Post

19
client/pages/signin.tsx Normal file
View file

@ -0,0 +1,19 @@
import { Page } from '@geist-ui/core';
import PageSeo from "@components/page-seo";
import Auth from "@components/auth";
import Header from "@components/header/header";
import styles from '@styles/Home.module.css'
const SignIn = () => (
<Page width={"100%"}>
<PageSeo title="Drift - Sign In" />
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={styles.main}>
<Auth page="signin" />
</Page.Content>
</Page>
)
export default SignIn

20
client/pages/signup.tsx Normal file
View file

@ -0,0 +1,20 @@
import { Page } from '@geist-ui/core';
import Auth from "@components/auth";
import Header from "@components/header/header";
import PageSeo from '@components/page-seo';
import styles from '@styles/Home.module.css'
const SignUp = () => (
<Page width="100%">
<PageSeo title="Drift - Sign Up" />
<Page.Header>
<Header />
</Page.Header>
<Page.Content className={styles.main}>
<Auth page="signup" />
</Page.Content>
</Page>
)
export default SignUp

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