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: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
buy_me_a_coffee: roma321m
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

[![ci](https://github.com/launchcal/launchcal.github.io/actions/workflows/ci.yml/badge.svg)](https://github.com/launchcal/launchcal.github.io/actions/workflows/ci.yml)
[![publish](https://github.com/launchcal/launchcal.github.io/actions/workflows/publish.yml/badge.svg)](https://github.com/launchcal/launchcal.github.io/actions/workflows/publish.yml)
[![Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-roma321m-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/roma321m)

Free, self-updating calendars of SpaceX launches. Subscribe once and every new launch, reschedule
and scrub shows up in your calendar, in your own time zone.
Expand Down Expand Up @@ -98,6 +99,11 @@ only print the changes, and `--calendar all=<calendarId>` to sync one calendar i
See the [terms of use](https://launchcal.github.io/terms.html) and
[privacy policy](https://launchcal.github.io/privacy.html) (no cookies, no tracking).

## Support

launchcal is free and ad-free. If it helps you catch a launch, you can
[buy me a coffee](https://buymeacoffee.com/roma321m).

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md). Report security issues privately, see [SECURITY.md](SECURITY.md).
Expand Down
7 changes: 4 additions & 3 deletions site/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ document.addEventListener('click', async (event) => {
window.prompt('Copy this calendar link:', link.href);
return;
}
link.dataset.label ??= link.textContent;
link.textContent = 'Copied';
const label = link.querySelector('span') ?? link;
link.dataset.label ??= label.textContent;
label.textContent = 'Copied';
copyStatus.textContent = 'Link copied';
clearTimeout(link.copyTimer);
link.copyTimer = setTimeout(() => {
link.textContent = link.dataset.label;
label.textContent = link.dataset.label;
copyStatus.textContent = '';
}, 1500);
});
Expand Down
4 changes: 4 additions & 0 deletions site/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<body>
<header class="page-header">
<a class="wordmark-link" href="./">launchcal</a>
<nav class="project-links" aria-label="Project">
<a class="github-link" href="https://github.com/launchcal/launchcal.github.io" aria-label="launchcal on GitHub"><svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="fill" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg><span class="wide-only">GitHub</span></a>
<a class="btn btn-small" href="https://buymeacoffee.com/roma321m" target="_blank" rel="noopener" aria-label="Buy me a coffee"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 9h12v5a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V9zM16 11h1.5a2.5 2.5 0 0 1 0 5H16M8 3v3M12 3v3"/></svg><span>Buy me a coffee</span></a>
</nav>
</header>

<main class="legal">
Expand Down
115 changes: 111 additions & 4 deletions site/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,101 @@ h3 {
padding: 96px var(--gutter) 48px;
}

.wordmark {
/* Top bar over the hero: wordmark on the left, GitHub and Buy Me a Coffee on the right. */
.topbar {
position: absolute;
top: 24px;
z-index: 2;
top: 16px;
left: var(--gutter);
right: var(--gutter);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}

.wordmark {
margin: 0;
font-size: 1rem;
letter-spacing: 0.35em;
}

.project-links {
display: flex;
align-items: center;
gap: 16px;
}

.github-link {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
font-weight: 700;
font-size: 0.8rem;
letter-spacing: 0.14em;
text-transform: uppercase;
text-decoration: none;
}

.github-link:hover {
text-decoration: underline;
text-underline-offset: 4px;
}

.github-link .icon {
width: 24px;
height: 24px;
}

.wide-only {
display: none;
}

@media (max-width: 599px) {
.topbar .wordmark,
.wordmark-link {
font-size: 0.85rem;
letter-spacing: 0.25em;
}

.project-links {
gap: 12px;
}
}

@media (max-width: 359px) {
.btn-small span {
display: none;
}

.btn-small .icon {
margin-right: 0;
}
}

@media (min-width: 600px) {
.wide-only {
display: inline;
}
}

.icon {
flex: none;
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}

.icon .fill {
fill: currentColor;
stroke: none;
}

.lede {
max-width: 34em;
margin: 0 0 32px;
Expand Down Expand Up @@ -142,9 +228,10 @@ h3 {
padding: 96px 48px 96px max(48px, calc((100vw - 1200px) / 2 + 48px));
}

.wordmark {
top: 40px;
.topbar {
top: 28px;
left: max(48px, calc((100vw - 1200px) / 2 + 48px));
right: max(48px, calc((100vw - 1200px) / 2 + 48px));
}
}

Expand Down Expand Up @@ -183,6 +270,22 @@ a:focus-visible {
border-width: 2px;
}

.btn .icon {
margin-right: 10px;
}

.btn-small {
min-height: 40px;
padding: 8px 12px;
font-size: 0.75rem;
white-space: nowrap;
background: rgba(0, 0, 0, 0.35);
}

.btn-small .icon {
margin-right: 8px;
}

.card .btn-primary {
background: var(--fg);
color: var(--bg);
Expand Down Expand Up @@ -368,6 +471,10 @@ footer p {

/* Terms and privacy pages. */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
max-width: 1200px;
margin: 0 auto;
padding: 24px var(--gutter);
Expand Down
4 changes: 4 additions & 0 deletions site/terms.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
<body>
<header class="page-header">
<a class="wordmark-link" href="./">launchcal</a>
<nav class="project-links" aria-label="Project">
<a class="github-link" href="https://github.com/launchcal/launchcal.github.io" aria-label="launchcal on GitHub"><svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="fill" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg><span class="wide-only">GitHub</span></a>
<a class="btn btn-small" href="https://buymeacoffee.com/roma321m" target="_blank" rel="noopener" aria-label="Buy me a coffee"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 9h12v5a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V9zM16 11h1.5a2.5 2.5 0 0 1 0 5H16M8 3v3M12 3v3"/></svg><span>Buy me a coffee</span></a>
</nav>
</header>

<main class="legal">
Expand Down
11 changes: 9 additions & 2 deletions src/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@
<body>
<header class="hero">
<img class="hero-photo" src="img/crs-20-launch.jpg" width="1277" height="1920" alt="A Falcon 9 rocket lifting off at night, its flame reflected in the water in front of the launch pad.">
<div class="hero-text">
<div class="topbar">
<p class="wordmark">launchcal</p>
<nav class="project-links" aria-label="Project">
<a class="github-link" href="https://github.com/launchcal/launchcal.github.io" aria-label="launchcal on GitHub"><svg class="icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path class="fill" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/></svg><span class="wide-only">GitHub</span></a>
<a class="btn btn-small" href="https://buymeacoffee.com/roma321m" target="_blank" rel="noopener" aria-label="Buy me a coffee"><svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 9h12v5a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V9zM16 11h1.5a2.5 2.5 0 0 1 0 5H16M8 3v3M12 3v3"/></svg><span>Buy me a coffee</span></a>
</nav>
</div>
<div class="hero-text">
<h1>SpaceX launches<br>in your calendar</h1>
<p class="lede">Free, unofficial calendars that keep themselves up to date. Scrubs and new launch times show up within hours, in your own time zone.</p>
<a class="btn btn-primary" href="#calendars">Choose a calendar</a>
Expand Down Expand Up @@ -51,7 +57,7 @@ <h2>What it looks like</h2>
<figcaption>Launches sit in your week next to everything else, marked as free time.</figcaption>
</figure>
</div>
<p class="shots-note">Screenshots of the public Google calendar, October 2026, with colours inverted to match this page.</p>
<p class="shots-note">Screenshots of the public Google calendar, October 2026.</p>
</section>

<section class="calendars" id="calendars">
Expand Down Expand Up @@ -80,6 +86,7 @@ <h2>Good to know</h2>
<p><strong>Unofficial.</strong> Not affiliated with, endorsed by or connected to SpaceX.</p>
<p>Launch data: <a href="https://thespacedevs.com/llapi">The Space Devs</a> (Launch Library 2). Photo: CRS-20 launch, NASA/Tony Gray and Tim Terry; NASA does not endorse this site.</p>
<p>Open source under the MIT license on <a href="https://github.com/launchcal/launchcal.github.io">GitHub</a>. No cookies, no tracking.</p>
<p>Free and ad-free. If it helps you catch a launch, you can <a href="https://buymeacoffee.com/roma321m" target="_blank" rel="noopener">buy me a coffee</a>.</p>
<p><a href="terms.html">Terms of use</a> 路 <a href="privacy.html">Privacy</a></p>
</footer>
<p class="visually-hidden" id="copy-status" role="status"></p>
Expand Down
15 changes: 12 additions & 3 deletions src/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ export const SITE_URL = 'https://launchcal.github.io';
/** The fields of an event the landing page shows in its "next launch" panel. */
export type NextLaunch = Pick<CalendarEvent, 'title' | 'start' | 'end' | 'timed' | 'confirmed' | 'location' | 'url'>;

/** Neutral outline icons for the card buttons; brand logos are deliberately not used. */
const ICON_CALENDAR_ADD = icon('<rect x="3" y="5" width="18" height="16" rx="1"/><path d="M3 10h18M8 3v4M16 3v4M12 13v6M9 16h6"/>');
const ICON_SUBSCRIBE = icon('<path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.5"/>');
const ICON_LINK = icon('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>');

function icon(paths: string): string {
return `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`;
}

/** Google's own share link: opens "Add this calendar?" for a signed-in user. */
export function googleAddUrl(googleId: string): string {
return `https://calendar.google.com/calendar/u/0?cid=${Buffer.from(googleId).toString('base64').replace(/=+$/, '')}`;
Expand All @@ -21,9 +30,9 @@ export function renderPage(template: string, calendars: CalendarDef[]): string {
<h3>${name}</h3>
<p>${escapeHtml(calendar.blurb)}</p>
<div class="actions">
<a class="btn btn-primary" href="${escapeHtml(googleAddUrl(calendar.googleId))}" target="_blank" rel="noopener" aria-label="Add ${name} to Google Calendar">Add to Google Calendar</a>
<a class="btn" href="${escapeHtml(ics.replace('https://', 'webcal://'))}" aria-label="Subscribe to ${name} in Apple Calendar or Outlook">Apple / Outlook</a>
<a class="btn btn-quiet" href="${escapeHtml(ics)}" data-copy aria-label="Copy the ${name} ICS link">Copy ICS link</a>
<a class="btn btn-primary" href="${escapeHtml(googleAddUrl(calendar.googleId))}" target="_blank" rel="noopener" aria-label="Add ${name} to Google Calendar">${ICON_CALENDAR_ADD}<span>Add to Google Calendar</span></a>
<a class="btn" href="${escapeHtml(ics.replace('https://', 'webcal://'))}" aria-label="Subscribe to ${name} in Apple Calendar or Outlook">${ICON_SUBSCRIBE}<span>Apple / Outlook</span></a>
<a class="btn btn-quiet" href="${escapeHtml(ics)}" data-copy aria-label="Copy the ${name} ICS link">${ICON_LINK}<span>Copy ICS link</span></a>
</div>
</article>`;
});
Expand Down
9 changes: 9 additions & 0 deletions test/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ describe('renderPage', () => {
assert.ok(html.includes('aria-label="Copy the SpaceX crewed launches ICS link"'));
});

it('puts a decorative icon before each button label, with the label in its own span for the copy feedback', () => {
const html = renderPage(template, [calendar]);

assert.equal(html.match(/<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">/g)!.length, 3);
assert.ok(html.includes('</svg><span>Add to Google Calendar</span></a>'));
assert.ok(html.includes('</svg><span>Apple / Outlook</span></a>'));
assert.ok(html.includes('</svg><span>Copy ICS link</span></a>'));
});

it('escapes calendar text', () => {
const html = renderPage(template, [{ ...calendar, name: 'Crew <b>& "friends"</b>', blurb: 'Dragon <i>& co</i>' }]);

Expand Down
Loading