site: ui/ux refresh, seo pass, find/lists indexes, 25 new finds
- newspaper-style masthead (stats | "Unique" | About/Blog) on every page with per-page italic subheader + last-updated date on listing pages; sitewide 42rem reading width - pinterest home (hero + masonry, "load more"); old list view at /index3/ - new pages: /about, /blog, /legal, /find/, /finds/, custom 404 - footer collection links now clickable; "founded" stat replaces "updated" in the masthead - centralised SEO component with per-page json-ld (Review, BlogPosting, Product, Article, CollectionPage, ItemList, BreadcrumbList, etc.); @astrojs/sitemap + @astrojs/rss; robots.txt; promoted finds canonical to their review - memoize getSiteStats() so the masthead/footer counts don't multiply per-page build cost - 25 new daily-finds captures
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
---
|
||||
export interface OgImage {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
alt: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalPath?: string;
|
||||
canonicalOverride?: string;
|
||||
ogType?: 'website' | 'article';
|
||||
ogImage?: OgImage;
|
||||
noindex?: boolean;
|
||||
publishedTime?: Date;
|
||||
modifiedTime?: Date;
|
||||
articleTags?: string[];
|
||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
canonicalPath,
|
||||
canonicalOverride,
|
||||
ogType = 'website',
|
||||
ogImage,
|
||||
noindex = false,
|
||||
publishedTime,
|
||||
modifiedTime,
|
||||
articleTags = [],
|
||||
jsonLd,
|
||||
} = Astro.props;
|
||||
|
||||
const SITE_NAME = 'Unique';
|
||||
const titleTag = title === SITE_NAME ? SITE_NAME : `${title} — ${SITE_NAME}`;
|
||||
|
||||
const canonicalUrl =
|
||||
canonicalOverride ??
|
||||
new URL(canonicalPath ?? Astro.url.pathname, Astro.site).href;
|
||||
|
||||
const jsonLdBlocks = jsonLd
|
||||
? Array.isArray(jsonLd)
|
||||
? jsonLd
|
||||
: [jsonLd]
|
||||
: [];
|
||||
---
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{titleTag}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
{noindex && <meta name="robots" content="noindex,nofollow" />}
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="theme-color" content="#fafaf7" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#161613" media="(prefers-color-scheme: dark)" />
|
||||
<link rel="alternate" type="application/rss+xml" title={`${SITE_NAME} — RSS`} href="/rss.xml" />
|
||||
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
{ogImage && (
|
||||
<>
|
||||
<meta property="og:image" content={ogImage.url} />
|
||||
<meta property="og:image:width" content={String(ogImage.width)} />
|
||||
<meta property="og:image:height" content={String(ogImage.height)} />
|
||||
<meta property="og:image:alt" content={ogImage.alt} />
|
||||
{ogImage.type && <meta property="og:image:type" content={ogImage.type} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{ogType === 'article' && publishedTime && (
|
||||
<meta property="article:published_time" content={publishedTime.toISOString()} />
|
||||
)}
|
||||
{ogType === 'article' && modifiedTime && (
|
||||
<meta property="article:modified_time" content={modifiedTime.toISOString()} />
|
||||
)}
|
||||
{ogType === 'article' &&
|
||||
articleTags.map((t) => <meta property="article:tag" content={t} />)}
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
{ogImage && (
|
||||
<>
|
||||
<meta name="twitter:image" content={ogImage.url} />
|
||||
<meta name="twitter:image:alt" content={ogImage.alt} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{jsonLdBlocks.map((block) => (
|
||||
<script type="application/ld+json" is:inline set:html={JSON.stringify(block)} />
|
||||
))}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "Anker Nano Universal Travel Adapter"
|
||||
subtitle: "All four plug standards plus four USB ports in something the size of a deck of cards"
|
||||
date: 2026-05-03
|
||||
link: https://www.amazon.com/dp/B0DHVNW1CN
|
||||
linkText: "Buy on Amazon"
|
||||
source: "Six Colors"
|
||||
topics: [tools, travel]
|
||||
tags: [travel, charging, edc]
|
||||
---
|
||||
|
||||
The smallest universal travel adapter we've seen that still includes all four common plug regions (US/EU/UK/AU) plus two USB-C and two USB-A outputs at a useful 30W combined. Roughly an inch thick and three inches long, it replaces a tangle of region-specific dongles with a single brick that lives permanently in the toiletry bag.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "Aootek Motion-Sensing Solar Fence Lights"
|
||||
subtitle: "Dim until something moves, then bright enough to actually see by"
|
||||
date: 2026-05-03
|
||||
link: https://www.amazon.com/Aootek-Motion-Sensor-Lights-Daylight/dp/B0GFM7PS73
|
||||
linkText: "Buy on Amazon"
|
||||
source: "Recomendo"
|
||||
topics: [tools, gifts]
|
||||
tags: [outdoor, solar, motion-sensor]
|
||||
---
|
||||
|
||||
A six-pack of fence-mountable solar lights for around twenty dollars that solves the long-standing problem with cheap solar lights — they hold a real charge and they're bright enough to matter. The smart mode dims them to ambient at night and ramps them up the moment a person or animal triggers the PIR sensor, so you get useful light without leaving full brightness on for hours.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "Birdfy AI Smart Bird Feeder"
|
||||
subtitle: "Camera feeder that names every visiting species and clips the good takeoffs"
|
||||
date: 2026-05-03
|
||||
link: https://www.amazon.com/NETVUE-Birdfy-Watching-Capture-Detection/dp/B0B56BVWBM
|
||||
linkText: "Buy on Amazon"
|
||||
source: "Recomendo"
|
||||
topics: [gifts, general-delight]
|
||||
tags: [outdoor, birds, ai]
|
||||
---
|
||||
|
||||
A bird feeder with a small camera in the eaves that auto-identifies the species visiting your yard and saves a clip every time one lands. The device is the gateway to a hidden category of attention you weren't paying — it turns out a backyard feeder gets dozens of distinct species through it in a season, and seeing the names attached changes how you notice the rest of the day.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "Casabrews CM5418 Espresso Machine"
|
||||
subtitle: "Sub-$200 espresso machine that punches well above its price"
|
||||
date: 2026-05-03
|
||||
link: https://www.amazon.com/Espresso-Professional-Removable-Cappuccino-Macchiato/dp/B09X3WGJ3R
|
||||
linkText: "Buy on Amazon"
|
||||
source: "Recomendo"
|
||||
topics: [tools, gifts]
|
||||
tags: [coffee, kitchen, espresso]
|
||||
---
|
||||
|
||||
A 20-bar pump espresso machine with a real steam wand for under two hundred dollars — the rare cheap espresso maker whose pulls aren't visibly thin and whose milk frothing actually works. The point isn't to compete with a Breville; it's to be the machine that makes the daily Nespresso-pod habit feel embarrassing within a week.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Cilicon"
|
||||
subtitle: "Throwaway macOS virtual machines via Apple's Virtualization framework"
|
||||
date: 2026-05-03
|
||||
link: https://github.com/traderepublic/Cilicon
|
||||
source: "One Thing Well"
|
||||
topics: [macos-apps, tools]
|
||||
tags: [virtualization, ci, open-source]
|
||||
---
|
||||
|
||||
A small open-source macOS app from Trade Republic that spins up a fresh, ephemeral VM from a template, runs whatever script you point at it, and tears the whole thing down on completion. It was built as a self-hosted CI runner for iOS apps, but it's quietly useful any time you want a clean Mac for ten minutes without polluting your real one — testing an installer, opening a sketchy DMG, profiling a build from scratch.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Companion Apps for Apple Music"
|
||||
subtitle: "Faithful re-creations of the classic iTunes interfaces, riding on top of modern Music"
|
||||
date: 2026-05-03
|
||||
link: https://marioaguzman.github.io/music/
|
||||
source: "One Thing Well"
|
||||
topics: [macos-apps, general-delight]
|
||||
tags: [retro, music, apple]
|
||||
---
|
||||
|
||||
A small set of macOS apps from designer Mario A. Guzman that wrap Apple Music in pixel-accurate recreations of the classic iTunes 1, iTunes 4, and early iPod-era interfaces — brushed-metal title bar and all. They're not skins; they're standalone players that actually drive your library, so the nostalgia comes with the convenience of streaming you already pay for.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Damnlines"
|
||||
subtitle: "Live webcams of the queues outside viral New York food spots"
|
||||
date: 2026-05-03
|
||||
link: https://damnlines.com/
|
||||
source: "Web Curios"
|
||||
topics: [general-delight, travel]
|
||||
tags: [nyc, webcam, food]
|
||||
---
|
||||
|
||||
A grid of street-level webcams pointed at the lines outside whichever bagel shop, soft-serve window, or pop-up bakery TikTok decided to ruin this week. Useful as a planning tool ("is the wait at Levain actually two hours right now?") and even better as ambient TV — a quiet meditation on what New Yorkers will, in fact, stand outside in the rain for.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "DockPops"
|
||||
subtitle: "iPhone-style app folders for the macOS Dock"
|
||||
date: 2026-05-03
|
||||
link: https://apps.apple.com/us/app/dockpops/id6759999009
|
||||
linkText: "Visit on App Store"
|
||||
source: "r/macapps"
|
||||
topics: [macos-apps]
|
||||
tags: [dock, productivity]
|
||||
---
|
||||
|
||||
A Mac app that gives the Dock the one feature it has never had — collapsible app folders that work like iOS's home-screen groups. Unlike macOS Stacks, the folders aren't tied to real directories on disk, so you can group apps by intent ("design tools", "writing"), give each folder its own Dock icon, and get a live preview of what's inside on hover.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Embassy of the Free Mind"
|
||||
subtitle: "Amsterdam museum and rare-book library for Hermetic and occult manuscripts"
|
||||
date: 2026-05-03
|
||||
link: https://embassyofthefreemind.com/en/
|
||||
source: "Cool Tools"
|
||||
topics: [travel, general-delight]
|
||||
tags: [museum, amsterdam, books]
|
||||
---
|
||||
|
||||
One of the world's largest collections of Hermetic, alchemical, and esoteric texts, housed in a 17th-century canal house in Amsterdam and open to the public via themed guided tours (alchemy, magic, witchcraft, Rosicrucianism). Thousands of manuscripts have been digitized and are free to read online, but the visit is the point — you don't get to touch the books, but you can smell ones that are five centuries old.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "Espro P7 French Press"
|
||||
subtitle: "Double micro-mesh filter that actually keeps grit out of the cup"
|
||||
date: 2026-05-03
|
||||
link: https://www.amazon.com/dp/B079T44WPZ
|
||||
linkText: "Buy on Amazon"
|
||||
source: "Tools and Toys"
|
||||
topics: [tools, gifts]
|
||||
tags: [coffee, kitchen, stainless-steel]
|
||||
---
|
||||
|
||||
A vacuum-insulated stainless French press whose filter is two stacked layers of woven micro-mesh — fine enough that the cup pours clean, with none of the silt that makes most French press coffee feel like sand at the bottom. The double wall keeps the brew warm for the second and third cups, which is when the standard glass press has already gone tepid.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: 'Felo "Ergonic" Screwdriver Set'
|
||||
subtitle: "Soft handles that deform under load to deliver more torque"
|
||||
date: 2026-05-03
|
||||
link: https://www.amazon.com/dp/B002YNQVPE
|
||||
linkText: "Buy on Amazon"
|
||||
source: "Tools and Toys"
|
||||
topics: [tools, gifts]
|
||||
tags: [hand-tools, ergonomic, made-in-germany]
|
||||
---
|
||||
|
||||
A German-made screwdriver set whose handles use a softer outer compound than the usual hard plastic — the grip squashes against your palm under twisting load, putting more skin in contact with the tool and reducing the pressure points that make long screw-driving sessions hurt. The shafts are chrome-vanadium with magnetized tips. It's the rare case of a household tool that's quietly nicer to hold than its peers without costing five times as much.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "FlutterTime"
|
||||
subtitle: "Menu-bar timezone converter with a draggable timeline"
|
||||
date: 2026-05-03
|
||||
link: https://apps.apple.com/us/app/fluttertime-timezone-converter/id6740316879
|
||||
linkText: "Visit on App Store"
|
||||
source: "r/macapps"
|
||||
topics: [macos-apps]
|
||||
tags: [timezone, menu-bar, free]
|
||||
---
|
||||
|
||||
A free, ad-free, no-tracking menu-bar app that does one thing — convert times across the cities you care about by dragging a single horizontal timeline forward and back. The visual model is the right one for the problem (you immediately *see* whether 9am Berlin is before or after your Tokyo colleague's lunch), and unlike most timezone converters it stays where you can glance at it.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "FrogFind"
|
||||
subtitle: "Search engine designed to render in browsers from 1995"
|
||||
date: 2026-05-03
|
||||
link: http://www.frogfind.com
|
||||
source: "One Thing Well"
|
||||
topics: [tools, general-delight]
|
||||
tags: [retro-computing, search, web]
|
||||
---
|
||||
|
||||
A DuckDuckGo-powered search front end that strips every page through a readability filter and serves it as plain HTML so it loads on a Power Mac, a System 7 emulator, a vintage PalmPilot, or any browser too old for TLS 1.2. The genuinely useful side effect is that even modern browsers get a clean, ad-free, JavaScript-free version of any page they visit through it.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Penumbra Magic Postcards"
|
||||
subtitle: "Paper postcards that play a video when you scan the QR"
|
||||
date: 2026-05-03
|
||||
link: https://www.penumbraprint.com/shop/
|
||||
source: "Web Curios"
|
||||
topics: [gifts, general-delight]
|
||||
tags: [print, ar, gift]
|
||||
---
|
||||
|
||||
Postcards printed with an unobtrusive QR that links to a private hosted video or photo set, so the recipient gets the physical card *and* a thirty-second clip from the trip. The hybrid solves the long-running paradox of postcards — the format is wonderful, but a still photo of a beach has lost most of what made you want to send it.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Magnified Sand"
|
||||
subtitle: "One-person archive of sand from around the world, photographed under a microscope"
|
||||
date: 2026-05-03
|
||||
link: https://magnifiedsand.com/
|
||||
source: "Recomendo"
|
||||
topics: [general-delight]
|
||||
tags: [website, science, archive]
|
||||
---
|
||||
|
||||
A labor-of-love site by a single collector who photographs sand samples from beaches all over the world at high magnification. What looks like a uniform tan blanket from a meter up turns out, in the microscope, to be a constantly varying mix of crushed quartz, pulverized shells, volcanic glass, and tiny fossils — every beach has a different signature, and the gallery makes that legible.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Notepad++ for Mac"
|
||||
subtitle: "Native macOS port of the Windows-only text editor people refuse to give up"
|
||||
date: 2026-05-03
|
||||
link: https://notepad-plus-plus-mac.org/
|
||||
source: "r/macapps"
|
||||
topics: [macos-apps]
|
||||
tags: [editor, open-source, native]
|
||||
---
|
||||
|
||||
A community-led, Apple-notarized port of Notepad++ written in Objective-C++ against the official source — not a Wine wrapper, not an Electron shell, an actual Universal Binary that runs natively on Apple Silicon. It exists for the population of switchers who never found a Mac editor with the same combination of zero-config, multi-document, regex-everywhere muscle memory.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Refine"
|
||||
subtitle: "Local-first writing assistant that runs entirely on-device"
|
||||
date: 2026-05-03
|
||||
link: https://refine.sh
|
||||
source: "r/macapps"
|
||||
topics: [macos-apps]
|
||||
tags: [writing, privacy, local-ai]
|
||||
---
|
||||
|
||||
A Grammarly-shaped writing assistant for the Mac that does grammar, tone, and style suggestions without sending a word over the wire — every model runs locally on Apple Silicon. Nine months of community feedback have sanded down the rough edges, and unlike most "local AI" apps it works in any text field system-wide, not just inside its own editor.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "Roll: Disposable Camera"
|
||||
subtitle: "Camera app that hides your photos for three days to a year"
|
||||
date: 2026-05-03
|
||||
link: https://apps.apple.com/us/app/roll-disposable-camera/id6759272118
|
||||
linkText: "Visit on App Store"
|
||||
source: "Web Curios"
|
||||
topics: [general-delight]
|
||||
tags: [ios, photography, slow]
|
||||
---
|
||||
|
||||
An iPhone camera that takes a photo and then refuses to show it to you — for three days, three weeks, three months, or up to a year, your choice. The trick of it is the gap: by the time the picture surfaces, your memory has dimmed enough that the image lands like a postcard from someone else, and the throwaway shots feel earned in a way the camera roll never quite does.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "Skull (Card Game)"
|
||||
subtitle: "Three roses, one skull, and the pile in the middle that everyone is lying about"
|
||||
date: 2026-05-03
|
||||
link: https://www.amazon.com/Bluffing-Strategy-Players-Average-Playtime/dp/B00GYDLY8E
|
||||
linkText: "Buy on Amazon"
|
||||
source: "Recomendo"
|
||||
topics: [gifts, general-delight]
|
||||
tags: [board-game, party, bluffing]
|
||||
---
|
||||
|
||||
A bluffing game with the simplest possible rule sheet — each player gets three rose cards and one skull, plays a card face-down, and at any point can challenge to flip a chosen number of cards from the stacks without hitting a skull. It plays in twenty minutes, the table tension is immediate, and unlike most party games it works just as well with three players as with six.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Stillgram"
|
||||
subtitle: "iPhone camera that erases the crowd around the landmark"
|
||||
date: 2026-05-03
|
||||
link: https://stillgram.io/
|
||||
source: "Recomendo"
|
||||
topics: [tools, travel]
|
||||
tags: [ios, photography, ai]
|
||||
---
|
||||
|
||||
An iPhone camera app that runs on-device AI to remove other people from your travel photos in real time — point it at the Trevi Fountain or Shibuya Crossing, snap, and you get the landmark uncluttered. The Pro tier ($14.99) lets you tap to keep specific people in the frame, so it doubles as the "everyone except my kid in front of the Eiffel Tower" mode that no other app does well.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Tom's Keys"
|
||||
subtitle: "Third-party car key fobs you program yourself for a fraction of dealer prices"
|
||||
date: 2026-05-03
|
||||
link: https://tomskey.com/
|
||||
source: "Recomendo"
|
||||
topics: [tools]
|
||||
tags: [auto, diy]
|
||||
---
|
||||
|
||||
A small operation selling aftermarket key fobs for most US-market cars at roughly a quarter of what the dealer charges, with online instructions that walk you through the manufacturer's bizarre programming sequence (open the door six times, insert key twice, press both buttons, repeat). The dance is the dealer's fault, not Tom's — and the spare fob you've been putting off for two years suddenly costs $98 instead of $400.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "UGREEN Uno MagSafe Power Bank"
|
||||
subtitle: "Tiny face on the screen reacts to your phone's battery level"
|
||||
date: 2026-05-03
|
||||
link: https://www.amazon.com/dp/B0D228PXC7
|
||||
linkText: "Buy on Amazon"
|
||||
source: "Tools and Toys"
|
||||
topics: [tools, gifts]
|
||||
tags: [charging, magsafe, delight]
|
||||
---
|
||||
|
||||
A 5,000mAh MagSafe-compatible power bank with a small front-facing screen that shows a cartoon face whose expression changes with the iPhone's charge state — sleepy when low, beaming when topped up. It's a charger that earns a glance instead of being silently buried in a bag, and it's the kind of small humanizing touch you don't expect from a USB-C accessory.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: "umoven Pop-Up Bifold Wallet"
|
||||
subtitle: "Push the slider and the cards fan out for you"
|
||||
date: 2026-05-03
|
||||
link: https://www.amazon.com/dp/B0CFV6FYGV
|
||||
linkText: "Buy on Amazon"
|
||||
source: "Tools and Toys"
|
||||
topics: [tools, gifts]
|
||||
tags: [edc, wallet, rfid]
|
||||
---
|
||||
|
||||
An aluminum-framed wallet with a thumb slider on the spine that ejects the card stack in a fanned arc, so the card you reach for is the card you see. RFID-blocked, holds 8–10 cards plus folded bills under an external strap. It's the closest a wallet gets to feeling mechanical — there's a small satisfying *click* every time you use it.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: "Unruly Play"
|
||||
subtitle: "Archive of 169 unconventional games for unconventional places"
|
||||
date: 2026-05-03
|
||||
link: https://unrulyplay.com
|
||||
source: "Kottke.org"
|
||||
topics: [general-delight]
|
||||
tags: [games, archive, public-space]
|
||||
---
|
||||
|
||||
A digital archive of 169 games, performances, and playful provocations designed to be staged in unexpected public locations — escalators, parking lots, hotel hallways, library stacks. Most are explained in a paragraph and a diagram and are free to use; the value is in the implicit argument that you can manufacture delight in pretty much any built environment if you bring the rules with you.
|
||||
+248
-31
@@ -1,33 +1,136 @@
|
||||
---
|
||||
import { getSiteStats } from '../lib/entries';
|
||||
import SEO from '../components/SEO.astro';
|
||||
import type { OgImage } from '../components/SEO.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
wide?: boolean;
|
||||
subheader?: string;
|
||||
lastUpdated?: Date;
|
||||
canonicalPath?: string;
|
||||
canonicalOverride?: string;
|
||||
ogType?: 'website' | 'article';
|
||||
ogImage?: OgImage;
|
||||
noindex?: boolean;
|
||||
publishedTime?: Date;
|
||||
modifiedTime?: Date;
|
||||
articleTags?: string[];
|
||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
||||
}
|
||||
|
||||
const { title, description = 'Unique — a blog about delightful things.', wide = false } = Astro.props;
|
||||
const {
|
||||
title,
|
||||
description = 'A small log of delightful, unique things — products, apps, phenomena, oddities.',
|
||||
subheader,
|
||||
lastUpdated,
|
||||
canonicalPath,
|
||||
canonicalOverride,
|
||||
ogType,
|
||||
ogImage,
|
||||
noindex,
|
||||
publishedTime,
|
||||
modifiedTime,
|
||||
articleTags,
|
||||
jsonLd,
|
||||
} = Astro.props;
|
||||
const siteName = 'Unique';
|
||||
const SITE_FOUNDED = new Date(2026, 4, 3);
|
||||
const stats = await getSiteStats();
|
||||
|
||||
const fmtLong = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
const fmtShort = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const updatedDate = lastUpdated ?? stats.lastUpdated;
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>{title === siteName ? siteName : `${title} — ${siteName}`}</title>
|
||||
<SEO
|
||||
title={title}
|
||||
description={description}
|
||||
canonicalPath={canonicalPath}
|
||||
canonicalOverride={canonicalOverride}
|
||||
ogType={ogType}
|
||||
ogImage={ogImage}
|
||||
noindex={noindex}
|
||||
publishedTime={publishedTime}
|
||||
modifiedTime={modifiedTime}
|
||||
articleTags={articleTags}
|
||||
jsonLd={jsonLd}
|
||||
/>
|
||||
</head>
|
||||
<body class:list={[{ wide }]}>
|
||||
<header>
|
||||
<a href="/" class="brand">{siteName}</a>
|
||||
<p class="tagline">A small log of delightful things.</p>
|
||||
<body>
|
||||
<header class="masthead">
|
||||
<div class="masthead-row">
|
||||
<div class="masthead-stats">
|
||||
<span>{stats.total} entries</span>
|
||||
<span>founded {fmtShort.format(SITE_FOUNDED)}</span>
|
||||
</div>
|
||||
<a href="/" class="masthead-brand">{siteName}</a>
|
||||
<nav class="masthead-nav" aria-label="Primary">
|
||||
<a href="/about/">About</a>
|
||||
<a href="/blog/">Blog</a>
|
||||
</nav>
|
||||
</div>
|
||||
{subheader && (
|
||||
<div class="subheader">
|
||||
<p class="subheader-line">{subheader}</p>
|
||||
{updatedDate && (
|
||||
<p class="subheader-date">
|
||||
Updated <time datetime={updatedDate.toISOString()}>{fmtLong.format(updatedDate)}</time>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
<footer>
|
||||
<p>© {new Date().getFullYear()} Unique</p>
|
||||
<footer class="site-footer">
|
||||
<div class="site-footer-cols">
|
||||
<section>
|
||||
<h4>Browse</h4>
|
||||
<ul>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/grid/">Grid</a></li>
|
||||
<li><a href="/sources/">Sources</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h4>Collections</h4>
|
||||
<ul>
|
||||
<li><a href="/grid/">Reviews ({stats.reviews})</a></li>
|
||||
<li><a href="/find/">Finds ({stats.finds})</a></li>
|
||||
<li><a href="/finds/">Lists ({stats.lists})</a></li>
|
||||
<li><a href="/blog/">Posts ({stats.posts})</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h4>About</h4>
|
||||
<ul>
|
||||
<li><a href="/about/">About</a></li>
|
||||
<li><a href="/blog/">Blog</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<div class="site-footer-legal">
|
||||
<p>
|
||||
© {year} Unique
|
||||
<span aria-hidden="true">·</span>
|
||||
<a href="/legal/">Legal</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
<style is:global>
|
||||
:root {
|
||||
@@ -61,38 +164,152 @@ const siteName = 'Unique';
|
||||
body {
|
||||
max-width: var(--max);
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
padding: 1.5rem 1.25rem 2rem;
|
||||
}
|
||||
body.wide { max-width: 76rem; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
header {
|
||||
margin-bottom: 2.5rem;
|
||||
|
||||
/* Masthead */
|
||||
.masthead {
|
||||
margin-bottom: 2rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
header .brand {
|
||||
.masthead-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.1rem 0;
|
||||
}
|
||||
.masthead-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
color: var(--fg);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
header .tagline {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.masthead-brand {
|
||||
font-family: ui-serif, Georgia, 'Iowan Old Style', 'Apple Garamond', 'Palatino Linotype', serif;
|
||||
font-weight: 700;
|
||||
font-size: clamp(2.4rem, 6vw, 4rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.masthead-brand:hover { text-decoration: none; color: var(--accent); }
|
||||
.masthead-nav {
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.15rem;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.masthead-nav a { color: var(--muted); }
|
||||
.masthead-nav a:hover { color: var(--accent); }
|
||||
|
||||
.subheader {
|
||||
text-align: center;
|
||||
padding: 0.85rem 0 1rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.subheader-line {
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
font-size: 1rem;
|
||||
color: var(--fg);
|
||||
}
|
||||
.subheader-date {
|
||||
margin: 0.25rem 0 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.masthead-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.6rem;
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
.masthead-stats {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
order: 2;
|
||||
}
|
||||
.masthead-brand { order: 1; }
|
||||
.masthead-nav {
|
||||
justify-self: center;
|
||||
align-items: center;
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
|
||||
main { min-height: 60vh; }
|
||||
footer {
|
||||
|
||||
/* Site footer */
|
||||
.site-footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1.25rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
footer p { margin: 0; }
|
||||
.site-footer-cols {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.site-footer-cols section {
|
||||
min-width: 7rem;
|
||||
}
|
||||
.site-footer ul { align-items: center; }
|
||||
.site-footer h4 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg);
|
||||
font-weight: 700;
|
||||
}
|
||||
.site-footer ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.site-footer ul a { color: var(--muted); }
|
||||
.site-footer ul a:hover { color: var(--accent); }
|
||||
.site-footer-legal {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
text-align: center;
|
||||
}
|
||||
.site-footer-legal p { margin: 0; }
|
||||
.site-footer-legal a { color: var(--muted); }
|
||||
.site-footer-legal a:hover { color: var(--accent); }
|
||||
.site-footer-legal span { margin: 0 0.4rem; }
|
||||
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||
letter-spacing: -0.015em;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Amazon Associates tracking IDs, keyed by Amazon hostname.
|
||||
// Add additional marketplaces here when applicable (each marketplace issues its own ID).
|
||||
const AMAZON_TAGS: Record<string, string> = {
|
||||
'amazon.com': 'unique.rzen.dev-20',
|
||||
};
|
||||
|
||||
// Hostnames that always belong to the US marketplace (short links resolve there).
|
||||
const AMAZON_US_SHORTLINKS = new Set(['amzn.to', 'a.co']);
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.toLowerCase().replace(/^www\./, '');
|
||||
}
|
||||
|
||||
function amazonHostFor(host: string): string | null {
|
||||
const h = normalizeHost(host);
|
||||
if (AMAZON_US_SHORTLINKS.has(h)) return 'amazon.com';
|
||||
if (h === 'smile.amazon.com') return 'amazon.com';
|
||||
if (h === 'amazon.com' || h.endsWith('.amazon.com')) return 'amazon.com';
|
||||
// Other regional marketplaces (amazon.co.uk, amazon.de, etc.). Only mark as
|
||||
// affiliate when we have a tag configured for that marketplace.
|
||||
const match = h.match(/^(?:[\w-]+\.)?(amazon\.(?:[a-z.]+))$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function isAmazonUrl(url: string): boolean {
|
||||
try {
|
||||
return amazonHostFor(new URL(url).hostname) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExternalLink {
|
||||
href: string;
|
||||
isAffiliate: boolean;
|
||||
network?: 'amazon';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the link to render externally. If the URL points at an Amazon
|
||||
* marketplace we have a tag for, append the tag and flag it as affiliate.
|
||||
* Otherwise return the URL unchanged.
|
||||
*/
|
||||
export function resolveExternalLink(rawUrl: string): ExternalLink {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
const marketplace = amazonHostFor(u.hostname);
|
||||
if (!marketplace) return { href: rawUrl, isAffiliate: false };
|
||||
const tag = AMAZON_TAGS[marketplace];
|
||||
if (!tag) return { href: rawUrl, isAffiliate: false, network: 'amazon' };
|
||||
u.searchParams.set('tag', tag);
|
||||
return { href: u.toString(), isAffiliate: true, network: 'amazon' };
|
||||
} catch {
|
||||
return { href: rawUrl, isAffiliate: false };
|
||||
}
|
||||
}
|
||||
|
||||
export const AFFILIATE_REL = 'sponsored noopener noreferrer';
|
||||
export const AFFILIATE_DISCLOSURE =
|
||||
'Affiliate link — we may earn a commission from qualifying Amazon purchases at no extra cost to you.';
|
||||
@@ -50,3 +50,40 @@ export async function getAllEntries(): Promise<Entry[]> {
|
||||
|
||||
return [...r, ...p, ...f].sort((a, b) => b.date.valueOf() - a.date.valueOf());
|
||||
}
|
||||
|
||||
export type SiteStats = {
|
||||
total: number;
|
||||
lastUpdated: Date | null;
|
||||
reviews: number;
|
||||
finds: number;
|
||||
posts: number;
|
||||
lists: number;
|
||||
};
|
||||
|
||||
let statsCache: Promise<SiteStats> | null = null;
|
||||
|
||||
export function getSiteStats(): Promise<SiteStats> {
|
||||
if (!statsCache) statsCache = computeSiteStats();
|
||||
return statsCache;
|
||||
}
|
||||
|
||||
async function computeSiteStats(): Promise<SiteStats> {
|
||||
const entries = await getAllEntries();
|
||||
const findItems = await getCollection('find');
|
||||
const reviews = entries.filter((e) => e.type === 'review').length;
|
||||
const finds = findItems.length;
|
||||
const posts = entries.filter((e) => e.type === 'post').length;
|
||||
const lists = entries.filter((e) => e.type === 'finds').length;
|
||||
const allDates = [
|
||||
...entries.map((e) => e.date),
|
||||
...findItems.map((f) => f.data.date),
|
||||
].sort((a, b) => b.valueOf() - a.valueOf());
|
||||
return {
|
||||
total: reviews + finds + posts + lists,
|
||||
lastUpdated: allDates[0] ?? null,
|
||||
reviews,
|
||||
finds,
|
||||
posts,
|
||||
lists,
|
||||
};
|
||||
}
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { heroes } from './hero';
|
||||
import type { OgImage } from '../components/SEO.astro';
|
||||
|
||||
export type OgFallback = 'reviews' | 'finds' | 'posts' | 'site';
|
||||
|
||||
const SITE_URL = 'https://unique.rzen.dev';
|
||||
|
||||
const FALLBACK_PATHS: Record<OgFallback, string> = {
|
||||
reviews: '/og/reviews.png',
|
||||
finds: '/og/finds.png',
|
||||
posts: '/og/posts.png',
|
||||
site: '/og/default.png',
|
||||
};
|
||||
|
||||
const FALLBACK_DIMS = { width: 1200, height: 630, type: 'image/png' };
|
||||
|
||||
export function buildOgImage(
|
||||
heroId: string | undefined,
|
||||
fallback: OgFallback,
|
||||
alt: string
|
||||
): OgImage {
|
||||
const hero = heroId ? heroes[heroId] : undefined;
|
||||
if (hero?.kind === 'image') {
|
||||
const fmt = hero.src.format;
|
||||
const mime = fmt
|
||||
? `image/${fmt === 'jpg' ? 'jpeg' : fmt}`
|
||||
: undefined;
|
||||
return {
|
||||
url: new URL(hero.src.src, SITE_URL).href,
|
||||
width: hero.src.width,
|
||||
height: hero.src.height,
|
||||
alt,
|
||||
type: mime,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: new URL(FALLBACK_PATHS[fallback], SITE_URL).href,
|
||||
width: FALLBACK_DIMS.width,
|
||||
height: FALLBACK_DIMS.height,
|
||||
alt,
|
||||
type: FALLBACK_DIMS.type,
|
||||
};
|
||||
}
|
||||
|
||||
export function absoluteUrl(path: string): string {
|
||||
return new URL(path, SITE_URL).href;
|
||||
}
|
||||
|
||||
export type Crumb = { name: string; url: string };
|
||||
|
||||
export function buildBreadcrumbs(crumbs: Crumb[]): Record<string, unknown> {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: crumbs.map((c, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
name: c.name,
|
||||
item: absoluteUrl(c.url),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type ListItem = { url: string; name: string };
|
||||
|
||||
export function buildItemList(
|
||||
items: ListItem[],
|
||||
name?: string
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
...(name ? { name } : {}),
|
||||
numberOfItems: items.length,
|
||||
itemListElement: items.map((it, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
name: it.name,
|
||||
url: absoluteUrl(it.url),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export const PERSON_AUTHOR = {
|
||||
'@type': 'Person',
|
||||
name: 'rzen',
|
||||
url: `${SITE_URL}/about/`,
|
||||
};
|
||||
|
||||
export const ORGANIZATION = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
'@id': `${SITE_URL}/#org`,
|
||||
name: 'Unique',
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/favicon.svg`,
|
||||
};
|
||||
|
||||
export const WEBSITE = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
'@id': `${SITE_URL}/#site`,
|
||||
name: 'Unique',
|
||||
url: SITE_URL,
|
||||
description:
|
||||
'A small log of delightful, unique things — products, apps, phenomena, oddities.',
|
||||
publisher: { '@id': `${SITE_URL}/#org` },
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
const SOFTWARE_CATEGORIES = new Set([
|
||||
'apps',
|
||||
'app',
|
||||
'software',
|
||||
'macos-apps',
|
||||
'macos',
|
||||
'ios-apps',
|
||||
'ios',
|
||||
'tools',
|
||||
]);
|
||||
|
||||
export function reviewedItemType(category: string): 'SoftwareApplication' | 'Product' {
|
||||
return SOFTWARE_CATEGORIES.has(category.toLowerCase())
|
||||
? 'SoftwareApplication'
|
||||
: 'Product';
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
const reviews = (await getCollection('reviews')).sort(
|
||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||
);
|
||||
const recent = reviews.slice(0, 6);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Not found"
|
||||
description="That page doesn't exist on Unique."
|
||||
subheader="That page doesn't exist."
|
||||
noindex
|
||||
>
|
||||
<article>
|
||||
<p>The page you were looking for isn't here. Maybe one of these will do:</p>
|
||||
|
||||
<h2>Recent reviews</h2>
|
||||
<ul class="recent">
|
||||
{recent.map((r) => (
|
||||
<li>
|
||||
<a href={`/reviews/${r.id}/`}>{r.data.name}</a>
|
||||
{r.data.subtitle && <span class="subtitle"> — {r.data.subtitle}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h2>Browse</h2>
|
||||
<ul class="nav">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/grid/">Grid</a></li>
|
||||
<li><a href="/sources/">Sources</a></li>
|
||||
<li><a href="/blog/">Blog</a></li>
|
||||
<li><a href="/about/">About</a></li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article h2 {
|
||||
margin: 2rem 0 0.6rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.recent, .nav {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.recent li, .nav li {
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.recent .subtitle { color: var(--muted); }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { buildOgImage, buildBreadcrumbs, absoluteUrl } from '../lib/seo';
|
||||
|
||||
const description =
|
||||
'About Unique — a small, one-person blog cataloguing delightful, unusual, well-made things.';
|
||||
|
||||
const ogImage = buildOgImage(undefined, 'site', 'About Unique');
|
||||
|
||||
const aboutPage: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'AboutPage',
|
||||
'@id': absoluteUrl('/about/#about'),
|
||||
name: 'About — Unique',
|
||||
description,
|
||||
url: absoluteUrl('/about/'),
|
||||
inLanguage: 'en-US',
|
||||
about: {
|
||||
'@type': 'Person',
|
||||
name: 'rzen',
|
||||
url: absoluteUrl('/about/'),
|
||||
},
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'About', url: '/about/' },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="About"
|
||||
description={description}
|
||||
subheader="A small log of delightful things, kept for our own amusement."
|
||||
ogImage={ogImage}
|
||||
jsonLd={[aboutPage, breadcrumbs]}
|
||||
>
|
||||
<article>
|
||||
<p>
|
||||
<strong>Unique</strong> is a quiet little blog that collects the small,
|
||||
well-made things that make ordinary days a bit better — the kind of
|
||||
object, app, or tool you find yourself recommending to a friend without
|
||||
thinking twice.
|
||||
</p>
|
||||
<p>
|
||||
It's run by one person, updated when there's something worth sharing,
|
||||
and indexed by topic so you can browse by interest. Nothing here is
|
||||
sponsored; some outbound links are affiliate links, which costs you
|
||||
nothing extra and helps keep the lights on.
|
||||
</p>
|
||||
<p>
|
||||
Start with the <a href="/">home page</a>, scan the
|
||||
<a href="/grid/">grid</a>, or peek at the
|
||||
<a href="/sources/">sources</a> we draw from.
|
||||
</p>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import EntryList from '../components/EntryList.astro';
|
||||
import { getAllEntries } from '../lib/entries';
|
||||
import {
|
||||
buildOgImage,
|
||||
buildBreadcrumbs,
|
||||
buildItemList,
|
||||
absoluteUrl,
|
||||
} from '../lib/seo';
|
||||
|
||||
const posts = (await getAllEntries()).filter((e) => e.type === 'post');
|
||||
const lastUpdated = posts[0]?.date;
|
||||
|
||||
const description = 'Longer-form notes from Unique — essays and meta about the project.';
|
||||
const ogImage = buildOgImage(undefined, 'posts', 'Unique blog');
|
||||
|
||||
const itemList = buildItemList(
|
||||
posts.map((p) => ({ url: p.href, name: p.primary })),
|
||||
'Posts on Unique'
|
||||
);
|
||||
|
||||
const blogSchema: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
'@id': absoluteUrl('/blog/#blog'),
|
||||
name: 'Unique — Blog',
|
||||
description,
|
||||
url: absoluteUrl('/blog/'),
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Blog', url: '/blog/' },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Blog"
|
||||
description={description}
|
||||
subheader="Longer notes, when something deserves more than a card."
|
||||
lastUpdated={lastUpdated}
|
||||
ogImage={ogImage}
|
||||
jsonLd={[blogSchema, itemList, breadcrumbs]}
|
||||
>
|
||||
{posts.length > 0 ? (
|
||||
<EntryList entries={posts} />
|
||||
) : (
|
||||
<p class="empty">Nothing yet — check back soon.</p>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
margin: 3rem 0;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -2,6 +2,12 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import EntryList from '../../components/EntryList.astro';
|
||||
import { getAllEntries } from '../../lib/entries';
|
||||
import {
|
||||
buildOgImage,
|
||||
buildBreadcrumbs,
|
||||
buildItemList,
|
||||
absoluteUrl,
|
||||
} from '../../lib/seo';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const entries = await getAllEntries();
|
||||
@@ -16,15 +22,49 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { category, entries } = Astro.props;
|
||||
const lastUpdated = entries[0]?.date;
|
||||
const count = entries.length;
|
||||
const topNames = entries.slice(0, 3).map((e) => e.primary).join(', ');
|
||||
const description =
|
||||
topNames.length > 0
|
||||
? `All ${count} entries in ${category}: ${topNames}${count > 3 ? '…' : '.'}`
|
||||
: `All entries in ${category}.`;
|
||||
|
||||
const ogImage = buildOgImage(undefined, 'site', `${category} on Unique`);
|
||||
|
||||
const itemList = buildItemList(
|
||||
entries.map((e) => ({ url: e.href, name: e.primary })),
|
||||
`${category} on Unique`
|
||||
);
|
||||
|
||||
const collectionPage: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
'@id': absoluteUrl(`/categories/${category}/#collection`),
|
||||
name: `${category} — Unique`,
|
||||
description,
|
||||
url: absoluteUrl(`/categories/${category}/`),
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: category, url: `/categories/${category}/` },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout title={`Category: ${category}`}>
|
||||
<h1>Category: {category}</h1>
|
||||
<BaseLayout
|
||||
title={`${category} (${count})`}
|
||||
description={description}
|
||||
subheader={`Delightful ${category}.`}
|
||||
lastUpdated={lastUpdated}
|
||||
ogImage={ogImage}
|
||||
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
||||
>
|
||||
<EntryList entries={entries} />
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
|
||||
<style>
|
||||
h1 { margin-bottom: 1rem; }
|
||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -4,6 +4,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import SourceBadge from '../../components/SourceBadge.astro';
|
||||
import Backlinks from '../../components/Backlinks.astro';
|
||||
import { findsReferencing } from '../../lib/backlinks';
|
||||
import {
|
||||
resolveExternalLink,
|
||||
AFFILIATE_REL,
|
||||
AFFILIATE_DISCLOSURE,
|
||||
} from '../../lib/affiliate';
|
||||
import { buildOgImage, buildBreadcrumbs, absoluteUrl } from '../../lib/seo';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const items = await getCollection('find');
|
||||
@@ -25,11 +31,53 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
|
||||
const linkLabel =
|
||||
item.data.linkText ?? new URL(item.data.link).hostname.replace(/^www\./, '');
|
||||
|
||||
const external = resolveExternalLink(item.data.link);
|
||||
|
||||
const description =
|
||||
item.data.description ?? item.data.subtitle ?? `A find: ${item.data.name}.`;
|
||||
|
||||
const canonicalOverride = item.data.promotedTo
|
||||
? absoluteUrl(`/reviews/${item.data.promotedTo}/`)
|
||||
: undefined;
|
||||
|
||||
const ogImage = buildOgImage(
|
||||
item.data.promotedTo,
|
||||
'finds',
|
||||
`${item.data.name} preview image`
|
||||
);
|
||||
|
||||
const productSchema: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
'@id': absoluteUrl(`/find/${item.id}/#product`),
|
||||
name: item.data.name,
|
||||
description,
|
||||
url: absoluteUrl(`/find/${item.id}/`),
|
||||
image: ogImage.url,
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
url: item.data.link,
|
||||
availability: 'https://schema.org/InStock',
|
||||
},
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Sources', url: '/sources/' },
|
||||
{ name: item.data.name, url: `/find/${item.id}/` },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={item.data.name}
|
||||
description={item.data.description ?? item.data.subtitle ?? item.data.name}
|
||||
description={description}
|
||||
ogType="article"
|
||||
ogImage={ogImage}
|
||||
canonicalOverride={canonicalOverride}
|
||||
publishedTime={item.data.date}
|
||||
articleTags={item.data.tags}
|
||||
jsonLd={[productSchema, breadcrumbs]}
|
||||
>
|
||||
<article>
|
||||
<p class="source-line"><SourceBadge source={item.data.source} /></p>
|
||||
@@ -49,11 +97,20 @@ const linkLabel =
|
||||
</div>
|
||||
|
||||
<p class="external">
|
||||
<a href={item.data.link} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={external.href}
|
||||
target="_blank"
|
||||
rel={external.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
|
||||
>
|
||||
{linkLabel} ↗
|
||||
</a>
|
||||
{external.isAffiliate && <span class="aff-badge">Affiliate</span>}
|
||||
</p>
|
||||
|
||||
{external.isAffiliate && (
|
||||
<p class="aff-disclosure">{AFFILIATE_DISCLOSURE}</p>
|
||||
)}
|
||||
|
||||
{
|
||||
item.data.promotedTo && (
|
||||
<p class="promoted">
|
||||
@@ -112,6 +169,26 @@ const linkLabel =
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.external a:hover { text-decoration: none; opacity: 0.9; }
|
||||
.aff-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.6rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
background: var(--rule);
|
||||
border-radius: 999px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.aff-disclosure {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.promoted {
|
||||
margin: 1.5rem 0 0;
|
||||
padding: 0.85rem 1rem;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getAllFinds } from '../../lib/find-items';
|
||||
|
||||
const finds = await getAllFinds();
|
||||
const lastUpdated = finds[0]?.data.date;
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Finds"
|
||||
description={`All ${finds.length} captured finds on Unique.`}
|
||||
subheader={`All ${finds.length} captured finds.`}
|
||||
lastUpdated={lastUpdated}
|
||||
noindex
|
||||
>
|
||||
<ul class="find-index">
|
||||
{finds.map((f) => (
|
||||
<li>
|
||||
<a href={`/find/${f.id}/`}>{f.data.name}</a>
|
||||
<time datetime={f.data.date.toISOString()}>{fmt.format(f.data.date)}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.find-index {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.find-index li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.find-index li:last-child { border-bottom: 0; }
|
||||
.find-index time {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -3,6 +3,13 @@ import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import FindsGrid from '../../components/FindsGrid.astro';
|
||||
import { resolveFinds } from '../../lib/find-items';
|
||||
import {
|
||||
buildOgImage,
|
||||
buildBreadcrumbs,
|
||||
buildItemList,
|
||||
PERSON_AUTHOR,
|
||||
absoluteUrl,
|
||||
} from '../../lib/seo';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const finds = await getCollection('finds');
|
||||
@@ -21,12 +28,48 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const description =
|
||||
post.data.description ?? post.data.blurb ?? `A collection: ${post.data.title}.`;
|
||||
|
||||
const ogImage = buildOgImage(
|
||||
items[0]?.data.promotedTo,
|
||||
'finds',
|
||||
`${post.data.title} cover image`
|
||||
);
|
||||
|
||||
const articleSchema: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
'@id': absoluteUrl(`/finds/${post.id}/#article`),
|
||||
headline: post.data.title,
|
||||
description,
|
||||
url: absoluteUrl(`/finds/${post.id}/`),
|
||||
datePublished: post.data.date.toISOString(),
|
||||
author: PERSON_AUTHOR,
|
||||
image: ogImage.url,
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
const itemList = buildItemList(
|
||||
items.map((it) => ({ url: `/find/${it.id}/`, name: it.data.name })),
|
||||
post.data.title
|
||||
);
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: post.data.title, url: `/finds/${post.id}/` },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={post.data.title}
|
||||
description={post.data.description ?? post.data.blurb}
|
||||
wide
|
||||
description={description}
|
||||
ogType="article"
|
||||
ogImage={ogImage}
|
||||
publishedTime={post.data.date}
|
||||
articleTags={post.data.tags}
|
||||
jsonLd={[articleSchema, itemList, breadcrumbs]}
|
||||
>
|
||||
<article>
|
||||
<header class="finds-header">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
const lists = (await getCollection('finds')).sort(
|
||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||
);
|
||||
const lastUpdated = lists[0]?.data.date;
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Lists"
|
||||
description={`All ${lists.length} themed lists on Unique.`}
|
||||
subheader={`All ${lists.length} themed lists.`}
|
||||
lastUpdated={lastUpdated}
|
||||
noindex
|
||||
>
|
||||
<ul class="lists-index">
|
||||
{lists.map((l) => (
|
||||
<li>
|
||||
<a href={`/finds/${l.id}/`}>{l.data.title}</a>
|
||||
<time datetime={l.data.date.toISOString()}>{fmt.format(l.data.date)}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.lists-index {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.lists-index li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.lists-index li:last-child { border-bottom: 0; }
|
||||
.lists-index time {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
+11
-17
@@ -13,14 +13,17 @@ const tiles = reviews.map((review) => ({
|
||||
subtitle: review.data.subtitle,
|
||||
hero: heroes[review.id],
|
||||
}));
|
||||
|
||||
const lastUpdated = reviews[0]?.data.date;
|
||||
---
|
||||
|
||||
<BaseLayout title="Grid" wide>
|
||||
<div class="intro">
|
||||
<h1>Grid view</h1>
|
||||
<p><a href="/">← list view</a></p>
|
||||
</div>
|
||||
|
||||
<BaseLayout
|
||||
title="Grid"
|
||||
description="Every review on Unique, tiled in a single grid."
|
||||
subheader="Every review as a tile."
|
||||
lastUpdated={lastUpdated}
|
||||
noindex
|
||||
>
|
||||
<ul class="grid">
|
||||
{
|
||||
tiles.map((tile) => (
|
||||
@@ -34,7 +37,7 @@ const tiles = reviews.map((review) => ({
|
||||
height={tile.hero.src.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt=""
|
||||
alt={tile.name}
|
||||
/>
|
||||
)}
|
||||
{tile.hero?.kind === 'video' && (
|
||||
@@ -46,6 +49,7 @@ const tiles = reviews.map((review) => ({
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
aria-label={tile.name}
|
||||
/>
|
||||
)}
|
||||
<div class="text">
|
||||
@@ -59,16 +63,6 @@ const tiles = reviews.map((review) => ({
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.intro h1 { margin: 0; font-size: 1.5rem; }
|
||||
.intro p { margin: 0; font-size: 0.9rem; color: var(--muted); }
|
||||
|
||||
.grid {
|
||||
column-count: 3;
|
||||
column-gap: 1rem;
|
||||
|
||||
+388
-18
@@ -1,28 +1,398 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import EntryList from '../components/EntryList.astro';
|
||||
import { getAllEntries } from '../lib/entries';
|
||||
import { heroes } from '../lib/hero';
|
||||
import { buildOgImage, buildItemList, WEBSITE, ORGANIZATION } from '../lib/seo';
|
||||
|
||||
const entries = (await getAllEntries()).slice(0, 12);
|
||||
const HERO_SLUG = 'alt-tab';
|
||||
const INITIAL_GRID = 7;
|
||||
const BATCH_SIZE = 7;
|
||||
|
||||
const reviews = (await getCollection('reviews')).sort(
|
||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||
);
|
||||
|
||||
const tiles = reviews.map((review) => ({
|
||||
href: `/reviews/${review.id}/`,
|
||||
id: review.id,
|
||||
name: review.data.name,
|
||||
subtitle: review.data.subtitle,
|
||||
hero: heroes[review.id],
|
||||
}));
|
||||
|
||||
const heroIndex = tiles.findIndex((t) => t.href === `/reviews/${HERO_SLUG}/`);
|
||||
const hero = heroIndex >= 0 ? tiles[heroIndex] : tiles[0];
|
||||
const rest = tiles.filter((_, i) => i !== (heroIndex >= 0 ? heroIndex : 0));
|
||||
|
||||
const lastUpdated = reviews[0]?.data.date;
|
||||
|
||||
const ogImage = buildOgImage(hero?.id, 'reviews', `${hero?.name ?? 'Unique'} hero image`);
|
||||
const itemList = buildItemList(
|
||||
tiles.slice(0, INITIAL_GRID).map((t) => ({ url: t.href, name: t.name })),
|
||||
'Latest reviews'
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout title="Unique">
|
||||
<EntryList entries={entries} />
|
||||
<p class="view-toggle">
|
||||
<a href="/sources/">sources</a>
|
||||
<span aria-hidden="true">·</span>
|
||||
<a href="/grid/">grid view →</a>
|
||||
</p>
|
||||
<BaseLayout
|
||||
title="Unique"
|
||||
description="A small log of delightful, unique things — products, apps, phenomena, oddities."
|
||||
subheader="A small log of delightful things."
|
||||
lastUpdated={lastUpdated}
|
||||
ogImage={ogImage}
|
||||
jsonLd={[WEBSITE, ORGANIZATION, itemList]}
|
||||
>
|
||||
{hero && (
|
||||
<a href={hero.href} class="hero">
|
||||
{hero.hero?.kind === 'image' && (
|
||||
<img
|
||||
class="hero-media"
|
||||
src={hero.hero.src.src}
|
||||
width={hero.hero.src.width}
|
||||
height={hero.hero.src.height}
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
alt={hero.name}
|
||||
/>
|
||||
)}
|
||||
{hero.hero?.kind === 'video' && (
|
||||
<video
|
||||
class="hero-media"
|
||||
src={hero.hero.src}
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
aria-label={hero.name}
|
||||
/>
|
||||
)}
|
||||
<div class="hero-text">
|
||||
<span class="hero-eyebrow">Today's pick</span>
|
||||
<h1 class="hero-name">{hero.name}</h1>
|
||||
{hero.subtitle && <p class="hero-subtitle">{hero.subtitle}</p>}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<ul class="grid" data-initial={INITIAL_GRID} data-batch={BATCH_SIZE}>
|
||||
{
|
||||
rest.map((tile, i) => (
|
||||
<li class:list={[{ hidden: i >= INITIAL_GRID }]} data-index={i}>
|
||||
<a href={tile.href} class:list={['tile', { 'text-only': !tile.hero }]}>
|
||||
{tile.hero?.kind === 'image' && (
|
||||
<img
|
||||
class="media"
|
||||
src={tile.hero.src.src}
|
||||
width={tile.hero.src.width}
|
||||
height={tile.hero.src.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt={tile.name}
|
||||
/>
|
||||
)}
|
||||
{tile.hero?.kind === 'video' && (
|
||||
<video
|
||||
class="media"
|
||||
src={tile.hero.src}
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
aria-label={tile.name}
|
||||
/>
|
||||
)}
|
||||
<div class="text">
|
||||
<span class="name">{tile.name}</span>
|
||||
<span class="subtitle">{tile.subtitle}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
{rest.length > INITIAL_GRID && (
|
||||
<div class="more-wrap">
|
||||
<button id="load-more" class="load-more" type="button">
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<script>
|
||||
const grid = document.querySelector<HTMLUListElement>('.grid');
|
||||
const button = document.querySelector<HTMLButtonElement>('#load-more');
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
// A long, gentle programmatic scroll. Native smooth-scroll is too quick and
|
||||
// jumps to a fixed target; we want to drift continuously as cards appear.
|
||||
function softScrollBy(distance: number, duration: number): Promise<void> {
|
||||
if (prefersReducedMotion || distance <= 0) {
|
||||
window.scrollBy(0, distance);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const startY = window.scrollY;
|
||||
const start = performance.now();
|
||||
function step(now: number) {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
window.scrollTo(0, startY + distance * easeInOutCubic(t));
|
||||
if (t < 1) requestAnimationFrame(step);
|
||||
else resolve();
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
if (grid && button) {
|
||||
const batch = Number(grid.dataset.batch ?? '7');
|
||||
let busy = false;
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
button.disabled = true;
|
||||
|
||||
const hidden = grid.querySelectorAll<HTMLLIElement>('li.hidden');
|
||||
const toReveal = Array.from(hidden).slice(0, batch);
|
||||
|
||||
// Reveal one at a time, with the page drifting smoothly to keep new
|
||||
// cards in view as they slide in.
|
||||
const REVEAL_DURATION = 520;
|
||||
const GAP = 220;
|
||||
const SCROLL_AMOUNT = 110;
|
||||
const SCROLL_DURATION = REVEAL_DURATION + GAP;
|
||||
|
||||
for (let i = 0; i < toReveal.length; i++) {
|
||||
const li = toReveal[i];
|
||||
li.classList.remove('hidden');
|
||||
li.classList.add('revealing');
|
||||
li.addEventListener(
|
||||
'animationend',
|
||||
() => li.classList.remove('revealing'),
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
// Start a soft scroll alongside each card (except the first, so the
|
||||
// user sees the initial reveal in place).
|
||||
if (i > 0 && !prefersReducedMotion) {
|
||||
softScrollBy(SCROLL_AMOUNT, SCROLL_DURATION);
|
||||
}
|
||||
await wait(GAP);
|
||||
}
|
||||
|
||||
// Final settle: ensure the button (or the last card) is comfortably
|
||||
// in view.
|
||||
if (!prefersReducedMotion) {
|
||||
const target = button.getBoundingClientRect();
|
||||
const overflow = target.bottom - window.innerHeight + 32;
|
||||
if (overflow > 0) await softScrollBy(overflow, 600);
|
||||
}
|
||||
|
||||
if (grid.querySelectorAll('li.hidden').length === 0) {
|
||||
button.classList.add('fading');
|
||||
button.addEventListener('transitionend', () => button.remove(), { once: true });
|
||||
} else {
|
||||
button.disabled = false;
|
||||
}
|
||||
busy = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.view-toggle {
|
||||
margin-top: 1.75rem;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
.hero {
|
||||
display: block;
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--bg);
|
||||
color: inherit;
|
||||
margin-bottom: 2.5rem;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.view-toggle a { color: var(--muted); }
|
||||
.view-toggle a:hover { color: var(--accent); }
|
||||
.view-toggle span { margin: 0 0.4rem; color: var(--muted); }
|
||||
.hero:hover {
|
||||
text-decoration: none;
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
|
||||
box-shadow: 0 18px 48px -18px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
.hero:hover .hero-name { color: var(--accent); }
|
||||
|
||||
.hero-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 62vh;
|
||||
object-fit: cover;
|
||||
background: var(--rule);
|
||||
}
|
||||
.hero-text {
|
||||
padding: 1.6rem 1.75rem 1.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.hero-eyebrow {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.hero-name {
|
||||
margin: 0;
|
||||
font-size: clamp(1.6rem, 3vw, 2.4rem);
|
||||
letter-spacing: -0.02em;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
.hero-subtitle {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: clamp(1rem, 1.4vw, 1.15rem);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.grid {
|
||||
column-count: 3;
|
||||
column-gap: 1rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.grid { column-count: 2; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.grid { column-count: 1; }
|
||||
}
|
||||
|
||||
.grid > li {
|
||||
break-inside: avoid;
|
||||
display: block;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.grid > li.hidden { display: none; }
|
||||
.grid > li.revealing {
|
||||
animation: tile-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
@keyframes tile-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(22px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grid > li.revealing { animation: none; }
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--rule);
|
||||
background: var(--bg);
|
||||
color: inherit;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.tile:hover {
|
||||
text-decoration: none;
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--rule));
|
||||
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.tile:hover .name { color: var(--accent); }
|
||||
|
||||
.media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: var(--rule);
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.name {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg);
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tile.text-only {
|
||||
background: linear-gradient(155deg, var(--rule), transparent);
|
||||
}
|
||||
.tile.text-only .text {
|
||||
padding: 1.5rem 1.25rem;
|
||||
}
|
||||
.tile.text-only .name {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.tile.text-only .subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.more-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 2rem 0 0.5rem;
|
||||
}
|
||||
.load-more {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--fg);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 999px;
|
||||
padding: 0.7rem 1.6rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
color 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
opacity 0.4s ease;
|
||||
}
|
||||
.load-more:hover {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--rule));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.load-more.fading {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import EntryList from '../components/EntryList.astro';
|
||||
import { getAllEntries } from '../lib/entries';
|
||||
|
||||
const allEntries = await getAllEntries();
|
||||
const entries = allEntries.slice(0, 12);
|
||||
const lastUpdated = allEntries[0]?.date;
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="List view"
|
||||
description="The latest entries on Unique, in a plain chronological list."
|
||||
subheader="The latest, in plain text."
|
||||
lastUpdated={lastUpdated}
|
||||
noindex
|
||||
>
|
||||
<EntryList entries={entries} />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { buildOgImage, buildBreadcrumbs } from '../lib/seo';
|
||||
|
||||
const ogImage = buildOgImage(undefined, 'site', 'Unique legal page');
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Legal', url: '/legal/' },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Legal"
|
||||
description="Privacy, terms, affiliate disclosure, and trademark notes for Unique."
|
||||
subheader="The legal mumbo jumbo, kept short and honest."
|
||||
ogImage={ogImage}
|
||||
jsonLd={breadcrumbs}
|
||||
>
|
||||
<article>
|
||||
<h2>Privacy</h2>
|
||||
<p>
|
||||
Unique doesn't run analytics, ad pixels, or third-party trackers. The
|
||||
site sets no cookies of its own. Your visit produces a normal web-server
|
||||
access log entry (IP, user-agent, referrer, requested path) at our
|
||||
hosting provider, kept only for the rolling window needed to investigate
|
||||
abuse and outages, and never sold or shared.
|
||||
</p>
|
||||
<p>
|
||||
Outbound links go to third-party sites that have their own privacy
|
||||
practices; we have no control over those.
|
||||
</p>
|
||||
|
||||
<h2>Affiliate disclosure</h2>
|
||||
<p>
|
||||
Some outbound links — most often to Amazon — are affiliate links. If you
|
||||
buy something after clicking one, we may earn a small commission at no
|
||||
additional cost to you. Affiliate links are marked with an
|
||||
<strong>Affiliate</strong> badge and a one-line disclosure on each page
|
||||
where they appear. Affiliate revenue does not influence what we cover or
|
||||
how we cover it; nothing on this site is sponsored or paid placement.
|
||||
</p>
|
||||
|
||||
<h2>Trademarks & copyright</h2>
|
||||
<p>
|
||||
All product names, logos, screenshots, and brand marks belong to their
|
||||
respective owners and appear here under fair use for editorial review
|
||||
and commentary. If you're a rights-holder and would like an image
|
||||
removed or attribution corrected, please get in touch.
|
||||
</p>
|
||||
<p>
|
||||
Original writing on Unique is © {new Date().getFullYear()} Unique, all
|
||||
rights reserved.
|
||||
</p>
|
||||
|
||||
<h2>Use of the site</h2>
|
||||
<p>
|
||||
Content is provided as-is for informational purposes; we make no
|
||||
warranty that any product covered here will suit your particular
|
||||
situation. Use your own judgement before buying or installing anything.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions, corrections, or takedown requests: reach out via the address
|
||||
on the <a href="/about/">About</a> page.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article h2 {
|
||||
margin-top: 2rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
article h2:first-of-type { margin-top: 0; }
|
||||
article p { margin: 0.7rem 0; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
@@ -1,6 +1,12 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import {
|
||||
buildOgImage,
|
||||
buildBreadcrumbs,
|
||||
PERSON_AUTHOR,
|
||||
absoluteUrl,
|
||||
} from '../../lib/seo';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('posts');
|
||||
@@ -18,9 +24,40 @@ const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const description = post.data.description ?? `Notes from Unique: ${post.data.title}.`;
|
||||
const ogImage = buildOgImage(undefined, 'posts', `${post.data.title} cover image`);
|
||||
|
||||
const blogPosting: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
'@id': absoluteUrl(`/posts/${post.id}/#post`),
|
||||
headline: post.data.title,
|
||||
description,
|
||||
url: absoluteUrl(`/posts/${post.id}/`),
|
||||
datePublished: post.data.date.toISOString(),
|
||||
author: PERSON_AUTHOR,
|
||||
image: ogImage.url,
|
||||
inLanguage: 'en-US',
|
||||
mainEntityOfPage: absoluteUrl(`/posts/${post.id}/`),
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Blog', url: '/blog/' },
|
||||
{ name: post.data.title, url: `/posts/${post.id}/` },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout title={post.data.title} description={post.data.description}>
|
||||
<BaseLayout
|
||||
title={post.data.title}
|
||||
description={description}
|
||||
ogType="article"
|
||||
ogImage={ogImage}
|
||||
publishedTime={post.data.date}
|
||||
articleTags={post.data.tags}
|
||||
jsonLd={[blogPosting, breadcrumbs]}
|
||||
>
|
||||
<article>
|
||||
<header class="post-header">
|
||||
<h1>{post.data.title}</h1>
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import SourceBadge from '../../components/SourceBadge.astro';
|
||||
import {
|
||||
resolveExternalLink,
|
||||
AFFILIATE_REL,
|
||||
AFFILIATE_DISCLOSURE,
|
||||
} from '../../lib/affiliate';
|
||||
import {
|
||||
buildOgImage,
|
||||
buildBreadcrumbs,
|
||||
reviewedItemType,
|
||||
PERSON_AUTHOR,
|
||||
absoluteUrl,
|
||||
} from '../../lib/seo';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const reviews = await getCollection('reviews');
|
||||
@@ -24,14 +36,53 @@ const linkLabel =
|
||||
review.data.link &&
|
||||
(review.data.linkText ?? new URL(review.data.link).hostname.replace(/^www\./, ''));
|
||||
|
||||
const external = review.data.link ? resolveExternalLink(review.data.link) : undefined;
|
||||
|
||||
const fromFindItem = review.data.fromFind
|
||||
? (await getCollection('find')).find((f) => f.id === review.data.fromFind)
|
||||
: undefined;
|
||||
|
||||
const description =
|
||||
review.data.description ?? review.data.subtitle ?? `A review of ${review.data.name}.`;
|
||||
|
||||
const ogImage = buildOgImage(review.id, 'reviews', `${review.data.name} hero image`);
|
||||
|
||||
const itemReviewed: Record<string, unknown> = {
|
||||
'@type': reviewedItemType(review.data.category),
|
||||
name: review.data.name,
|
||||
};
|
||||
if (review.data.link) itemReviewed.url = review.data.link;
|
||||
if (ogImage) itemReviewed.image = ogImage.url;
|
||||
|
||||
const reviewSchema: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Review',
|
||||
'@id': absoluteUrl(`/reviews/${review.id}/#review`),
|
||||
url: absoluteUrl(`/reviews/${review.id}/`),
|
||||
name: review.data.name,
|
||||
headline: review.data.subtitle || review.data.name,
|
||||
description,
|
||||
datePublished: review.data.date.toISOString(),
|
||||
author: PERSON_AUTHOR,
|
||||
inLanguage: 'en-US',
|
||||
itemReviewed,
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: review.data.category, url: `/categories/${review.data.category}/` },
|
||||
{ name: review.data.name, url: `/reviews/${review.id}/` },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={review.data.name}
|
||||
description={review.data.description ?? review.data.subtitle}
|
||||
description={description}
|
||||
ogType="article"
|
||||
ogImage={ogImage}
|
||||
publishedTime={review.data.date}
|
||||
articleTags={review.data.tags}
|
||||
jsonLd={[reviewSchema, breadcrumbs]}
|
||||
>
|
||||
<article>
|
||||
{review.data.source && (
|
||||
@@ -50,16 +101,24 @@ const fromFindItem = review.data.fromFind
|
||||
{review.data.category}
|
||||
</a>
|
||||
{
|
||||
review.data.link && (
|
||||
external && (
|
||||
<>
|
||||
<span class="dot">·</span>
|
||||
<a href={review.data.link} target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href={external.href}
|
||||
target="_blank"
|
||||
rel={external.isAffiliate ? AFFILIATE_REL : 'noopener noreferrer'}
|
||||
>
|
||||
{linkLabel} ↗
|
||||
</a>
|
||||
{external.isAffiliate && <span class="aff-badge">Affiliate</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</p>
|
||||
{external?.isAffiliate && (
|
||||
<p class="aff-disclosure">{AFFILIATE_DISCLOSURE}</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{fromFindItem && (
|
||||
@@ -120,6 +179,26 @@ const fromFindItem = review.data.fromFind
|
||||
.meta .dot { margin: 0 0.4rem; }
|
||||
.meta .category { color: var(--muted); }
|
||||
.meta .category:hover { color: var(--accent); }
|
||||
.aff-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.45rem;
|
||||
padding: 0.12rem 0.45rem;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
background: var(--rule);
|
||||
border-radius: 999px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.aff-disclosure {
|
||||
margin: 0.6rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.body :global(p) { margin: 0 0 1rem; }
|
||||
.body :global(h2) { font-size: 1.3rem; margin: 2rem 0 0.75rem; }
|
||||
.body :global(h3) { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export async function GET(context) {
|
||||
const [reviews, posts, finds] = await Promise.all([
|
||||
getCollection('reviews'),
|
||||
getCollection('posts'),
|
||||
getCollection('finds'),
|
||||
]);
|
||||
|
||||
const items = [
|
||||
...reviews.map((r) => ({
|
||||
title: r.data.name,
|
||||
description: r.data.description ?? r.data.subtitle ?? '',
|
||||
pubDate: r.data.date,
|
||||
link: `/reviews/${r.id}/`,
|
||||
categories: [r.data.category, ...r.data.tags],
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
title: p.data.title,
|
||||
description: p.data.description ?? '',
|
||||
pubDate: p.data.date,
|
||||
link: `/posts/${p.id}/`,
|
||||
categories: [p.data.category, ...p.data.tags],
|
||||
})),
|
||||
...finds.map((f) => ({
|
||||
title: f.data.title,
|
||||
description: f.data.description ?? f.data.blurb ?? '',
|
||||
pubDate: f.data.date,
|
||||
link: `/finds/${f.id}/`,
|
||||
categories: [f.data.category, ...f.data.tags],
|
||||
})),
|
||||
].sort((a, b) => b.pubDate.valueOf() - a.pubDate.valueOf());
|
||||
|
||||
return rss({
|
||||
title: 'Unique',
|
||||
description:
|
||||
'A small log of delightful, unique things — products, apps, phenomena, oddities.',
|
||||
site: context.site,
|
||||
items,
|
||||
customData: '<language>en-us</language>',
|
||||
});
|
||||
}
|
||||
+41
-12
@@ -2,6 +2,12 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import sourcesData from '../../sources.json';
|
||||
import { getAllFinds } from '../lib/find-items';
|
||||
import {
|
||||
buildOgImage,
|
||||
buildBreadcrumbs,
|
||||
buildItemList,
|
||||
absoluteUrl,
|
||||
} from '../lib/seo';
|
||||
|
||||
type Source = {
|
||||
name: string;
|
||||
@@ -40,14 +46,43 @@ for (const f of allFinds) {
|
||||
arr.push(f);
|
||||
findsBySource.set(f.data.source, arr);
|
||||
}
|
||||
|
||||
const lastUpdated = allFinds.sort(
|
||||
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||
)[0]?.data.date;
|
||||
|
||||
const description = `The ${sources.length} feeds, shops, and forums Unique draws daily picks from.`;
|
||||
const ogImage = buildOgImage(undefined, 'site', 'Unique sources catalog');
|
||||
|
||||
const itemList = buildItemList(
|
||||
sources.map((s) => ({ url: s.url, name: s.name })),
|
||||
'Sources used by Unique'
|
||||
);
|
||||
|
||||
const collectionPage: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
'@id': absoluteUrl('/sources/#collection'),
|
||||
name: 'Sources — Unique',
|
||||
description,
|
||||
url: absoluteUrl('/sources/'),
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Sources', url: '/sources/' },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout title="Sources" wide>
|
||||
<div class="intro">
|
||||
<h1>Sources</h1>
|
||||
<p>Where the daily picks come from. {sources.length} feeds, shops, and forums.</p>
|
||||
</div>
|
||||
|
||||
<BaseLayout
|
||||
title="Sources"
|
||||
description={description}
|
||||
subheader={`Where the daily picks come from. ${sources.length} feeds, shops, and forums.`}
|
||||
lastUpdated={lastUpdated}
|
||||
ogImage={ogImage}
|
||||
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
||||
>
|
||||
<ul class="grid">
|
||||
{
|
||||
sources.map((source) => {
|
||||
@@ -93,12 +128,6 @@ for (const f of allFinds) {
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.intro h1 { margin: 0 0 0.25rem; font-size: 1.85rem; }
|
||||
.intro p { margin: 0; color: var(--muted); }
|
||||
|
||||
.grid {
|
||||
column-count: 3;
|
||||
column-gap: 1rem;
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import EntryList from '../../components/EntryList.astro';
|
||||
import { getAllEntries } from '../../lib/entries';
|
||||
import {
|
||||
buildOgImage,
|
||||
buildBreadcrumbs,
|
||||
buildItemList,
|
||||
absoluteUrl,
|
||||
} from '../../lib/seo';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const entries = await getAllEntries();
|
||||
@@ -16,15 +22,49 @@ export async function getStaticPaths() {
|
||||
}
|
||||
|
||||
const { tag, entries } = Astro.props;
|
||||
const lastUpdated = entries[0]?.date;
|
||||
const count = entries.length;
|
||||
const topNames = entries.slice(0, 3).map((e) => e.primary).join(', ');
|
||||
const description =
|
||||
topNames.length > 0
|
||||
? `All ${count} entries tagged #${tag}: ${topNames}${count > 3 ? '…' : '.'}`
|
||||
: `All entries tagged #${tag}.`;
|
||||
|
||||
const ogImage = buildOgImage(undefined, 'site', `#${tag} on Unique`);
|
||||
|
||||
const itemList = buildItemList(
|
||||
entries.map((e) => ({ url: e.href, name: e.primary })),
|
||||
`#${tag} on Unique`
|
||||
);
|
||||
|
||||
const collectionPage: Record<string, unknown> = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
'@id': absoluteUrl(`/tags/${tag}/#collection`),
|
||||
name: `#${tag} — Unique`,
|
||||
description,
|
||||
url: absoluteUrl(`/tags/${tag}/`),
|
||||
inLanguage: 'en-US',
|
||||
};
|
||||
|
||||
const breadcrumbs = buildBreadcrumbs([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: `#${tag}`, url: `/tags/${tag}/` },
|
||||
]);
|
||||
---
|
||||
|
||||
<BaseLayout title={`Tag: ${tag}`}>
|
||||
<h1>#{tag}</h1>
|
||||
<BaseLayout
|
||||
title={`#${tag} (${count})`}
|
||||
description={description}
|
||||
subheader={`Tagged: ${tag}.`}
|
||||
lastUpdated={lastUpdated}
|
||||
ogImage={ogImage}
|
||||
jsonLd={[collectionPage, itemList, breadcrumbs]}
|
||||
>
|
||||
<EntryList entries={entries} />
|
||||
<p class="back"><a href="/">← back</a></p>
|
||||
|
||||
<style>
|
||||
h1 { margin-bottom: 1rem; }
|
||||
.back { margin-top: 2rem; font-size: 0.9rem; }
|
||||
</style>
|
||||
</BaseLayout>
|
||||
|
||||
Reference in New Issue
Block a user