Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion CLAUDE.md

This file was deleted.

2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Domain routing is handled by Cloudflare Pages Functions middleware (`functions/_

- Microsite layouts are standalone HTML documents — they do NOT extend `DefaultLayout` or import `global.css`
- Each microsite has fully independent styling (no shared design tokens or utility classes)
- Static assets use `/<name>/` prefixed paths (e.g. `/hands/images/human-hands.jpg`)
- Static assets use `/<name>/` prefixed paths (e.g. `/hands/images/human-hands.jpg`)
- Each microsite has its own `robots.txt.ts`, `sitemap.xml.ts`, and `404.astro`
- The main site's sitemap excludes microsite paths via the `filter` option in `astro.config.mjs`
- Middleware does NOT run in local dev — access microsites at `localhost:4321/<name>/`
Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,29 @@ This repo also hosts standalone microsites, each with its own domain, layout, an
- **[useyourdamnhands.com](https://useyourdamnhands.com)** — A public service announcement about unnecessary tools
- **[whatarewedoinghere.org](https://whatarewedoinghere.org)** — Coming soon

Microsites live under `src/microsites/<name>/` (layouts, components, styles) and `src/pages/<name>/` (routes). Domain routing is handled by Cloudflare Pages Functions middleware (`functions/_middleware.ts`).
Domain routing is handled by Cloudflare Pages Functions middleware (`functions/_middleware.ts`). There are two ways to create a microsite:

#### Option A: Single index.html (simple)

For lightweight microsites that don't need Astro's build pipeline:

1. Create `src/pages/<name>/index.html` — Astro treats raw `.html` files in `src/pages/` as page routes without any processing. The file is served at `/<name>/` in both dev and production.
2. To attach a custom domain, add an entry to `MICROSITES` in `functions/_middleware.ts`:
```ts
{ prefix: '/<name>', domain: 'example.com' }
```

`src/pages/useless-box/index.html` is a minimal example that renders a red box.

#### Option B: Full Astro microsite (complex)

For microsites that need components, data, TypeScript, or multiple routes:

- `src/microsites/<name>/` — layouts, components, data, styles (not routed by Astro)
- `src/pages/<name>/` — page routes (`index.astro`, `404.astro`, `robots.txt.ts`, `sitemap.xml.ts`)
- `public/<name>/` — static assets (images, favicon)

After adding routes under `src/pages/<name>/`, exclude the prefix from the main sitemap in `astro.config.mjs` and register the domain in the middleware as above.

### Themes

Expand All @@ -59,7 +81,7 @@ Themes are defined in `src/themes/` as TypeScript files satisfying the `Theme` i

### Requirements

- [Node.js](https://nodejs.org/) 22+
- [Node.js](https://nodejs.org/) 22+ - install via `brew install node` (assuming you have Homebrew installed)
- [pnpm](https://pnpm.io/) — install via `corepack enable pnpm`

### Quick Start
Expand Down
2 changes: 1 addition & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { rehypeLazyImages } from './src/utils/rehypeLazyImages';

export default defineConfig({
site: 'https://tinney.dev',
trailingSlash: 'never',
trailingSlash: 'ignore',
integrations: [
sitemap({
filter: (page) => !page.includes('/hands/') && !page.includes('/whatarewedoinghere/'),
Expand Down
5 changes: 1 addition & 4 deletions functions/_middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,7 @@ interface CFContext {
env: { ASSETS: { fetch: (req: Request | URL | string) => Promise<Response> } };
}

const MICROSITES: MicrositeRoute[] = [
{ prefix: '/hands', domain: 'useyourdamnhands.com' },
{ prefix: '/whatarewedoinghere', domain: 'whatarewedoinghere.org' },
];
const MICROSITES: MicrositeRoute[] = [{ prefix: '/hands', domain: 'useyourdamnhands.com' }];

function findMicrositeByHost(hostname: string): MicrositeRoute | undefined {
return MICROSITES.find((m) => hostname === m.domain || hostname === `www.${m.domain}`);
Expand Down
Binary file added public/useless-box/images/dark-wood.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/useless-box/images/off.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/useless-box/images/on.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/useless-box/images/robot-hand.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/useless-box/images/wood.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/useless-box/sounds/switch.mp3
Binary file not shown.
8 changes: 8 additions & 0 deletions src/data/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
"homepageUrl": "https://tinney.dev",
"gitHubUrl": "https://github.com/cdtinney/tinney.dev"
},
{
"name": "useless-box",
"shortDescription": "A useless box.",
"techStack": ["HTML", "Cloudflare Pages"],
"homepageUrl": "https://tinney.dev/useless-box",
"micrositePath": "/useless-box",
"gitHubUrl": "https://github.com/cdtinney/tinney.dev"
},
{
"name": "useyourdamnhands.com",
"shortDescription": "A public service announcement about unnecessary tools.",
Expand Down
262 changes: 262 additions & 0 deletions src/pages/useless-box/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Useless Box</title>
<style>
body {
--wood-bg-image: url('/useless-box/images/wood.png');
--dark-wood-bg-image: url('/useless-box/images/dark-wood.png');
--wood-bg-color: #c4a484;
--wood-bg-color-dark: rgb(172, 141, 112);

margin: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: white;
}

.wood-texture {
background-color: var(--wood-bg-color);
background-image: var(--wood-bg-image);
border: 1px solid rgba(0, 0, 0, 0.15);
}

.box-container {
width: 300px;
display: flex;
flex-direction: column;
position: relative;

--depth: 30px;
border-radius: 0 var(--depth);
corner-shape: bevel;
border-right: var(--depth) solid var(--wood-bg-color-dark);
border-bottom: var(--depth) solid var(--wood-bg-color-dark);

box-shadow: 2px 2px 4px 0px rgba(0, 0, 0, 0.4);

/* Inside the box */
background-image: var(--dark-wood-bg-image);
background-color: rgba(123, 0, 0, 0.95);
}

.box-container--on {
background-color: rgba(0, 123, 0, 0.95);
}

.box-top {
height: 200px;
box-shadow: 0 10px 10px -10px rgba(0, 0, 0, 0.5);

transform: rotateX(0);
transform-origin: top center;
transition: transform 0.6s ease;
z-index: 1;
}

.box-top--open {
transform: rotateX(35deg);
}

.box-bottom {
height: 240px;
display: flex;
justify-content: center;
z-index: 0;
}

#switch {
height: 64px;
margin-top: 24px;
cursor: pointer;

&.switch--on {
cursor: default;
}
}

.hand-container {
position: absolute;
top: 30px;
left: 70px;
height: 175px;
width: auto;
}

.hand-container--moving {
animation:
hand-move-down 1s ease-in-out forwards,
hand-move-up 1s linear forwards 1s;
}

.hand {
height: 100%;
}

@keyframes hand-move-down {
0% {
transform: translateY(0);
}

100% {
transform: translateY(65px);
}
}

@keyframes hand-move-up {
0% {
transform: translateY(65px);
}

100% {
transform: translateY(0px);
}
}
</style>
</head>
<body>
<div class="box-container">
<div class="box-top wood-texture"></div>
<div class="box-bottom wood-texture">
<img id="switch" src="/useless-box/images/off.png" alt="Toggle switch" />
</div>

<div class="hand-container">
<img class="hand" src="/useless-box/images/robot-hand.png" alt="Hand" />
</div>
</div>

<script>
const BoxState = {
OPENING: 'opening',
OPEN: 'open',
CLOSED: 'closed',
CLOSING: 'closing',
};

const globalState = {
toggleSwitchOn: false,
boxState: BoxState.CLOSED,
};

const elements = {
boxContainer: () => document.querySelector('.box-container'),
boxTop: () => document.querySelector('.box-top'),
toggleSwitch: () => document.getElementById('switch'),
handContainer: () => document.querySelector('.hand-container'),
};

const imageUrls = {
SWITCH_ON: '/useless-box/images/on.png',
SWITCH_OFF: '/useless-box/images/off.png',
};

function triggerHandAnimation({ onHandMoveDownEnd, onHandMoveUpEnd }) {
const movingClass = 'hand-container--moving';
const handContainer = elements.handContainer();
// Adding the class triggers the animation
handContainer.classList.add(movingClass);

const AnimationNames = {
HAND_MOVE_DOWN: 'hand-move-down',
HAND_MOVE_UP: 'hand-move-up',
};
handContainer.addEventListener('animationend', ({ animationName }) => {
if (animationName === AnimationNames.HAND_MOVE_DOWN) {
onHandMoveDownEnd();
}

if (animationName === AnimationNames.HAND_MOVE_UP) {
handContainer.classList.remove(movingClass);
onHandMoveUpEnd();
}
});
}

function updateSwitchUi() {
const on = globalState.toggleSwitchOn;
const toggleSwitch = elements.toggleSwitch();
toggleSwitch.src = on ? imageUrls.SWITCH_ON : imageUrls.SWITCH_OFF;
toggleSwitch.classList.toggle('switch--on', on);
}

function updateBoxUi() {
const boxTop = elements.boxTop();
boxTop.classList.toggle('box-top--open', globalState.boxState === BoxState.OPENING);
}

function updateBoxContainerUi() {
const boxContainer = elements.boxContainer();
boxContainer.classList.toggle('box-container--on', globalState.toggleSwitchOn);
}

function updateUiBasedOnGlobalState() {
updateSwitchUi();
updateBoxUi();
updateBoxContainerUi();
}

function waitForBoxTopTransitionEnd(onEnd) {
elements.boxTop().addEventListener(
'transitionend',
({ propertyName }) => {
if (event.propertyName !== 'transform') {
return;
}

onEnd();
},
{ once: true },
);
}

function playClickSound() {
const clickSound = new Audio('/useless-box/sounds/switch.mp3');
clickSound.currentTime = 0.11;
clickSound.play();
}

function setupToggleSwitch() {
const toggleSwitch = elements.toggleSwitch();
toggleSwitch.addEventListener('click', () => {
if (globalState.boxState !== BoxState.CLOSED) {
return;
}

playClickSound();
globalState.toggleSwitchOn = !globalState.toggleSwitchOn;
globalState.boxState = BoxState.OPENING;
updateUiBasedOnGlobalState();

waitForBoxTopTransitionEnd(() => {
triggerHandAnimation({
// Switch is clicked, hand moves down to turn it off
onHandMoveDownEnd: () => {
globalState.toggleSwitchOn = false;
playClickSound();
updateUiBasedOnGlobalState();
},

// Hand moves back inside box
onHandMoveUpEnd: () => {
globalState.boxState = BoxState.CLOSING;
updateUiBasedOnGlobalState();

waitForBoxTopTransitionEnd(() => {
globalState.boxState = BoxState.CLOSED;
updateUiBasedOnGlobalState();
});
},
});
});
});
}

setupToggleSwitch();
</script>
</body>
</html>
Loading