Compare commits

...

18 Commits

Author SHA1 Message Date
85234ce522 Sync package-lock.json as well
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 16m0s
2025-09-01 10:34:07 +09:30
f743618b5f Don't delete node_modules to hopefully speed up remote deployment 2025-09-01 10:19:50 +09:30
ce6dffbaf5 Better handle project URLs
Some checks failed
Build and Deploy to Web Server / deploy (push) Has been cancelled
2025-08-29 15:02:25 +09:30
d0c1319258 Why did I do package-lock and not package?
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 19m33s
2025-08-29 14:12:06 +09:30
7c8ab1c988 Better handle contact form JS
Some checks failed
Build and Deploy to Web Server / deploy (push) Failing after 11m57s
2025-08-29 12:52:22 +09:30
52362b748b Add SSR deployment method
Some checks are pending
Build and Deploy to Web Server / deploy (push) Has started running
2025-08-29 12:43:07 +09:30
e46a4a560a Add SSR contact form
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 18m1s
2025-08-29 11:36:58 +09:30
1c3b37a70c Merge branch 'main' of git.nathancummins.com.au:encie22/portfolio
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 16m51s
2025-08-28 16:06:56 +09:30
ca15fa782d Expand DNDT, add TrackInline, more tracks 2025-08-28 16:03:29 +09:30
ba8eec5879 Add colour options for primary colour 2025-08-28 16:02:32 +09:30
0802b69c56 Add Y space to tokens on projects page
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 15m56s
2025-08-26 13:45:39 +09:30
c74299c2b1 Add links and key figures to project pages
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 16m35s
2025-08-25 17:49:45 +09:30
982589927f Allow filtering of projects
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 15m24s
2025-08-25 11:18:39 +09:30
025914083c Add MWM, fix max-h
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 16m13s
2025-08-25 09:45:23 +09:30
69a17c48f3 Fix about page black padding
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 15m55s
2025-08-22 17:09:25 +09:30
352a9c18f5 Flesh out about a little more, still temporary
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 15m49s
2025-08-22 16:42:08 +09:30
51723e0c44 Small correction to old bio
All checks were successful
Build and Deploy to Web Server / deploy (push) Successful in 15m46s
2025-08-22 10:53:41 +09:30
17f185f953 Add automatic development webserver launch 2025-08-22 10:42:06 +09:30
39 changed files with 2410 additions and 75 deletions

View File

@@ -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'

View File

@@ -17,5 +17,8 @@
],
"css.customData": [
".vscode/tailwind.json"
]
],
"[mdx]": {
"editor.wordWrap": "on"
}
}

18
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run development webserver on startup",
"type": "shell",
"command": "npm run dev",
"windows": {
"command": "npm run dev"
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"runOptions": { "runOn": "folderOpen" }
}
]
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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.
![The axolotl, which I affectionately name Frank after the axolotl of a friend](/src/assets/img/projects/dungeons-and-dining-tables/nathan-cummins-dungeons-and-dining-tables-13.jpg)
![The axolotl, which I affectionately name Frank after the axolotl of a friend. Screenshot from a development build.](/src/assets/img/projects/dungeons-and-dining-tables/nathan-cummins-dungeons-and-dining-tables-13.jpg)
# 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>
![The decoration mode. Screenshot from a development build.](/src/assets/img/projects/dungeons-and-dining-tables/nathan-cummins-dungeons-and-dining-tables-4.jpeg)
# 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>

Binary file not shown.

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

Binary file not shown.

BIN
public/audio/dungeons-and-dining-tables/The_Couch_Troll.mp3 (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,4 @@
Nathan Cummins is an award-winning composer, orchestrator, conductor, and performer, based in Adelaide, South Australia.
Nathan Cummins is an award-winning composer, orchestrator, conductor, and sound designer based in Adelaide, South Australia.
He specialises in crafted orchestration for video games, film, and television with a focus on live and vibrant textures across multiple genres and mediums.
His original works and orchestrations have featured prominently in media and in the concert hall, including performances by the Australian String Quartet, the Elder Conservatorium Wind Orchestra, and the Gold Coast Philharmonic Orchestra.
His orchestrations have been used in concerts and recording sessions throughout the world.

Binary file not shown.

Binary file not shown.

View 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"
}
}

View File

@@ -0,0 +1,8 @@
{
"src": "dungeons-and-dining-tables/Kindlerest.mp3",
"metadata": {
"title": "Kindlerest",
"subtitle": "Dungeons and Dining Tables",
"extra": "Synthetic Orchestra"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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>

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -14,9 +14,16 @@ interface Props {
project: CollectionEntry<"projects">;
textOn?: "left" | "right";
quality?: number;
class?: string;
}
const { project, textOn = "left", quality = "80" } = Astro.props;
const {
project,
textOn = "left",
quality = "80",
class: className,
...attrs
} = Astro.props;
const images = getAllProjectImages(project);
@@ -124,7 +131,10 @@ const projectHasBody = project.body && project.body.trim().length > 0;
const link = `/projects/${slugify(project.data.type)}/${slugify(project.data.slug)}/`;
---
<div class="grid grid-cols-1 text-left md:grid-cols-2">
<div
class:list={["grid grid-cols-1 text-left md:grid-cols-2", className]}
{...attrs}
>
<div
class:list={[
"order-1 flex flex-col items-start justify-center py-4",

View File

@@ -3,14 +3,18 @@ interface Props {
Tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
lineColour?: string;
lineColourDark?: string;
class?: string;
}
const {
Tag = "h1",
lineColour = "border-primary",
lineColourDark = "dark:text-primary"
lineColourDark = "dark:text-primary",
class: className
} = Astro.props;
---
<Tag class="font-header m-0 text-center text-3xl"><slot /></Tag>
<hr class={`mx-auto my-4 w-16 border-2 ${lineColour} ${lineColourDark}`} />
<div class={className}>
<Tag class="font-header m-0 text-center text-3xl"><slot /></Tag>
<hr class={`mx-auto my-4 w-16 border-2 ${lineColour} ${lineColourDark}`} />
</div>

View File

@@ -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}

View 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>

View File

@@ -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
View File

@@ -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
View 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;
}

View File

@@ -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;
}

View File

@@ -1,32 +1,176 @@
---
import aboutImage from "@assets/img/about.jpg";
import conductingImage from "@assets/img/about/nathan-cummins-composer-orchestrator-conductor-conducting-1.jpg";
import generalImage from "@assets/img/about/nathan-cummins-composer-orchestrator-conductor-general-1.png";
import H2 from "@components/MDX/H2.astro";
import Paragraph from "@components/Paragraph.astro";
import SectionTitle from "@components/SectionTitle.astro";
import TextLink from "@components/TextLink.astro";
import site from "@data/site";
import MainLayout from "@layouts/MainLayout.astro";
import { Image } from "astro:assets";
import { Content } from "../assets/bios/about.mdx";
import aboutImage from "../assets/img/about.jpg";
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="About">
<div class="w-full">
<section id="about" class="bg-white dark:bg-gray-950">
<div class="mx-auto max-w-4xl px-8 py-16 text-justify sm:text-center">
<SectionTitle>About</SectionTitle>
<Content components={{ p: Paragraph, a: TextLink }} />
<div class="w-full bg-white py-16 dark:bg-gray-950">
<section id="about">
<div class="mx-auto max-w-4xl px-8 text-left">
<Image
src={aboutImage}
alt="A photo of Nathan conducting the Woodville Concert Band"
class="mx-auto mt-8 size-96 rounded-full object-cover drop-shadow-lg/75 transition ease-in-out md:hover:scale-150 md:hover:shadow-xl/50"
class="mx-auto mb-8 size-96 rounded-full object-cover drop-shadow-lg/75 transition ease-in-out md:hover:scale-150 md:hover:shadow-xl/50"
transition:name="aboutImage"
/>
<span class="block w-full text-center"
><a
href="/contact/"
class="bg-primary text-md font-header hover:text-primary repeat hover:ring-primary mx-auto mt-8 inline-block rounded object-center px-6 py-3 font-light text-white uppercase drop-shadow-lg/75 transition hover:bg-white hover:ring-2"
>
Get in touch!
</a></span
<SectionTitle class="py-8">Dr Nathan Cummins</SectionTitle>
<H2>{site.tagline}</H2>
<Paragraph
>Nathan Cummins is a composer, orchestrator, conductor, and sound
designer based in Adelaide, South Australia. He holds a PhD in Sonic
Arts and Music Composition from the Elder Conservatorium of Music, and
his work spans concert performance, video games, film scores, and
interactive media.</Paragraph
>
<Paragraph
><Image
src={generalImage}
alt="Nathan conducting, asking all members of the ensemble to stand for applause."
class="mx-auto max-h-64 w-auto object-cover pb-8 md:float-right md:h-94 md:pl-8"
layout="full-width"
fit="cover"
height={256}
/>With a practice grounded in orchestral craft and enriched by
technological innovation, Nathan creates music and sound that is
vibrant, immersive, and deeply connected to its medium. His work
ranges from symphonic writing and choral music through to jazz and
electronic sound worlds.</Paragraph
>
<Paragraph
>Nathan is passionate about the power of music and sound to shape
stories, connect communities, and transform interactive experiences.
Whether collaborating with game developers, film directors,
choreographers, or fellow composers, he brings a combination of
artistry, innovation, and pragmatism that makes him a versatile and
distinctive voice in contemporary audio.</Paragraph
>
</div>
</section>
<section id="composition">
<div class="mx-auto max-w-4xl px-8 text-left">
<H2>Composition</H2>
<Paragraph
>At the heart of Nathan's career is composition. His concert works
have been performed by ensembles across Australia, and his music for
video games is recognised for its distinctive voice and adaptability.</Paragraph
>
<Paragraph>Recent projects include:</Paragraph>
<ul class="list-disc pl-4">
<li>
<span class="font-bold"
><TextLink
href="/projects/video-game/dungeons-and-dining-tables/"
>
Dungeons and Dining Tables</TextLink
>:</span
> a cosy, highly orchestral score full of warmth and colour.
</li>
<li>
<span class="font-bold">Roc's Odyssey:</span> an edgy, symphonic metal
soundtrack blending excitement with orchestral grandeur.
</li>
<li>
<span class="font-bold">The Lion and the Mouse:</span> a narrative work
for orchestra (or wind orchestra) that aims to teach children about music
and the instruments of the orchestra.
</li>
</ul>
<Paragraph
>Nathan thrives on tailoring music to story and gameplay, crafting
scores that not only support but heighten the emotional and narrative
experience.</Paragraph
>
</div>
</section>
<section id="orchestration">
<div class="mx-auto max-w-4xl px-8 text-left">
<H2>Orchestration</H2>
<Paragraph
>As an orchestrator, Nathan is highly sought after for his ability to
bring clarity, energy, and colour to music across genres. His
orchestrations have been performed and recorded internationally,
ranging from intimate chamber textures to full symphonic works.</Paragraph
>
<Paragraph
>He is also an expert in preparing scores and parts for recording
sessions, with a focus on legibility and efficiency. By translating
the composer's intent into clear, practical sheet music, Nathan helps
ensembles and composers save valuable time and money while ensuring
players can deliver their best performance.</Paragraph
>
</div>
</section>
<section id="Conducting">
<div class="mx-auto max-w-4xl px-8 text-left">
<H2>Conducting</H2>
<Paragraph
>Nathan is a versatile conductor with specialisations in concert
performance, video game music, and studio recording. He is one of
Australias leading directors of concerts featuring video game music,
and is equally at home on the podium of a symphonic performance or in
the precision-driven environment of a recording session.</Paragraph
>
<Paragraph
><Image
src={conductingImage}
alt="Nathan conducting the Woodville Concert Band, smiling while standing in front of the ensemble with his arm outstretched."
class="mx-auto max-h-96 w-auto pb-8 md:float-left md:h-94 md:pr-8"
layout="full-width"
fit="cover"
height={384}
/>Highlights of his conducting work include <span class="italic"
>Music With Motion</span
>, a cross-media concert series tying the performance of video game
music with live video projection, and <span class="italic"
>Pixelated Symphonies</span
>, a landmark concert with the <TextLink
href="https://www.awo.org.au/">Adelaide Wind Orchestra</TextLink
> that featured 14 newly commissioned video game arrangements by Nathan
himself. His repertoire as a conductor spans contemporary media music,
classical works, and cross-genre collaborations, always with a focus on
clarity, energy, and connection with performers.</Paragraph
>
<Paragraph
>Nathan is also the Artistic Director of the <TextLink
href="https://www.woodvillecb.com.au/"
>Woodville Concert Band</TextLink
>, an active, diverse, and inclusive community ensemble of around 45
members. Under his direction, the band has become a cultural hub,
reflecting the motto “music played for the community, by the
community.” Through performances that engage 8,0009,000 community
members annually, the ensemble promotes music education, fosters
inclusivity, and provides a distinctive cultural offering that
supports local industries and artists. Nathans leadership has been
instrumental in shaping the bands vision of accessibility, culture,
and community over profit, while still maintaining high artistic
standards.</Paragraph
>
</div>
</section>
<section id="sound">
<div class="mx-auto max-w-4xl px-8 text-left">
<H2>Sound Design</H2>
<Paragraph
>In addition to his musical practices, Nathan is an active sound
designer for video games. His work is highly detailed and
project-specific, creating soundscapes that respond dynamically to
gameplay and integrate with music.</Paragraph
>
<Paragraph
>What sets Nathan apart is his ability to bridge music, sound, and
technology. As a skilled software developer, he creates unique
in-engine solutions for audio, and has developed his own middleware
for FMOD that he implements across projects. This integrated approach
allows him to deliver innovative, seamless sound design that enhances
immersion.</Paragraph
>
</div>
</section>

View 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"
}
);
};

View File

@@ -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" />

View File

@@ -1,14 +1,19 @@
---
import { getCollection } from "astro:content";
import SectionTitle from "../components/SectionTitle.astro";
import MainLayout from "../layouts/MainLayout.astro";
import { slugify } from "@lib/utils";
import SectionTitle from "@components/SectionTitle.astro";
import MainLayout from "@layouts/MainLayout.astro";
import Token from "@components/Token.astro";
import ProjectCard from "@components/ProjectCard.astro";
const projects = (await getCollection("projects")).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
const types = [...new Set(projects.map((project) => project.data.type))];
---
<MainLayout title="Projects">
@@ -16,6 +21,22 @@ const projects = (await getCollection("projects")).sort(
<section id="projects" class="bg-white dark:bg-gray-950">
<div class="mx-auto max-w-4xl px-8 py-16 text-center">
<SectionTitle>Recent Projects</SectionTitle>
<div id="filter-tokens" class="my-8 space-y-2 space-x-2">
<Token
class="filter-token !text-primary ring-primary bg-white ring-2 hover:cursor-pointer"
data-type="all">All</Token
>
{
types.map((type) => (
<Token
class="filter-token hover:cursor-pointer"
data-type={slugify(type)}
>
{type}
</Token>
))
}
</div>
<div class="space-y-16 md:space-y-8">
{
projects.map((project, index) => {
@@ -23,6 +44,8 @@ const projects = (await getCollection("projects")).sort(
<ProjectCard
project={project}
textOn={index % 2 === 0 ? "left" : "right"}
data-type={slugify(project.data.type)}
class="project"
/>
);
})
@@ -32,3 +55,46 @@ const projects = (await getCollection("projects")).sort(
</section>
</div>
</MainLayout>
<script>
function setUpFilters() {
const tokensContainer = document.querySelector("#filter-tokens");
if (!tokensContainer) return;
const tokens =
tokensContainer.querySelectorAll<HTMLElement>(".filter-token");
const projects = document.querySelectorAll<HTMLElement>(".project");
tokensContainer.addEventListener("click", (e) => {
const token = (e.target as Element).closest<HTMLElement>(".filter-token");
if (!token) return;
tokens.forEach((t) =>
t.classList.remove(
"bg-white",
"!text-primary",
"ring-primary",
"ring-2"
)
);
token.classList.add(
"bg-white",
"!text-primary",
"ring-primary",
"ring-2"
);
const type = token.dataset.type;
projects.forEach((project) => {
const match = type === "all" || project.dataset.type === type;
project.classList.toggle("hidden", !match);
});
});
}
setUpFilters();
document.addEventListener("astro:page-load", setUpFilters);
</script>

View File

@@ -2,6 +2,9 @@
import ImageCarousel from "@components/ImageCarousel.astro";
import MainLayout from "@layouts/MainLayout.astro";
import TextLink from "@components/TextLink.astro";
import { Icon } from "astro-icon/components";
import { getCollection, render } from "astro:content";
import {
@@ -9,7 +12,7 @@ import {
getFullExternalURLOfImage,
getProjectHero,
slugify
} from "@/lib/utils";
} from "@lib/utils";
export async function getStaticPaths() {
const projects = await getCollection("projects", ({ body }) => {
@@ -18,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 }
}));
@@ -71,15 +75,56 @@ import P from "@components/MDX/P.astro";
><div
class="absolute inset-0 flex h-full w-full items-center justify-center px-8 text-center"
>
<div class="block">
<h1 class="font-header text-5xl uppercase text-shadow-lg/75">
<div class="block space-y-4">
<h1
class="font-header text-4xl uppercase text-shadow-lg/75 md:text-6xl"
>
{project.data.title}
</h1>
<div class="pt-2 text-lg font-bold">
<div class="text-lg font-bold md:text-xl">
<span>{project.data.role}</span> | <span
>{project.data.type}</span
>
</div>
{
project.data.keyFigure && project.data.keyFigure.length > 0 && (
<div class="text-md mt-8">
{project.data.keyFigure.map((figure) => {
return (
<div>
<span class="font-bold">{figure.title}:</span>
<span>
{figure.href && (
<TextLink href={figure.href}>
{figure.name}
</TextLink>
)}
{!figure.href && figure.name}
</span>
</div>
);
})}
</div>
)
}
{
project.data.externalLinks !== undefined && (
<span class="relative order-3 mx-auto flex w-full items-center justify-center space-x-4 text-4xl">
{project.data.externalLinks.map((link) => (
<TextLink
href={link.href}
includeExternalLinkIcon={false}
aria-label={link.name}
>
<Icon
name={link.icon}
class="text-white transition hover:text-gray-300"
/>
</TextLink>
))}
</span>
)
}
</div>
</div></ImageCarousel
>

View File

@@ -17,7 +17,7 @@ const awards = (await getCollection("awards")).sort(
<SectionTitle>Awards and Recognition</SectionTitle>
<div class="space-y-8">
{
awards.map((award, index) => {
awards.map((award) => {
return <Award award={award} />;
})
}

View File

@@ -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;