summary refs log tree commit diff
diff options
context:
space:
mode:
authorAshelyn Rose <git@tempest.dev>2023-12-02 16:29:03 -0700
committerAshelyn Rose <git@tempest.dev>2023-12-02 16:29:03 -0700
commitc74fad179379260dcf46edeae22c1f04ee842508 (patch)
tree6e5cce69c0b01227599933179cc7be2b230486a0
parent11aed6e30f3050a1062e37149bba878d485d52a8 (diff)
Manual static image optimization
-rw-r--r--app/about/page.tsx8
-rw-r--r--app/contact/page.tsx76
-rw-r--r--components/ContactForm.tsx69
-rw-r--r--components/Image/index.tsx108
-rw-r--r--components/InfoBar/index.tsx8
-rw-r--r--components/layout/Header.tsx8
-rw-r--r--config/system.json12
-rw-r--r--next-env.d.ts1
-rw-r--r--next.config.js5
-rw-r--r--package-lock.json8
-rw-r--r--package.json2
-rw-r--r--styles/layout.css5
-rw-r--r--utils/profiles.ts8
13 files changed, 210 insertions, 108 deletions
diff --git a/app/about/page.tsx b/app/about/page.tsx
index 863bea9..255f403 100644
--- a/app/about/page.tsx
+++ b/app/about/page.tsx
@@ -1,8 +1,7 @@
-import Image from 'next/image'
+import Image from 'components/Image'
 
 import system from '~/config/system.json'
 import styles from '~/styles/about.module.css'
-import profilePics from '~/utils/profiles'
 
 export interface Member {
   name: string,
@@ -11,6 +10,7 @@ export interface Member {
   color: string,
   bioShort: string,
   readMore: string,
+  profileImg: string,
 }
 
 export default function About() {
@@ -32,7 +32,7 @@ export default function About() {
                 alt=""
                 width={150}
                 height={150}
-                src={profilePics[member.name.toLowerCase()]}
+                src={member.profileImg}
               />
               <h2>{member.name}</h2>
               <p className={styles.pronouns}>{member.mainPronouns}</p>
@@ -61,7 +61,7 @@ export default function About() {
                 alt=""
                 width={80}
                 height={80}
-                src={profilePics[member.name.toLowerCase()]}
+                src={member.profileImg}
               />
               <h2>{member.name}</h2>
               <p className={styles.pronouns}>{member.mainPronouns}</p>
diff --git a/app/contact/page.tsx b/app/contact/page.tsx
index 1c144c2..6af8ad1 100644
--- a/app/contact/page.tsx
+++ b/app/contact/page.tsx
@@ -1,56 +1,7 @@
-'use client'
-import { useState, useRef, FormEvent } from 'react'
 import InfoBar from "~/components/InfoBar/"
-
-import styles from '~/styles/form.module.css'
-
-const submitUrl = 'https://contact.tempest.dev/api/contact/me'
+import ContactForm from "~/components/ContactForm"
 
 export default function Contact() {
-  const [submitting, setSubmitting] = useState(false)
-  const [status, setStatus] = useState('')
-
-  const nameRef = useRef<HTMLInputElement>()
-  const emailRef = useRef<HTMLInputElement>()
-  const messageRef = useRef<HTMLTextAreaElement>()
-
-  const submit = async (ev: FormEvent<HTMLFormElement>) => {
-    ev.preventDefault()
-
-    setStatus('')
-
-    const name = nameRef.current.value
-    const email = emailRef.current.value
-    const message = messageRef.current.value
-
-    if (!name) setStatus(s => s + ' Name required.')
-    if (!email) setStatus(s => s + ' Email required.')
-    if (!message) setStatus(s => s + ' Message required.')
-
-    if (!name || !email || !message)
-      return
-
-    setSubmitting(true)
-
-    try {
-
-      await fetch(submitUrl, {
-        method: 'post',
-        headers: {
-          'content-type': 'application/json'
-        },
-        body: JSON.stringify({
-          name, email, message
-        })
-      })
-
-      setStatus('Message sent successfully')
-    } catch {
-      setStatus('Error submitting message, please try again')
-    } finally {
-      setSubmitting(false)
-    }
-  }
 
   return (
     <>
@@ -61,30 +12,7 @@ export default function Contact() {
         <InfoBar>
           Be nice.  Please don't make us regret putting this here
         </InfoBar>
-        <form className={styles.form} onSubmit={submit}>
-          <label htmlFor="name">Name:</label>
-          <input disabled={submitting} name="name" ref={nameRef} />
-
-          <label htmlFor="email">Email:</label>
-          <input disabled={submitting} name="email" ref={emailRef} />
-
-          <label htmlFor="message">Message:</label>
-          <textarea disabled={submitting} name="message" ref={messageRef} />
-
-          <button disabled={submitting} type="submit">Submit</button>
-          {status && <span className={styles.status}>{status}</span>}
-          {/*<FormController
-          url="https://kae.tempest.dev/api/contact/tempest"
-          afterSubmit={() => setStatus('Message sent successfully!')}
-          onError={() => setStatus('Error submitting message, please try again.')}
-        >
-          <Input name="name" validate={v => !!v} />
-          <Input name="email" validate={v => !!v && v.includes('@')} />
-          <TextArea name="message" validate={v => !!v && v.length < 1000} />
-          <Button onClick={() => setStatus(null)} type="submit">submit</Button>
-          {status && <p>{status}</p>}
-        </FormController>*/}
-        </form>
+        <ContactForm/>
       </main>
     </>
   )
diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx
new file mode 100644
index 0000000..a64cb85
--- /dev/null
+++ b/components/ContactForm.tsx
@@ -0,0 +1,69 @@
+'use client'
+
+import styles from '~/styles/form.module.css'
+
+const submitUrl = 'https://contact.tempest.dev/api/contact/me'
+import { useState, useRef, FormEvent } from 'react'
+
+export default function ContactForm() {
+  const [submitting, setSubmitting] = useState(false)
+  const [status, setStatus] = useState('')
+
+  const nameRef = useRef<HTMLInputElement>()
+  const emailRef = useRef<HTMLInputElement>()
+  const messageRef = useRef<HTMLTextAreaElement>()
+
+  const submit = async (ev: FormEvent<HTMLFormElement>) => {
+    ev.preventDefault()
+
+    setStatus('')
+
+    const name = nameRef.current.value
+    const email = emailRef.current.value
+    const message = messageRef.current.value
+
+    if (!name) setStatus(s => s + ' Name required.')
+    if (!email) setStatus(s => s + ' Email required.')
+    if (!message) setStatus(s => s + ' Message required.')
+
+    if (!name || !email || !message)
+      return
+
+    setSubmitting(true)
+
+    try {
+
+      await fetch(submitUrl, {
+        method: 'post',
+        headers: {
+          'content-type': 'application/json'
+        },
+        body: JSON.stringify({
+          name, email, message
+        })
+      })
+
+      setStatus('Message sent successfully')
+    } catch {
+      setStatus('Error submitting message, please try again')
+    } finally {
+      setSubmitting(false)
+    }
+  }
+
+  return (
+    <form className={styles.form} onSubmit={submit}>
+      <label htmlFor="name">Name:</label>
+      <input disabled={submitting} name="name" ref={nameRef} />
+
+      <label htmlFor="email">Email:</label>
+      <input disabled={submitting} name="email" ref={emailRef} />
+
+      <label htmlFor="message">Message:</label>
+      <textarea disabled={submitting} name="message" ref={messageRef} />
+
+      <button disabled={submitting} type="submit">Submit</button>
+      {status && <span className={styles.status}>{status}</span>}
+    </form>
+  )
+}
diff --git a/components/Image/index.tsx b/components/Image/index.tsx
new file mode 100644
index 0000000..e7a393c
--- /dev/null
+++ b/components/Image/index.tsx
@@ -0,0 +1,108 @@
+import { promises as fs } from 'fs'
+import path from 'path'
+import { ImgHTMLAttributes } from 'react'
+import sharp from 'sharp'
+
+const outputDir = path.join(process.cwd(), '.next/static/images')
+const serverPath = '/_next/static/images/'
+
+interface ReqProps {
+  src: string,
+  alt: string,
+}
+
+type FixedSingleDimension = {width: number} | {height: number}
+type FixedBothDimension = {width: number, height: number}
+type MultiSize = {width: number[]} | {height: number[]}
+type SizeProps = FixedSingleDimension | FixedBothDimension | MultiSize
+type ImageAttrs = Omit<ImgHTMLAttributes<HTMLImageElement>, "width" | "height" | "src"> 
+
+type ImageProps = ImageAttrs
+  & SizeProps
+  & ReqProps 
+
+interface ProcessedImage {
+  hostPath: string,
+  randPrefix: string,
+  widths: {[width: number] : string}
+}
+
+async function ImageServer({src,...imgAttrs}: ImageProps) {
+  const srcImg = sharp(src)
+  const extension = path.extname(src)
+  const filename = path.basename(src, extension)
+  const randPrefix = (Math.random() + 1).toString(36).substring(7)
+  const width = 'width' in imgAttrs ? imgAttrs.width : null
+  const height = 'height' in imgAttrs ? imgAttrs.height : null
+
+  console.log(`processing ${src} for width ${width} and height ${height}`)
+
+  // Make sure we can write to the dir
+  await fs.mkdir(outputDir, {recursive: true})
+
+  // Handle where we have an array of dimensions
+  if (Array.isArray(width) || Array.isArray(height)) {
+    const dimension = Array.isArray(width) ? 'width' : 'height'
+    const sizes = Array.isArray(width) ? width : height as number[]
+    const suffix = dimension.charAt(0)
+
+    // Copy in original
+    fs.copyFile(src, path.join(outputDir, `${randPrefix}-${filename}${extension}`))
+
+    // Create resized copies
+    const srcSetLines = await Promise.all(sizes
+      .map(async size => {
+        const outputName = `${randPrefix}-${filename}-${size}${suffix}${extension}`
+        const outputPath = path.join(outputDir, outputName)
+        return srcImg
+          .resize({[dimension]: size})
+          .toFile(outputPath)
+          .then(result => `${serverPath}${outputName} ${result.width}w`)
+      }))
+
+    return (
+      <img
+        src={src}
+        srcSet={srcSetLines.join(',')}
+        {...imgAttrs}
+        width={undefined}
+        height={undefined}
+      />
+    )
+  } else {
+    const dimensions = {
+      width: typeof width === 'number' ? width : undefined,
+      height: typeof height === 'number' ? height : undefined
+    }
+
+    const dimensionString = (() => {
+      if (dimensions.height && dimensions.height)
+        return `${dimensions.width}x${dimensions.height}`
+      if (dimensions.width)
+        return `${dimensions.width}w`
+      return `${dimensions.height}h`
+    })()
+
+    const outputName = `${randPrefix}-${filename}-${dimensionString}${extension}`
+    const outputPath = path.join(outputDir, outputName)
+    const outputSrc =   serverPath + outputName
+    await srcImg.resize(dimensions).toFile(outputPath)
+
+    return (
+      <img
+        src={outputSrc}
+        {...imgAttrs}
+        {...dimensions}
+      />
+    )
+  }
+}
+
+export default function ImageServerWrap(props: ImageProps) {
+  return (
+    <>
+      {/* @ts-expect-error Async Server Component */}
+      <ImageServer {...props} />
+    </>
+  )
+}
diff --git a/components/InfoBar/index.tsx b/components/InfoBar/index.tsx
index 2901883..20f7130 100644
--- a/components/InfoBar/index.tsx
+++ b/components/InfoBar/index.tsx
@@ -1,8 +1,7 @@
 import { ReactNode } from 'react'
-import Image from 'next/image'
+import Image from 'components/Image'
 
 import system from '~/config/system.json'
-import profilePics from '~/utils/profiles'
 import styles from './InfoBar.module.css'
 
 interface ArbitraryChildrenProps {
@@ -39,10 +38,9 @@ export default function InfoBar(props: InfobarProps) {
   }
 
   if ('authorName' in props) {
-    const authorKey = props.authorName.toLowerCase()
     const author = system.members.find((member) => member.name === props.authorName)
     const style = { '--author-color': author?.color } as React.CSSProperties
-    const picture = profilePics[authorKey]
+    const picture = author.profileImg
 
     return (
       <aside className={`${styles.infobar} ${styles.postMeta}`} style={style}>
@@ -59,7 +57,7 @@ export default function InfoBar(props: InfobarProps) {
     const memberKey = props.memberName.toLowerCase()
     const member = system.members.find((member) => member.name === props.memberName)
     const style = { '--member-color': member?.color } as React.CSSProperties
-    const picture = profilePics[memberKey]
+    const picture = member.profileImg
 
     return (
       <aside className={`${styles.infobar} ${styles.memberProfile}`} style={style}>
diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx
index 5e589c9..f55047c 100644
--- a/components/layout/Header.tsx
+++ b/components/layout/Header.tsx
@@ -1,11 +1,9 @@
-'use client'
-
 import React from 'react'
-import Image from 'next/image'
+import Image from 'components/Image'
 
 import { usePathname } from 'next/navigation'
 
-import header from '~/images/aurora-1197753.jpg'
+const header = 'images/aurora-1197753.jpg'
 
 export default function Title() {
   const pathname = usePathname()
@@ -29,7 +27,7 @@ export default function Title() {
           src={header}
           alt=""
           role="presentation"
-          fill={true}
+          width={[2560,1920,1280,800,600]}
           sizes={`
             (max-width: 2560) 100vw,
             (max-width: 1920) 100vw,
diff --git a/config/system.json b/config/system.json
index ad8adc3..a1b2f55 100644
--- a/config/system.json
+++ b/config/system.json
@@ -12,7 +12,8 @@
       {"name": "names:", "value": "rose, rosalyn, occasionally callista"},
       {"name": "pronouns:", "value": "they/them.  very occasionally she/her but i'm not usually in the mood"},
       {"name": "orientation:", "value": "the hell if i know"}
-    ]
+    ],
+    "profileImg": "images/profile/rose.png"
   },{
     "name": "Dawn",
     "featured": true,
@@ -25,7 +26,8 @@
       {"name": "Names:", "value": "Dawn.  Perhaps \"Ashelyn Dawn\" if you're feeling fancy"},
       {"name": "Pronouns:", "value": "She/her, no exceptions"},
       {"name": "Orientation:", "value": "Asexual, polyromantic lesbian"}
-    ]
+    ],
+    "profileImg": "images/profile/dawn.png"
   },{
     "name": "echo",
     "mainPronouns": "it/its",
@@ -37,7 +39,8 @@
       {"name": "name:", "value": "echo"},
       {"name": "pronouns:", "value": "it/its, “that one”, most neopronouns okay"},
       {"name": "orientation:", "value": "aroace"}
-    ]
+    ],
+    "profileImg": "images/profile/echo.png"
   },{
     "name": "Corona",
     "featured": true,
@@ -50,6 +53,7 @@
       {"name": "Names:", "value": "Corona (previously known as Harrow)"},
       {"name": "Pronouns:", "value": "she/her or they/them with equal preference"},
       {"name": "Orientation", "value": "Indeterminate"}
-    ]
+    ],
+    "profileImg": "images/profile/corona.png"
   }]
 }
diff --git a/next-env.d.ts b/next-env.d.ts
index 4f11a03..2532e77 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,5 +1,4 @@
 /// <reference types="next" />
-/// <reference types="next/image-types/global" />
 
 // NOTE: This file should not be edited
 // see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/next.config.js b/next.config.js
index 152b5d3..de1d2e3 100644
--- a/next.config.js
+++ b/next.config.js
@@ -1,4 +1,7 @@
 module.exports = {
   output: 'export',
-  trailingSlash: true
+  trailingSlash: true,
+  images: {
+    disableStaticImages: true
+  },
 }
diff --git a/package-lock.json b/package-lock.json
index 818bbd3..684ba5f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,7 +19,7 @@
       },
       "devDependencies": {
         "@types/node": "20.1.0",
-        "@types/react": "^18.2.6",
+        "@types/react": "^18.2.41",
         "typescript": "5.0.4"
       }
     },
@@ -195,9 +195,9 @@
       "dev": true
     },
     "node_modules/@types/react": {
-      "version": "18.2.6",
-      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.6.tgz",
-      "integrity": "sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==",
+      "version": "18.2.41",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.41.tgz",
+      "integrity": "sha512-CwOGr/PiLiNBxEBqpJ7fO3kocP/2SSuC9fpH5K7tusrg4xPSRT/193rzolYwQnTN02We/ATXKnb6GqA5w4fRxw==",
       "dev": true,
       "dependencies": {
         "@types/prop-types": "*",
diff --git a/package.json b/package.json
index f478321..e05f3f6 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,7 @@
   },
   "devDependencies": {
     "@types/node": "20.1.0",
-    "@types/react": "^18.2.6",
+    "@types/react": "^18.2.41",
     "typescript": "5.0.4"
   }
 }
diff --git a/styles/layout.css b/styles/layout.css
index d416712..abc709a 100644
--- a/styles/layout.css
+++ b/styles/layout.css
@@ -141,9 +141,10 @@ header .headerBackground {
 }
 
 header .headerBackground img {
-  z-index: -1;
   object-fit: cover;
   object-position: center 75%;
+  width: 100%;
+  height: 100%;
 }
 
 header .headerBackground::after {
@@ -152,6 +153,8 @@ header .headerBackground::after {
   width: 100%;
   height: 100%;
   background: rgba(0,0,0,.35);
+  position: absolute;
+  top: 0;
 }
 
 header:not(.homepage) ~ h1.pageTitle {
diff --git a/utils/profiles.ts b/utils/profiles.ts
deleted file mode 100644
index 06302a2..0000000
--- a/utils/profiles.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import rose from '~/images/profile/rose.png'
-import dawn from '~/images/profile/dawn.png'
-import echo from '~/images/profile/echo.png'
-import corona from '~/images/profile/corona.png'
-
-export default {
-  rose, dawn, echo, corona
-}