Next
This commit is contained in:
parent
ab164dc65d
commit
33b5b2d179
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"importOrder": [
|
||||
"^react(.*)",
|
||||
"^next(.*)",
|
||||
"@mui(.*)",
|
||||
"<THIRD_PARTY_MODULES>",
|
||||
"^[./]"
|
||||
],
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"prettier.configPath": ".prettierrc"
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import { IDBPDatabase, openDB } from 'idb'
|
||||
|
||||
import { client } from '../socialvoid'
|
||||
|
||||
let _db: IDBPDatabase | undefined
|
||||
|
||||
async function getDB() {
|
||||
if (typeof _db !== 'undefined') {
|
||||
return _db
|
||||
}
|
||||
|
||||
_db = await openDB('socialvoidCaches', 1, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore('documents')
|
||||
},
|
||||
})
|
||||
return _db
|
||||
}
|
||||
|
||||
async function getStore() {
|
||||
return (await getDB())
|
||||
.transaction('documents', 'readwrite')
|
||||
.objectStore('documents')
|
||||
}
|
||||
|
||||
export async function getDocument(id: string): Promise<string> {
|
||||
const cache = await (await getStore()).get(id)
|
||||
|
||||
if (typeof cache !== 'undefined') {
|
||||
return URL.createObjectURL(new Blob([cache]))
|
||||
}
|
||||
|
||||
const data = (await client.cdn.download(id)) as ArrayBuffer
|
||||
|
||||
await (await getStore()).put(data, id)
|
||||
|
||||
return URL.createObjectURL(new Blob([data]))
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import AppBar from '@mui/material/AppBar'
|
||||
import Container from '@mui/material/Container'
|
||||
import Paper from '@mui/material/Paper'
|
||||
import Slide from '@mui/material/Slide'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import useScrollTrigger from '@mui/material/useScrollTrigger'
|
||||
|
||||
export default function Header() {
|
||||
const trigger = useScrollTrigger()
|
||||
|
||||
return (
|
||||
<Slide appear={false} direction="down" in={!trigger}>
|
||||
<Paper
|
||||
component="header"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderTop: 'none',
|
||||
borderRight: 'none',
|
||||
borderLeft: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<AppBar
|
||||
position="sticky"
|
||||
color="transparent"
|
||||
sx={{ boxShadow: 'none' }}
|
||||
>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div">
|
||||
Socialvoid
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
</Container>
|
||||
</Paper>
|
||||
</Slide>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import { useTheme } from '@mui/material/styles'
|
||||
|
||||
export default function Router() {
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 10,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress sx={{ color: theme.palette.text.primary }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { NextRouter } from 'next/router'
|
||||
|
||||
import Card, { CardProps } from '@mui/material/Card'
|
||||
import CardActionArea from '@mui/material/CardActionArea'
|
||||
import CardContent from '@mui/material/CardContent'
|
||||
import CardHeader from '@mui/material/CardHeader'
|
||||
import CardMedia from '@mui/material/CardMedia'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import moment from 'moment'
|
||||
import * as sv from 'socialvoid'
|
||||
|
||||
import { getDocument } from '../cache'
|
||||
import { dispatch } from '../socialvoid'
|
||||
import { unparse } from '../utilities/parser'
|
||||
import { NotDeletedPost, postNotDeleted } from '../utilities/types'
|
||||
|
||||
type PostProps = CardProps & {
|
||||
post: sv.Post
|
||||
repost?: boolean
|
||||
router?: NextRouter
|
||||
}
|
||||
|
||||
function PostFrame(props: CardProps) {
|
||||
return <Card variant="outlined" {...props} />
|
||||
}
|
||||
|
||||
function InnerPostFrame(props: CardProps) {
|
||||
return (
|
||||
<PostFrame
|
||||
sx={{ mb: 3, mr: 'auto', ml: 'auto', width: '90%' }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DeletedPostView() {
|
||||
return (
|
||||
<CardContent>
|
||||
<Typography variant="body1">This post is deleted.</Typography>
|
||||
</CardContent>
|
||||
)
|
||||
}
|
||||
|
||||
function PostView({ post }: { post: NotDeletedPost }) {
|
||||
return (
|
||||
<>
|
||||
<CardHeader
|
||||
title={
|
||||
<Typography>
|
||||
<span style={{ fontWeight: 'bold' }}>{post.peer.name}</span>{' '}
|
||||
<span style={{ opacity: 0.5 }}>
|
||||
@{post.peer.username} ·{' '}
|
||||
{moment(post.posted_timestamp * 1000).fromNow()}
|
||||
</span>
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
|
||||
<CardContent>
|
||||
<Typography
|
||||
variant="body1"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: unparse(post.text, post.entities),
|
||||
}}
|
||||
></Typography>
|
||||
</CardContent>
|
||||
|
||||
{post.reposted_post && (
|
||||
<InnerPostFrame>
|
||||
{postNotDeleted(post.reposted_post) ? (
|
||||
<PostView post={post.reposted_post} />
|
||||
) : (
|
||||
<DeletedPostView />
|
||||
)}
|
||||
</InnerPostFrame>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Post(props: PostProps) {
|
||||
const { post, router } = props
|
||||
|
||||
const body = postNotDeleted(post) ? (
|
||||
<PostView post={post} />
|
||||
) : (
|
||||
<DeletedPostView />
|
||||
)
|
||||
|
||||
return (
|
||||
<PostFrame variant="outlined" {...props}>
|
||||
{router ? (
|
||||
<CardActionArea
|
||||
onClick={() =>
|
||||
router.push({ pathname: '/post/[id]', query: { id: post.id } })
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</CardActionArea>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</PostFrame>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { useMemo } from 'react'
|
||||
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
|
||||
import { SnackbarProvider } from 'notistack'
|
||||
|
||||
export default function Theme({ children }: { children: React.ReactNode }) {
|
||||
const dark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||
|
||||
const theme = useMemo(
|
||||
() => createTheme({ palette: { mode: dark ? 'dark' : 'light' } }),
|
||||
[dark]
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider>{children}</SnackbarProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
|
@ -1,4 +1,11 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
webpack: (config) => {
|
||||
if (typeof config.resolve.fallback !== 'undefined') {
|
||||
config.resolve.fallback.fs = false
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
}
|
||||
|
|
17
package.json
17
package.json
|
@ -5,18 +5,31 @@
|
|||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"pretty": "prettier --config .prettierrc run --write pages/ utilities/ themes/ specifications/ socialvoid/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@mui/icons-material": "^5.1.0",
|
||||
"@mui/material": "^5.1.0",
|
||||
"idb": "^6.1.5",
|
||||
"moment": "^2.29.1",
|
||||
"next": "12.0.3",
|
||||
"notistack": "^2.0.3",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2"
|
||||
"react-dom": "17.0.2",
|
||||
"socialvoid": "^0.0.0-alpha.8",
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "^3.1.1",
|
||||
"@types/moment": "^2.13.0",
|
||||
"@types/node": "16.11.7",
|
||||
"@types/react": "17.0.34",
|
||||
"eslint": "7",
|
||||
"eslint-config-next": "12.0.3",
|
||||
"prettier": "^2.4.1",
|
||||
"typescript": "4.4.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,23 @@
|
|||
import '../styles/globals.css'
|
||||
import type { AppProps } from 'next/app'
|
||||
import { AppProps } from 'next/app'
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />
|
||||
import Container from '@mui/material/Container'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
|
||||
import { SnackbarProvider } from 'notistack'
|
||||
|
||||
import Header from '../components/Header'
|
||||
import Theme from '../components/Theme'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
<Theme>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider>
|
||||
<Header />
|
||||
<Container component="main" maxWidth="sm">
|
||||
<Component {...pageProps} />
|
||||
</Container>
|
||||
</SnackbarProvider>
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyApp
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
type Data = {
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
res.status(200).json({ name: 'John Doe' })
|
||||
}
|
107
pages/index.tsx
107
pages/index.tsx
|
@ -1,72 +1,43 @@
|
|||
import type { NextPage } from 'next'
|
||||
import Head from 'next/head'
|
||||
import Image from 'next/image'
|
||||
import styles from '../styles/Home.module.css'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import { useSnackbar } from 'notistack'
|
||||
|
||||
import Loader from '../components/Loader'
|
||||
import Post from '../components/Post'
|
||||
import { dispatch } from '../socialvoid'
|
||||
import { redirectIfNotAuthenticated } from '../utilities/redirect'
|
||||
import { NotDeletedPost, postNotDeleted } from '../utilities/types'
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
const [posts, setPosts] = useState<NotDeletedPost[]>()
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
redirectIfNotAuthenticated(router)
|
||||
|
||||
dispatch(async (client) => {
|
||||
const posts = (await client.timeline.retrieveFeed(page)).filter(
|
||||
postNotDeleted
|
||||
)
|
||||
|
||||
setPosts(posts)
|
||||
}, snackbar)
|
||||
}, [page])
|
||||
|
||||
const Home: NextPage = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>
|
||||
Welcome to <a href="https://nextjs.org">Next.js!</a>
|
||||
</h1>
|
||||
|
||||
<p className={styles.description}>
|
||||
Get started by editing{' '}
|
||||
<code className={styles.code}>pages/index.tsx</code>
|
||||
</p>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a href="https://nextjs.org/docs" className={styles.card}>
|
||||
<h2>Documentation →</h2>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a href="https://nextjs.org/learn" className={styles.card}>
|
||||
<h2>Learn →</h2>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://github.com/vercel/next.js/tree/master/examples"
|
||||
className={styles.card}
|
||||
>
|
||||
<h2>Examples →</h2>
|
||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
className={styles.card}
|
||||
>
|
||||
<h2>Deploy →</h2>
|
||||
<p>
|
||||
Instantly deploy your Next.js site to a public URL with Vercel.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Powered by{' '}
|
||||
<span className={styles.logo}>
|
||||
<Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
|
||||
</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<>
|
||||
{posts ? (
|
||||
posts.map((post) => (
|
||||
<Post key={post.id} post={post} router={router} sx={{ mt: 3 }} />
|
||||
))
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import * as sv from 'socialvoid'
|
||||
import { z } from 'zod'
|
||||
|
||||
import Loader from '../../components/Loader'
|
||||
import Post from '../../components/Post'
|
||||
import { dispatch } from '../../socialvoid'
|
||||
|
||||
export default function Post_() {
|
||||
const router = useRouter()
|
||||
const [post, setPost] = useState<sv.Post>()
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(async (client) => {
|
||||
const post = await client.timeline.getPost(
|
||||
z.string().parse(router.query.id)
|
||||
)
|
||||
|
||||
setPost(post)
|
||||
})
|
||||
})
|
||||
|
||||
return post ? <Post post={post} sx={{ mt: 3 }} /> : <Loader />
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Link from '@mui/material/Link'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
import { useSnackbar } from 'notistack'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { dispatch } from '../socialvoid'
|
||||
import { spassword } from '../specifications'
|
||||
import { handleZodErrors } from '../utilities/errors'
|
||||
import { redirectIfAuthenticated } from '../utilities/redirect'
|
||||
|
||||
export default function SignUp() {
|
||||
const router = useRouter()
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
handleZodErrors(() => {
|
||||
event.preventDefault()
|
||||
|
||||
const data = new FormData(event.currentTarget)
|
||||
|
||||
const { username, password } = z
|
||||
.object({
|
||||
username: z.string().nonempty(),
|
||||
password: spassword,
|
||||
})
|
||||
.parse({
|
||||
username: data.get('username'),
|
||||
password: data.get('password'),
|
||||
})
|
||||
|
||||
dispatch(async (client) => {
|
||||
await client.newSession()
|
||||
await client.session.authenticateUser(username, password)
|
||||
|
||||
router.replace('/')
|
||||
}, snackbar)
|
||||
}, snackbar)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
redirectIfAuthenticated(router)
|
||||
})
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={submit} sx={{ mt: 3 }} noValidate>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="Username"
|
||||
name="username"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="password"
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
/>
|
||||
<Button type="submit" fullWidth variant="contained" sx={{ mt: 3, mb: 3 }}>
|
||||
Sign In
|
||||
</Button>
|
||||
<Link href="/signup" variant="body2" sx={{ float: 'right' }}>
|
||||
Don’t have an account?
|
||||
</Link>
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import Loader from '../components/Loader'
|
||||
|
||||
export default function SignUp() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/tos')
|
||||
})
|
||||
|
||||
return <Loader />
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
import { useEffect } from 'react'
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Link from '@mui/material/Link'
|
||||
import TextField from '@mui/material/TextField'
|
||||
|
||||
import { useSnackbar } from 'notistack'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { dispatch } from '../../socialvoid'
|
||||
import { spassword } from '../../specifications'
|
||||
import { handleZodErrors } from '../../utilities/errors'
|
||||
import { redirectIfAuthenticated } from '../../utilities/redirect'
|
||||
|
||||
export default function SignUp() {
|
||||
const router = useRouter()
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
handleZodErrors(() => {
|
||||
event.preventDefault()
|
||||
const data = new FormData(event.currentTarget)
|
||||
|
||||
const params = z
|
||||
.object({
|
||||
tosId: z.string().nonempty(),
|
||||
username: z.string().nonempty(),
|
||||
password: spassword,
|
||||
passwordConfirmation: z.string(),
|
||||
firstName: z.string().nonempty(),
|
||||
lastName: z.string().optional(),
|
||||
})
|
||||
.parse({
|
||||
tosId: router.query.tosId,
|
||||
username: data.get('username'),
|
||||
password: data.get('password'),
|
||||
passwordConfirmation: data.get('passwordConfirmation'),
|
||||
firstName: data.get('firstName'),
|
||||
lastName: data.get('lastName'),
|
||||
})
|
||||
|
||||
if (params.password !== params.passwordConfirmation) {
|
||||
snackbar.enqueueSnackbar('Passwords don’t match.', {
|
||||
variant: 'warning',
|
||||
preventDuplicate: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(async (client) => {
|
||||
await client.newSession()
|
||||
|
||||
await client.session.register(
|
||||
params.tosId,
|
||||
params.username,
|
||||
params.password,
|
||||
params.firstName,
|
||||
params.lastName
|
||||
)
|
||||
|
||||
await client.session.authenticateUser(params.username, params.password)
|
||||
|
||||
snackbar.enqueueSnackbar('Signed up successfully.', {
|
||||
variant: 'success',
|
||||
})
|
||||
|
||||
router.replace('/')
|
||||
}, snackbar)
|
||||
}, snackbar)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
redirectIfAuthenticated(router)
|
||||
})
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={submit} sx={{ mt: 3 }} noValidate>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="firstName"
|
||||
label="First name"
|
||||
name="firstName"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
id="lastName"
|
||||
label="Last name"
|
||||
name="lastName"
|
||||
autoComplete="off"
|
||||
sx={{ mt: 3 }}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="Username"
|
||||
name="username"
|
||||
autoComplete="off"
|
||||
sx={{ mt: 3 }}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="password"
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
sx={{ mt: 3 }}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
fullWidth
|
||||
id="passwordConfirmation"
|
||||
label="Confirm password"
|
||||
name="passwordConfirmation"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
sx={{ mt: 3 }}
|
||||
/>
|
||||
<Button type="submit" fullWidth variant="contained" sx={{ mt: 3, mb: 3 }}>
|
||||
Sign Up
|
||||
</Button>
|
||||
<Link href="/signin" variant="body2" sx={{ float: 'right' }}>
|
||||
Already have an account?
|
||||
</Link>
|
||||
</Box>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Checkbox from '@mui/material/Checkbox'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Typography from '@mui/material/Typography'
|
||||
|
||||
import { useSnackbar } from 'notistack'
|
||||
import { HelpDocument } from 'socialvoid'
|
||||
import { z } from 'zod'
|
||||
|
||||
import Loader from '../components/Loader'
|
||||
import { dispatch } from '../socialvoid'
|
||||
import { unparse } from '../utilities/parser'
|
||||
import { redirectIfAuthenticated } from '../utilities/redirect'
|
||||
|
||||
export default function ToS() {
|
||||
const router = useRouter()
|
||||
const snackbar = useSnackbar()
|
||||
const [document, setDocument] = useState<HelpDocument>()
|
||||
|
||||
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
const data = new FormData(event.currentTarget)
|
||||
|
||||
console.log(data.get('acceptTermOfServices'))
|
||||
|
||||
if (data.get('acceptTermOfServices') !== 'on') {
|
||||
snackbar.enqueueSnackbar('You must read and accept.', {
|
||||
variant: 'info',
|
||||
preventDuplicate: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
snackbar.closeSnackbar()
|
||||
|
||||
router.replace({
|
||||
pathname: '/signup/[tosId]',
|
||||
query: { tosId: z.string().parse(data.get('tosId')) },
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
redirectIfAuthenticated(router)
|
||||
|
||||
dispatch(async (client) => {
|
||||
setDocument(await client.help.getTermsOfService())
|
||||
}, snackbar)
|
||||
})
|
||||
|
||||
return document ? (
|
||||
<>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ mt: 3 }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: unparse(document.text, document.entities),
|
||||
}}
|
||||
></Typography>
|
||||
<Box component="form" noValidate sx={{ mt: 3 }} onSubmit={submit}>
|
||||
<input type="hidden" name="tosId" value={document.id} />
|
||||
<FormControlLabel
|
||||
control={<Checkbox name="acceptTermOfServices" color="primary" />}
|
||||
label="I have read and accepted."
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
fullWidth
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Loader />
|
||||
)
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { ProviderContext } from 'notistack'
|
||||
import { Client, errors } from 'socialvoid'
|
||||
|
||||
export const client = new Client()
|
||||
|
||||
export async function dispatch(
|
||||
func: (client: Client) => Promise<void> | void,
|
||||
snackbar?: ProviderContext
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('Cannot dispatch requests in server-side')
|
||||
}
|
||||
|
||||
try {
|
||||
await func(client)
|
||||
} catch (err) {
|
||||
if (err instanceof errors.SocialvoidError) {
|
||||
if (
|
||||
err instanceof errors.SessionExpired ||
|
||||
err instanceof errors.BadSessionChallengeAnswer ||
|
||||
err instanceof errors.InvalidSessionIdentification
|
||||
) {
|
||||
client.deleteSession()
|
||||
|
||||
if (snackbar) {
|
||||
snackbar.enqueueSnackbar('Session expired.', {
|
||||
variant: 'error',
|
||||
preventDuplicate: true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (snackbar) {
|
||||
snackbar.enqueueSnackbar(
|
||||
err.errorMessage.endsWith('.')
|
||||
? err.errorMessage
|
||||
: err.errorMessage + '.',
|
||||
{
|
||||
variant: 'error',
|
||||
preventDuplicate: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (err instanceof Error) {
|
||||
switch (err.message) {
|
||||
case 'Session does not exist':
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const spassword = z
|
||||
.string({ required_error: 'Password is required.' })
|
||||
// .min(12, "The password should be 12 characters at least.")
|
||||
.max(128, 'The password can’t be longer than 128 characters.')
|
||||
.regex(/.*[A-Z].*/, 'The password should contain a capital letter.')
|
||||
.regex(/.*[0-9].*[0-9].*/, 'The password should contain 2 numbers at least.')
|
|
@ -1,116 +0,0 @@
|
|||
.container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
padding: 4rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid #eaeaea;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: #0070f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title a:hover,
|
||||
.title a:focus,
|
||||
.title a:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 4rem 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
background: #fafafa;
|
||||
border-radius: 5px;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 10px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus,
|
||||
.card:active {
|
||||
color: #0070f3;
|
||||
border-color: #0070f3;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 1em;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
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;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import createTheme from '@mui/material/styles/createTheme'
|
||||
|
||||
const dark = createTheme({ palette: { mode: 'dark' } })
|
||||
|
||||
export default dark
|
|
@ -0,0 +1,56 @@
|
|||
import { ProviderContext } from 'notistack'
|
||||
import { ZodError, defaultErrorMap, setErrorMap } from 'zod'
|
||||
|
||||
function camelToNormal(s: string) {
|
||||
const chars = new Array<string>()
|
||||
|
||||
for (const char of s) {
|
||||
if (char === char.toUpperCase()) {
|
||||
if (chars.length === 0) {
|
||||
chars.push(char)
|
||||
} else {
|
||||
chars.push(' ')
|
||||
chars.push(char.toLowerCase())
|
||||
}
|
||||
} else {
|
||||
chars.push(char)
|
||||
}
|
||||
}
|
||||
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
setErrorMap((issue, _ctx) => {
|
||||
let path = issue.path[0].toString()
|
||||
|
||||
path = path.charAt(0).toUpperCase() + path.slice(1)
|
||||
|
||||
if (
|
||||
(issue.code === 'too_small' && issue.minimum === 1) ||
|
||||
(issue.code === 'invalid_type' && issue.received === 'undefined')
|
||||
) {
|
||||
return { message: `${camelToNormal(path)} is empty.` }
|
||||
}
|
||||
|
||||
return defaultErrorMap(issue, _ctx)
|
||||
})
|
||||
|
||||
export function handleZodErrors(func: () => void, snackbar: ProviderContext) {
|
||||
try {
|
||||
func()
|
||||
} catch (err) {
|
||||
if (!(err instanceof ZodError)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
const flatten = err.flatten()
|
||||
const fieldErrors = Object.values(flatten.fieldErrors)
|
||||
|
||||
if (fieldErrors.length !== 0) {
|
||||
snackbar.enqueueSnackbar(fieldErrors[0][0], {
|
||||
variant: 'warning',
|
||||
preventDuplicate: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import { TextEntity } from 'socialvoid'
|
||||
|
||||
function is(x: TextEntity, y: TextEntity) {
|
||||
return (
|
||||
x.offset === y.offset &&
|
||||
x.length === y.length &&
|
||||
x.type === y.type &&
|
||||
x.value === y.value
|
||||
)
|
||||
}
|
||||
|
||||
function htmlEscape(s: string) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function _unparse(
|
||||
text: string,
|
||||
entities: TextEntity[],
|
||||
offset: number,
|
||||
length: number
|
||||
) {
|
||||
let noffset = offset
|
||||
let ntext = ''
|
||||
|
||||
for (const entity of entities) {
|
||||
if (offset >= entity.length + entity.offset) {
|
||||
continue
|
||||
}
|
||||
|
||||
ntext += htmlEscape(text.slice(offset, entity.offset))
|
||||
|
||||
const netntities = entities.filter(
|
||||
(entity2) =>
|
||||
entity.offset + entity.length >= entity2.offset && !is(entity, entity2)
|
||||
)
|
||||
|
||||
let atext = ''
|
||||
|
||||
if (netntities) {
|
||||
atext = _unparse(text, netntities, entity.offset, entity.length)
|
||||
} else {
|
||||
atext = htmlEscape(
|
||||
text.slice(entity.offset, entity.offset + entity.length)
|
||||
)
|
||||
}
|
||||
|
||||
switch (entity.type) {
|
||||
case 'BOLD':
|
||||
ntext += `<b>${atext}</b>`
|
||||
break
|
||||
case 'ITALIC':
|
||||
ntext += `<i>${atext}</i>`
|
||||
break
|
||||
case 'CODE':
|
||||
ntext += `<code>${atext}</code>`
|
||||
break
|
||||
case 'STRIKE':
|
||||
ntext += `<s>${atext}</s>`
|
||||
break
|
||||
case 'UNDERLINE':
|
||||
ntext += `<u>${atext}</u>`
|
||||
break
|
||||
case 'URL':
|
||||
ntext += `<a href="${htmlEscape(entity.value!)}">${atext}</a>`
|
||||
break
|
||||
case 'MENTION':
|
||||
ntext += `<a href="sv://peer/${encodeURIComponent(
|
||||
entity.value!
|
||||
)}">${atext}</a>`
|
||||
break
|
||||
case 'HASHTAG':
|
||||
ntext += atext
|
||||
}
|
||||
|
||||
offset = entity.offset + entity.length
|
||||
}
|
||||
|
||||
return ntext + htmlEscape(text.slice(offset, length + noffset))
|
||||
}
|
||||
|
||||
export function unparse(text: string, entities: TextEntity[]) {
|
||||
entities = entities.sort((a) => a.offset - a.length)
|
||||
|
||||
return _unparse(text, entities, 0, text.length).replace(/\n/g, '<br />')
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import router, { NextRouter } from 'next/router'
|
||||
|
||||
import { client } from '../socialvoid'
|
||||
|
||||
export function redirectIfAuthenticated(router: NextRouter) {
|
||||
if (client.sessionExists) {
|
||||
router.replace('/')
|
||||
}
|
||||
}
|
||||
|
||||
export function redirectIfNotAuthenticated(router: NextRouter) {
|
||||
if (!client.sessionExists) {
|
||||
router.replace('/signin')
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export function humanize(unix: number) {
|
||||
const delta = new Date().getTime() - new Date(unix * 3000).getTime()
|
||||
|
||||
const seconds = Math.round(delta / 1000)
|
||||
const minutes = Math.round(seconds / 60)
|
||||
const hours = Math.round(minutes / 60)
|
||||
const days = Math.round(hours / 24)
|
||||
const weeks = Math.round(days / 7)
|
||||
const months = Math.round(weeks / 4)
|
||||
const years = Math.round(months / 12)
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { Post } from 'socialvoid'
|
||||
|
||||
export type NotDeletedPost = Post & {
|
||||
peer: NonNullable<Post['peer']>
|
||||
source: NonNullable<Post['source']>
|
||||
text: NonNullable<Post['text']>
|
||||
like_count: NonNullable<Post['like_count']>
|
||||
repost_count: NonNullable<Post['repost_count']>
|
||||
quote_count: NonNullable<Post['quote_count']>
|
||||
reply_count: NonNullable<Post['reply_count']>
|
||||
}
|
||||
|
||||
export function postNotDeleted(post: Post): post is NotDeletedPost {
|
||||
return post.peer != null
|
||||
}
|
Loading…
Reference in New Issue