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
|
**/*.tfvars
|
||||||
**/*.tfstate
|
**/*.tfstate
|
||||||
**/*.tfstate.backup
|
**/*.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
|
```bash
|
||||||
npm run start
|
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 mdx from "@astrojs/mdx";
|
||||||
import node from "@astrojs/node";
|
import node from "@astrojs/node";
|
||||||
|
|
||||||
@@ -9,6 +10,26 @@ export default defineConfig({
|
|||||||
adapter: node({
|
adapter: node({
|
||||||
mode: "standalone",
|
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",
|
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",
|
"private": "true",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cd website && astro dev",
|
"dev": "astro dev",
|
||||||
"start": "cd website && astro build && node ./dist/server/entry.mjs",
|
"start": "astro build --remote && node ./dist/server/entry.mjs",
|
||||||
"build": "cd website && astro build",
|
"build": "astro build --remote",
|
||||||
"preview": "cd website && astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "cd website && astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.14.0"
|
"node": "^22.14.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@astrojs/db": "^0.18.3",
|
||||||
"@astrojs/mdx": "^4.3.0",
|
"@astrojs/mdx": "^4.3.0",
|
||||||
"@astrojs/node": "^9.5.1",
|
"@astrojs/node": "^9.5.1",
|
||||||
"@astrojs/rss": "^4.0.10",
|
"@astrojs/rss": "^4.0.10",
|
||||||
"@astrojs/sitemap": "^3.2.1",
|
"@astrojs/sitemap": "^3.2.1",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"astro": "^5.1.1",
|
"astro": "^5.1.1",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
"nodemailer": "^7.0.11",
|
||||||
"typescript": "^5.4.3"
|
"typescript": "^5.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -24,12 +24,39 @@
|
|||||||
--colour-hyperlink-90: #bfe3ff;
|
--colour-hyperlink-90: #bfe3ff;
|
||||||
--colour-hyperlink-95: #e0f1ff;
|
--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: var(--colour-primary-90);
|
||||||
--colour-primary-fg-accent: var(--colour-primary-80);
|
--colour-primary-fg-accent: var(--colour-primary-80);
|
||||||
--colour-primary-bg: var(--colour-primary-10);
|
--colour-primary-bg: var(--colour-primary-10);
|
||||||
|
--colour-primary-bg-accent: var(--colour-primary-20);
|
||||||
--colour-code-fg: var(--colour-primary-90);
|
--colour-code-fg: var(--colour-primary-90);
|
||||||
--colour-code-bg: var(--colour-primary-15);
|
--colour-code-bg: var(--colour-primary-15);
|
||||||
--colour-hyperlink: var(--colour-hyperlink-80);
|
--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-sm: 1rem;
|
||||||
--font-size-base: 1.125rem;
|
--font-size-base: 1.125rem;
|
||||||
@@ -56,6 +83,10 @@
|
|||||||
--colour-primary-fg-accent: var(--colour-primary-40);
|
--colour-primary-fg-accent: var(--colour-primary-40);
|
||||||
--colour-primary-bg: var(--colour-primary-95);
|
--colour-primary-bg: var(--colour-primary-95);
|
||||||
--colour-hyperlink: var(--colour-hyperlink-40);
|
--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;
|
line-height: 1.5;
|
||||||
|
|
||||||
/* Geometric Humanist stack from https://modernfontstacks.com */
|
/* Geometric Humanist stack from https://modernfontstacks.com */
|
||||||
font-family: Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro,
|
font-family:
|
||||||
sans-serif;
|
Avenir, Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
small {
|
||||||
@@ -81,6 +112,10 @@ small {
|
|||||||
margin-block-start: var(--spacing-block-sm);
|
margin-block-start: var(--spacing-block-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/** Base layout */
|
/** Base layout */
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -109,6 +144,7 @@ img {
|
|||||||
[media-end content-start]
|
[media-end content-start]
|
||||||
minmax(var(--grid-max-content-width), auto)
|
minmax(var(--grid-max-content-width), auto)
|
||||||
[content-end grid-end];
|
[content-end grid-end];
|
||||||
|
grid-auto-rows: max-content;
|
||||||
column-gap: var(--spacing-block-sm);
|
column-gap: var(--spacing-block-sm);
|
||||||
max-width: var(--grid-total-width);
|
max-width: var(--grid-total-width);
|
||||||
|
|
||||||
@@ -127,7 +163,7 @@ img {
|
|||||||
grid-column: grid;
|
grid-column: grid;
|
||||||
grid-template-columns: subgrid;
|
grid-template-columns: subgrid;
|
||||||
|
|
||||||
> :is(section, header, aside) {
|
> :is(section, header, aside, form) {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: subgrid;
|
grid-template-columns: subgrid;
|
||||||
grid-column: grid;
|
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 */
|
/* utilities */
|
||||||
:is(
|
:is(
|
||||||
.para-spacing-tight:is(p, h1, h2, h3, h4, h5, h6, hr, img, figure, ul, ol),
|
.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);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,57 +1,57 @@
|
|||||||
.h-card div:has(img) {
|
.h-card div:has(img) {
|
||||||
width: 6rem;
|
width: 6rem;
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-card img {
|
.h-card img {
|
||||||
width: 6rem;
|
width: 6rem;
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
filter: contrast(1.25);
|
filter: contrast(1.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-card div:has(img)::after {
|
.h-card div:has(img)::after {
|
||||||
/* Colour overlay */
|
/* Colour overlay */
|
||||||
background-color: var(--colour-primary-80);
|
background-color: var(--colour-primary-80);
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
|
|
||||||
/* Same size and shape as the img */
|
/* Same size and shape as the img */
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
width: 6rem;
|
width: 6rem;
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
|
|
||||||
/* Positioned on top of the img */
|
/* Positioned on top of the img */
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -6rem;
|
top: -6rem;
|
||||||
|
|
||||||
/* A content value is needed to get the ::after to render */
|
/* A content value is needed to get the ::after to render */
|
||||||
content: '';
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (min-width: 36rem) {
|
@media (min-width: 36rem) {
|
||||||
.h-card {
|
.h-card {
|
||||||
grid-column: media-start / content-end;
|
grid-column: media-start / content-end;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: subgrid; /** Subgrid of main column layout */
|
grid-template-columns: subgrid; /** Subgrid of main column layout */
|
||||||
grid-template-rows: min-content 1fr;
|
grid-template-rows: min-content 1fr;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"empty heading"
|
"empty heading"
|
||||||
"photo text";
|
"photo text";
|
||||||
}
|
column-gap: var(--spacing-block-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.h-card div:has(img) {
|
.h-card div:has(img) {
|
||||||
grid-area: photo;
|
grid-area: photo;
|
||||||
margin-block-start: var(--spacing-block-sm);
|
margin-block-start: var(--spacing-block-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-card header {
|
.h-card header {
|
||||||
grid-area: heading;
|
grid-area: heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-card__text {
|
.h-card__text {
|
||||||
grid-area: text;
|
grid-area: text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 */
|
/* Remove default margin in favour of better control in authored CSS */
|
||||||
body, h1, h2, h3, h4, p,
|
body,
|
||||||
figure, blockquote, dl, dd {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
p,
|
||||||
|
figure,
|
||||||
|
blockquote,
|
||||||
|
dl,
|
||||||
|
dd {
|
||||||
margin-block: 0;
|
margin-block: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
|
/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */
|
||||||
ul[role='list'],
|
ul[role="list"],
|
||||||
ol[role='list'] {
|
ol[role="list"] {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,14 +40,21 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Set shorter line heights on headings and interactive elements */
|
/* Set shorter line heights on headings and interactive elements */
|
||||||
h1, h2, h3, h4,
|
h1,
|
||||||
button, input, label {
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
label {
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Balance text wrapping on headings */
|
/* Balance text wrapping on headings */
|
||||||
h1, h2,
|
h1,
|
||||||
h3, h4 {
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +66,10 @@ picture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Inherit fonts for inputs and buttons */
|
/* Inherit fonts for inputs and buttons */
|
||||||
input, button,
|
input,
|
||||||
textarea, select {
|
button,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,3 +88,6 @@ textarea:not([rows]) {
|
|||||||
padding: 0;
|
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>
|
<li>
|
||||||
<a href="/links">Links</a>
|
<a href="/links">Links</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/contact">Contact</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</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