client/server: lint and add functionality for admin to update homepage
This commit is contained in:
parent
be6de7c796
commit
b5024e3f45
26 changed files with 617 additions and 313 deletions
|
@ -4,10 +4,12 @@ Drift is a self-hostable Gist clone. It's also a major work-in-progress, but is
|
|||
|
||||
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).
|
||||
|
||||
<hr />
|
||||
|
||||
**Contents:**
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Development](#development)
|
||||
- [Production](#production)
|
||||
|
@ -27,7 +29,7 @@ To migrate the sqlite database in development, you can use `yarn migrate` to see
|
|||
|
||||
### Production
|
||||
|
||||
`yarn build` in both `client/` and `server/` will produce production code for the client and server respectively.
|
||||
`yarn build` in both `client/` and `server/` will produce production code for the client and server respectively.
|
||||
|
||||
If you're deploying the front-end to something like Vercel, you'll need to set the root folder to `client/`.
|
||||
|
||||
|
@ -50,8 +52,6 @@ You can change these to your liking.
|
|||
- `MEMORY_DB`: if `true`, a sqlite database will not be created and changes will only exist in memory. Mainly for the demo.
|
||||
- `REGISTRATION_PASSWORD`: if `true`, the user will be required to provide this password to sign-up, in addition to their username and account password. If it's not set, no additional password will be required.
|
||||
- `SECRET_KEY`: the same secret key as the client
|
||||
- `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
|
||||
- `DRIFT_HOME`: defaults to ~/.drift, the directory for storing the database and eventually images
|
||||
|
||||
|
|
|
@ -2,42 +2,37 @@ import { Popover, Button } from "@geist-ui/core"
|
|||
import { MoreVertical } from "@geist-ui/icons"
|
||||
|
||||
type Action = {
|
||||
title: string
|
||||
onClick: () => void
|
||||
title: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const ActionDropdown = ({
|
||||
title = "Actions",
|
||||
actions,
|
||||
showTitle = false,
|
||||
title = "Actions",
|
||||
actions,
|
||||
showTitle = false
|
||||
}: {
|
||||
title?: string,
|
||||
showTitle?: boolean,
|
||||
actions: Action[]
|
||||
title?: string
|
||||
showTitle?: boolean
|
||||
actions: Action[]
|
||||
}) => {
|
||||
return (
|
||||
<Popover
|
||||
title={title}
|
||||
content={
|
||||
<>
|
||||
{showTitle && <Popover.Item title>
|
||||
{title}
|
||||
</Popover.Item>}
|
||||
{actions.map(action => (
|
||||
<Popover.Item
|
||||
onClick={action.onClick}
|
||||
key={action.title}
|
||||
>
|
||||
{action.title}
|
||||
</Popover.Item>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
hideArrow
|
||||
>
|
||||
<Button iconRight={<MoreVertical />} auto></Button>
|
||||
</Popover>
|
||||
)
|
||||
return (
|
||||
<Popover
|
||||
title={title}
|
||||
content={
|
||||
<>
|
||||
{showTitle && <Popover.Item title>{title}</Popover.Item>}
|
||||
{actions.map((action) => (
|
||||
<Popover.Item onClick={action.onClick} key={action.title}>
|
||||
{action.title}
|
||||
</Popover.Item>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
hideArrow
|
||||
>
|
||||
<Button iconRight={<MoreVertical />} auto></Button>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionDropdown
|
||||
export default ActionDropdown
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Text, Spacer } from "@geist-ui/core"
|
|||
import Cookies from "js-cookie"
|
||||
import styles from "./admin.module.css"
|
||||
import PostTable from "./post-table"
|
||||
import ServerInfo from "./server-info"
|
||||
import UserTable from "./user-table"
|
||||
|
||||
export const adminFetcher = async (
|
||||
|
@ -24,6 +25,8 @@ const Admin = () => {
|
|||
return (
|
||||
<div className={styles.adminWrapper}>
|
||||
<Text h2>Administration</Text>
|
||||
<ServerInfo />
|
||||
<Spacer height={1} />
|
||||
<UserTable />
|
||||
<Spacer height={1} />
|
||||
<PostTable />
|
||||
|
|
|
@ -9,7 +9,7 @@ import ActionDropdown from "./action-dropdown"
|
|||
|
||||
const PostTable = () => {
|
||||
const [posts, setPosts] = useState<Post[]>()
|
||||
const { setToast } = useToasts()
|
||||
// const { setToast } = useToasts()
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPosts = async () => {
|
||||
|
@ -35,8 +35,8 @@ const PostTable = () => {
|
|||
visibility: post.visibility,
|
||||
size: post.files
|
||||
? byteToMB(
|
||||
post.files.reduce((acc, file) => acc + file.html.length, 0)
|
||||
)
|
||||
post.files.reduce((acc, file) => acc + file.html.length, 0)
|
||||
)
|
||||
: 0,
|
||||
actions: ""
|
||||
}
|
||||
|
@ -109,14 +109,14 @@ const PostTable = () => {
|
|||
dataIndex: "",
|
||||
key: "actions",
|
||||
width: 50,
|
||||
render(post: Post) {
|
||||
render() {
|
||||
return (
|
||||
<ActionDropdown
|
||||
title="Actions"
|
||||
actions={[
|
||||
{
|
||||
title: "Delete",
|
||||
onClick: () => deletePost(post.id)
|
||||
onClick: () => deletePost()
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
@ -128,7 +128,11 @@ const PostTable = () => {
|
|||
return (
|
||||
<SettingsGroup title="Posts">
|
||||
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
||||
{posts && <Fieldset.Subtitle><h5>{posts.length} posts</h5></Fieldset.Subtitle>}
|
||||
{posts && (
|
||||
<Fieldset.Subtitle>
|
||||
<h5>{posts.length} posts</h5>
|
||||
</Fieldset.Subtitle>
|
||||
)}
|
||||
{posts && <Table columns={tableColumns} data={tablePosts} />}
|
||||
</SettingsGroup>
|
||||
)
|
||||
|
|
75
client/components/admin/server-info.tsx
Normal file
75
client/components/admin/server-info.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import SettingsGroup from "@components/settings-group"
|
||||
import { Button, Input, Spacer, Textarea, useToasts } from "@geist-ui/core"
|
||||
import { useEffect, useState } from "react"
|
||||
import { adminFetcher } from "."
|
||||
|
||||
const Homepage = () => {
|
||||
const [description, setDescription] = useState<string>("")
|
||||
const [title, setTitle] = useState<string>("")
|
||||
const { setToast } = useToasts()
|
||||
useEffect(() => {
|
||||
const fetchServerInfo = async () => {
|
||||
const res = await adminFetcher("/server-info")
|
||||
const data = await res.json()
|
||||
setDescription(data.welcomeMessage)
|
||||
setTitle(data.welcomeTitle)
|
||||
}
|
||||
fetchServerInfo()
|
||||
}, [])
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
const res = await adminFetcher("/server-info", {
|
||||
method: "PUT",
|
||||
body: {
|
||||
description,
|
||||
title
|
||||
}
|
||||
})
|
||||
|
||||
if (res.status === 200) {
|
||||
setToast({
|
||||
type: "success",
|
||||
text: "Server info updated"
|
||||
})
|
||||
setDescription(description)
|
||||
setTitle(title)
|
||||
} else {
|
||||
setToast({
|
||||
text: "Something went wrong",
|
||||
type: "error"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsGroup title="Homepage">
|
||||
<form onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label htmlFor="title">Title</label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Spacer height={1} />
|
||||
<div>
|
||||
<label htmlFor="message">Description (markdown)</label>
|
||||
<Textarea
|
||||
width={"100%"}
|
||||
height={10}
|
||||
id="message"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Spacer height={1} />
|
||||
<Button htmlType="submit">Update</Button>
|
||||
</form>
|
||||
</SettingsGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default Homepage
|
|
@ -131,7 +131,8 @@ const UserTable = () => {
|
|||
actions={[
|
||||
{
|
||||
title: user.role === "admin" ? "Change role" : "Make admin",
|
||||
onClick: () => toggleRole(user.id, user.role === "admin" ? "user" : "admin")
|
||||
onClick: () =>
|
||||
toggleRole(user.id, user.role === "admin" ? "user" : "admin")
|
||||
},
|
||||
{
|
||||
title: "Delete",
|
||||
|
@ -147,7 +148,11 @@ const UserTable = () => {
|
|||
return (
|
||||
<SettingsGroup title="Users">
|
||||
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
|
||||
{users && <Fieldset.Subtitle><h5>{users.length} users</h5></Fieldset.Subtitle>}
|
||||
{users && (
|
||||
<Fieldset.Subtitle>
|
||||
<h5>{users.length} users</h5>
|
||||
</Fieldset.Subtitle>
|
||||
)}
|
||||
{users && <Table columns={usernameColumns} data={tableUsers} />}
|
||||
</SettingsGroup>
|
||||
)
|
||||
|
|
|
@ -89,10 +89,10 @@ const Header = () => {
|
|||
href: "/mine"
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
name: "settings",
|
||||
icon: <SettingsIcon />,
|
||||
value: 'settings',
|
||||
href: '/settings'
|
||||
value: "settings",
|
||||
href: "/settings"
|
||||
},
|
||||
{
|
||||
name: "sign out",
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
import {
|
||||
Button,
|
||||
useToasts,
|
||||
ButtonDropdown,
|
||||
Input,
|
||||
} from "@geist-ui/core"
|
||||
import { Button, useToasts, ButtonDropdown, Input } from "@geist-ui/core"
|
||||
import { useRouter } from "next/router"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import generateUUID from "@lib/generate-uuid"
|
||||
|
|
|
@ -97,10 +97,7 @@ const ListItem = ({
|
|||
{post.files?.map((file: File) => {
|
||||
return (
|
||||
<div key={file.id}>
|
||||
<Link
|
||||
color
|
||||
href={`/post/${post.id}#${file.title}`}
|
||||
>
|
||||
<Link color href={`/post/${post.id}#${file.title}`}>
|
||||
{file.title || "Untitled file"}
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
@ -85,9 +85,7 @@ const PostPage = ({ post: initialPost, isProtected }: Props) => {
|
|||
}
|
||||
|
||||
const viewParentClick = () => {
|
||||
router.push(
|
||||
`/post/${post.parent!.id}`
|
||||
)
|
||||
router.push(`/post/${post.parent!.id}`)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
@ -123,11 +121,7 @@ const PostPage = ({ post: initialPost, isProtected }: Props) => {
|
|||
Edit a Copy
|
||||
</Button>
|
||||
{post.parent && (
|
||||
<Button
|
||||
auto
|
||||
icon={<Parent />}
|
||||
onClick={viewParentClick}
|
||||
>
|
||||
<Button auto icon={<Parent />} onClick={viewParentClick}>
|
||||
View Parent
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
@ -1,24 +1,21 @@
|
|||
import { Fieldset, Text, Divider } from "@geist-ui/core"
|
||||
import styles from './settings-group.module.css'
|
||||
import styles from "./settings-group.module.css"
|
||||
|
||||
type Props = {
|
||||
title: string,
|
||||
children: React.ReactNode | React.ReactNode[],
|
||||
title: string
|
||||
children: React.ReactNode | React.ReactNode[]
|
||||
}
|
||||
|
||||
const SettingsGroup = ({
|
||||
title,
|
||||
children,
|
||||
}: Props) => {
|
||||
return <Fieldset>
|
||||
<Fieldset.Content>
|
||||
<Text h4>{title}</Text>
|
||||
</Fieldset.Content>
|
||||
<Divider />
|
||||
<Fieldset.Content className={styles.content}>
|
||||
{children}
|
||||
</Fieldset.Content>
|
||||
</Fieldset>
|
||||
const SettingsGroup = ({ title, children }: Props) => {
|
||||
return (
|
||||
<Fieldset>
|
||||
<Fieldset.Content>
|
||||
<Text h4>{title}</Text>
|
||||
</Fieldset.Content>
|
||||
<Divider />
|
||||
<Fieldset.Content className={styles.content}>{children}</Fieldset.Content>
|
||||
</Fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsGroup
|
||||
|
|
|
@ -3,20 +3,24 @@ import Profile from "./sections/profile"
|
|||
import SettingsGroup from "../settings-group"
|
||||
|
||||
const SettingsPage = () => {
|
||||
return (<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--gap)',
|
||||
marginBottom: 'var(--gap)',
|
||||
}}>
|
||||
<h1>Settings</h1>
|
||||
<SettingsGroup title="Profile">
|
||||
<Profile />
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="Password">
|
||||
<Password />
|
||||
</SettingsGroup>
|
||||
</div>)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--gap)",
|
||||
marginBottom: "var(--gap)"
|
||||
}}
|
||||
>
|
||||
<h1>Settings</h1>
|
||||
<SettingsGroup title="Profile">
|
||||
<Profile />
|
||||
</SettingsGroup>
|
||||
<SettingsGroup title="Password">
|
||||
<Password />
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
export default SettingsPage
|
||||
|
|
|
@ -3,95 +3,132 @@ import Cookies from "js-cookie"
|
|||
import { useState } from "react"
|
||||
|
||||
const Password = () => {
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [newPassword, setNewPassword] = useState<string>('')
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>("")
|
||||
const [newPassword, setNewPassword] = useState<string>("")
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>("")
|
||||
|
||||
const { setToast } = useToasts()
|
||||
const { setToast } = useToasts()
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(e.target.value)
|
||||
}
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(e.target.value)
|
||||
}
|
||||
|
||||
const handleNewPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewPassword(e.target.value)
|
||||
}
|
||||
const handleNewPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewPassword(e.target.value)
|
||||
}
|
||||
|
||||
const handleConfirmPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfirmPassword(e.target.value)
|
||||
}
|
||||
const handleConfirmPasswordChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
setConfirmPassword(e.target.value)
|
||||
}
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!password || !newPassword || !confirmPassword) {
|
||||
setToast({
|
||||
text: "Please fill out all fields",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!password || !newPassword || !confirmPassword) {
|
||||
setToast({
|
||||
text: "Please fill out all fields",
|
||||
type: "error"
|
||||
})
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setToast({
|
||||
text: "New password and confirm password do not match",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setToast({
|
||||
text: "New password and confirm password do not match",
|
||||
type: "error"
|
||||
})
|
||||
}
|
||||
|
||||
const res = await fetch("/server-api/auth/change-password", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
oldPassword: password,
|
||||
newPassword,
|
||||
}),
|
||||
})
|
||||
const res = await fetch("/server-api/auth/change-password", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("drift-token")}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
oldPassword: password,
|
||||
newPassword
|
||||
})
|
||||
})
|
||||
|
||||
if (res.status === 200) {
|
||||
setToast({
|
||||
text: "Password updated successfully",
|
||||
type: "success"
|
||||
})
|
||||
setPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
} else {
|
||||
const data = await res.json()
|
||||
|
||||
if (res.status === 200) {
|
||||
setToast({
|
||||
text: "Password updated successfully",
|
||||
type: "success",
|
||||
})
|
||||
setPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setToast({
|
||||
text: data.error ?? "Failed to update password",
|
||||
type: "error"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setToast({
|
||||
text: data.error ?? "Failed to update password",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--gap)",
|
||||
maxWidth: "300px",
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="current-password">Current password</label>
|
||||
<Input onChange={handlePasswordChange} minLength={6} maxLength={128} value={password} id="current-password" htmlType="password" required autoComplete="current-password" placeholder="Current Password" width={"100%"} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new-password">New password</label>
|
||||
<Input onChange={handleNewPasswordChange} minLength={6} maxLength={128} value={newPassword} id="new-password" htmlType="password" required autoComplete="new-password" placeholder="New Password" width={"100%"} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirm-password">Confirm password</label>
|
||||
<Input onChange={handleConfirmPasswordChange} minLength={6} maxLength={128} value={confirmPassword} id="confirm-password" htmlType="password" required autoComplete="confirm-password" placeholder="Confirm Password" width={"100%"} />
|
||||
</div>
|
||||
<Button htmlType="submit" auto>Change Password</Button>
|
||||
</form>)
|
||||
return (
|
||||
<form
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--gap)",
|
||||
maxWidth: "300px"
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="current-password">Current password</label>
|
||||
<Input
|
||||
onChange={handlePasswordChange}
|
||||
minLength={6}
|
||||
maxLength={128}
|
||||
value={password}
|
||||
id="current-password"
|
||||
htmlType="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
placeholder="Current Password"
|
||||
width={"100%"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="new-password">New password</label>
|
||||
<Input
|
||||
onChange={handleNewPasswordChange}
|
||||
minLength={6}
|
||||
maxLength={128}
|
||||
value={newPassword}
|
||||
id="new-password"
|
||||
htmlType="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
placeholder="New Password"
|
||||
width={"100%"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirm-password">Confirm password</label>
|
||||
<Input
|
||||
onChange={handleConfirmPasswordChange}
|
||||
minLength={6}
|
||||
maxLength={128}
|
||||
value={confirmPassword}
|
||||
id="confirm-password"
|
||||
htmlType="password"
|
||||
required
|
||||
autoComplete="confirm-password"
|
||||
placeholder="Confirm Password"
|
||||
width={"100%"}
|
||||
/>
|
||||
</div>
|
||||
<Button htmlType="submit" auto>
|
||||
Change Password
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default Password
|
||||
export default Password
|
||||
|
|
|
@ -4,97 +4,121 @@ import Cookies from "js-cookie"
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
const Profile = () => {
|
||||
const user = useUserData()
|
||||
const [name, setName] = useState<string>()
|
||||
const [email, setEmail] = useState<string>()
|
||||
const [bio, setBio] = useState<string>()
|
||||
const user = useUserData()
|
||||
const [name, setName] = useState<string>()
|
||||
const [email, setEmail] = useState<string>()
|
||||
const [bio, setBio] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
console.log(user)
|
||||
if (user?.displayName) setName(user.displayName)
|
||||
if (user?.email) setEmail(user.email)
|
||||
if (user?.bio) setBio(user.bio)
|
||||
}, [user])
|
||||
useEffect(() => {
|
||||
if (user?.displayName) setName(user.displayName)
|
||||
if (user?.email) setEmail(user.email)
|
||||
if (user?.bio) setBio(user.bio)
|
||||
}, [user])
|
||||
|
||||
const { setToast } = useToasts()
|
||||
const { setToast } = useToasts()
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(e.target.value)
|
||||
}
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(e.target.value)
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value)
|
||||
}
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value)
|
||||
}
|
||||
|
||||
const handleBioChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setBio(e.target.value)
|
||||
}
|
||||
const handleBioChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setBio(e.target.value)
|
||||
}
|
||||
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!name && !email && !bio) {
|
||||
setToast({
|
||||
text: "Please fill out at least one field",
|
||||
type: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!name && !email && !bio) {
|
||||
setToast({
|
||||
text: "Please fill out at least one field",
|
||||
type: "error"
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const data = {
|
||||
displayName: name,
|
||||
email,
|
||||
bio,
|
||||
}
|
||||
const data = {
|
||||
displayName: name,
|
||||
email,
|
||||
bio
|
||||
}
|
||||
|
||||
const res = await fetch("/server-api/user/profile", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${Cookies.get("drift-token")}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const res = await fetch("/server-api/user/profile", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("drift-token")}`
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
|
||||
if (res.status === 200) {
|
||||
setToast({
|
||||
text: "Profile updated",
|
||||
type: "success",
|
||||
})
|
||||
} else {
|
||||
setToast({
|
||||
text: "Something went wrong updating your profile",
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
if (res.status === 200) {
|
||||
setToast({
|
||||
text: "Profile updated",
|
||||
type: "success"
|
||||
})
|
||||
} else {
|
||||
setToast({
|
||||
text: "Something went wrong updating your profile",
|
||||
type: "error"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (<>
|
||||
<Note type="warning" marginBottom={"var(--gap)"}>
|
||||
This information will be publicly available on your profile
|
||||
</Note>
|
||||
<form
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--gap)",
|
||||
maxWidth: "300px",
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="displayName">Display name</label>
|
||||
<Input id="displayName" width={"100%"} placeholder="my name" value={name || ''} onChange={handleNameChange} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<Input id="email" htmlType="email" width={"100%"} placeholder="my@email.io" value={email || ''} onChange={handleEmailChange} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="bio">Biography (max 250 characters)</label>
|
||||
<Textarea id="bio" width="100%" maxLength={250} placeholder="I enjoy..." value={bio || ''} onChange={handleBioChange} />
|
||||
</div>
|
||||
<Button htmlType="submit" auto>Submit</Button>
|
||||
</form></>)
|
||||
return (
|
||||
<>
|
||||
<Note type="warning" marginBottom={"var(--gap)"}>
|
||||
This information will be publicly available on your profile
|
||||
</Note>
|
||||
<form
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--gap)",
|
||||
maxWidth: "300px"
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="displayName">Display name</label>
|
||||
<Input
|
||||
id="displayName"
|
||||
width={"100%"}
|
||||
placeholder="my name"
|
||||
value={name || ""}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<Input
|
||||
id="email"
|
||||
htmlType="email"
|
||||
width={"100%"}
|
||||
placeholder="my@email.io"
|
||||
value={email || ""}
|
||||
onChange={handleEmailChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="bio">Biography (max 250 characters)</label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
width="100%"
|
||||
maxLength={250}
|
||||
placeholder="I enjoy..."
|
||||
value={bio || ""}
|
||||
onChange={handleBioChange}
|
||||
/>
|
||||
</div>
|
||||
<Button htmlType="submit" auto>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
||||
export default Profile
|
||||
|
|
|
@ -3,14 +3,16 @@ import PageSeo from "@components/page-seo"
|
|||
import HomeComponent from "@components/home"
|
||||
import { Page, Text } from "@geist-ui/core"
|
||||
import type { GetStaticProps } from "next"
|
||||
import { InferGetStaticPropsType } from 'next'
|
||||
type Props = {
|
||||
introContent: string
|
||||
introTitle: string
|
||||
rendered: string
|
||||
} | {
|
||||
error: boolean
|
||||
}
|
||||
import { InferGetStaticPropsType } from "next"
|
||||
type Props =
|
||||
| {
|
||||
introContent: string
|
||||
introTitle: string
|
||||
rendered: string
|
||||
}
|
||||
| {
|
||||
error: boolean
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async () => {
|
||||
try {
|
||||
|
@ -32,21 +34,26 @@ export const getStaticProps: GetStaticProps = async () => {
|
|||
// Next.js will attempt to re-generate the page:
|
||||
// - When a request comes in
|
||||
// - At most every 60 seconds
|
||||
revalidate: 60, // In seconds
|
||||
revalidate: 60 // In seconds
|
||||
}
|
||||
} catch (err) {
|
||||
// If there was an error, it's likely due to the server not running, so we attempt to regenerate the page
|
||||
return {
|
||||
props: {
|
||||
error: true,
|
||||
error: true
|
||||
},
|
||||
revalidate: 10, // In seconds
|
||||
revalidate: 10 // In seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: fix props type
|
||||
const Home = ({ rendered, introContent, introTitle, error }: InferGetStaticPropsType<typeof getStaticProps>) => {
|
||||
const Home = ({
|
||||
rendered,
|
||||
introContent,
|
||||
introTitle,
|
||||
error
|
||||
}: InferGetStaticPropsType<typeof getStaticProps>) => {
|
||||
return (
|
||||
<Page className={styles.wrapper}>
|
||||
<PageSeo />
|
||||
|
|
|
@ -69,7 +69,6 @@ export const getServerSideProps: GetServerSideProps = async ({
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
props: {
|
||||
post: json,
|
||||
|
|
|
@ -1,15 +1,27 @@
|
|||
import { Button, Divider, Text, Fieldset, Input, Page, Note, Textarea } from "@geist-ui/core"
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Text,
|
||||
Fieldset,
|
||||
Input,
|
||||
Page,
|
||||
Note,
|
||||
Textarea
|
||||
} from "@geist-ui/core"
|
||||
import PageSeo from "@components/page-seo"
|
||||
import styles from "@styles/Home.module.css"
|
||||
import SettingsPage from "@components/settings"
|
||||
|
||||
const Settings = () => (
|
||||
<Page width={"100%"}>
|
||||
<PageSeo title="Drift - Settings" />
|
||||
<Page.Content className={styles.main} style={{ gap: 'var(--gap)', display: 'flex', flexDirection: 'column' }}>
|
||||
<SettingsPage />
|
||||
</Page.Content>
|
||||
</Page>
|
||||
<Page width={"100%"}>
|
||||
<PageSeo title="Drift - Settings" />
|
||||
<Page.Content
|
||||
className={styles.main}
|
||||
style={{ gap: "var(--gap)", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<SettingsPage />
|
||||
</Page.Content>
|
||||
</Page>
|
||||
)
|
||||
|
||||
export default Settings
|
||||
|
|
|
@ -6,6 +6,7 @@ import { errors } from "celebrate"
|
|||
import secretKey from "@lib/middleware/secret-key"
|
||||
import markdown from "@lib/render-markdown"
|
||||
import config from "@lib/config"
|
||||
import { ServerInfo } from "@lib/models/ServerInfo"
|
||||
|
||||
export const app = express()
|
||||
|
||||
|
@ -19,17 +20,25 @@ app.use("/files", files)
|
|||
app.use("/admin", admin)
|
||||
app.use("/health", health)
|
||||
|
||||
app.get("/welcome", secretKey, (req, res) => {
|
||||
const introContent = config.welcome_content
|
||||
const introTitle = config.welcome_title
|
||||
if (!introContent || !introTitle) {
|
||||
return res.status(500).json({ error: "Missing welcome content" })
|
||||
app.get("/welcome", secretKey, async (req, res) => {
|
||||
const serverInfo = await ServerInfo.findOne({
|
||||
where: {
|
||||
id: "1"
|
||||
}
|
||||
})
|
||||
|
||||
if (!serverInfo) {
|
||||
return res.status(500).json({
|
||||
message: "Server info not found."
|
||||
})
|
||||
}
|
||||
|
||||
const { welcomeMessage, welcomeTitle } = serverInfo
|
||||
|
||||
return res.json({
|
||||
title: introTitle,
|
||||
content: introContent,
|
||||
rendered: markdown(introContent)
|
||||
title: welcomeTitle,
|
||||
content: welcomeMessage,
|
||||
rendered: markdown(welcomeMessage)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import config from "@lib/config"
|
|||
import databasePath from "@lib/get-database-path"
|
||||
import { Sequelize } from "sequelize-typescript"
|
||||
import { SequelizeStorage, Umzug } from "umzug"
|
||||
import { QueryTypes } from "sequelize"
|
||||
|
||||
export const sequelize = new Sequelize({
|
||||
dialect: "sqlite",
|
||||
|
@ -11,10 +12,24 @@ export const sequelize = new Sequelize({
|
|||
logging: console.log
|
||||
})
|
||||
|
||||
if (config.memory_db) {
|
||||
console.log("Using in-memory database")
|
||||
} else {
|
||||
console.log(`Database path: ${databasePath}`)
|
||||
export const initServerInfo = async () => {
|
||||
const serverInfo = await sequelize.query(
|
||||
"SELECT * FROM `server-info` WHERE id = '1'",
|
||||
{
|
||||
type: QueryTypes.SELECT
|
||||
}
|
||||
)
|
||||
|
||||
if (serverInfo.length === 0) {
|
||||
console.log("server-info table not found, creating...")
|
||||
console.log(
|
||||
"You can change the welcome message and title on the admin settings page."
|
||||
)
|
||||
await sequelize.query("INSERT INTO `server-info` (id) VALUES ('1')", {
|
||||
type: QueryTypes.INSERT
|
||||
})
|
||||
console.log("server-info table created.")
|
||||
}
|
||||
}
|
||||
|
||||
export const umzug = new Umzug({
|
||||
|
@ -30,6 +45,12 @@ export const umzug = new Umzug({
|
|||
|
||||
export type Migration = typeof umzug._types.migration
|
||||
|
||||
if (config.memory_db) {
|
||||
console.log("Using in-memory database")
|
||||
} else {
|
||||
console.log(`Database path: ${databasePath}`)
|
||||
}
|
||||
|
||||
// If you're in a development environment, you can manually migrate with `yarn migrate:{up,down}` in the `server` folder
|
||||
if (config.is_production) {
|
||||
;(async () => {
|
||||
|
|
33
server/src/lib/models/ServerInfo.ts
Normal file
33
server/src/lib/models/ServerInfo.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
Model,
|
||||
Column,
|
||||
Table,
|
||||
IsUUID,
|
||||
PrimaryKey,
|
||||
DataType,
|
||||
Unique
|
||||
} from "sequelize-typescript"
|
||||
|
||||
@Table({
|
||||
tableName: "server-info"
|
||||
})
|
||||
export class ServerInfo extends Model {
|
||||
@IsUUID(4)
|
||||
@PrimaryKey
|
||||
@Unique
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
defaultValue: DataType.UUIDV4
|
||||
})
|
||||
id!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING
|
||||
})
|
||||
welcomeMessage!: string
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING
|
||||
})
|
||||
welcomeTitle!: string
|
||||
}
|
|
@ -3,24 +3,24 @@ import { DataTypes } from "sequelize"
|
|||
import type { Migration } from "../database"
|
||||
|
||||
export const up: Migration = async ({ context: queryInterface }) =>
|
||||
Promise.all([
|
||||
queryInterface.addColumn("users", "email", {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
}),
|
||||
queryInterface.addColumn("users", "displayName", {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
}),
|
||||
queryInterface.addColumn("users", "bio", {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
}),
|
||||
])
|
||||
Promise.all([
|
||||
queryInterface.addColumn("users", "email", {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
}),
|
||||
queryInterface.addColumn("users", "displayName", {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
}),
|
||||
queryInterface.addColumn("users", "bio", {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
})
|
||||
])
|
||||
|
||||
export const down: Migration = async ({ context: queryInterface }) =>
|
||||
Promise.all([
|
||||
queryInterface.removeColumn("users", "email"),
|
||||
queryInterface.removeColumn("users", "displayName"),
|
||||
queryInterface.removeColumn("users", "bio"),
|
||||
])
|
||||
Promise.all([
|
||||
queryInterface.removeColumn("users", "email"),
|
||||
queryInterface.removeColumn("users", "displayName"),
|
||||
queryInterface.removeColumn("users", "bio")
|
||||
])
|
||||
|
|
37
server/src/migrations/10_add_server_info_table.ts
Normal file
37
server/src/migrations/10_add_server_info_table.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
"use strict"
|
||||
import { DataTypes } from "sequelize"
|
||||
import type { Migration } from "../database"
|
||||
|
||||
export const up: Migration = async ({ context: queryInterface }) =>
|
||||
queryInterface.createTable("server-info", {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
defaultValue: 1
|
||||
},
|
||||
welcomeMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
defaultValue:
|
||||
"## Drift is a self-hostable clone of GitHub Gist. \nIt is a simple way to share code and text snippets with your friends, with support for the following:\n \n - Render GitHub Extended Markdown (including images)\n - User authentication\n - Private, public, and password protected posts\n - Markdown is rendered and stored on the server\n - Syntax highlighting and automatic language detection\n - Drag-and-drop file uploading\n\n 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.** \n\nYou can find the source code on [GitHub](https://github.com/MaxLeiter/drift).",
|
||||
allowNull: true
|
||||
},
|
||||
welcomeTitle: {
|
||||
type: DataTypes.TEXT,
|
||||
defaultValue: "Welcome to Drift",
|
||||
allowNull: true
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
allowNull: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
allowNull: true
|
||||
}
|
||||
})
|
||||
|
||||
export const down: Migration = async ({ context: queryInterface }) =>
|
||||
queryInterface.dropTable("server-info")
|
|
@ -4,6 +4,7 @@ import { User } from "@lib/models/User"
|
|||
import { File } from "@lib/models/File"
|
||||
import { Router } from "express"
|
||||
import { celebrate, Joi } from "celebrate"
|
||||
import { ServerInfo } from "@lib/models/ServerInfo"
|
||||
|
||||
export const admin = Router()
|
||||
|
||||
|
@ -197,3 +198,54 @@ admin.delete("/post/:id", async (req, res, next) => {
|
|||
next(e)
|
||||
}
|
||||
})
|
||||
|
||||
admin.get("/server-info", async (req, res, next) => {
|
||||
try {
|
||||
const info = await ServerInfo.findOne({
|
||||
where: {
|
||||
id: 1
|
||||
}
|
||||
})
|
||||
|
||||
res.json(info)
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
})
|
||||
|
||||
admin.put(
|
||||
"/server-info",
|
||||
celebrate({
|
||||
body: {
|
||||
title: Joi.string().required(),
|
||||
description: Joi.string().required()
|
||||
}
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { description, title } = req.body
|
||||
const serverInfo = await ServerInfo.findOne({
|
||||
where: {
|
||||
id: 1
|
||||
}
|
||||
})
|
||||
|
||||
if (!serverInfo) {
|
||||
return res.status(404).json({
|
||||
error: "Server info not found"
|
||||
})
|
||||
}
|
||||
|
||||
await serverInfo.update({
|
||||
welcomeMessage: description,
|
||||
welcomeTitle: title
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true
|
||||
})
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
@ -195,7 +195,8 @@ auth.post("/signout", secretKey, async (req, res, next) => {
|
|||
}
|
||||
})
|
||||
|
||||
auth.put("/change-password",
|
||||
auth.put(
|
||||
"/change-password",
|
||||
jwt,
|
||||
celebrate({
|
||||
body: {
|
||||
|
|
|
@ -30,13 +30,14 @@ user.get("/self", jwt, async (req: UserJwtRequest, res, next) => {
|
|||
}
|
||||
})
|
||||
|
||||
user.put("/profile",
|
||||
user.put(
|
||||
"/profile",
|
||||
jwt,
|
||||
celebrate({
|
||||
body: {
|
||||
displayName: Joi.string().optional().allow(""),
|
||||
bio: Joi.string().optional().allow(""),
|
||||
email: Joi.string().optional().email().allow(""),
|
||||
email: Joi.string().optional().email().allow("")
|
||||
}
|
||||
}),
|
||||
async (req: UserJwtRequest, res, next) => {
|
||||
|
|
|
@ -2,8 +2,10 @@ import { createServer } from "http"
|
|||
import { app } from "./app"
|
||||
import config from "./lib/config"
|
||||
import "./database"
|
||||
import { initServerInfo } from "./database"
|
||||
|
||||
;(async () => {
|
||||
// await sequelize.sync()
|
||||
initServerInfo()
|
||||
createServer(app).listen(config.port, () =>
|
||||
console.info(`Server running on port ${config.port}`)
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue