Compare commits
35 Commits
e0fad33706
...
48ca4c2a03
| Author | SHA1 | Date | |
|---|---|---|---|
| 48ca4c2a03 | |||
| bde6f2a253 | |||
| 8e00726b04 | |||
| e0170e82aa | |||
| 64f2092161 | |||
| ba4b4ea980 | |||
| 6592d49165 | |||
| 2fdf12259c | |||
| e568105b99 | |||
| a1304c5afd | |||
| c9f7ad699c | |||
| 20effac610 | |||
| 9c50e74904 | |||
| b0be9fae2f | |||
| 8f95c6ff74 | |||
| 9d8d2a266a | |||
| 6c268a5548 | |||
| 50f4d52317 | |||
| 46c9b77316 | |||
| 40d6c7f248 | |||
| a81d1de1e5 | |||
| 46387b41ce | |||
| 476fe39f50 | |||
| a85b7b36c6 | |||
| 1d83c50e27 | |||
| fcb297637d | |||
| 781af6414e | |||
| 35f391d933 | |||
| 22ce9d06e2 | |||
| 9d610b6b3d | |||
| 0da1f8710e | |||
| e119eb62e8 | |||
| 9dab11d615 | |||
| fe69e75001 | |||
| 508db104f2 |
@@ -1,5 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.helix/
|
||||
.vscode/
|
||||
.zed/
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ pnpm-debug.log*
|
||||
**/*.tfvars
|
||||
**/*.tfstate
|
||||
**/*.tfstate.backup
|
||||
|
||||
*.sqlite
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -1,12 +0,0 @@
|
||||
FROM node:lts AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
CMD ["node", "./website/dist/server/entry.mjs"]
|
||||
19
README.md
19
README.md
@@ -24,22 +24,3 @@ To run with Node:
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
## Infrastructure
|
||||
|
||||
The infrastructure is on DigitalOcean.
|
||||
|
||||
The website is hosted using the App Platform service from DigitalOcean. This is
|
||||
free for static websites, and is quite flexible to add in extra apps as Droplets
|
||||
or Functions at a later time if I so please.
|
||||
|
||||
DigitalOcean App Platform re-deploys the website every time there's an update to
|
||||
the `main` branch in this repo.
|
||||
|
||||
All the DigitalOcean infrastructure is managed using Terraform. The code for
|
||||
this is in the `infrastructure/` directory.
|
||||
|
||||
The domain, however, is registered on AWS. The nameservers registered in AWS
|
||||
have to be kept manually up-to-date with the DigitalOcean nameservers. These
|
||||
shouldn't change, though, so this is unlikely to need intervention more than
|
||||
once.
|
||||
|
||||
30
compose.yml
Normal file
30
compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
website:
|
||||
build:
|
||||
context: website
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
LOCAL_SMTP_HOST: smtp
|
||||
LOCAL_SMTP_PORT: 2500
|
||||
LOCAL_SMTP_PASSWORD: smtp
|
||||
ports:
|
||||
- "8000:4321"
|
||||
|
||||
smtp:
|
||||
build:
|
||||
context: smtp
|
||||
args:
|
||||
LOCAL_SMTP_PORT: 2500
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
LOCAL_SMTP_PORT: 2500
|
||||
LOCAL_SMTP_PASSWORD: smtp
|
||||
REMOTE_SMTP_PASSWORD_FILE: /run/secrets/remote_smtp_password
|
||||
secrets:
|
||||
- remote_smtp_password
|
||||
|
||||
secrets:
|
||||
remote_smtp_password:
|
||||
external: true
|
||||
26
infrastructure/.terraform.lock.hcl
generated
26
infrastructure/.terraform.lock.hcl
generated
@@ -1,26 +0,0 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/digitalocean/digitalocean" {
|
||||
version = "2.38.0"
|
||||
constraints = "~> 2.0"
|
||||
hashes = [
|
||||
"h1:ElG5GAiHN6paMNUG0JnQ4uCv1Y37ZUbCXQSAQwb/j8U=",
|
||||
"zh:04d1ca6ac6d7e69635657aeac8aadb75f84018305514381f9d7bed48065df61b",
|
||||
"zh:081258f8526b3597eeb7154d5b453c2fe36194ca1a95e0de655d7a1080224be0",
|
||||
"zh:15e77088b8a73012d87ad29ad3cf10392642a6781a35c0923a45386fe61cda61",
|
||||
"zh:1a0c741f9be0f22c18fec93b4200c1968c1c6e4ac02292d494ace78b16f13fea",
|
||||
"zh:2a3a778cd982e5e09a2414f0a16c54101c56d7ab6f824ea3e55709a608f7f3ea",
|
||||
"zh:6832b594a7e408e085bf148c5a1e2eaf94ed8f47796b917acf56078ee9362ca1",
|
||||
"zh:78b8acad99e7035344677f70c08c3daf457cfa3f8b467e73352d14353889b8cf",
|
||||
"zh:8a1c446f1b3b5dee097fc000cf4d341b00602ace60246af4a09e5a49666a0638",
|
||||
"zh:8c23094d06f5b4c780dbdde21c56b9b03b275c1cc8728669ef8f3f45e0d9fb24",
|
||||
"zh:8c5a9871c2a2e094f58723624bb3fd2dbedafc8d9cab609df79d01011de79b8a",
|
||||
"zh:a32adff1d0fc405f1596b6dc679b3029c87177e717eb991c418fff13ec559427",
|
||||
"zh:a3f89176401db06ccc97b89a23bb004c8ab1562ce04178d6a08125bd4b8c073e",
|
||||
"zh:d739de876cdd6176570c610e0edca0d2133b8e21787f22caa9a72d9806055c07",
|
||||
"zh:d8a874a0e442c10deae3217bb88375209d45f79d52cb7d2b19756863d6ab6414",
|
||||
"zh:eac47cd0b28953404622d11d8d4049ce2a4da3bc0a0ecaf386e5f55117386bbd",
|
||||
"zh:ec3686163d1177d41f13588bb24db9a2b3afd199ac410d8c9899f053f271515c",
|
||||
]
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
resource "digitalocean_app" "joeac_net" {
|
||||
project_id = "c106269c-1115-4682-8757-867368e057a4"
|
||||
|
||||
spec {
|
||||
name = "joeac-net"
|
||||
region = local.region
|
||||
domain {
|
||||
name = local.domain
|
||||
}
|
||||
features = [
|
||||
"buildpack-stack=ubuntu-22"
|
||||
]
|
||||
|
||||
alert {
|
||||
disabled = false
|
||||
rule = "DEPLOYMENT_FAILED"
|
||||
}
|
||||
|
||||
alert {
|
||||
disabled = false
|
||||
rule = "DOMAIN_FAILED"
|
||||
}
|
||||
|
||||
ingress {
|
||||
rule {
|
||||
component {
|
||||
name = "personal-website"
|
||||
preserve_path_prefix = false
|
||||
}
|
||||
|
||||
match {
|
||||
path {
|
||||
prefix = "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static_site {
|
||||
build_command = "npm run build"
|
||||
environment_slug = "node-js"
|
||||
name = "personal-website"
|
||||
output_dir = "website/dist"
|
||||
source_dir = "/"
|
||||
|
||||
github {
|
||||
branch = "main"
|
||||
deploy_on_push = true
|
||||
repo = "joeacarstairs/personal-website"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
locals {
|
||||
domain = "joeac.net"
|
||||
region = "lon"
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
digitalocean = {
|
||||
source = "digitalocean/digitalocean"
|
||||
version = "~> 2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
provider "digitalocean" {
|
||||
token = var.do_token
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
variable "do_token" {
|
||||
type = string
|
||||
sensitive = true
|
||||
description = "A DigitalOcean access token. Can also be provided as an environment variable"
|
||||
}
|
||||
12
smtp/.msmtprc
Normal file
12
smtp/.msmtprc
Normal file
@@ -0,0 +1,12 @@
|
||||
defaults
|
||||
auth on
|
||||
tls on
|
||||
tls_trust_file /etc/ssl/certs/ca-certificates.crt
|
||||
logfile /var/msmtp/msmtp.log
|
||||
|
||||
account default
|
||||
eval echo from "$LOCAL_SMTP_ENVELOPE_FROM"
|
||||
eval echo host "$REMOTE_SMTP_HOST"
|
||||
eval echo port "$REMOTE_SMTP_PORT"
|
||||
eval echo user "$REMOTE_SMTP_USER"
|
||||
passwordeval cat "$REMOTE_SMTP_PASSWORD_FILE"
|
||||
23
smtp/Dockerfile
Normal file
23
smtp/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
FROM alpine:3.22
|
||||
|
||||
WORKDIR /
|
||||
RUN mkdir -p /var/msmtp
|
||||
RUN apk --update --no-cache add git autoconf automake build-base gettext gettext-dev gnutls-dev libtool make texinfo && \
|
||||
git clone https://github.com/marlam/msmtp.git --branch msmtp-1.8.32 --single-branch --depth 1
|
||||
WORKDIR /msmtp
|
||||
RUN autoreconf -fi && \
|
||||
./configure && \
|
||||
make && \
|
||||
make install
|
||||
|
||||
ARG LOCAL_SMTP_PORT
|
||||
EXPOSE $LOCAL_SMTP_PORT
|
||||
|
||||
COPY .msmtprc ./
|
||||
|
||||
CMD msmtpd \
|
||||
--auth=$LOCAL_SMTP_USER,'echo $LOCAL_SMTP_PASSWORD' \
|
||||
--command='msmtp -C .msmtprc -f %F --' \
|
||||
--interface=0.0.0.0 \
|
||||
--log=/var/msmtp/msmtpd.log \
|
||||
--port=$LOCAL_SMTP_PORT
|
||||
7
website/.dockerignore
Normal file
7
website/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.astro/
|
||||
dist/
|
||||
node_modules/
|
||||
.dockerignore
|
||||
.env
|
||||
*.sqlite
|
||||
Dockerfile
|
||||
22
website/Dockerfile
Normal file
22
website/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:lts-alpine3.22 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY astro.config.mjs ./
|
||||
COPY db ./db/
|
||||
ARG DB_URL=file:/app/db.sqlite
|
||||
ENV ASTRO_DB_REMOTE_URL=$DB_URL
|
||||
RUN mkdir -p "$(dirname "$(echo "$ASTRO_DB_REMOTE_URL" | cut -d':' -f 2)")"
|
||||
RUN npm run astro db push
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV MAX_DAILY_EMAILS=100
|
||||
|
||||
EXPOSE 4321
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import { defineConfig, envField } from "astro/config";
|
||||
import db from "@astrojs/db";
|
||||
import mdx from "@astrojs/mdx";
|
||||
import node from "@astrojs/node";
|
||||
|
||||
@@ -9,6 +10,26 @@ export default defineConfig({
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
env: {
|
||||
schema: {
|
||||
MAX_DAILY_EMAILS: envField.number({
|
||||
context: "server",
|
||||
access: "secret",
|
||||
}),
|
||||
LOCAL_SMTP_ENVELOPE_FROM: envField.string({
|
||||
context: "server",
|
||||
access: "secret",
|
||||
}),
|
||||
LOCAL_SMTP_HOST: envField.string({ context: "server", access: "secret" }),
|
||||
LOCAL_SMTP_PORT: envField.number({ context: "server", access: "secret" }),
|
||||
LOCAL_SMTP_USER: envField.string({ context: "server", access: "secret" }),
|
||||
LOCAL_SMTP_PASSWORD: envField.string({
|
||||
context: "server",
|
||||
access: "secret",
|
||||
}),
|
||||
CONTACT_MAILBOX: envField.string({ context: "server", access: "secret" }),
|
||||
},
|
||||
},
|
||||
site: "https://joeac.net",
|
||||
integrations: [mdx(), sitemap()],
|
||||
integrations: [db(), mdx(), sitemap()],
|
||||
});
|
||||
|
||||
34
website/db/config.ts
Normal file
34
website/db/config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { column, defineDb, defineTable } from "astro:db";
|
||||
|
||||
const Otp = defineTable({
|
||||
columns: {
|
||||
userId: column.text(),
|
||||
value: column.text(),
|
||||
createdAt: column.number(),
|
||||
validUntil: column.number(),
|
||||
},
|
||||
});
|
||||
|
||||
const SendmailToken = defineTable({
|
||||
columns: {
|
||||
userId: column.text(),
|
||||
value: column.text(),
|
||||
createdAt: column.number(),
|
||||
validUntil: column.number(),
|
||||
},
|
||||
});
|
||||
|
||||
const SentEmails = defineTable({
|
||||
columns: {
|
||||
messageId: column.text(),
|
||||
sentAt: column.number(),
|
||||
},
|
||||
});
|
||||
|
||||
export default defineDb({
|
||||
tables: {
|
||||
Otp,
|
||||
SendmailToken,
|
||||
SentEmails,
|
||||
},
|
||||
});
|
||||
3
website/db/seed.ts
Normal file
3
website/db/seed.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { db } from "astro:db";
|
||||
|
||||
export default async function seed() {}
|
||||
3047
package-lock.json → website/package-lock.json
generated
3047
package-lock.json → website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,25 @@
|
||||
{
|
||||
"private": "true",
|
||||
"scripts": {
|
||||
"dev": "cd website && astro dev",
|
||||
"start": "cd website && astro build && node ./dist/server/entry.mjs",
|
||||
"build": "cd website && astro build",
|
||||
"preview": "cd website && astro preview",
|
||||
"astro": "cd website && astro"
|
||||
"dev": "astro dev",
|
||||
"start": "astro build --remote && node ./dist/server/entry.mjs",
|
||||
"build": "astro build --remote",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/db": "^0.18.3",
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/node": "^9.5.1",
|
||||
"@astrojs/rss": "^4.0.10",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"astro": "^5.1.1",
|
||||
"markdown-it": "^14.1.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"typescript": "^5.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,12 +24,39 @@
|
||||
--colour-hyperlink-90: #bfe3ff;
|
||||
--colour-hyperlink-95: #e0f1ff;
|
||||
|
||||
--colour-grey-10: oklch(0.1 0.01 84);
|
||||
--colour-grey-20: oklch(0.2 0.01 84);
|
||||
--colour-grey-30: oklch(0.3 0.01 84);
|
||||
--colour-grey-40: oklch(0.4 0.01 84);
|
||||
--colour-grey-50: oklch(0.5 0.01 84);
|
||||
--colour-grey-60: oklch(0.6 0.01 84);
|
||||
--colour-grey-70: oklch(0.7 0.01 84);
|
||||
--colour-grey-80: oklch(0.8 0.01 84);
|
||||
--colour-grey-90: oklch(0.9 0.01 84);
|
||||
--colour-grey-95: oklch(0.95 0.01 84);
|
||||
|
||||
--colour-error-10: oklch(0.1 0.2 26);
|
||||
--colour-error-20: oklch(0.2 0.2 26);
|
||||
--colour-error-30: oklch(0.3 0.2 26);
|
||||
--colour-error-40: oklch(0.4 0.2 26);
|
||||
--colour-error-50: oklch(0.5 0.2 26);
|
||||
--colour-error-60: oklch(0.6 0.2 26);
|
||||
--colour-error-70: oklch(0.7 0.2 26);
|
||||
--colour-error-80: oklch(0.8 0.2 26);
|
||||
--colour-error-90: oklch(0.9 0.2 26);
|
||||
--colour-error-95: oklch(0.95 0.2 26);
|
||||
|
||||
--colour-primary-fg: var(--colour-primary-90);
|
||||
--colour-primary-fg-accent: var(--colour-primary-80);
|
||||
--colour-primary-bg: var(--colour-primary-10);
|
||||
--colour-primary-bg-accent: var(--colour-primary-20);
|
||||
--colour-code-fg: var(--colour-primary-90);
|
||||
--colour-code-bg: var(--colour-primary-15);
|
||||
--colour-hyperlink: var(--colour-hyperlink-80);
|
||||
--colour-grey-fg: var(--colour-grey-70);
|
||||
--colour-grey-bg: var(--colour-grey-30);
|
||||
--colour-error-fg: var(--colour-error-90);
|
||||
--colour-error-bg: var(--colour-error-40);
|
||||
|
||||
--font-size-sm: 1rem;
|
||||
--font-size-base: 1.125rem;
|
||||
@@ -56,6 +83,10 @@
|
||||
--colour-primary-fg-accent: var(--colour-primary-40);
|
||||
--colour-primary-bg: var(--colour-primary-95);
|
||||
--colour-hyperlink: var(--colour-hyperlink-40);
|
||||
--colour-grey-fg: var(--colour-grey-40);
|
||||
--colour-grey-bg: var(--colour-grey-80);
|
||||
--colour-error-fg: var(--colour-error-20);
|
||||
--colour-error-bg: var(--colour-error-80);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +100,8 @@ body {
|
||||
line-height: 1.5;
|
||||
|
||||
/* Geometric Humanist stack from https://modernfontstacks.com */
|
||||
font-family: Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro,
|
||||
sans-serif;
|
||||
font-family:
|
||||
Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif;
|
||||
}
|
||||
|
||||
small {
|
||||
@@ -81,6 +112,10 @@ small {
|
||||
margin-block-start: var(--spacing-block-sm);
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/** Base layout */
|
||||
|
||||
body {
|
||||
@@ -109,6 +144,7 @@ img {
|
||||
[media-end content-start]
|
||||
minmax(var(--grid-max-content-width), auto)
|
||||
[content-end grid-end];
|
||||
grid-auto-rows: max-content;
|
||||
column-gap: var(--spacing-block-sm);
|
||||
max-width: var(--grid-total-width);
|
||||
|
||||
@@ -127,7 +163,7 @@ img {
|
||||
grid-column: grid;
|
||||
grid-template-columns: subgrid;
|
||||
|
||||
> :is(section, header, aside) {
|
||||
> :is(section, header, aside, form) {
|
||||
display: grid;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: grid;
|
||||
@@ -380,6 +416,67 @@ block-comment {
|
||||
}
|
||||
}
|
||||
|
||||
/* forms */
|
||||
form {
|
||||
margin-inline: auto;
|
||||
max-width: max-content;
|
||||
|
||||
:is(button, fieldset, input, label, object, output, select, textarea, img) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-block-end: var(--spacing-block-xs);
|
||||
}
|
||||
|
||||
* + :is(label, input[type="submit"], button) {
|
||||
margin-block-start: var(--spacing-block-md);
|
||||
}
|
||||
|
||||
:is(input[type="submit"], input[type="button"], button) {
|
||||
padding-inline: var(--spacing-inline-sm);
|
||||
max-width: max-content;
|
||||
&:not(dialog *) {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
:is(input[type="email"], input[type="text"], select) {
|
||||
max-width: 100%;
|
||||
width: 16rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
max-width: 100%;
|
||||
width: 40rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* dialogs */
|
||||
|
||||
dialog {
|
||||
background: var(--colour-primary-bg-accent);
|
||||
border: 2px solid var(--colour-primary-fg-accent);
|
||||
bottom: auto;
|
||||
color: var(--colour-primary-fg);
|
||||
left: calc(0.5 * (100vw - min(90vw, 36rem)));
|
||||
margin: 0;
|
||||
padding-block: var(--spacing-block-sm);
|
||||
padding-inline: var(--spacing-inline-sm);
|
||||
text-align: center;
|
||||
top: auto;
|
||||
width: min(90vw, 36rem);
|
||||
}
|
||||
|
||||
@media (min-width: 80rem) {
|
||||
dialog {
|
||||
left: calc(
|
||||
var(--body-margin-inline-start) + var(--grid-margin-inline) + 2 *
|
||||
var(--spacing-inline-md)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* utilities */
|
||||
:is(
|
||||
.para-spacing-tight:is(p, h1, h2, h3, h4, h5, h6, hr, img, figure, ul, ol),
|
||||
@@ -387,3 +484,11 @@ block-comment {
|
||||
) {
|
||||
margin-block-start: var(--spacing-block-xs);
|
||||
}
|
||||
|
||||
.error {
|
||||
background: var(--colour-error-bg);
|
||||
border: 2px solid var(--colour-error-fg);
|
||||
color: var(--colour-error-fg);
|
||||
padding-block: var(--spacing--block-sm);
|
||||
padding-inline: var(--spacing-inline-sm);
|
||||
}
|
||||
|
||||
@@ -27,10 +27,9 @@
|
||||
top: -6rem;
|
||||
|
||||
/* A content value is needed to get the ::after to render */
|
||||
content: '';
|
||||
content: "";
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 36rem) {
|
||||
.h-card {
|
||||
grid-column: media-start / content-end;
|
||||
@@ -40,6 +39,7 @@
|
||||
grid-template-areas:
|
||||
"empty heading"
|
||||
"photo text";
|
||||
column-gap: var(--spacing-block-sm);
|
||||
}
|
||||
|
||||
.h-card div:has(img) {
|
||||
|
||||
29
website/public/css/otp.css
Normal file
29
website/public/css/otp.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.otp-inputs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-inline-sm);
|
||||
justify-content: center;
|
||||
|
||||
input {
|
||||
border: none;
|
||||
border-block-end: 2px solid var(--colour-grey-fg);
|
||||
font-size: var(--font-size-lg);
|
||||
text-align: center;
|
||||
width: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.otp-form {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-block-sm);
|
||||
|
||||
> * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="submit"] {
|
||||
max-width: max-content;
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,22 @@ html {
|
||||
}
|
||||
|
||||
/* Remove default margin in favour of better control in authored CSS */
|
||||
body, h1, h2, h3, h4, p,
|
||||
figure, blockquote, dl, dd {
|
||||
body,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p,
|
||||
figure,
|
||||
blockquote,
|
||||
dl,
|
||||
dd {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
|
||||
ul[role='list'],
|
||||
ol[role='list'] {
|
||||
ul[role="list"],
|
||||
ol[role="list"] {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -32,14 +40,21 @@ body {
|
||||
}
|
||||
|
||||
/* Set shorter line heights on headings and interactive elements */
|
||||
h1, h2, h3, h4,
|
||||
button, input, label {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
button,
|
||||
input,
|
||||
label {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Balance text wrapping on headings */
|
||||
h1, h2,
|
||||
h3, h4 {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@@ -51,8 +66,10 @@ picture {
|
||||
}
|
||||
|
||||
/* Inherit fonts for inputs and buttons */
|
||||
input, button,
|
||||
textarea, select {
|
||||
input,
|
||||
button,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -71,3 +88,6 @@ textarea:not([rows]) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
7
website/src/actions/index.ts
Normal file
7
website/src/actions/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import otp from "./otp/otp";
|
||||
import sendmail from "./sendmail";
|
||||
|
||||
export const server = {
|
||||
otp,
|
||||
sendmail,
|
||||
};
|
||||
7
website/src/actions/otp/otp.ts
Normal file
7
website/src/actions/otp/otp.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import send from "./send-otp";
|
||||
import verify from "./verify-otp";
|
||||
|
||||
export default {
|
||||
send,
|
||||
verify,
|
||||
};
|
||||
45
website/src/actions/otp/send-otp.ts
Normal file
45
website/src/actions/otp/send-otp.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import crypto from "node:crypto";
|
||||
import { z } from "astro/zod";
|
||||
import { defineAction } from "astro:actions";
|
||||
import { db, Otp } from "astro:db";
|
||||
import { transporter } from "../sendmail";
|
||||
|
||||
export default defineAction({
|
||||
input: z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
type: z.enum(["email"]),
|
||||
}),
|
||||
handler: sendOtp,
|
||||
});
|
||||
|
||||
type OtpParams = {
|
||||
email: string;
|
||||
name?: string;
|
||||
type: "email";
|
||||
};
|
||||
|
||||
async function sendOtp({ email, name }: OtpParams) {
|
||||
const otp = crypto.randomBytes(3).toString("hex").toLocaleUpperCase();
|
||||
const otpPretty = `${otp.slice(0, 3)}-${otp.slice(3)}`;
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: `"Joe Carstairs" <me@joeac.net>`,
|
||||
to: `${name ? `"${name}" ` : ""}<${email}>`,
|
||||
subject: `joeac.net: your OTP is ${otpPretty}`,
|
||||
text: `
|
||||
Someone tried to use this email address on joeac.net. If this was you,
|
||||
your one-time passcode is ${otpPretty}. If this wasn't you, you don't need
|
||||
to do anything.`,
|
||||
});
|
||||
console.log(
|
||||
`Sent OTP (${otpPretty}) to ${email}. Message ID: ${info.messageId}`,
|
||||
);
|
||||
|
||||
await db.insert(Otp).values({
|
||||
userId: email,
|
||||
value: otp,
|
||||
createdAt: Date.now(),
|
||||
validUntil: Date.now() + 1000 * 60 * 5,
|
||||
});
|
||||
}
|
||||
48
website/src/actions/otp/verify-otp.ts
Normal file
48
website/src/actions/otp/verify-otp.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { z } from "astro/zod";
|
||||
import { defineAction } from "astro:actions";
|
||||
import { and, db, eq, gte, Otp, SendmailToken } from "astro:db";
|
||||
|
||||
export default defineAction({
|
||||
input: z.object({
|
||||
guess: z.string().length(6),
|
||||
lenient: z.boolean().default(false),
|
||||
userId: z.string().nonempty(),
|
||||
}),
|
||||
handler: verifyOtp,
|
||||
});
|
||||
|
||||
async function verifyOtp({ guess, lenient, userId }: VerifyOtpParams) {
|
||||
const leniency = lenient ? 1000 * 60 : 0;
|
||||
const isOtpCorrect =
|
||||
(await db.$count(
|
||||
Otp,
|
||||
and(
|
||||
eq(Otp.userId, userId),
|
||||
eq(Otp.value, guess),
|
||||
gte(Otp.validUntil, Date.now() - leniency),
|
||||
),
|
||||
)) > 0;
|
||||
|
||||
if (!isOtpCorrect) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await db.delete(Otp).where(and(eq(Otp.userId, userId), eq(Otp.value, guess)));
|
||||
|
||||
const token = randomBytes(256).toString("hex");
|
||||
await db.insert(SendmailToken).values({
|
||||
userId,
|
||||
value: token,
|
||||
createdAt: Date.now(),
|
||||
validUntil: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
type VerifyOtpParams = {
|
||||
guess: string;
|
||||
lenient: boolean;
|
||||
userId: string;
|
||||
};
|
||||
93
website/src/actions/sendmail.ts
Normal file
93
website/src/actions/sendmail.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { z } from "astro/zod";
|
||||
import { defineAction } from "astro:actions";
|
||||
import nodemailer from "nodemailer";
|
||||
import {
|
||||
MAX_DAILY_EMAILS,
|
||||
LOCAL_SMTP_HOST,
|
||||
LOCAL_SMTP_PASSWORD,
|
||||
LOCAL_SMTP_PORT,
|
||||
LOCAL_SMTP_USER,
|
||||
CONTACT_MAILBOX,
|
||||
LOCAL_SMTP_ENVELOPE_FROM,
|
||||
} from "astro:env/server";
|
||||
import { and, db, eq, gte, SendmailToken, SentEmails } from "astro:db";
|
||||
|
||||
export default defineAction({
|
||||
input: z.object({
|
||||
email: z.string().email(),
|
||||
message: z.string().nonempty(),
|
||||
name: z.string().nonempty(),
|
||||
userId: z.string().nonempty(),
|
||||
token: z.string().nonempty(),
|
||||
}),
|
||||
handler: sendmail,
|
||||
});
|
||||
|
||||
type SendEmailParams = {
|
||||
email: string;
|
||||
message: string;
|
||||
name: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
async function sendmail({
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
token,
|
||||
userId,
|
||||
}: SendEmailParams) {
|
||||
const isTokenCorrect =
|
||||
(await db.$count(
|
||||
SendmailToken,
|
||||
and(
|
||||
eq(SendmailToken.userId, userId),
|
||||
eq(SendmailToken.value, token),
|
||||
gte(SendmailToken.validUntil, Date.now()),
|
||||
),
|
||||
)) > 0;
|
||||
if (!isTokenCorrect) {
|
||||
return false;
|
||||
}
|
||||
await db
|
||||
.delete(SendmailToken)
|
||||
.where(
|
||||
and(eq(SendmailToken.userId, userId), eq(SendmailToken.value, token)),
|
||||
);
|
||||
|
||||
const emailsSentLast24Hours = await db.$count(
|
||||
SentEmails,
|
||||
gte(SentEmails.sentAt, Date.now() - 1000 * 60 * 60 * 24),
|
||||
);
|
||||
if (emailsSentLast24Hours > MAX_DAILY_EMAILS) {
|
||||
throw new Error(
|
||||
`${emailsSentLast24Hours} emails have been sent in the last 24 hours, but the max daily load is ${MAX_DAILY_EMAILS}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail({
|
||||
from: LOCAL_SMTP_ENVELOPE_FROM,
|
||||
to: CONTACT_MAILBOX,
|
||||
subject: `joeac.net: ${name} left a message`,
|
||||
text: `${name} <${email}> sent you a message:\n\n\n${message}`,
|
||||
});
|
||||
await db
|
||||
.insert(SentEmails)
|
||||
.values({ messageId: info.messageId, sentAt: Date.now() });
|
||||
|
||||
console.log("Sent an email to Joe. Message ID: ", info.messageId);
|
||||
}
|
||||
|
||||
export const transporter = nodemailer.createTransport({
|
||||
host: LOCAL_SMTP_HOST,
|
||||
from: LOCAL_SMTP_ENVELOPE_FROM,
|
||||
port: LOCAL_SMTP_PORT,
|
||||
secure: false,
|
||||
authMethod: "PLAIN",
|
||||
auth: {
|
||||
type: "login",
|
||||
user: LOCAL_SMTP_USER,
|
||||
pass: LOCAL_SMTP_PASSWORD,
|
||||
},
|
||||
});
|
||||
@@ -12,5 +12,8 @@
|
||||
<li>
|
||||
<a href="/links">Links</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/contact">Contact</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
31
website/src/components/OtpDialog.astro
Normal file
31
website/src/components/OtpDialog.astro
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
---
|
||||
|
||||
<script src="../scripts/otp-form-wc.ts"></script>
|
||||
<link rel="stylesheet" href="/css/otp.css" />
|
||||
|
||||
<dialog class="otp-dialog">
|
||||
<otp-form>
|
||||
<form class="otp-form" class="otp-form">
|
||||
<p>
|
||||
I've sent a six-digit code to <span class="otp-recipient">your email address</span>.
|
||||
Let me know what it is, so I can confirm this really is your email address. The
|
||||
code will be valid <span class="otp-valid-until">for five minutes</span>.
|
||||
</p>
|
||||
|
||||
<p hidden class="error"/>
|
||||
|
||||
<div class="otp-inputs">
|
||||
<input maxlength="1" name="1" required autocapitalize="characters" autocomplete="one-time-code" aria-label="First digit of one-time passcode" autofocus>
|
||||
<input maxlength="1" name="2" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Second digit of one-time passcode">
|
||||
<input maxlength="1" name="3" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Third digit of one-time passcode">
|
||||
<input maxlength="1" name="4" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Fourth digit of one-time passcode">
|
||||
<input maxlength="1" name="5" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Fifth digit of one-time passcode">
|
||||
<input maxlength="1" name="6" required autocapitalize="characters" autocomplete="one-time-code" aria-label="Sixth digit of one-time passcode">
|
||||
</div>
|
||||
|
||||
<input type="submit" value="Verify">
|
||||
<button disabled class="resend-button">Resend (60s)</button>
|
||||
</form>
|
||||
</otp-form>
|
||||
</dialog>
|
||||
96
website/src/pages/contact.astro
Normal file
96
website/src/pages/contact.astro
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
import OtpDialog from "../components/OtpDialog.astro";
|
||||
import Page from "../layouts/Page.astro";
|
||||
---
|
||||
|
||||
<Page title="Contact" description="Contact Joe Carstairs">
|
||||
<OtpDialog />
|
||||
|
||||
<form class="contact-form">
|
||||
<h1>Contact me</h1>
|
||||
<p hidden class="error"/>
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" required>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" name="email" type="email" required>
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" required></textarea>
|
||||
<input type="submit" value="Send">
|
||||
</form>
|
||||
|
||||
<section class="success" hidden="true">
|
||||
<h1>You sent me a message!</h1>
|
||||
<p>Thanks for that. I may be in touch.</p>
|
||||
<p>In case you forgot, your message was this:</p>
|
||||
<hr>
|
||||
<dl>
|
||||
<dt>Name</dt> <dd class="sentname">???</dd>
|
||||
<dt>Email</dt> <dd class="sentemail">???</dd>
|
||||
<dt>Message</dt> <dd class="sentmessage">???</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { resendOtp } from '../scripts/contact/resend-otp';
|
||||
import { submitContactForm } from '../scripts/contact/submit-contact-form';
|
||||
import { submitOtpForm } from '../scripts/contact/submit-otp-form';
|
||||
import type { Selectors } from '../scripts/contact/selectors';
|
||||
|
||||
function locateOrPanic<T extends Element>(selector: string, desc: string, root?: Element): T {
|
||||
const elem = (root ?? document).querySelector<T>(selector);
|
||||
if (!elem) {
|
||||
alert(`Technical error: could not locate ${desc}. Please let Joe know if you have another means of contacting him.`);
|
||||
throw new Error(`Could not locate ${desc}`);
|
||||
}
|
||||
return elem;
|
||||
}
|
||||
|
||||
const contactForm = locateOrPanic<HTMLFormElement>('form.contact-form', 'contact form');
|
||||
const otpDialog = locateOrPanic<HTMLDialogElement>('dialog.otp-dialog', 'OTP dialog');
|
||||
const otpForm = locateOrPanic<HTMLFormElement>('form.otp-form', 'OTP form');
|
||||
const successSection = document.querySelector('section.success');
|
||||
let resendButtonInterval: undefined | NodeJS.Timeout = undefined;
|
||||
|
||||
const selectors: Selectors = {
|
||||
contactForm: {
|
||||
emailElem: () => locateOrPanic<HTMLInputElement>('input[name="email"]', 'email input', contactForm),
|
||||
errorElem: () => contactForm.querySelector('.error'),
|
||||
nameElem: () => locateOrPanic<HTMLInputElement>('input[name="name"]', 'name input', contactForm),
|
||||
messageElem: () => locateOrPanic<HTMLTextAreaElement>('textarea[name="message"]', 'message textarea', contactForm),
|
||||
self: () => contactForm,
|
||||
submitButton: () => contactForm.querySelector('input[type="submit"]'),
|
||||
},
|
||||
otpDialog: {
|
||||
allOtpInputs: () => otpForm.querySelectorAll('input:not([type="submit"])'),
|
||||
errorElem: () => otpDialog.querySelector('.error'),
|
||||
firstOtpInput: () => otpForm.querySelector('input:not([type="submit"]):first-child'),
|
||||
otpForm: () => otpForm,
|
||||
otpRecipient: () => otpDialog.querySelector('.otp-recipient'),
|
||||
otpValidUntil: () => otpDialog.querySelector('.otp-valid-until'),
|
||||
resendButton: () => otpDialog.querySelector<HTMLButtonElement>('button.resend-button'),
|
||||
self: () => otpDialog,
|
||||
submitButton: () => otpForm.querySelector('input[type="submit"]'),
|
||||
},
|
||||
successSection: {
|
||||
email: () => successSection?.querySelector('.sentemail') ?? null,
|
||||
name: () => successSection?.querySelector('.sentname') ?? null,
|
||||
message: () => successSection?.querySelector('.sentmessage') ?? null,
|
||||
self: () => successSection,
|
||||
}
|
||||
};
|
||||
|
||||
contactForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
({ resendButtonInterval } = await submitContactForm(selectors, resendButtonInterval));
|
||||
});
|
||||
|
||||
selectors.otpDialog.resendButton()?.addEventListener('click', async () => {
|
||||
({ resendButtonInterval } = await resendOtp(selectors, resendButtonInterval));
|
||||
});
|
||||
|
||||
otpForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await submitOtpForm(selectors);
|
||||
});
|
||||
</script>
|
||||
</Page>
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
import Page from '../layouts/Page.astro';
|
||||
---
|
||||
|
||||
<Page title="Joe's housewarming" description="Details for Joe's housewarming, July 2024">
|
||||
<section>
|
||||
<h1>Joe's housewarming</h1>
|
||||
|
||||
<p>
|
||||
I, Joe Carstairs, hereby promise to keep this webpage updated with
|
||||
accurate information.
|
||||
</p>
|
||||
|
||||
<dl>
|
||||
<dt>when</dt>
|
||||
<dd>2pm-6pm Sunday 21 July 2024</dd>
|
||||
|
||||
<dt>where</dt>
|
||||
<dd>57 Manor Place, EH3 7EG</dd>
|
||||
|
||||
<dt>what to wear</dt>
|
||||
<dd>whatever you like (as long as it's decent)</dd>
|
||||
|
||||
<dt>what to bring</dt>
|
||||
<dd>
|
||||
good food<br>
|
||||
and/or good drink<br>
|
||||
and/or good chat<br>
|
||||
and/or good tunes<br>
|
||||
and/or a good game<br>
|
||||
and/or just your good self
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</Page>
|
||||
|
||||
30
website/src/scripts/contact/post-error-message.ts
Normal file
30
website/src/scripts/contact/post-error-message.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Selectors } from "./selectors";
|
||||
|
||||
export function postErrorMessageOnContactForm(
|
||||
{ contactForm }: Selectors,
|
||||
errorMsg: string,
|
||||
) {
|
||||
const errorElem = contactForm.errorElem();
|
||||
if (errorElem) {
|
||||
errorElem.textContent = errorMsg;
|
||||
errorElem.removeAttribute("hidden");
|
||||
} else {
|
||||
alert(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
export function postErrorMessageOnOtpForm(
|
||||
{ otpDialog }: Selectors,
|
||||
errorMsg: string,
|
||||
) {
|
||||
const errorElem = otpDialog.errorElem();
|
||||
if (errorElem) {
|
||||
errorElem.textContent = errorMsg;
|
||||
errorElem.removeAttribute("hidden");
|
||||
} else {
|
||||
alert(errorMsg);
|
||||
}
|
||||
for (const input of otpDialog.allOtpInputs()) {
|
||||
(input as HTMLInputElement).value = "";
|
||||
}
|
||||
}
|
||||
29
website/src/scripts/contact/resend-otp.ts
Normal file
29
website/src/scripts/contact/resend-otp.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { actions } from "astro:actions";
|
||||
import { resetResendButton } from "./reset-resend-button";
|
||||
import { postErrorMessageOnOtpForm } from "./post-error-message";
|
||||
import type { Selectors } from "./selectors";
|
||||
|
||||
const fallbackErrorMsg =
|
||||
"No can do. I'm afraid joeac.net is a bit broken right now - sorry about that.";
|
||||
|
||||
export async function resendOtp(
|
||||
selectors: Selectors,
|
||||
resetButtonInterval: NodeJS.Timeout | undefined,
|
||||
): Promise<Result> {
|
||||
const result = resetResendButton(selectors, resetButtonInterval);
|
||||
const name = selectors.contactForm.nameElem()?.value;
|
||||
const email = selectors.contactForm.emailElem().value;
|
||||
|
||||
const sendOtpResult = await actions.otp.send({ type: "email", name, email });
|
||||
if (sendOtpResult.error) {
|
||||
const errorMsg = sendOtpResult.error?.toString() ?? fallbackErrorMsg;
|
||||
postErrorMessageOnOtpForm(selectors, errorMsg);
|
||||
throw sendOtpResult.error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
type Result = {
|
||||
resendButtonInterval: NodeJS.Timeout | undefined;
|
||||
};
|
||||
32
website/src/scripts/contact/reset-resend-button.ts
Normal file
32
website/src/scripts/contact/reset-resend-button.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Selectors } from "./selectors";
|
||||
|
||||
export function resetResendButton(
|
||||
{ otpDialog }: Selectors,
|
||||
resendButtonInterval: NodeJS.Timeout | undefined,
|
||||
): Result {
|
||||
clearInterval(resendButtonInterval);
|
||||
|
||||
const resendButton = otpDialog.resendButton();
|
||||
if (resendButton) {
|
||||
resendButton.setAttribute("data-countdown", "60");
|
||||
resendButton.setAttribute("disabled", "");
|
||||
|
||||
resendButtonInterval = setInterval(() => {
|
||||
const countdown = +(resendButton.getAttribute("data-countdown") ?? 1) - 1;
|
||||
resendButton.setAttribute("data-countdown", countdown.toString());
|
||||
resendButton.textContent = `Resend (${countdown}s)`;
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(resendButtonInterval);
|
||||
resendButton.textContent = "Resend";
|
||||
resendButton.removeAttribute("disabled");
|
||||
}, 1000 * 60);
|
||||
}
|
||||
|
||||
return { resendButtonInterval };
|
||||
}
|
||||
|
||||
type Result = {
|
||||
resendButtonInterval: NodeJS.Timeout | undefined;
|
||||
};
|
||||
27
website/src/scripts/contact/selectors.ts
Normal file
27
website/src/scripts/contact/selectors.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type Selectors = {
|
||||
contactForm: {
|
||||
emailElem: () => HTMLInputElement;
|
||||
errorElem: () => Element | null;
|
||||
nameElem: () => HTMLInputElement;
|
||||
messageElem: () => HTMLTextAreaElement;
|
||||
self: () => HTMLFormElement | null;
|
||||
submitButton: () => HTMLInputElement | null;
|
||||
};
|
||||
otpDialog: {
|
||||
allOtpInputs: () => NodeListOf<HTMLInputElement>;
|
||||
errorElem: () => Element | null;
|
||||
firstOtpInput: () => HTMLInputElement | null;
|
||||
otpForm: () => HTMLFormElement | null;
|
||||
otpRecipient: () => Element | null;
|
||||
otpValidUntil: () => Element | null;
|
||||
resendButton: () => HTMLButtonElement | null;
|
||||
self: () => HTMLDialogElement;
|
||||
submitButton: () => HTMLInputElement | null;
|
||||
};
|
||||
successSection: {
|
||||
email: () => Element | null;
|
||||
message: () => Element | null;
|
||||
name: () => Element | null;
|
||||
self: () => Element | null;
|
||||
};
|
||||
};
|
||||
53
website/src/scripts/contact/submit-contact-form.ts
Normal file
53
website/src/scripts/contact/submit-contact-form.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { actions } from "astro:actions";
|
||||
import { postErrorMessageOnContactForm } from "./post-error-message";
|
||||
import { resetResendButton } from "./reset-resend-button";
|
||||
import type { Selectors } from "./selectors";
|
||||
|
||||
const fallbackErrorMsg =
|
||||
"No can do. I'm afraid joeac.net is a bit broken right now - sorry about that.";
|
||||
|
||||
export async function submitContactForm(
|
||||
selectors: Selectors,
|
||||
resendButtonInterval: NodeJS.Timeout | undefined,
|
||||
): Promise<Result> {
|
||||
const { contactForm, otpDialog } = selectors;
|
||||
|
||||
const name = contactForm.nameElem()?.value;
|
||||
const email = contactForm.emailElem().value;
|
||||
|
||||
contactForm.submitButton()?.setAttribute("disabled", "");
|
||||
const sendOtpResult = await actions.otp.send({ type: "email", name, email });
|
||||
if (sendOtpResult.error) {
|
||||
const errorMsg = sendOtpResult.error?.toString() ?? fallbackErrorMsg;
|
||||
postErrorMessageOnContactForm(selectors, errorMsg);
|
||||
throw sendOtpResult.error;
|
||||
}
|
||||
|
||||
const otpRecipient = otpDialog.otpRecipient();
|
||||
email && otpRecipient && (otpRecipient.textContent = `<${email}>`);
|
||||
const otpValidUntil = otpDialog.otpValidUntil();
|
||||
const validUntil = new Date(Date.now() + 1000 * 60 * 5);
|
||||
otpValidUntil &&
|
||||
(otpValidUntil.textContent = `until ${validUntil.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`);
|
||||
|
||||
const dialog = otpDialog.self();
|
||||
dialog.showModal();
|
||||
contactForm.submitButton()?.removeAttribute("disabled");
|
||||
dialog.addEventListener("click", function (event) {
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
const isInDialog =
|
||||
rect.top <= event.clientY &&
|
||||
event.clientY <= rect.top + rect.height &&
|
||||
rect.left <= event.clientX &&
|
||||
event.clientX <= rect.left + rect.width;
|
||||
if (!isInDialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
return resetResendButton(selectors, resendButtonInterval);
|
||||
}
|
||||
|
||||
type Result = {
|
||||
resendButtonInterval: NodeJS.Timeout | undefined;
|
||||
};
|
||||
72
website/src/scripts/contact/submit-otp-form.ts
Normal file
72
website/src/scripts/contact/submit-otp-form.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { actions } from "astro:actions";
|
||||
import type { Selectors } from "./selectors";
|
||||
import {
|
||||
postErrorMessageOnContactForm,
|
||||
postErrorMessageOnOtpForm,
|
||||
} from "./post-error-message";
|
||||
|
||||
const fallbackErrorMsg =
|
||||
"No can do. I'm afraid joeac.net is a bit broken right now - sorry about that.";
|
||||
|
||||
export async function submitOtpForm(selectors: Selectors) {
|
||||
const { contactForm, otpDialog, successSection } = selectors;
|
||||
const otpForm = otpDialog.otpForm();
|
||||
otpDialog.submitButton()?.setAttribute("disabled", "");
|
||||
|
||||
const otpFormData = new FormData(otpForm ?? undefined);
|
||||
const guess = [
|
||||
otpFormData.get("1"),
|
||||
otpFormData.get("2"),
|
||||
otpFormData.get("3"),
|
||||
otpFormData.get("4"),
|
||||
otpFormData.get("5"),
|
||||
otpFormData.get("6"),
|
||||
].join("");
|
||||
|
||||
const name = contactForm.nameElem()?.value;
|
||||
const email = contactForm.emailElem().value;
|
||||
const message = contactForm.messageElem()?.value;
|
||||
|
||||
const verifyResult = await actions.otp.verify({ guess, userId: email });
|
||||
if (verifyResult.error) {
|
||||
otpDialog.submitButton()?.removeAttribute("disabled");
|
||||
postErrorMessageOnOtpForm(
|
||||
selectors,
|
||||
verifyResult.error?.toString() ?? fallbackErrorMsg,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!verifyResult.data) {
|
||||
otpDialog.submitButton()?.removeAttribute("disabled");
|
||||
postErrorMessageOnOtpForm(selectors, "Incorrect OTP. Check your email?");
|
||||
otpDialog.firstOtpInput()?.focus();
|
||||
return;
|
||||
}
|
||||
const sendmailToken = verifyResult.data;
|
||||
|
||||
const sendmailResult = await actions.sendmail({
|
||||
email,
|
||||
message: message,
|
||||
name: name,
|
||||
userId: email,
|
||||
token: sendmailToken,
|
||||
});
|
||||
if (sendmailResult.error) {
|
||||
const errorMsg = sendmailResult.error?.toString() ?? fallbackErrorMsg;
|
||||
postErrorMessageOnOtpForm(selectors, errorMsg);
|
||||
otpDialog.submitButton()?.removeAttribute("disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
const sentName = successSection.name();
|
||||
const sentEmail = successSection.email();
|
||||
const sentMessage = successSection.message();
|
||||
sentName && (sentName.textContent = name ?? "???");
|
||||
sentEmail && (sentEmail.textContent = email ?? "???");
|
||||
sentMessage && (sentMessage.textContent = message ?? "???");
|
||||
|
||||
contactForm.self()?.remove();
|
||||
successSection.self()?.removeAttribute("hidden");
|
||||
otpDialog.submitButton()?.removeAttribute("disabled");
|
||||
otpDialog.self().close();
|
||||
}
|
||||
68
website/src/scripts/otp-form-wc.ts
Normal file
68
website/src/scripts/otp-form-wc.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
class OtpForm extends HTMLElement {
|
||||
observer?: MutationObserver;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.observer = new MutationObserver(() => {
|
||||
this.clearInputs();
|
||||
this.configureInputs();
|
||||
});
|
||||
this.observer.observe(this, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.observer?.disconnect();
|
||||
}
|
||||
|
||||
clearInputs() {
|
||||
console.log("clearing all inputs");
|
||||
const inputs = this.querySelectorAll(
|
||||
'input:not([type="submit"])',
|
||||
) as NodeListOf<HTMLInputElement>;
|
||||
for (const input of inputs) {
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
configureInputs() {
|
||||
this.observer?.disconnect();
|
||||
const inputs = this.querySelectorAll(
|
||||
'input:not([type="submit"])',
|
||||
) as NodeListOf<HTMLInputElement>;
|
||||
const form = this.querySelector("form") as HTMLFormElement;
|
||||
|
||||
for (const input of inputs) {
|
||||
input.addEventListener("focus", () => {
|
||||
input.select();
|
||||
});
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
if (input.value.length > 0) {
|
||||
input.value = input.value.slice(0, 1).toLocaleUpperCase();
|
||||
const nextInput = input.nextElementSibling as HTMLInputElement;
|
||||
if (nextInput) {
|
||||
nextInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const submitButton = form.querySelector(
|
||||
'input[type="submit"]',
|
||||
) as HTMLInputElement;
|
||||
submitButton.focus();
|
||||
let areAllInputsEntered = true;
|
||||
inputs.forEach((input) => {
|
||||
areAllInputsEntered = areAllInputsEntered && input.value.length > 0;
|
||||
});
|
||||
if (areAllInputsEntered) {
|
||||
form.requestSubmit(submitButton);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("otp-form", OtpForm);
|
||||
Reference in New Issue
Block a user