mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat(logo): add seasonal logo to website with glob-based loading
This commit is contained in:
1 parent
2dbc92035d
commit
d0a167461f
6 files changed
+150
-81
No files matched your search
@@ -1,32 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { keyframes } from "@emotion/react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import lockupColor from "../assets/logos/thingport-lockup-compact-color.svg";
|
||||
import lockupOnDark from "../assets/logos/thingport-lockup-compact-on-dark.svg";
|
||||
import autumn from "../assets/logos/seasonal/autumn.svg";
|
||||
import birthday from "../assets/logos/seasonal/birthday.svg";
|
||||
import christmas from "../assets/logos/seasonal/christmas.svg";
|
||||
import coffee from "../assets/logos/seasonal/coffee.svg";
|
||||
import diwali from "../assets/logos/seasonal/diwali.svg";
|
||||
import easter from "../assets/logos/seasonal/easter.svg";
|
||||
import halloween from "../assets/logos/seasonal/halloween.svg";
|
||||
import hanukkah from "../assets/logos/seasonal/hanukkah.svg";
|
||||
import holi from "../assets/logos/seasonal/holi.svg";
|
||||
import jonines from "../assets/logos/seasonal/jonines.svg";
|
||||
import lunarNewYear from "../assets/logos/seasonal/lunarnewyear.svg";
|
||||
import muertos from "../assets/logos/seasonal/muertos.svg";
|
||||
import newYear from "../assets/logos/seasonal/newyear.svg";
|
||||
import programmersDay from "../assets/logos/seasonal/programmersday.svg";
|
||||
import ramadan from "../assets/logos/seasonal/ramadan.svg";
|
||||
import saltibarsciai from "../assets/logos/seasonal/saltibarsciai.svg";
|
||||
import spring from "../assets/logos/seasonal/spring.svg";
|
||||
import stPatricks from "../assets/logos/seasonal/stpatricks.svg";
|
||||
import summer from "../assets/logos/seasonal/summer.svg";
|
||||
import valentines from "../assets/logos/seasonal/valentines.svg";
|
||||
import winter from "../assets/logos/seasonal/winter.svg";
|
||||
import womensDay from "../assets/logos/seasonal/womensday.svg";
|
||||
import { type Season, seasonFor } from "../utils/season";
|
||||
import { seasonFor } from "../utils/season";
|
||||
|
||||
type Props = {
|
||||
size?: "lg" | "md" | "sm";
|
||||
@@ -40,31 +18,8 @@ const HEIGHTS: Record<NonNullable<Props["size"]>, string> = {
|
||||
sm: "1.125rem",
|
||||
};
|
||||
|
||||
// April Fools' turns the logo itself, so it has none.
|
||||
const OVERLAYS: Record<Exclude<Season, "aprilFools">, string> = {
|
||||
newYear,
|
||||
lunarNewYear,
|
||||
valentines,
|
||||
holi,
|
||||
womensDay,
|
||||
stPatricks,
|
||||
spring,
|
||||
ramadan,
|
||||
easter,
|
||||
saltibarsciai,
|
||||
summer,
|
||||
jonines,
|
||||
birthday,
|
||||
programmersDay,
|
||||
autumn,
|
||||
coffee,
|
||||
diwali,
|
||||
halloween,
|
||||
muertos,
|
||||
hanukkah,
|
||||
winter,
|
||||
christmas,
|
||||
};
|
||||
// Each season's overlay is named after it, in lower case; only today's is loaded.
|
||||
export const SEASONAL_OVERLAYS = import.meta.glob<string>("../assets/logos/seasonal/*.svg", { import: "default" });
|
||||
|
||||
// Upside down all day, with a wobble now and then as if it noticed.
|
||||
const upsideDown = keyframes`
|
||||
@@ -78,9 +33,20 @@ const upsideDown = keyframes`
|
||||
export default function Wordmark({ size = "md", seasonal = false }: Props) {
|
||||
const theme = useTheme();
|
||||
const [season] = useState(() => (seasonal ? seasonFor(new Date()) : null));
|
||||
const [overlay, setOverlay] = useState<string | null>(null);
|
||||
const src = theme.palette.mode === "dark" ? lockupOnDark : lockupColor;
|
||||
const height = HEIGHTS[size];
|
||||
const overlay = season && season !== "aprilFools" ? OVERLAYS[season] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const load = season && SEASONAL_OVERLAYS[`../assets/logos/seasonal/${season.toLowerCase()}.svg`];
|
||||
if (!load) return;
|
||||
let active = true;
|
||||
void load().then((url) => active && setOverlay(url));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [season]);
|
||||
|
||||
const logo = (
|
||||
<Box
|
||||
component="img"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { easterSunday, equinox, seasonFor } from "./season";
|
||||
import { easterSunday, equinox, SEASONS, seasonFor } from "./season";
|
||||
|
||||
const on = (year: number, month: number, day: number) => seasonFor(new Date(year, month - 1, day, 12));
|
||||
const minutesOff = (date: Date, iso: string) => Math.abs(date.getTime() - Date.parse(iso)) / 60_000;
|
||||
@@ -113,3 +113,13 @@ describe("seasonFor", () => {
|
||||
expect(on(2026, 10, 14)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("seasonal overlays", () => {
|
||||
it("has a drawing for every season but April Fools', which turns the logo itself", () => {
|
||||
const files = Object.keys(import.meta.glob("../assets/logos/seasonal/*.svg")).map((path) =>
|
||||
path.replace(/^.*\/(.+)\.svg$/, "$1"),
|
||||
);
|
||||
const seasons = SEASONS.filter((season) => season !== "aprilFools").map((season) => season.toLowerCase());
|
||||
expect(files.toSorted()).toEqual(seasons.toSorted());
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,31 @@
|
||||
export type Season =
|
||||
| "newYear"
|
||||
| "lunarNewYear"
|
||||
| "valentines"
|
||||
| "holi"
|
||||
| "womensDay"
|
||||
| "stPatricks"
|
||||
| "spring"
|
||||
| "ramadan"
|
||||
| "aprilFools"
|
||||
| "easter"
|
||||
| "saltibarsciai"
|
||||
| "summer"
|
||||
| "jonines"
|
||||
| "birthday"
|
||||
| "programmersDay"
|
||||
| "autumn"
|
||||
| "coffee"
|
||||
| "diwali"
|
||||
| "halloween"
|
||||
| "muertos"
|
||||
| "hanukkah"
|
||||
| "winter"
|
||||
| "christmas";
|
||||
/** In calendar order. Each has an overlay of the same name, in lower case, in assets/logos/seasonal, but April Fools' turns the logo. */
|
||||
export const SEASONS = [
|
||||
"newYear",
|
||||
"lunarNewYear",
|
||||
"valentines",
|
||||
"holi",
|
||||
"womensDay",
|
||||
"stPatricks",
|
||||
"spring",
|
||||
"ramadan",
|
||||
"aprilFools",
|
||||
"easter",
|
||||
"saltibarsciai",
|
||||
"summer",
|
||||
"jonines",
|
||||
"birthday",
|
||||
"programmersDay",
|
||||
"autumn",
|
||||
"coffee",
|
||||
"diwali",
|
||||
"halloween",
|
||||
"muertos",
|
||||
"hanukkah",
|
||||
"winter",
|
||||
"christmas",
|
||||
] as const;
|
||||
|
||||
export type Season = (typeof SEASONS)[number];
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
|
||||
The product site, docs and blog, published to GitHub Pages by
|
||||
[`.github/workflows/web-pages.yml`](../.github/workflows/web-pages.yml). Built with [Astro](https://astro.build) as
|
||||
plain static HTML. The only JavaScript is the theme toggle.
|
||||
plain static HTML. The little JavaScript there is: the theme toggle, copy buttons, and the seasonal logo, which
|
||||
the visitor's browser picks by date (the pages are built ahead of time).
|
||||
|
||||
```bash
|
||||
cd web
|
||||
|
||||
@@ -15,7 +15,7 @@ const current = Astro.url.pathname;
|
||||
|
||||
<header class="site-header">
|
||||
<div class="container bar">
|
||||
<a class="brand" href={url()} aria-label="Thingport home"><Logo /></a>
|
||||
<a class="brand" href={url()} aria-label="Thingport home"><Logo seasonal /></a>
|
||||
<nav class="nav" aria-label="Main">
|
||||
{
|
||||
NAV.map((item) => (
|
||||
|
||||
@@ -2,10 +2,98 @@
|
||||
import lockupColor from "../../../frontend/src/assets/logos/thingport-lockup-compact-color.svg";
|
||||
import lockupOnDark from "../../../frontend/src/assets/logos/thingport-lockup-compact-on-dark.svg";
|
||||
|
||||
type Props = { height?: number };
|
||||
const { height = 30 } = Astro.props;
|
||||
type Props = {
|
||||
height?: number;
|
||||
/** Dresses the logo up for the season it is, as the app's sidebar does. */
|
||||
seasonal?: boolean;
|
||||
};
|
||||
const { height = 30, seasonal = false } = Astro.props;
|
||||
const width = (h: number, img: { width: number; height: number }) => Math.round((img.width / img.height) * h);
|
||||
|
||||
// Every season's overlay, by its lower-case name; the page is built ahead of time, so the visitor's browser picks today's.
|
||||
const overlays = seasonal
|
||||
? Object.fromEntries(
|
||||
Object.entries(
|
||||
import.meta.glob<{ default: ImageMetadata }>("../../../frontend/src/assets/logos/seasonal/*.svg", {
|
||||
eager: true,
|
||||
}),
|
||||
).map(([path, image]) => [path.replace(/^.*\/(.+)\.svg$/, "$1"), image.default.src]),
|
||||
)
|
||||
: null;
|
||||
---
|
||||
|
||||
<img class="only-light" src={lockupColor.src} alt="Thingport" height={height} width={width(height, lockupColor)} />
|
||||
<img class="only-dark" src={lockupOnDark.src} alt="Thingport" height={height} width={width(height, lockupOnDark)} />
|
||||
<span class="logo" style={`--h: ${height}px`} data-overlays={overlays && JSON.stringify(overlays)}>
|
||||
<img class="only-light" src={lockupColor.src} alt="Thingport" height={height} width={width(height, lockupColor)} />
|
||||
<img class="only-dark" src={lockupOnDark.src} alt="Thingport" height={height} width={width(height, lockupOnDark)} />
|
||||
</span>
|
||||
|
||||
<script>
|
||||
import { seasonFor } from "../../../frontend/src/utils/season";
|
||||
|
||||
const season = seasonFor(new Date());
|
||||
for (const logo of season ? document.querySelectorAll<HTMLElement>(".logo[data-overlays]") : []) {
|
||||
const src = (JSON.parse(logo.dataset.overlays!) as Record<string, string>)[season!.toLowerCase()];
|
||||
if (season === "aprilFools") logo.classList.add("fools");
|
||||
else if (src) {
|
||||
const overlay = document.createElement("img");
|
||||
overlay.className = "overlay";
|
||||
overlay.src = src;
|
||||
overlay.alt = "";
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
logo.append(overlay);
|
||||
}
|
||||
logo.classList.add("seasonal");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.logo {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
line-height: 0;
|
||||
}
|
||||
/* Room for what spills past the logo's right edge. */
|
||||
.logo:global(.seasonal) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
/* The overlay's viewBox (-30 -45 530 175) is the logo's (0 0 411 100) with room around it. */
|
||||
.logo :global(.overlay) {
|
||||
position: absolute;
|
||||
left: calc(var(--h) * -0.3);
|
||||
top: calc(var(--h) * -0.45);
|
||||
height: calc(var(--h) * 1.75);
|
||||
width: auto;
|
||||
/* Wider than the logo it sits in, which the site's img rule would otherwise shrink it to. */
|
||||
max-width: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* April Fools': upside down all day, with a wobble now and then as if it noticed. */
|
||||
.logo:global(.fools) img {
|
||||
transform: rotate(180deg);
|
||||
animation: upside-down 5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes upside-down {
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
76% {
|
||||
transform: rotate(168deg);
|
||||
}
|
||||
82% {
|
||||
transform: rotate(191deg);
|
||||
}
|
||||
88% {
|
||||
transform: rotate(176deg);
|
||||
}
|
||||
94% {
|
||||
transform: rotate(183deg);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.logo:global(.fools) img {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user