diff --git a/heat-stack/app/routes/settings+/profile.photo.tsx b/heat-stack/app/routes/settings+/profile.photo.tsx
index 5fc95aa6..30264243 100644
--- a/heat-stack/app/routes/settings+/profile.photo.tsx
+++ b/heat-stack/app/routes/settings+/profile.photo.tsx
@@ -1,5 +1,6 @@
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import {
json,
redirect,
@@ -7,15 +8,21 @@ import {
unstable_parseMultipartFormData,
type DataFunctionArgs,
} from '@remix-run/node'
-import { Form, useActionData, useLoaderData } from '@remix-run/react'
+import {
+ Form,
+ useActionData,
+ useLoaderData,
+ useNavigation,
+} from '@remix-run/react'
import { useState } from 'react'
-import { ServerOnly } from 'remix-utils'
+import { AuthenticityTokenInput } from 'remix-utils/csrf/react'
import { z } from 'zod'
import { ErrorList } from '#app/components/forms.tsx'
import { Button } from '#app/components/ui/button.tsx'
import { Icon } from '#app/components/ui/icon.tsx'
import { StatusButton } from '#app/components/ui/status-button.tsx'
import { requireUserId } from '#app/utils/auth.server.ts'
+import { validateCSRF } from '#app/utils/csrf.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import {
getUserImgSrc,
@@ -23,9 +30,11 @@ import {
useDoubleCheck,
useIsPending,
} from '#app/utils/misc.tsx'
+import { type BreadcrumbHandle } from './profile.tsx'
-export const handle = {
+export const handle: BreadcrumbHandle & SEOHandle = {
breadcrumb:
Photo,
+ getSitemapEntries: () => null,
}
const MAX_SIZE = 1024 * 1024 * 3 // 3MB
@@ -34,7 +43,7 @@ const DeleteImageSchema = z.object({
intent: z.literal('delete'),
})
-const SubmitFormSchema = z.object({
+const NewImageSchema = z.object({
intent: z.literal('submit'),
photoFile: z
.instanceof(File)
@@ -42,7 +51,7 @@ const SubmitFormSchema = z.object({
.refine(file => file.size <= MAX_SIZE, 'Image size must be less than 3MB'),
})
-const PhotoFormSchema = z.union([DeleteImageSchema, SubmitFormSchema])
+const PhotoFormSchema = z.union([DeleteImageSchema, NewImageSchema])
export async function loader({ request }: DataFunctionArgs) {
const userId = await requireUserId(request)
@@ -65,6 +74,7 @@ export async function action({ request }: DataFunctionArgs) {
request,
unstable_createMemoryUploadHandler({ maxPartSize: MAX_SIZE }),
)
+ await validateCSRF(formData, request.headers)
const submission = await parse(formData, {
schema: PhotoFormSchema.transform(async data => {
@@ -112,18 +122,26 @@ export default function PhotoRoute() {
const doubleCheckDeleteImage = useDoubleCheck()
const actionData = useActionData
()
+ const navigation = useNavigation()
const [form, fields] = useForm({
id: 'profile-photo',
constraint: getFieldsetConstraint(PhotoFormSchema),
lastSubmission: actionData?.submission,
onValidate({ formData }) {
- return parse(formData, { schema: PhotoFormSchema })
+ // otherwise, the best error zod gives us is "Invalid input" which is not
+ // enough
+ if (formData.get('intent') === 'delete') {
+ return parse(formData, { schema: DeleteImageSchema })
+ }
+ return parse(formData, { schema: NewImageSchema })
},
shouldRevalidate: 'onBlur',
})
const isPending = useIsPending()
+ const pendingIntent = isPending ? navigation.formData?.get('intent') : null
+ const lastSubmissionIntent = actionData?.submission.value?.intent
const [newImageSrc, setNewImageSrc] = useState(null)
@@ -136,6 +154,7 @@ export default function PhotoRoute() {
onReset={() => setNewImageSrc(null)}
{...form.props}
>
+
- {
- const file = e.currentTarget.files?.[0]
- if (file) {
- const reader = new FileReader()
- reader.onload = event => {
- setNewImageSrc(event.target?.result?.toString() ?? null)
+
+ {/*
+ We're doing some kinda odd things to make it so this works well
+ without JavaScript. Basically, we're using CSS to ensure the right
+ buttons show up based on the input's "valid" state (whether or not
+ an image has been selected). Progressive enhancement FTW!
+ */}
+
{
+ const file = e.currentTarget.files?.[0]
+ if (file) {
+ const reader = new FileReader()
+ reader.onload = event => {
+ setNewImageSrc(event.target?.result?.toString() ?? null)
+ }
+ reader.readAsDataURL(file)
}
- reader.readAsDataURL(file)
+ }}
+ />
+
+
- {newImageSrc ? (
-
+ >
+ Save Photo
+
+
+ {data.user.image?.id ? (
- Save Photo
+
+ {doubleCheckDeleteImage.doubleCheck
+ ? 'Are you sure?'
+ : 'Delete'}
+
-
-
- ) : (
-
-
-
- {/* This is here for progressive enhancement. If the client doesn't
- hydrate (or hasn't yet) this button will be available to submit the
- selected photo. */}
-
- {() => (
-
- )}
-
- {data.user.image?.id ? (
-
- ) : null}
-
- )}
+ ) : null}
+
diff --git a/heat-stack/app/routes/settings+/profile.tsx b/heat-stack/app/routes/settings+/profile.tsx
index 09f7df2d..cf8869cb 100644
--- a/heat-stack/app/routes/settings+/profile.tsx
+++ b/heat-stack/app/routes/settings+/profile.tsx
@@ -1,3 +1,4 @@
+import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { json, type DataFunctionArgs } from '@remix-run/node'
import { Link, Outlet, useMatches } from '@remix-run/react'
import { z } from 'zod'
@@ -8,8 +9,12 @@ import { prisma } from '#app/utils/db.server.ts'
import { cn, invariantResponse } from '#app/utils/misc.tsx'
import { useUser } from '#app/utils/user.ts'
-export const handle = {
+export const BreadcrumbHandle = z.object({ breadcrumb: z.any() })
+export type BreadcrumbHandle = z.infer