client: add downloading and viewing raw files (#21)
This commit is contained in:
parent
606e38e192
commit
f9e9c6fe06
20 changed files with 189 additions and 49 deletions
|
@ -1,13 +1,13 @@
|
|||
# <img src="client/public/assets/logo.png" height="32px" alt="" /> Drift
|
||||
|
||||
|
||||
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 also a major work-in-progress, but is (almost, no database yet) 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.
|
||||
|
||||
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).
|
||||
|
||||
## 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.
|
||||
|
||||
- [x] creating and sharing private, public, unlisted posts
|
||||
|
@ -17,7 +17,7 @@ Drit is a major work in progress. Below is a (rough) list of completed and envis
|
|||
- [x] responsive UI
|
||||
- [x] user auth
|
||||
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11))
|
||||
- [ ] downloading files (individually and entire posts)
|
||||
- [x] downloading files (individually and entire posts)
|
||||
- [ ] password protected posts
|
||||
- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development)
|
||||
- [ ] non-node backend
|
||||
|
|
|
@ -3,9 +3,8 @@ 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 propHrefWithoutLeadingSlash = props.href && props.href.startsWith("/") ? props.href.substring(1) : props.href;
|
||||
const href = basePath ? `${basePath}/${propHrefWithoutLeadingSlash}` : props.href;
|
||||
(href)
|
||||
return <GeistLink {...props} href={href} />
|
||||
}
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
|
|||
e.preventDefault()
|
||||
if (signingIn) {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/signin', reqOpts)
|
||||
const resp = await fetch('/server-api/auth/signin', reqOpts)
|
||||
const json = await resp.json()
|
||||
handleJson(json)
|
||||
} catch (err: any) {
|
||||
|
@ -43,7 +43,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
|
|||
}
|
||||
} else {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/signup', reqOpts)
|
||||
const resp = await fetch('/server-api/auth/signup', reqOpts)
|
||||
const json = await resp.json()
|
||||
handleJson(json)
|
||||
} catch (err: any) {
|
||||
|
|
|
@ -29,3 +29,13 @@
|
|||
.textarea {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.actionWrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.actionWrapper .actions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { ButtonGroup, Button } from "@geist-ui/core"
|
||||
import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons'
|
||||
import { RefObject, useCallback, useMemo } from "react"
|
||||
import styles from '../document.module.css'
|
||||
|
||||
// TODO: clean up
|
||||
|
||||
|
@ -122,11 +123,8 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
|
|||
], [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick])
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<ButtonGroup style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
}}>
|
||||
<div className={styles.actionWrapper}>
|
||||
<ButtonGroup className={styles.actions}>
|
||||
{formattingActions.map(({ icon, name, action }) => (
|
||||
<Button auto scale={2 / 3} px={0.6} aria-label={name} key={name} icon={icon} onMouseDown={(e) => e.preventDefault()} onClick={action} />
|
||||
))}
|
|
@ -1,10 +1,11 @@
|
|||
import { Button, Card, Input, Spacer, Tabs, Textarea } from "@geist-ui/core"
|
||||
import { ChangeEvent, memo, useMemo, useRef, useState } from "react"
|
||||
import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
|
||||
import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react"
|
||||
import styles from './document.module.css'
|
||||
import MarkdownPreview from '../preview'
|
||||
import { Trash } from '@geist-ui/icons'
|
||||
import FormattingIcons from "../formatting-icons"
|
||||
import { Trash, Download, ExternalLink } from '@geist-ui/icons'
|
||||
import FormattingIcons from "./formatting-icons"
|
||||
import Skeleton from "react-loading-skeleton"
|
||||
// import Link from "next/link"
|
||||
type Props = {
|
||||
editable?: boolean
|
||||
remove?: () => void
|
||||
|
@ -14,9 +15,38 @@ type Props = {
|
|||
setContent?: (content: string) => void
|
||||
initialTab?: "edit" | "preview"
|
||||
skeleton?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton }: Props) => {
|
||||
const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
|
||||
return (<div className={styles.actionWrapper}>
|
||||
<ButtonGroup className={styles.actions}>
|
||||
<Tooltip text="Download">
|
||||
<a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
scale={2 / 3} px={0.6}
|
||||
icon={<Download />}
|
||||
auto
|
||||
aria-label="Download"
|
||||
/>
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip text="Open raw in new tab">
|
||||
<a href={rawLink} target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
scale={2 / 3} px={0.6}
|
||||
icon={<ExternalLink />}
|
||||
auto
|
||||
aria-label="Open raw file in new tab"
|
||||
/>
|
||||
</a>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton, id }: Props) => {
|
||||
const codeEditorRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [tab, setTab] = useState(initialTab)
|
||||
const height = editable ? "500px" : '100%'
|
||||
|
@ -47,6 +77,13 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawLink = useMemo(() => {
|
||||
if (id) {
|
||||
return `/file/raw/${id}`
|
||||
}
|
||||
}, [id])
|
||||
|
||||
if (skeleton) {
|
||||
return <>
|
||||
<Spacer height={1} />
|
||||
|
@ -82,6 +119,7 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
|
|||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
{tab === 'edit' && editable && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
|
||||
{rawLink && <DownloadButton rawLink={rawLink} />}
|
||||
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
|
||||
<Tabs.Item label={editable ? "Edit" : "Raw"} value="edit">
|
||||
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}
|
||||
|
|
|
@ -175,6 +175,7 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
|
|||
auto
|
||||
type="abort"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<Spacer height={5 / 6} width={0} />
|
||||
<MenuIcon />
|
||||
|
|
|
@ -9,7 +9,7 @@ const fetcher = (url: string) => fetch(url, {
|
|||
}).then(r => r.json())
|
||||
|
||||
const MyPosts = () => {
|
||||
const { data, error } = useSWR('/api/users/mine', fetcher)
|
||||
const { data, error } = useSWR('/server-api/users/mine', fetcher)
|
||||
return <PostList posts={data} error={error} />
|
||||
}
|
||||
|
||||
|
|
|
@ -94,35 +94,37 @@ const allowedFileExtensions = [
|
|||
'webmanifest',
|
||||
]
|
||||
|
||||
// TODO: this shouldn't need to know about docs
|
||||
function FileDropzone({ setDocs, docs }: { setDocs: (docs: Document[]) => void, docs: Document[] }) {
|
||||
function FileDropzone({ setDocs, docs }: { setDocs: React.Dispatch<React.SetStateAction<Document[]>>, docs: Document[] }) {
|
||||
const { palette } = useTheme()
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
acceptedFiles.forEach((file: File) => {
|
||||
const reader = new FileReader()
|
||||
const { setToast } = useToasts()
|
||||
const onDrop = useCallback(async (acceptedFiles) => {
|
||||
const newDocs = await Promise.all(acceptedFiles.map((file: File) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onabort = () => 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([{
|
||||
reader.onabort = () => setToast({ text: 'File reading was aborted', type: 'error' })
|
||||
reader.onerror = () => setToast({ text: 'File reading failed', type: 'error' })
|
||||
reader.onload = () => {
|
||||
const content = reader.result as string
|
||||
resolve({
|
||||
title: file.name,
|
||||
content,
|
||||
id: generateUUID()
|
||||
}])
|
||||
} else {
|
||||
setDocs([...docs, {
|
||||
title: file.name,
|
||||
content,
|
||||
id: generateUUID()
|
||||
}])
|
||||
})
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
})
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}))
|
||||
|
||||
}, [docs, setDocs])
|
||||
if (docs.length === 1) {
|
||||
if (docs[0].content === '') {
|
||||
setDocs(newDocs)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setDocs((oldDocs) => [...oldDocs, ...newDocs])
|
||||
}, [setDocs, setToast, docs])
|
||||
|
||||
const validator = (file: File) => {
|
||||
// TODO: make this configurable
|
||||
|
|
|
@ -31,7 +31,7 @@ const Post = () => {
|
|||
|
||||
const onSubmit = async (visibility: string) => {
|
||||
setSubmitting(true)
|
||||
const response = await fetch('/api/posts/create', {
|
||||
const response = await fetch('/server-api/posts/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
|
@ -16,7 +16,7 @@ const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: bo
|
|||
async function checkToken() {
|
||||
const token = localStorage.getItem('drift-token')
|
||||
if (token) {
|
||||
const response = await fetch('/api/auth/verify-token', {
|
||||
const response = await fetch('/server-api/auth/verify-token', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
|
|
|
@ -10,9 +10,13 @@ const nextConfig = {
|
|||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
source: "/server-api/:path*",
|
||||
destination: `${process.env.API_URL}/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/file/raw/:id",
|
||||
destination: `/api/raw/:id`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"@fec/remark-a11y-emoji": "^3.1.0",
|
||||
"@geist-ui/core": "^2.3.5",
|
||||
"@geist-ui/icons": "^1.0.1",
|
||||
"client-zip": "^2.0.0",
|
||||
"comlink": "^4.3.1",
|
||||
"dotenv": "^16.0.0",
|
||||
"next": "12.1.0",
|
||||
|
|
24
client/pages/api/raw/[id].ts
Normal file
24
client/pages/api/raw/[id].ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
|
||||
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const { id, download } = req.query
|
||||
const file = await fetch(`${process.env.API_URL}/files/raw/${id}`)
|
||||
if (file.ok) {
|
||||
const data = await file.json()
|
||||
const { title, content } = data
|
||||
// serve the file raw as plain text
|
||||
res.setHeader("Content-Type", "text/plain")
|
||||
res.setHeader('Cache-Control', 's-maxage=86400');
|
||||
if (download) {
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${title}"`)
|
||||
} else {
|
||||
res.setHeader("Content-Disposition", `inline; filename="${title}"`)
|
||||
}
|
||||
|
||||
res.status(200).send(content)
|
||||
} else {
|
||||
res.status(404).send("File not found")
|
||||
}
|
||||
}
|
||||
|
||||
export default getRawFile
|
|
@ -1,4 +1,4 @@
|
|||
import { Page, Text } from "@geist-ui/core";
|
||||
import { Button, Page, Text } from "@geist-ui/core";
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
@ -19,7 +19,7 @@ const Post = ({ theme, changeTheme }: ThemeProps) => {
|
|||
async function fetchPost() {
|
||||
setIsLoading(true);
|
||||
if (router.query.id) {
|
||||
const post = await fetch(`/api/posts/${router.query.id}`, {
|
||||
const post = await fetch(`/server-api/posts/${router.query.id}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
@ -46,6 +46,23 @@ const Post = ({ theme, changeTheme }: ThemeProps) => {
|
|||
fetchPost()
|
||||
}, [router, router.query.id])
|
||||
|
||||
const download = async () => {
|
||||
const clientZip = require("client-zip")
|
||||
|
||||
const blob = await clientZip.downloadZip(post.files.map((file: any) => {
|
||||
return {
|
||||
name: file.title,
|
||||
input: file.content,
|
||||
lastModified: new Date(file.updatedAt)
|
||||
}
|
||||
})).blob()
|
||||
const link = document.createElement("a")
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `${post.title}.zip`
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
return (
|
||||
<Page width={"100%"}>
|
||||
<Head>
|
||||
|
@ -62,10 +79,17 @@ const Post = ({ theme, changeTheme }: ThemeProps) => {
|
|||
{!error && isLoading && <><Text h2><Skeleton width={400} /></Text>
|
||||
<Document skeleton={true} />
|
||||
</>}
|
||||
{!isLoading && post && <><Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
|
||||
{!isLoading && post && <>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
|
||||
<Button auto onClick={download}>
|
||||
Download as Zip
|
||||
</Button>
|
||||
</div>
|
||||
{post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => (
|
||||
<Document
|
||||
key={id}
|
||||
id={id}
|
||||
content={content}
|
||||
title={title}
|
||||
editable={false}
|
||||
|
|
|
@ -480,6 +480,11 @@ character-reference-invalid@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560"
|
||||
integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==
|
||||
|
||||
client-zip@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/client-zip/-/client-zip-2.0.0.tgz#c93676c92ddb40c858da83517c27297a53874f8d"
|
||||
integrity sha512-JFd4zdhxk5F01NmNnBq3+iMgJkkh0ku9NsI1wZlUjZ52inPJX92vR5TlSkjxRhmHJBPI7YqanD71wDEiKhdWtw==
|
||||
|
||||
clsx@^1.0.4:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
|
||||
|
|
|
@ -2,7 +2,7 @@ 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';
|
||||
import { posts, users, auth, files } from './routes';
|
||||
|
||||
export const app = express();
|
||||
|
||||
|
@ -17,6 +17,7 @@ app.use(cors(corsOptions));
|
|||
app.use("/auth", auth)
|
||||
app.use("/posts", posts)
|
||||
app.use("/users", users)
|
||||
app.use("/files", files)
|
||||
|
||||
app.use(errorhandler({
|
||||
debug: process.env.ENV !== 'production',
|
||||
|
|
29
server/src/routes/files.ts
Normal file
29
server/src/routes/files.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Router } from 'express'
|
||||
// import { Movie } from '../models/Post'
|
||||
import { File } from '../../lib/models/File'
|
||||
|
||||
export const files = Router()
|
||||
|
||||
files.get("/raw/:id", async (req, res, next) => {
|
||||
try {
|
||||
const file = await File.findOne({
|
||||
where: {
|
||||
id: req.params.id
|
||||
},
|
||||
attributes: ["title", "content"],
|
||||
})
|
||||
// TODO: fix post inclusion
|
||||
// if (file?.post.visibility === 'public' || file?.post.visibility === 'unlisted') {
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.json(file);
|
||||
// } else {
|
||||
// TODO: should this be `private, `?
|
||||
// res.setHeader("Cache-Control", "max-age=86400");
|
||||
// res.json(file);
|
||||
// }
|
||||
}
|
||||
catch (e) {
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { auth } from './auth';
|
||||
export { posts } from './posts';
|
||||
export { users } from './users';
|
||||
export { files } from './files';
|
||||
|
|
|
@ -69,7 +69,7 @@ posts.get("/:id", async (req: UserJwtRequest, res, next) => {
|
|||
{
|
||||
model: File,
|
||||
as: "files",
|
||||
attributes: ["id", "title", "content", "sha"],
|
||||
attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"],
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
|
@ -80,8 +80,11 @@ posts.get("/:id", async (req: UserJwtRequest, res, next) => {
|
|||
})
|
||||
|
||||
if (post?.visibility === 'public' || post?.visibility === 'unlisted') {
|
||||
res.setHeader("Cache-Control", "public, max-age=86400");
|
||||
res.json(post);
|
||||
} else {
|
||||
// TODO: should this be `private, `?
|
||||
res.setHeader("Cache-Control", "max-age=86400");
|
||||
jwt(req, res, () => {
|
||||
res.json(post);
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue