chore: style changes 7bceb67c
Steve · 2026-05-08 22:11 18 file(s) · +92 −129
apps/easel/.env.example +4 −0
11 11
# Comma-separated AIC classification_title filters (lowercased, e.g. painting,drawing,print)
12 12
EASEL_CLASSIFICATIONS=painting
13 13
14 +
# Phrases excluded via must_not match across title/description/term/subject/category/classification.
15 +
# Comma-separated. Set empty to disable filtering.
16 +
EASEL_EXCLUDE_TERMS=erotic,erotica,shunga
17 +
14 18
# On startup, fill any missing day in the last N days. 0 disables backfill.
15 19
EASEL_BACKFILL_DAYS=0
16 20
apps/easel/src/aic.rs +33 −7
48 48
    id: i64,
49 49
}
50 50
51 -
fn build_params(classifications: &[String]) -> String {
51 +
const EXCLUDE_FIELDS: &[&str] = &[
52 +
    "title",
53 +
    "description",
54 +
    "short_description",
55 +
    "term_titles",
56 +
    "subject_titles",
57 +
    "category_titles",
58 +
    "classification_titles",
59 +
];
60 +
61 +
fn build_params(classifications: &[String], exclude_terms: &[String]) -> String {
52 62
    let terms: Vec<serde_json::Value> = classifications
53 63
        .iter()
54 64
        .map(|c| serde_json::Value::String(c.to_lowercase()))
55 65
        .collect();
66 +
    let must_not: Vec<serde_json::Value> = exclude_terms
67 +
        .iter()
68 +
        .map(|t| {
69 +
            serde_json::json!({
70 +
                "multi_match": {
71 +
                    "query": t,
72 +
                    "fields": EXCLUDE_FIELDS,
73 +
                    "type": "phrase"
74 +
                }
75 +
            })
76 +
        })
77 +
        .collect();
56 78
    let body = serde_json::json!({
57 79
        "query": {
58 80
            "bool": {
60 82
                    { "term": { "is_public_domain": true } },
61 83
                    { "terms": { "classification_title.keyword": terms } },
62 84
                    { "exists": { "field": "image_id" } }
63 -
                ]
85 +
                ],
86 +
                "must_not": must_not
64 87
            }
65 88
        }
66 89
    });
70 93
pub async fn total_matching(
71 94
    client: &reqwest::Client,
72 95
    classifications: &[String],
96 +
    exclude_terms: &[String],
73 97
) -> Result<u64, String> {
74 -
    let params = build_params(classifications);
98 +
    let params = build_params(classifications, exclude_terms);
75 99
    let url = format!(
76 100
        "{SEARCH_URL}?params={}&limit=1&fields=id",
77 101
        urlencoding::encode(&params)
94 118
pub async fn fetch_artwork_at(
95 119
    client: &reqwest::Client,
96 120
    classifications: &[String],
121 +
    exclude_terms: &[String],
97 122
    page: u64,
98 123
) -> Result<Option<RawArtwork>, String> {
99 -
    let params = build_params(classifications);
124 +
    let params = build_params(classifications, exclude_terms);
100 125
    let url = format!(
101 126
        "{SEARCH_URL}?params={}&limit=1&page={page}&fields={FIELDS}",
102 127
        urlencoding::encode(&params)
120 145
    client: &reqwest::Client,
121 146
    db: &Db,
122 147
    classifications: &[String],
148 +
    exclude_terms: &[String],
123 149
    max_retries: u32,
124 150
) -> Result<RawArtwork, String> {
125 -
    let total = total_matching(client, classifications).await?;
151 +
    let total = total_matching(client, classifications, exclude_terms).await?;
126 152
    if total == 0 {
127 153
        return Err("AIC search returned zero matches for given classifications".to_string());
128 154
    }
132 158
            let mut rng = rand::thread_rng();
133 159
            rng.gen_range(1..=total)
134 160
        };
135 -
        let art = match fetch_artwork_at(client, classifications, page).await? {
161 +
        let art = match fetch_artwork_at(client, classifications, exclude_terms, page).await? {
136 162
            Some(a) => a,
137 163
            None => continue,
138 164
        };
188 214
189 215
    #[test]
190 216
    fn build_params_lowercases_classifications() {
191 -
        let p = build_params(&["Painting".to_string(), "DRAWING".to_string()]);
217 +
        let p = build_params(&["Painting".to_string(), "DRAWING".to_string()], &[]);
192 218
        assert!(p.contains("\"painting\""));
193 219
        assert!(p.contains("\"drawing\""));
194 220
        assert!(p.contains("is_public_domain"));
apps/easel/src/scheduler.rs +1 −0
58 58
        &state.http,
59 59
        &state.db,
60 60
        &state.classifications,
61 +
        &state.exclude_terms,
61 62
        state.max_dedup_retries,
62 63
    )
63 64
    .await?;
apps/easel/src/server.rs +12 −15
24 24
    pub http: reqwest::Client,
25 25
    pub tz: chrono_tz::Tz,
26 26
    pub classifications: Vec<String>,
27 +
    pub exclude_terms: Vec<String>,
27 28
    pub backfill_days: u32,
28 29
    pub max_dedup_retries: u32,
29 30
}
33 34
struct IndexTemplate {
34 35
    today_date: String,
35 36
    artwork: Option<ArtworkView>,
36 -
    archive: Vec<ArchiveRow>,
37 37
}
38 38
39 39
#[derive(Template)]
41 41
struct DayTemplate {
42 42
    date: String,
43 43
    artwork: ArtworkView,
44 -
    archive: Vec<ArchiveRow>,
45 44
}
46 45
47 46
#[derive(Template)]
66 65
    dimensions: String,
67 66
    place_of_origin: String,
68 67
    credit_line: String,
68 +
    description: String,
69 69
    short_description: String,
70 70
    image_url: String,
71 71
    source_url: String,
95 95
        dimensions: a.dimensions.unwrap_or_default(),
96 96
        place_of_origin: a.place_of_origin.unwrap_or_default(),
97 97
        credit_line: a.credit_line.unwrap_or_default(),
98 +
        description: a.description.unwrap_or_default(),
98 99
        short_description: a.short_description.unwrap_or_default(),
99 100
        image_url: iiif_url(&a.image_id),
100 101
        source_url: source_url(a.artwork_id),
136 137
            });
137 138
        }
138 139
    };
139 -
    let archive = db::list_daily(&state.db, 30)
140 -
        .unwrap_or_default()
141 -
        .iter()
142 -
        .map(to_archive_row)
143 -
        .collect();
144 140
    render(IndexTemplate {
145 141
        today_date: today,
146 142
        artwork,
147 -
        archive,
148 143
    })
149 144
}
150 145
203 198
                .into_response();
204 199
        }
205 200
    };
206 -
    let archive = db::list_daily(&state.db, 30)
207 -
        .unwrap_or_default()
208 -
        .iter()
209 -
        .map(to_archive_row)
210 -
        .collect();
211 201
    render(DayTemplate {
212 202
        date,
213 203
        artwork,
214 -
        archive,
215 204
    })
216 205
}
217 206
351 340
    if classifications.is_empty() {
352 341
        panic!("EASEL_CLASSIFICATIONS resolved to empty list");
353 342
    }
343 +
    let exclude_terms: Vec<String> = std::env::var("EASEL_EXCLUDE_TERMS")
344 +
        .unwrap_or_else(|_| "erotic,erotica,shunga".to_string())
345 +
        .split(',')
346 +
        .map(|s| s.trim().to_string())
347 +
        .filter(|s| !s.is_empty())
348 +
        .collect();
354 349
    let backfill_days: u32 = std::env::var("EASEL_BACKFILL_DAYS")
355 350
        .ok()
356 351
        .and_then(|v| v.parse().ok())
368 363
        http,
369 364
        tz,
370 365
        classifications: classifications.clone(),
366 +
        exclude_terms: exclude_terms.clone(),
371 367
        backfill_days,
372 368
        max_dedup_retries,
373 369
    });
374 370
375 371
    tracing::info!(
376 -
        "easel starting: tz={} classifications={:?} backfill_days={} retries={}",
372 +
        "easel starting: tz={} classifications={:?} exclude_terms={:?} backfill_days={} retries={}",
377 373
        state.tz.name(),
378 374
        classifications,
375 +
        exclude_terms,
379 376
        backfill_days,
380 377
        max_dedup_retries
381 378
    );
apps/easel/static/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

apps/easel/static/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

apps/easel/static/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

apps/easel/static/favicon-16x16.png (added) +0 −0

Binary file — no preview.

apps/easel/static/favicon-32x32.png (added) +0 −0

Binary file — no preview.

apps/easel/static/favicon.ico (added) +0 −0

Binary file — no preview.

apps/easel/static/icon.png (added) +0 −0

Binary file — no preview.

apps/easel/static/og.png (added) +0 −0

Binary file — no preview.

apps/easel/static/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
apps/easel/static/styles.css +15 −53
1 -
.container {
2 -
  max-width: 720px;
3 -
  margin: 0 auto;
4 -
  padding: 24px 16px 64px;
5 -
}
6 -
7 1
.artwork-figure {
8 -
  margin: 16px 0 24px;
2 +
  margin: 0 0 1rem;
9 3
  border: 1px solid #333;
10 4
}
11 5
16 10
}
17 11
18 12
.artwork-meta {
19 -
  margin-bottom: 16px;
13 +
  margin-bottom: 0.5rem;
20 14
}
21 15
22 16
.artwork-date {
23 17
  opacity: 0.5;
24 18
  font-size: 12px;
25 -
  margin: 0 0 4px;
26 19
}
27 20
28 21
.artwork-title {
29 -
  font-size: 18px;
30 -
  margin: 0 0 4px;
22 +
  font-size: 16px;
23 +
  font-weight: 700;
31 24
}
32 25
33 26
.artwork-artist {
34 -
  margin: 0;
35 27
  opacity: 0.7;
36 28
}
37 29
38 30
.artwork-details {
39 31
  display: grid;
40 32
  grid-template-columns: max-content 1fr;
41 -
  gap: 4px 16px;
42 -
  margin: 16px 0;
33 +
  gap: 0.25rem 1rem;
34 +
  margin: 1rem 0;
43 35
  font-size: 13px;
44 36
}
45 37
47 39
  opacity: 0.5;
48 40
}
49 41
50 -
.artwork-details dd {
51 -
  margin: 0;
52 -
}
53 -
54 42
.artwork-description {
55 -
  margin: 16px 0;
56 43
  opacity: 0.85;
44 +
  font-size: 13px;
57 45
}
58 46
59 -
.artwork-source {
60 -
  font-size: 12px;
61 -
  opacity: 0.7;
47 +
.artwork-description p + p {
48 +
  margin-top: 0.75rem;
62 49
}
63 50
64 51
.archive-list {
65 -
  margin-top: 48px;
52 +
  margin-top: 2rem;
66 53
  border-top: 1px solid #333;
67 -
  padding-top: 16px;
54 +
  padding-top: 1rem;
55 +
  width: 100%;
68 56
}
69 57
70 58
.archive-list h3 {
71 -
  font-size: 14px;
59 +
  font-size: 12px;
72 60
  opacity: 0.5;
73 -
  margin: 0 0 8px;
74 61
  text-transform: uppercase;
75 62
  letter-spacing: 0.05em;
76 -
}
77 -
78 -
.item-list {
79 -
  list-style: none;
80 -
  margin: 0;
81 -
  padding: 0;
82 -
}
83 -
84 -
.item {
85 -
  border-bottom: 1px solid #333;
63 +
  margin-bottom: 0.5rem;
86 64
}
87 65
88 66
.item a {
89 67
  display: grid;
90 68
  grid-template-columns: 90px 1fr auto;
91 -
  gap: 12px;
92 -
  padding: 8px 0;
69 +
  gap: 0.75rem;
93 70
  text-decoration: none;
94 71
  color: inherit;
95 72
}
96 73
97 -
.item-meta {
98 -
  opacity: 0.5;
99 -
  font-size: 12px;
100 -
}
101 -
102 74
.item-title {
103 75
  overflow: hidden;
104 76
  text-overflow: ellipsis;
105 77
  white-space: nowrap;
106 78
}
107 -
108 -
.empty {
109 -
  opacity: 0.5;
110 -
  padding: 32px 0;
111 -
  text-align: center;
112 -
}
113 -
114 -
.error-page {
115 -
  padding: 32px 0;
116 -
}
apps/easel/templates/_artwork.html +7 −10
1 1
<article class="artwork">
2 2
  <figure class="artwork-figure">
3 -
    <a href="{{ artwork.source_url }}" target="_blank" rel="noopener noreferrer">
4 -
      <img src="{{ artwork.image_url }}" alt="{{ artwork.title }}" loading="lazy" />
5 -
    </a>
3 +
    <img src="{{ artwork.image_url }}" alt="{{ artwork.title }}" loading="lazy" />
6 4
  </figure>
7 5
  <header class="artwork-meta">
8 6
    <p class="artwork-date">{{ artwork.date }}</p>
9 -
    <h2 class="artwork-title"><em>{{ artwork.title }}</em></h2>
7 +
    <h2 class="artwork-title">
8 +
      <a href="{{ artwork.source_url }}" target="_blank" rel="noopener noreferrer"><em>{{ artwork.title }}</em></a>
9 +
    </h2>
10 10
    {% if !artwork.artist_display.is_empty() %}
11 11
    <p class="artwork-artist">{{ artwork.artist_display }}</p>
12 12
    {% endif %}
28 28
    <dt>Credit</dt><dd>{{ artwork.credit_line }}</dd>
29 29
    {% endif %}
30 30
  </dl>
31 -
  {% if !artwork.short_description.is_empty() %}
31 +
  {% if !artwork.description.is_empty() %}
32 +
  <div class="artwork-description">{{ artwork.description|safe }}</div>
33 +
  {% else if !artwork.short_description.is_empty() %}
32 34
  <p class="artwork-description">{{ artwork.short_description }}</p>
33 35
  {% endif %}
34 -
  <p class="artwork-source">
35 -
    <a href="{{ artwork.source_url }}" target="_blank" rel="noopener noreferrer">
36 -
      view on artic.edu →
37 -
    </a>
38 -
  </p>
39 36
</article>
apps/easel/templates/base.html +19 −10
3 3
  <head>
4 4
    <meta charset="UTF-8" />
5 5
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <title>{% block title %}Easel{% endblock %}</title>
7 +
    <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
8 +
    <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
9 +
    <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
10 +
    <link rel="manifest" href="/static/site.webmanifest">
11 +
    <link rel="icon" href="/static/favicon.ico">
12 +
    <meta property="og:title" content="Easel">
13 +
    <meta property="og:description" content="A daily painting from the Art Institute of Chicago">
14 +
    <meta property="og:image" content="/static/og.png">
15 +
    <meta property="og:type" content="website">
6 16
    <meta name="theme-color" content="#121113" />
7 17
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 18
    <link rel="stylesheet" href="/static/styles.css" />
9 -
    <title>{% block title %}Easel{% endblock %}</title>
10 19
    <meta name="description" content="A daily painting from the Art Institute of Chicago" />
11 20
  </head>
12 21
  <body>
13 -
    <div class="container">
14 -
      <div class="header">
15 -
        <a href="/" class="logo"><h1>EASEL</h1></a>
16 -
        <nav class="links">
17 -
          <a href="/">today</a>
18 -
          <a href="/archive">archive</a>
19 -
        </nav>
20 -
      </div>
22 +
    <header class="header">
23 +
      <a href="/" class="logo">EASEL</a>
24 +
      <nav class="links">
25 +
        <a href="/">today</a>
26 +
        <a href="/archive">archive</a>
27 +
      </nav>
28 +
    </header>
29 +
    <main>
21 30
      {% block content %}{% endblock %}
22 -
    </div>
31 +
    </main>
23 32
  </body>
24 33
</html>
apps/easel/templates/day.html +0 −17
2 2
{% block title %}Easel — {{ date }}{% endblock %}
3 3
{% block content %}
4 4
  {% include "_artwork.html" %}
5 -
6 -
  {% if !archive.is_empty() %}
7 -
  <section class="archive-list">
8 -
    <h3>Other days</h3>
9 -
    <ul class="item-list">
10 -
      {% for row in archive %}
11 -
      <li class="item">
12 -
        <a href="/day/{{ row.date }}">
13 -
          <span class="item-meta">{{ row.date }}</span>
14 -
          <span class="item-title"><em>{{ row.title }}</em></span>
15 -
          {% if !row.artist.is_empty() %}<span class="item-meta">{{ row.artist }}</span>{% endif %}
16 -
        </a>
17 -
      </li>
18 -
      {% endfor %}
19 -
    </ul>
20 -
  </section>
21 -
  {% endif %}
22 5
{% endblock %}
apps/easel/templates/index.html +0 −17
8 8
      <p>Today's artwork ({{ today_date }}) is not yet available. Check back shortly.</p>
9 9
    </div>
10 10
  {% endif %}
11 -
12 -
  {% if !archive.is_empty() %}
13 -
  <section class="archive-list">
14 -
    <h3>Recent days</h3>
15 -
    <ul class="item-list">
16 -
      {% for row in archive %}
17 -
      <li class="item">
18 -
        <a href="/day/{{ row.date }}">
19 -
          <span class="item-meta">{{ row.date }}</span>
20 -
          <span class="item-title"><em>{{ row.title }}</em></span>
21 -
          {% if !row.artist.is_empty() %}<span class="item-meta">{{ row.artist }}</span>{% endif %}
22 -
        </a>
23 -
      </li>
24 -
      {% endfor %}
25 -
    </ul>
26 -
  </section>
27 -
  {% endif %}
28 11
{% endblock %}