Compare commits
35 Commits
69792d4faf
...
main
Author | SHA1 | Date | |
---|---|---|---|
85234ce522
|
|||
f743618b5f
|
|||
ce6dffbaf5
|
|||
d0c1319258
|
|||
7c8ab1c988
|
|||
52362b748b
|
|||
e46a4a560a
|
|||
1c3b37a70c
|
|||
ca15fa782d
|
|||
ba8eec5879
|
|||
0802b69c56
|
|||
c74299c2b1
|
|||
982589927f
|
|||
025914083c
|
|||
69a17c48f3
|
|||
352a9c18f5
|
|||
51723e0c44
|
|||
17f185f953
|
|||
7805a7dfa9
|
|||
41079e93c1
|
|||
a4423b18cf
|
|||
a37051afce
|
|||
e790264b99
|
|||
aa392b8122
|
|||
583e2ba10b
|
|||
e97c58a7a1
|
|||
ee5da382c9
|
|||
778490bed8
|
|||
1f45a74b2a
|
|||
63e0e1fd28
|
|||
e6ffefaa2e
|
|||
9445b3f2e3
|
|||
41a827d390
|
|||
c3dd168566
|
|||
b2c89275ec
|
6
.editorconfig
Normal file
6
.editorconfig
Normal file
@@ -0,0 +1,6 @@
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = crlf
|
@@ -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'
|
||||
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -4,7 +4,8 @@
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint"
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -17,5 +17,8 @@
|
||||
],
|
||||
"css.customData": [
|
||||
".vscode/tailwind.json"
|
||||
]
|
||||
],
|
||||
"[mdx]": {
|
||||
"editor.wordWrap": "on"
|
||||
}
|
||||
}
|
18
.vscode/tasks.json
vendored
Normal file
18
.vscode/tasks.json
vendored
Normal 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" }
|
||||
}
|
||||
]
|
||||
}
|
40
LICENSE
Normal file
40
LICENSE
Normal file
@@ -0,0 +1,40 @@
|
||||
This repository contains both software source code and other creative works.
|
||||
|
||||
## Code licence
|
||||
|
||||
All software source code in this repository is licensed under the MIT License
|
||||
below, unless otherwise indicated in a specific file's header.
|
||||
|
||||
## MIT License
|
||||
|
||||
Copyright (c) 2025 Nathan Cummins
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Non-code content licence
|
||||
|
||||
All non-code content in this repository — including, but not limited to, music
|
||||
(including melodies, harmonies, arrangements, lyrics, sound recordings, and
|
||||
other musical elements), audio recordings, images, photographs, written
|
||||
biographies, descriptive texts, and project documentation — is copyright © 2025
|
||||
Nathan Cummins, all rights reserved, unless otherwise stated in the file itself.
|
||||
|
||||
Such non-code content may not be copied, modified, distributed, or otherwise
|
||||
used without prior written permission from Nathan Cummins, except where
|
||||
explicitly provided under a separate licence or allowed by law.
|
16
README.md
16
README.md
@@ -2,17 +2,19 @@
|
||||
|
||||
## [nathancummins.com.au](https://www.nathancummins.com.au)
|
||||
|
||||
This repo contains the source for my personal portfolio website at [nathancummins.com.au](https://www.nathancummins.com.au) that features projects bios, skills, music, and projects that I have worked on.
|
||||
This repo contains the source and creative materials for my personal portfolio website at [nathancummins.com.au](https://www.nathancummins.com.au) which showcases my projects, skills, music, and biography.
|
||||
You are free to use any code in this repository under the conditions of the license as described below and in the [LICENSE file](LICENSE).
|
||||
|
||||
Thanks for checking it out!
|
||||
|
||||
## TL;DR
|
||||
|
||||
Please feel free to fork, clone, modify, or otherwise make use of any source code in this website for your own projects.
|
||||
This does not include all copyrighted materials (or any materials that are otherwise intellectual property), including audio files, imagery, and some text relating especially relating to projects or biographical information.
|
||||
I kindly request that you refrain from obtaining any of these materials via any method.
|
||||
- **Code:** All source code in this repository is licensed under the [MIT License](LICENSE). You are welcome to fork, clone, modify, or otherwise use the code in your own projects, provided you give appropriate credit.
|
||||
- **Non-code content:** All other materials — including music and audio (melodies, harmonies, arrangements, lyrics, sound recordings, and other musical elements and audio), images, photographs, written biographies, project descriptions, and any other creative works — are copyright © 2025 Nathan Cummins, **all rights reserved**. These may not be copied, modified, distributed, or otherwise used without prior written permission.
|
||||
|
||||
If you use anything from this repository I would appreciate proper credit by linking back to my [website](https://www.nathancummins.com.au) or at least by including my name and email address ([nathan@nathancummins.com.au](https://www.nathancummins.com.au)).
|
||||
I'd also love to know how you have made use of it - reach out and let me know and I'd love to see what you've done!
|
||||
If you use the code, I would appreciate proper credit by linking back to my [website](https://www.nathancummins.com.au) or including my name and email ([nathan@nathancummins.com.au](https://www.nathancummins.com.au)).
|
||||
|
||||
I’d also love to see what you've made — feel free to get in touch!
|
||||
|
||||
## Built With
|
||||
|
||||
@@ -47,7 +49,7 @@ This website was built primarily with the following technologies:
|
||||
To build, as with most Astro projects, simply run `npm run build`, which currently outputs the built website to the `dist` folder.
|
||||
Eventually, this website will automatically deploy via my custom-built deployment system and self-hosted server environment (todo!).
|
||||
|
||||
### Show your support
|
||||
## Show your support
|
||||
|
||||
You sharing my website is all the support I need.
|
||||
However, if you'd like to support me in any other way, please feel free to get in touch!
|
||||
|
@@ -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",
|
||||
|
@@ -3,7 +3,14 @@ title: Awoken
|
||||
role: Principal Orchestrator
|
||||
type: Feature Film
|
||||
date: 2019-07-01
|
||||
slug: awoken
|
||||
description: A young medical student attempts to cure her brother from a terminal sleep illness, called fatal familial insomnia, where you are unable to sleep until you die. On her quest to help him, a more sinister reason for his condition is revealed.
|
||||
keyFigure:
|
||||
- title: Composed by
|
||||
name: Christopher Larkin
|
||||
href: https://composerlarkin.com
|
||||
- title: Directed by
|
||||
name: Daniel J. Phillips
|
||||
images:
|
||||
{
|
||||
"hero":
|
@@ -3,7 +3,14 @@ title: Barbecue
|
||||
role: Additional Orchestration
|
||||
type: Feature Film
|
||||
date: 2017-07-01
|
||||
slug: barbecue
|
||||
description: The rituals, stories and traditions surrounding the process of cooking meat over an open flame are shared by numerous cultures around the world.
|
||||
keyFigure:
|
||||
- title: Composed by
|
||||
name: Christopher Larkin
|
||||
href: https://composerlarkin.com
|
||||
- title: Directed by
|
||||
name: Matthew Salleh
|
||||
images:
|
||||
{
|
||||
"hero":
|
@@ -3,6 +3,10 @@ title: Down To Earth
|
||||
role: Composer
|
||||
type: Short Film
|
||||
date: 2020-07-01
|
||||
slug: down-to-earth
|
||||
keyFigure:
|
||||
- title: Directed by
|
||||
name: Nick Crowhurst
|
||||
description: Three young misfits venture into the Australian outback to find what they believe is a meteorite that has crash landed near their country town, only to discover it's something far more mysterious.
|
||||
images:
|
||||
{
|
91
projects/dungeons-and-dining-tables.mdx
Normal file
91
projects/dungeons-and-dining-tables.mdx
Normal file
@@ -0,0 +1,91 @@
|
||||
---
|
||||
title: Dungeons and Dining Tables
|
||||
role: Composer and Sound Designer
|
||||
type: Video Game
|
||||
date: 2026-07-01
|
||||
slug: dungeons-and-dining-tables
|
||||
ongoing: true
|
||||
description: "Delve dungeons to collect rare furniture, take that furniture back to your home to decorate and the cosier your house is, the higher your stats! 🪑🏡⚔️ Go forth and drive the grumpiness from the land: Your perfect home is just a dungeon away! Oh, and you play as an Axolotl 🥳"
|
||||
keyFigure:
|
||||
- title: Developed by
|
||||
name: Catalyst Games
|
||||
frontPage:
|
||||
order: 1
|
||||
images:
|
||||
{
|
||||
"hero":
|
||||
{
|
||||
"src": "nathan-cummins-dungeons-and-dining-tables-1.jpg",
|
||||
"alt": "The Dungeons and Dining Tables main character, an axolotl, looking over a valley."
|
||||
},
|
||||
"other": "dungeons-and-dining-tables"
|
||||
}
|
||||
externalLinks:
|
||||
[
|
||||
{ "name": "Website", "href": "https://dndt.link", "icon": "mdi:web" },
|
||||
{
|
||||
"name": "Steam",
|
||||
"href": "https://dndt.link/steam",
|
||||
"icon": "fa7-brands:steam"
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
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 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>
|
@@ -3,7 +3,13 @@ title: Ella
|
||||
role: Composer
|
||||
type: Short Film
|
||||
date: 2013-07-01
|
||||
slug: ella
|
||||
description: When Ella learns that her sister Gracie is sick, Ella must turn to the stars to help heal her sister.
|
||||
keyFigure:
|
||||
- title: Directed by
|
||||
name: Jess Cahill
|
||||
- title: Winner of
|
||||
name: NASA Humans In Space Art Challenge, viewed in the International Space Station
|
||||
images:
|
||||
{
|
||||
"hero":
|
@@ -3,7 +3,12 @@ title: Frontier Quest
|
||||
role: Composer and Sound Designer
|
||||
type: Video Game
|
||||
date: 2021-02-01
|
||||
slug: frontier-quest
|
||||
description: Rebuild a frontier town and become acquainted with its community in puzzle-RPG Frontier Quest! Explore the frontier, collect materials and defeat monsters with the right equipment in order to restore the town and uncover the secrets of the frontier.
|
||||
keyFigure:
|
||||
- title: Developed by
|
||||
name: Stellar Advent
|
||||
href: https://stellaradvent.com/
|
||||
images:
|
||||
{
|
||||
"hero":
|
@@ -3,7 +3,12 @@ title: Lucie's Potager
|
||||
role: Composer and Sound Designer
|
||||
type: Video Game
|
||||
date: 2023-02-01
|
||||
slug: lucies-potager
|
||||
description: Farm and sell exotic plants with Lucie in this shopkeeping RPG!
|
||||
keyFigure:
|
||||
- title: Developed by
|
||||
name: Stellar Advent
|
||||
href: https://stellaradvent.com/
|
||||
images:
|
||||
{
|
||||
"hero":
|
@@ -1,9 +1,14 @@
|
||||
---
|
||||
title: Meowing Point
|
||||
role: Additional Music
|
||||
role: Additional Music (Single Track)
|
||||
type: Video Game
|
||||
date: 2023-10-01
|
||||
slug: meowing-point
|
||||
description: Point and meow. 3D hidden cat game. These creatures have been turned into stone. Find and save them all.
|
||||
keyFigure:
|
||||
- title: Developed by
|
||||
name: Francisco Martinez
|
||||
href: https://yofrancisco.com/
|
||||
images:
|
||||
{
|
||||
"hero":
|
@@ -3,6 +3,11 @@ title: "Music With Motion: Down Under"
|
||||
role: Conductor / Artistic Director
|
||||
type: Concert
|
||||
date: 2025-11-15
|
||||
slug: music-with-motion-down-under
|
||||
keyFigure:
|
||||
- title: Performed by
|
||||
name: Woodville Concert Band
|
||||
href: https://www.woodvillecb.com.au/2025/music-with-motion-down-under/
|
||||
description: "Music With Motion: Down Under is a dynamic celebration of Australia's video game soundtracks—performed live by a full wind orchestra and synchronised in real time with stunning visuals on the big screen. Premiering all-new orchestrations created especially for this event by award-winning Artistic Director Dr Nathan Cummins, the performance showcases music from games developed across the country, including several from right here in South Australia. Many of these games have never had their music performed live, and you'll be among the very first to hear it."
|
||||
frontPage: { "order": 2 }
|
||||
images:
|
21
projects/pixelated-symphonies.mdx
Normal file
21
projects/pixelated-symphonies.mdx
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Pixelated Symphonies
|
||||
role: Conductor / Artistic Director
|
||||
type: Concert
|
||||
date: 2025-03-09
|
||||
slug: pixelated-symphonies
|
||||
description: "A symphonic exploration of video game music with 14 commissioned arrangements. Experience the magic of these legendary symphonic video game soundtracks like never before! Join [the Adelaide Wind Orchestra] for an unforgettable concert of musical enchantment as video game industry insider and composer Dr Nathan Cummins and AWO take you on a wild ride through this iconic music. From the epic adventures of Super Mario to the heartwarming melodies of The Legend of Zelda, immerse yourself and be swept away by the brilliance of Australia's leading symphonic wind orchestra."
|
||||
frontPage: { "order": 3 }
|
||||
keyFigure:
|
||||
- title: Performed by
|
||||
name: Adelaide Wind Orchestra
|
||||
href: https://www.awo.org.au/
|
||||
images:
|
||||
{
|
||||
"hero":
|
||||
{
|
||||
"src": "nathan-cummins-pixelated-symphonies-1.jpg",
|
||||
"alt": "The key artwork for the concert Pixelated Symphonies."
|
||||
}
|
||||
}
|
||||
---
|
@@ -3,8 +3,15 @@ title: Roc's Odyssey
|
||||
role: Composer and Sound Designer
|
||||
type: Video Game
|
||||
date: 2025-02-21
|
||||
slug: rocs-odyssey
|
||||
description: Explore vast landscapes and get lost in a beautiful yet dangerous world in Roc's Odyssey. Filled with intense combat, strange allies, platforming, enemies, treasures, secrets, upgrades, friendships and lore this hand drawn action adventure will have you hooked!
|
||||
frontPage: { "order": 4 }
|
||||
keyFigure:
|
||||
- title: Developed by
|
||||
name: Sunshine Festival Studios
|
||||
href: https://www.sunshinefestivalstudios.com/
|
||||
- title: Director / Lead Developer
|
||||
name: Hari "Graig" Dimitriou
|
||||
images:
|
||||
{
|
||||
"hero":
|
@@ -3,7 +3,12 @@ title: Scooter
|
||||
role: Composer
|
||||
type: Short Film
|
||||
date: 2022-02-21
|
||||
slug: scooter
|
||||
description: An eccentric senior citizen who would like to visit his daughter one last time sets him off on an epic 250km journey from the outback to the city on his trusty scooter.
|
||||
keyFigure:
|
||||
- title: Directed by
|
||||
name: John deCaux / Six Foot Four Productions
|
||||
href: https://sixfootfourproductions.com.au
|
||||
images:
|
||||
{
|
||||
"hero":
|
@@ -3,7 +3,12 @@ title: "Submerged: Hidden Depths"
|
||||
role: Music Transcription & Preparation
|
||||
type: Video Game
|
||||
date: 2020-10-22
|
||||
slug: submerged-hidden-depths
|
||||
description: Boat, climb, interact and explore in the beautiful ruins of a sunken world.
|
||||
keyFigure:
|
||||
- title: Composed by
|
||||
name: Jeff van Dyck
|
||||
href: https://jeffvandyck.com/
|
||||
images:
|
||||
{
|
||||
"hero":
|
@@ -3,7 +3,12 @@ title: The Lion and the Mouse
|
||||
role: Composer
|
||||
type: Concert Music
|
||||
date: 2024-10-12
|
||||
slug: the-lion-and-the-mouse
|
||||
description: "A piece for narrator and orchestra (or wind orchestra) that aims to teach children about music and the instruments of the orchestra. Experience the beloved Aesop fable come to life, through wind instruments and storytelling designed to inspire the imagination of children and families."
|
||||
keyFigure:
|
||||
- title: Performed by
|
||||
name: Woodville Concert Band
|
||||
href: https://www.woodvillecb.com.au/2024/the-lion-and-the-mouse/
|
||||
frontPage: { "order": 5 }
|
||||
images:
|
||||
{
|
@@ -3,7 +3,12 @@ title: TOHU
|
||||
role: Brass Arrangement (Junkle), Orchestration (Circus)
|
||||
type: Video Game
|
||||
date: 2021-10-22
|
||||
slug: tohu
|
||||
description: Experience a brand new adventure game set amongst a world of weird and wonderful fish planets. Explore beautiful environments, solve intricate puzzles and discover the truth about a mysterious little girl and her mechanical alter-ego, Cubus.
|
||||
keyFigure:
|
||||
- title: Composed by
|
||||
name: Christopher Larkin
|
||||
href: https://composerlarkin.com
|
||||
images:
|
||||
{
|
||||
"hero":
|
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.
@@ -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.
|
||||
|
BIN
src/assets/img/about/nathan-cummins-composer-orchestrator-conductor-conducting-1.jpg
(Stored with Git LFS)
Normal file
BIN
src/assets/img/about/nathan-cummins-composer-orchestrator-conductor-conducting-1.jpg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src/assets/img/about/nathan-cummins-composer-orchestrator-conductor-general-1.png
(Stored with Git LFS)
Normal file
BIN
src/assets/img/about/nathan-cummins-composer-orchestrator-conductor-general-1.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@@ -1,27 +0,0 @@
|
||||
---
|
||||
title: Dungeons and Dining Tables
|
||||
role: Composer and Sound Designer
|
||||
type: Video Game
|
||||
date: 2026-07-01
|
||||
ongoing: true
|
||||
description: "Delve dungeons to collect rare furniture, take that furniture back to your home to decorate and the cosier your house is, the higher your stats! 🪑🏡⚔️ Go forth and drive the grumpiness from the land: Your perfect home is just a dungeon away! Oh, and you play as an Axolotl 🥳"
|
||||
frontPage: { "order": 1 }
|
||||
images:
|
||||
{
|
||||
"hero":
|
||||
{
|
||||
"src": "nathan-cummins-dungeons-and-dining-tables-1.jpg",
|
||||
"alt": "The Dungeons and Dining Tables main character, an axolotl, looking over a valley."
|
||||
},
|
||||
"other": "dungeons-and-dining-tables"
|
||||
}
|
||||
externalLinks:
|
||||
[
|
||||
{ "name": "Website", "href": "https://dndt.link", "icon": "mdi:web" },
|
||||
{
|
||||
"name": "Steam",
|
||||
"href": "https://dndt.link/steam",
|
||||
"icon": "fa7-brands:steam"
|
||||
}
|
||||
]
|
||||
---
|
@@ -1,16 +0,0 @@
|
||||
---
|
||||
title: Pixelated Symphonies
|
||||
role: Conductor / Artistic Director
|
||||
type: Concert
|
||||
date: 2025-03-09
|
||||
description: "Experience the magic of these legendary symphonic video game soundtracks like never before! Join [the Adelaide Wind Orchestra] for an unforgettable concert of musical enchantment as video game industry insider and composer Dr Nathan Cummins and AWO take you on a wild ride through this iconic music. From the epic adventures of Super Mario to the heartwarming melodies of The Legend of Zelda, immerse yourself and be swept away by the brilliance of Australia's leading symphonic wind orchestra."
|
||||
frontPage: { "order": 3 }
|
||||
images:
|
||||
{
|
||||
"hero":
|
||||
{
|
||||
"src": "nathan-cummins-pixelated-symphonies-1.jpg",
|
||||
"alt": "The key artwork for the concert Pixelated Symphonies."
|
||||
}
|
||||
}
|
||||
---
|
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"
|
||||
}
|
||||
}
|
@@ -2,7 +2,7 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { render } from "astro:content";
|
||||
|
||||
import Link from "@components/Link.astro";
|
||||
import TextLink from "@components/TextLink.astro";
|
||||
|
||||
interface Props {
|
||||
award: CollectionEntry<"awards">;
|
||||
@@ -21,7 +21,7 @@ const { Content } = await render(award);
|
||||
{award.data.giver} ({award.data.date.getFullYear()})
|
||||
</span>
|
||||
<dd class="pt-2">
|
||||
<Content components={{ a: Link }} />
|
||||
<Content components={{ a: TextLink }} />
|
||||
</dd>
|
||||
</dt>
|
||||
</dl>
|
||||
|
@@ -2,7 +2,7 @@
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { render } from "astro:content";
|
||||
|
||||
import Link from "@components/Link.astro";
|
||||
import A from "@components/MDX/A.astro";
|
||||
|
||||
interface Props {
|
||||
award: CollectionEntry<"awards">;
|
||||
@@ -23,7 +23,7 @@ const { Content } = await render(award);
|
||||
</div>
|
||||
<div class="bg-white p-2 text-left">
|
||||
<div class="text-sm text-gray-600">
|
||||
<Content components={{ a: Link }} />
|
||||
<Content components={{ a: A }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
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,11 +2,11 @@
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
import { socials } from "@assets/socials.ts";
|
||||
import Link from "@components/Link.astro";
|
||||
import TextLink from "@components/TextLink.astro";
|
||||
---
|
||||
|
||||
<footer
|
||||
class="footer -z-10 bg-gray-900 px-8 py-8 text-center text-white"
|
||||
class="footer bg-gray-900 px-8 py-8 text-center text-white"
|
||||
transition:name="footer"
|
||||
>
|
||||
<div class="socials p-2">
|
||||
@@ -32,21 +32,25 @@ import Link from "@components/Link.astro";
|
||||
</ul>
|
||||
</div>
|
||||
<p class="p-2 text-sm">
|
||||
Copyright © <span id="footer-year"></span> Nathan Cummins.
|
||||
Copyright © <span id="footer-year">{new Date().getFullYear()}</span> Nathan
|
||||
Cummins.
|
||||
</p>
|
||||
<div
|
||||
class="statements mx-auto grid max-w-4xl grid-cols-1 gap-x-16 lg:grid-cols-2"
|
||||
>
|
||||
<p class="col-span-1 p-2 text-xs italic">
|
||||
I support and believe in the values of open source software and
|
||||
communities. This website is made from scratch using the <Link
|
||||
href="https://astro.build/">Astro framework</Link
|
||||
communities. This website is made from scratch using the <TextLink
|
||||
href="https://astro.build/">Astro framework</TextLink
|
||||
>
|
||||
and with <Link href="https://tailwindcss.com/">Tailwind CSS</Link>. The
|
||||
full source code is available on my self-hosted <Link
|
||||
and with <TextLink href="https://tailwindcss.com/">Tailwind CSS</TextLink
|
||||
>. The full source code is available on my self-hosted <TextLink
|
||||
href="https://git.nathancummins.com.au/encie22/portfolio"
|
||||
>
|
||||
Gitea repository</Link
|
||||
Gitea repository</TextLink
|
||||
>. My website is hosted on my own self-hosted server environment. Find out
|
||||
more at <TextLink href="https://nathancummins.domains"
|
||||
>Nathan Cummins Domains</TextLink
|
||||
>.
|
||||
</p>
|
||||
<p class="col-span-1 p-2 text-xs italic">
|
||||
|
@@ -6,7 +6,7 @@ import { shuffleArray } from "@lib/utils";
|
||||
|
||||
interface Props {
|
||||
images: Array<ImageMetadata>;
|
||||
className: string;
|
||||
class: string;
|
||||
altText?: string | ((index: number) => string);
|
||||
interval?: number;
|
||||
backgroundColour?: string;
|
||||
@@ -23,7 +23,7 @@ interface Props {
|
||||
|
||||
const {
|
||||
images,
|
||||
className,
|
||||
class: className,
|
||||
altText = null,
|
||||
interval = 5000,
|
||||
backgroundColour = "bg-black",
|
||||
@@ -43,8 +43,8 @@ const IDs: string[] = [];
|
||||
const imagesArray = shuffle ? shuffleArray(images) : images;
|
||||
---
|
||||
|
||||
<div class:list={[className]}>
|
||||
<div class="relative h-full w-full">
|
||||
<div class:list={[className, "relative overflow-hidden"]}>
|
||||
<div>
|
||||
<div class:list={[backgroundColour, backgroundOpacity, "absolute inset-0"]}>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ const imagesArray = shuffle ? shuffleArray(images) : images;
|
||||
<Image
|
||||
data-id={`${ID}`}
|
||||
class:list={[
|
||||
"absolute h-full w-full object-cover object-center transition-opacity",
|
||||
"absolute !h-full w-full object-cover object-center transition-opacity",
|
||||
transitionStyle,
|
||||
transitionDuration,
|
||||
index > 0 ? "opacity-0" : "",
|
||||
@@ -71,7 +71,6 @@ const imagesArray = shuffle ? shuffleArray(images) : images;
|
||||
aria-hidden={index === 0 ? "false" : "true"}
|
||||
layout="full-width"
|
||||
fit="cover"
|
||||
style="height: 100% !important;"
|
||||
quality={quality}
|
||||
height={height === undefined ? undefined : height}
|
||||
/>
|
||||
@@ -86,9 +85,8 @@ const imagesArray = shuffle ? shuffleArray(images) : images;
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<script define:vars={{ imagesArray, interval, IDs }}>
|
||||
|
@@ -3,9 +3,9 @@ import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
interface Props extends HTMLAttributes<"a"> {}
|
||||
|
||||
import Link from "@components/Link.astro";
|
||||
import TextLink from "@components/TextLink.astro";
|
||||
|
||||
const { href, ...attrs } = Astro.props as Props;
|
||||
const { href, ...attrs } = Astro.props;
|
||||
---
|
||||
|
||||
<Link href={href} {...attrs}><slot /></Link>
|
||||
<TextLink href={href} {...attrs}><slot /></TextLink>
|
||||
|
@@ -1,7 +1,9 @@
|
||||
<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"
|
||||
>
|
||||
---
|
||||
// TODO: Handle author, etc., via inclusion of some sort of tag in the quote, either frontmatter tags or simply [author=Person Name]
|
||||
---
|
||||
|
||||
<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>
|
||||
|
@@ -17,95 +17,56 @@ if (dataMeta) {
|
||||
}
|
||||
|
||||
const languageIcons: Record<string, string> = {
|
||||
javascript: "mdi:language-javascript"
|
||||
javascript: "mdi:language-javascript",
|
||||
typescript: "mdi:language-typescript",
|
||||
csharp: "mdi:language-csharp",
|
||||
css: "mdi:language-css3",
|
||||
html: "mdi:language-html5",
|
||||
bash: "mdi:bash",
|
||||
astro: "simple-icons:astro"
|
||||
};
|
||||
|
||||
const languageIcon = languageIcons[props["data-language"]];
|
||||
const language = props["data-language"];
|
||||
|
||||
const languageIcon = languageIcons[language];
|
||||
|
||||
const title = meta.title;
|
||||
|
||||
const copyID = Math.random().toString(36);
|
||||
---
|
||||
|
||||
<figure class="my-4 rounded-b">
|
||||
{
|
||||
title && (
|
||||
<figcaption>
|
||||
<div class="bg-primary w-full rounded-t-md border-1 border-black px-4 py-2 text-white">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center justify-between gap-2 font-bold">
|
||||
{languageIcon && <Icon name={languageIcon} />}
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
<button id="copy__code">Copy Code</button>
|
||||
</div>
|
||||
<figcaption>
|
||||
<div
|
||||
class="bg-primary w-full rounded-t-md border-1 border-gray-600 px-4 py-2 text-white"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center justify-between gap-2 font-bold">
|
||||
{languageIcon && <Icon name={languageIcon} />}
|
||||
{
|
||||
!languageIcon && language !== "plaintext" && (
|
||||
<p class="uppercase">{language}</p>
|
||||
)
|
||||
}
|
||||
{title && <p>{title}</p>}
|
||||
</div>
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
<button class="font-bold" id=`copy-${copyID}`>
|
||||
Copy <Icon name="mdi:content-copy" class="ml-0.5 inline-block" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</figcaption>
|
||||
|
||||
<pre
|
||||
class:list={[
|
||||
"py-2",
|
||||
"border-x border-b",
|
||||
"rounded-b",
|
||||
!title ? "rounded-t border-t" : "",
|
||||
className
|
||||
]}
|
||||
class:list={["border-x border-b border-gray-600", "rounded-b", className]}
|
||||
{...props}><slot /></pre>
|
||||
</figure>
|
||||
|
||||
<style is:global>
|
||||
.astro-code {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.astro-code code {
|
||||
/* Define a counter for each <code> inside .astro-code */
|
||||
counter-reset: step;
|
||||
/* Start from zero, increment the counter */
|
||||
counter-increment: step 0;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.astro-code code .line {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.astro-code code .line::before {
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
width: 2rem;
|
||||
margin-right: 1.25rem;
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
|
||||
/* Fix element position during horizontal scroll */
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
|
||||
/* Give a bit of space to counter on horizontal scroll */
|
||||
padding-right: 0.25rem;
|
||||
|
||||
/* Illustrative purpose, please extract the value from the theme instead */
|
||||
background-color: white;
|
||||
color: hsla(0, 0%, 0%, 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ dataCode }}>
|
||||
<script define:vars={{ dataCode, copyID }}>
|
||||
if (typeof navigator.clipboard !== undefined) {
|
||||
const trigger = document.querySelector("#copy__code");
|
||||
const trigger = document.getElementById(`copy-${copyID}`);
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
// Write the code to clipboard
|
||||
navigator.clipboard.writeText(dataCode);
|
||||
});
|
||||
}
|
||||
|
@@ -1 +1 @@
|
||||
<h5 class="font-base text-base font-bold"><slot /></h5>
|
||||
<h5 class="text-lg font-bold"><slot /></h5>
|
||||
|
@@ -1 +1 @@
|
||||
<h6 class="font-base text-base underline"><slot /></h6>
|
||||
<h6 class="text-base font-bold"><slot /></h6>
|
||||
|
17
src/components/MDX/IMG.astro
Normal file
17
src/components/MDX/IMG.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import Image from "astro/components/Image.astro";
|
||||
const { src, alt } = Astro.props;
|
||||
|
||||
import { getImageByPath } from "@lib/utils";
|
||||
|
||||
const image = getImageByPath(src);
|
||||
---
|
||||
|
||||
{
|
||||
image && (
|
||||
<div class="my-8">
|
||||
<Image src={image} alt={alt} />
|
||||
<div class="mt-2 block w-full text-center text-sm italic">{alt}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -7,4 +7,4 @@ interface Props extends HTMLAttributes<"p"> {}
|
||||
const { ...attrs } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<Paragraph className="my-4" {...attrs}><slot /></Paragraph>
|
||||
<Paragraph class="my-4" {...attrs}><slot /></Paragraph>
|
||||
|
@@ -13,18 +13,20 @@ const { navbarDisplay = "normal" } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<nav
|
||||
class="text-primary font-header fixed top-0 right-0 left-0 z-50 uppercase"
|
||||
class="font-header group/nav fixed top-0 right-0 left-0 z-50 uppercase"
|
||||
aria-label="Primary"
|
||||
transition:name="nav"
|
||||
transition:animate="none"
|
||||
id="nav"
|
||||
data-mobile-menu="hidden"
|
||||
data-transparency={navbarDisplay}
|
||||
>
|
||||
<div
|
||||
id="navbar-lg"
|
||||
class:list={[
|
||||
"mx-auto border-b-1 border-solid px-4 shadow-lg transition",
|
||||
navbarDisplay == "transparent"
|
||||
? "should-be-transparent border-gray-500 bg-transparent"
|
||||
: "border-white bg-white dark:border-gray-700 dark:bg-gray-700 dark:text-white"
|
||||
"mx-auto border-0 border-b-1 border-solid border-b-white bg-white px-4 shadow-lg transition group-data-[mobile-menu=hidden]/nav:group-data-[transparency=transparent]/nav:border-b-gray-500 group-data-[mobile-menu=hidden]/nav:group-data-[transparency=transparent]/nav:bg-transparent dark:border-b-gray-700 dark:bg-gray-700",
|
||||
"text-gray-500 dark:text-white",
|
||||
"group-data-[mobile-menu=hidden]/nav:group-data-[transparency=transparent]/nav:!text-gray-500"
|
||||
]}
|
||||
>
|
||||
<div class="flex h-12 items-center justify-between">
|
||||
@@ -39,12 +41,8 @@ const { navbarDisplay = "normal" } = Astro.props as Props;
|
||||
|
||||
<!-- Desktop Menu -->
|
||||
<div class="hidden space-x-6 font-medium md:flex">
|
||||
<div>
|
||||
<ThemeSwitcher
|
||||
class="inline-block"
|
||||
selectClass=""
|
||||
optionClass="text-primary bg-white uppercase"
|
||||
/>
|
||||
<div class="text-primary">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
{
|
||||
links.map((link) => (
|
||||
@@ -52,11 +50,7 @@ const { navbarDisplay = "normal" } = Astro.props as Props;
|
||||
href={link.href}
|
||||
class:list={[
|
||||
"font-header font-medium uppercase transition hover:text-gray-300",
|
||||
pathName === link.href
|
||||
? "text-primary navbar-desktop-link-current"
|
||||
: navbarDisplay === "transparent"
|
||||
? "navbar-desktop-link text-gray-500"
|
||||
: "navbar-desktop-link text-gray-500 dark:text-white"
|
||||
pathName === link.href ? "text-primary" : ""
|
||||
]}
|
||||
aria-current={pathName === link.href ? "page" : undefined}
|
||||
>
|
||||
@@ -68,10 +62,7 @@ const { navbarDisplay = "normal" } = Astro.props as Props;
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<div class="md:hidden">
|
||||
<button
|
||||
id="menu-toggle"
|
||||
class="text-2xl text-gray-500 focus:outline-none dark:text-white"
|
||||
>
|
||||
<button id="menu-toggle" class="text-2xl focus:outline-none">
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
@@ -81,7 +72,11 @@ const { navbarDisplay = "normal" } = Astro.props as Props;
|
||||
<!-- Mobile Menu -->
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="hidden space-y-2 bg-white px-4 pt-4 pb-4 font-medium shadow-lg md:hidden dark:bg-gray-600 dark:text-white"
|
||||
class:list={[
|
||||
"hidden space-y-2 bg-white px-4 pt-4 pb-4 font-medium shadow-lg transition group-data-[mobile-menu=visible]/nav:block md:hidden group-data-[mobile-menu=visible]/nav:md:hidden dark:bg-gray-600 starting:opacity-0",
|
||||
"text-gray-500 dark:text-white",
|
||||
"group-data-[mobile-menu=hidden]/nav:group-data-[transparency=transparent]/nav:!text-gray-500"
|
||||
]}
|
||||
>
|
||||
{
|
||||
links.map((link) => (
|
||||
@@ -89,9 +84,7 @@ const { navbarDisplay = "normal" } = Astro.props as Props;
|
||||
href={link.href}
|
||||
class:list={[
|
||||
"block transition hover:text-gray-300",
|
||||
pathName === link.href
|
||||
? "text-primary"
|
||||
: "text-gray-500 dark:text-white"
|
||||
pathName === link.href ? "text-primary" : ""
|
||||
]}
|
||||
aria-current={pathName === link.href ? "page" : undefined}
|
||||
>
|
||||
@@ -99,12 +92,8 @@ const { navbarDisplay = "normal" } = Astro.props as Props;
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<div>
|
||||
<ThemeSwitcher
|
||||
class="inline-block"
|
||||
selectClass=""
|
||||
optionClass="text-primary bg-white uppercase"
|
||||
/>
|
||||
<div class="text-primary">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -123,104 +112,41 @@ const { navbarDisplay = "normal" } = Astro.props as Props;
|
||||
</script>
|
||||
|
||||
<script>
|
||||
let menuIsShowing = false;
|
||||
|
||||
const toggleMenu = () => {
|
||||
const toggle = document.getElementById("menu-toggle")!;
|
||||
const mobileMenu = document.getElementById("mobile-menu")!;
|
||||
const navbar = document.getElementById("navbar-lg")!;
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
mobileMenu.classList.toggle("hidden");
|
||||
menuIsShowing = !mobileMenu.classList.contains("hidden");
|
||||
|
||||
const shouldBeTransparent = navbar.classList.contains(
|
||||
"should-be-transparent"
|
||||
);
|
||||
|
||||
if (shouldBeTransparent) {
|
||||
if (menuIsShowing) {
|
||||
navbar.classList.remove("bg-transparent", "border-gray-500");
|
||||
navbar.classList.add(
|
||||
"bg-white",
|
||||
"border-white",
|
||||
"dark:bg-gray-700",
|
||||
"dark:border-gray-700",
|
||||
"dark:text-white"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkScroll();
|
||||
});
|
||||
};
|
||||
let nav: HTMLElement | null;
|
||||
let toggle: HTMLElement | null;
|
||||
|
||||
const checkScroll = () => {
|
||||
const navbar = document.getElementById("navbar-lg")!;
|
||||
const navbarDesktopLinks = document.getElementsByClassName(
|
||||
"navbar-desktop-link"
|
||||
);
|
||||
if (!nav) {
|
||||
nav = document.getElementById("nav");
|
||||
}
|
||||
|
||||
if (menuIsShowing) {
|
||||
navbar.classList.remove(
|
||||
"bg-transparent",
|
||||
"border-gray-500",
|
||||
"should-be-transparent"
|
||||
);
|
||||
navbar.classList.add(
|
||||
"bg-white",
|
||||
"border-white",
|
||||
"dark:bg-gray-700",
|
||||
"dark:border-gray-700",
|
||||
"dark:text-white"
|
||||
);
|
||||
if (!nav) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.scrollY === 0 && window.navbarDisplay === "transparent") {
|
||||
navbar.classList.remove(
|
||||
"bg-white",
|
||||
"border-white",
|
||||
"dark:bg-gray-700",
|
||||
"dark:border-gray-700",
|
||||
"dark:text-white"
|
||||
);
|
||||
navbar.classList.add(
|
||||
"bg-transparent",
|
||||
"border-gray-500",
|
||||
"should-be-transparent"
|
||||
);
|
||||
|
||||
for (let i = 0; i < navbarDesktopLinks.length; i++) {
|
||||
navbarDesktopLinks[i].classList.remove("dark:text-white");
|
||||
}
|
||||
} else {
|
||||
navbar.classList.remove(
|
||||
"bg-transparent",
|
||||
"border-gray-500",
|
||||
"should-be-transparent"
|
||||
);
|
||||
navbar.classList.add(
|
||||
"bg-white",
|
||||
"border-white",
|
||||
"dark:bg-gray-700",
|
||||
"dark:border-gray-700",
|
||||
"dark:text-white"
|
||||
);
|
||||
|
||||
for (let i = 0; i < navbarDesktopLinks.length; i++) {
|
||||
navbarDesktopLinks[i].classList.add("dark:text-white");
|
||||
}
|
||||
}
|
||||
nav.setAttribute(
|
||||
"data-transparency",
|
||||
window.scrollY === 0 && window.navbarDisplay === "transparent"
|
||||
? "transparent"
|
||||
: "normal"
|
||||
);
|
||||
};
|
||||
|
||||
document.addEventListener("astro:page-load", () => {
|
||||
// Sync menuIsShowing with actual menu state on load
|
||||
const mobileMenu = document.getElementById("mobile-menu");
|
||||
if (mobileMenu) {
|
||||
menuIsShowing = !mobileMenu.classList.contains("hidden");
|
||||
nav = document.getElementById("nav");
|
||||
toggle = document.getElementById("menu-toggle");
|
||||
|
||||
if (toggle) {
|
||||
toggle.addEventListener("click", () => {
|
||||
if (nav) {
|
||||
const visible = nav.getAttribute("data-mobile-menu") === "visible";
|
||||
nav.setAttribute("data-mobile-menu", visible ? "hidden" : "visible");
|
||||
}
|
||||
|
||||
checkScroll();
|
||||
});
|
||||
}
|
||||
toggleMenu();
|
||||
|
||||
checkScroll();
|
||||
});
|
||||
|
||||
|
@@ -1,11 +1,9 @@
|
||||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
interface Props extends HTMLAttributes<"p"> {
|
||||
className?: string;
|
||||
}
|
||||
interface Props extends HTMLAttributes<"p"> {}
|
||||
|
||||
const { className = "my-4", ...attrs } = Astro.props as Props;
|
||||
const { class: className = "my-4", ...attrs } = Astro.props;
|
||||
---
|
||||
|
||||
<p class:list={[className]} {...attrs}>
|
||||
|
@@ -11,7 +11,7 @@ interface Props {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const { height = "h-28" } = Astro.props as Props;
|
||||
const { height = "h-28" } = Astro.props;
|
||||
|
||||
import { getAudioDurationInSeconds } from "get-audio-duration";
|
||||
import { join } from "path";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,20 +1,29 @@
|
||||
---
|
||||
import { getAllProjectImages } from '@lib/utils';
|
||||
import { getAllProjectImages } from "@lib/utils";
|
||||
|
||||
import Link from "@components/Link.astro";
|
||||
import Paragraph from "@components/Paragraph.astro";
|
||||
import TextLink from "@components/TextLink.astro";
|
||||
import Token from "@components/Token.astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { Image } from "astro:assets";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
|
||||
import { slugify } from "@lib/utils";
|
||||
|
||||
interface Props {
|
||||
project: CollectionEntry<"projects">;
|
||||
textOn?: "left" | "right";
|
||||
quality?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { project, textOn = "left", quality = "80" } = Astro.props as Props;
|
||||
const {
|
||||
project,
|
||||
textOn = "left",
|
||||
quality = "80",
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props;
|
||||
|
||||
const images = getAllProjectImages(project);
|
||||
|
||||
@@ -119,9 +128,13 @@ const rotateOptions = [
|
||||
];
|
||||
|
||||
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",
|
||||
@@ -130,22 +143,60 @@ const projectHasBody = project.body && project.body.trim().length > 0;
|
||||
>
|
||||
<span
|
||||
><h2 class="font-header-alt inline-block text-lg font-semibold">
|
||||
{projectHasBody && <Link href=`/projects/${project.id}/`>{project.data.title}</Link>}{!projectHasBody && project.data.title }{!project.data.ongoing && <span class="italic text-sm font-light"> ({project.data.date.getFullYear()})</span>}</h2></span
|
||||
{
|
||||
projectHasBody && (
|
||||
<TextLink href={link}>{project.data.title}</TextLink>
|
||||
)
|
||||
}{!projectHasBody && project.data.title}{
|
||||
!project.data.ongoing && (
|
||||
<span class="text-sm font-light italic">
|
||||
{" "}
|
||||
({project.data.date.getFullYear()})
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</h2></span
|
||||
>
|
||||
<h3 class="font-header-alt font-base font-medium">
|
||||
{project.data.role}
|
||||
</h3>
|
||||
<div class="pt-2">
|
||||
<div class="mt-2">
|
||||
<Token>{project.data.type}</Token>
|
||||
</div>
|
||||
<Paragraph>{project.data.description}</Paragraph>
|
||||
{
|
||||
project.data.keyFigure && project.data.keyFigure.length > 0 && (
|
||||
<div class="mt-2 text-sm">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
<Paragraph
|
||||
class:list={["px-0", "my-2", textOn === "right" ? "" : "md:pr-10"]}
|
||||
>{project.data.description}</Paragraph
|
||||
>
|
||||
{
|
||||
project.data.externalLinks !== undefined && (
|
||||
<span class="relative order-3 ml-auto flex w-full items-start justify-start space-x-2 text-xl">
|
||||
{project.data.externalLinks.map((link, index) => (
|
||||
<Link href={link.href} includeExternalLinkIcon={false} aria-label={link.name}>
|
||||
{project.data.externalLinks.map((link) => (
|
||||
<TextLink
|
||||
href={link.href}
|
||||
includeExternalLinkIcon={false}
|
||||
aria-label={link.name}
|
||||
>
|
||||
<Icon name={link.icon} />
|
||||
</Link>
|
||||
</TextLink>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
|
@@ -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"
|
||||
} = Astro.props as Props;
|
||||
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>
|
||||
|
@@ -2,7 +2,6 @@
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
interface Props extends HTMLAttributes<"a"> {
|
||||
className?: string;
|
||||
includeExternalLinkIcon?: boolean;
|
||||
}
|
||||
|
||||
@@ -10,10 +9,10 @@ import { Icon } from "astro-icon/components";
|
||||
|
||||
const {
|
||||
href,
|
||||
className,
|
||||
class: className,
|
||||
includeExternalLinkIcon = true,
|
||||
...attrs
|
||||
} = Astro.props as Props;
|
||||
} = Astro.props;
|
||||
|
||||
const linkIsExternal: boolean =
|
||||
href !== undefined && href !== null
|
@@ -1,35 +0,0 @@
|
||||
---
|
||||
const { class: providedClasses, selectClass, optionClass } = Astro.props;
|
||||
---
|
||||
|
||||
<theme-selector class:list={[providedClasses]}></theme-selector>
|
||||
<script is:inline define:vars={{ selectClass, optionClass }}>
|
||||
if (!customElements.get("theme-selector")) {
|
||||
customElements.define(
|
||||
"theme-selector",
|
||||
class extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<select name="theme-select" class=${selectClass}>
|
||||
<option value="auto" class=${optionClass}>Auto</option>
|
||||
<option value="light" class=${optionClass}>Light</option>
|
||||
<option value="dark" class=${optionClass}>Dark</option>
|
||||
</select>
|
||||
`;
|
||||
this.querySelector("select").onchange = (event) =>
|
||||
theme.setTheme(event.target.value);
|
||||
this.setAttribute("aria-label", "Select Theme");
|
||||
this.updateSelectedTheme();
|
||||
|
||||
document.addEventListener("theme-changed", (event) => {
|
||||
this.updateSelectedTheme(event.detail.theme);
|
||||
});
|
||||
}
|
||||
|
||||
updateSelectedTheme(newTheme = theme.getTheme()) {
|
||||
this.querySelector("select").value = newTheme;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
@@ -1,10 +1,11 @@
|
||||
---
|
||||
interface Props {
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
interface Props extends HTMLAttributes<"span"> {
|
||||
colour?: String;
|
||||
textColour?: String;
|
||||
size?: String;
|
||||
hover?: String;
|
||||
className?: String;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -12,7 +13,7 @@ const {
|
||||
textColour = "text-white",
|
||||
size = "text-xs",
|
||||
hover = "hover:bg-white hover:text-primary hover:ring-primary hover:ring-2",
|
||||
className,
|
||||
class: className,
|
||||
...attrs
|
||||
} = Astro.props as Props;
|
||||
---
|
||||
|
@@ -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>
|
@@ -2,14 +2,24 @@ import { glob } from "astro/loaders";
|
||||
import { defineCollection, z } from "astro:content";
|
||||
|
||||
const projects = defineCollection({
|
||||
loader: glob({ pattern: "**/*.mdx", base: "./src/assets/projects" }),
|
||||
loader: glob({ pattern: "**/*.mdx", base: "./projects" }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
role: z.string(),
|
||||
type: z.string(),
|
||||
date: z.date(),
|
||||
description: z.string(),
|
||||
slug: z.string(),
|
||||
ongoing: z.boolean().optional().default(false),
|
||||
keyFigure: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
name: z.string(),
|
||||
href: z.string().optional()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
frontPage: z
|
||||
.object({
|
||||
order: z.number()
|
||||
@@ -58,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()
|
||||
})
|
||||
});
|
||||
|
||||
|
@@ -4,6 +4,6 @@ export default {
|
||||
last: "Cummins"
|
||||
},
|
||||
title: "Dr",
|
||||
jobTitle: "Composer, Orchestrator, Conductor, Sound Designer, Performer",
|
||||
jobTitle: "Composer, Orchestrator, Conductor, Sound Designer, Sound Designer",
|
||||
email: "nathan@nathancummins.com.au"
|
||||
};
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import person from "@data/person";
|
||||
|
||||
export default {
|
||||
title: `${person.names.first} ${person.names.last} - Adelaide Composer, Orchestrator, Conductor, and Performer`,
|
||||
title: `${person.names.first} ${person.names.last} - Adelaide Composer, Orchestrator, Conductor, and Sound Designer`,
|
||||
description:
|
||||
"Nathan Cummins is an award-winning composer, orchestrator, sound designer, and conductor known for his vibrant music across video games, film, and live performance. Based in Adelaide, he brings classical craft, bold creativity, and technical innovation to every project.",
|
||||
tagline: "Composer, Orchestrator, Conductor, Performer",
|
||||
tagline: "Composer, Orchestrator, Conductor, Sound Designer",
|
||||
image: {
|
||||
externalURL: `${import.meta.env.SITE}/nathan-cummins-composer-orchestrator-conductor.jpg`,
|
||||
src: "nathan-cummins-composer-orchestrator-conductor.jpg",
|
||||
|
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;
|
||||
}
|
||||
|
@@ -26,9 +26,9 @@ import MainHead from "@layouts/MainHead.astro";
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<MainHead {title} {subtitle} {description} {image} />
|
||||
<body>
|
||||
<body class="flex min-h-svh flex-col">
|
||||
<Navbar {navbarDisplay} />
|
||||
<main>
|
||||
<main class="grow">
|
||||
<slot />
|
||||
</main>
|
||||
<Footer />
|
||||
|
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 }
|
||||
@@ -14,6 +16,23 @@ const allProjectHeros = import.meta.glob<{ default: ImageMetadata }>(
|
||||
{ eager: true }
|
||||
);
|
||||
|
||||
const allImages = import.meta.glob<{ default: ImageMetadata }>(
|
||||
"/**/*.{jpeg,jpg,png,gif}",
|
||||
{
|
||||
eager: true
|
||||
}
|
||||
);
|
||||
|
||||
export function getImageByPath(path: string): ImageMetadata | null {
|
||||
for (const [imagePath, mod] of Object.entries(allImages)) {
|
||||
if (imagePath.includes(path)) {
|
||||
return mod.default;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getProjectHero(project: Project): ImageMetadata {
|
||||
let image: ImageMetadata = Object.values(allProjectHeros)[0].default;
|
||||
for (const [path, mod] of Object.entries(allProjectHeros)) {
|
||||
@@ -110,3 +129,23 @@ export async function getFullExternalURLOfImage(
|
||||
return new URL((await getImage({ src: image })).src, import.meta.env.SITE)
|
||||
.href;
|
||||
}
|
||||
|
||||
export function slugify(input: string): string {
|
||||
if (!input) return "";
|
||||
let slug = input.toLowerCase().trim();
|
||||
slug = slug.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
slug = slug.replace(/[^a-z0-9\s-]/g, " ");
|
||||
slug = slug.replace(/[\s-]+/g, "-");
|
||||
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;
|
||||
}
|
||||
|
@@ -11,32 +11,35 @@ const heroImages = import.meta.glob<{ default: ImageMetadata; eager: true }>(
|
||||
const heroImagesArray = await convertEagerImagesImportGlobToArray(heroImages);
|
||||
---
|
||||
|
||||
<MainLayout title="Oops! 404">
|
||||
<div class="flex h-screen w-full items-center justify-center">
|
||||
<MainLayout title="Oops! 404" navbarDisplay="transparent">
|
||||
<div class="absolute inset-0 h-full w-full bg-black">
|
||||
<ImageCarousel
|
||||
images={heroImagesArray}
|
||||
className="absolute z-10 h-full w-full"
|
||||
class="h-full w-full"
|
||||
foreground={true}
|
||||
foregroundOpacity="opacity-90"
|
||||
foreground={true}
|
||||
foregroundOpacity="opacity-80"
|
||||
/>
|
||||
<div class="z-20 p-36 text-center">
|
||||
<h1
|
||||
class="font-header pb-8 text-5xl font-medium text-white uppercase text-shadow-lg/75 md:text-7xl lg:text-9xl"
|
||||
>
|
||||
Oops!
|
||||
</h1>
|
||||
<h2
|
||||
class="font-header text-2xl font-medium text-gray-300 uppercase text-shadow-lg/75 md:text-2xl lg:text-4xl"
|
||||
>
|
||||
404 - Page not found
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex h-full w-full flex-col justify-center px-8 py-36 text-center"
|
||||
>
|
||||
<h1
|
||||
class="font-header pb-8 text-5xl font-medium text-white uppercase text-shadow-lg/75 md:text-7xl lg:text-9xl"
|
||||
>
|
||||
Oops!
|
||||
</h1>
|
||||
<h2
|
||||
class="font-header text-2xl font-medium text-gray-300 uppercase text-shadow-lg/75 md:text-2xl lg:text-4xl"
|
||||
>
|
||||
404 - Page not found
|
||||
</h2>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
class="bg-primary text-md font-header hover:text-primary repeat hover:ring-primary mx-auto mt-8 inline-block rounded px-6 py-3 font-light text-white uppercase drop-shadow-lg/75 transition hover:bg-white hover:ring-2"
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="/"
|
||||
class="bg-primary text-md font-header hover:text-primary repeat hover:ring-primary mx-auto mt-8 inline-block rounded px-6 py-3 font-light text-white uppercase drop-shadow-lg/75 transition hover:bg-white hover:ring-2"
|
||||
>
|
||||
Home
|
||||
</a>
|
||||
</div>
|
||||
</MainLayout>
|
||||
|
@@ -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 Link from "../components/Link.astro";
|
||||
import Paragraph from "../components/Paragraph.astro";
|
||||
import SectionTitle from "../components/SectionTitle.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: Link }} />
|
||||
<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
|
||||
Australia’s 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,000–9,000 community
|
||||
members annually, the ensemble promotes music education, fosters
|
||||
inclusivity, and provides a distinctive cultural offering that
|
||||
supports local industries and artists. Nathan’s leadership has been
|
||||
instrumental in shaping the band’s 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>
|
||||
|
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,26 +1,35 @@
|
||||
---
|
||||
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 Link from "../components/Link.astro";
|
||||
import Paragraph from "../components/Paragraph.astro";
|
||||
import SectionTitle from "../components/SectionTitle.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" />
|
||||
<Link href="mailto:nathan@nathancummins.com.au" className="mt-4"
|
||||
>nathan@nathancummins.com.au</Link
|
||||
<TextLink href="mailto:nathan@nathancummins.com.au" class="mt-4"
|
||||
>nathan@nathancummins.com.au</TextLink
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-1 flex flex-col items-center justify-center p-4">
|
||||
<Icon name="fa7-solid:envelope" class="text-4xl" />
|
||||
<Paragraph className="my-0 mt-4"
|
||||
<Paragraph class="my-0 mt-4"
|
||||
>PO Box 2112 Regency Park SA 5942</Paragraph
|
||||
>
|
||||
</div>
|
||||
|
@@ -7,9 +7,9 @@ import { Content } from "@assets/bios/short.mdx";
|
||||
import aboutImage from "@assets/img/about.jpg";
|
||||
import AwardCard from "@components/AwardCard.astro";
|
||||
import ImageCarousel from "@components/ImageCarousel.astro";
|
||||
import Link from "@components/Link.astro";
|
||||
import Paragraph from "@components/Paragraph.astro";
|
||||
import SectionTitle from "@components/SectionTitle.astro";
|
||||
import TextLink from "@components/TextLink.astro";
|
||||
import person from "@data/person";
|
||||
import site from "@data/site";
|
||||
import MainLayout from "@layouts/MainLayout.astro";
|
||||
@@ -27,7 +27,7 @@ const heroImagesArray = await convertEagerImagesImportGlobToArray(heroImages);
|
||||
const imagesForCTA = import.meta.glob<{ default: ImageMetadata; eager: true }>([
|
||||
"../assets/img/hero/*",
|
||||
"../assets/img/project-heros/*",
|
||||
"../assets/img/projects/*"
|
||||
"../assets/img/projects/**"
|
||||
]);
|
||||
|
||||
const imagesForCTAArray =
|
||||
@@ -57,7 +57,7 @@ const tracks = (
|
||||
<div class="absolute inset-0 h-full w-full bg-black">
|
||||
<ImageCarousel
|
||||
images={heroImagesArray}
|
||||
className="h-full w-full"
|
||||
class="h-full w-full"
|
||||
foreground={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -88,7 +88,7 @@ const tracks = (
|
||||
<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: Link }} />
|
||||
<Content components={{ p: Paragraph, a: TextLink }} />
|
||||
<Image
|
||||
src={aboutImage}
|
||||
alt="Nathan conducting the Woodville Concert Band"
|
||||
@@ -109,7 +109,7 @@ const tracks = (
|
||||
<div class="flex h-48 w-full items-center justify-center">
|
||||
<ImageCarousel
|
||||
images={imagesForCTAArray}
|
||||
className="absolute -z-40 h-full w-full overflow-hidden"
|
||||
class="h-full w-full"
|
||||
foreground={true}
|
||||
foregroundColour="bg-primary"
|
||||
foregroundOpacity="opacity-75 dark:opacity-25"
|
||||
@@ -118,13 +118,15 @@ const tracks = (
|
||||
quality={5}
|
||||
height={192}
|
||||
shuffle={true}
|
||||
/>
|
||||
<a
|
||||
href="/contact/"
|
||||
class="text-md font-header repeat text-primary hover:bg-primary mx-auto inline-block rounded bg-white px-6 py-3 font-light uppercase drop-shadow-lg/75 transition hover:text-white hover:ring-2 hover:ring-white"
|
||||
><div class="flex h-full w-full items-center">
|
||||
<a
|
||||
href="/contact/"
|
||||
class="text-md font-header repeat text-primary hover:bg-primary mx-auto inline-block rounded bg-white px-6 py-3 font-light uppercase drop-shadow-lg/75 transition hover:text-white hover:ring-2 hover:ring-white"
|
||||
>
|
||||
Get in touch!
|
||||
</a>
|
||||
</div></ImageCarousel
|
||||
>
|
||||
Get in touch!
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section id="projects" class="overflow-hidden bg-white dark:bg-gray-950">
|
||||
@@ -193,7 +195,7 @@ const tracks = (
|
||||
class="animate-scrolling-awards m-0 mx-auto flex max-w-3xs flex-col space-y-8 p-0"
|
||||
>
|
||||
{
|
||||
awardsDoubled.map((award, index) => {
|
||||
awardsDoubled.map((award) => {
|
||||
return <AwardCard award={award} />;
|
||||
})
|
||||
}
|
||||
@@ -203,10 +205,10 @@ const tracks = (
|
||||
</div>
|
||||
</section>
|
||||
<section id="fillerUntilServicesIsComplete" class="text-white">
|
||||
<div class="flex h-64 w-full items-center justify-center">
|
||||
<div class="flex h-96 w-full items-center justify-center">
|
||||
<ImageCarousel
|
||||
images={imagesForCTAArray}
|
||||
className="absolute -z-40 h-full w-full overflow-hidden"
|
||||
class="h-full w-full"
|
||||
foreground={true}
|
||||
foregroundColour="bg-primary"
|
||||
foregroundOpacity="opacity-50 dark:opacity-25"
|
||||
@@ -224,13 +226,13 @@ const tracks = (
|
||||
<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" />
|
||||
<Link href="mailto:nathan@nathancummins.com.au" className="mt-4"
|
||||
>nathan@nathancummins.com.au</Link
|
||||
<TextLink href="mailto:nathan@nathancummins.com.au" class="mt-4"
|
||||
>nathan@nathancummins.com.au</TextLink
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-1 flex flex-col items-center justify-center p-4">
|
||||
<Icon name="fa7-solid:envelope" class="text-4xl" />
|
||||
<Paragraph className="my-0 mt-4"
|
||||
<Paragraph class="my-0 mt-4"
|
||||
>PO Box 2112 Regency Park SA 5942</Paragraph
|
||||
>
|
||||
</div>
|
||||
|
@@ -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>
|
||||
|
@@ -1,92 +0,0 @@
|
||||
---
|
||||
import ImageCarousel from "@components/ImageCarousel.astro";
|
||||
import MainLayout from "@layouts/MainLayout.astro";
|
||||
|
||||
import { getCollection, render } from "astro:content";
|
||||
|
||||
import {
|
||||
getAllProjectImages,
|
||||
getFullExternalURLOfImage,
|
||||
getProjectHero
|
||||
} from "@/lib/utils";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const projects = await getCollection("projects", ({ body }) => {
|
||||
return body && body.trim().length > 0;
|
||||
});
|
||||
|
||||
return projects.map((project) => ({
|
||||
params: { slug: project.id },
|
||||
props: { project }
|
||||
}));
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
const { Content } = await render(project);
|
||||
|
||||
const images = getAllProjectImages(project);
|
||||
const hero = getProjectHero(project);
|
||||
|
||||
const seoImage: SiteImage = {
|
||||
externalURL: await getFullExternalURLOfImage(hero),
|
||||
src: hero.src,
|
||||
alt: project.data.images.hero.alt,
|
||||
width: hero.width,
|
||||
height: hero.height
|
||||
};
|
||||
|
||||
import A from "@components/MDX/A.astro";
|
||||
import Blockquote from "@components/MDX/Blockquote.astro";
|
||||
import CodeSnippet from "@components/MDX/CodeSnippet.astro";
|
||||
import H1 from "@components/MDX/H1.astro";
|
||||
import H2 from "@components/MDX/H2.astro";
|
||||
import H3 from "@components/MDX/H3.astro";
|
||||
import H4 from "@components/MDX/H4.astro";
|
||||
import H5 from "@components/MDX/H5.astro";
|
||||
import H6 from "@components/MDX/H6.astro";
|
||||
import P from "@components/MDX/P.astro";
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title={project.data.title}
|
||||
description={project.data.description}
|
||||
image={seoImage}
|
||||
>
|
||||
<div class="w-full">
|
||||
<section id="cta" class="text-white">
|
||||
<div class="flex h-64 w-full items-center justify-center">
|
||||
<ImageCarousel
|
||||
images={images}
|
||||
className="absolute -z-40 h-full w-full"
|
||||
foreground={true}
|
||||
interval={2000}
|
||||
transitionDuration="duration-1000"
|
||||
quality={50}
|
||||
height={256}
|
||||
shuffle={true}
|
||||
/>
|
||||
<h1 class="font-header text-4xl uppercase text-shadow-lg/75">
|
||||
{project.data.title}
|
||||
</h1>
|
||||
</div>
|
||||
</section>
|
||||
<section id="projects" class="bg-white dark:bg-gray-950">
|
||||
<div <div class="mx-auto max-w-4xl px-8 py-16 text-justify md:text-left">
|
||||
<Content
|
||||
components={{
|
||||
p: P,
|
||||
a: A,
|
||||
h1: H1,
|
||||
h2: H2,
|
||||
h3: H3,
|
||||
h4: H4,
|
||||
h5: H5,
|
||||
h6: H6,
|
||||
blockquote: Blockquote,
|
||||
pre: CodeSnippet
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</MainLayout>
|
153
src/pages/projects/[type]/[slug].astro
Normal file
153
src/pages/projects/[type]/[slug].astro
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
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 {
|
||||
getAllProjectImages,
|
||||
getFullExternalURLOfImage,
|
||||
getProjectHero,
|
||||
slugify
|
||||
} from "@lib/utils";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const projects = await getCollection("projects", ({ body }) => {
|
||||
return body && body.trim().length > 0;
|
||||
});
|
||||
|
||||
return projects.map((project) => ({
|
||||
params: {
|
||||
type: slugify(project.data.type),
|
||||
slug: slugify(project.data.slug)
|
||||
},
|
||||
props: { project }
|
||||
}));
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
const { Content } = await render(project);
|
||||
|
||||
const images = getAllProjectImages(project);
|
||||
const hero = getProjectHero(project);
|
||||
|
||||
const seoImage: SiteImage = {
|
||||
externalURL: await getFullExternalURLOfImage(hero),
|
||||
src: hero.src,
|
||||
alt: project.data.images.hero.alt,
|
||||
width: hero.width,
|
||||
height: hero.height
|
||||
};
|
||||
|
||||
import A from "@components/MDX/A.astro";
|
||||
import Blockquote from "@components/MDX/Blockquote.astro";
|
||||
import CodeSnippet from "@components/MDX/CodeSnippet.astro";
|
||||
import H1 from "@components/MDX/H1.astro";
|
||||
import H2 from "@components/MDX/H2.astro";
|
||||
import H3 from "@components/MDX/H3.astro";
|
||||
import H4 from "@components/MDX/H4.astro";
|
||||
import H5 from "@components/MDX/H5.astro";
|
||||
import H6 from "@components/MDX/H6.astro";
|
||||
import IMG from "@components/MDX/IMG.astro";
|
||||
import P from "@components/MDX/P.astro";
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title={project.data.title}
|
||||
description={project.data.description}
|
||||
image={seoImage}
|
||||
>
|
||||
<div class="w-full">
|
||||
<section id="title" class="text-white">
|
||||
<div class="flex h-100 w-full items-center justify-center">
|
||||
<ImageCarousel
|
||||
images={images}
|
||||
class="h-full w-full"
|
||||
foreground={true}
|
||||
interval={2000}
|
||||
transitionDuration="duration-1000"
|
||||
quality={70}
|
||||
height={400}
|
||||
shuffle={true}
|
||||
><div
|
||||
class="absolute inset-0 flex h-full w-full items-center justify-center px-8 text-center"
|
||||
>
|
||||
<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="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
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<section id="projects" class="bg-white dark:bg-gray-950">
|
||||
<div class="mx-auto max-w-4xl px-8 py-16 text-justify md:text-left">
|
||||
<Content
|
||||
components={{
|
||||
p: P,
|
||||
a: A,
|
||||
h1: H1,
|
||||
h2: H2,
|
||||
h3: H3,
|
||||
h4: H4,
|
||||
h5: H5,
|
||||
h6: H6,
|
||||
blockquote: Blockquote,
|
||||
pre: CodeSnippet,
|
||||
img: IMG
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</MainLayout>
|
@@ -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} />;
|
||||
})
|
||||
}
|
||||
|
@@ -1,13 +0,0 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
const getRobotsTxt = (sitemapURL: URL) => `\
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: ${sitemapURL.href}
|
||||
`;
|
||||
|
||||
export const GET: APIRoute = ({ site }) => {
|
||||
const sitemapURL = new URL("sitemap-index.xml", site);
|
||||
return new Response(getRobotsTxt(sitemapURL));
|
||||
};
|
@@ -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;
|
||||
@@ -78,4 +90,40 @@
|
||||
font-family: "Roboto", sans-serif;
|
||||
@apply text-gray-600 dark:text-white;
|
||||
}
|
||||
|
||||
.astro-code {
|
||||
@apply overflow-auto;
|
||||
}
|
||||
|
||||
.astro-code code {
|
||||
counter-reset: step;
|
||||
counter-increment: step 0;
|
||||
|
||||
@apply text-sm;
|
||||
@apply block;
|
||||
@apply w-fit min-w-full;
|
||||
}
|
||||
|
||||
.astro-code code .line {
|
||||
@apply inline-block;
|
||||
@apply w-full;
|
||||
@apply pr-8;
|
||||
@apply bg-white dark:bg-gray-100;
|
||||
}
|
||||
|
||||
.astro-code code .line::before {
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
|
||||
@apply w-10;
|
||||
@apply mr-2 ml-auto pt-1 pr-2 pb-1;
|
||||
@apply inline-block;
|
||||
@apply text-right;
|
||||
|
||||
@apply sticky;
|
||||
@apply left-0;
|
||||
@apply z-10;
|
||||
|
||||
@apply bg-gray-200;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user