Compare commits
1 commit
refactor
...
dragAndDro
Author | SHA1 | Date | |
---|---|---|---|
|
9a506bd9da |
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.
|
|
152
README.md
|
@ -1,147 +1,29 @@
|
||||||
# <img src="src/public/assets/logo.png" height="32px" alt="" /> Drift
|
# 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
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
In the root directory, run `pnpm i`. If you need `pnpm`, you can download it [here](https://pnpm.io/installation).
|
|
||||||
You can run `pnpm dev` in `client` for file watching and live reloading.
|
|
||||||
|
|
||||||
To work with [prisma](prisma.io/), you can use `pnpm prisma` or `pnpm exec prisma` to interact with the database.
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
`pnpm build` will produce production code. `pnpm start` will start the Next.js server.
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
You can change these to your liking.
|
|
||||||
|
|
||||||
`.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
|
||||||
|
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.
|
||||||
|
|
||||||
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.
|
- [x] creating and sharing private, public, unlisted posts
|
||||||
|
- [x] syntax highlighting (detected by file extension)
|
||||||
- [x] Next.js 13 `app` directory
|
- [x] multiple files per post
|
||||||
- [x] creating and sharing private, public, password-protected, and unlisted posts
|
- [ ] uploading files via drag-and-drop
|
||||||
- [x] syntax highlighting
|
|
||||||
- [x] expiring posts
|
|
||||||
- [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
|
- [ ] downloading files (individually and entire posts)
|
||||||
- [x] downloading files (individually and entire posts)
|
- [ ] password protected posts
|
||||||
- [x] password protected posts
|
- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development)
|
||||||
- [x] postgres database
|
- [ ] non-node backend
|
||||||
- [x] administrator account / settings
|
- [ ] administrator account / settings
|
||||||
- [x] docker-compose (PRs: [#13](https://github.com/MaxLeiter/Drift/pull/13), [#75](https://github.com/MaxLeiter/Drift/pull/75))
|
- [ ] docker-compose (PR: [#13](https://github.com/MaxLeiter/Drift/pull/13))
|
||||||
- [ ] publish docker builds
|
- [ ] publish docker builds
|
||||||
- [ ] user settings
|
- [ ] user settings
|
||||||
- [ ] works enough with JavaScript disabled
|
- [ ] works enough with JavaScript disabled
|
||||||
- [ ] in-depth documentation
|
- [ ] 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/
|
1
client/.env.local
Normal file
|
@ -0,0 +1 @@
|
||||||
|
API_URL=http://localhost:3000
|
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.
|
12
client/components/Link.tsx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
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.substr(1) : props.href;
|
||||||
|
const href = basePath ? `${basePath}/${propHrefWithoutLeadingSlash}` : props.href;
|
||||||
|
(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('/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('/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
|
31
client/components/document/document.module.css
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
.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%;
|
||||||
|
}
|
116
client/components/document/index.tsx
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
import { Button, Card, Input, Spacer, Tabs, Textarea } from "@geist-ui/core"
|
||||||
|
import { ChangeEvent, memo, useMemo, useRef, useState } from "react"
|
||||||
|
import styles from './document.module.css'
|
||||||
|
import MarkdownPreview from '../preview'
|
||||||
|
import { Trash } from '@geist-ui/icons'
|
||||||
|
import FormattingIcons from "../formatting-icons"
|
||||||
|
import Skeleton from "react-loading-skeleton"
|
||||||
|
type Props = {
|
||||||
|
editable?: boolean
|
||||||
|
remove?: () => void
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
setTitle?: (title: string) => void
|
||||||
|
setContent?: (content: string) => void
|
||||||
|
initialTab?: "edit" | "preview"
|
||||||
|
skeleton?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton }: 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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} />}
|
||||||
|
<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)
|
139
client/components/formatting-icons/index.tsx
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import { ButtonGroup, Button } from "@geist-ui/core"
|
||||||
|
import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons'
|
||||||
|
import { RefObject, useCallback, useMemo } from "react"
|
||||||
|
|
||||||
|
// 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 style={{ position: 'relative', zIndex: 1 }}>
|
||||||
|
<ButtonGroup style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
}}>
|
||||||
|
{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
|
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;
|
||||||
|
}
|
221
client/components/header/index.tsx
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
<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('/api/users/mine', fetcher)
|
||||||
|
return <PostList posts={data} error={error} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MyPosts
|
|
@ -0,0 +1,40 @@
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
178
client/components/new-post/drag-and-drop/index.tsx
Normal file
|
@ -0,0 +1,178 @@
|
||||||
|
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',
|
||||||
|
]
|
||||||
|
|
||||||
|
// TODO: this shouldn't need to know about docs
|
||||||
|
function FileDropzone({ setDocs, docs }: { setDocs: (docs: Document[]) => void, docs: Document[] }) {
|
||||||
|
const { palette } = useTheme()
|
||||||
|
const onDrop = useCallback((acceptedFiles) => {
|
||||||
|
acceptedFiles.forEach((file: File) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
|
||||||
|
reader.onabort = () => console.log('file reading was aborted')
|
||||||
|
reader.onerror = () => console.log('file reading has failed')
|
||||||
|
reader.onload = () => {
|
||||||
|
const content = reader.result as string
|
||||||
|
if (docs.length === 1 && docs[0].content === '') {
|
||||||
|
setDocs([{
|
||||||
|
title: file.name,
|
||||||
|
content,
|
||||||
|
id: generateUUID()
|
||||||
|
}])
|
||||||
|
} else {
|
||||||
|
setDocs([...docs, {
|
||||||
|
title: file.name,
|
||||||
|
content,
|
||||||
|
id: generateUUID()
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
})
|
||||||
|
|
||||||
|
}, [docs, setDocs])
|
||||||
|
|
||||||
|
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('/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;
|
||||||
|
}
|
||||||
|
}
|
39
client/components/new-post/title/index.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
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"
|
||||||
|
marginLeft={'var(--gap)'}
|
||||||
|
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 { duotoneDark, duotoneLight } 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' ? duotoneDark : duotoneLight}
|
||||||
|
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('/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.
|
20
client/next.config.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
const dotenv = require("dotenv");
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
outputStandalone: true,
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: `${process.env.API_URL}/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
41
client/package.json
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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
|
66
client/pages/index.tsx
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
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 = `### Drift is a self-hostable clone of GitHub Gist.
|
||||||
|
#### It is a simple way to share code and text snippets with your friends, with support for the following:
|
||||||
|
|
||||||
|
- Render GitHub Extended Markdown (including images)
|
||||||
|
- User authentication
|
||||||
|
- Private, public, and secret posts
|
||||||
|
|
||||||
|
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).
|
||||||
|
**This demo is on a memory-only database, so accounts and pastes can be deleted at any time.**
|
||||||
|
You can find the source code on [GitHub](https://github.com/MaxLeiter/drift).
|
||||||
|
|
||||||
|
Drift was inspired by [this tweet](https://twitter.com/emilyst/status/1499858264346935297):
|
||||||
|
> What is the absolute closest thing to GitHub Gist that can be self-hosted?
|
||||||
|
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.
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
introContent: introDoc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
82
client/pages/post/[id].tsx
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { 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(`/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])
|
||||||
|
|
||||||
|
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 && <><Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
|
||||||
|
{post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => (
|
||||||
|
<Document
|
||||||
|
key={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"]
|
||||||
|
}
|
2948
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"
|
||||||
|
}
|
||||||
|
}
|
24
server/src/app.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import * as express from 'express';
|
||||||
|
import * as bodyParser from 'body-parser';
|
||||||
|
import * as errorhandler from 'strong-error-handler';
|
||||||
|
import * as cors from 'cors';
|
||||||
|
import { posts, users, auth } from './routes';
|
||||||
|
|
||||||
|
export const app = express();
|
||||||
|
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true }));
|
||||||
|
app.use(bodyParser.json({ limit: '5mb' }));
|
||||||
|
|
||||||
|
const corsOptions = {
|
||||||
|
origin: `http://localhost:3001`,
|
||||||
|
};
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
|
||||||
|
app.use("/auth", auth)
|
||||||
|
app.use("/posts", posts)
|
||||||
|
app.use("/users", users)
|
||||||
|
|
||||||
|
app.use(errorhandler({
|
||||||
|
debug: process.env.ENV !== 'production',
|
||||||
|
log: true,
|
||||||
|
}));
|