chore: added /art-calendar e8987cc9
Steve Simkins · 2026-05-08 22:38 3 file(s) · +134 −183
src/data/constants.ts +9 −0
58 58
		path: "/birds",
59 59
	},
60 60
	{
61 +
		title: "Art Calendar",
62 +
		path: "/art-calendar",
63 +
	},
64 +
	{
61 65
		title: "Uses",
62 66
		path: "/uses",
63 67
	},
97 101
		name: "Wine Log",
98 102
		href: "https://cellar.stevedylan.dev/feed.xml",
99 103
		description: "Wines I've tasted, logged in the cellar.",
104 +
	},
105 +
	{
106 +
		name: "Art Calendar",
107 +
		href: "https://easel.stevedylan.dev/feed.xml",
108 +
		description: "A painting a day from the Art Institute of Chicago.",
100 109
	},
101 110
];
102 111
src/pages/art-a-day.astro (deleted) +0 −183
1 -
---
2 -
export const prerender = false;
3 -
4 -
import PageLayout from "@/layouts/Base.astro";
5 -
import { getFormattedDate } from "@/utils";
6 -
import sanitizeHtml from "sanitize-html";
7 -
8 -
type Artwork = {
9 -
	id: number;
10 -
	title: string;
11 -
	artist_display: string | null;
12 -
	artist_title: string | null;
13 -
	date_display: string | null;
14 -
	medium_display: string | null;
15 -
	dimensions: string | null;
16 -
	place_of_origin: string | null;
17 -
	credit_line: string | null;
18 -
	description: string | null;
19 -
	short_description: string | null;
20 -
	image_id: string | null;
21 -
};
22 -
23 -
const meta = {
24 -
	title: "Art a Day",
25 -
	description:
26 -
		"A different painting every day from the Art Institute of Chicago.",
27 -
};
28 -
29 -
const SEARCH_URL = "https://api.artic.edu/api/v1/artworks/search";
30 -
const FIELDS = [
31 -
	"id",
32 -
	"title",
33 -
	"artist_display",
34 -
	"artist_title",
35 -
	"date_display",
36 -
	"medium_display",
37 -
	"dimensions",
38 -
	"place_of_origin",
39 -
	"credit_line",
40 -
	"description",
41 -
	"short_description",
42 -
	"image_id",
43 -
].join(",");
44 -
45 -
const filterParams = {
46 -
	query: {
47 -
		bool: {
48 -
			must: [
49 -
				{ term: { is_public_domain: true } },
50 -
				{ term: { "classification_title.keyword": "painting" } },
51 -
				{ term: { "artwork_type_title.keyword": "Painting" } },
52 -
				{ exists: { field: "image_id" } },
53 -
			],
54 -
		},
55 -
	},
56 -
};
57 -
const encodedParams = encodeURIComponent(JSON.stringify(filterParams));
58 -
59 -
const today = new Date();
60 -
const dayIndex = Math.floor(today.getTime() / 86_400_000);
61 -
62 -
let artwork: Artwork | null = null;
63 -
let error: string | null = null;
64 -
65 -
const aicHeaders = {
66 -
	"AIC-User-Agent": "stevedylan.dev (stevedsimkins@gmail.com)",
67 -
	"User-Agent": "stevedylan.dev (stevedsimkins@gmail.com)",
68 -
	Accept: "application/json",
69 -
};
70 -
71 -
try {
72 -
	const countRes = await fetch(
73 -
		`${SEARCH_URL}?params=${encodedParams}&limit=1&fields=id`,
74 -
		{ headers: aicHeaders },
75 -
	);
76 -
	if (!countRes.ok) throw new Error(`AIC count returned ${countRes.status}`);
77 -
	const countJson = await countRes.json();
78 -
	const total: number = countJson?.pagination?.total ?? 0;
79 -
	if (total <= 0) throw new Error("no paintings found");
80 -
	// AIC caps from + size at 10000; stay well under.
81 -
	const safeTotal = Math.min(total, 9000);
82 -
	const page = (dayIndex % safeTotal) + 1;
83 -
84 -
	const pickRes = await fetch(
85 -
		`${SEARCH_URL}?params=${encodedParams}&limit=1&page=${page}&fields=${FIELDS}`,
86 -
		{ headers: aicHeaders },
87 -
	);
88 -
	if (!pickRes.ok) throw new Error(`AIC pick returned ${pickRes.status}`);
89 -
	const pickJson = await pickRes.json();
90 -
	artwork = pickJson?.data?.[0] ?? null;
91 -
	if (!artwork) throw new Error("empty result");
92 -
} catch (e) {
93 -
	error = e instanceof Error ? e.message : "Failed to reach AIC API";
94 -
}
95 -
96 -
const imageUrl = artwork?.image_id
97 -
	? `https://www.artic.edu/iiif/2/${artwork.image_id}/full/843,/0/default.jpg`
98 -
	: null;
99 -
const sourceUrl = artwork
100 -
	? `https://www.artic.edu/artworks/${artwork.id}`
101 -
	: null;
102 -
const cleanDescription = artwork?.description
103 -
	? sanitizeHtml(artwork.description, {
104 -
			allowedTags: ["p", "em", "strong", "a", "br", "i", "b"],
105 -
			allowedAttributes: { a: ["href", "title"] },
106 -
		})
107 -
	: null;
108 -
109 -
Astro.response.headers.set(
110 -
	"Cache-Control",
111 -
	"public, max-age=3600, s-maxage=86400",
112 -
);
113 -
---
114 -
115 -
<PageLayout meta={meta}>
116 -
	<div class="flex min-h-screen flex-col items-start justify-start gap-6">
117 -
		<div class="flex flex-col gap-1">
118 -
			<h1 class="title">Art a Day</h1>
119 -
			<p class="text-sm opacity-70">
120 -
				One painting every day from the
121 -
				<a class="style-link" href="https://www.artic.edu/" target="_blank" rel="noopener">Art Institute of Chicago</a>.
122 -
				{getFormattedDate(today)}.
123 -
			</p>
124 -
		</div>
125 -
		{error || !artwork ? (
126 -
			<p class="text-red-400 text-sm">Couldn't load today's painting{error ? `: ${error}` : ""}.</p>
127 -
		) : (
128 -
			<article class="flex flex-col w-full gap-6">
129 -
				{imageUrl && (
130 -
					<img
131 -
						src={imageUrl}
132 -
						alt={artwork.title}
133 -
						loading="eager"
134 -
						class="w-full max-w-2xl h-auto"
135 -
					/>
136 -
				)}
137 -
				<div class="flex flex-col gap-1">
138 -
					<h2 class="text-xl italic text-accent-2">{artwork.title}</h2>
139 -
					{artwork.artist_display && (
140 -
						<p class="text-sm whitespace-pre-line">{artwork.artist_display}</p>
141 -
					)}
142 -
					{artwork.date_display && (
143 -
						<p class="text-sm opacity-70">{artwork.date_display}</p>
144 -
					)}
145 -
				</div>
146 -
				<dl class="grid grid-cols-1 sm:grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
147 -
					{artwork.place_of_origin && (
148 -
						<>
149 -
							<dt class="opacity-50 uppercase tracking-widest text-xs">Origin</dt>
150 -
							<dd>{artwork.place_of_origin}</dd>
151 -
						</>
152 -
					)}
153 -
					{artwork.medium_display && (
154 -
						<>
155 -
							<dt class="opacity-50 uppercase tracking-widest text-xs">Medium</dt>
156 -
							<dd>{artwork.medium_display}</dd>
157 -
						</>
158 -
					)}
159 -
					{artwork.dimensions && (
160 -
						<>
161 -
							<dt class="opacity-50 uppercase tracking-widest text-xs">Dimensions</dt>
162 -
							<dd>{artwork.dimensions}</dd>
163 -
						</>
164 -
					)}
165 -
					{artwork.credit_line && (
166 -
						<>
167 -
							<dt class="opacity-50 uppercase tracking-widest text-xs">Credit</dt>
168 -
							<dd>{artwork.credit_line}</dd>
169 -
						</>
170 -
					)}
171 -
				</dl>
172 -
				{cleanDescription && (
173 -
					<div class="prose prose-cactus prose-sm max-w-none" set:html={cleanDescription} />
174 -
				)}
175 -
				{sourceUrl && (
176 -
					<a class="style-link self-start" href={sourceUrl} target="_blank" rel="noopener">
177 -
						View on artic.edu
178 -
					</a>
179 -
				)}
180 -
			</article>
181 -
		)}
182 -
	</div>
183 -
</PageLayout>
src/pages/art-calendar.astro (added) +125 −0
1 +
---
2 +
export const prerender = false;
3 +
4 +
import PageLayout from "@/layouts/Base.astro";
5 +
import { getFormattedDate } from "@/utils";
6 +
7 +
type Artwork = {
8 +
	date: string;
9 +
	artwork_id: number;
10 +
	title: string;
11 +
	artist_display: string | null;
12 +
	date_display: string | null;
13 +
	medium_display: string | null;
14 +
	dimensions: string | null;
15 +
	place_of_origin: string | null;
16 +
	credit_line: string | null;
17 +
	short_description: string | null;
18 +
	image_id: string | null;
19 +
	image_url: string | null;
20 +
	source_url: string | null;
21 +
};
22 +
23 +
const meta = {
24 +
	title: "Art Calendar",
25 +
	description:
26 +
		"A different painting every day from the Art Institute of Chicago.",
27 +
};
28 +
29 +
const FEED_URL = "https://easel.stevedylan.dev/feed.xml";
30 +
const today = new Date();
31 +
32 +
let artwork: Artwork | null = null;
33 +
let error: string | null = null;
34 +
35 +
try {
36 +
	const res = await fetch("https://easel.stevedylan.dev/api/today", {
37 +
		headers: { Accept: "application/json" },
38 +
	});
39 +
	if (!res.ok) throw new Error(`easel returned ${res.status}`);
40 +
	artwork = (await res.json()) as Artwork;
41 +
} catch (e) {
42 +
	error = e instanceof Error ? e.message : "Failed to reach easel API";
43 +
}
44 +
45 +
Astro.response.headers.set(
46 +
	"Cache-Control",
47 +
	"public, max-age=3600, s-maxage=86400",
48 +
);
49 +
---
50 +
51 +
<PageLayout meta={meta}>
52 +
	<div class="flex min-h-screen flex-col items-start justify-start gap-6">
53 +
		<div class="flex flex-col gap-1">
54 +
			<h1 class="title">Art Calendar</h1>
55 +
			<p class="text-sm opacity-70">
56 +
				One painting every day from the
57 +
				<a class="style-link" href="https://www.artic.edu/" target="_blank" rel="noopener">Art Institute of Chicago</a>.
58 +
				{getFormattedDate(today)}.
59 +
			</p>
60 +
			<p class="text-sm opacity-70 flex items-center gap-1">
61 +
				<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256" class="shrink-0 text-accent-2" aria-hidden="true">
62 +
					<path fill="currentColor" d="M106.91 149.09A71.53 71.53 0 0 1 128 200a8 8 0 0 1-16 0a56 56 0 0 0-56-56a8 8 0 0 1 0-16a71.53 71.53 0 0 1 50.91 21.09M56 80a8 8 0 0 0 0 16a104 104 0 0 1 104 104a8 8 0 0 0 16 0A120 120 0 0 0 56 80m118.79 1.21A166.9 166.9 0 0 0 56 32a8 8 0 0 0 0 16a151 151 0 0 1 107.48 44.52A151 151 0 0 1 208 200a8 8 0 0 0 16 0a166.9 166.9 0 0 0-49.21-118.79M60 184a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/>
63 +
				</svg>
64 +
				<a class="style-link" href={FEED_URL} target="_blank" rel="noopener">RSS feed</a>
65 +
			</p>
66 +
		</div>
67 +
		{error || !artwork ? (
68 +
			<p class="text-red-400 text-sm">Couldn't load today's painting{error ? `: ${error}` : ""}.</p>
69 +
		) : (
70 +
			<article class="flex flex-col w-full gap-6">
71 +
				{artwork.image_url && (
72 +
					<img
73 +
						src={artwork.image_url}
74 +
						alt={artwork.title}
75 +
						loading="eager"
76 +
						class="w-full max-w-2xl h-auto"
77 +
					/>
78 +
				)}
79 +
				<div class="flex flex-col gap-1">
80 +
					<h2 class="text-xl italic text-accent-2">{artwork.title}</h2>
81 +
					{artwork.artist_display && (
82 +
						<p class="text-sm whitespace-pre-line">{artwork.artist_display}</p>
83 +
					)}
84 +
					{artwork.date_display && (
85 +
						<p class="text-sm opacity-70">{artwork.date_display}</p>
86 +
					)}
87 +
				</div>
88 +
				<dl class="grid grid-cols-1 sm:grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
89 +
					{artwork.place_of_origin && (
90 +
						<>
91 +
							<dt class="opacity-50 uppercase tracking-widest text-xs">Origin</dt>
92 +
							<dd>{artwork.place_of_origin}</dd>
93 +
						</>
94 +
					)}
95 +
					{artwork.medium_display && (
96 +
						<>
97 +
							<dt class="opacity-50 uppercase tracking-widest text-xs">Medium</dt>
98 +
							<dd>{artwork.medium_display}</dd>
99 +
						</>
100 +
					)}
101 +
					{artwork.dimensions && (
102 +
						<>
103 +
							<dt class="opacity-50 uppercase tracking-widest text-xs">Dimensions</dt>
104 +
							<dd>{artwork.dimensions}</dd>
105 +
						</>
106 +
					)}
107 +
					{artwork.credit_line && (
108 +
						<>
109 +
							<dt class="opacity-50 uppercase tracking-widest text-xs">Credit</dt>
110 +
							<dd>{artwork.credit_line}</dd>
111 +
						</>
112 +
					)}
113 +
				</dl>
114 +
				{artwork.short_description && (
115 +
					<p class="text-sm">{artwork.short_description}</p>
116 +
				)}
117 +
				{artwork.source_url && (
118 +
					<a class="style-link self-start" href={artwork.source_url} target="_blank" rel="noopener">
119 +
						View on artic.edu
120 +
					</a>
121 +
				)}
122 +
			</article>
123 +
		)}
124 +
	</div>
125 +
</PageLayout>