Dashboards (#791)
* Create backend for Dashboards * Rm lastupdatetime for now * Added a create-dashboard and sharable view dashboard page * Various nit fixes. * Renamed Dashboards to Posts * Fix nits
This commit is contained in:
parent
1d1b09c938
commit
851cffd73e
12
common/post.ts
Normal file
12
common/post.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { JSONContent } from '@tiptap/core'
|
||||
|
||||
export type Post = {
|
||||
id: string
|
||||
title: string
|
||||
content: JSONContent
|
||||
creatorId: string // User id
|
||||
createdTime: number
|
||||
slug: string
|
||||
}
|
||||
|
||||
export const MAX_POST_TITLE_LENGTH = 480
|
|
@ -175,5 +175,14 @@ service cloud.firestore {
|
|||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
|
||||
}
|
||||
}
|
||||
|
||||
match /posts/{postId} {
|
||||
allow read;
|
||||
allow update: if request.auth.uid == resource.data.creatorId
|
||||
&& request.resource.data.diff(resource.data)
|
||||
.affectedKeys()
|
||||
.hasOnly(['name', 'content']);
|
||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
83
functions/src/create-post.ts
Normal file
83
functions/src/create-post.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { getUser } from './utils'
|
||||
import { slugify } from '../../common/util/slugify'
|
||||
import { randomString } from '../../common/util/random'
|
||||
import { Post, MAX_POST_TITLE_LENGTH } from '../../common/post'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
const contentSchema: z.ZodType<JSONContent> = z.lazy(() =>
|
||||
z.intersection(
|
||||
z.record(z.any()),
|
||||
z.object({
|
||||
type: z.string().optional(),
|
||||
attrs: z.record(z.any()).optional(),
|
||||
content: z.array(contentSchema).optional(),
|
||||
marks: z
|
||||
.array(
|
||||
z.intersection(
|
||||
z.record(z.any()),
|
||||
z.object({
|
||||
type: z.string(),
|
||||
attrs: z.record(z.any()).optional(),
|
||||
})
|
||||
)
|
||||
)
|
||||
.optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const postSchema = z.object({
|
||||
title: z.string().min(1).max(MAX_POST_TITLE_LENGTH),
|
||||
content: contentSchema,
|
||||
})
|
||||
|
||||
export const createpost = newEndpoint({}, async (req, auth) => {
|
||||
const firestore = admin.firestore()
|
||||
const { title, content } = validate(postSchema, req.body)
|
||||
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator)
|
||||
throw new APIError(400, 'No user exists with the authenticated user ID.')
|
||||
|
||||
console.log('creating post owned by', creator.username, 'titled', title)
|
||||
|
||||
const slug = await getSlug(title)
|
||||
|
||||
const postRef = firestore.collection('posts').doc()
|
||||
|
||||
const post: Post = {
|
||||
id: postRef.id,
|
||||
creatorId: creator.id,
|
||||
slug,
|
||||
title,
|
||||
createdTime: Date.now(),
|
||||
content: content,
|
||||
}
|
||||
|
||||
await postRef.create(post)
|
||||
|
||||
return { status: 'success', post }
|
||||
})
|
||||
|
||||
export const getSlug = async (title: string) => {
|
||||
const proposedSlug = slugify(title)
|
||||
|
||||
const preexistingPost = await getPostFromSlug(proposedSlug)
|
||||
|
||||
return preexistingPost ? proposedSlug + '-' + randomString() : proposedSlug
|
||||
}
|
||||
|
||||
export async function getPostFromSlug(slug: string) {
|
||||
const firestore = admin.firestore()
|
||||
const snap = await firestore
|
||||
.collection('posts')
|
||||
.where('slug', '==', slug)
|
||||
.get()
|
||||
|
||||
return snap.empty ? undefined : (snap.docs[0].data() as Post)
|
||||
}
|
|
@ -72,6 +72,7 @@ import { stripewebhook, createcheckoutsession } from './stripe'
|
|||
import { getcurrentuser } from './get-current-user'
|
||||
import { acceptchallenge } from './accept-challenge'
|
||||
import { getcustomtoken } from './get-custom-token'
|
||||
import { createpost } from './create-post'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
return onRequest(opts, handler as any)
|
||||
|
@ -97,6 +98,7 @@ const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
|||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
|
||||
const createPostFunction = toCloudFunction(createpost)
|
||||
|
||||
export {
|
||||
healthFunction as health,
|
||||
|
@ -120,4 +122,5 @@ export {
|
|||
getCurrentUserFunction as getcurrentuser,
|
||||
acceptChallenge as acceptchallenge,
|
||||
getCustomTokenFunction as getcustomtoken,
|
||||
createPostFunction as createpost,
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ import { unsubscribe } from './unsubscribe'
|
|||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { getcustomtoken } from './get-custom-token'
|
||||
import { createpost } from './create-post'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
const app = express()
|
||||
|
@ -67,6 +68,7 @@ addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
|||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||
addEndpointRoute('/getcustomtoken', getcustomtoken)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
addEndpointRoute('/createpost', createpost)
|
||||
|
||||
app.listen(PORT)
|
||||
console.log(`Serving functions on port ${PORT}.`)
|
||||
|
|
|
@ -4,6 +4,7 @@ import { chunk } from 'lodash'
|
|||
import { Contract } from '../../common/contract'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { Group } from '../../common/group'
|
||||
import { Post } from 'common/post'
|
||||
|
||||
export const log = (...args: unknown[]) => {
|
||||
console.log(`[${new Date().toISOString()}]`, ...args)
|
||||
|
@ -80,6 +81,10 @@ export const getGroup = (groupId: string) => {
|
|||
return getDoc<Group>('groups', groupId)
|
||||
}
|
||||
|
||||
export const getPost = (postId: string) => {
|
||||
return getDoc<Post>('posts', postId)
|
||||
}
|
||||
|
||||
export const getUser = (userId: string) => {
|
||||
return getDoc<User>('users', userId)
|
||||
}
|
||||
|
|
|
@ -10,7 +10,11 @@ export function FileUploadButton(props: {
|
|||
const ref = useRef<HTMLInputElement>(null)
|
||||
return (
|
||||
<>
|
||||
<button className={className} onClick={() => ref.current?.click()}>
|
||||
<button
|
||||
type={'button'}
|
||||
className={className}
|
||||
onClick={() => ref.current?.click()}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<input
|
||||
|
|
46
web/components/share-post-modal.tsx
Normal file
46
web/components/share-post-modal.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { LinkIcon } from '@heroicons/react/outline'
|
||||
import toast from 'react-hot-toast'
|
||||
import { copyToClipboard } from 'web/lib/util/copy'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { Modal } from './layout/modal'
|
||||
import { Col } from './layout/col'
|
||||
import { Title } from './title'
|
||||
import { Button } from './button'
|
||||
import { TweetButton } from './tweet-button'
|
||||
import { Row } from './layout/row'
|
||||
|
||||
export function SharePostModal(props: {
|
||||
shareUrl: string
|
||||
isOpen: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}) {
|
||||
const { isOpen, setOpen, shareUrl } = props
|
||||
|
||||
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setOpen} size="md">
|
||||
<Col className="gap-4 rounded bg-white p-4">
|
||||
<Title className="!mt-0 !mb-2" text="Share this post" />
|
||||
<Button
|
||||
size="2xl"
|
||||
color="gradient"
|
||||
className={'mb-2 flex max-w-xs self-center'}
|
||||
onClick={() => {
|
||||
copyToClipboard(shareUrl)
|
||||
toast.success('Link copied!', {
|
||||
icon: linkIcon,
|
||||
})
|
||||
track('copy share post link')
|
||||
}}
|
||||
>
|
||||
{linkIcon} Copy link
|
||||
</Button>
|
||||
|
||||
<Row className="z-0 justify-start gap-4 self-center">
|
||||
<TweetButton className="self-start" tweetText={shareUrl} />
|
||||
</Row>
|
||||
</Col>
|
||||
</Modal>
|
||||
)
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { auth } from './users'
|
||||
import { APIError, getFunctionUrl } from 'common/api'
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
export { APIError } from 'common/api'
|
||||
|
||||
export async function call(url: string, method: string, params: any) {
|
||||
|
@ -88,3 +89,7 @@ export function acceptChallenge(params: any) {
|
|||
export function getCurrentUser(params: any) {
|
||||
return call(getFunctionUrl('getcurrentuser'), 'GET', params)
|
||||
}
|
||||
|
||||
export function createPost(params: { title: string; content: JSONContent }) {
|
||||
return call(getFunctionUrl('createpost'), 'POST', params)
|
||||
}
|
||||
|
|
34
web/lib/firebase/posts.ts
Normal file
34
web/lib/firebase/posts.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
deleteDoc,
|
||||
doc,
|
||||
getDocs,
|
||||
query,
|
||||
updateDoc,
|
||||
where,
|
||||
} from 'firebase/firestore'
|
||||
import { Post } from 'common/post'
|
||||
import { coll, getValue } from './utils'
|
||||
|
||||
export const posts = coll<Post>('posts')
|
||||
|
||||
export function postPath(postSlug: string) {
|
||||
return `/post/${postSlug}`
|
||||
}
|
||||
|
||||
export function updatePost(post: Post, updates: Partial<Post>) {
|
||||
return updateDoc(doc(posts, post.id), updates)
|
||||
}
|
||||
|
||||
export function deletePost(post: Post) {
|
||||
return deleteDoc(doc(posts, post.id))
|
||||
}
|
||||
|
||||
export function getPost(postId: string) {
|
||||
return getValue<Post>(doc(posts, postId))
|
||||
}
|
||||
|
||||
export async function getPostBySlug(slug: string) {
|
||||
const q = query(posts, where('slug', '==', slug))
|
||||
const docs = (await getDocs(q)).docs
|
||||
return docs.length === 0 ? null : docs[0].data()
|
||||
}
|
93
web/pages/create-post.tsx
Normal file
93
web/pages/create-post.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { useState } from 'react'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { Page } from 'web/components/page'
|
||||
import { Title } from 'web/components/title'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
|
||||
import { TextEditor, useTextEditor } from 'web/components/editor'
|
||||
import { createPost } from 'web/lib/firebase/api'
|
||||
import clsx from 'clsx'
|
||||
import Router from 'next/router'
|
||||
import { MAX_POST_TITLE_LENGTH } from 'common/post'
|
||||
import { postPath } from 'web/lib/firebase/posts'
|
||||
|
||||
export default function CreatePost() {
|
||||
const [title, setTitle] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const { editor, upload } = useTextEditor({
|
||||
disabled: isSubmitting,
|
||||
})
|
||||
|
||||
const isValid = editor && title.length > 0 && editor.isEmpty === false
|
||||
|
||||
async function savePost(title: string) {
|
||||
if (!editor) return
|
||||
const newPost = {
|
||||
title: title,
|
||||
content: editor.getJSON(),
|
||||
}
|
||||
|
||||
const result = await createPost(newPost).catch((e) => {
|
||||
console.log(e)
|
||||
setError('There was an error creating the post, please try again')
|
||||
return e
|
||||
})
|
||||
if (result.post) {
|
||||
await Router.push(postPath(result.post.slug))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<div className="rounded-lg px-6 py-4 sm:py-0">
|
||||
<Title className="!mt-0" text="Create a post" />
|
||||
<form>
|
||||
<div className="form-control w-full">
|
||||
<label className="label">
|
||||
<span className="mb-1">
|
||||
Title<span className={'text-red-700'}> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder="e.g. Elon Mania Post"
|
||||
className="input input-bordered resize-none"
|
||||
autoFocus
|
||||
maxLength={MAX_POST_TITLE_LENGTH}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value || '')}
|
||||
/>
|
||||
<Spacer h={6} />
|
||||
<label className="label">
|
||||
<span className="mb-1">
|
||||
Content<span className={'text-red-700'}> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<TextEditor editor={editor} upload={upload} />
|
||||
<Spacer h={6} />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
'btn btn-primary normal-case',
|
||||
isSubmitting && 'loading disabled'
|
||||
)}
|
||||
disabled={isSubmitting || !isValid || upload.isLoading}
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true)
|
||||
await savePost(title)
|
||||
setIsSubmitting(false)
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create a post'}
|
||||
</button>
|
||||
{error !== '' && <div className="text-red-700">{error}</div>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
97
web/pages/post/[...slugs]/index.tsx
Normal file
97
web/pages/post/[...slugs]/index.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
import { Page } from 'web/components/page'
|
||||
|
||||
import { postPath, getPostBySlug } from 'web/lib/firebase/posts'
|
||||
import { Post } from 'common/post'
|
||||
import { Title } from 'web/components/title'
|
||||
import { Spacer } from 'web/components/layout/spacer'
|
||||
import { Content } from 'web/components/editor'
|
||||
import { UserLink } from 'web/components/user-page'
|
||||
import { getUser, User } from 'web/lib/firebase/users'
|
||||
import { ShareIcon } from '@heroicons/react/solid'
|
||||
import clsx from 'clsx'
|
||||
import { Button } from 'web/components/button'
|
||||
import { useState } from 'react'
|
||||
import { SharePostModal } from 'web/components/share-post-modal'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { ENV_CONFIG } from 'common/envs/constants'
|
||||
import Custom404 from 'web/pages/404'
|
||||
|
||||
export async function getStaticProps(props: { params: { slugs: string[] } }) {
|
||||
const { slugs } = props.params
|
||||
|
||||
const post = await getPostBySlug(slugs[0])
|
||||
const creator = post ? await getUser(post.creatorId) : null
|
||||
|
||||
return {
|
||||
props: {
|
||||
post: post,
|
||||
creator: creator,
|
||||
},
|
||||
|
||||
revalidate: 60, // regenerate after a minute
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStaticPaths() {
|
||||
return { paths: [], fallback: 'blocking' }
|
||||
}
|
||||
|
||||
export default function PostPage(props: { post: Post; creator: User }) {
|
||||
const [isShareOpen, setShareOpen] = useState(false)
|
||||
|
||||
if (props.post == null) {
|
||||
return <Custom404 />
|
||||
}
|
||||
|
||||
const shareUrl = `https://${ENV_CONFIG.domain}${postPath(props?.post.slug)}`
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="mx-auto w-full max-w-3xl ">
|
||||
<Spacer h={1} />
|
||||
<Title className="!mt-0" text={props.post.title} />
|
||||
<Row>
|
||||
<Col className="flex-1">
|
||||
<div className={'inline-flex'}>
|
||||
<div className="mr-1 text-gray-500">Created by</div>
|
||||
<UserLink
|
||||
className="text-neutral"
|
||||
name={props.creator.name}
|
||||
username={props.creator.username}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
className={'flex'}
|
||||
onClick={() => {
|
||||
setShareOpen(true)
|
||||
}}
|
||||
>
|
||||
<ShareIcon
|
||||
className={clsx('mr-2 h-[24px] w-5')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Share
|
||||
<SharePostModal
|
||||
isOpen={isShareOpen}
|
||||
setOpen={setShareOpen}
|
||||
shareUrl={shareUrl}
|
||||
/>
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Spacer h={2} />
|
||||
<div className="rounded-lg bg-white px-6 py-4 sm:py-0">
|
||||
<div className="form-control w-full py-2">
|
||||
<Content content={props.post.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user