Initial design based off original website, some things still to do
This commit is contained in:
4
src/components/Analytics.astro
Normal file
4
src/components/Analytics.astro
Normal file
@@ -0,0 +1,4 @@
|
||||
<script
|
||||
src="https://analytics.nathancummins.domains/api/script.js"
|
||||
data-site-id="1"
|
||||
defer></script>
|
||||
28
src/components/Award.astro
Normal file
28
src/components/Award.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { render } from "astro:content";
|
||||
|
||||
import Link from "@components/Link.astro";
|
||||
|
||||
interface Props {
|
||||
award: CollectionEntry<"awards">;
|
||||
}
|
||||
|
||||
const { award } = Astro.props as Props;
|
||||
|
||||
const { Content } = await render(award);
|
||||
---
|
||||
|
||||
<div>
|
||||
<dl>
|
||||
<dt>
|
||||
<h2 class="-mb-1 pb-0 text-base font-bold">{award.data.title}</h2>
|
||||
<span class="text-sm italic">
|
||||
{award.data.giver} ({award.data.date.getFullYear()})
|
||||
</span>
|
||||
<dd class="pt-2">
|
||||
<Content components={{ a: Link }} />
|
||||
</dd>
|
||||
</dt>
|
||||
</dl>
|
||||
</div>
|
||||
30
src/components/AwardCard.astro
Normal file
30
src/components/AwardCard.astro
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import { render } from "astro:content";
|
||||
|
||||
import Link from "@components/Link.astro";
|
||||
|
||||
interface Props {
|
||||
award: CollectionEntry<"awards">;
|
||||
}
|
||||
|
||||
const { award } = Astro.props as Props;
|
||||
|
||||
const { Content } = await render(award);
|
||||
---
|
||||
|
||||
<div class="col-span-1">
|
||||
<div class="bg-primary rounded text-white shadow">
|
||||
<div class="p-2">
|
||||
<div class="text-l font-bold">{award.data.title}</div>
|
||||
<div class="text-sm italic">
|
||||
{award.data.giver} ({award.data.date.getFullYear()})
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-2 text-left">
|
||||
<div class="text-sm text-gray-600">
|
||||
<Content components={{ a: Link }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
60
src/components/Footer.astro
Normal file
60
src/components/Footer.astro
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
import { socials } from "@assets/socials.ts";
|
||||
import Link from "@components/Link.astro";
|
||||
---
|
||||
|
||||
<footer
|
||||
class="footer -z-10 bg-gray-900 px-8 py-8 text-center text-white"
|
||||
transition:name="footer"
|
||||
>
|
||||
<div class="socials p-2">
|
||||
<ul class="flex justify-center space-x-4">
|
||||
{
|
||||
socials.map((social) => (
|
||||
<li class="inline-block">
|
||||
<a
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-icon"
|
||||
aria-label={social.name}
|
||||
>
|
||||
<Icon
|
||||
name={social.icon}
|
||||
class="text-primary h-12 w-12 transition hover:text-white"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<p class="p-2 text-sm">
|
||||
Copyright © <span id="footer-year"></span> Nathan Cummins.
|
||||
</p>
|
||||
<div class="statements grid 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
|
||||
>
|
||||
and with <Link href="https://tailwindcss.com/">Tailwind CSS</Link>. The
|
||||
full source code is available on my self-hosted <Link
|
||||
href="https://git.nathancummins.com.au/encie22/portfolio"
|
||||
>
|
||||
Gitea repository</Link
|
||||
>.
|
||||
</p>
|
||||
<p class="col-span-1 p-2 text-xs italic">
|
||||
I acknowledge that the land on which I work and live is the traditional
|
||||
lands of the Kaurna people. I pay respect to their cultural beliefs and to
|
||||
their Elders past, present, and emerging.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script is:inline data-astro-rerun>
|
||||
document.getElementById("footer-year").innerText = new Date().getFullYear();
|
||||
</script>
|
||||
62
src/components/HeroImageNative.astro
Normal file
62
src/components/HeroImageNative.astro
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
import type { ImageMetadata } from "astro";
|
||||
|
||||
interface Props {
|
||||
images: Record<string, () => Promise<{ default: ImageMetadata }>>;
|
||||
transitionStyle?: string;
|
||||
}
|
||||
|
||||
const { images, transitionStyle = "ease-in-out" } = Astro.props as Props;
|
||||
|
||||
const imagesArray = await Promise.all(
|
||||
Object.values(images).map(async (image) => image())
|
||||
);
|
||||
---
|
||||
|
||||
<div class="absolute -z-50 h-full w-full bg-black"></div>
|
||||
<img
|
||||
src={imagesArray[0].default.src}
|
||||
class:list={[
|
||||
"object-no-repeat absolute -z-50 h-full w-full object-cover object-center transition duration-2000",
|
||||
transitionStyle
|
||||
]}
|
||||
id="hero-image-1"
|
||||
/>
|
||||
<img
|
||||
src={imagesArray[1].default.src}
|
||||
class:list={[
|
||||
"object-no-repeat absolute -z-50 h-full w-full object-cover object-center transition duration-2000",
|
||||
transitionStyle
|
||||
]}
|
||||
id="hero-image-2"
|
||||
/>
|
||||
|
||||
<script define:vars={{ imagesArray }}>
|
||||
let currentIndex = 2; // Start at 2 since first two are already shown
|
||||
let visibleIndex = 0; // 0 or 1 — tracks which image is currently visible
|
||||
|
||||
const changeImage = () => {
|
||||
const currentImage = document.getElementById(
|
||||
`hero-image-${visibleIndex + 1}`
|
||||
);
|
||||
const nextImage = document.getElementById(
|
||||
`hero-image-${1 - visibleIndex + 1}`
|
||||
);
|
||||
|
||||
if (currentImage && nextImage) {
|
||||
// Prepare next image
|
||||
nextImage.src =
|
||||
imagesArray[currentIndex % imagesArray.length].default.src;
|
||||
nextImage.classList.remove("opacity-0"); // fade it in
|
||||
currentImage.classList.add("opacity-0"); // fade it out
|
||||
|
||||
// Once transition is done, update the image that faded out
|
||||
setTimeout(() => {
|
||||
currentIndex = (currentIndex + 1) % imagesArray.length;
|
||||
visibleIndex = 1 - visibleIndex; // swap visible image index
|
||||
}, 2000); // match this to the fade duration
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(changeImage, 5000);
|
||||
</script>
|
||||
126
src/components/ImageCarousel.astro
Normal file
126
src/components/ImageCarousel.astro
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import type { ImageMetadata } from "astro";
|
||||
|
||||
import { shuffleArray } from "@lib/utils";
|
||||
|
||||
interface Props {
|
||||
images: Array<ImageMetadata>;
|
||||
className: string;
|
||||
altText?: string | ((index: number) => string);
|
||||
interval?: number;
|
||||
backgroundColour?: string;
|
||||
backgroundOpacity?: string;
|
||||
transitionStyle?: string;
|
||||
transitionDuration?: string;
|
||||
foreground?: boolean;
|
||||
foregroundColour?: string;
|
||||
foregroundOpacity?: string;
|
||||
quality?: number;
|
||||
height?: number;
|
||||
shuffle?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
images,
|
||||
className,
|
||||
altText = null,
|
||||
interval = 5000,
|
||||
backgroundColour = "bg-black",
|
||||
backgroundOpacity = "opacity-100",
|
||||
transitionStyle = "ease-in-out",
|
||||
transitionDuration = "duration-2000",
|
||||
foreground = false,
|
||||
foregroundColour = "bg-black",
|
||||
foregroundOpacity = "opacity-50",
|
||||
quality = "80",
|
||||
height,
|
||||
shuffle = false
|
||||
} = Astro.props as Props;
|
||||
|
||||
const IDs: string[] = [];
|
||||
|
||||
const imagesArray = shuffle ? shuffleArray(images) : images;
|
||||
---
|
||||
|
||||
<div class:list={[className]}>
|
||||
<div class="relative h-full w-full">
|
||||
<div class:list={[backgroundColour, backgroundOpacity, "absolute inset-0"]}>
|
||||
</div>
|
||||
|
||||
{
|
||||
imagesArray.map((image, index) => {
|
||||
const ID = `image-carousel-${Math.random().toString(36)}`;
|
||||
IDs.push(ID);
|
||||
|
||||
return (
|
||||
<Image
|
||||
data-id={`${ID}`}
|
||||
class:list={[
|
||||
"absolute h-full w-full object-cover object-center transition-opacity",
|
||||
transitionStyle,
|
||||
transitionDuration,
|
||||
index > 0 ? "opacity-0" : "",
|
||||
index < 2 ? "" : "hidden"
|
||||
]}
|
||||
src={image}
|
||||
alt={
|
||||
typeof altText === "function" ? altText(index) : (altText ?? "")
|
||||
}
|
||||
loading={index < 2 ? "eager" : "lazy"}
|
||||
aria-hidden={index === 0 ? "false" : "true"}
|
||||
layout="full-width"
|
||||
fit="cover"
|
||||
style="height: 100% !important;"
|
||||
quality={quality}
|
||||
height={height === undefined ? undefined : height}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
foreground && (
|
||||
<div
|
||||
class:list={[foregroundColour, foregroundOpacity, "absolute inset-0"]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ imagesArray, interval, IDs }}>
|
||||
let currentIndex = 0;
|
||||
|
||||
setInterval(() => {
|
||||
const total = imagesArray.length;
|
||||
const current = document.querySelectorAll(
|
||||
`[data-id="${IDs[currentIndex]}"]`
|
||||
)[0];
|
||||
const nextIndex = (currentIndex + 1) % total;
|
||||
const next = document.querySelectorAll(`[data-id="${IDs[nextIndex]}"]`)[0];
|
||||
|
||||
if (!current || !next) return;
|
||||
|
||||
// Fade out current, fade in next
|
||||
current.classList.add("opacity-0");
|
||||
current.setAttribute("aria-hidden", "true");
|
||||
|
||||
next.classList.remove("opacity-0", "hidden");
|
||||
next.setAttribute("aria-hidden", "false");
|
||||
|
||||
const preloadIndex = (nextIndex + 1) % total;
|
||||
const preloadImage = document.querySelectorAll(
|
||||
`[data-id="${IDs[preloadIndex]}"]`
|
||||
)[0];
|
||||
if (preloadImage) {
|
||||
preloadImage.classList.add("opacity-0");
|
||||
preloadImage.classList.remove("hidden");
|
||||
preloadImage.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
currentIndex = nextIndex;
|
||||
}, interval);
|
||||
</script>
|
||||
42
src/components/Link.astro
Normal file
42
src/components/Link.astro
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
interface Props extends HTMLAttributes<"a"> {
|
||||
className?: string;
|
||||
includeExternalLinkIcon?: boolean;
|
||||
}
|
||||
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
const {
|
||||
href,
|
||||
className,
|
||||
includeExternalLinkIcon = true,
|
||||
...attrs
|
||||
} = Astro.props as Props;
|
||||
|
||||
const linkIsExternal: boolean =
|
||||
href !== undefined && href !== null
|
||||
? href.toString().startsWith("http")
|
||||
: false;
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class:list={["group text-primary inline-block transition", className]}
|
||||
target={href?.toString().startsWith("http") ? "_blank" : undefined}
|
||||
rel={linkIsExternal ? "noopener noreferrer" : undefined}
|
||||
{...attrs}
|
||||
><span class="group-hover:text-gray-300"><slot /></span>{
|
||||
includeExternalLinkIcon && linkIsExternal && (
|
||||
<sup class="ml-0.5 inline-block">
|
||||
<Icon
|
||||
name="mdi:external-link"
|
||||
class:list={[
|
||||
"text-primary m-0 inline-block group-hover:text-gray-300"
|
||||
]}
|
||||
/>
|
||||
</sup>
|
||||
)
|
||||
}
|
||||
</a>
|
||||
150
src/components/Navbar.astro
Normal file
150
src/components/Navbar.astro
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
const pathName = new URL(Astro.request.url).pathname;
|
||||
|
||||
interface Props {
|
||||
navbarDisplay: "normal" | "transparent";
|
||||
}
|
||||
|
||||
import person from "@data/person";
|
||||
import links from "@assets/menu-primary";
|
||||
import ThemeSwitcher from "@components/ThemeSwitcher.astro";
|
||||
|
||||
const { navbarDisplay = "normal" } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<nav
|
||||
class="text-primary font-header fixed top-0 right-0 left-0 z-50 uppercase"
|
||||
aria-label="Primary"
|
||||
transition:name="nav"
|
||||
transition:animate="none"
|
||||
>
|
||||
<div
|
||||
id="navbar-lg"
|
||||
class:list={[
|
||||
"mx-auto border-b-1 border-solid px-4 shadow-lg transition",
|
||||
navbarDisplay == "transparent"
|
||||
? "border-gray-500 bg-transparent"
|
||||
: "border-white bg-white"
|
||||
]}
|
||||
>
|
||||
<div class="flex h-12 items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<div class="font-header flex-shrink-0 text-2xl font-bold uppercase">
|
||||
<a href="/" aria-label="Go home"
|
||||
>{person.names.first} {person.names.last}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
{
|
||||
links.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class:list={[
|
||||
"font-header font-medium uppercase transition hover:text-gray-300",
|
||||
pathName === link.href ? "text-primary" : "text-gray-500"
|
||||
]}
|
||||
aria-current={pathName === link.href ? "page" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<div class="md:hidden">
|
||||
<button
|
||||
id="menu-toggle"
|
||||
class="text-2xl text-gray-500 focus:outline-none"
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
{
|
||||
links.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class:list={[
|
||||
"block transition hover:text-gray-300",
|
||||
pathName === link.href ? "text-primary" : "text-gray-500"
|
||||
]}
|
||||
aria-current={pathName === link.href ? "page" : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<div>
|
||||
<ThemeSwitcher
|
||||
class="inline-block"
|
||||
selectClass=""
|
||||
optionClass="text-primary bg-white uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Extra spacing for non-transparent navbar -->
|
||||
<div
|
||||
class:list={[
|
||||
"y-0 r-0 h-12",
|
||||
navbarDisplay === "transparent" ? "hidden" : "block"
|
||||
]}
|
||||
>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const toggleMenu = () => {
|
||||
const toggle = document.getElementById("menu-toggle");
|
||||
const mobileMenu = document.getElementById("mobile-menu");
|
||||
|
||||
if (toggle && mobileMenu) {
|
||||
toggle.addEventListener("click", () => {
|
||||
mobileMenu.classList.toggle("hidden");
|
||||
});
|
||||
}
|
||||
};
|
||||
document.addEventListener("astro:page-load", toggleMenu);
|
||||
</script>
|
||||
|
||||
<script define:vars={{ navbarDisplay }} data-astro-rerun>
|
||||
window.navbarDisplay = navbarDisplay || "normal";
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const checkScroll = () => {
|
||||
const navbar = document.getElementById("navbar-lg");
|
||||
if (navbar) {
|
||||
if (window.scrollY === 0 && window.navbarDisplay === "transparent") {
|
||||
// At top - make navbar transparent
|
||||
navbar.classList.remove("bg-white", "border-white");
|
||||
navbar.classList.add("bg-transparent", "border-gray-500");
|
||||
} else {
|
||||
// Scrolled down - add background color
|
||||
navbar.classList.remove("bg-transparent", "border-gray-500");
|
||||
navbar.classList.add("bg-white", "border-white");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", checkScroll);
|
||||
|
||||
document.addEventListener("astro:page-load", checkScroll);
|
||||
</script>
|
||||
13
src/components/Paragraph.astro
Normal file
13
src/components/Paragraph.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
interface Props extends HTMLAttributes<"p"> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const { className = "my-4", ...attrs } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<p class:list={[className]} {...attrs}>
|
||||
<slot />
|
||||
</p>
|
||||
425
src/components/Player.astro
Normal file
425
src/components/Player.astro
Normal file
@@ -0,0 +1,425 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
interface Props {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
const { height = "h-28" } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<div
|
||||
id="player"
|
||||
class:list={["fixed right-0 bottom-0 left-0 z-50 bg-black shadow-lg", height]}
|
||||
transition:persist=""
|
||||
transition:name="player"
|
||||
transition:animate="none"
|
||||
>
|
||||
<div
|
||||
id="player:time:progress"
|
||||
class="relative top-0 right-0 left-0 m-0 h-2 w-full bg-gray-900 p-0"
|
||||
>
|
||||
<div
|
||||
id="player:time:progress:buffered"
|
||||
class="absolute top-0 left-0 m-0 h-full bg-gray-700 p-0"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
id="player:time:progress:played"
|
||||
class="bg-primary absolute top-0 left-0 m-0 h-full p-0"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 text-white">
|
||||
<div class="grid grid-cols-2 text-xs">
|
||||
<span id="player:time:current" class="col-span-1 text-left"></span>
|
||||
<span id="player:time:total" class="col-span-1 text-right"></span>
|
||||
</div>
|
||||
<div class="flex h-full flex-row items-center justify-center align-middle">
|
||||
<div class="m-0 flex grow-0 flex-row space-x-2 p-0 text-3xl">
|
||||
<span>
|
||||
<Icon name="fa7-solid:backward" id="player:controls:backward" />
|
||||
</span>
|
||||
<span>
|
||||
<Icon name="fa7-solid:play" id="player:controls:play" />
|
||||
<Icon
|
||||
name="fa7-solid:pause"
|
||||
id="player:controls:pause"
|
||||
class="hidden"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<Icon name="fa7-solid:forward" id="player:controls:forward" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-grow flex-col text-sm">
|
||||
<span id="player:metadata:title" class="w-full text-right font-semibold"
|
||||
></span>
|
||||
<span id="player:metadata:subtitle" class="w-full text-right"></span>
|
||||
<span id="player:metadata:extra" class="w-full text-right"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class:list={[height]}></div>
|
||||
|
||||
<script src="/js/howler.min.js" is:inline></script>
|
||||
|
||||
<!-- Retrieve elements -->
|
||||
<script is:inline>
|
||||
// numbers
|
||||
const player_playerTimeCurrent = document.getElementById(
|
||||
"player:time:current"
|
||||
);
|
||||
const player_playerTimeTotal = document.getElementById("player:time:total");
|
||||
|
||||
// progress bars
|
||||
const player_playerTimeProgress = document.getElementById(
|
||||
"player:time:progress"
|
||||
);
|
||||
const player_playerTimeProgressPlayed = document.getElementById(
|
||||
"player:time:progress:played"
|
||||
);
|
||||
const player_playerTimeProgressBuffered = document.getElementById(
|
||||
"player:time:progress:buffered"
|
||||
);
|
||||
|
||||
// metadata
|
||||
const player_playerMetadataTitle = document.getElementById(
|
||||
"player:metadata:title"
|
||||
);
|
||||
const player_playerMetadataSubtitle = document.getElementById(
|
||||
"player:metadata:subtitle"
|
||||
);
|
||||
const player_playerMetadataExtra = document.getElementById(
|
||||
"player:metadata:extra"
|
||||
);
|
||||
|
||||
// controls
|
||||
const player_playerControlsPlay = document.getElementById(
|
||||
"player:controls:play"
|
||||
);
|
||||
const player_playerControlsPause = document.getElementById(
|
||||
"player:controls:pause"
|
||||
);
|
||||
const player_playerControlsForward = document.getElementById(
|
||||
"player:controls:forward"
|
||||
);
|
||||
const player_playerControlsBackward = document.getElementById(
|
||||
"player:controls:backward"
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- Add listeners -->
|
||||
<script is:inline>
|
||||
if (player_playerTimeProgress) {
|
||||
player_playerTimeProgress.addEventListener("click", function (event) {
|
||||
const x = event.offsetX;
|
||||
const percentage =
|
||||
x / player_playerTimeProgress.getBoundingClientRect().width;
|
||||
player_seekTrack(percentage);
|
||||
});
|
||||
}
|
||||
|
||||
if (player_playerControlsPlay) {
|
||||
player_playerControlsPlay.addEventListener("click", function (event) {
|
||||
player_playPause();
|
||||
});
|
||||
}
|
||||
|
||||
if (player_playerControlsPause) {
|
||||
player_playerControlsPause.addEventListener("click", function (event) {
|
||||
player_playPause();
|
||||
});
|
||||
}
|
||||
|
||||
if (player_playerControlsForward) {
|
||||
player_playerControlsForward.addEventListener("click", function (event) {
|
||||
player_playForward();
|
||||
});
|
||||
}
|
||||
|
||||
if (player_playerControlsBackward) {
|
||||
player_playerControlsBackward.addEventListener("click", function (event) {
|
||||
player_playBackward();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Playing control logic -->
|
||||
<script is:inline>
|
||||
function player_seekTrack(percentage) {
|
||||
const currentTrack = player_getCurrentTrack();
|
||||
if (currentTrack) {
|
||||
const duration = currentTrack.howl.duration();
|
||||
const seconds = duration * percentage;
|
||||
const ID = player_getCurrentTrackID();
|
||||
currentTrack.howl.seek(seconds, ID);
|
||||
}
|
||||
}
|
||||
|
||||
function player_playPause() {
|
||||
const currentTrack = player_getCurrentTrack();
|
||||
if (!currentTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ID = player_getCurrentTrackID();
|
||||
|
||||
if (currentTrack.howl.playing(ID)) {
|
||||
currentTrack.howl.pause(ID);
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: Valid ID but not playing
|
||||
if (typeof ID === "number" && ID >= 0) {
|
||||
currentTrack.howl.play(ID);
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 3: No valid ID yet (first play)
|
||||
player_nextIndex++;
|
||||
player_currentID = currentTrack.howl.play();
|
||||
}
|
||||
|
||||
function player_playForward() {
|
||||
player_playNext();
|
||||
}
|
||||
|
||||
function player_playBackward() {
|
||||
player_playPrevious();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Handle UI -->
|
||||
<script is:inline>
|
||||
function player_formatTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
// Pad with 0s if less than 10
|
||||
const paddedMinutes = String(minutes).padStart(2, "0");
|
||||
const paddedSecs = String(secs).padStart(2, "0");
|
||||
return `${paddedMinutes}:${paddedSecs}`;
|
||||
}
|
||||
|
||||
function player_getBufferedAmount(track) {
|
||||
const audioNode = track._sounds[0]._node;
|
||||
if (audioNode && audioNode.buffered.length) {
|
||||
// Get the highest buffered point
|
||||
return audioNode.buffered.end(0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function player_updateProgressAndDuration(
|
||||
current,
|
||||
total,
|
||||
progress,
|
||||
buffered
|
||||
) {
|
||||
if (player_playerTimeCurrent) {
|
||||
player_playerTimeCurrent.textContent = player_formatTime(current);
|
||||
}
|
||||
|
||||
if (player_playerTimeTotal) {
|
||||
player_playerTimeTotal.textContent = player_formatTime(total);
|
||||
}
|
||||
|
||||
if (player_playerTimeProgressPlayed) {
|
||||
player_playerTimeProgressPlayed.style.width = `${(progress * 100).toString()}%`;
|
||||
}
|
||||
|
||||
if (player_playerTimeProgressBuffered) {
|
||||
let bufferedPercentage = (total / buffered) * 100;
|
||||
|
||||
if (Math.abs(100 - bufferedPercentage) < 1) {
|
||||
bufferedPercentage = 100;
|
||||
}
|
||||
|
||||
player_playerTimeProgressBuffered.style.width = `${bufferedPercentage.toString()}%`;
|
||||
}
|
||||
}
|
||||
|
||||
function player_updateMetadata(title, subtitle, extra, artist) {
|
||||
if (player_playerMetadataTitle) {
|
||||
player_playerMetadataTitle.textContent = title;
|
||||
}
|
||||
|
||||
if (player_playerMetadataSubtitle) {
|
||||
player_playerMetadataSubtitle.textContent = subtitle;
|
||||
}
|
||||
|
||||
if (player_playerMetadataExtra) {
|
||||
player_playerMetadataExtra.textContent = extra;
|
||||
}
|
||||
}
|
||||
|
||||
function player_updatePlayPause() {
|
||||
if (player_playerControlsPlay && player_playerControlsPause) {
|
||||
const currentTrack = player_getCurrentTrack();
|
||||
|
||||
if (currentTrack) {
|
||||
if (currentTrack.howl.playing()) {
|
||||
player_playerControlsPause.classList.remove("hidden");
|
||||
player_playerControlsPlay.classList.add("hidden");
|
||||
} else {
|
||||
player_playerControlsPause.classList.add("hidden");
|
||||
player_playerControlsPlay.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function player_updateUIElements() {
|
||||
const currentTrack = player_getCurrentTrack();
|
||||
if (currentTrack) {
|
||||
// progress bar and times
|
||||
const current = currentTrack.howl.seek();
|
||||
const duration = currentTrack.howl.duration();
|
||||
const progress = current / duration;
|
||||
const buffered = player_getBufferedAmount(currentTrack.howl);
|
||||
|
||||
player_updateProgressAndDuration(current, duration, progress, buffered);
|
||||
|
||||
// metadata
|
||||
const title = currentTrack.metadata.title;
|
||||
const subtitle = currentTrack.metadata.subtitle || "";
|
||||
const extra = currentTrack.metadata.extra || "";
|
||||
const artist = currentTrack.metadata.artist || "";
|
||||
|
||||
player_updateMetadata(title, subtitle, extra, artist);
|
||||
|
||||
player_updatePlayPause();
|
||||
}
|
||||
|
||||
requestAnimationFrame(player_updateUIElements);
|
||||
}
|
||||
|
||||
requestAnimationFrame(player_updateUIElements);
|
||||
</script>
|
||||
|
||||
<!-- Main logic -->
|
||||
<script is:inline>
|
||||
const player_playlist = [];
|
||||
let player_currentID = -999;
|
||||
let player_nextIndex = 0;
|
||||
|
||||
function player_getCurrentTrack() {
|
||||
if (player_getPlaylistSize() === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return player_playlist[player_getCurrentTrackIndex()];
|
||||
}
|
||||
|
||||
function player_getCurrentTrackNumber() {
|
||||
return player_nextIndex;
|
||||
}
|
||||
|
||||
function player_getCurrentTrackIndex() {
|
||||
if (player_nextIndex === 0) {
|
||||
return player_nextIndex;
|
||||
}
|
||||
return player_nextIndex - 1;
|
||||
}
|
||||
|
||||
function player_getCurrentTrackID() {
|
||||
return player_currentID;
|
||||
}
|
||||
|
||||
function player_getPlaylistSize() {
|
||||
return player_playlist.length;
|
||||
}
|
||||
|
||||
function player_playNext() {
|
||||
if (player_getPlaylistSize() === 0) {
|
||||
// the playlist is empty, just exit
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTrack = player_getCurrentTrack();
|
||||
const currentID = player_getCurrentTrackID();
|
||||
|
||||
if (currentTrack && currentTrack.howl.playing()) {
|
||||
currentTrack.howl.stop(currentID);
|
||||
currentTrack.howl.seek(0, currentID);
|
||||
}
|
||||
|
||||
if (player_nextIndex >= player_getPlaylistSize()) {
|
||||
// wrap around
|
||||
player_nextIndex = 0;
|
||||
}
|
||||
|
||||
const newTrack = player_playlist[player_nextIndex];
|
||||
player_nextIndex++;
|
||||
player_currentID = newTrack.howl.play();
|
||||
}
|
||||
|
||||
function player_playPrevious() {
|
||||
if (player_getPlaylistSize() === 0) {
|
||||
// the playlist is empty, just exit
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTrack = player_getCurrentTrack();
|
||||
const ID = player_getCurrentTrackID();
|
||||
|
||||
if (
|
||||
currentTrack &&
|
||||
currentTrack.howl.playing() &&
|
||||
currentTrack.howl.seek() >= 2
|
||||
) {
|
||||
// there's currently a track playing and we're more than two seconds into it
|
||||
// play it from the start rather than going back to the previous
|
||||
currentTrack.howl.seek(0, ID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTrack && currentTrack.howl.playing(ID)) {
|
||||
currentTrack.howl.stop(ID);
|
||||
}
|
||||
|
||||
// Rewind index
|
||||
player_nextIndex -= 2;
|
||||
if (player_nextIndex < 0) {
|
||||
player_nextIndex = player_getPlaylistSize() - 1;
|
||||
}
|
||||
|
||||
const newTrack = player_playlist[player_nextIndex];
|
||||
player_nextIndex++;
|
||||
player_currentID = newTrack.howl.play();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Player utilities -->
|
||||
<script is:inline>
|
||||
function player_constructTrack(trackData) {
|
||||
trackData.howl = new Howl({
|
||||
src: [`/audio/${trackData.src}`],
|
||||
html5: true,
|
||||
preload: "metadata",
|
||||
volume: 1,
|
||||
onend: player_playNext
|
||||
});
|
||||
return trackData;
|
||||
}
|
||||
|
||||
function player_addToPlaylist(track, autoPlay = true) {
|
||||
const currentTrack = player_getCurrentTrack();
|
||||
if (
|
||||
currentTrack &&
|
||||
currentTrack.metadata.title === track.metadata.title &&
|
||||
currentTrack.metadata.subtitle === track.metadata.subtitle
|
||||
) {
|
||||
player_playPause();
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoPlay) {
|
||||
player_playlist.splice(player_nextIndex, 0, track);
|
||||
player_playNext();
|
||||
} else {
|
||||
player_playlist.push(track);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
188
src/components/ProjectCard.astro
Normal file
188
src/components/ProjectCard.astro
Normal file
@@ -0,0 +1,188 @@
|
||||
---
|
||||
import { getAllProjectImages } from '@lib/utils';
|
||||
|
||||
import Link from "@components/Link.astro";
|
||||
import Paragraph from "@components/Paragraph.astro";
|
||||
import Token from "@components/Token.astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { Image } from "astro:assets";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
|
||||
interface Props {
|
||||
project: CollectionEntry<"projects">;
|
||||
textOn?: "left" | "right";
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
const { project, textOn = "left", quality = "80" } = Astro.props as Props;
|
||||
|
||||
const images = getAllProjectImages(project);
|
||||
|
||||
const translateXOptions = [
|
||||
"group-hover:translate-x-8",
|
||||
"group-hover:translate-x-9",
|
||||
"group-hover:translate-x-10",
|
||||
"group-hover:translate-x-11",
|
||||
"group-hover:translate-x-12",
|
||||
"group-hover:translate-x-13",
|
||||
"group-hover:translate-x-14",
|
||||
"group-hover:translate-x-15",
|
||||
"group-hover:translate-x-16",
|
||||
"group-hover:translate-x-17",
|
||||
"group-hover:translate-x-18",
|
||||
"group-hover:translate-x-19",
|
||||
"group-hover:translate-x-20",
|
||||
"group-hover:translate-x-21",
|
||||
"group-hover:translate-x-22",
|
||||
"group-hover:translate-x-23",
|
||||
"group-hover:translate-x-24",
|
||||
"group-hover:-translate-x-8",
|
||||
"group-hover:-translate-x-9",
|
||||
"group-hover:-translate-x-10",
|
||||
"group-hover:-translate-x-11",
|
||||
"group-hover:-translate-x-12",
|
||||
"group-hover:-translate-x-13",
|
||||
"group-hover:-translate-x-14",
|
||||
"group-hover:-translate-x-15",
|
||||
"group-hover:-translate-x-16",
|
||||
"group-hover:-translate-x-17",
|
||||
"group-hover:-translate-x-18",
|
||||
"group-hover:-translate-x-19",
|
||||
"group-hover:-translate-x-20",
|
||||
"group-hover:-translate-x-21",
|
||||
"group-hover:-translate-x-22",
|
||||
"group-hover:-translate-x-23",
|
||||
"group-hover:-translate-x-24"
|
||||
];
|
||||
|
||||
const translateYOptions = [
|
||||
"group-hover:translate-y-8",
|
||||
"group-hover:translate-y-9",
|
||||
"group-hover:translate-y-10",
|
||||
"group-hover:translate-y-11",
|
||||
"group-hover:translate-y-12",
|
||||
"group-hover:translate-y-13",
|
||||
"group-hover:translate-y-14",
|
||||
"group-hover:translate-y-15",
|
||||
"group-hover:translate-y-16",
|
||||
"group-hover:translate-y-17",
|
||||
"group-hover:translate-y-18",
|
||||
"group-hover:translate-y-19",
|
||||
"group-hover:translate-y-20",
|
||||
"group-hover:translate-y-21",
|
||||
"group-hover:translate-y-22",
|
||||
"group-hover:translate-y-23",
|
||||
"group-hover:translate-y-24",
|
||||
"group-hover:-translate-y-9",
|
||||
"group-hover:-translate-y-10",
|
||||
"group-hover:-translate-y-11",
|
||||
"group-hover:-translate-y-12",
|
||||
"group-hover:-translate-y-13",
|
||||
"group-hover:-translate-y-14",
|
||||
"group-hover:-translate-y-15",
|
||||
"group-hover:-translate-y-16",
|
||||
"group-hover:-translate-y-17",
|
||||
"group-hover:-translate-y-18",
|
||||
"group-hover:-translate-y-19",
|
||||
"group-hover:-translate-y-20",
|
||||
"group-hover:-translate-y-21",
|
||||
"group-hover:-translate-y-22",
|
||||
"group-hover:-translate-y-23",
|
||||
"group-hover:-translate-y-24"
|
||||
];
|
||||
|
||||
const rotateOptions = [
|
||||
"rotate-1",
|
||||
"rotate-2",
|
||||
"rotate-3",
|
||||
"rotate-4",
|
||||
"rotate-5",
|
||||
"rotate-6",
|
||||
"rotate-7",
|
||||
"rotate-8",
|
||||
"rotate-9",
|
||||
"rotate-10",
|
||||
"rotate-11",
|
||||
"rotate-12",
|
||||
"-rotate-1",
|
||||
"-rotate-2",
|
||||
"-rotate-3",
|
||||
"-rotate-4",
|
||||
"-rotate-5",
|
||||
"-rotate-6",
|
||||
"-rotate-7",
|
||||
"-rotate-8",
|
||||
"-rotate-9",
|
||||
"-rotate-10",
|
||||
"-rotate-11",
|
||||
"-rotate-12"
|
||||
];
|
||||
|
||||
const projectHasBody = project.body && project.body.trim().length > 0;
|
||||
---
|
||||
|
||||
<div class="grid grid-cols-1 text-left md:grid-cols-2">
|
||||
<div
|
||||
class:list={[
|
||||
"order-1 flex flex-col items-start justify-center py-4",
|
||||
textOn === "right" ? "md:order-2" : "md:order-1"
|
||||
]}
|
||||
>
|
||||
<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 }</h2></span
|
||||
>
|
||||
<h3 class="font-header-alt font-base font-medium">
|
||||
{project.data.role}
|
||||
</h3>
|
||||
<div class="pt-2">
|
||||
<Token>{project.data.type}</Token>
|
||||
</div>
|
||||
<Paragraph>{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}>
|
||||
<Icon name={link.icon} />
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class:list={[
|
||||
"group relative order-2 ml-auto flex h-full min-h-64 w-full items-center justify-center",
|
||||
textOn === "right" ? "md:order-1" : "md:order-2"
|
||||
]}
|
||||
>
|
||||
{
|
||||
images
|
||||
.reverse()
|
||||
.map((image, index) => (
|
||||
<Image
|
||||
id={`hero-image-${index}`}
|
||||
class:list={[
|
||||
"animate-floaty absolute h-48 w-auto origin-center transform rounded object-cover shadow-lg/50 transition duration-300 ease-in-out group-hover:z-30 group-hover:scale-130 hover:z-40",
|
||||
translateXOptions[
|
||||
Math.floor(Math.random() * translateXOptions.length)
|
||||
],
|
||||
translateYOptions[
|
||||
Math.floor(Math.random() * translateYOptions.length)
|
||||
],
|
||||
rotateOptions[Math.floor(Math.random() * rotateOptions.length)]
|
||||
]}
|
||||
src={image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
layout="constrained"
|
||||
fit="cover"
|
||||
height={192}
|
||||
style={`animation-delay: -${Math.floor(Math.random() * (12 - 4) + 4)}s; animation-direction: ${Math.random() < 0.5 ? "normal" : "reverse"};`}
|
||||
quality={quality}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
57
src/components/SEO.astro
Normal file
57
src/components/SEO.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
url?: string;
|
||||
image?: SiteImage;
|
||||
}
|
||||
|
||||
const { title, description, url = Astro.url, image } = Astro.props;
|
||||
|
||||
import site from "@data/site";
|
||||
---
|
||||
|
||||
<!-- SEO -->
|
||||
<link rel="canonical" href={url} />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:site_name" content={site.title} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={url} />
|
||||
<meta
|
||||
property="og:image"
|
||||
content={image !== undefined && image.externalURL !== undefined
|
||||
? image.externalURL
|
||||
: site.image.externalURL}
|
||||
/>
|
||||
<meta
|
||||
property="og:image:url"
|
||||
content={image !== undefined && image.externalURL !== undefined
|
||||
? image.externalURL
|
||||
: site.image.externalURL}
|
||||
/>
|
||||
<meta
|
||||
property="og:image:secure_url"
|
||||
content={image !== undefined && image.externalURL !== undefined
|
||||
? image.externalURL
|
||||
: site.image.externalURL}
|
||||
/>
|
||||
<meta property="og:image:alt" content={image?.alt || site.image.alt} />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content="1920" />
|
||||
<meta property="og:image:height" content="1080" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:site" content="@nathancummins.au" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta
|
||||
name="twitter:image:src"
|
||||
content={image !== undefined && image.externalURL !== undefined
|
||||
? image.externalURL
|
||||
: site.image.externalURL}
|
||||
/>
|
||||
<meta name="twitter:image:alt" content={title} />
|
||||
<meta name="twitter:domain" content={import.meta.env.SITE} />
|
||||
16
src/components/SectionTitle.astro
Normal file
16
src/components/SectionTitle.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
interface Props {
|
||||
Tag?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
lineColour?: string;
|
||||
lineColourDark?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
Tag = "h1",
|
||||
lineColour = "border-primary",
|
||||
lineColourDark = "dark:text-primary"
|
||||
} = Astro.props as 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}`} />
|
||||
62
src/components/ThemeManager.astro
Normal file
62
src/components/ThemeManager.astro
Normal file
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type Props = {
|
||||
defaultTheme?: "auto" | "dark" | "light" | undefined;
|
||||
};
|
||||
|
||||
const { defaultTheme = "auto" } = Astro.props;
|
||||
---
|
||||
|
||||
<script is:inline data-default-theme={defaultTheme}>
|
||||
window.theme ??= (() => {
|
||||
const defaultTheme =
|
||||
document.currentScript.getAttribute("data-default-theme");
|
||||
const storageKey = "theme";
|
||||
const store =
|
||||
typeof localStorage !== "undefined"
|
||||
? localStorage
|
||||
: { getItem: () => null, setItem: () => {} };
|
||||
|
||||
const mediaMatcher = window.matchMedia("(prefers-color-scheme: light)");
|
||||
let systemTheme = mediaMatcher.matches ? "light" : "dark";
|
||||
mediaMatcher.addEventListener("change", (event) => {
|
||||
systemTheme = event.matches ? "light" : "dark";
|
||||
applyTheme(getTheme());
|
||||
});
|
||||
|
||||
function applyTheme(theme) {
|
||||
const resolvedTheme = theme === "auto" ? systemTheme : theme;
|
||||
document.documentElement.dataset.theme = resolvedTheme;
|
||||
document.documentElement.style.colorScheme = resolvedTheme;
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("theme-changed", {
|
||||
detail: { theme, systemTheme, defaultTheme }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function setTheme(theme = defaultTheme) {
|
||||
store.setItem(storageKey, theme);
|
||||
applyTheme(theme);
|
||||
}
|
||||
|
||||
function getTheme() {
|
||||
return store.getItem(storageKey) || defaultTheme;
|
||||
}
|
||||
|
||||
function getSystemTheme() {
|
||||
return systemTheme;
|
||||
}
|
||||
|
||||
function getDefaultTheme() {
|
||||
return defaultTheme;
|
||||
}
|
||||
|
||||
return { setTheme, getTheme, getSystemTheme, getDefaultTheme };
|
||||
})();
|
||||
theme.setTheme(theme.getTheme());
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener("astro:after-swap", () =>
|
||||
window.theme.setTheme(window.theme.getTheme())
|
||||
);
|
||||
</script>
|
||||
35
src/components/ThemeSelect.astro
Normal file
35
src/components/ThemeSelect.astro
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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>
|
||||
114
src/components/ThemeSwitcher.astro
Normal file
114
src/components/ThemeSwitcher.astro
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
---
|
||||
|
||||
<span
|
||||
class="theme:switcher flex justify-center text-center align-middle text-2xl"
|
||||
onclick="toggleTheme()"
|
||||
>
|
||||
<span class="theme:light-mode">
|
||||
<Icon name="fa7-solid:sun" />
|
||||
</span>
|
||||
<span class="theme:dark-mode">
|
||||
<Icon name="fa7-solid:moon" />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<script is:inline>
|
||||
function updateThemeIconAndAriaLabel(
|
||||
currentTheme = theme.getTheme(),
|
||||
systemTheme = theme.getSystemTheme()
|
||||
) {
|
||||
if (
|
||||
currentTheme === "dark" ||
|
||||
(currentTheme === "auto" && systemTheme === "dark")
|
||||
) {
|
||||
updateToDark();
|
||||
} else if (
|
||||
currentTheme === "light" ||
|
||||
(currentTheme === "auto" && systemTheme === "light")
|
||||
) {
|
||||
updateToLight();
|
||||
}
|
||||
}
|
||||
|
||||
function updateToDark() {
|
||||
const lightModeIcons = document.getElementsByClassName("theme:light-mode");
|
||||
const darkModeIcons = document.getElementsByClassName("theme:dark-mode");
|
||||
for (let icon of lightModeIcons) {
|
||||
icon.classList.add("hidden");
|
||||
}
|
||||
|
||||
for (let icon of darkModeIcons) {
|
||||
icon.classList.remove("hidden");
|
||||
}
|
||||
|
||||
const themeSwitchers = document.getElementsByClassName("theme:switcher");
|
||||
for (let switcher of themeSwitchers) {
|
||||
switcher.setAttribute(
|
||||
"aria-label",
|
||||
"Theme toggle button: click to activate light mode."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function updateToLight() {
|
||||
const lightModeIcons = document.getElementsByClassName("theme:light-mode");
|
||||
const darkModeIcons = document.getElementsByClassName("theme:dark-mode");
|
||||
|
||||
for (let icon of lightModeIcons) {
|
||||
icon.classList.remove("hidden");
|
||||
}
|
||||
|
||||
for (let icon of darkModeIcons) {
|
||||
icon.classList.add("hidden");
|
||||
}
|
||||
|
||||
const themeSwitchers = document.getElementsByClassName("theme:switcher");
|
||||
for (let switcher of themeSwitchers) {
|
||||
switcher.setAttribute(
|
||||
"aria-label",
|
||||
"Theme toggle button: click to activate dark mode."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const currentTheme = theme.getTheme();
|
||||
const defaultTheme = theme.getDefaultTheme();
|
||||
const systemTheme = theme.getSystemTheme();
|
||||
let newTheme;
|
||||
|
||||
if (defaultTheme === "auto") {
|
||||
newTheme =
|
||||
currentTheme === "auto" || currentTheme === systemTheme
|
||||
? systemTheme === "dark"
|
||||
? "light"
|
||||
: "dark"
|
||||
: "auto";
|
||||
} else {
|
||||
newTheme =
|
||||
currentTheme === defaultTheme
|
||||
? defaultTheme === "dark"
|
||||
? "light"
|
||||
: "dark"
|
||||
: currentTheme === "auto"
|
||||
? systemTheme === "dark"
|
||||
? "light"
|
||||
: "dark"
|
||||
: defaultTheme;
|
||||
}
|
||||
|
||||
theme.setTheme(newTheme);
|
||||
updateThemeIconAndAriaLabel(newTheme, systemTheme);
|
||||
}
|
||||
|
||||
document.addEventListener("theme-changed", (e) =>
|
||||
updateThemeIconAndAriaLabel(e.detail.currentTheme, e.detail.systemTheme)
|
||||
);
|
||||
|
||||
updateThemeIconAndAriaLabel();
|
||||
document.addEventListener("astro:after-swap", () => {
|
||||
updateThemeIconAndAriaLabel();
|
||||
});
|
||||
</script>
|
||||
30
src/components/Token.astro
Normal file
30
src/components/Token.astro
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
interface Props {
|
||||
colour?: String;
|
||||
textColour?: String;
|
||||
size?: String;
|
||||
hover?: String;
|
||||
className?: String;
|
||||
}
|
||||
|
||||
const {
|
||||
colour = "bg-primary",
|
||||
textColour = "text-white",
|
||||
size = "text-xs",
|
||||
hover = "hover:bg-white hover:text-primary hover:ring-primary hover:ring-2",
|
||||
className,
|
||||
...attrs
|
||||
} = Astro.props as Props;
|
||||
---
|
||||
|
||||
<span
|
||||
class:list={[
|
||||
"inline-block rounded px-2 py-1 font-bold uppercase",
|
||||
colour,
|
||||
textColour,
|
||||
className,
|
||||
size,
|
||||
hover
|
||||
]}
|
||||
{...attrs}><slot /></span
|
||||
>
|
||||
37
src/components/TrackCard.astro
Normal file
37
src/components/TrackCard.astro
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
|
||||
interface Props {
|
||||
track: CollectionEntry<"tracks">;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const { track, index } = Astro.props as Props;
|
||||
|
||||
import Image from "astro/components/Image.astro";
|
||||
---
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
"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=`player_addToPlaylist(player_constructTrack(${JSON.stringify(track.data)}))`
|
||||
>
|
||||
<Image
|
||||
src={track.data.card.image.src}
|
||||
alt={track.data.card.image.alt}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="bg-primary absolute inset-0 z-10 flex h-full w-full flex-col items-center justify-center p-16 text-center opacity-0 transition duration-300 hover:opacity-90"
|
||||
>
|
||||
<span class="font-header text-md md:text-l block text-gray-100"
|
||||
>{track.data.card.text.secondary}</span
|
||||
>
|
||||
<span class="font-header block text-xl font-medium uppercase md:text-2xl"
|
||||
>{track.data.card.text.primary}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,210 +0,0 @@
|
||||
---
|
||||
import astroLogo from '../assets/astro.svg';
|
||||
import background from '../assets/background.svg';
|
||||
---
|
||||
|
||||
<div id="container">
|
||||
<img id="background" src={background.src} alt="" fetchpriority="high" />
|
||||
<main>
|
||||
<section id="hero">
|
||||
<a href="https://astro.build"
|
||||
><img src={astroLogo.src} width="115" height="48" alt="Astro Homepage" /></a
|
||||
>
|
||||
<h1>
|
||||
To get started, open the <code><pre>src/pages</pre></code> directory in your project.
|
||||
</h1>
|
||||
<section id="links">
|
||||
<a class="button" href="https://docs.astro.build">Read our docs</a>
|
||||
<a href="https://astro.build/chat"
|
||||
>Join our Discord <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M107.7 8.07A105.15 105.15 0 0 0 81.47 0a72.06 72.06 0 0 0-3.36 6.83 97.68 97.68 0 0 0-29.11 0A72.37 72.37 0 0 0 45.64 0a105.89 105.89 0 0 0-26.25 8.09C2.79 32.65-1.71 56.6.54 80.21a105.73 105.73 0 0 0 32.17 16.15 77.7 77.7 0 0 0 6.89-11.11 68.42 68.42 0 0 1-10.85-5.18c.91-.66 1.8-1.34 2.66-2a75.57 75.57 0 0 0 64.32 0c.87.71 1.76 1.39 2.66 2a68.68 68.68 0 0 1-10.87 5.19 77 77 0 0 0 6.89 11.1 105.25 105.25 0 0 0 32.19-16.14c2.64-27.38-4.51-51.11-18.9-72.15ZM42.45 65.69C36.18 65.69 31 60 31 53s5-12.74 11.43-12.74S54 46 53.89 53s-5.05 12.69-11.44 12.69Zm42.24 0C78.41 65.69 73.25 60 73.25 53s5-12.74 11.44-12.74S96.23 46 96.12 53s-5.04 12.69-11.43 12.69Z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<a href="https://astro.build/blog/astro-5/" id="news" class="box">
|
||||
<svg width="32" height="32" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M24.667 12c1.333 1.414 2 3.192 2 5.334 0 4.62-4.934 5.7-7.334 12C18.444 28.567 18 27.456 18 26c0-4.642 6.667-7.053 6.667-14Zm-5.334-5.333c1.6 1.65 2.4 3.43 2.4 5.333 0 6.602-8.06 7.59-6.4 17.334C13.111 27.787 12 25.564 12 22.666c0-4.434 7.333-8 7.333-16Zm-6-5.333C15.111 3.555 16 5.556 16 7.333c0 8.333-11.333 10.962-5.333 22-3.488-.774-6-4-6-8 0-8.667 8.666-10 8.666-20Z"
|
||||
fill="#111827"></path></svg
|
||||
>
|
||||
<h2>What's New in Astro 5.0?</h2>
|
||||
<p>
|
||||
From content layers to server islands, click to learn more about the new features and
|
||||
improvements in Astro 5.0
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
filter: blur(100px);
|
||||
}
|
||||
|
||||
#container {
|
||||
font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
#links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
#links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
color: #111827;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
#links a:hover {
|
||||
color: rgb(78, 80, 86);
|
||||
}
|
||||
|
||||
#links a svg {
|
||||
height: 1em;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
color: white;
|
||||
background: linear-gradient(83.21deg, #3245ff 0%, #bc52ee 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
|
||||
inset 0 -2px 0 rgba(0, 0, 0, 0.24);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
#links a.button:hover {
|
||||
color: rgb(230, 230, 230);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family:
|
||||
ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono',
|
||||
monospace;
|
||||
font-weight: normal;
|
||||
background: linear-gradient(14deg, #d83333 0%, #f041ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 1em;
|
||||
font-weight: normal;
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #4b5563;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.006em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
background:
|
||||
linear-gradient(66.77deg, #f3cddd 0%, #f5cee7 100%) padding-box,
|
||||
linear-gradient(155deg, #d83333 0%, #f041ff 18%, #f5cee7 45%) border-box;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.box {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 1);
|
||||
border-radius: 16px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
|
||||
#news {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
max-width: 300px;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
backdrop-filter: blur(50px);
|
||||
}
|
||||
|
||||
#news:hover {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
@media screen and (max-height: 368px) {
|
||||
#news {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
#container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#hero {
|
||||
display: block;
|
||||
padding-top: 10%;
|
||||
}
|
||||
|
||||
#links {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#links a.button {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
|
||||
#news {
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
bottom: 2.5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user