Contact Page
This commit is contained in:
7
api/db/migrations/20240821020900_portrait/migration.sql
Normal file
7
api/db/migrations/20240821020900_portrait/migration.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Portrait" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"fileId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Portrait_pkey" PRIMARY KEY ("id")
|
||||
);
|
@ -43,3 +43,8 @@ model Social {
|
||||
type Handle
|
||||
username String
|
||||
}
|
||||
|
||||
model Portrait {
|
||||
id Int @id @default(autoincrement())
|
||||
fileId String
|
||||
}
|
||||
|
19
api/src/graphql/portraits.sdl.ts
Normal file
19
api/src/graphql/portraits.sdl.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export const schema = gql`
|
||||
type Portrait {
|
||||
id: Int!
|
||||
fileId: String!
|
||||
}
|
||||
|
||||
type Query {
|
||||
portrait: Portrait @skipAuth
|
||||
}
|
||||
|
||||
input CreatePortraitInput {
|
||||
fileId: String!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createPortrait(input: CreatePortraitInput!): Portrait! @requireAuth
|
||||
deletePortrait: Portrait! @requireAuth
|
||||
}
|
||||
`
|
@ -20,8 +20,8 @@ export const schema = gql`
|
||||
}
|
||||
|
||||
type Query {
|
||||
socials: [Social!]! @requireAuth
|
||||
social(id: Int!): Social @requireAuth
|
||||
socials: [Social!]! @skipAuth
|
||||
social(id: Int!): Social @skipAuth
|
||||
}
|
||||
|
||||
input CreateSocialInput {
|
||||
|
@ -39,7 +39,10 @@ export const handleTusUpload = (
|
||||
res.raw.statusCode = 405
|
||||
res.raw.end('Method not allowed')
|
||||
}
|
||||
} else tusHandler.handle(req.raw, res.raw)
|
||||
} else {
|
||||
setCorsHeaders(res)
|
||||
tusHandler.handle(req.raw, res.raw)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthenticatedRequest = async (
|
||||
|
37
api/src/services/portraits/portraits.ts
Normal file
37
api/src/services/portraits/portraits.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { QueryResolvers, MutationResolvers } from 'types/graphql'
|
||||
|
||||
import { ValidationError } from '@redwoodjs/graphql-server'
|
||||
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
export const portrait: QueryResolvers['portrait'] = async () => {
|
||||
const portrait = await db.portrait.findFirst()
|
||||
|
||||
if (portrait) return portrait
|
||||
else
|
||||
return {
|
||||
id: -1,
|
||||
fileId: '/no_portrait.webp',
|
||||
}
|
||||
}
|
||||
|
||||
export const createPortrait: MutationResolvers['createPortrait'] = async ({
|
||||
input,
|
||||
}) => {
|
||||
if (await db.portrait.findFirst())
|
||||
throw new ValidationError('Portrait already exists')
|
||||
else
|
||||
return db.portrait.create({
|
||||
data: input,
|
||||
})
|
||||
}
|
||||
|
||||
export const deletePortrait: MutationResolvers['deletePortrait'] = async () => {
|
||||
const portrait = await db.portrait.findFirst()
|
||||
|
||||
if (!portrait) throw new ValidationError('Portrait does not exist')
|
||||
else
|
||||
return db.portrait.delete({
|
||||
where: { id: portrait.id },
|
||||
})
|
||||
}
|
@ -31,6 +31,13 @@ export const theme = {
|
||||
extend: {
|
||||
width: {
|
||||
46: '11.5rem',
|
||||
54: '13.5rem',
|
||||
},
|
||||
maxWidth: {
|
||||
68: '17rem',
|
||||
},
|
||||
aspectRatio: {
|
||||
portrait: '4 / 5',
|
||||
},
|
||||
fontFamily: {
|
||||
syne: ['Syne', 'sans-serif'],
|
||||
|
@ -23,7 +23,6 @@
|
||||
"@uppy/dashboard": "^4.0.2",
|
||||
"@uppy/drag-drop": "^4.0.1",
|
||||
"@uppy/file-input": "^4.0.0",
|
||||
"@uppy/image-editor": "^3.0.0",
|
||||
"@uppy/progress-bar": "^4.0.0",
|
||||
"@uppy/react": "^4.0.1",
|
||||
"@uppy/tus": "^4.0.0",
|
||||
|
BIN
web/public/no_portrait.webp
Normal file
BIN
web/public/no_portrait.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.8 KiB |
@ -4,6 +4,7 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
|
||||
import FatalErrorPage from 'src/pages/FatalErrorPage'
|
||||
import Routes from 'src/Routes'
|
||||
|
||||
import './scaffold.css'
|
||||
import { AuthProvider, useAuth } from './auth'
|
||||
|
||||
import './index.css'
|
||||
|
@ -16,6 +16,10 @@ const Routes = () => {
|
||||
<Route path="/socials/{id:Int}" page={SocialSocialPage} name="social" />
|
||||
<Route path="/socials" page={SocialSocialsPage} name="socials" />
|
||||
</Set>
|
||||
|
||||
<Set wrap={ScaffoldLayout} title="Portrait" titleTo="portrait">
|
||||
<Route path="/portrait" page={PortraitPortraitPage} name="portrait" />
|
||||
</Set>
|
||||
</PrivateSet>
|
||||
|
||||
<Set wrap={AccountbarLayout} title="Login">
|
||||
@ -32,6 +36,7 @@ const Routes = () => {
|
||||
|
||||
<Set wrap={NavbarLayout}>
|
||||
<Route path="/" page={HomePage} name="home" />
|
||||
<Route path="/contact" page={ContactPage} name="contact" />
|
||||
</Set>
|
||||
|
||||
<Route notfound page={NotFoundPage} />
|
||||
|
80
web/src/components/ContactCard/ContactCard/ContactCard.tsx
Normal file
80
web/src/components/ContactCard/ContactCard/ContactCard.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
import SocialLinksCell from 'src/components/Social/SocialLinksCell'
|
||||
|
||||
interface ContactCardProps {
|
||||
portraitUrl: string
|
||||
}
|
||||
|
||||
const ContactCard = ({ portraitUrl }: ContactCardProps) => {
|
||||
const [width, setWidth] = useState()
|
||||
const [height, setHeight] = useState()
|
||||
|
||||
const observedDiv = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!observedDiv.current) return
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (observedDiv.current.offsetWidth !== width)
|
||||
setWidth(observedDiv.current.offsetWidth)
|
||||
|
||||
if (observedDiv.current.offsetHeight !== height)
|
||||
setHeight(observedDiv.current.offsetHeight)
|
||||
})
|
||||
|
||||
resizeObserver.observe(observedDiv.current)
|
||||
|
||||
return function cleanup() {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [width, height])
|
||||
|
||||
return (
|
||||
<>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
/* Tailwind arbitrary classes (e.g h-[${height}px]) doesn't work? */
|
||||
__html: `
|
||||
.contact-me-image {
|
||||
width: ${width}px;
|
||||
height: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
width: auto;
|
||||
min-height: 18.25rem;
|
||||
height: ${height}px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
|
||||
<div className="card bg-base-100 shadow-xl md:card-side">
|
||||
<figure>
|
||||
<img
|
||||
className="contact-me-image aspect-portrait object-cover"
|
||||
src={portraitUrl}
|
||||
alt={`${process.env.NAME}`}
|
||||
/>
|
||||
</figure>
|
||||
<div
|
||||
className="card-body mx-auto h-fit w-fit md:mx-0"
|
||||
ref={observedDiv}
|
||||
>
|
||||
<h2 className="card-title justify-center text-3xl md:justify-start">
|
||||
Contact Me
|
||||
</h2>
|
||||
<p className="p-2"></p>
|
||||
<div className="card-actions">
|
||||
<SocialLinksCell />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContactCard
|
@ -0,0 +1,37 @@
|
||||
import type { FindPortrait, FindPortraitVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
TypedDocumentNode,
|
||||
CellFailureProps,
|
||||
CellSuccessProps,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
import ContactCard from 'src/components/ContactCard/ContactCard/ContactCard'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
FindPortrait,
|
||||
FindPortraitVariables
|
||||
> = gql`
|
||||
query FindPortrait {
|
||||
portrait: portrait {
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
|
||||
export const Empty = () => <CellEmpty />
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps<FindPortrait>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
|
||||
export const Success = ({
|
||||
portrait,
|
||||
}: CellSuccessProps<FindPortrait, FindPortraitVariables>) => (
|
||||
<ContactCard portraitUrl={portrait.fileId} />
|
||||
)
|
37
web/src/components/Portrait/PortraitCell/PortraitCell.tsx
Normal file
37
web/src/components/Portrait/PortraitCell/PortraitCell.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import type { FindPortrait, FindPortraitVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
import PortraitForm from 'src/components/Portrait/PortraitForm/PortraitForm'
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
FindPortrait,
|
||||
FindPortraitVariables
|
||||
> = gql`
|
||||
query FindPortrait {
|
||||
portrait: portrait {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
|
||||
export const Empty = () => <CellEmpty />
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps<FindPortraitVariables>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
|
||||
export const Success = ({
|
||||
portrait,
|
||||
}: CellSuccessProps<FindPortrait, FindPortraitVariables>) =>
|
||||
portrait.id === -1 ? <PortraitForm /> : <PortraitForm portrait={portrait} />
|
213
web/src/components/Portrait/PortraitForm/PortraitForm.tsx
Normal file
213
web/src/components/Portrait/PortraitForm/PortraitForm.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { Meta, UploadResult } from '@uppy/core'
|
||||
import type {
|
||||
CreatePortraitMutation,
|
||||
CreatePortraitMutationVariables,
|
||||
DeletePortraitMutation,
|
||||
DeletePortraitMutationVariables,
|
||||
EditPortrait,
|
||||
FindPortrait,
|
||||
FindPortraitVariables,
|
||||
} from 'types/graphql'
|
||||
|
||||
import { TypedDocumentNode, useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/dist/toast'
|
||||
|
||||
import Uploader from 'src/components/Uploader/Uploader'
|
||||
|
||||
interface PortraitFormProps {
|
||||
portrait?: EditPortrait['portrait']
|
||||
}
|
||||
|
||||
export const QUERY: TypedDocumentNode<
|
||||
FindPortrait,
|
||||
FindPortraitVariables
|
||||
> = gql`
|
||||
query FindPortrait {
|
||||
portrait {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DELETE_PORTRAIT_MUTATION: TypedDocumentNode<
|
||||
DeletePortraitMutation,
|
||||
DeletePortraitMutationVariables
|
||||
> = gql`
|
||||
mutation DeletePortraitMutation {
|
||||
deletePortrait {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CREATE_PORTRAIT_MUTATION: TypedDocumentNode<
|
||||
CreatePortraitMutation,
|
||||
CreatePortraitMutationVariables
|
||||
> = gql`
|
||||
mutation CreatePortraitMutation($input: CreatePortraitInput!) {
|
||||
createPortrait(input: $input) {
|
||||
id
|
||||
fileId
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PortraitForm = (props: PortraitFormProps) => {
|
||||
const [fileId, _setFileId] = useState<string>(props.portrait?.fileId)
|
||||
const fileIdRef = useRef(fileId)
|
||||
|
||||
const setFileId = (fileId: string) => {
|
||||
_setFileId(fileId)
|
||||
fileIdRef.current = fileId
|
||||
}
|
||||
|
||||
const unloadAbortController = new AbortController()
|
||||
|
||||
const [deletePortrait, { loading: deleteLoading }] = useMutation(
|
||||
DELETE_PORTRAIT_MUTATION,
|
||||
{
|
||||
onCompleted: () => {
|
||||
toast.success('Portrait deleted')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
refetchQueries: [{ query: QUERY }],
|
||||
awaitRefetchQueries: true,
|
||||
}
|
||||
)
|
||||
|
||||
const [createPortrait, { loading: createLoading }] = useMutation(
|
||||
CREATE_PORTRAIT_MUTATION,
|
||||
{
|
||||
onCompleted: () => toast.success('Portrait saved'),
|
||||
onError: (error) => toast.error(error.message),
|
||||
refetchQueries: [{ query: QUERY }],
|
||||
awaitRefetchQueries: true,
|
||||
}
|
||||
)
|
||||
|
||||
const handleBeforeUnload = (_e: BeforeUnloadEvent) => {
|
||||
deleteFile(fileIdRef.current)
|
||||
|
||||
if (navigator.userAgent.match(/firefox|fxios/i)) {
|
||||
// Dear Mozilla, please implement keepalive on Firefox
|
||||
// Sincerely, everybody
|
||||
const time = Date.now()
|
||||
while (Date.now() - time < 500) {
|
||||
/* empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onUploadComplete = (
|
||||
result: UploadResult<Meta, Record<string, never>>
|
||||
) => {
|
||||
setFileId(result.successful[0]?.uploadURL)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload, {
|
||||
once: true,
|
||||
signal: unloadAbortController.signal,
|
||||
})
|
||||
}
|
||||
|
||||
if (props.portrait?.fileId)
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
<img
|
||||
className="aspect-portrait max-w-2xl rounded-xl object-cover"
|
||||
src={props.portrait?.fileId}
|
||||
alt={`${process.env.NAME} Portrait`}
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
title="Delete portrait"
|
||||
className="btn btn-error btn-sm uppercase"
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure?')) {
|
||||
deleteFile(props.portrait?.fileId)
|
||||
deletePortrait()
|
||||
setFileId(null)
|
||||
}
|
||||
}}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
else
|
||||
return (
|
||||
<div className="mx-auto w-fit space-y-2">
|
||||
{!fileId ? (
|
||||
<>
|
||||
<Uploader
|
||||
onComplete={onUploadComplete}
|
||||
width="22rem"
|
||||
height="11.5rem"
|
||||
className="flex justify-center"
|
||||
/>
|
||||
<p className="text-center">
|
||||
High quality, 4:5 aspect ratio image recommended
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
className="aspect-portrait max-w-2xl rounded-xl object-cover"
|
||||
src={fileId}
|
||||
alt={`${process.env.NAME} Portrait`}
|
||||
/>
|
||||
)}
|
||||
{fileId && (
|
||||
<div className="flex justify-center space-x-2">
|
||||
<button
|
||||
className={`btn btn-sm ${!fileId && 'btn-disabled'} uppercase`}
|
||||
disabled={!fileId || deleteLoading}
|
||||
onClick={() => {
|
||||
deleteFile(fileId)
|
||||
setFileId(null)
|
||||
unloadAbortController.abort()
|
||||
}}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-primary btn-sm ${
|
||||
!fileId && 'btn-disabled'
|
||||
} uppercase`}
|
||||
disabled={!fileId || createLoading}
|
||||
onClick={() => {
|
||||
createPortrait({
|
||||
variables: {
|
||||
input: {
|
||||
fileId,
|
||||
},
|
||||
},
|
||||
})
|
||||
unloadAbortController.abort()
|
||||
}}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const deleteFile = async (fileId: string) => {
|
||||
await fetch(fileId, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Tus-Resumable': '1.0.0',
|
||||
},
|
||||
keepalive: true,
|
||||
})
|
||||
}
|
||||
|
||||
export default PortraitForm
|
@ -45,12 +45,16 @@ const NewSocial = () => {
|
||||
<div className="overflow-hidden overflow-x-auto rounded-xl bg-base-100">
|
||||
<table className="table w-80">
|
||||
<thead className="bg-base-200 font-syne">
|
||||
<th className="w-0">New Social</th>
|
||||
<tr>
|
||||
<th>New Social</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<th>
|
||||
<SocialForm onSave={onSave} error={error} loading={loading} />
|
||||
</th>
|
||||
<tr>
|
||||
<th>
|
||||
<SocialForm onSave={onSave} error={error} loading={loading} />
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
26
web/src/components/Social/SocialLinks/SocialLinks.tsx
Normal file
26
web/src/components/Social/SocialLinks/SocialLinks.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { FindSocials } from 'types/graphql'
|
||||
|
||||
import { baseUrls, getLogoComponent } from 'src/lib/handle'
|
||||
|
||||
const SocialLinks = ({ socials }: FindSocials) => {
|
||||
return (
|
||||
<div className={`grid max-w-68 grid-cols-[repeat(auto-fit,3rem)] gap-2`}>
|
||||
{[...socials]
|
||||
.sort((a, b) => (a.type > b.type ? 1 : -1))
|
||||
.map((social, i) => (
|
||||
<div key={i} className="tooltip" data-tip={social.name}>
|
||||
<a
|
||||
className="btn btn-square"
|
||||
href={`${baseUrls[social.type]}${social.username}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{getLogoComponent(social.type)}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SocialLinks
|
@ -0,0 +1,35 @@
|
||||
import type { FindSocials, FindSocialsVariables } from 'types/graphql'
|
||||
|
||||
import type {
|
||||
CellSuccessProps,
|
||||
CellFailureProps,
|
||||
TypedDocumentNode,
|
||||
} from '@redwoodjs/web'
|
||||
|
||||
import CellEmpty from 'src/components/Cell/CellEmpty/CellEmpty'
|
||||
import CellFailure from 'src/components/Cell/CellFailure/CellFailure'
|
||||
import CellLoading from 'src/components/Cell/CellLoading/CellLoading'
|
||||
import SocialLinks from 'src/components/Social/SocialLinks/SocialLinks'
|
||||
|
||||
export const QUERY: TypedDocumentNode<FindSocials, FindSocialsVariables> = gql`
|
||||
query SocialsQuery {
|
||||
socials {
|
||||
id
|
||||
name
|
||||
type
|
||||
username
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <CellLoading />
|
||||
|
||||
export const Empty = () => <CellEmpty />
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps<FindSocials>) => (
|
||||
<CellFailure error={error} />
|
||||
)
|
||||
|
||||
export const Success = ({ socials }: CellSuccessProps<FindSocials>) => (
|
||||
<SocialLinks socials={socials} />
|
||||
)
|
@ -3,7 +3,6 @@ import { useState } from 'react'
|
||||
import Compressor from '@uppy/compressor'
|
||||
import Uppy from '@uppy/core'
|
||||
import type { UploadResult, Meta } from '@uppy/core'
|
||||
import ImageEditor from '@uppy/image-editor'
|
||||
import { Dashboard } from '@uppy/react'
|
||||
import Tus from '@uppy/tus'
|
||||
|
||||
@ -11,17 +10,30 @@ import { isProduction } from '@redwoodjs/api/dist/logger'
|
||||
|
||||
import '@uppy/core/dist/style.min.css'
|
||||
import '@uppy/dashboard/dist/style.min.css'
|
||||
import '@uppy/image-editor/dist/style.min.css'
|
||||
|
||||
interface Props {
|
||||
onComplete?(result: UploadResult<Meta, Record<string, never>>): void
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
className?: string
|
||||
maxFiles?: number
|
||||
disabled?: boolean
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
const apiDomain = isProduction
|
||||
? process.env.API_ADDRESS_PROD
|
||||
: process.env.API_ADDRESS_DEV
|
||||
|
||||
const Uploader = ({ onComplete }: Props) => {
|
||||
const Uploader = ({
|
||||
onComplete,
|
||||
width,
|
||||
height,
|
||||
className,
|
||||
disabled = false,
|
||||
hidden = false,
|
||||
maxFiles = 1,
|
||||
}: Props) => {
|
||||
const [uppy] = useState(() => {
|
||||
const instance = new Uppy({
|
||||
restrictions: {
|
||||
@ -31,7 +43,7 @@ const Uploader = ({ onComplete }: Props) => {
|
||||
'image/jpg',
|
||||
'image/jpeg',
|
||||
],
|
||||
maxNumberOfFiles: 10,
|
||||
maxNumberOfFiles: maxFiles,
|
||||
maxFileSize: 25 * 1024 * 1024,
|
||||
},
|
||||
onBeforeUpload: (files) => {
|
||||
@ -54,12 +66,21 @@ const Uploader = ({ onComplete }: Props) => {
|
||||
.use(Compressor, {
|
||||
mimeType: 'image/webp',
|
||||
})
|
||||
.use(ImageEditor)
|
||||
|
||||
return instance.on('complete', onComplete)
|
||||
})
|
||||
|
||||
return <Dashboard uppy={uppy} proudlyDisplayPoweredByUppy={false} />
|
||||
return (
|
||||
<Dashboard
|
||||
uppy={uppy}
|
||||
proudlyDisplayPoweredByUppy={false}
|
||||
width={width}
|
||||
height={height}
|
||||
disabled={disabled}
|
||||
hidden={hidden}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Uploader
|
||||
|
@ -19,11 +19,10 @@ type NavbarLayoutProps = {
|
||||
const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
const { isAuthenticated, logOut } = useAuth()
|
||||
|
||||
// TODO: populate with buttons to other page
|
||||
const navbarRoutes: NavbarRoute[] = [
|
||||
{
|
||||
name: 'Test',
|
||||
path: routes.home(),
|
||||
name: 'Contact',
|
||||
path: routes.contact(),
|
||||
},
|
||||
]
|
||||
|
||||
@ -32,6 +31,10 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => {
|
||||
name: 'Socials',
|
||||
path: routes.socials(),
|
||||
},
|
||||
{
|
||||
name: 'Portrait',
|
||||
path: routes.portrait(),
|
||||
},
|
||||
]
|
||||
|
||||
const navbarButtons = () =>
|
||||
|
42
web/src/pages/ContactPage/ContactPage.tsx
Normal file
42
web/src/pages/ContactPage/ContactPage.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Metadata } from '@redwoodjs/web'
|
||||
|
||||
import ContactCardCell from 'src/components/ContactCard/ContactCardCell'
|
||||
|
||||
const ContactPage = () => {
|
||||
const [width, setWidth] = useState()
|
||||
const [height, setHeight] = useState()
|
||||
|
||||
const observedDiv = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!observedDiv.current) return
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (observedDiv.current.offsetWidth !== width)
|
||||
setWidth(observedDiv.current.offsetWidth)
|
||||
|
||||
if (observedDiv.current.offsetHeight !== height)
|
||||
setHeight(observedDiv.current.offsetHeight)
|
||||
})
|
||||
|
||||
resizeObserver.observe(observedDiv.current)
|
||||
|
||||
return function cleanup() {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [width, height])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Metadata title={`${process.env.NAME} | Contact`} />
|
||||
|
||||
<div className="flex min-h-[calc(100vh-6rem)] items-center justify-center">
|
||||
<ContactCardCell />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContactPage
|
5
web/src/pages/Portrait/PortraitPage/PortraitPage.tsx
Normal file
5
web/src/pages/Portrait/PortraitPage/PortraitPage.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import PortraitCell from 'src/components/Portrait/PortraitCell'
|
||||
|
||||
const PortraitPage = () => <PortraitCell />
|
||||
|
||||
export default PortraitPage
|
@ -1,7 +1,5 @@
|
||||
import NewSocial from 'src/components/Social/NewSocial'
|
||||
import NewSocial from 'src/components/Social/NewSocial/NewSocial'
|
||||
|
||||
const NewSocialPage = () => {
|
||||
return <NewSocial />
|
||||
}
|
||||
const NewSocialPage = () => <NewSocial />
|
||||
|
||||
export default NewSocialPage
|
||||
|
243
web/src/scaffold.css
Normal file
243
web/src/scaffold.css
Normal file
@ -0,0 +1,243 @@
|
||||
.rw-scaffold {
|
||||
@apply bg-white text-gray-600;
|
||||
}
|
||||
.rw-scaffold h1,
|
||||
.rw-scaffold h2 {
|
||||
@apply m-0;
|
||||
}
|
||||
.rw-scaffold a {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
.rw-scaffold ul {
|
||||
@apply m-0 p-0;
|
||||
}
|
||||
.rw-scaffold input:-ms-input-placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
.rw-scaffold input::-ms-input-placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
.rw-scaffold input::placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
.rw-header {
|
||||
@apply flex justify-between px-8 py-4;
|
||||
}
|
||||
.rw-main {
|
||||
@apply mx-4 pb-4;
|
||||
}
|
||||
.rw-segment {
|
||||
@apply w-full overflow-hidden rounded-lg border border-gray-200;
|
||||
scrollbar-color: theme('colors.zinc.400') transparent;
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar {
|
||||
height: initial;
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar-track {
|
||||
@apply rounded-b-[10px] rounded-t-none border-0 border-t border-solid border-gray-200 bg-transparent p-[2px];
|
||||
}
|
||||
.rw-segment::-webkit-scrollbar-thumb {
|
||||
@apply rounded-full border-[3px] border-solid border-transparent bg-zinc-400 bg-clip-content;
|
||||
}
|
||||
.rw-segment-header {
|
||||
@apply bg-gray-200 px-4 py-3 text-gray-700;
|
||||
}
|
||||
.rw-segment-main {
|
||||
@apply bg-gray-100 p-4;
|
||||
}
|
||||
.rw-link {
|
||||
@apply text-blue-400 underline;
|
||||
}
|
||||
.rw-link:hover {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
.rw-forgot-link {
|
||||
@apply mt-1 text-right text-xs text-gray-400 underline;
|
||||
}
|
||||
.rw-forgot-link:hover {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
.rw-heading {
|
||||
@apply font-semibold;
|
||||
}
|
||||
.rw-heading.rw-heading-primary {
|
||||
@apply text-xl;
|
||||
}
|
||||
.rw-heading.rw-heading-secondary {
|
||||
@apply text-sm;
|
||||
}
|
||||
.rw-heading .rw-link {
|
||||
@apply text-gray-600 no-underline;
|
||||
}
|
||||
.rw-heading .rw-link:hover {
|
||||
@apply text-gray-900 underline;
|
||||
}
|
||||
.rw-cell-error {
|
||||
@apply text-sm font-semibold;
|
||||
}
|
||||
.rw-form-wrapper {
|
||||
@apply -mt-4 text-sm;
|
||||
}
|
||||
.rw-cell-error,
|
||||
.rw-form-error-wrapper {
|
||||
@apply my-4 rounded border border-red-100 bg-red-50 p-4 text-red-600;
|
||||
}
|
||||
.rw-form-error-title {
|
||||
@apply m-0 font-semibold;
|
||||
}
|
||||
.rw-form-error-list {
|
||||
@apply mt-2 list-inside list-disc;
|
||||
}
|
||||
.rw-button {
|
||||
@apply flex cursor-pointer justify-center rounded border-0 bg-gray-200 px-4 py-1 text-xs font-semibold uppercase leading-loose tracking-wide text-gray-500 no-underline transition duration-100;
|
||||
}
|
||||
.rw-button:hover {
|
||||
@apply bg-gray-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-small {
|
||||
@apply rounded-sm px-2 py-1 text-xs;
|
||||
}
|
||||
.rw-button.rw-button-green {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-green:hover {
|
||||
@apply bg-green-700;
|
||||
}
|
||||
.rw-button.rw-button-blue {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-blue:hover {
|
||||
@apply bg-blue-700;
|
||||
}
|
||||
.rw-button.rw-button-red {
|
||||
@apply bg-red-500 text-white;
|
||||
}
|
||||
.rw-button.rw-button-red:hover {
|
||||
@apply bg-red-700 text-white;
|
||||
}
|
||||
.rw-button-icon {
|
||||
@apply mr-1 text-xl leading-5;
|
||||
}
|
||||
.rw-button-group {
|
||||
@apply mx-2 my-3 flex justify-center;
|
||||
}
|
||||
.rw-button-group .rw-button {
|
||||
@apply mx-1;
|
||||
}
|
||||
.rw-form-wrapper .rw-button-group {
|
||||
@apply mt-8;
|
||||
}
|
||||
.rw-label {
|
||||
@apply mt-6 block text-left font-semibold text-gray-600;
|
||||
}
|
||||
.rw-label.rw-label-error {
|
||||
@apply text-red-600;
|
||||
}
|
||||
.rw-input {
|
||||
@apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none;
|
||||
}
|
||||
.rw-check-radio-items {
|
||||
@apply flex justify-items-center;
|
||||
}
|
||||
.rw-check-radio-item-none {
|
||||
@apply text-gray-600;
|
||||
}
|
||||
.rw-input[type='checkbox'],
|
||||
.rw-input[type='radio'] {
|
||||
@apply ml-0 mr-1 mt-1 inline w-4;
|
||||
}
|
||||
.rw-input:focus {
|
||||
@apply border-gray-400;
|
||||
}
|
||||
.rw-input-error {
|
||||
@apply border-red-600 text-red-600;
|
||||
}
|
||||
.rw-input-error:focus {
|
||||
@apply border-red-600 outline-none;
|
||||
box-shadow: 0 0 5px #c53030;
|
||||
}
|
||||
.rw-field-error {
|
||||
@apply mt-1 block text-xs font-semibold uppercase text-red-600;
|
||||
}
|
||||
.rw-table-wrapper-responsive {
|
||||
@apply overflow-x-auto;
|
||||
}
|
||||
.rw-table-wrapper-responsive .rw-table {
|
||||
min-width: 48rem;
|
||||
}
|
||||
.rw-table {
|
||||
@apply w-full text-sm;
|
||||
}
|
||||
.rw-table th,
|
||||
.rw-table td {
|
||||
@apply p-3;
|
||||
}
|
||||
.rw-table td {
|
||||
@apply bg-white text-gray-900;
|
||||
}
|
||||
.rw-table tr:nth-child(odd) td,
|
||||
.rw-table tr:nth-child(odd) th {
|
||||
@apply bg-gray-50;
|
||||
}
|
||||
.rw-table thead tr {
|
||||
@apply bg-gray-200 text-gray-600;
|
||||
}
|
||||
.rw-table th {
|
||||
@apply text-left font-semibold;
|
||||
}
|
||||
.rw-table thead th {
|
||||
@apply text-left;
|
||||
}
|
||||
.rw-table tbody th {
|
||||
@apply text-right;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.rw-table tbody th {
|
||||
@apply w-1/5;
|
||||
}
|
||||
}
|
||||
.rw-table tbody tr {
|
||||
@apply border-t border-gray-200;
|
||||
}
|
||||
.rw-table input {
|
||||
@apply ml-0;
|
||||
}
|
||||
.rw-table-actions {
|
||||
@apply flex h-4 items-center justify-end pr-1;
|
||||
}
|
||||
.rw-table-actions .rw-button {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
.rw-table-actions .rw-button:hover {
|
||||
@apply bg-gray-500 text-white;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue {
|
||||
@apply text-blue-500;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue:hover {
|
||||
@apply bg-blue-500 text-white;
|
||||
}
|
||||
.rw-table-actions .rw-button-red {
|
||||
@apply text-red-600;
|
||||
}
|
||||
.rw-table-actions .rw-button-red:hover {
|
||||
@apply bg-red-600 text-white;
|
||||
}
|
||||
.rw-text-center {
|
||||
@apply text-center;
|
||||
}
|
||||
.rw-login-container {
|
||||
@apply mx-auto my-16 flex w-96 flex-wrap items-center justify-center;
|
||||
}
|
||||
.rw-login-container .rw-form-wrapper {
|
||||
@apply w-full text-center;
|
||||
}
|
||||
.rw-login-link {
|
||||
@apply mt-4 w-full text-center text-sm text-gray-600;
|
||||
}
|
||||
.rw-webauthn-wrapper {
|
||||
@apply mx-4 mt-6 leading-6;
|
||||
}
|
||||
.rw-webauthn-wrapper h2 {
|
||||
@apply mb-4 text-xl font-bold;
|
||||
}
|
21
yarn.lock
21
yarn.lock
@ -6241,19 +6241,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@uppy/image-editor@npm:^3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@uppy/image-editor@npm:3.0.0"
|
||||
dependencies:
|
||||
"@uppy/utils": "npm:^6.0.0"
|
||||
cropperjs: "npm:1.5.7"
|
||||
preact: "npm:^10.5.13"
|
||||
peerDependencies:
|
||||
"@uppy/core": ^4.0.0
|
||||
checksum: 10c0/c609720ddb53c6116763a6f8e8569f05a6d67ed0e9664bdedcb65dca08a744d4bed2bcb141e8e701091c89a351cdf5bce28da57d0c39972a8a512ef200f063c7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@uppy/informer@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "@uppy/informer@npm:4.0.0"
|
||||
@ -9569,13 +9556,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cropperjs@npm:1.5.7":
|
||||
version: 1.5.7
|
||||
resolution: "cropperjs@npm:1.5.7"
|
||||
checksum: 10c0/0674042b395397f17e8ffd5dcb639663bf00ff4dcbb896329033e7daa255f22ea5a61548460be9cf1c21e44b874d01d8464cde92bb7a9565670a24694b7468f1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cross-env@npm:7.0.3":
|
||||
version: 7.0.3
|
||||
resolution: "cross-env@npm:7.0.3"
|
||||
@ -21651,7 +21631,6 @@ __metadata:
|
||||
"@uppy/dashboard": "npm:^4.0.2"
|
||||
"@uppy/drag-drop": "npm:^4.0.1"
|
||||
"@uppy/file-input": "npm:^4.0.0"
|
||||
"@uppy/image-editor": "npm:^3.0.0"
|
||||
"@uppy/progress-bar": "npm:^4.0.0"
|
||||
"@uppy/react": "npm:^4.0.1"
|
||||
"@uppy/tus": "npm:^4.0.0"
|
||||
|
Reference in New Issue
Block a user