Allow edits to their user page
This commit is contained in:
parent
7a87138d1c
commit
527d00cafc
|
@ -6,6 +6,13 @@ export type User = {
|
||||||
username: string
|
username: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
|
||||||
|
// For their user page
|
||||||
|
bio?: string
|
||||||
|
bannerUrl?: string
|
||||||
|
website?: string
|
||||||
|
twitterHandle?: string
|
||||||
|
discordHandle?: string
|
||||||
|
|
||||||
balance: number
|
balance: number
|
||||||
totalDeposits: number
|
totalDeposits: number
|
||||||
totalPnLCached: number
|
totalPnLCached: number
|
||||||
|
|
|
@ -3,22 +3,22 @@ export const randomString = (length = 12) =>
|
||||||
.toString(16)
|
.toString(16)
|
||||||
.substring(2, length + 2)
|
.substring(2, length + 2)
|
||||||
|
|
||||||
|
export function genHash(str: string) {
|
||||||
|
// xmur3
|
||||||
|
for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) {
|
||||||
|
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
|
||||||
|
h = (h << 13) | (h >>> 19)
|
||||||
|
}
|
||||||
|
return function () {
|
||||||
|
h = Math.imul(h ^ (h >>> 16), 2246822507)
|
||||||
|
h = Math.imul(h ^ (h >>> 13), 3266489909)
|
||||||
|
return (h ^= h >>> 16) >>> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createRNG(seed: string) {
|
export function createRNG(seed: string) {
|
||||||
// https://stackoverflow.com/a/47593316/1592933
|
// https://stackoverflow.com/a/47593316/1592933
|
||||||
|
|
||||||
function genHash(str: string) {
|
|
||||||
// xmur3
|
|
||||||
for (var i = 0, h = 1779033703 ^ str.length; i < str.length; i++) {
|
|
||||||
h = Math.imul(h ^ str.charCodeAt(i), 3432918353)
|
|
||||||
h = (h << 13) | (h >>> 19)
|
|
||||||
}
|
|
||||||
return function () {
|
|
||||||
h = Math.imul(h ^ (h >>> 16), 2246822507)
|
|
||||||
h = Math.imul(h ^ (h >>> 13), 3266489909)
|
|
||||||
return (h ^= h >>> 16) >>> 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const gen = genHash(seed)
|
const gen = genHash(seed)
|
||||||
let [a, b, c, d] = [gen(), gen(), gen(), gen()]
|
let [a, b, c, d] = [gen(), gen(), gen(), gen()]
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,9 @@ service cloud.firestore {
|
||||||
|
|
||||||
match /users/{userId} {
|
match /users/{userId} {
|
||||||
allow read;
|
allow read;
|
||||||
|
allow update: if resource.data.id == request.auth.uid
|
||||||
|
|| request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle']);
|
||||||
}
|
}
|
||||||
|
|
||||||
match /private-users/{userId} {
|
match /private-users/{userId} {
|
||||||
|
|
|
@ -12,10 +12,11 @@ export const SiteLink = (props: {
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'break-words z-10 hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||||
|
target="_blank"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -24,7 +25,7 @@ export const SiteLink = (props: {
|
||||||
<Link href={href}>
|
<Link href={href}>
|
||||||
<a
|
<a
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'break-words z-10 hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
'z-10 break-words hover:underline hover:decoration-indigo-400 hover:decoration-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
style={{ /* For iOS safari */ wordBreak: 'break-word' }}
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { Col } from './layout/col'
|
||||||
import { Linkify } from './linkify'
|
import { Linkify } from './linkify'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
|
import { LinkIcon } from '@heroicons/react/solid'
|
||||||
|
import { genHash } from '../../common/util/random'
|
||||||
|
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
|
@ -33,10 +35,9 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
|
|
||||||
const possesive = isCurrentUser ? 'Your ' : `${user.name}'s `
|
const possesive = isCurrentUser ? 'Your ' : `${user.name}'s `
|
||||||
|
|
||||||
const bannerImageUrl =
|
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||||
'https://images.unsplash.com/photo-1548197253-652ffe79752c?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1975&q=80'
|
|
||||||
|
|
||||||
const placeholderBio = `Hi! Always happy to chat; reach out at akrolsmir@gmail.com, or find a time on https://calendly.com/austinchen/manifold !`
|
const placeholderBio = `I... haven't gotten around to writing a bio yet 😛`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
@ -50,10 +51,11 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
<div
|
<div
|
||||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url(${bannerImageUrl})`,
|
backgroundImage: `url(${bannerUrl})`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative -top-10 left-4">
|
<div className="relative -top-10 left-4">
|
||||||
|
{/* TODO: add a white ring to the avatar */}
|
||||||
<Avatar username={user.username} avatarUrl={user.avatarUrl} size={20} />
|
<Avatar username={user.username} avatarUrl={user.avatarUrl} size={20} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -64,25 +66,50 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Linkify text={placeholderBio}></Linkify>
|
<Linkify text={user.bio || placeholderBio}></Linkify>
|
||||||
</div>
|
</div>
|
||||||
<Spacer h={4} />
|
<Spacer h={4} />
|
||||||
|
|
||||||
<Row className="gap-4">
|
<Col className="sm:flex-row sm:gap-4">
|
||||||
<a href={`https://twitter.com/akrolsmir`}>
|
{user.website && (
|
||||||
<Row className="items-center gap-1">
|
<SiteLink href={user.website}>
|
||||||
<img src="/twitter-logo.svg" className="h-4 w-4" alt="Twitter" />
|
<Row className="items-center gap-1">
|
||||||
<span className="text-sm text-gray-500">akrolsmir</span>
|
<LinkIcon className="h-4 w-4" />
|
||||||
</Row>
|
<span className="text-sm text-gray-500">{user.website}</span>
|
||||||
</a>
|
</Row>
|
||||||
|
</SiteLink>
|
||||||
|
)}
|
||||||
|
|
||||||
<a href="https://discord.com/invite/eHQBNBqXuh">
|
{user.twitterHandle && (
|
||||||
<Row className="items-center gap-1">
|
<SiteLink href={`https://twitter.com/${user.twitterHandle}`}>
|
||||||
<img src="/discord-logo.svg" className="h-4 w-4" alt="Discord" />
|
<Row className="items-center gap-1">
|
||||||
<span className="text-sm text-gray-500">akrolsmir#4125</span>
|
<img
|
||||||
</Row>
|
src="/twitter-logo.svg"
|
||||||
</a>
|
className="h-4 w-4"
|
||||||
</Row>
|
alt="Twitter"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{user.twitterHandle}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
</SiteLink>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.discordHandle && (
|
||||||
|
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
|
||||||
|
<Row className="items-center gap-1">
|
||||||
|
<img
|
||||||
|
src="/discord-logo.svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
alt="Discord"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{user.discordHandle}
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
|
</SiteLink>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
|
||||||
<Spacer h={10} />
|
<Spacer h={10} />
|
||||||
|
|
||||||
|
@ -91,3 +118,15 @@ export function UserPage(props: { user: User; currentUser?: User }) {
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Assign each user to a random default banner based on the hash of userId
|
||||||
|
export function defaultBannerUrl(userId: string) {
|
||||||
|
const defaultBanner = [
|
||||||
|
'https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2131&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1458682625221-3a45f8a844c7?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1974&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1558517259-165ae4b10f7f?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2080&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1563260797-cb5cd70254c8?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2069&q=80',
|
||||||
|
'https://images.unsplash.com/photo-1603399587513-136aa9398f2d?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1467&q=80',
|
||||||
|
]
|
||||||
|
return defaultBanner[genHash(userId)() % defaultBanner.length]
|
||||||
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
limit,
|
limit,
|
||||||
getDocs,
|
getDocs,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
updateDoc,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { getAuth } from 'firebase/auth'
|
import { getAuth } from 'firebase/auth'
|
||||||
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
|
import { ref, getStorage, uploadBytes, getDownloadURL } from 'firebase/storage'
|
||||||
|
@ -21,7 +22,8 @@ import {
|
||||||
import { app } from './init'
|
import { app } from './init'
|
||||||
import { PrivateUser, User } from '../../../common/user'
|
import { PrivateUser, User } from '../../../common/user'
|
||||||
import { createUser } from './api-call'
|
import { createUser } from './api-call'
|
||||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
import { getValues, listenForValue, listenForValues } from './utils'
|
||||||
|
|
||||||
export type { User }
|
export type { User }
|
||||||
|
|
||||||
const db = getFirestore(app)
|
const db = getFirestore(app)
|
||||||
|
@ -45,6 +47,10 @@ export async function setUser(userId: string, user: User) {
|
||||||
await setDoc(doc(db, 'users', userId), user)
|
await setDoc(doc(db, 'users', userId), user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateUser(userId: string, update: Partial<User>) {
|
||||||
|
await updateDoc(doc(db, 'users', userId), { ...update })
|
||||||
|
}
|
||||||
|
|
||||||
export function listenForUser(
|
export function listenForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
setUser: (user: User | null) => void
|
setUser: (user: User | null) => void
|
||||||
|
|
|
@ -16,6 +16,40 @@ import { changeUserInfo } from '../lib/firebase/api-call'
|
||||||
import { uploadImage } from '../lib/firebase/storage'
|
import { uploadImage } from '../lib/firebase/storage'
|
||||||
import { Col } from '../components/layout/col'
|
import { Col } from '../components/layout/col'
|
||||||
import { Row } from '../components/layout/row'
|
import { Row } from '../components/layout/row'
|
||||||
|
import { User } from '../../common/user'
|
||||||
|
import { defaultBannerUrl, updateUser } from '../lib/firebase/users'
|
||||||
|
|
||||||
|
function EditUserField(props: {
|
||||||
|
user: User
|
||||||
|
field: 'bio' | 'bannerUrl' | 'twitterHandle' | 'discordHandle'
|
||||||
|
label: string
|
||||||
|
isEditing: boolean
|
||||||
|
}) {
|
||||||
|
const { user, field, label, isEditing } = props
|
||||||
|
const [value, setValue] = useState(user[field] ?? '')
|
||||||
|
|
||||||
|
async function updateField() {
|
||||||
|
await updateUser(user.id, { [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="label">{label}</label>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value || '')}
|
||||||
|
onBlur={updateField}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="ml-1 break-words text-gray-500">{value || '-'}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -166,6 +200,42 @@ export default function ProfilePage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
{/* TODO: Allow users with M$ 2000 of assets to set custom banners */}
|
||||||
|
{/* <EditUserField
|
||||||
|
user={user}
|
||||||
|
field="bannerUrl"
|
||||||
|
label="Banner Url"
|
||||||
|
isEditing={isEditing}
|
||||||
|
/> */}
|
||||||
|
<label className="label">Banner</label>
|
||||||
|
<div
|
||||||
|
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${
|
||||||
|
user.bannerUrl || defaultBannerUrl(user.id)
|
||||||
|
})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{[
|
||||||
|
['bio', 'Bio'],
|
||||||
|
['website', 'Website URL'],
|
||||||
|
['twitterHandle', 'Twitter'],
|
||||||
|
['discordHandle', 'Discord'],
|
||||||
|
].map(([field, label]) => (
|
||||||
|
<EditUserField
|
||||||
|
user={user}
|
||||||
|
// @ts-ignore
|
||||||
|
field={field}
|
||||||
|
label={label}
|
||||||
|
isEditing={isEditing}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Email</label>
|
<label className="label">Email</label>
|
||||||
<div className="ml-1 text-gray-500">
|
<div className="ml-1 text-gray-500">
|
||||||
|
|
Loading…
Reference in New Issue
Block a user