Compare commits

..

1 commit

Author SHA1 Message Date
Max Leiter
9a506bd9da
client: drag and drop file uploading 2022-03-11 15:17:00 -08:00
301 changed files with 8586 additions and 21149 deletions

View file

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

View file

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

1
.github/FUNDING.yml vendored
View file

@ -1 +0,0 @@
github: MaxLeiter

View file

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

View file

@ -1,10 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: Feature request
assignees: ''
---

View file

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

View file

@ -1,8 +0,0 @@
{
"semi": false,
"trailingComma": "none",
"singleQuote": false,
"printWidth": 80,
"useTabs": true,
"plugins": ["prettier-plugin-tailwindcss"]
}

View file

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

View file

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

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

150
README.md
View file

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

@ -0,0 +1 @@
node_modules/

1
client/.env.local Normal file
View file

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

3
client/.eslintrc.json Normal file
View file

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

View file

@ -4,7 +4,6 @@
/node_modules /node_modules
/.pnp /.pnp
.pnp.js .pnp.js
analyze
# testing # testing
/coverage /coverage

56
client/Dockerfile Normal file
View 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
View file

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

View file

@ -0,0 +1,12 @@
import { 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

View 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;
}

View 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&apos;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

View 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%;
}

View 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)

View 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

View 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);

View file

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

View file

@ -0,0 +1,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 > * /}

View 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

View file

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

View 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

View 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

View 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;
}
}

View 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)

View 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

View 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

View 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

View 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;
}

View 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)

View 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);
}

View 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

View file

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

View file

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

View 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);
});
};

View file

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

View file

@ -0,0 +1,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
View 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

View file

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

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

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

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View 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

View file

Before

Width:  |  Height:  |  Size: 653 B

After

Width:  |  Height:  |  Size: 653 B

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

Before

Width:  |  Height:  |  Size: 930 B

After

Width:  |  Height:  |  Size: 930 B

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

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

File diff suppressed because it is too large Load diff

View 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"
}
}

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,7 +0,0 @@
module.exports = {
plugins: {
"@tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {}
}
}

View file

@ -1,3 +0,0 @@
{
"extends": ["config:base", "group:allNonMajor", "schedule:earlyMondays"]
}

1
server/.dockerignore Normal file
View file

@ -0,0 +1 @@
node_modules/

3
server/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.env
node_modules/
dist/

38
server/Dockerfile Normal file
View 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
View file

@ -0,0 +1,4 @@
import * as dotenv from 'dotenv';
dotenv.config();
import './src/server';

4
server/lib/config.ts Normal file
View file

@ -0,0 +1,4 @@
export default {
port: process.env.PORT || 3000,
jwt_secret: process.env.JWT_SECRET || 'myjwtsecret',
}

View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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,
}));

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