Compare commits
No commits in common. "refactor" and "dupePosts" have entirely different histories.
354 changed files with 15023 additions and 20542 deletions
35
.env.default
35
.env.default
|
@ -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=
|
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
26
.github/workflows/server-CI.yaml
vendored
26
.github/workflows/server-CI.yaml
vendored
|
@ -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
36
.gitignore
vendored
|
@ -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
|
.vercel
|
||||||
|
drift.sqlite
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
@ -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
145
README.md
|
@ -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.maxleiter.com. The demo is built on master but has no database, so files and accounts can be wiped at any time.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
|
If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User).
|
||||||
|
|
||||||
Drift is built with Next.js 13, React Server Components, [shadcn/ui](https://github.com/shadcn/ui), and [Prisma](https://prisma.io/).
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
**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
|
## Setup
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
In the root directory, run `pnpm i`. If you need `pnpm`, you can download it [here](https://pnpm.io/installation).
|
In both `server` and `client`, run `yarn` (if you need yarn, you can download it [here](https://yarnpkg.com/).)
|
||||||
You can run `pnpm dev` in `client` for file watching and live reloading.
|
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
|
### 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
|
### Environment Variables
|
||||||
|
|
||||||
You can change these to your liking.
|
You can change these to your liking.
|
||||||
|
|
||||||
`.env`:
|
`client/.env`:
|
||||||
|
|
||||||
- `DRIFT_URL`: the URL of the drift instance.
|
- `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
|
||||||
- `DATABASE_URL`: the URL to connect to your postgres instance. For example, `postgresql://user:password@localhost:5432/drift`.
|
- `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_CONTENT`: a markdown string that's rendered on the home page
|
||||||
- `WELCOME_TITLE`: the file title for the post on the homepage.
|
- `WELCOME_TITLE`: the file title for the post on the homepage.
|
||||||
- `ENABLE_ADMIN`: the first account created is an administrator account
|
- `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.
|
- `DRIFT_HOME`: defaults to ~/.drift, the directory for storing the database and eventually images
|
||||||
- `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`.
|
|
||||||
|
|
||||||
## Running with pm2
|
## Running with pm2
|
||||||
|
|
||||||
It's easy to start Drift using [pm2](https://pm2.keymetrics.io/).
|
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
|
## 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, unlisted posts
|
||||||
- [x] creating and sharing private, public, password-protected, and unlisted posts
|
- [x] syntax highlighting (detected by file extension)
|
||||||
- [x] syntax highlighting
|
- [x] multiple files per post
|
||||||
- [x] expiring posts
|
- [x] uploading files via drag-and-drop
|
||||||
- [x] responsive UI
|
- [x] responsive UI
|
||||||
- [x] user auth
|
- [x] user auth
|
||||||
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
|
- [ ] 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] downloading files (individually and entire posts)
|
||||||
- [x] password protected posts
|
- [x] password protected posts
|
||||||
- [x] postgres database
|
- [x] sqlite database
|
||||||
- [x] administrator account / settings
|
- [ ] administrator account / settings
|
||||||
- [x] docker-compose (PRs: [#13](https://github.com/MaxLeiter/Drift/pull/13), [#75](https://github.com/MaxLeiter/Drift/pull/75))
|
- [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13))
|
||||||
- [ ] publish docker builds
|
- [ ] publish docker builds
|
||||||
- [ ] user settings
|
- [ ] user settings
|
||||||
- [ ] works enough with JavaScript disabled
|
- [ ] 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?
|
- [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
2
client/.env.local
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
API_URL=http://localhost:3000
|
||||||
|
SECRET_KEY=secret
|
3
client/.eslintrc.json
Normal file
3
client/.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
35
client/.gitignore
vendored
Normal file
35
client/.gitignore
vendored
Normal 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
|
|
@ -3,6 +3,5 @@
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"useTabs": true,
|
"useTabs": true
|
||||||
"plugins": ["prettier-plugin-tailwindcss"]
|
|
||||||
}
|
}
|
58
client/Dockerfile
Normal file
58
client/Dockerfile
Normal 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
34
client/README.md
Normal 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.
|
12
client/components/Link.tsx
Normal file
12
client/components/Link.tsx
Normal 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
|
25
client/components/admin/admin.module.css
Normal file
25
client/components/admin/admin.module.css
Normal 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;
|
||||||
|
}
|
100
client/components/admin/index.tsx
Normal file
100
client/components/admin/index.tsx
Normal 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
|
51
client/components/admin/post-modal-link.tsx
Normal file
51
client/components/admin/post-modal-link.tsx
Normal 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
|
61
client/components/app/index.tsx
Normal file
61
client/components/app/index.tsx
Normal 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
|
22
client/components/auth/auth.module.css
Normal file
22
client/components/auth/auth.module.css
Normal 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;
|
||||||
|
}
|
131
client/components/auth/index.tsx
Normal file
131
client/components/auth/index.tsx
Normal 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'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
|
22
client/components/badges/created-ago-badge/index.tsx
Normal file
22
client/components/badges/created-ago-badge/index.tsx
Normal 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
|
60
client/components/badges/expiration-badge/index.tsx
Normal file
60
client/components/badges/expiration-badge/index.tsx
Normal 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
|
23
client/components/badges/visibility-badge/index.tsx
Normal file
23
client/components/badges/visibility-badge/index.tsx
Normal 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
|
26
client/components/button-dropdown/dropdown.module.css
Normal file
26
client/components/button-dropdown/dropdown.module.css
Normal 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;
|
||||||
|
}
|
116
client/components/button-dropdown/index.tsx
Normal file
116
client/components/button-dropdown/index.tsx
Normal 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
|
40
client/components/button/button.module.css
Normal file
40
client/components/button/button.module.css
Normal 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);
|
||||||
|
}
|
28
client/components/button/index.tsx
Normal file
28
client/components/button/index.tsx
Normal 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
|
35
client/components/edit-document-list/index.tsx
Normal file
35
client/components/edit-document-list/index.tsx
Normal 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)
|
40
client/components/edit-document/document.module.css
Normal file
40
client/components/edit-document/document.module.css
Normal 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;
|
||||||
|
}
|
131
client/components/edit-document/formatting-icons/index.tsx
Normal file
131
client/components/edit-document/formatting-icons/index.tsx
Normal 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
|
118
client/components/edit-document/index.tsx
Normal file
118
client/components/edit-document/index.tsx
Normal 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)
|
15
client/components/fade-in/fade.module.css
Normal file
15
client/components/fade-in/fade.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
30
client/components/fade-in/index.tsx
Normal file
30
client/components/fade-in/index.tsx
Normal 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
|
72
client/components/file-dropdown/dropdown.module.css
Normal file
72
client/components/file-dropdown/dropdown.module.css
Normal 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) {
|
||||||
|
}
|
83
client/components/file-dropdown/index.tsx
Normal file
83
client/components/file-dropdown/index.tsx
Normal 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
|
109
client/components/file-tree/file-tree.module.css
Normal file
109
client/components/file-tree/file-tree.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
61
client/components/file-tree/index.tsx
Normal file
61
client/components/file-tree/index.tsx
Normal 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
|
27
client/components/head/index.tsx
Normal file
27
client/components/head/index.tsx
Normal 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;
|
46
client/components/header/controls.tsx
Normal file
46
client/components/header/controls.tsx
Normal 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);
|
43
client/components/header/header.module.css
Normal file
43
client/components/header/header.module.css
Normal 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;
|
||||||
|
}
|
197
client/components/header/header.tsx
Normal file
197
client/components/header/header.tsx
Normal 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
|
10
client/components/header/index.tsx
Normal file
10
client/components/header/index.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
const Header = dynamic(import('./header'), {
|
||||||
|
// ssr: false,
|
||||||
|
// loading: () => <MenuSkeleton />,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default Header
|
3
client/components/home/home.module.css
Normal file
3
client/components/home/home.module.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.textarea {
|
||||||
|
height: 100%;
|
||||||
|
}
|
43
client/components/home/index.tsx
Normal file
43
client/components/home/index.tsx
Normal 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
|
24
client/components/input/index.tsx
Normal file
24
client/components/input/index.tsx
Normal 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
|
57
client/components/input/input.module.css
Normal file
57
client/components/input/input.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
13
client/components/my-posts/index.tsx
Normal file
13
client/components/my-posts/index.tsx
Normal 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
|
|
@ -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);
|
||||||
|
}
|
89
client/components/new-post/drag-and-drop/index.tsx
Normal file
89
client/components/new-post/drag-and-drop/index.tsx
Normal 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)
|
278
client/components/new-post/index.tsx
Normal file
278
client/components/new-post/index.tsx
Normal 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
|
54
client/components/new-post/password-modal/index.tsx
Normal file
54
client/components/new-post/password-modal/index.tsx
Normal 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'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
|
|
@ -1,15 +1,9 @@
|
||||||
.root {
|
|
||||||
padding-bottom: 200px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--gap-half);
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-top: var(--gap-double);
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,12 +13,24 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.datePicker {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.title {
|
||||||
width: 100%;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 650px) {
|
@media screen and (max-width: 650px) {
|
||||||
|
.title {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -34,7 +40,7 @@
|
||||||
|
|
||||||
.buttons .rightButtons {
|
.buttons .rightButtons {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons .rightButtons > * {
|
.buttons .rightButtons > * {
|
45
client/components/new-post/title/index.tsx
Normal file
45
client/components/new-post/title/index.tsx
Normal 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)
|
27
client/components/page-seo/index.tsx
Normal file
27
client/components/page-seo/index.tsx
Normal 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;
|
138
client/components/post-list/index.tsx
Normal file
138
client/components/post-list/index.tsx
Normal 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
|
21
client/components/post-list/list-item-skeleton.tsx
Normal file
21
client/components/post-list/list-item-skeleton.tsx
Normal 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
|
30
client/components/post-list/list-item.module.css
Normal file
30
client/components/post-list/list-item.module.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
74
client/components/post-list/list-item.tsx
Normal file
74
client/components/post-list/list-item.tsx
Normal 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
|
34
client/components/post-list/post-list.module.css
Normal file
34
client/components/post-list/post-list.module.css
Normal 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);
|
||||||
|
}
|
146
client/components/post-page/index.tsx
Normal file
146
client/components/post-page/index.tsx
Normal 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
|
51
client/components/post-page/post-page.module.css
Normal file
51
client/components/post-page/post-page.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
55
client/components/preview/index.tsx
Normal file
55
client/components/preview/index.tsx
Normal 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)
|
|
@ -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 {
|
.markdownPreview pre {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: "Courier New", Courier, monospace;
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
@ -69,6 +9,35 @@
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdownPreview h1,
|
||||||
|
.markdownPreview h2,
|
||||||
|
.markdownPreview h3,
|
||||||
|
.markdownPreview h4,
|
||||||
|
.markdownPreview h5,
|
||||||
|
.markdownPreview h6 {
|
||||||
|
margin-top: var(--gap);
|
||||||
|
margin-bottom: var(--gap-half);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h1 {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h2 {
|
||||||
|
color: var(--darkest-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h3,
|
||||||
|
.markdownPreview h4,
|
||||||
|
.markdownPreview h5,
|
||||||
|
.markdownPreview h6 {
|
||||||
|
color: var(--darker-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview a {
|
||||||
|
color: #0070f3;
|
||||||
|
}
|
||||||
|
|
||||||
/* Auto-linked headers */
|
/* Auto-linked headers */
|
||||||
.markdownPreview h1 a,
|
.markdownPreview h1 a,
|
||||||
.markdownPreview h2 a,
|
.markdownPreview h2 a,
|
||||||
|
@ -93,49 +62,50 @@
|
||||||
filter: opacity(0.5);
|
filter: opacity(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdownPreview ul {
|
.markdownPreview h1 {
|
||||||
margin-bottom: 1.5rem;
|
font-size: 2rem;
|
||||||
margin-left: 1.5rem;
|
|
||||||
list-style-type: disc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdownPreview ol {
|
.markdownPreview h2 {
|
||||||
margin-bottom: 1.5rem;
|
font-size: 1.5rem;
|
||||||
margin-left: 1.5rem;
|
}
|
||||||
list-style-type: decimal;
|
|
||||||
|
.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 {
|
.markdownPreview ul li::before {
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdownPreview code {
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
.markdownPreview code::before,
|
.markdownPreview code::before,
|
||||||
.markdownPreview code::after {
|
.markdownPreview code::after {
|
||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdownPreview blockquote {
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
font-style: italic;
|
|
||||||
border-left-width: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview blockquote p {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview table {
|
|
||||||
overflow-y: auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdownPreview table th {
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
border-top-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 800px) {
|
@media screen and (max-width: 800px) {
|
||||||
.markdownPreview h1 a::after,
|
.markdownPreview h1 a::after,
|
||||||
.markdownPreview h2 a::after,
|
.markdownPreview h2 a::after,
|
36
client/components/scroll-to-top/index.tsx
Normal file
36
client/components/scroll-to-top/index.tsx
Normal 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
|
|
@ -1,10 +1,3 @@
|
||||||
.root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scroll-up {
|
.scroll-up {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 2;
|
z-index: 2;
|
20
client/components/shift-by.tsx
Normal file
20
client/components/shift-by.tsx
Normal 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
|
49
client/components/view-document/document.module.css
Normal file
49
client/components/view-document/document.module.css
Normal 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%;
|
||||||
|
}
|
125
client/components/view-document/index.tsx
Normal file
125
client/components/view-document/index.tsx
Normal 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)
|
|
@ -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 = [
|
export const allowedFileTypes = [
|
||||||
"application/json",
|
"application/json",
|
||||||
"application/x-javascript",
|
"application/x-javascript",
|
||||||
|
@ -84,8 +70,6 @@ export const codeFileExtensions = [
|
||||||
"cxx",
|
"cxx",
|
||||||
"go",
|
"go",
|
||||||
"h",
|
"h",
|
||||||
"m",
|
|
||||||
"ha",
|
|
||||||
"hpp",
|
"hpp",
|
||||||
"htm",
|
"htm",
|
||||||
"html",
|
"html",
|
||||||
|
@ -109,7 +93,6 @@ export const codeFileExtensions = [
|
||||||
"rb",
|
"rb",
|
||||||
"rs",
|
"rs",
|
||||||
"s",
|
"s",
|
||||||
"sh",
|
|
||||||
"sass",
|
"sass",
|
||||||
"scala",
|
"scala",
|
||||||
"scss",
|
"scss",
|
||||||
|
@ -126,7 +109,7 @@ export const codeFileExtensions = [
|
||||||
"xml",
|
"xml",
|
||||||
"y",
|
"y",
|
||||||
"yaml",
|
"yaml",
|
||||||
"fish"
|
"zig"
|
||||||
]
|
]
|
||||||
|
|
||||||
export const allowedFileExtensions = [
|
export const allowedFileExtensions = [
|
16
client/lib/get-post-path.ts
Normal file
16
client/lib/get-post-path.ts
Normal 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}`
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
|
// useDebounce.js
|
||||||
import { useState, useEffect } from "react"
|
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)
|
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
11
client/lib/hooks/use-shared-state.ts
Normal file
11
client/lib/hooks/use-shared-state.ts
Normal 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
|
35
client/lib/hooks/use-signed-in.ts
Normal file
35
client/lib/hooks/use-signed-in.ts
Normal 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
|
|
@ -1,6 +1,6 @@
|
||||||
import { useRef, useEffect } from "react"
|
import { useRef, useEffect } from "react"
|
||||||
|
|
||||||
function useTraceUpdate(props: { [key: string]: unknown }) {
|
function useTraceUpdate(props: { [key: string]: any }) {
|
||||||
const prev = useRef(props)
|
const prev = useRef(props)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
|
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]
|
ps[k] = [prev.current[k], v]
|
||||||
}
|
}
|
||||||
return ps
|
return ps
|
||||||
}, {} as { [key: string]: unknown })
|
}, {} as { [key: string]: any })
|
||||||
if (Object.keys(changedProps).length > 0) {
|
if (Object.keys(changedProps).length > 0) {
|
||||||
console.log("Changed props:", changedProps)
|
console.log("Changed props:", changedProps)
|
||||||
}
|
}
|
43
client/lib/hooks/use-user-data.ts
Normal file
43
client/lib/hooks/use-user-data.ts
Normal 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
|
152
client/lib/render-markdown.tsx
Normal file
152
client/lib/render-markdown.tsx
Normal 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">​<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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -10,8 +10,9 @@ const epochs = [
|
||||||
["second", 1]
|
["second", 1]
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
// Get duration
|
||||||
const getDuration = (timeAgoInSeconds: number) => {
|
const getDuration = (timeAgoInSeconds: number) => {
|
||||||
for (const [name, seconds] of epochs) {
|
for (let [name, seconds] of epochs) {
|
||||||
const interval = Math.floor(timeAgoInSeconds / seconds)
|
const interval = Math.floor(timeAgoInSeconds / seconds)
|
||||||
|
|
||||||
if (interval >= 1) {
|
if (interval >= 1) {
|
37
client/lib/types.d.ts
vendored
Normal file
37
client/lib/types.d.ts
vendored
Normal 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
|
||||||
|
}
|
1
next-env.d.ts → client/next-env.d.ts
vendored
1
next-env.d.ts → client/next-env.d.ts
vendored
|
@ -1,6 +1,5 @@
|
||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference types="next/navigation-types/compat/navigation" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
39
client/next.config.mjs
Normal file
39
client/next.config.mjs
Normal 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
74
client/package.json
Normal 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
41
client/pages/_app.tsx
Normal 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
|
31
client/pages/_document.tsx
Normal file
31
client/pages/_document.tsx
Normal 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
|
43
client/pages/_middleware.tsx
Normal file
43
client/pages/_middleware.tsx
Normal 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
58
client/pages/admin.tsx
Normal 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
|
24
client/pages/api/html/[id].ts
Normal file
24
client/pages/api/html/[id].ts
Normal 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
|
57
client/pages/api/markdown/[id].ts
Normal file
57
client/pages/api/markdown/[id].ts
Normal 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
|
34
client/pages/api/raw/[id].ts
Normal file
34
client/pages/api/raw/[id].ts
Normal 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
|
|
@ -1,14 +1,9 @@
|
||||||
import markdown from "@wcj/markdown-to-html"
|
import type { NextApiHandler } from "next"
|
||||||
/**
|
|
||||||
* returns rendered HTML from a Drift file
|
import markdown from "@lib/render-markdown"
|
||||||
*/
|
|
||||||
export async function getHtmlFromFile({
|
const renderMarkdown: NextApiHandler = async (req, res) => {
|
||||||
content,
|
const { content, title } = req.body
|
||||||
title
|
|
||||||
}: {
|
|
||||||
content: string
|
|
||||||
title: string
|
|
||||||
}) {
|
|
||||||
const renderAsMarkdown = [
|
const renderAsMarkdown = [
|
||||||
"markdown",
|
"markdown",
|
||||||
"md",
|
"md",
|
||||||
|
@ -29,17 +24,19 @@ export async function getHtmlFromFile({
|
||||||
const type = fileType()
|
const type = fileType()
|
||||||
let contentToRender: string = content || ""
|
let contentToRender: string = content || ""
|
||||||
if (!renderAsMarkdown.includes(type)) {
|
if (!renderAsMarkdown.includes(type)) {
|
||||||
contentToRender = `
|
contentToRender = `~~~${type}
|
||||||
~~~${type}
|
|
||||||
${content}
|
${content}
|
||||||
~~~
|
~~~`
|
||||||
`
|
|
||||||
} else {
|
} else {
|
||||||
contentToRender = "\n" + content
|
contentToRender = "\n" + content
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = markdown(contentToRender, {
|
if (typeof contentToRender !== "string") {
|
||||||
showLineNumbers: false
|
res.status(400).send("content must be a string")
|
||||||
})
|
return
|
||||||
return html
|
|
||||||
}
|
}
|
||||||
|
res.status(200).write(markdown(contentToRender))
|
||||||
|
res.end()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default renderMarkdown
|
19
client/pages/expired.tsx
Normal file
19
client/pages/expired.tsx
Normal 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're trying to view has expired.</Text>
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
</Page.Content>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Expired
|
47
client/pages/index.tsx
Normal file
47
client/pages/index.tsx
Normal 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
63
client/pages/mine.tsx
Normal 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
|
81
client/pages/new/from/[id].tsx
Normal file
81
client/pages/new/from/[id].tsx
Normal 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
|
28
client/pages/new/index.tsx
Normal file
28
client/pages/new/index.tsx
Normal 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
|
60
client/pages/post/[id].tsx
Normal file
60
client/pages/post/[id].tsx
Normal 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
|
||||||
|
|
58
client/pages/post/private/[id].tsx
Normal file
58
client/pages/post/private/[id].tsx
Normal 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
|
||||||
|
|
83
client/pages/post/protected/[id].tsx
Normal file
83
client/pages/post/protected/[id].tsx
Normal 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
19
client/pages/signin.tsx
Normal 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
20
client/pages/signup.tsx
Normal 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
Loading…
Reference in a new issue