Compare commits
1 commit
refactor
...
contributi
Author | SHA1 | Date | |
---|---|---|---|
|
66770f0681 |
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"
|
|
||||||
}
|
|
||||||
}
|
|
1
.github/FUNDING.yml
vendored
|
@ -1 +0,0 @@
|
||||||
github: MaxLeiter
|
|
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -1,38 +0,0 @@
|
||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: bug
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
|
||||||
- OS: [e.g. iOS]
|
|
||||||
- Browser [e.g. chrome, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
|
||||||
- Device: [e.g. iPhone6]
|
|
||||||
- OS: [e.g. iOS8.1]
|
|
||||||
- Browser [e.g. stock browser, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
10
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -1,10 +0,0 @@
|
||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: Feature request
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
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
|
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"semi": false,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"singleQuote": false,
|
|
||||||
"printWidth": 80,
|
|
||||||
"useTabs": true,
|
|
||||||
"plugins": ["prettier-plugin-tailwindcss"]
|
|
||||||
}
|
|
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
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Thank you for your interest in Drift!
|
|
||||||
|
|
||||||
### I want to report a bug
|
|
||||||
|
|
||||||
Look at the open and closed issues to see if this was not already discussed before. If you can't see any, feel free to open a new issue.
|
|
||||||
If you think you discovered a security vulnerability, do not open a public issue on GitHub. Please email maxwell.leiter@gmail.com in the interest of responsible disclosure.
|
|
||||||
|
|
||||||
### I want to contribute to the code
|
|
||||||
|
|
||||||
Make sure to discuss your ideas with the community in an issue or on the IRC channel.
|
|
||||||
Take a look at the open issues labeled as help wanted or good first issue if you want to help without having a specific idea in mind.
|
|
||||||
Make sure that your PRs do not contain unnecessary commits or merge commits. Squash commits whenever possible.
|
|
||||||
Rebase (instead of merge) outdated PRs on the master branch.
|
|
||||||
Give extra care to your commit messages. Use the imperative present tense and follow Tim Pope's guidelines.
|
|
21
LICENSE
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2022 Max Leiter
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
142
README.md
|
@ -1,147 +1,45 @@
|
||||||
# <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 (almost, no database yet) 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.
|
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
`pnpm build` will produce production code. `pnpm start` will start the Next.js server.
|
**Note: Drift is not yet ready for production usage and should not be used seriously until the database has been setup, which I'll get to when the server API is semi stable.**
|
||||||
|
|
||||||
### Environment Variables
|
`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).
|
||||||
|
|
||||||
You can change these to your liking.
|
If you're deploying the front-end to something like Vercel, you'll need to set the root folder to `client/`.
|
||||||
|
|
||||||
`.env`:
|
|
||||||
|
|
||||||
- `DRIFT_URL`: the URL of the drift instance.
|
|
||||||
- `DATABASE_URL`: the URL to connect to your postgres instance. For example, `postgresql://user:password@localhost:5432/drift`.
|
|
||||||
- `WELCOME_CONTENT`: a markdown string that's rendered on the home page
|
|
||||||
- `WELCOME_TITLE`: the file title for the post on the homepage.
|
|
||||||
- `ENABLE_ADMIN`: the first account created is an administrator account
|
|
||||||
- `REGISTRATION_PASSWORD`: the password required to register an account. If not set, no password is required.
|
|
||||||
- `NODE_ENV`: defaults to development, can be `production`
|
|
||||||
|
|
||||||
#### Auth environment variables
|
|
||||||
|
|
||||||
**Note:** Only credential auth currently supports the registration password, so if you want to secure registration, you must use only credential auth.
|
|
||||||
|
|
||||||
- `GITHUB_CLIENT_ID`: the client ID for GitHub OAuth.
|
|
||||||
- `GITHUB_CLIENT_SECRET`: the client secret for GitHub OAuth.
|
|
||||||
- `NEXTAUTH_URL`: the URL of the drift instance. Not required if hosting on Vercel.
|
|
||||||
- `CREDENTIAL_AUTH`: whether to allow username/password authentication. Defaults to `true`.
|
|
||||||
|
|
||||||
## Running with pm2
|
|
||||||
|
|
||||||
It's easy to start Drift using [pm2](https://pm2.keymetrics.io/).
|
|
||||||
First, add the `.env` file with your values (see the above section for the required options).
|
|
||||||
|
|
||||||
Then, use the following command to start the server:
|
|
||||||
|
|
||||||
- `pnpm build && pm2 start pnpm --name drift --interpreter bash -- start`
|
|
||||||
|
|
||||||
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.
|
Drit 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
|
- [ ] password protected posts
|
||||||
- [x] postgres database
|
- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development)
|
||||||
- [x] administrator account / settings
|
- [ ] non-node backend
|
||||||
- [x] docker-compose (PRs: [#13](https://github.com/MaxLeiter/Drift/pull/13), [#75](https://github.com/MaxLeiter/Drift/pull/75))
|
- [ ] administrator account / settings
|
||||||
|
- [ ] 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
|
- [ ] 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?
|
- [ ] 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
|
|
||||||
|
|
1
client/.dockerignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
node_modules/
|
3
client/.env.local
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
API_URL=http://localhost:3000
|
||||||
|
WELCOME_TITLE="Welcome to Drift"
|
||||||
|
WELCOME_CONTENT="### Drift is a self-hostable clone of GitHub Gist. \nIt is a simple way to share code and text snippets with your friends, with support for the following:\n \n - Render GitHub Extended Markdown (including images)\n - User authentication\n - Private, public, and secret posts\n \n If you want to signup, you can join at [/signup](/signup) as long as you have a passcode provided by the administrator (which you don\'t need for this demo).\n **This demo is on a memory-only database, so accounts and pastes can be deleted at any time.**\n You can find the source code on [GitHub](https://github.com/MaxLeiter/drift).\n \n Drift was inspired by [this tweet](https://twitter.com/emilyst/status/1499858264346935297):\n > What is the absolute closest thing to GitHub Gist that can be self-hosted?\n In terms of design and functionality. Hosts images and markdown, rendered. Creates links that can be private or public. Uses/requires registration. I have looked at dozens of pastebin-like things."
|
3
client/.eslintrc.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
1
.gitignore → client/.gitignore
vendored
|
@ -4,7 +4,6 @@
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
analyze
|
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
56
client/Dockerfile
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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.js ./
|
||||||
|
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
|
@ -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.
|
11
client/components/Link.tsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { Link as GeistLink, LinkProps } 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
|
21
client/components/auth/auth.module.css
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
.container {
|
||||||
|
padding: 2rem 2rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formHeader {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
100
client/components/auth/index.tsx
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { Button, Card, Input, Text } from '@geist-ui/core'
|
||||||
|
import styles from './auth.module.css'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
import Link from '../Link'
|
||||||
|
|
||||||
|
const Auth = ({ page }: { page: "signup" | "signin" }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const signingIn = page === 'signin'
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
|
||||||
|
const handleJson = (json: any) => {
|
||||||
|
if (json.error) {
|
||||||
|
setError(json.error.message)
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('drift-token', json.token)
|
||||||
|
localStorage.setItem('drift-userid', json.userId)
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqOpts = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
if (signingIn) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/server-api/auth/signin', reqOpts)
|
||||||
|
const json = await resp.json()
|
||||||
|
handleJson(json)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Something went wrong")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/server-api/auth/signup', reqOpts)
|
||||||
|
const json = await resp.json()
|
||||||
|
handleJson(json)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Something went wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.form}>
|
||||||
|
<div className={styles.formHeader}>
|
||||||
|
<h1>{signingIn ? 'Sign In' : 'Sign Up'}</h1>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Card>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<Input
|
||||||
|
htmlType="text"
|
||||||
|
id="username"
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="Username"
|
||||||
|
required
|
||||||
|
label='Username'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<Input
|
||||||
|
htmlType='password'
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="Password"
|
||||||
|
required
|
||||||
|
label='Password'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<Button type="success" ghost htmlType="submit">{signingIn ? 'Sign In' : 'Sign Up'}</Button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
{signingIn && <Text>Don't have an account? <Link color href="/signup" >Sign up</Link></Text>}
|
||||||
|
{!signingIn && <Text>Already have an account? <Link color href="/signin" >Sign in</Link></Text>}
|
||||||
|
</div>
|
||||||
|
{error && <Text type='error'>{error}</Text>}
|
||||||
|
</Card>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Auth
|
41
client/components/document/document.module.css
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
.input {
|
||||||
|
background: #efefef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descriptionContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 400px;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileNameContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileNameContainer {
|
||||||
|
display: flex;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileNameContainer > div {
|
||||||
|
/* Override geist-ui styling */
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionWrapper {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionWrapper .actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
}
|
137
client/components/document/formatting-icons/index.tsx
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
import { ButtonGroup, Button } from "@geist-ui/core"
|
||||||
|
import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons'
|
||||||
|
import { RefObject, useCallback, useMemo } from "react"
|
||||||
|
import styles from '../document.module.css'
|
||||||
|
|
||||||
|
// 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((e) => {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// TODO; fails because settext async
|
||||||
|
textareaRef.current.setSelectionRange(before.length + 2, before.length + 2 + selectedText.length)
|
||||||
|
}
|
||||||
|
}, [setText, textareaRef])
|
||||||
|
|
||||||
|
const handleItalicClick = useCallback((e) => {
|
||||||
|
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)
|
||||||
|
textareaRef.current.focus()
|
||||||
|
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
|
||||||
|
}
|
||||||
|
}, [setText, textareaRef])
|
||||||
|
|
||||||
|
const handleLinkClick = useCallback((e) => {
|
||||||
|
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)
|
||||||
|
textareaRef.current.focus()
|
||||||
|
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
|
||||||
|
}
|
||||||
|
}, [setText, textareaRef])
|
||||||
|
|
||||||
|
const handleImageClick = useCallback((e) => {
|
||||||
|
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)
|
||||||
|
textareaRef.current.focus()
|
||||||
|
textareaRef.current.setSelectionRange(before.length + 1, before.length + 1 + selectedText.length)
|
||||||
|
}
|
||||||
|
}, [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
|
154
client/components/document/index.tsx
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
|
||||||
|
import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react"
|
||||||
|
import styles from './document.module.css'
|
||||||
|
import MarkdownPreview from '../preview'
|
||||||
|
import { Trash, Download, ExternalLink } from '@geist-ui/icons'
|
||||||
|
import FormattingIcons from "./formatting-icons"
|
||||||
|
import Skeleton from "react-loading-skeleton"
|
||||||
|
// import Link from "next/link"
|
||||||
|
type Props = {
|
||||||
|
editable?: boolean
|
||||||
|
remove?: () => void
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
setTitle?: (title: string) => void
|
||||||
|
setContent?: (content: string) => void
|
||||||
|
initialTab?: "edit" | "preview"
|
||||||
|
skeleton?: boolean
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
|
||||||
|
return (<div className={styles.actionWrapper}>
|
||||||
|
<ButtonGroup className={styles.actions}>
|
||||||
|
<Tooltip 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 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 = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton, id }: Props) => {
|
||||||
|
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const [tab, setTab] = useState(initialTab)
|
||||||
|
const height = editable ? "500px" : '100%'
|
||||||
|
|
||||||
|
const handleTabChange = (newTab: string) => {
|
||||||
|
if (newTab === 'edit') {
|
||||||
|
codeEditorRef.current?.focus()
|
||||||
|
}
|
||||||
|
setTab(newTab as 'edit' | 'preview')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getType = useMemo(() => {
|
||||||
|
if (!title) return
|
||||||
|
const pathParts = title.split(".")
|
||||||
|
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : ""
|
||||||
|
return language
|
||||||
|
}, [title])
|
||||||
|
|
||||||
|
const removeFile = (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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLink = useMemo(() => {
|
||||||
|
if (id) {
|
||||||
|
return `/file/raw/${id}`
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
if (skeleton) {
|
||||||
|
return <>
|
||||||
|
<Spacer height={1} />
|
||||||
|
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}>
|
||||||
|
<div className={styles.fileNameContainer}>
|
||||||
|
<Skeleton width={275} height={36} />
|
||||||
|
{editable && <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 >
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Spacer height={1} />
|
||||||
|
<Card marginBottom={'var(--gap)'} marginTop={'var(--gap)'} style={{ maxWidth: 980, margin: "0 auto" }}>
|
||||||
|
<div className={styles.fileNameContainer}>
|
||||||
|
<Input
|
||||||
|
placeholder="MyFile.md"
|
||||||
|
value={title}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle ? setTitle(event.target.value) : null}
|
||||||
|
marginTop="var(--gap-double)"
|
||||||
|
size={1.2}
|
||||||
|
font={1.2}
|
||||||
|
label="Filename"
|
||||||
|
disabled={!editable}
|
||||||
|
width={"100%"}
|
||||||
|
/>
|
||||||
|
{remove && editable && <Button type="abort" ghost icon={<Trash />} auto height={'36px'} width={'36px'} onClick={() => removeFile(remove)} />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.descriptionContainer}>
|
||||||
|
{tab === 'edit' && editable && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
|
||||||
|
{rawLink && <DownloadButton rawLink={rawLink} />}
|
||||||
|
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
|
||||||
|
<Tabs.Item label={editable ? "Edit" : "Raw"} value="edit">
|
||||||
|
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Textarea
|
||||||
|
ref={codeEditorRef}
|
||||||
|
placeholder="Type some contents..."
|
||||||
|
value={content}
|
||||||
|
onChange={(event) => setContent ? setContent(event.target.value) : null}
|
||||||
|
width="100%"
|
||||||
|
disabled={!editable}
|
||||||
|
// 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">
|
||||||
|
<MarkdownPreview height={height} content={content} type={getType} />
|
||||||
|
</Tabs.Item>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
</div >
|
||||||
|
</Card >
|
||||||
|
<Spacer height={1} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default memo(Document)
|
41
client/components/header/controls.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import React from 'react'
|
||||||
|
import MoonIcon from '@geist-ui/icons/moon'
|
||||||
|
import SunIcon from '@geist-ui/icons/sun'
|
||||||
|
import { Select } from '@geist-ui/core'
|
||||||
|
import { ThemeProps } from '../../pages/_app'
|
||||||
|
// import { useAllThemes, useTheme } from '@geist-ui/core'
|
||||||
|
import styles from './header.module.css'
|
||||||
|
|
||||||
|
const Controls = ({ changeTheme, theme }: ThemeProps) => {
|
||||||
|
const switchThemes = (type: string | string[]) => {
|
||||||
|
changeTheme()
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return
|
||||||
|
window.localStorage.setItem('drift-theme', Array.isArray(type) ? type[0] : type)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<Select
|
||||||
|
scale={0.5}
|
||||||
|
h="28px"
|
||||||
|
pure
|
||||||
|
onChange={switchThemes}
|
||||||
|
value={theme}
|
||||||
|
>
|
||||||
|
<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
|
@ -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;
|
||||||
|
}
|
222
client/components/header/index.tsx
Normal file
|
@ -0,0 +1,222 @@
|
||||||
|
import { Page, ButtonGroup, Button, useBodyScroll, useMediaQuery, Tabs, Spacer } from "@geist-ui/core";
|
||||||
|
import { Github as GitHubIcon, UserPlus as SignUpIcon, User as SignInIcon, Home as HomeIcon, Menu as MenuIcon, Tool as SettingsIcon, UserX as SignoutIcon, PlusCircle as NewIcon, List as YourIcon, Moon, Sun } from "@geist-ui/icons";
|
||||||
|
import { DriftProps } from "../../pages/_app";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import styles from './header.module.css';
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import useSignedIn from "../../lib/hooks/use-signed-in";
|
||||||
|
|
||||||
|
type Tab = {
|
||||||
|
name: string
|
||||||
|
icon: JSX.Element
|
||||||
|
condition?: boolean
|
||||||
|
value: string
|
||||||
|
onClick?: () => void
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const Header = ({ changeTheme, theme }: DriftProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedTab, setSelectedTab] = useState<string>();
|
||||||
|
const [expanded, setExpanded] = useState<boolean>(false)
|
||||||
|
const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true })
|
||||||
|
const isMobile = useMediaQuery('xs', { match: 'down' })
|
||||||
|
const { isLoading, isSignedIn, signout } = useSignedIn({ redirectIfNotAuthed: false })
|
||||||
|
const [pages, setPages] = useState<Tab[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBodyHidden(expanded)
|
||||||
|
}, [expanded, setBodyHidden])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) {
|
||||||
|
setExpanded(false)
|
||||||
|
}
|
||||||
|
}, [isMobile])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pageList: Tab[] = [
|
||||||
|
{
|
||||||
|
name: "Home",
|
||||||
|
href: "/",
|
||||||
|
icon: <HomeIcon />,
|
||||||
|
condition: true,
|
||||||
|
value: "home"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "New",
|
||||||
|
href: "/new",
|
||||||
|
icon: <NewIcon />,
|
||||||
|
condition: isSignedIn,
|
||||||
|
value: "new"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Yours",
|
||||||
|
href: "/mine",
|
||||||
|
icon: <YourIcon />,
|
||||||
|
condition: isSignedIn,
|
||||||
|
value: "mine"
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// name: "Settings",
|
||||||
|
// href: "/settings",
|
||||||
|
// icon: <SettingsIcon />,
|
||||||
|
// condition: isSignedIn
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
name: "Sign out",
|
||||||
|
onClick: () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
|
// // send token to API blacklist
|
||||||
|
// fetch('/api/auth/signout', {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: {
|
||||||
|
// 'Content-Type': 'application/json'
|
||||||
|
// },
|
||||||
|
// body: JSON.stringify({
|
||||||
|
// token: localStorage.getItem("drift-token")
|
||||||
|
// })
|
||||||
|
// })
|
||||||
|
|
||||||
|
signout();
|
||||||
|
router.push("/signin");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
href: "#signout",
|
||||||
|
icon: <SignoutIcon />,
|
||||||
|
condition: isSignedIn,
|
||||||
|
value: "signout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sign in",
|
||||||
|
href: "/signin",
|
||||||
|
icon: <SignInIcon />,
|
||||||
|
condition: !isSignedIn,
|
||||||
|
value: "signin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sign up",
|
||||||
|
href: "/signup",
|
||||||
|
icon: <SignUpIcon />,
|
||||||
|
condition: !isSignedIn,
|
||||||
|
value: "signup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: isMobile ? "GitHub" : "",
|
||||||
|
href: "https://github.com/maxleiter/drift",
|
||||||
|
icon: <GitHubIcon />,
|
||||||
|
condition: true,
|
||||||
|
value: "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: isMobile ? "Change theme" : "",
|
||||||
|
onClick: function () {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
changeTheme();
|
||||||
|
setSelectedTab(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: theme === 'light' ? <Moon /> : <Sun />,
|
||||||
|
condition: true,
|
||||||
|
value: "theme",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return setPages([])
|
||||||
|
}
|
||||||
|
|
||||||
|
setPages(pageList.filter(page => page.condition))
|
||||||
|
}, [changeTheme, isLoading, isMobile, isSignedIn, router, signout, theme])
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// setSelectedTab(pages.find((page) => {
|
||||||
|
// console.log(page.href, router.asPath)
|
||||||
|
// if (page.href && page.href === router.asPath) {
|
||||||
|
// return true
|
||||||
|
// }
|
||||||
|
// })?.href)
|
||||||
|
// }, [pages, router, router.pathname])
|
||||||
|
|
||||||
|
const onTabChange = (tab: string) => {
|
||||||
|
const match = pages.find(page => page.value === tab)
|
||||||
|
if (match?.onClick) {
|
||||||
|
match.onClick()
|
||||||
|
} else if (match?.href) {
|
||||||
|
router.push(`${match.href}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page.Header height={'var(--page-nav-height)'} margin={0} paddingBottom={0} paddingTop={"var(--gap)"}>
|
||||||
|
<div className={styles.tabs}>
|
||||||
|
<Tabs
|
||||||
|
value={selectedTab}
|
||||||
|
leftSpace={0}
|
||||||
|
align="center"
|
||||||
|
hideDivider
|
||||||
|
hideBorder
|
||||||
|
onChange={onTabChange}>
|
||||||
|
{!isLoading && 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
|
||||||
|
|
||||||
|
|
||||||
|
// {/* {/* <ButtonGroup>
|
||||||
|
// <Button onClick={() => {
|
||||||
|
|
||||||
|
// }}><Link href="/signin">Sign out</Link></Button>
|
||||||
|
// <Button>
|
||||||
|
// <Link href="/mine">
|
||||||
|
// Yours
|
||||||
|
// </Link>
|
||||||
|
// </Button>
|
||||||
|
// <Button>
|
||||||
|
// {/* TODO: Link outside Button, but seems to break ButtonGroup */}
|
||||||
|
// <Link href="/new">
|
||||||
|
// New
|
||||||
|
// </Link>
|
||||||
|
// </Button >
|
||||||
|
// <Button onClick={() => changeTheme()}>
|
||||||
|
// <ShiftBy y={6}>{theme.type === 'light' ? <Moon /> : <Sun />}</ShiftBy>
|
||||||
|
// </Button>
|
||||||
|
// </ButtonGroup > * /}
|
16
client/components/my-posts/index.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import useSWR from "swr"
|
||||||
|
import PostList from "../post-list"
|
||||||
|
|
||||||
|
const fetcher = (url: string) => fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem("drift-token")}`
|
||||||
|
},
|
||||||
|
}).then(r => r.json())
|
||||||
|
|
||||||
|
const MyPosts = () => {
|
||||||
|
const { data, error } = useSWR('/server-api/users/mine', fetcher)
|
||||||
|
return <PostList posts={data} 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: border 0.24s ease-in-out;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: red;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition: border 0.24s ease-in-out;
|
||||||
|
border: 2px solid red;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error > li:before {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.error ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--gap-double);
|
||||||
|
}
|
180
client/components/new-post/drag-and-drop/index.tsx
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
import { Button, Text, useTheme, useToasts } from '@geist-ui/core'
|
||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import { useDropzone } from 'react-dropzone'
|
||||||
|
import styles from './drag-and-drop.module.css'
|
||||||
|
import { Document } from '../'
|
||||||
|
import generateUUID from '../../../lib/generate-uuid'
|
||||||
|
import { XCircle } from '@geist-ui/icons'
|
||||||
|
const allowedFileTypes = [
|
||||||
|
'application/json',
|
||||||
|
'application/x-javascript',
|
||||||
|
'application/xhtml+xml',
|
||||||
|
'application/xml',
|
||||||
|
'text/xml',
|
||||||
|
'text/plain',
|
||||||
|
'text/html',
|
||||||
|
'text/csv',
|
||||||
|
'text/tab-separated-values',
|
||||||
|
'text/x-c',
|
||||||
|
'text/x-c++',
|
||||||
|
'text/x-csharp',
|
||||||
|
'text/x-java',
|
||||||
|
'text/x-javascript',
|
||||||
|
'text/x-php',
|
||||||
|
'text/x-python',
|
||||||
|
'text/x-ruby',
|
||||||
|
'text/x-scala',
|
||||||
|
'text/x-swift',
|
||||||
|
'text/x-typescript',
|
||||||
|
'text/x-vb',
|
||||||
|
'text/x-vbscript',
|
||||||
|
'text/x-yaml',
|
||||||
|
'text/x-c++',
|
||||||
|
'text/x-c#',
|
||||||
|
'text/mathml',
|
||||||
|
'text/x-markdown',
|
||||||
|
'text/markdown',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Files with no extension can't be easily detected as plain-text,
|
||||||
|
// so instead of allowing all of them we'll just allow common ones
|
||||||
|
const allowedFileNames = [
|
||||||
|
'Makefile',
|
||||||
|
'README',
|
||||||
|
'Dockerfile',
|
||||||
|
'Jenkinsfile',
|
||||||
|
'LICENSE',
|
||||||
|
'.env',
|
||||||
|
'.gitignore',
|
||||||
|
'.gitattributes',
|
||||||
|
'.env.example',
|
||||||
|
'.env.development',
|
||||||
|
'.env.production',
|
||||||
|
'.env.test',
|
||||||
|
'.env.staging',
|
||||||
|
'.env.development.local',
|
||||||
|
'yarn.lock',
|
||||||
|
]
|
||||||
|
|
||||||
|
const allowedFileExtensions = [
|
||||||
|
'json',
|
||||||
|
'js',
|
||||||
|
'jsx',
|
||||||
|
'ts',
|
||||||
|
'tsx',
|
||||||
|
'c',
|
||||||
|
'cpp',
|
||||||
|
'c++',
|
||||||
|
'c#',
|
||||||
|
'java',
|
||||||
|
'php',
|
||||||
|
'py',
|
||||||
|
'rb',
|
||||||
|
'scala',
|
||||||
|
'swift',
|
||||||
|
'vb',
|
||||||
|
'vbscript',
|
||||||
|
'yaml',
|
||||||
|
'less',
|
||||||
|
'stylus',
|
||||||
|
'styl',
|
||||||
|
'sass',
|
||||||
|
'scss',
|
||||||
|
'lock',
|
||||||
|
'md',
|
||||||
|
'markdown',
|
||||||
|
'txt',
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'css',
|
||||||
|
'csv',
|
||||||
|
'log',
|
||||||
|
'sql',
|
||||||
|
'xml',
|
||||||
|
'webmanifest',
|
||||||
|
]
|
||||||
|
|
||||||
|
function FileDropzone({ setDocs, docs }: { setDocs: React.Dispatch<React.SetStateAction<Document[]>>, docs: Document[] }) {
|
||||||
|
const { palette } = useTheme()
|
||||||
|
const { setToast } = useToasts()
|
||||||
|
const onDrop = useCallback(async (acceptedFiles) => {
|
||||||
|
const newDocs = await Promise.all(acceptedFiles.map((file: File) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (docs.length === 1) {
|
||||||
|
if (docs[0].content === '') {
|
||||||
|
setDocs(newDocs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocs((oldDocs) => [...oldDocs, ...newDocs])
|
||||||
|
}, [setDocs, setToast, docs])
|
||||||
|
|
||||||
|
const validator = (file: File) => {
|
||||||
|
// TODO: make this configurable
|
||||||
|
const maxFileSize = 1000000;
|
||||||
|
if (file.size > maxFileSize) {
|
||||||
|
return {
|
||||||
|
code: 'file-too-big',
|
||||||
|
message: 'File is too big. Maximum file size is ' + (maxFileSize).toFixed(2) + ' 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 some of your files.</Text>
|
||||||
|
{fileRejectionItems}
|
||||||
|
</ul>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileDropzone
|
112
client/components/new-post/index.tsx
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { Button, ButtonDropdown, useToasts } from '@geist-ui/core'
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import generateUUID from '../../lib/generate-uuid';
|
||||||
|
import Document from '../document';
|
||||||
|
import FileDropzone from './drag-and-drop';
|
||||||
|
import styles from './post.module.css'
|
||||||
|
import Title from './title';
|
||||||
|
|
||||||
|
export type Document = {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Post = () => {
|
||||||
|
const { setToast } = useToasts()
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState<string>()
|
||||||
|
const [docs, setDocs] = useState<Document[]>([{
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
id: generateUUID()
|
||||||
|
}])
|
||||||
|
const [isSubmitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const remove = (id: string) => {
|
||||||
|
setDocs(docs.filter((doc) => doc.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (visibility: string) => {
|
||||||
|
setSubmitting(true)
|
||||||
|
const response = await fetch('/server-api/posts/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem("drift-token")}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
files: docs,
|
||||||
|
visibility,
|
||||||
|
userId: localStorage.getItem("drift-userid"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
setSubmitting(false)
|
||||||
|
if (json.id)
|
||||||
|
router.push(`/post/${json.id}`)
|
||||||
|
else {
|
||||||
|
setToast({ text: json.error.message, type: "error" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTitle = useCallback((title: string, id: string) => {
|
||||||
|
setDocs(docs.map((doc) => doc.id === id ? { ...doc, title } : doc))
|
||||||
|
}, [docs])
|
||||||
|
|
||||||
|
const updateContent = useCallback((content: string, id: string) => {
|
||||||
|
setDocs(docs.map((doc) => doc.id === id ? { ...doc, content } : doc))
|
||||||
|
}, [docs])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title title={title} setTitle={setTitle} />
|
||||||
|
<FileDropzone docs={docs} setDocs={setDocs} />
|
||||||
|
{
|
||||||
|
docs.map(({ id }) => {
|
||||||
|
const doc = docs.find((doc) => doc.id === id)
|
||||||
|
return (
|
||||||
|
<Document
|
||||||
|
remove={() => remove(id)}
|
||||||
|
key={id}
|
||||||
|
editable={true}
|
||||||
|
setContent={(content) => updateContent(content, id)}
|
||||||
|
setTitle={(title) => updateTitle(title, id)}
|
||||||
|
content={doc?.content}
|
||||||
|
title={doc?.title}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={() => {
|
||||||
|
setDocs([...docs, {
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
id: generateUUID()
|
||||||
|
}])
|
||||||
|
}}
|
||||||
|
style={{ flex: .5, lineHeight: '40px' }}
|
||||||
|
type="default"
|
||||||
|
>
|
||||||
|
Add a File
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Post
|
21
client/components/new-post/post.module.css
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
.buttons {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--gap-double);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 650px) {
|
||||||
|
.title {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
38
client/components/new-post/title/index.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { Text, Input } from '@geist-ui/core'
|
||||||
|
import { memo } from 'react'
|
||||||
|
import ShiftBy from '../../shift-by'
|
||||||
|
import styles from '../post.module.css'
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
setTitle: (title: string) => void
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Title = ({ setTitle, title }: props) => {
|
||||||
|
return (<div className={styles.title}>
|
||||||
|
<Text h1 width={"150px"} className={styles.drift}>Drift</Text>
|
||||||
|
<ShiftBy y={-3}>
|
||||||
|
<Input
|
||||||
|
placeholder={titlePlaceholders[Math.floor(Math.random() * titlePlaceholders.length)]}
|
||||||
|
value={title || ""}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
height={"55px"}
|
||||||
|
font={1.5}
|
||||||
|
label="Post title"
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
</ShiftBy>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Title)
|
40
client/components/post-list/index.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { 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"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
posts: any
|
||||||
|
error: any
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostList = ({ posts, error }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{error && <Text type='error'>Failed to load.</Text>}
|
||||||
|
{!posts && <ul>
|
||||||
|
<li>
|
||||||
|
<ListItemSkeleton />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<ListItemSkeleton />
|
||||||
|
</li>
|
||||||
|
</ul>}
|
||||||
|
{posts?.length === 0 && <Text>You have no posts. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
|
||||||
|
{
|
||||||
|
posts?.length > 0 && <div>
|
||||||
|
<ul>
|
||||||
|
{posts.map((post: any) => {
|
||||||
|
return <ListItem post={post} key={post.id} />
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PostList
|
19
client/components/post-list/list-item-skeleton.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { Card, Spacer, Grid, Divider } from "@geist-ui/core";
|
||||||
|
import Skeleton from "react-loading-skeleton";
|
||||||
|
|
||||||
|
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
|
58
client/components/post-list/list-item.tsx
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { Card, Spacer, Grid, Divider, Link, Text, Input, Tooltip } from "@geist-ui/core"
|
||||||
|
import NextLink from "next/link"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import timeAgo from "../../lib/time-ago"
|
||||||
|
import ShiftBy from "../shift-by"
|
||||||
|
import VisibilityBadge from "../visibility-badge"
|
||||||
|
|
||||||
|
const FilenameInput = ({ title }: { title: string }) => <Input
|
||||||
|
value={title}
|
||||||
|
marginTop="var(--gap-double)"
|
||||||
|
size={1.2}
|
||||||
|
font={1.2}
|
||||||
|
label="Filename"
|
||||||
|
readOnly
|
||||||
|
width={"100%"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
const ListItem = ({ post }: { post: any }) => {
|
||||||
|
const createdDate = useMemo(() => new Date(post.createdAt), [post.createdAt])
|
||||||
|
const [time, setTimeAgo] = useState(timeAgo(createdDate))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTimeAgo(timeAgo(createdDate))
|
||||||
|
}, 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [createdDate])
|
||||||
|
|
||||||
|
const formattedTime = `${createdDate.toLocaleDateString()} ${createdDate.toLocaleTimeString()}`
|
||||||
|
return (<li key={post.id}>
|
||||||
|
<Card style={{ overflowY: 'scroll' }}>
|
||||||
|
<Spacer height={1 / 2} />
|
||||||
|
<Grid.Container justify={'space-between'}>
|
||||||
|
<Grid xs={8}>
|
||||||
|
<Text h3 paddingLeft={1 / 2}>
|
||||||
|
<NextLink passHref={true} href={`/post/${post.id}`}>
|
||||||
|
<Link color>{post.title}
|
||||||
|
<ShiftBy y={-1}><VisibilityBadge visibility={post.visibility} /></ShiftBy>
|
||||||
|
</Link>
|
||||||
|
</NextLink>
|
||||||
|
</Text></Grid>
|
||||||
|
<Grid xs={7}><Text type="secondary" h5><Tooltip text={formattedTime}>{time}</Tooltip></Text></Grid>
|
||||||
|
<Grid xs={4}><Text type="secondary" h5>{post.files.length === 1 ? "1 file" : `${post.files.length} files`}</Text></Grid>
|
||||||
|
</Grid.Container>
|
||||||
|
|
||||||
|
<Divider h="1px" my={0} />
|
||||||
|
|
||||||
|
<Card.Content >
|
||||||
|
{post.files.map((file: any) => {
|
||||||
|
return <FilenameInput key={file.id} title={file.title} />
|
||||||
|
})}
|
||||||
|
</Card.Content>
|
||||||
|
|
||||||
|
</Card>
|
||||||
|
</li>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ListItem
|
26
client/components/post-list/post-list.module.css
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
.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;
|
||||||
|
}
|
28
client/components/preview/index.tsx
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { memo, useEffect, useState } from "react"
|
||||||
|
import ReactMarkdownPreview from "./react-markdown-preview"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content?: string
|
||||||
|
height?: number | string
|
||||||
|
// file extensions we can highlight
|
||||||
|
type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkdownPreview = ({ content = '', height = 500, type = 'markdown' }: Props) => {
|
||||||
|
const [contentToRender, setContent] = useState(content)
|
||||||
|
useEffect(() => {
|
||||||
|
// 'm' so it doesn't flash code when you change the type to md
|
||||||
|
const renderAsMarkdown = ['m', 'markdown', 'md', 'mdown', 'mkdn', 'mkd', 'mdwn', 'mdtxt', 'mdtext', 'text', '']
|
||||||
|
if (!renderAsMarkdown.includes(type)) {
|
||||||
|
setContent(`~~~${type}
|
||||||
|
${content}
|
||||||
|
~~~
|
||||||
|
`)
|
||||||
|
} else {
|
||||||
|
setContent(content)
|
||||||
|
}
|
||||||
|
}, [type, content])
|
||||||
|
return (<ReactMarkdownPreview height={height} content={contentToRender} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(MarkdownPreview)
|
60
client/components/preview/preview.module.css
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
.markdownPreview pre {
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.42857143;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h1,
|
||||||
|
.markdownPreview h2,
|
||||||
|
.markdownPreview h3,
|
||||||
|
.markdownPreview h4,
|
||||||
|
.markdownPreview h5,
|
||||||
|
.markdownPreview h6 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h4 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h5 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview h6 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview ul {
|
||||||
|
list-style: inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview ul li::before {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview ul ul {
|
||||||
|
list-style: circle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPreview ul ul li {
|
||||||
|
margin-left: var(--gap);
|
||||||
|
}
|
53
client/components/preview/react-markdown-preview.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import ReactMarkdown from "react-markdown"
|
||||||
|
import remarkGfm from "remark-gfm"
|
||||||
|
import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
|
||||||
|
// @ts-ignore because of no types in remark-a11y-emoji
|
||||||
|
import a11yEmoji from '@fec/remark-a11y-emoji';
|
||||||
|
import styles from './preview.module.css'
|
||||||
|
import { vscDarkPlus as dark, vs as light } from 'react-syntax-highlighter/dist/cjs/styles/prism'
|
||||||
|
import useSharedState from "../../lib/hooks/use-shared-state";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: string | undefined
|
||||||
|
height: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReactMarkdownPreview = ({ content, height }: Props) => {
|
||||||
|
const [themeType] = useSharedState<string>('theme')
|
||||||
|
return (<div style={{ height }}>
|
||||||
|
<ReactMarkdown className={styles.markdownPreview} remarkPlugins={[remarkGfm, a11yEmoji]}
|
||||||
|
components={{
|
||||||
|
code({ node, inline, className, children, ...props }) {
|
||||||
|
const match = /language-(\w+)/.exec(className || '')
|
||||||
|
return !inline && match ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
lineNumberStyle={{
|
||||||
|
minWidth: "2.25rem"
|
||||||
|
}}
|
||||||
|
customStyle={{
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
background: 'transparent'
|
||||||
|
}}
|
||||||
|
codeTagProps={{
|
||||||
|
style: { background: 'transparent' }
|
||||||
|
}}
|
||||||
|
style={themeType === 'dark' ? dark : light}
|
||||||
|
showLineNumbers={true}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
{...props}
|
||||||
|
>{String(children).replace(/\n$/, '')}</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<code className={className} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{content || ""}
|
||||||
|
</ReactMarkdown></div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReactMarkdownPreview
|
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
|
24
client/components/visibility-badge/index.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { Badge } from "@geist-ui/core"
|
||||||
|
|
||||||
|
type Visibility = "unlisted" | "private" | "public"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
visibility: Visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
const VisibilityBadge = ({ visibility }: Props) => {
|
||||||
|
const getBadgeType = () => {
|
||||||
|
switch (visibility) {
|
||||||
|
case "public":
|
||||||
|
return "success"
|
||||||
|
case "private":
|
||||||
|
return "warning"
|
||||||
|
case "unlisted":
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<Badge marginLeft={'var(--gap)'} type={getBadgeType()}>{visibility}</Badge>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VisibilityBadge
|
30
client/lib/generate-uuid.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
export default function generateUUID() {
|
||||||
|
if (typeof crypto === 'object') {
|
||||||
|
if (typeof crypto.randomUUID === 'function') {
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
if (typeof crypto.getRandomValues === 'function' && typeof Uint8Array === 'function') {
|
||||||
|
// https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
|
||||||
|
const callback = (c: string) => {
|
||||||
|
const num = Number(c);
|
||||||
|
return (num ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (num / 4)))).toString(16);
|
||||||
|
};
|
||||||
|
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let timestamp = new Date().getTime();
|
||||||
|
let perforNow = (typeof performance !== 'undefined' && performance.now && performance.now() * 1000) || 0;
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
let random = Math.random() * 16;
|
||||||
|
if (timestamp > 0) {
|
||||||
|
random = (timestamp + random) % 16 | 0;
|
||||||
|
timestamp = Math.floor(timestamp / 16);
|
||||||
|
} else {
|
||||||
|
random = (perforNow + random) % 16 | 0;
|
||||||
|
perforNow = Math.floor(perforNow / 16);
|
||||||
|
}
|
||||||
|
return (c === 'x' ? random : (random & 0x3) | 0x8).toString(16);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
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
|
44
client/lib/hooks/use-signed-in.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useCallback, useEffect } from "react"
|
||||||
|
import useSharedState from "./use-shared-state";
|
||||||
|
|
||||||
|
const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: boolean }) => {
|
||||||
|
const [isSignedIn, setSignedIn] = useSharedState('isSignedIn', false)
|
||||||
|
const [isLoading, setLoading] = useSharedState('isLoading', true)
|
||||||
|
const signout = useCallback(() => setSignedIn(false), [setSignedIn])
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
if (redirectIfNotAuthed && !isLoading && isSignedIn === false) {
|
||||||
|
router.push('/signin')
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkToken() {
|
||||||
|
const token = localStorage.getItem('drift-token')
|
||||||
|
if (token) {
|
||||||
|
const response = await fetch('/server-api/auth/verify-token', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
setSignedIn(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
checkToken()
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
checkToken()
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [setLoading, setSignedIn])
|
||||||
|
|
||||||
|
return { isSignedIn, isLoading, signout }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSignedIn
|
41
client/lib/time-ago.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
// Modified from https://gist.github.com/IbeVanmeenen/4e3e58820c9168806e57530563612886
|
||||||
|
// which is based on https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site
|
||||||
|
|
||||||
|
const epochs = [
|
||||||
|
['year', 31536000],
|
||||||
|
['month', 2592000],
|
||||||
|
['day', 86400],
|
||||||
|
['hour', 3600],
|
||||||
|
['minute', 60],
|
||||||
|
['second', 1]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Get duration
|
||||||
|
const getDuration = (timeAgoInSeconds: number) => {
|
||||||
|
for (let [name, seconds] of epochs) {
|
||||||
|
const interval = Math.floor(timeAgoInSeconds / seconds);
|
||||||
|
|
||||||
|
if (interval >= 1) {
|
||||||
|
return {
|
||||||
|
interval: interval,
|
||||||
|
epoch: name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
interval: 0,
|
||||||
|
epoch: 'second'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate
|
||||||
|
const timeAgo = (date: Date) => {
|
||||||
|
const timeAgoInSeconds = Math.floor((new Date().getTime() - new Date(date).getTime()) / 1000);
|
||||||
|
const { interval, epoch } = getDuration(timeAgoInSeconds);
|
||||||
|
const suffix = interval === 1 ? '' : 's';
|
||||||
|
|
||||||
|
return `${interval} ${epoch}${suffix} ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default timeAgo
|
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.
|
24
client/next.config.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
const dotenv = require("dotenv");
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
outputStandalone: true,
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/server-api/:path*",
|
||||||
|
destination: `${process.env.API_URL}/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/file/raw/:id",
|
||||||
|
destination: `/api/raw/:id`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
42
client/package.json
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "drift",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 3001",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fec/remark-a11y-emoji": "^3.1.0",
|
||||||
|
"@geist-ui/core": "^2.3.5",
|
||||||
|
"@geist-ui/icons": "^1.0.1",
|
||||||
|
"client-zip": "^2.0.0",
|
||||||
|
"comlink": "^4.3.1",
|
||||||
|
"dotenv": "^16.0.0",
|
||||||
|
"next": "12.1.0",
|
||||||
|
"prismjs": "^1.27.0",
|
||||||
|
"react": "17.0.2",
|
||||||
|
"react-debounce-render": "^8.0.2",
|
||||||
|
"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",
|
||||||
|
"react-syntax-highlighter-virtualized-renderer": "^1.1.0",
|
||||||
|
"rehype-katex": "^6.0.2",
|
||||||
|
"rehype-stringify": "^9.0.3",
|
||||||
|
"remark-gfm": "^3.0.1",
|
||||||
|
"remark-math": "^5.1.1",
|
||||||
|
"swr": "^1.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "17.0.21",
|
||||||
|
"@types/react": "17.0.39",
|
||||||
|
"@types/react-syntax-highlighter": "^13.5.2",
|
||||||
|
"eslint": "8.10.0",
|
||||||
|
"eslint-config-next": "12.1.0",
|
||||||
|
"typescript": "4.6.2"
|
||||||
|
}
|
||||||
|
}
|
73
client/pages/_app.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import '../styles/globals.css'
|
||||||
|
import { GeistProvider, CssBaseline, useTheme } from '@geist-ui/core'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import type { AppProps as NextAppProps } from "next/app";
|
||||||
|
import useSharedState from '../lib/hooks/use-shared-state';
|
||||||
|
|
||||||
|
import 'react-loading-skeleton/dist/skeleton.css'
|
||||||
|
import { SkeletonTheme } from 'react-loading-skeleton';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
export type ThemeProps = {
|
||||||
|
theme: "light" | "dark" | string,
|
||||||
|
changeTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppProps<P = any> = {
|
||||||
|
pageProps: P;
|
||||||
|
} & Omit<NextAppProps<P>, "pageProps">;
|
||||||
|
|
||||||
|
export type DriftProps = ThemeProps
|
||||||
|
|
||||||
|
function MyApp({ Component, pageProps }: AppProps<ThemeProps>) {
|
||||||
|
const [themeType, setThemeType] = useSharedState<string>('theme', 'light')
|
||||||
|
const theme = useTheme();
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || !window.localStorage) return
|
||||||
|
const storedTheme = window.localStorage.getItem('drift-theme')
|
||||||
|
if (storedTheme) setThemeType(storedTheme)
|
||||||
|
// TODO: useReducer?
|
||||||
|
}, [setThemeType, themeType])
|
||||||
|
|
||||||
|
const changeTheme = () => {
|
||||||
|
const newTheme = themeType === 'dark' ? 'light' : 'dark'
|
||||||
|
localStorage.setItem('drift-theme', newTheme)
|
||||||
|
setThemeType(last => (last === 'dark' ? 'light' : 'dark'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const skeletonBaseColor = useMemo(() => {
|
||||||
|
if (themeType === 'dark') return '#333'
|
||||||
|
return '#eee'
|
||||||
|
}, [themeType])
|
||||||
|
const skeletonHighlightColor = useMemo(() => {
|
||||||
|
if (themeType === 'dark') return '#555'
|
||||||
|
return '#ddd'
|
||||||
|
}, [themeType])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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="/apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<link rel="mask-icon" href="/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" />
|
||||||
|
</Head>
|
||||||
|
<GeistProvider themeType={themeType} >
|
||||||
|
<SkeletonTheme baseColor={skeletonBaseColor} highlightColor={skeletonHighlightColor}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Component {...pageProps} theme={themeType || 'light'} changeTheme={changeTheme} />
|
||||||
|
</SkeletonTheme>
|
||||||
|
</GeistProvider>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyApp
|
31
client/pages/_document.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import Document, { Html, Head, Main, NextScript, DocumentContext } from 'next/document'
|
||||||
|
import { CssBaseline } from '@geist-ui/core'
|
||||||
|
|
||||||
|
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
|
24
client/pages/api/raw/[id].ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
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}`)
|
||||||
|
if (file.ok) {
|
||||||
|
const data = await file.json()
|
||||||
|
const { title, content } = data
|
||||||
|
// serve the file raw as plain text
|
||||||
|
res.setHeader("Content-Type", "text/plain")
|
||||||
|
res.setHeader('Cache-Control', 's-maxage=86400');
|
||||||
|
if (download) {
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${title}"`)
|
||||||
|
} else {
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${title}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send(content)
|
||||||
|
} else {
|
||||||
|
res.status(404).send("File not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getRawFile
|
52
client/pages/index.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import Head from 'next/head'
|
||||||
|
import styles from '../styles/Home.module.css'
|
||||||
|
import { Page, Spacer, Text } from '@geist-ui/core'
|
||||||
|
|
||||||
|
import Header from '../components/header'
|
||||||
|
import { ThemeProps } from './_app'
|
||||||
|
import Document from '../components/document'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import ShiftBy from '../components/shift-by'
|
||||||
|
|
||||||
|
export function getStaticProps() {
|
||||||
|
const introDoc = process.env.WELCOME_CONTENT
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
introContent: introDoc,
|
||||||
|
introTitle: process.env.WELCOME_TITLE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = ThemeProps & {
|
||||||
|
introContent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Home = ({ theme, changeTheme, introContent }: Props) => {
|
||||||
|
return (
|
||||||
|
<Page className={styles.container} width="100%">
|
||||||
|
<Head>
|
||||||
|
<title>Drift</title>
|
||||||
|
<meta name="description" content="A self-hostable clone of GitHub Gist" />
|
||||||
|
</Head>
|
||||||
|
<Page.Header>
|
||||||
|
<Header theme={theme} changeTheme={changeTheme} />
|
||||||
|
</Page.Header>
|
||||||
|
<Page.Content width={"var(--main-content-width)"} margin="auto" paddingTop={"var(--gap)"}>
|
||||||
|
<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> Welcome to Drift</Text>
|
||||||
|
</div>
|
||||||
|
<Document
|
||||||
|
editable={false}
|
||||||
|
content={introContent}
|
||||||
|
title={`Welcome to Drift.md`}
|
||||||
|
initialTab={`preview`}
|
||||||
|
/>
|
||||||
|
</Page.Content>
|
||||||
|
</Page >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home
|
25
client/pages/mine.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import Head from 'next/head'
|
||||||
|
import styles from '../styles/Home.module.css'
|
||||||
|
import { Page } from '@geist-ui/core'
|
||||||
|
|
||||||
|
import Header from '../components/header'
|
||||||
|
import MyPosts from '../components/my-posts'
|
||||||
|
|
||||||
|
const Home = ({ theme, changeTheme }: { theme: "light" | "dark", changeTheme: () => void }) => {
|
||||||
|
return (
|
||||||
|
<Page className={styles.container} width="100%">
|
||||||
|
<Head>
|
||||||
|
<title>Drift</title>
|
||||||
|
<meta name="description" content="A self-hostable clone of GitHub Gist" />
|
||||||
|
</Head>
|
||||||
|
<Page.Header>
|
||||||
|
<Header theme={theme} changeTheme={changeTheme} />
|
||||||
|
</Page.Header>
|
||||||
|
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}>
|
||||||
|
<MyPosts />
|
||||||
|
</Page.Content>
|
||||||
|
</Page >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home
|
33
client/pages/new.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import Head from 'next/head'
|
||||||
|
import styles from '../styles/Home.module.css'
|
||||||
|
import NewPost from '../components/new-post'
|
||||||
|
import { Page } from '@geist-ui/core'
|
||||||
|
import useSignedIn from '../lib/hooks/use-signed-in'
|
||||||
|
import Header from '../components/header'
|
||||||
|
import { ThemeProps } from './_app'
|
||||||
|
import { useRouter } from 'next/router'
|
||||||
|
|
||||||
|
const Home = ({ theme, changeTheme }: ThemeProps) => {
|
||||||
|
const router = useRouter()
|
||||||
|
const { isSignedIn, isLoading } = useSignedIn({ redirectIfNotAuthed: true })
|
||||||
|
if (!isSignedIn && !isLoading) {
|
||||||
|
router.push("/signin")
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Page className={styles.container} width="100%">
|
||||||
|
<Head>
|
||||||
|
<title>Drift</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Page.Header>
|
||||||
|
<Header theme={theme} changeTheme={changeTheme} />
|
||||||
|
</Page.Header>
|
||||||
|
|
||||||
|
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="0 auto" className={styles.main}>
|
||||||
|
{isSignedIn && <NewPost />}
|
||||||
|
</Page.Content>
|
||||||
|
</Page >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home
|
106
client/pages/post/[id].tsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import { Button, Page, Text } from "@geist-ui/core";
|
||||||
|
import Skeleton from 'react-loading-skeleton';
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Document from '../../components/document'
|
||||||
|
import Header from "../../components/header";
|
||||||
|
import VisibilityBadge from "../../components/visibility-badge";
|
||||||
|
import { ThemeProps } from "../_app";
|
||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
const Post = ({ theme, changeTheme }: ThemeProps) => {
|
||||||
|
const [post, setPost] = useState<any>()
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchPost() {
|
||||||
|
setIsLoading(true);
|
||||||
|
if (router.query.id) {
|
||||||
|
const post = await fetch(`/server-api/posts/${router.query.id}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${localStorage.getItem("drift-token")}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (post.ok) {
|
||||||
|
const res = await post.json()
|
||||||
|
if (res)
|
||||||
|
setPost(res)
|
||||||
|
else
|
||||||
|
setError("Post not found")
|
||||||
|
} else {
|
||||||
|
if (post.status.toString().startsWith("4")) {
|
||||||
|
router.push("/signin")
|
||||||
|
} else {
|
||||||
|
setError(post.statusText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchPost()
|
||||||
|
}, [router, router.query.id])
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
const clientZip = require("client-zip")
|
||||||
|
|
||||||
|
const blob = await clientZip.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()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page width={"100%"}>
|
||||||
|
<Head>
|
||||||
|
{isLoading && <title>loading - Drift</title>}
|
||||||
|
{!isLoading && <title>{post.title} - Drift</title>}
|
||||||
|
{!isLoading && post.visibility !== 'private' && <meta name="description" content={post.description} />}
|
||||||
|
</Head>
|
||||||
|
<Page.Header>
|
||||||
|
<Header theme={theme} changeTheme={changeTheme} />
|
||||||
|
</Page.Header>
|
||||||
|
<Page.Content width={"var(--main-content-width)"} margin="auto">
|
||||||
|
{error && <Text type="error">{error}</Text>}
|
||||||
|
{/* {!error && (isLoading || !post?.files) && <Loading />} */}
|
||||||
|
{!error && isLoading && <><Text h2><Skeleton width={400} /></Text>
|
||||||
|
<Document skeleton={true} />
|
||||||
|
</>}
|
||||||
|
{!isLoading && post && <>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
|
||||||
|
<Button auto onClick={download}>
|
||||||
|
Download as Zip
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => (
|
||||||
|
<Document
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
content={content}
|
||||||
|
title={title}
|
||||||
|
editable={false}
|
||||||
|
initialTab={'preview'}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>}
|
||||||
|
</Page.Content>
|
||||||
|
</Page >
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Post
|
||||||
|
|
22
client/pages/signin.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { Page } from "@geist-ui/core";
|
||||||
|
import Head from 'next/head'
|
||||||
|
import Auth from "../components/auth";
|
||||||
|
import Header from "../components/header";
|
||||||
|
import { ThemeProps } from "./_app";
|
||||||
|
|
||||||
|
const SignIn = ({ theme, changeTheme }: ThemeProps) => (
|
||||||
|
<Page width={"100%"}>
|
||||||
|
<Head>
|
||||||
|
<title>Drift - Sign In</title>
|
||||||
|
<meta name="description" content="A self-hostable clone of GitHub Gist" />
|
||||||
|
</Head>
|
||||||
|
<Page.Header>
|
||||||
|
<Header theme={theme} changeTheme={changeTheme} />
|
||||||
|
</Page.Header>
|
||||||
|
<Page.Content paddingTop={"var(--gap)"} width={"var(--main-content-width)"} margin="auto">
|
||||||
|
<Auth page="signin" />
|
||||||
|
</Page.Content>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default SignIn
|
22
client/pages/signup.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { Page } from "@geist-ui/core";
|
||||||
|
import Head from "next/head";
|
||||||
|
import Auth from "../components/auth";
|
||||||
|
import Header from "../components/header";
|
||||||
|
import { ThemeProps } from "./_app";
|
||||||
|
|
||||||
|
const SignUp = ({ theme, changeTheme }: ThemeProps) => (
|
||||||
|
<Page width="100%">
|
||||||
|
<Head>
|
||||||
|
<title>Drift - Sign Up</title>
|
||||||
|
<meta name="description" content="A self-hostable clone of GitHub Gist" />
|
||||||
|
</Head>
|
||||||
|
<Page.Header>
|
||||||
|
<Header theme={theme} changeTheme={changeTheme} />
|
||||||
|
</Page.Header>
|
||||||
|
<Page.Content width={"var(--main-content-width)"} paddingTop={"var(--gap)"} margin="auto">
|
||||||
|
<Auth page="signup" />
|
||||||
|
</Page.Content>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default SignUp
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.4 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
124
client/public/assets/logo.svg
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="72.000008"
|
||||||
|
height="72"
|
||||||
|
viewBox="0 0 19.05 19.05"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
inkscape:export-filename="/home/reese/git/github.com/maxleiter/drift/logo.png"
|
||||||
|
inkscape:export-xdpi="682.66669"
|
||||||
|
inkscape:export-ydpi="682.66669"
|
||||||
|
inkscape:version="1.1.2 (1:1.1+202202050950+0a00cf5339)"
|
||||||
|
sodipodi:docname="logo.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#ffffff"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:pageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showgrid="false"
|
||||||
|
showguides="false"
|
||||||
|
inkscape:zoom="13.877295"
|
||||||
|
inkscape:cx="10.448722"
|
||||||
|
inkscape:cy="34.444753"
|
||||||
|
inkscape:current-layer="g3632"
|
||||||
|
units="px"
|
||||||
|
viewbox-width="19.05" />
|
||||||
|
<defs
|
||||||
|
id="defs2">
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath7860">
|
||||||
|
<circle
|
||||||
|
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.93688;stroke-linecap:round"
|
||||||
|
id="circle7862"
|
||||||
|
cx="115.27311"
|
||||||
|
cy="135.3275"
|
||||||
|
r="9.1405506" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath7864">
|
||||||
|
<circle
|
||||||
|
style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:none;stroke-width:2.93688;stroke-linecap:round"
|
||||||
|
id="circle7866"
|
||||||
|
cx="115.27311"
|
||||||
|
cy="135.3275"
|
||||||
|
r="9.1405506" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g
|
||||||
|
inkscape:label="source strokes"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
style="display:none"
|
||||||
|
transform="translate(-106.13256,-126.18696)">
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 114.7741,133.0871 c 0,0 2.24373,3.38322 0.005,7.06735 -2.23896,3.68413 -8.84476,5.87171 -8.84476,5.87171"
|
||||||
|
id="path1824"
|
||||||
|
sodipodi:nodetypes="csc" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 99.71221,140.61603 c 0,0 6.55112,-0.26544 10.1251,-2.27285 3.57398,-2.00741 4.93679,-5.25608 4.93679,-5.25608"
|
||||||
|
id="path857"
|
||||||
|
sodipodi:nodetypes="czc" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 114.7741,133.0871 c 0,0 3.22515,3.50294 1.78507,7.47454 -1.44009,3.97159 -7.66948,7.78507 -7.66948,7.78507"
|
||||||
|
id="path949"
|
||||||
|
sodipodi:nodetypes="czc" />
|
||||||
|
<path
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 114.7741,133.0871 c 0,0 6.66681,-0.12736 17.37373,8.90799"
|
||||||
|
id="path1345"
|
||||||
|
sodipodi:nodetypes="cc" />
|
||||||
|
</g>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1 copy"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="g3632"
|
||||||
|
transform="translate(-106.13256,-126.18696)">
|
||||||
|
<rect
|
||||||
|
style="display:inline;fill:#1b1b1b;fill-opacity:1;stroke:none;stroke-width:2.81834;stroke-linecap:round"
|
||||||
|
id="rect6284"
|
||||||
|
width="18.28112"
|
||||||
|
height="18.28112"
|
||||||
|
x="106.13255"
|
||||||
|
y="126.18695"
|
||||||
|
clip-path="url(#clipPath7864)"
|
||||||
|
transform="matrix(1.0420598,0,0,1.0420598,-4.4639102,-5.3073932)" />
|
||||||
|
<g
|
||||||
|
id="g937"
|
||||||
|
inkscape:label="drift"
|
||||||
|
clip-path="url(#clipPath7860)"
|
||||||
|
mask="none"
|
||||||
|
style="display:inline;stroke-width:0.959638"
|
||||||
|
transform="matrix(1.0420598,0,0,1.0420598,-4.4639102,-5.3073932)">
|
||||||
|
<path
|
||||||
|
id="path935"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.253904px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 132.14783,141.99509 c -10.70692,-9.03535 -17.37373,-8.90799 -17.37373,-8.90799 0,0 2.38807,3.48286 0.94799,7.45446 -1.44009,3.97159 -7.66636,7.74668 -7.66636,7.74668 z"
|
||||||
|
sodipodi:nodetypes="csccc" />
|
||||||
|
<path
|
||||||
|
id="path931"
|
||||||
|
style="fill:#e7e7e7;fill-opacity:1;stroke:none;stroke-width:0.253904px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 108.88969,148.34671 c 0,0 6.22939,-3.81348 7.66948,-7.78507 1.44008,-3.9716 -1.78507,-7.47454 -1.78507,-7.47454 0,0 1.22037,3.09102 -1.01836,6.77515 -2.23896,3.68413 -8.92258,4.9787 -8.92258,4.9787 z"
|
||||||
|
sodipodi:nodetypes="cscccc" />
|
||||||
|
<path
|
||||||
|
id="path933"
|
||||||
|
style="fill:#c6c6c6;fill-opacity:1;stroke:none;stroke-width:0.253904px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||||
|
d="m 105.93434,146.02616 c 0,0 6.6058,-2.18758 8.84476,-5.87171 2.23873,-3.68413 -0.005,-7.06735 -0.005,-7.06735 0,0 -1.36281,3.24867 -4.93679,5.25608 -3.57398,2.00741 -10.1251,2.27285 -10.1251,2.27285 z"
|
||||||
|
sodipodi:nodetypes="csccc" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 653 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 930 B After Width: | Height: | Size: 930 B |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 3 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
28
client/styles/Home.module.css
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
.main {
|
||||||
|
min-height: 100vh;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: var(--main-content-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
32
client/styles/globals.css
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
:root {
|
||||||
|
--main-content-width: 800px;
|
||||||
|
--page-nav-height: 60px;
|
||||||
|
--gap: 8px;
|
||||||
|
--gap-half: calc(var(--gap) / 2);
|
||||||
|
--gap-double: calc(var(--gap) * 2);
|
||||||
|
--border-radius: 4px;
|
||||||
|
--font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--main-content-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||||
|
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
20
client/tsconfig.json
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
2953
client/yarn.lock
Normal file
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "default",
|
|
||||||
"rsc": true,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.js",
|
|
||||||
"css": "app/globals.css",
|
|
||||||
"baseColor": "slate",
|
|
||||||
"cssVariables": true
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@components",
|
|
||||||
"utils": "@utils"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,34 +0,0 @@
|
||||||
services:
|
|
||||||
server:
|
|
||||||
build:
|
|
||||||
context: ./server
|
|
||||||
args:
|
|
||||||
- NODE_ENV=production
|
|
||||||
container_name: server
|
|
||||||
restart: unless-stopped
|
|
||||||
user: 1000:1000
|
|
||||||
environment:
|
|
||||||
- PORT
|
|
||||||
- JWT_SECRET=jwt_secret # change_me! # use `openssl rand -hex 32` to generate a strong secret
|
|
||||||
- SECRET_KEY=secret # change me!
|
|
||||||
- MEMORY_DB
|
|
||||||
- REGISTRATION_PASSWORD
|
|
||||||
- WELCOME_CONTENT
|
|
||||||
- WELCOME_TITLE
|
|
||||||
- ENABLE_ADMIN
|
|
||||||
- DRIFT_HOME
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
client:
|
|
||||||
build:
|
|
||||||
context: ./client
|
|
||||||
args:
|
|
||||||
- API_URL=http://server:3000
|
|
||||||
container_name: client
|
|
||||||
restart: unless-stopped
|
|
||||||
user: 1000:1000
|
|
||||||
environment:
|
|
||||||
- API_URL=http://server:3000
|
|
||||||
- SECRET_KEY=secret # change me!
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
|
@ -1,16 +0,0 @@
|
||||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
|
||||||
module.exports = {
|
|
||||||
preset: "ts-jest",
|
|
||||||
testEnvironment: "node",
|
|
||||||
setupFiles: ["<rootDir>/src/test/setup-tests.ts"],
|
|
||||||
// TODO: update to app dir
|
|
||||||
moduleNameMapper: {
|
|
||||||
"@lib/(.*)": "<rootDir>/src/lib/$1",
|
|
||||||
"@components/(.*)": "<rootDir>/src/app/components/$1",
|
|
||||||
"\\.(css)$": "identity-obj-proxy"
|
|
||||||
},
|
|
||||||
testPathIgnorePatterns: ["/node_modules/", "/.next/"],
|
|
||||||
transform: {
|
|
||||||
"^.+\\.(js|jsx|ts|tsx)$": "ts-jest"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,46 +0,0 @@
|
||||||
import bundleAnalyzer from "@next/bundle-analyzer"
|
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
experimental: {
|
|
||||||
appDir: true
|
|
||||||
},
|
|
||||||
rewrites() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
source: "/file/raw/:id",
|
|
||||||
destination: `/api/raw/:id`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/signout",
|
|
||||||
destination: `/api/auth/signout`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
domains: ["avatars.githubusercontent.com"]
|
|
||||||
},
|
|
||||||
env: {
|
|
||||||
NEXT_PUBLIC_DRIFT_URL:
|
|
||||||
process.env.DRIFT_URL ||
|
|
||||||
(process.env.VERCEL_URL
|
|
||||||
? `https://${process.env.VERCEL_URL}`
|
|
||||||
: "http://localhost:3000")
|
|
||||||
},
|
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: process.env.VERCEL_ENV !== "production"
|
|
||||||
},
|
|
||||||
typescript: {
|
|
||||||
ignoreBuildErrors: process.env.VERCEL_ENV !== "production"
|
|
||||||
},
|
|
||||||
modularizeImports: {
|
|
||||||
"react-feather": {
|
|
||||||
transform: "react-feather/dist/icons/{{kebabCase member}}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default process.env.ANALYZE === "true"
|
|
||||||
? bundleAnalyzer({ enabled: true })(nextConfig)
|
|
||||||
: nextConfig
|
|
113
package.json
|
@ -1,113 +0,0 @@
|
||||||
{
|
|
||||||
"name": "drift",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev --port 3000",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start --port 3000",
|
|
||||||
"lint": "next lint && prettier --list-different --config .prettierrc 'src/{components,lib,app,pages}/**/*.{ts,tsx}' --write",
|
|
||||||
"analyze": "cross-env ANALYZE=true next build",
|
|
||||||
"find:unused": "next-unused",
|
|
||||||
"prisma": "prisma",
|
|
||||||
"jest": "jest"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
|
||||||
"@next/eslint-plugin-next": "13.4.11-canary.0",
|
|
||||||
"@prisma/client": "^5.0.0",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.4",
|
|
||||||
"@radix-ui/react-dialog": "^1.0.3",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.1.3",
|
|
||||||
"@radix-ui/react-popover": "^1.0.5",
|
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
|
||||||
"@radix-ui/react-tabs": "^1.0.3",
|
|
||||||
"@radix-ui/react-tooltip": "^1.0.5",
|
|
||||||
"@tailwindcss/nesting": "0.0.0-insiders.565cd3e",
|
|
||||||
"@tailwindcss/typography": "^0.5.9",
|
|
||||||
"class-variance-authority": "^0.6.0",
|
|
||||||
"client-only": "^0.0.1",
|
|
||||||
"client-zip": "2.3.1",
|
|
||||||
"cmdk": "^0.2.0",
|
|
||||||
"date-fns": "^2.30.0",
|
|
||||||
"jest": "^29.5.0",
|
|
||||||
"lodash.debounce": "^4.0.8",
|
|
||||||
"next": "13.4.11-canary.1",
|
|
||||||
"next-auth": "^4.22.3",
|
|
||||||
"next-themes": "^0.2.1",
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-cookie": "^4.1.1",
|
|
||||||
"react-datepicker": "4.10.0",
|
|
||||||
"react-day-picker": "^8.8.0",
|
|
||||||
"react-dom": "18.2.0",
|
|
||||||
"react-dropzone": "14.2.3",
|
|
||||||
"react-error-boundary": "^4.0.4",
|
|
||||||
"react-feather": "^2.0.10",
|
|
||||||
"react-hot-toast": "2.4.1",
|
|
||||||
"server-only": "^0.0.1",
|
|
||||||
"swr": "^2.2.0",
|
|
||||||
"tailwind-merge": "^1.13.0",
|
|
||||||
"tailwindcss-animate": "^1.0.5",
|
|
||||||
"textarea-markdown-editor": "1.0.4",
|
|
||||||
"ts-jest": "^29.1.0",
|
|
||||||
"uuid": "^9.0.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@next/bundle-analyzer": "13.4.11-canary.0",
|
|
||||||
"@total-typescript/ts-reset": "^0.4.2",
|
|
||||||
"@types/bcrypt": "^5.0.0",
|
|
||||||
"@types/git-http-backend": "^1.0.1",
|
|
||||||
"@types/jest": "^29.4.1",
|
|
||||||
"@types/lodash.debounce": "^4.0.7",
|
|
||||||
"@types/node": "18.15.11",
|
|
||||||
"@types/react": "18.0.35",
|
|
||||||
"@types/react-datepicker": "4.10.0",
|
|
||||||
"@types/react-dom": "18.0.11",
|
|
||||||
"@types/uuid": "^9.0.1",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^5.58.0",
|
|
||||||
"@typescript-eslint/parser": "^5.58.0",
|
|
||||||
"@wcj/markdown-to-html": "^2.2.1",
|
|
||||||
"autoprefixer": "^10.4.14",
|
|
||||||
"clsx": "^1.2.1",
|
|
||||||
"cross-env": "7.0.3",
|
|
||||||
"csstype": "^3.1.2",
|
|
||||||
"dotenv": "^16.0.3",
|
|
||||||
"eslint": "8.38.0",
|
|
||||||
"eslint-config-next": "13.4.11-canary.1",
|
|
||||||
"jest-mock-extended": "^3.0.3",
|
|
||||||
"next-unused": "0.0.6",
|
|
||||||
"postcss": "^8.4.21",
|
|
||||||
"postcss-flexbugs-fixes": "^5.0.2",
|
|
||||||
"postcss-hover-media-feature": "^1.0.2",
|
|
||||||
"postcss-nested": "^6.0.1",
|
|
||||||
"postcss-preset-env": "^8.4.1",
|
|
||||||
"prettier": "2.8.7",
|
|
||||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
|
||||||
"prisma": "^5.0.0",
|
|
||||||
"tailwindcss": "^3.3.2",
|
|
||||||
"typescript": "5.1.6",
|
|
||||||
"typescript-plugin-css-modules": "5.0.1"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"sharp": "^0.32.0"
|
|
||||||
},
|
|
||||||
"next-unused": {
|
|
||||||
"alias": {
|
|
||||||
"@components": "components/",
|
|
||||||
"@lib": "src/lib/",
|
|
||||||
"@styles": "styles/"
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"components",
|
|
||||||
"lib"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"prisma": {
|
|
||||||
"schema": "src/prisma/schema.prisma"
|
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-dom": "18.2.0"
|
|
||||||
}
|
|
||||||
}
|
|
9208
pnpm-lock.yaml
|
@ -1,7 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
"@tailwindcss/nesting": {},
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"extends": ["config:base", "group:allNonMajor", "schedule:earlyMondays"]
|
|
||||||
}
|
|
1
server/.dockerignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
node_modules/
|
3
server/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.env
|
||||||
|
node_modules/
|
||||||
|
dist/
|
38
server/Dockerfile
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
# 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 git
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json yarn.lock tsconfig.json tslint.json ./
|
||||||
|
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 . .
|
||||||
|
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
FROM node:16-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 drift
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
USER drift
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
4
server/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import './src/server';
|
4
server/lib/config.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export default {
|
||||||
|
port: process.env.PORT || 3000,
|
||||||
|
jwt_secret: process.env.JWT_SECRET || 'myjwtsecret',
|
||||||
|
}
|
30
server/lib/middleware/jwt.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import config from '../config';
|
||||||
|
import { User as UserModel } from '../models/User';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserJwtRequest extends Request {
|
||||||
|
user?: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function authenticateToken(req: UserJwtRequest, res: Response, next: NextFunction) {
|
||||||
|
const authHeader = req.headers['authorization']
|
||||||
|
const token = authHeader && authHeader.split(' ')[1]
|
||||||
|
|
||||||
|
if (token == null) return res.sendStatus(401)
|
||||||
|
|
||||||
|
jwt.verify(token, config.jwt_secret, async (err: any, user: any) => {
|
||||||
|
if (err) return res.sendStatus(403)
|
||||||
|
const userObj = await UserModel.findByPk(user.id);
|
||||||
|
if (!userObj) {
|
||||||
|
return res.sendStatus(403);
|
||||||
|
}
|
||||||
|
req.user = user
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}
|
49
server/lib/models/File.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { BelongsTo, Column, CreatedAt, DataType, ForeignKey, IsUUID, Model, PrimaryKey, Scopes, Table } from 'sequelize-typescript';
|
||||||
|
import { Post } from './Post';
|
||||||
|
import { User } from './User';
|
||||||
|
|
||||||
|
|
||||||
|
@Scopes(() => ({
|
||||||
|
full: {
|
||||||
|
include: [{
|
||||||
|
model: User,
|
||||||
|
through: { attributes: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Post,
|
||||||
|
through: { attributes: [] },
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
@Table
|
||||||
|
export class File extends Model {
|
||||||
|
@IsUUID(4)
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.UUID,
|
||||||
|
defaultValue: DataType.UUIDV4,
|
||||||
|
})
|
||||||
|
id!: string
|
||||||
|
|
||||||
|
@Column
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
sha!: string;
|
||||||
|
|
||||||
|
@ForeignKey(() => User)
|
||||||
|
@BelongsTo(() => User, 'userId')
|
||||||
|
user!: User;
|
||||||
|
|
||||||
|
@ForeignKey(() => Post)
|
||||||
|
@BelongsTo(() => Post, 'postId')
|
||||||
|
post!: Post;
|
||||||
|
|
||||||
|
@CreatedAt
|
||||||
|
@Column
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
54
server/lib/models/Post.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import { BelongsToMany, Column, CreatedAt, DataType, HasMany, IsUUID, Model, PrimaryKey, Scopes, Table, UpdatedAt } from 'sequelize-typescript';
|
||||||
|
import { PostAuthor } from './PostAuthor';
|
||||||
|
import { User } from './User';
|
||||||
|
import { File } from './File';
|
||||||
|
|
||||||
|
@Scopes(() => ({
|
||||||
|
user: {
|
||||||
|
include: [{
|
||||||
|
model: User,
|
||||||
|
through: { attributes: [] },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
include: [{
|
||||||
|
model: User,
|
||||||
|
through: { attributes: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: File,
|
||||||
|
through: { attributes: [] },
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
@Table
|
||||||
|
export class Post extends Model {
|
||||||
|
@IsUUID(4)
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.UUID,
|
||||||
|
defaultValue: DataType.UUIDV4,
|
||||||
|
})
|
||||||
|
id!: string
|
||||||
|
|
||||||
|
@Column
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@BelongsToMany(() => User, () => PostAuthor)
|
||||||
|
users?: User[];
|
||||||
|
|
||||||
|
@HasMany(() => File)
|
||||||
|
files?: File[];
|
||||||
|
|
||||||
|
@CreatedAt
|
||||||
|
@Column
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
visibility!: string;
|
||||||
|
|
||||||
|
@UpdatedAt
|
||||||
|
@Column
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
22
server/lib/models/PostAuthor.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { Model, Column, Table, ForeignKey, IsUUID, PrimaryKey, DataType } from "sequelize-typescript";
|
||||||
|
import { Post } from "./Post";
|
||||||
|
import { User } from "./User";
|
||||||
|
|
||||||
|
@Table
|
||||||
|
export class PostAuthor extends Model {
|
||||||
|
@IsUUID(4)
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.UUID,
|
||||||
|
defaultValue: DataType.UUIDV4,
|
||||||
|
})
|
||||||
|
id!: string
|
||||||
|
|
||||||
|
@ForeignKey(() => Post)
|
||||||
|
@Column
|
||||||
|
postId!: number;
|
||||||
|
|
||||||
|
@ForeignKey(() => User)
|
||||||
|
@Column
|
||||||
|
authorId!: number;
|
||||||
|
}
|
47
server/lib/models/User.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { Model, Column, Table, BelongsToMany, Scopes, CreatedAt, UpdatedAt, IsUUID, PrimaryKey, DataType } from "sequelize-typescript";
|
||||||
|
import { Post } from "./Post";
|
||||||
|
import { PostAuthor } from "./PostAuthor";
|
||||||
|
|
||||||
|
@Scopes(() => ({
|
||||||
|
posts: {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Post,
|
||||||
|
through: { attributes: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
withoutPassword: {
|
||||||
|
attributes: {
|
||||||
|
exclude: ["password"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
@Table
|
||||||
|
export class User extends Model {
|
||||||
|
@IsUUID(4)
|
||||||
|
@PrimaryKey
|
||||||
|
@Column({
|
||||||
|
type: DataType.UUID,
|
||||||
|
defaultValue: DataType.UUIDV4,
|
||||||
|
})
|
||||||
|
id!: string
|
||||||
|
|
||||||
|
@Column
|
||||||
|
username!: string;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@BelongsToMany(() => Post, () => PostAuthor)
|
||||||
|
posts?: Post[];
|
||||||
|
|
||||||
|
@CreatedAt
|
||||||
|
@Column
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdatedAt
|
||||||
|
@Column
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
8
server/lib/sequelize.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import {Sequelize} from 'sequelize-typescript';
|
||||||
|
|
||||||
|
export const sequelize = new Sequelize({
|
||||||
|
dialect: 'sqlite',
|
||||||
|
database: 'movies',
|
||||||
|
storage: ':memory:',
|
||||||
|
models: [__dirname + '/models']
|
||||||
|
});
|
39
server/package.json
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
"name": "sequelize-typescript-starter",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"start": "ts-node index.ts",
|
||||||
|
"dev": "nodemon index.ts",
|
||||||
|
"build": "tsc -p ."
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.0.1",
|
||||||
|
"body-parser": "^1.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.0.0",
|
||||||
|
"express": "^4.16.2",
|
||||||
|
"express-jwt": "^6.1.1",
|
||||||
|
"jsonwebtoken": "^8.5.1",
|
||||||
|
"nodemon": "^2.0.15",
|
||||||
|
"reflect-metadata": "^0.1.10",
|
||||||
|
"sequelize": "^6.17.0",
|
||||||
|
"sequelize-typescript": "^2.1.3",
|
||||||
|
"sqlite3": "https://github.com/mapbox/node-sqlite3#918052b538b0effe6c4a44c74a16b2749c08a0d2",
|
||||||
|
"strong-error-handler": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/cors": "^2.8.12",
|
||||||
|
"@types/express": "^4.0.39",
|
||||||
|
"@types/express-jwt": "^6.0.4",
|
||||||
|
"@types/jsonwebtoken": "^8.5.8",
|
||||||
|
"@types/node": "^17.0.21",
|
||||||
|
"ts-node": "^10.6.0",
|
||||||
|
"tslint": "^6.1.3",
|
||||||
|
"typescript": "^4.6.2"
|
||||||
|
}
|
||||||
|
}
|