Compare commits
10 Commits
0802b69c56
...
main
Author | SHA1 | Date | |
---|---|---|---|
85234ce522
|
|||
f743618b5f
|
|||
ce6dffbaf5
|
|||
d0c1319258
|
|||
7c8ab1c988
|
|||
52362b748b
|
|||
e46a4a560a
|
|||
1c3b37a70c
|
|||
ca15fa782d
|
|||
ba8eec5879
|
@@ -47,4 +47,12 @@ jobs:
|
||||
|
||||
- name: Deploy via rsync
|
||||
run: |
|
||||
rsync -avz --delete --exclude '.git' --exclude '.gitea' --exclude 'README.md' --exclude 'LICENSE.md' --exclude 'LICENSE' --exclude '.github' --exclude '.gitignore' --exclude '.gitattributes' --exclude 'deploy.yml' ./dist/ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_DIRECTORY }}
|
||||
rsync -avz --delete --exclude 'node_modules/' ./dist/ ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_DIRECTORY }}
|
||||
|
||||
- name: Copy package.json via rsync
|
||||
run: |
|
||||
rsync -avz ./package*.json ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_DIRECTORY }}
|
||||
|
||||
- name: Install and restart service
|
||||
run: |
|
||||
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} '/home/deployers/${{ secrets.DEPLOY_USER }}/deploy.sh'
|
||||
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -17,5 +17,8 @@
|
||||
],
|
||||
"css.customData": [
|
||||
".vscode/tailwind.json"
|
||||
]
|
||||
],
|
||||
"[mdx]": {
|
||||
"editor.wordWrap": "on"
|
||||
}
|
||||
}
|
@@ -14,6 +14,8 @@ import robotsTxt from "astro-robots-txt";
|
||||
|
||||
import { transformerMetaHighlight } from "@shikijs/transformers";
|
||||
|
||||
import node from "@astrojs/node";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
@@ -51,12 +53,15 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
site: "https://www.nathancummins.com.au",
|
||||
|
||||
trailingSlash: "always",
|
||||
|
||||
image: {
|
||||
responsiveStyles: false
|
||||
},
|
||||
|
||||
output: "static"
|
||||
});
|
||||
output: "static",
|
||||
|
||||
adapter: node({
|
||||
mode: "standalone"
|
||||
})
|
||||
});
|
1650
package-lock.json
generated
1650
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.3.1",
|
||||
"@astrojs/node": "^9.4.3",
|
||||
"@astrojs/sitemap": "^3.4.2",
|
||||
"@iconify-json/fa": "^1.2.1",
|
||||
"@iconify-json/fa7-brands": "^1.2.0",
|
||||
@@ -22,6 +23,7 @@
|
||||
"astro-robots-txt": "^1.0.0",
|
||||
"astro-seo-schema": "^5.1.0",
|
||||
"get-audio-duration": "^4.0.1",
|
||||
"nodemailer": "^7.0.5",
|
||||
"schema-dts": "^1.1.5",
|
||||
"tailwindcss": "^4.1.11"
|
||||
},
|
||||
@@ -29,6 +31,7 @@
|
||||
"@cspell/dict-en-au": "^1.1.4",
|
||||
"@shikijs/transformers": "^3.9.2",
|
||||
"@types/howler": "^2.2.12",
|
||||
"@types/nodemailer": "^7.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"eslint": "^9.32.0",
|
||||
|
@@ -31,19 +31,61 @@ externalLinks:
|
||||
]
|
||||
---
|
||||
|
||||
_This page contains just a few details of my involvement in the project. I will continue to update it and add more details while the game develops (and when I get the time to!)_.
|
||||
import TrackInline from "@components/TrackInline.astro";
|
||||
|
||||
<small>
|
||||
_This page contains just a few details of my involvement in the project. I
|
||||
will continue to update it and add more details while the game develops (and
|
||||
when I get the time to!)_.
|
||||
</small>
|
||||
|
||||
Since late 2023 I have had the pleasure of working with the team at Catalyst Games on the cozy-adventure RPG _Dungeons and Dining Tables_, a game where you play as an axolotl on a quest to decorate their house.
|
||||
This little axolotl and the team around it have since become key figures in my life and I consider myself privileged to be a part of the project's development.
|
||||
|
||||

|
||||

|
||||
|
||||
# My Role
|
||||
|
||||
While I joined the team partway through the development of the project, I have still been involved since quite early in the development timeline as a composer, sound designer, and also as a programmer responsible for almost all integration of audio.
|
||||
This has allowed me to deeply integrate both music and sound into the game and with cross-pollination of the two together, from birds that tweet the melody of the area that you are in, to music that mixes between highly orchestrated pieces to music for jazz quartet, to custom designed logic allowing sounds to be obscured by objects in the game-world.
|
||||
|
||||
# The Gameplay and My Approach
|
||||
|
||||
While I can't share too much currently, the [steam page](https://store.steampowered.com/app/2941630/Dungeons_and_Dining_Tables/) has a good overview of the the gameplay:
|
||||
|
||||
> Dungeons and Dining Tables is a Cozy-Adventure RPG where you play as an Axolotl on a quest to decorate their house and defeat the termites out to eat their furniture!
|
||||
>
|
||||
> Randomly generated dungeons to explore with puzzles to complete, enemies to defeat and rare furniture loot to collect. Travel through the World Tree, take on new rogue-like challenges and defeat bosses for the most epic of furnishings (maybe even a Legendary Bed of Maximum Health!)
|
||||
>
|
||||
> Furniture collected on a dungeon run can be used to decorate not only your humble home but also the homes of the cute and quirky characters of the town of Kindlerest! Decorating your home will give you comfy points that are used to level up your house, improve player stats, upgrade shops and other quality of life features for your dungeon runs.
|
||||
|
||||
Considering the gameplay, my approach to the audio has been to make it as dynamic and responsive to the gameplay as possible, with reactive music and sound design that not only compliments but heightens the user experience.
|
||||
This is best exemplified by the music of the town _Kindlerest_, which is a cosy orchestral piece (with some hustle-and-bustle for good measure) that transitions seamlessly to a jazz quartet piece (_Pine for a Tune_) while you are decorating your house via some simple layering in FMOD.
|
||||
|
||||
<div class="my-4">
|
||||
<TrackInline id="dndt-kindlerest" />
|
||||
<TrackInline id="dndt-pine-for-a-tune" />
|
||||
</div>
|
||||
|
||||

|
||||
|
||||
# My Inspiration
|
||||
|
||||
Throughout the project I have taken a lot of inspiration from games that have been significant to my development as a composer and as a sound designer.
|
||||
The games that have inspired me the most include _Super Mario Galaxy_, and many games from the _The Legend of Zelda_ series, in particular the approach to sound design from _The Legend of Zelda: Breath of the Wild_.
|
||||
Throughout the project my inspiration has been drawn from a variety of sources.
|
||||
Primarily and probably unsurprisingly, I have drawn a lot of inspiration from the art style itself and in particular the animations done by the extremely talented Ty Hemi.
|
||||
The art style and animations are so full of character that it is difficult not to be inspired by them and as a result my sound design work tries to reflect the same character and charm.
|
||||
|
||||
I have also of course taken a lot of inspiration from games that have been significant to my development as a composer and as a sound designer.
|
||||
The games that have inspired me the most include _Super Mario Galaxy_ and many games from the _The Legend of Zelda_ series, in particular the approach to the sound design of _The Legend of Zelda: Breath of the Wild_.
|
||||
It is also impossible to ignore the influence of the _Animal Crossing_ series, in particular _Animal Crossing: New Horizons_ which is most evident in the UI sounds.
|
||||
|
||||
# Listen
|
||||
|
||||
<div class="my-4">
|
||||
<TrackInline id="dndt-lampshade-grove" />
|
||||
<TrackInline id="dndt-kindlerest" />
|
||||
<TrackInline id="dndt-pine-for-a-tune" />
|
||||
<TrackInline id="dndt-the-couch-troll" />
|
||||
<TrackInline id="dndt-the-couch-trolls-lair" />
|
||||
<TrackInline id="dndt-gameplay-trailer" />
|
||||
</div>
|
||||
|
BIN
public/audio/dungeons-and-dining-tables/Dungeons_and_Dining_Tables.mp3
(Stored with Git LFS)
Normal file
BIN
public/audio/dungeons-and-dining-tables/Dungeons_and_Dining_Tables.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/audio/dungeons-and-dining-tables/Kindlerest.mp3
(Stored with Git LFS)
Normal file
BIN
public/audio/dungeons-and-dining-tables/Kindlerest.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/audio/dungeons-and-dining-tables/Pine_for_a_Tune.mp3
(Stored with Git LFS)
Normal file
BIN
public/audio/dungeons-and-dining-tables/Pine_for_a_Tune.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/audio/dungeons-and-dining-tables/The_Couch_Troll.mp3
(Stored with Git LFS)
Normal file
BIN
public/audio/dungeons-and-dining-tables/The_Couch_Troll.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/audio/dungeons-and-dining-tables/The_Couch_Trolls_Lair.mp3
(Stored with Git LFS)
Normal file
BIN
public/audio/dungeons-and-dining-tables/The_Couch_Trolls_Lair.mp3
(Stored with Git LFS)
Normal file
Binary file not shown.
8
src/assets/tracks/dndt-gameplay-trailer.json
Normal file
8
src/assets/tracks/dndt-gameplay-trailer.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"src": "dungeons-and-dining-tables/Dungeons_and_Dining_Tables.mp3",
|
||||
"metadata": {
|
||||
"title": "Gameplay Trailer",
|
||||
"subtitle": "Dungeons and Dining Tables",
|
||||
"extra": "Synthetic Orchestra"
|
||||
}
|
||||
}
|
8
src/assets/tracks/dndt-kindlerest.json
Normal file
8
src/assets/tracks/dndt-kindlerest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"src": "dungeons-and-dining-tables/Kindlerest.mp3",
|
||||
"metadata": {
|
||||
"title": "Kindlerest",
|
||||
"subtitle": "Dungeons and Dining Tables",
|
||||
"extra": "Synthetic Orchestra"
|
||||
}
|
||||
}
|
8
src/assets/tracks/dndt-pine-for-a-tune.json
Normal file
8
src/assets/tracks/dndt-pine-for-a-tune.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"src": "dungeons-and-dining-tables/Pine_for_a_Tune.mp3",
|
||||
"metadata": {
|
||||
"title": "Pine for a Tune",
|
||||
"subtitle": "Dungeons and Dining Tables",
|
||||
"extra": "Synthetic Orchestra"
|
||||
}
|
||||
}
|
8
src/assets/tracks/dndt-the-couch-troll.json
Normal file
8
src/assets/tracks/dndt-the-couch-troll.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"src": "dungeons-and-dining-tables/The_Couch_Troll.mp3",
|
||||
"metadata": {
|
||||
"title": "The Couch Troll",
|
||||
"subtitle": "Dungeons and Dining Tables",
|
||||
"extra": "Synthetic Orchestra"
|
||||
}
|
||||
}
|
8
src/assets/tracks/dndt-the-couch-trolls-lair.json
Normal file
8
src/assets/tracks/dndt-the-couch-trolls-lair.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"src": "dungeons-and-dining-tables/The_Couch_Trolls_Lair.mp3",
|
||||
"metadata": {
|
||||
"title": "The Couch Troll's Lair",
|
||||
"subtitle": "Dungeons and Dining Tables",
|
||||
"extra": "Synthetic Orchestra"
|
||||
}
|
||||
}
|
105
src/components/ContactForm.astro
Normal file
105
src/components/ContactForm.astro
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
const ID = `contact-form-${Math.random().toString(36)}`;
|
||||
---
|
||||
|
||||
<form
|
||||
class="mx-auto mt-8 max-w-xl space-y-6 rounded bg-white p-8 shadow-lg dark:bg-gray-900"
|
||||
id={ID}
|
||||
>
|
||||
<div>
|
||||
<label for="name" class="block font-bold">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
class="mt-2 block w-full rounded p-2 shadow dark:bg-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block font-bold">Your email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
class="mt-2 block w-full rounded p-2 shadow dark:bg-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject" class="block font-bold">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
class="mt-2 block w-full rounded p-2 shadow dark:bg-gray-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="message" class="block font-bold">Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows="4"
|
||||
class="mt-2 block w-full rounded p-2 shadow dark:bg-gray-600"></textarea>
|
||||
</div>
|
||||
<button
|
||||
id="submit"
|
||||
type="submit"
|
||||
class="bg-primary text-md font-header hover:text-primary repeat hover:ring-primary mx-auto inline-block rounded px-6 py-3 font-light text-white uppercase drop-shadow transition hover:bg-white hover:ring-2"
|
||||
><Icon
|
||||
name="mdi:loading"
|
||||
id="loading-icon"
|
||||
class="mr-4 hidden animate-spin"
|
||||
/><span id="response-message" class="inline">Send message</span></button
|
||||
>
|
||||
</form>
|
||||
|
||||
<script define:vars={{ ID }}>
|
||||
function init() {
|
||||
const form = document.getElementById(ID);
|
||||
const responseMsg = form.querySelector("#response-message");
|
||||
const button = form.querySelector("#submit");
|
||||
const loadingIcon = form.querySelector("#loading-icon");
|
||||
|
||||
form.addEventListener("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
loadingIcon.classList.replace("hidden", "inline");
|
||||
responseMsg.textContent = "Sending...";
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch("/api/email/contact/", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
responseMsg.textContent = data.message;
|
||||
form.reset();
|
||||
} else {
|
||||
responseMsg.textContent = data.message;
|
||||
}
|
||||
} catch (err) {
|
||||
responseMsg.textContent =
|
||||
"An error occurred. Please refresh the page and try again.";
|
||||
}
|
||||
|
||||
loadingIcon.classList.replace("inline", "hidden");
|
||||
|
||||
setTimeout(() => {
|
||||
responseMsg.textContent = "Send message";
|
||||
button.disabled = false;
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
document.addEventListener("astro:after-swap", init);
|
||||
</script>
|
@@ -2,10 +2,8 @@
|
||||
// TODO: Handle author, etc., via inclusion of some sort of tag in the quote, either frontmatter tags or simply [author=Person Name]
|
||||
---
|
||||
|
||||
<div class="flex w-full items-center">
|
||||
<blockquote
|
||||
class="border-primary mx-auto my-8 max-w-xl border-l-6 py-2 pl-4 italic"
|
||||
>
|
||||
<div class="my-4 flex w-full items-center px-2 md:px-8">
|
||||
<blockquote class="border-primary mx-auto border-l-6 pl-4 italic">
|
||||
<p class="text-lg font-light"><slot /></p>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
@@ -54,7 +54,7 @@ const initialQueue = await Promise.all(
|
||||
</div>
|
||||
<div
|
||||
id="player:time:progress:played"
|
||||
class="bg-primary absolute top-0 left-0 m-0 h-full p-0"
|
||||
class="from-primary to-primary-50 absolute top-0 left-0 m-0 h-full bg-gradient-to-r p-0"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,7 +342,7 @@ const initialQueue = await Promise.all(
|
||||
return { bufferedSeconds: 0, bufferedPercent: 0 };
|
||||
}
|
||||
|
||||
addFromTrackCard(track) {
|
||||
addToQueue(track, immediatePlay = false) {
|
||||
var current = this.playlist[this.index];
|
||||
|
||||
if (
|
||||
@@ -356,7 +356,7 @@ const initialQueue = await Promise.all(
|
||||
this.play();
|
||||
}
|
||||
} else {
|
||||
this.queue(track, true);
|
||||
this.queue(track, immediatePlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -14,6 +14,10 @@ import { getAudioDurationInSeconds } from "get-audio-duration";
|
||||
import { join } from "path";
|
||||
const fullFilePath = join(process.cwd(), "public", track.data.src);
|
||||
const duration = await getAudioDurationInSeconds(fullFilePath);
|
||||
|
||||
if (!track.data.card) {
|
||||
throw new Error(`Track with ID "${track.id}" is missing card data.`);
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
@@ -21,7 +25,7 @@ const duration = await getAudioDurationInSeconds(fullFilePath);
|
||||
"relative flex min-h-42 flex-auto hover:cursor-pointer",
|
||||
index < 2 ? "lg:w-1/2" : "sm:w-1/2 md:w-1/3 lg:w-1/4"
|
||||
]}
|
||||
onclick=`window.player.addFromTrackCard({ src: "${track.data.src}", "metadata": ${JSON.stringify(track.data.metadata)}, "duration": "${duration}" })`
|
||||
onclick=`window.player.addToQueue({ src: "${track.data.src}", "metadata": ${JSON.stringify(track.data.metadata)}, "duration": "${duration}" }, true);`
|
||||
>
|
||||
<Image
|
||||
src={track.data.card.image.src}
|
||||
|
34
src/components/TrackInline.astro
Normal file
34
src/components/TrackInline.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import { getTrackByID } from "@lib/utils";
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { id, class: className, ...attrs } = Astro.props as Props;
|
||||
|
||||
const track = getTrackByID(id);
|
||||
|
||||
if (!track) {
|
||||
throw new Error(`Track with ID "${id}" not found.`);
|
||||
}
|
||||
|
||||
import { getAudioDurationInSeconds } from "get-audio-duration";
|
||||
import { join } from "path";
|
||||
const fullFilePath = join(process.cwd(), "public", track.data.src);
|
||||
const duration = await getAudioDurationInSeconds(fullFilePath);
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
"text-primary flex items-center transition hover:cursor-pointer hover:text-gray-300",
|
||||
className
|
||||
]}
|
||||
onclick=`window.player.addToQueue({ src: "${track.data.src}", "metadata": ${JSON.stringify(track.data.metadata)}, "duration": "${duration}" }, true);`
|
||||
{...attrs}
|
||||
>
|
||||
<Icon name="fa7-solid:play-circle" class="mr-2 inline-block" />
|
||||
<span class="inline-block">{track.data.metadata.title}</span>
|
||||
</div>
|
@@ -68,23 +68,30 @@ const tracks = defineCollection({
|
||||
subtitle: z.string().optional(),
|
||||
extra: z.string().optional(),
|
||||
artist: z.string().optional().default("Nathan Cummins"),
|
||||
artwork: z.preprocess((val) => `/src/assets/img/tracks/${val}`, image())
|
||||
artwork: z
|
||||
.preprocess((val) => `/src/assets/img/tracks/${val}`, image())
|
||||
.optional()
|
||||
}),
|
||||
autoQueue: z
|
||||
.object({
|
||||
order: z.number()
|
||||
})
|
||||
.optional(),
|
||||
card: z.object({
|
||||
image: z.object({
|
||||
src: z.preprocess((val) => `/src/assets/img/tracks/${val}`, image()),
|
||||
alt: z.string()
|
||||
}),
|
||||
text: z.object({
|
||||
primary: z.string(),
|
||||
secondary: z.string()
|
||||
card: z
|
||||
.object({
|
||||
image: z.object({
|
||||
src: z.preprocess(
|
||||
(val) => `/src/assets/img/tracks/${val}`,
|
||||
image()
|
||||
),
|
||||
alt: z.string()
|
||||
}),
|
||||
text: z.object({
|
||||
primary: z.string(),
|
||||
secondary: z.string()
|
||||
})
|
||||
})
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
});
|
||||
|
||||
|
13
src/env.d.ts
vendored
13
src/env.d.ts
vendored
@@ -12,3 +12,16 @@ interface Window {
|
||||
player: any;
|
||||
playerIsInitialised: boolean;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly EMAIL_USER: string;
|
||||
readonly EMAIL_PASS: string;
|
||||
readonly EMAIL_PORT: number;
|
||||
readonly EMAIL_SECURE: "true" | "false";
|
||||
readonly EMAIL_HOST: string;
|
||||
readonly EMAIL_FROM: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
46
src/lib/email.ts
Normal file
46
src/lib/email.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createTransport } from "nodemailer";
|
||||
import SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
|
||||
import type { Options } from "nodemailer/lib/mailer";
|
||||
|
||||
interface Email {
|
||||
email: string;
|
||||
message: string;
|
||||
html?: string;
|
||||
subject: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function constructEmailHtml(props: Email) {
|
||||
return `<div>${props.message}</div>`;
|
||||
}
|
||||
|
||||
export default async function (props: Email) {
|
||||
const transporter = createTransport({
|
||||
host: process.env.EMAIL_HOST || import.meta.env.EMAIL_HOST,
|
||||
port: process.env.EMAIL_PORT || import.meta.env.EMAIL_PORT,
|
||||
secure:
|
||||
process.env.EMAIL_SECURE === "true" ||
|
||||
import.meta.env.EMAIL_SECURE === "true",
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER || import.meta.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS || import.meta.env.EMAIL_PASS
|
||||
}
|
||||
} as SMTPTransport.Options);
|
||||
|
||||
if (!props.html) {
|
||||
props.html = constructEmailHtml(props);
|
||||
}
|
||||
|
||||
const message: Options = {
|
||||
from: `${props.name} (Website) <${process.env.EMAIL_FROM || import.meta.env.EMAIL_FROM}>`,
|
||||
to: "nathan@nathancummins.com.au",
|
||||
replyTo: props.email,
|
||||
subject: props.subject,
|
||||
text: props.message,
|
||||
html: props.html
|
||||
};
|
||||
|
||||
const info = await transporter.sendMail(message);
|
||||
return info;
|
||||
}
|
@@ -1,9 +1,11 @@
|
||||
import type { ImageMetadata } from "astro";
|
||||
import { getImage } from "astro:assets";
|
||||
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { getCollection, type CollectionEntry } from "astro:content";
|
||||
type Project = CollectionEntry<"projects">;
|
||||
|
||||
const tracks = await getCollection("tracks");
|
||||
|
||||
const allProjectOtherImages = import.meta.glob<{ default: ImageMetadata }>(
|
||||
"/src/assets/img/projects/**/*",
|
||||
{ eager: true }
|
||||
@@ -137,3 +139,13 @@ export function slugify(input: string): string {
|
||||
slug = slug.replace(/^-+|-+$/g, "");
|
||||
return slug;
|
||||
}
|
||||
|
||||
export function getTrackByID(id: string): CollectionEntry<"tracks"> | null {
|
||||
for (const track of tracks) {
|
||||
if (track.id === id) {
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
52
src/pages/api/email/contact.ts
Normal file
52
src/pages/api/email/contact.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import sendEmail from "@lib/email";
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const email = data.get("email") as string | null;
|
||||
const subject = data.get("subject") as string | null;
|
||||
const message = data.get("message") as string | null;
|
||||
const name = data.get("name") as string | null;
|
||||
|
||||
if (!email || !subject || !message || !name) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Required field missing." }),
|
||||
{
|
||||
status: 400,
|
||||
statusText: "Missing required fields."
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const html = `<h1>Message Received via Contact Form</h1><br>
|
||||
<b>Name</b>: ${name}<br>
|
||||
<b>Email</b>: ${email}<br>
|
||||
<b>Message</b>: ${message}`;
|
||||
|
||||
try {
|
||||
await sendEmail({ email, subject, message, name, html });
|
||||
} catch (error) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: `Message failed to send with an internal server error. Please contact me directly via a method below.`
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
statusText: `Message failed to send with an internal server error. Please contact me directly via a method below.`
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message:
|
||||
"Thanks for getting in touch! I will try to get back to you as soon as I can."
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK"
|
||||
}
|
||||
);
|
||||
};
|
@@ -1,16 +1,25 @@
|
||||
---
|
||||
import ContactForm from "@components/ContactForm.astro";
|
||||
import Paragraph from "@components/Paragraph.astro";
|
||||
import SectionTitle from "@components/SectionTitle.astro";
|
||||
import TextLink from "@components/TextLink.astro";
|
||||
import MainLayout from "@layouts/MainLayout.astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import Paragraph from "../components/Paragraph.astro";
|
||||
import SectionTitle from "../components/SectionTitle.astro";
|
||||
import TextLink from "../components/TextLink.astro";
|
||||
import MainLayout from "../layouts/MainLayout.astro";
|
||||
---
|
||||
|
||||
<MainLayout title="Contact">
|
||||
<div class="w-full">
|
||||
<section id="contact" class="bg-white dark:bg-gray-950">
|
||||
<section id="contact" class="bg-primary dark:bg-gray-800">
|
||||
<div class="mx-auto max-w-4xl px-8 py-16 text-center">
|
||||
<SectionTitle>Let's get in touch!</SectionTitle>
|
||||
<SectionTitle class="text-white" lineColour="text-white"
|
||||
>Let's get in touch!</SectionTitle
|
||||
>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</section>
|
||||
<section id="details" class="bg-white dark:bg-gray-950">
|
||||
<div class="mx-auto max-w-4xl px-8 py-16 text-center">
|
||||
<SectionTitle>Other ways to reach me</SectionTitle>
|
||||
<div class="grid grid-cols-1 text-lg md:grid-cols-2">
|
||||
<div class="col-span-1 flex flex-col items-center justify-center p-4">
|
||||
<Icon name="fa7-solid:at" class="text-4xl" />
|
||||
|
@@ -21,7 +21,8 @@ export async function getStaticPaths() {
|
||||
|
||||
return projects.map((project) => ({
|
||||
params: {
|
||||
slug: `${slugify(project.data.type)}/${slugify(project.data.slug)}`
|
||||
type: slugify(project.data.type),
|
||||
slug: slugify(project.data.slug)
|
||||
},
|
||||
props: { project }
|
||||
}));
|
@@ -5,6 +5,18 @@
|
||||
|
||||
@theme {
|
||||
--color-primary: #ff2d00;
|
||||
--color-primary-50: oklch(1 0.244 31.9);
|
||||
--color-primary-100: oklch(0.91 0.244 31.9);
|
||||
--color-primary-200: oklch(0.84 0.244 31.9);
|
||||
--color-primary-300: oklch(0.77 0.244 31.9);
|
||||
--color-primary-400: oklch(0.7 0.244 31.9);
|
||||
--color-primary-500: #ff2d00;
|
||||
--color-primary-600: oklch(0.55 0.244 31.9);
|
||||
--color-primary-700: oklch(0.45 0.244 31.9);
|
||||
--color-primary-800: oklch(0.35 0.244 31.9);
|
||||
--color-primary-900: oklch(0.18 0.244 31.9);
|
||||
--color-primary-950: oklch(0.09 0.244 31.9);
|
||||
|
||||
--font-header: "Lutschine Bold", sans-serif;
|
||||
--font-header-alt: "Lutschine Regular", sans-serif;
|
||||
--font-body: "Roboto", sans-serif;
|
||||
|
Reference in New Issue
Block a user