Initial profile refactor of layout and config

This commit is contained in:
Frank Johnson
2021-09-12 05:03:58 -04:00
parent 3dbb963e4e
commit 55d48057da
13 changed files with 383 additions and 170 deletions

View File

@@ -66,6 +66,9 @@ module.exports = {
gridAutoColumns: { gridAutoColumns: {
'preview-layout': 'minmax(30rem, 1fr) minmax(auto, 2fr)', 'preview-layout': 'minmax(30rem, 1fr) minmax(auto, 2fr)',
}, },
gridTemplateColumns: {
'profile-layout': 'minmax(32rem, 1fr) 2fr',
},
keyframes: { keyframes: {
'bounce-sm': { 'bounce-sm': {
'0%, 100%': { '0%, 100%': {

View File

@@ -23,7 +23,7 @@ const InputText = ({
)} )}
/> />
<input <input
className="pl-2 pt-1 text-indigo-800 font-medium mb-px pb-px bg-transparent relative" className="text-ch-gray-300 rounded-none bg-ch-gray-600 border border-transparent focus:border-ch-gray-300 px-2 py-1 relative w-full"
onChange={onChange} onChange={onChange}
value={value} value={value}
readOnly={!onChange} readOnly={!onChange}

View File

@@ -10,7 +10,7 @@ const InputText = ({ type = 'text', className, name, validation }) => {
<> <>
<div className={getActiveClasses('relative mt-5', className)}> <div className={getActiveClasses('relative mt-5', className)}>
<FieldError <FieldError
className="absolute -my-5 text-sm text-red-500 font-ropa-sans" className="absolute -my-5 text-sm text-red-500"
name={name} name={name}
/> />
<TextField <TextField

View File

@@ -0,0 +1,54 @@
import Svg from 'src/components/Svg/Svg'
interface KeyValueType {
keyName: string
children: React.ReactNode
hide?: boolean
canEdit?: boolean
onEdit?: () => void
isEditable?: boolean
bottom?: boolean
className?: string
}
const KeyValue = ({
keyName,
children,
hide = false,
canEdit = false,
onEdit,
isEditable = false,
bottom = false,
className = "",
} : KeyValueType) => {
if (!children || hide) return null
return (
<div className={"flex flex-col " + className}>
<div className={"text-ch-blue-400 font-fira-code flex items-center leading-4 text-sm whitespace-nowrap " + (bottom ? "order-2" : "")}>
{keyName}
{canEdit &&
(isEditable ? (
<button
className="font-fira-sans items-center ml-4 grid grid-flow-col-dense p-px px-2 gap-2 bg-ch-purple-400 bg-opacity-30 hover:bg-opacity-80 rounded-sm border border-ch-purple-400"
id="rename-button"
onClick={onEdit}
>
<Svg
name="check"
className="w-6 h-6 text-ch-purple-500"
strokeWidth={3}
/>
<span>Update</span>
</button>
) : (
<button onClick={onEdit}>
<Svg name="pencil-solid" className="h-4 w-4 ml-4 mb-2" />
</button>
))}
</div>
<div className={"text-ch-gray-300 " + (bottom ? "mb-1" : "mt-1")}>{children}</div>
</div>
)
}
export default KeyValue

View File

@@ -4,6 +4,7 @@ import CadPackage from 'src/components/CadPackage/CadPackage'
import { countEmotes } from 'src/helpers/emote' import { countEmotes } from 'src/helpers/emote'
import ImageUploader from 'src/components/ImageUploader' import ImageUploader from 'src/components/ImageUploader'
import { ImageFallback } from '../ImageUploader/ImageUploader'
const ProjectCard = ({ title, mainImage, user, Reaction, cadPackage }) => ( const ProjectCard = ({ title, mainImage, user, Reaction, cadPackage }) => (
<li <li
@@ -31,9 +32,7 @@ const ProjectCard = ({ title, mainImage, user, Reaction, cadPackage }) => (
</div> </div>
<div className="flex items-center mt-1"> <div className="flex items-center mt-1">
<div className="w-8 h-8 overflow-hidden rounded-full border border-ch-gray-300 shadow"> <div className="w-8 h-8 overflow-hidden rounded-full border border-ch-gray-300 shadow">
<ImageUploader <ImageFallback
className=""
aspectRatio={1}
imageUrl={user?.image} imageUrl={user?.image}
width={50} width={50}
/> />

View File

@@ -17,51 +17,7 @@ import { useIdeInit } from 'src/components/EncodedUrl/helpers'
import ProfileViewer from '../ProfileViewer/ProfileViewer' import ProfileViewer from '../ProfileViewer/ProfileViewer'
import Svg from 'src/components/Svg/Svg' import Svg from 'src/components/Svg/Svg'
import OpenscadStaticImageMessage from 'src/components/OpenscadStaticImageMessage/OpenscadStaticImageMessage' import OpenscadStaticImageMessage from 'src/components/OpenscadStaticImageMessage/OpenscadStaticImageMessage'
import KeyValue from 'src/components/KeyValue/KeyValue'
const KeyValue = ({
keyName,
children,
hide = false,
canEdit = false,
onEdit,
isEditable = false,
}: {
keyName: string
children: React.ReactNode
hide?: boolean
canEdit?: boolean
onEdit?: () => void
isEditable?: boolean
}) => {
if (!children || hide) return null
return (
<div>
<div className="text-ch-blue-400 font-fira-code flex text-sm whitespace-nowrap">
{keyName}
{canEdit &&
(isEditable ? (
<button
className="font-fira-sans items-center ml-4 grid grid-flow-col-dense p-px px-2 gap-2 bg-ch-purple-400 bg-opacity-30 hover:bg-opacity-80 rounded-sm border border-ch-purple-400"
id="rename-button"
onClick={onEdit}
>
<Svg
name="check"
className="w-6 h-6 text-ch-purple-500"
strokeWidth={3}
/>
<span>Update</span>
</button>
) : (
<button onClick={onEdit}>
<Svg name="pencil-solid" className="h-4 w-4 ml-4 mb-2" />
</button>
))}
</div>
<div className="text-ch-gray-300">{children}</div>
</div>
)
}
const ProjectProfile = ({ const ProjectProfile = ({
userProject, userProject,
@@ -140,7 +96,7 @@ const ProjectProfile = ({
</div> </div>
{/* Side panel */} {/* Side panel */}
<div className="bg-ch-gray-760 font-fira-sans px-20 pt-12 overflow-y-auto"> <div className="bg-ch-gray-760 font-fira-sans px-20 pt-12 overflow-y-auto ch-scrollbar">
<div className="grid grid-flow-row-dense gap-6"> <div className="grid grid-flow-row-dense gap-6">
<h3 className="text-5xl capitalize text-ch-gray-300"> <h3 className="text-5xl capitalize text-ch-gray-300">
{project?.title.replace(/-/g, ' ')} {project?.title.replace(/-/g, ' ')}
@@ -301,3 +257,4 @@ const ProjectProfile = ({
} }
export default ProjectProfile export default ProjectProfile

View File

@@ -36,7 +36,7 @@ const ProjectsList = ({
return ( return (
<section className="max-w-7xl mx-auto"> <section className="max-w-7xl mx-auto">
<ul <ul
className="grid gap-x-8 gap-y-8 items-center mx-4 relative" className="grid gap-x-8 gap-y-8 items-center relative"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))' }} style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(16rem, 1fr))' }}
> >
{filteredProjects.map( {filteredProjects.map(

View File

@@ -1,113 +0,0 @@
import { useState, useEffect } from 'react'
import { useAuth } from '@redwoodjs/auth'
import { navigate, routes } from '@redwoodjs/router'
import Editor from 'rich-markdown-editor'
import ImageUploader from 'src/components/ImageUploader'
import Button from 'src/components/Button'
import ProfileTextInput from 'src/components/ProfileTextInput'
import ProjectsOfUser from 'src/components/ProjectsOfUserCell'
const UserProfile = ({
user,
isEditable,
loading,
onSave,
error,
projects,
}) => {
const { currentUser } = useAuth()
const canEdit = currentUser?.sub === user.id
const isImageEditable = !isEditable && canEdit // image is editable when not in profile edit mode in order to separate them as it's too hard too to upload an image to cloudinary temporarily until the use saves (and maybe have to clean up) for the time being
useEffect(() => {
isEditable && !canEdit && navigate(routes.user({ userName: user.userName }))
}, [currentUser])
const [input, setInput] = useState({
userName: user.userName,
name: user.name,
bio: user.bio,
image: user.image,
})
const { userName, name } = input
const editableTextFields = { userName, name }
return (
<>
<section className="max-w-2xl mx-auto mt-20 ">
<div className="flex">
{!isEditable && (
<div className="w-40 flex-shrink-0">
<ImageUploader
className="rounded-half rounded-br-lg shadow-md border-2 border-gray-200 border-solid"
onImageUpload={({ cloudinaryPublicId: image }) => {
onSave(user.userName, {
...input,
image,
})
}}
aspectRatio={1}
isEditable={isImageEditable}
imageUrl={user.image}
width={300}
/>
</div>
)}
<div className="ml-6 flex flex-col justify-between">
<ProfileTextInput
fields={editableTextFields}
onChange={({ userName, name }) =>
setInput({
...input,
name,
userName: userName.replace(/([^a-zA-Z\d_:])/g, '-'),
})
}
isEditable={isEditable}
/>
{isEditable ? (
<Button
className="bg-indigo-200"
iconName="plus"
onClick={() => onSave(user.userName, input)}
>
Save Profile
</Button> // TODO replace pencil with a save icon
) : canEdit ? (
<Button
className="bg-indigo-200"
iconName="pencil"
onClick={() =>
navigate(routes.editUser({ userName: user.userName }))
}
>
Edit Profile
</Button>
) : null}
</div>
</div>
<div className="mt-10">
<h3 className="text-3xl text-gray-500 font-ropa-sans">Bio:</h3>
<div
name="description"
className="markdown-overrides rounded-lg shadow-md bg-white p-12 my-6 min-h-md"
>
<Editor
defaultValue={user.bio || ''}
readOnly={!isEditable}
onChange={(bioFn) =>
setInput({
...input,
bio: bioFn(),
})
}
/>
</div>
</div>
<div className="mt-10">
<h3 className="text-3xl text-gray-500 font-ropa-sans">Projects:</h3>
<ProjectsOfUser userName={user?.userName} />
</div>
</section>
</>
)
}
export default UserProfile

View File

@@ -0,0 +1,112 @@
import { useState, useEffect, useRef, useReducer, ReactNode } from 'react'
import { useAuth } from '@redwoodjs/auth'
import { Link, navigate, routes } from '@redwoodjs/router'
import ProjectsOfUser from 'src/components/ProjectsOfUserCell'
import IdeHeader from 'src/components/IdeHeader/IdeHeader'
import Svg from 'src/components/Svg/Svg'
import { fieldsConfig, fieldReducer, UserProfileType, FieldConfigType } from './userProfileConfig'
function buildFieldsConfig(fieldsConfig, user) {
Object.entries(fieldsConfig).forEach(([key, field] : [string, FieldConfigType]) => {
field.currentValue = field.newValue = user[key]
field.name = key
})
return fieldsConfig
}
const UserProfile = ({
user,
isEditable,
loading,
onSave,
error,
projects,
} : UserProfileType) => {
const { currentUser } = useAuth()
const hasEditPermission = currentUser?.sub === user.id
useEffect(() => {
isEditable && !hasEditPermission && navigate(routes.user({ userName: user.userName }))
}, [currentUser])
const initializedFields = buildFieldsConfig(fieldsConfig, user)
const [fields, fieldDispatch] = useReducer(fieldReducer, initializedFields)
return (
<>
<div className="h-screen flex flex-col text-lg font-fira-sans">
<div className="flex">
<Link
to={routes.home()}
className="w-16 h-16 flex items-center justify-center bg-ch-gray-900"
>
<Svg className="w-12" name="favicon" />
</Link>
<IdeHeader
handleRender={() => {}}
projectOwner={user?.userName}
projectOwnerImage={user?.image}
projectOwnerId={user?.id}
/>
</div>
<div className="relative flex-grow h-full">
<div className="grid md:grid-cols-profile-layout grid-flow-row-dense absolute inset-0">
{/* Side panel */}
<section className="bg-ch-gray-760 font-fira-sans px-12 pt-12 overflow-y-auto ch-scrollbar">
<div className="flex gap-6">
{!isEditable && (
<div className="w-28 flex-shrink-0">
<fields.image.component
field={fields.image}
user={user}
save={onSave}
hasEditPermission={hasEditPermission}
/>
</div>
)}
<div>
<fields.name.component
field={fields.name}
dispatch={fieldDispatch}
user={user}
save={onSave}
hasEditPermission={hasEditPermission}
/>
<fields.userName.component
field={fields.userName}
dispatch={fieldDispatch}
user={user}
save={onSave}
hasEditPermission={hasEditPermission}
/>
</div>
</div>
<div className="mt-10">
<fields.bio.component
field={fields.bio}
dispatch={fieldDispatch}
user={user}
save={onSave}
hasEditPermission={hasEditPermission}
/>
</div>
<div className="my-5">
<fields.createdAt.component
field={fields.createdAt}
/>
</div>
</section>
{/* Viewer */}
<div className="py-10 px-8 w-full min-h-md relative bg-ch-gray-800 overflow-auto ch-scrollbar">
<h3 className="text-2xl text-ch-gray-500 mb-4">Projects</h3>
<ProjectsOfUser userName={user?.userName} />
</div>
</div>
</div>
</div>
</>
)
}
export default UserProfile

View File

@@ -0,0 +1,189 @@
import React, { ReactNode, useRef } from 'react'
import KeyValue from 'src/components/KeyValue/KeyValue'
import InputText from '../InputText/InputText'
import Editor from 'rich-markdown-editor'
import ImageUploader from 'src/components/ImageUploader'
import { User } from 'types/graphql'
export interface UserProfileType {
user: User,
isEditable: boolean,
loading: boolean,
error: boolean,
onSave: Function,
projects: {}[],
}
export interface FieldConfigType {
name?: string, // introspection ugh
editable: boolean,
component?: ReactNode,
needsRef?: boolean,
isEditing?: boolean | undefined,
onSave?: Function,
currentValue?: any,
newValue?: any,
}
const ProfileKeyValue = ({ field, dispatch, user, save, hasEditPermission, children, bottom = false }) => {
return (
<KeyValue
keyName={field.name}
hide={!user[field.name] && !hasEditPermission}
canEdit={hasEditPermission}
onEdit={() => {
if (field.isEditing) {
save(user.userName, { [field.name]: field.newValue })
}
dispatch({ type: "SET_CURRENT_VALUE", payload: { field: field.name, value: field.newValue }})
dispatch({ type: "TOGGLE_EDITING", payload: field.name })
}}
isEditable={hasEditPermission && field.isEditing}
bottom={bottom}
className="mb-4"
>
{ children }
</KeyValue>
)
}
const bioField : FieldConfigType = {
editable: true,
needsRef: true,
component: (props) => {
const ref = useRef(null)
const { dispatch, field } = props
return <ProfileKeyValue {...props}>
<div
id="bio-wrap"
name="bio"
className={
'markdown-overrides rounded-sm pb-2 mt-2' +
(field.isEditable ? ' min-h-md' : '')
}
onClick={(e) =>
e?.target?.id === 'bio-wrap' &&
ref?.current?.focusAtEnd()
}
>
<Editor
ref={ref}
defaultValue={field?.currentValue || ''}
readOnly={!field.isEditing}
onChange={(bio) => dispatch({ type: "SET_NEW_VALUE", payload: { field: field.bio, value: bio() }})}
/>
</div>
</ProfileKeyValue>
},
}
const createdAtField : FieldConfigType = {
editable: false,
component: (props) => {
const { field } = props
return <KeyValue keyName="Member Since">
<p className="text-ch-gray-300">{ new Date(field.currentValue).toLocaleDateString() }</p>
</KeyValue>
},
}
const imageField : FieldConfigType = {
editable: true,
component: (props) => {
const { field, user, save, hasEditPermission } = props
return (
<ImageUploader
className="rounded-3xl rounded-tr-none shadow-md border-2 border-ch-gray-300"
onImageUpload={({ cloudinaryPublicId: image }) => {
save(user.userName, {
image,
})
}}
aspectRatio={1}
isEditable={hasEditPermission && !field.isEditing}
imageUrl={user.image}
width={300}
/>
)
},
}
const nameField : FieldConfigType = {
editable: true,
component: (props) => {
const { user, dispatch, field } = props
return <ProfileKeyValue {...props} bottom={true}>
{ (!field.isEditing)
? <h1 className="text-4xl">{ user?.name }</h1>
: <InputText
className="text-xl"
value={field.newValue}
onChange={({ target: { value } }) => dispatch({ type: "SET_NEW_VALUE", payload: { field: field.name, value }})}
isEditable={!field.isEditable}
/>
}
</ProfileKeyValue>
},
}
const userNameField : FieldConfigType = {
editable: true,
component: (props) => {
const { dispatch, field } = props
return <ProfileKeyValue {...props} bottom={true}>
{ (!field.isEditing)
? <h2 className="text-ch-gray-400">@{ field?.currentValue?.replace(/([^a-zA-Z\d_:])/g, '-') }</h2>
: <InputText
className="text-xl"
value={field.newValue}
onChange={({ target: { value } }) => dispatch({ type: "SET_NEW_VALUE", payload: { field: field.name, value }})}
isEditable={!field.isEditable}
/>
}
</ProfileKeyValue>
},
}
export const fieldsConfig = {
bio: bioField,
createdAt: createdAtField,
id: {
editable: false,
},
image: imageField,
name: nameField,
updatedAt: {
editable: false,
},
userName: userNameField,
}
export function fieldReducer(state, action) {
switch (action.type) {
case "TOGGLE_EDITING":
return {
...state,
[action.payload]: {
...state[action.payload],
isEditing: (state[action.payload].editable && !state[action.payload].isEditing) ? true : false,
}
}
case "SET_NEW_VALUE":
const newState = {
...state,
[action.payload.field]: {
...state[action.payload.field],
newValue: action.payload.value,
}
}
return newState
default:
return state
}
}

View File

@@ -17,6 +17,19 @@
body { body {
font-family: 'Fira Sans', ui-sans-serif, system-ui, -apple-system, system-ui, "Segoe UI", "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-family: 'Fira Sans', ui-sans-serif, system-ui, -apple-system, system-ui, "Segoe UI", "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
} }
/* custom scrollbar */
.ch-scrollbar::-webkit-scrollbar {
@apply w-3;
}
.ch-scrollbar::-webkit-scrollbar-track {
@apply bg-ch-gray-700;
}
.ch-scrollbar::-webkit-scrollbar-thumb {
@apply bg-ch-pink-800 bg-opacity-30 hover:bg-opacity-60;
}
} }

View File

@@ -1,14 +1,13 @@
import MainLayout from 'src/layouts/MainLayout'
import EditUserCell from 'src/components/EditUserCell' import EditUserCell from 'src/components/EditUserCell'
import Seo from 'src/components/Seo/Seo' import Seo from 'src/components/Seo/Seo'
const UserPage = ({ userName }) => { const UserPage = ({ userName }) => {
return ( return (
<MainLayout> <>
<Seo title={userName} description="Add new project page" lang="en-US" /> <Seo title={userName} description="Add new project page" lang="en-US" />
<EditUserCell userName={userName} isEditable /> <EditUserCell userName={userName} isEditable />
</MainLayout> </>
) )
} }

View File

@@ -4,11 +4,11 @@ import Seo from 'src/components/Seo/Seo'
const UserPage = ({ userName }) => { const UserPage = ({ userName }) => {
return ( return (
<MainLayout> <>
<Seo title={userName} description="User page" lang="en-US" /> <Seo title={userName} description="User page" lang="en-US" />
<EditUserCell userName={userName} /> <EditUserCell userName={userName} />
</MainLayout> </>
) )
} }