A Signal's own icon, from all of Font Awesome Free, searchable (#261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
- Signals: a feed of your own, picked out of the noise of everything you subscribe to, holding the items TypeSafe's Jev says are about the subject you give it: a name, words, and a sentence of your own if they are not enough, from the last 30 days and every new one, judged on your own TypeSafe key, each with an icon of its own if you like (`/api/signals`, and `typesafe_key` on `/api/me`) (#254).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ async fn create_missing(orm: &sea_orm::DatabaseConnection) -> Result<()> {
|
|||||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if orm.execute_unprepared("SELECT icon FROM signals LIMIT 0").await.is_err() {
|
||||||
|
let sql = "ALTER TABLE signals ADD COLUMN icon TEXT";
|
||||||
|
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||||
|
}
|
||||||
if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
|
if orm.execute_unprepared("SELECT pause_ms FROM deadair_prefs LIMIT 0").await.is_err() {
|
||||||
let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
|
let sql = "ALTER TABLE deadair_prefs ADD COLUMN pause_ms BIGINT";
|
||||||
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
orm.execute_unprepared(sql).await.with_context(|| sql.to_owned())?;
|
||||||
@@ -2547,9 +2551,19 @@ pub struct SignalRow {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub keywords: Vec<String>,
|
pub keywords: Vec<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
pub unread: i64,
|
pub unread: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a Signal is changed to: each field None to leave it as it is.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SignalChange<'a> {
|
||||||
|
pub name: Option<&'a str>,
|
||||||
|
pub words: Option<&'a [String]>,
|
||||||
|
pub description: Option<Option<&'a str>>,
|
||||||
|
pub icon: Option<Option<&'a str>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One Signal's question about an item: what it is called, its words, and its owner's sentence.
|
/// One Signal's question about an item: what it is called, its words, and its owner's sentence.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Ask {
|
pub struct Ask {
|
||||||
@@ -2591,7 +2605,7 @@ impl Db {
|
|||||||
let user = a.p(user_id);
|
let user = a.p(user_id);
|
||||||
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
|
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT g.id, g.name, g.keywords, g.description,
|
"SELECT g.id, g.name, g.keywords, g.description, g.icon,
|
||||||
(SELECT count(*) FROM signal_verdicts v
|
(SELECT count(*) FROM signal_verdicts v
|
||||||
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
|
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
|
||||||
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
|
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
|
||||||
@@ -2609,6 +2623,7 @@ impl Db {
|
|||||||
name: r.try_get("", "name")?,
|
name: r.try_get("", "name")?,
|
||||||
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
|
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
|
||||||
description: r.try_get("", "description")?,
|
description: r.try_get("", "description")?,
|
||||||
|
icon: r.try_get("", "icon")?,
|
||||||
unread: r.try_get("", "unread")?,
|
unread: r.try_get("", "unread")?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -2629,17 +2644,11 @@ impl Db {
|
|||||||
Ok(m.id)
|
Ok(m.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renames a Signal, or gives it new words or a new sentence. Either of those puts every item
|
/// Changes a Signal. A new name, words or sentence puts every item to Jev again, since what it
|
||||||
/// to Jev again, since what it said was about the old ones; a new name does too, since the
|
/// said was about the old question (#259); a new icon does not. False for a Signal that is not
|
||||||
/// question is about the name (#259). False for a Signal that is not this person's.
|
/// this person's.
|
||||||
pub async fn set_signal(
|
pub async fn set_signal(&self, user_id: i64, id: i64, change: SignalChange<'_>) -> Result<bool> {
|
||||||
&self,
|
let SignalChange { name, words, description, icon } = change;
|
||||||
user_id: i64,
|
|
||||||
id: i64,
|
|
||||||
name: Option<&str>,
|
|
||||||
words: Option<&[String]>,
|
|
||||||
description: Option<Option<&str>>,
|
|
||||||
) -> Result<bool> {
|
|
||||||
if self.signal(user_id, id).await?.is_none() {
|
if self.signal(user_id, id).await?.is_none() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -2653,8 +2662,13 @@ impl Db {
|
|||||||
if let Some(d) = description {
|
if let Some(d) = description {
|
||||||
m.description = Set(d.map(str::to_owned));
|
m.description = Set(d.map(str::to_owned));
|
||||||
}
|
}
|
||||||
|
if let Some(i) = icon {
|
||||||
|
m.icon = Set(i.map(str::to_owned));
|
||||||
|
}
|
||||||
if name.is_some() || words.is_some() || description.is_some() {
|
if name.is_some() || words.is_some() || description.is_some() {
|
||||||
signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?;
|
signal_verdicts::Entity::delete_many().filter(signal_verdicts::Column::SignalId.eq(id)).exec(&self.orm).await?;
|
||||||
|
}
|
||||||
|
if name.is_some() || words.is_some() || description.is_some() || icon.is_some() {
|
||||||
m.update(&self.orm).await?;
|
m.update(&self.orm).await?;
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -3396,15 +3410,19 @@ mod tests {
|
|||||||
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().unread, 0, "read in its feed is read in the Signal");
|
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().unread, 0, "read in its feed is read in the Signal");
|
||||||
|
|
||||||
assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found");
|
assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found");
|
||||||
assert!(!db.set_signal(2, f1, Some("mine"), None, None).await.unwrap());
|
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());
|
||||||
assert!(!db.remove_signal(2, f1).await.unwrap());
|
assert!(!db.remove_signal(2, f1).await.unwrap());
|
||||||
|
|
||||||
db.set_signal(1, f1, None, Some(&words(&["f1"])), None).await.unwrap();
|
db.set_signal(1, f1, SignalChange { words: Some(&words(&["f1"])), ..Default::default() }).await.unwrap();
|
||||||
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "new keywords are judged afresh");
|
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "new keywords are judged afresh");
|
||||||
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
|
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
|
||||||
db.set_signal(1, f1, None, None, Some(Some("Grand prix racing"))).await.unwrap();
|
db.set_signal(1, f1, SignalChange { description: Some(Some("Grand prix racing")), ..Default::default() }).await.unwrap();
|
||||||
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "so is a new sentence");
|
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 0, "so is a new sentence");
|
||||||
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().description.as_deref(), Some("Grand prix racing"));
|
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().description.as_deref(), Some("Grand prix racing"));
|
||||||
|
db.record_verdicts("f", "x", &[(f1, true)]).await.unwrap();
|
||||||
|
db.set_signal(1, f1, SignalChange { icon: Some(Some("solid/flag-checkered")), ..Default::default() }).await.unwrap();
|
||||||
|
assert_eq!(db.signal_count(1, f1, Filter::All, None).await.unwrap(), 1, "a new icon asks nothing again");
|
||||||
|
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().icon.as_deref(), Some("solid/flag-checkered"));
|
||||||
assert!(db.remove_signal(1, food).await.unwrap());
|
assert!(db.remove_signal(1, food).await.unwrap());
|
||||||
db.record_verdicts("f", "x", &[(food, true)]).await.unwrap();
|
db.record_verdicts("f", "x", &[(food, true)]).await.unwrap();
|
||||||
assert_eq!(db.signals(1).await.unwrap().len(), 1, "a verdict for a removed Signal is not kept");
|
assert_eq!(db.signals(1).await.unwrap().len(), 1, "a verdict for a removed Signal is not kept");
|
||||||
|
|||||||
@@ -308,6 +308,10 @@ pub mod signals {
|
|||||||
/// are not enough (#259).
|
/// are not enough (#259).
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
/// Its icon, a Font Awesome Free one as `style/name` (`solid/robot`), in place of its
|
||||||
|
/// initials (#261).
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub icon: Option<String>,
|
||||||
pub created: i64,
|
pub created: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -429,6 +429,24 @@ struct SignalBody {
|
|||||||
/// What its owner means by it, in their words: a string sets it, null or empty clears it.
|
/// What its owner means by it, in their words: a string sets it, null or empty clears it.
|
||||||
#[serde(default, deserialize_with = "double_option")]
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
description: Option<Option<String>>,
|
description: Option<Option<String>>,
|
||||||
|
/// A Font Awesome Free icon, `solid/robot`: a string sets it, null or empty clears it.
|
||||||
|
#[serde(default, deserialize_with = "double_option")]
|
||||||
|
icon: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page puts the name into an address (`/icons/<style>/<name>.svg`), so nothing but a style
|
||||||
|
/// and a plain name gets through.
|
||||||
|
fn signal_icon(i: Option<String>) -> Result<Option<String>, ApiError> {
|
||||||
|
let Some(i) = i.filter(|i| !i.is_empty()) else { return Ok(None) };
|
||||||
|
let ok = i.split_once('/').is_some_and(|(style, name)| {
|
||||||
|
["solid", "regular", "brands"].contains(&style)
|
||||||
|
&& (1..=64).contains(&name.len())
|
||||||
|
&& name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
});
|
||||||
|
if !ok {
|
||||||
|
return Err(ApiError::bad_request("an icon is a Font Awesome Free one, such as solid/robot"));
|
||||||
|
}
|
||||||
|
Ok(Some(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn signal_description(d: Option<String>) -> Result<Option<String>, ApiError> {
|
fn signal_description(d: Option<String>) -> Result<Option<String>, ApiError> {
|
||||||
@@ -471,7 +489,12 @@ async fn add_signal(
|
|||||||
let name = signal_name(body.name.as_deref().unwrap_or(""))?;
|
let name = signal_name(body.name.as_deref().unwrap_or(""))?;
|
||||||
let words = signal_words(body.keywords.unwrap_or_default())?;
|
let words = signal_words(body.keywords.unwrap_or_default())?;
|
||||||
let description = signal_description(body.description.flatten())?;
|
let description = signal_description(body.description.flatten())?;
|
||||||
|
let icon = signal_icon(body.icon.flatten())?;
|
||||||
let id = state.ctx.db.add_signal(user.id, &name, &words, description.as_deref()).await?;
|
let id = state.ctx.db.add_signal(user.id, &name, &words, description.as_deref()).await?;
|
||||||
|
if icon.is_some() {
|
||||||
|
let change = crate::db::SignalChange { icon: Some(icon.as_deref()), ..Default::default() };
|
||||||
|
state.ctx.db.set_signal(user.id, id, change).await?;
|
||||||
|
}
|
||||||
state.ctx.signals.notify_one();
|
state.ctx.signals.notify_one();
|
||||||
Ok(Json(serde_json::json!({ "id": id })))
|
Ok(Json(serde_json::json!({ "id": id })))
|
||||||
}
|
}
|
||||||
@@ -485,11 +508,18 @@ async fn patch_signal(
|
|||||||
let name = body.name.as_deref().map(signal_name).transpose()?;
|
let name = body.name.as_deref().map(signal_name).transpose()?;
|
||||||
let words = body.keywords.map(signal_words).transpose()?;
|
let words = body.keywords.map(signal_words).transpose()?;
|
||||||
let description = body.description.map(signal_description).transpose()?;
|
let description = body.description.map(signal_description).transpose()?;
|
||||||
let changed = name.is_some() || words.is_some() || description.is_some();
|
let icon = body.icon.map(signal_icon).transpose()?;
|
||||||
if !state.ctx.db.set_signal(user.id, id, name.as_deref(), words.as_deref(), description.as_ref().map(|d| d.as_deref())).await? {
|
let asks_again = name.is_some() || words.is_some() || description.is_some();
|
||||||
|
let change = crate::db::SignalChange {
|
||||||
|
name: name.as_deref(),
|
||||||
|
words: words.as_deref(),
|
||||||
|
description: description.as_ref().map(|d| d.as_deref()),
|
||||||
|
icon: icon.as_ref().map(|i| i.as_deref()),
|
||||||
|
};
|
||||||
|
if !state.ctx.db.set_signal(user.id, id, change).await? {
|
||||||
return Err(ApiError::not_found("no such Signal"));
|
return Err(ApiError::not_found("no such Signal"));
|
||||||
}
|
}
|
||||||
if changed {
|
if asks_again {
|
||||||
state.ctx.signals.notify_one();
|
state.ctx.signals.notify_one();
|
||||||
}
|
}
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
@@ -1211,6 +1241,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_signal_icon_is_a_style_and_a_plain_name() {
|
||||||
|
assert_eq!(signal_icon(Some("solid/robot".into())).ok().flatten().as_deref(), Some("solid/robot"));
|
||||||
|
assert_eq!(signal_icon(Some("brands/apple".into())).ok().flatten().as_deref(), Some("brands/apple"));
|
||||||
|
assert!(matches!(signal_icon(Some(String::new())), Ok(None)), "empty clears it");
|
||||||
|
// It becomes part of an address and a style attribute on the page.
|
||||||
|
for bad in ["solid/../../api/me", "light/robot", "solid/Robot", "solid/x');background:url(x", "robot", "solid/"] {
|
||||||
|
assert!(signal_icon(Some(bad.into())).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_a_plain_theme_name_is_handed_on() {
|
fn only_a_plain_theme_name_is_handed_on() {
|
||||||
assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));
|
assert!(theme_ok("dracula", "dark") && theme_ok("nordic", "auto"));
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ their Signals that has not heard about the item yet.
|
|||||||
#256). It is closed or open as an OPML's folder is, remembered in each browser. A + at the right
|
#256). It is closed or open as an OPML's folder is, remembered in each browser. A + at the right
|
||||||
of the folder's row opens New signal. Inside the folder are the person's Signals, each with its
|
of the folder's row opens New signal. Inside the folder are the person's Signals, each with its
|
||||||
words under its name and its unread count.
|
words under its name and its unread count.
|
||||||
|
- **An icon of its own** (#261), chosen in the Signal's dialog from all of Font Awesome Free 7.3.1,
|
||||||
|
the set the page's own icons come from: 2,883 icons, solid, regular and brands, searched by name
|
||||||
|
and Font Awesome's search terms ("ai" finds the robot). Without one, a Signal shows its initials.
|
||||||
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with a + of its own.
|
- **The folder's page** lists the Signals, as an OPML's page lists its feeds, with a + of its own.
|
||||||
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
|
- **A Signal's page** is an ordinary item list: All, Unread, Downloaded and Pinned, search and sort.
|
||||||
Its header says what it listens for (the sentence, or the words), and that nothing is picked
|
Its header says what it listens for (the sentence, or the words), and that nothing is picked
|
||||||
@@ -190,8 +193,10 @@ up to 500 characters. Anything else is a `400` saying so.
|
|||||||
### `PATCH /api/signals/{id}`
|
### `PATCH /api/signals/{id}`
|
||||||
|
|
||||||
Any of the three: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`,
|
Any of the three: `{ "name": "F1" }`, `{ "keywords": ["f1", "grand prix"] }`,
|
||||||
`{ "description": null }` (null or empty clears it). Any change drops the Signal's verdicts and
|
`{ "description": null }` (null or empty clears it), `{ "icon": "solid/robot" }`. A change to the
|
||||||
wakes the judge, since each is part of the question. Answers `204`, or `404` for a Signal that is not yours.
|
name, words or sentence drops the Signal's verdicts and wakes the judge, since each is part of the
|
||||||
|
question; a new icon does not. The page sends only what changed. An icon is `solid/`, `regular/` or
|
||||||
|
`brands/` and a plain name, since the page puts it into an address: anything else is a `400`. Answers `204`, or `404` for a Signal that is not yours.
|
||||||
|
|
||||||
### `DELETE /api/signals/{id}`
|
### `DELETE /api/signals/{id}`
|
||||||
|
|
||||||
@@ -261,6 +266,7 @@ erDiagram
|
|||||||
text name
|
text name
|
||||||
text keywords "JSON array"
|
text keywords "JSON array"
|
||||||
text description "NULL: none"
|
text description "NULL: none"
|
||||||
|
text icon "solid/robot, NULL: initials"
|
||||||
bigint created
|
bigint created
|
||||||
}
|
}
|
||||||
signal_verdicts {
|
signal_verdicts {
|
||||||
@@ -272,8 +278,12 @@ erDiagram
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `signals` and `signal_verdicts` are created from their entities like every table
|
- `signals` and `signal_verdicts` are created from their entities like every table
|
||||||
(`crates/ipx/src/entity.rs`). `users.typesafe_key` and `signals.description` were added to
|
(`crates/ipx/src/entity.rs`). `users.typesafe_key`, `signals.description` and `signals.icon`
|
||||||
existing tables, so `create_missing` adds each column when it is missing.
|
were added to existing tables, so `create_missing` adds each column when it is missing.
|
||||||
|
- The icons are files of ipx-web's, not the database's: `web/build.mjs` copies Font Awesome Free's
|
||||||
|
SVGs to `/icons/<style>/<name>.svg`, kept a day, and writes `/icons/index.json`, names, labels
|
||||||
|
and search terms, about 90 KB compressed, named by its hash and fetched when the picker first
|
||||||
|
opens. A Signal's tile draws its one file as a CSS mask, so it takes the theme's colour.
|
||||||
- A Signal's list is `entries_where` with
|
- A Signal's list is `entries_where` with
|
||||||
`EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)`
|
`EXISTS (SELECT 1 FROM signal_verdicts v WHERE v.signal_id = $n AND v.feed_id = e.feed_id AND v.guid = e.guid AND v.hit)`
|
||||||
on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all
|
on top of the subscribed-feeds scope. So filters, search, sort, block lists and read state all
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
|
"version": "1.0.3-dev",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ipx-web",
|
"name": "ipx-web",
|
||||||
|
"version": "1.0.3-dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "7.3.1",
|
||||||
"@swc/core": "^1.16.2",
|
"@swc/core": "^1.16.2",
|
||||||
"@swc/html": "^1.16.2",
|
"@swc/html": "^1.16.2",
|
||||||
"hls.js": "1.6.19"
|
"hls.js": "1.6.19"
|
||||||
@@ -15,6 +18,15 @@
|
|||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fortawesome/fontawesome-free": {
|
||||||
|
"version": "7.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz",
|
||||||
|
"integrity": "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg==",
|
||||||
|
"license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.63.0",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "7.3.1",
|
||||||
"@swc/core": "^1.16.2",
|
"@swc/core": "^1.16.2",
|
||||||
"@swc/html": "^1.16.2",
|
"@swc/html": "^1.16.2",
|
||||||
"hls.js": "1.6.19"
|
"hls.js": "1.6.19"
|
||||||
|
|||||||
@@ -154,3 +154,31 @@ test('a Signal is a place of its own, found by its id', () => {
|
|||||||
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
|
assert.match(run(`listUrl(':signal:7')`), /^\/api\/signals\/7\/entries\?/);
|
||||||
run(`S.signals = []`);
|
run(`S.signals = []`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the icon picker finds icons by the start of their words, the one named it first', () => {
|
||||||
|
const all = JSON.stringify([
|
||||||
|
['solid/caret-down', 'Caret Down', 'arrow'], ['solid/scarf', 'Scarf', 'clothing'], ['solid/taxi', 'Taxi', 'cab car'],
|
||||||
|
['solid/car-side', 'Car Side', 'auto vehicle'], ['solid/car', 'Car', 'auto vehicle'], ['solid/robot', 'Robot', 'ai bot machine'],
|
||||||
|
['brands/apple', 'Apple', 'fruit ios mac'],
|
||||||
|
]);
|
||||||
|
const ids = q => JSON.parse(run(`JSON.stringify(iconMatches(${all}, ${JSON.stringify(q)}, 10).map(i => i[0]))`));
|
||||||
|
// Named car, then car as a word of the name, then as a search term, then caret; never scarf.
|
||||||
|
assert.deepEqual(ids('car'), ['solid/car', 'solid/car-side', 'solid/taxi', 'solid/caret-down']);
|
||||||
|
assert.deepEqual(ids('ai'), ['solid/robot'], 'search terms count');
|
||||||
|
assert.deepEqual(ids('mac fruit'), ['brands/apple'], 'every word');
|
||||||
|
assert.equal(run(`iconMatches(${all}, '', 2).length`), 2, 'nothing typed is the first few');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing a Signal sends only what changed, so a new icon asks Jev nothing again', () => {
|
||||||
|
const sg = `{id: 1, name: 'F1', keywords: ['f1'], description: null, icon: null, unread: 0}`;
|
||||||
|
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1'], description: null, icon: 'solid/car'}))`)),
|
||||||
|
{ icon: 'solid/car' });
|
||||||
|
assert.deepEqual(JSON.parse(run(`JSON.stringify(signalChanges(${sg}, {name: 'F1', keywords: ['f1', 'gp'], description: null, icon: null}))`)),
|
||||||
|
{ keywords: ['f1', 'gp'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Signal draws its icon only when it is a style and a plain name', () => {
|
||||||
|
assert.match(run(`signalTile({name: 'F1', icon: 'solid/flag-checkered'})`), /url\('\/icons\/solid\/flag-checkered\.svg'\)/);
|
||||||
|
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: "solid/x');background:url(evil"})`), /evil/);
|
||||||
|
assert.doesNotMatch(run(`signalTile({name: 'F1', icon: null})`), /ficon/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2084,9 +2084,15 @@ test('a Signal holds the items Jev says fit its keywords, read where they are re
|
|||||||
await page.locator('#sgname').fill('F1');
|
await page.locator('#sgname').fill('F1');
|
||||||
await page.locator('#sgkwin').fill('monaco');
|
await page.locator('#sgkwin').fill('monaco');
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
|
// An icon of its own, found by searching Font Awesome Free (#261).
|
||||||
|
await page.locator('#sgicon').click();
|
||||||
|
await page.locator('#sgiconq').fill('flag checkered');
|
||||||
|
await page.locator('[data-ic="solid/flag-checkered"]').click();
|
||||||
await page.locator('#sgsave').click();
|
await page.locator('#sgsave').click();
|
||||||
const row = page.locator('#feedlist .feed.child', { hasText: 'F1' });
|
const row = page.locator('#feedlist .feed.child', { hasText: 'F1' });
|
||||||
await expect(row).toHaveClass(/sel/);
|
await expect(row).toHaveClass(/sel/);
|
||||||
|
await expect(row.locator('.ficon')).toHaveAttribute('style', /\/icons\/solid\/flag-checkered\.svg/);
|
||||||
|
expect((await page.request.get('/icons/solid/flag-checkered.svg')).status()).toBe(200);
|
||||||
|
|
||||||
const signals = async () => (await (await page.request.get('/api/signals')).json());
|
const signals = async () => (await (await page.request.get('/api/signals')).json());
|
||||||
const sg = (await signals()).find(s => s.name === 'F1');
|
const sg = (await signals()).find(s => s.name === 'F1');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256).
|
- Signals pick what matters out of the noise of every feed you subscribe to: New signal takes a name, the words to listen for and, if those are not enough, a sentence saying what you mean, and its page lists the items from your feeds that are about it, read and unread as in their own feeds. Settings takes your TypeSafe key, which Signals need (#254). They are kept in a Signals folder first in the feed list, under All Subscriptions, closed or open as an OPML is, with a + on its row for a new one and its own page listing them (#255, #256). Each can have an icon of its own, searched for among all of Font Awesome Free, in place of its initials (#261).
|
||||||
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
|
- A list opens at once when you have seen it before or the page fetched it ahead: All Subscriptions and the feeds with something unread are fetched when the page loads, and a feed or filter as the pointer or a finger reaches it, then refreshed behind once shown. A list that has to be waited for shows a spinner instead of nothing (#247). Currently Listening and the Directory's front page are fetched ahead the same way and open at once (#248).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ http://{$WEB_BIND::8080} {
|
|||||||
|
|
||||||
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
|
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
|
||||||
# keeps them a year; the icons a day, under names that do not change.
|
# keeps them a year; the icons a day, under names that do not change.
|
||||||
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
|
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js /icons/index.json
|
||||||
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
header @hashed Cache-Control "public, max-age=31536000, immutable"
|
||||||
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
|
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
|
||||||
header /favicon.ico Content-Type image/png
|
header /favicon.ico Content-Type image/png
|
||||||
|
|||||||
15
web/app.css
15
web/app.css
@@ -592,6 +592,21 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
|
|||||||
.feed.child .art{width:28px;height:28px;font-size:11px}
|
.feed.child .art{width:28px;height:28px;font-size:11px}
|
||||||
/* A Signal's tile is its icon, as a place's is: there is no artwork of its own. */
|
/* A Signal's tile is its icon, as a place's is: there is no artwork of its own. */
|
||||||
.art.sig .i{width:45%;height:45%}
|
.art.sig .i{width:45%;height:45%}
|
||||||
|
/* A Signal's own icon (#261): Font Awesome's file as a mask, so it is drawn in the text's colour
|
||||||
|
and follows the theme as the page's own icons do. */
|
||||||
|
.ficon{
|
||||||
|
display:block;width:50%;height:50%;background:currentColor;
|
||||||
|
-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat;
|
||||||
|
}
|
||||||
|
.iconpick{flex:none;border-radius:9px}
|
||||||
|
.iconpick .art{width:38px;height:38px;font-size:13px}
|
||||||
|
.icongrid{
|
||||||
|
display:grid;grid-template-columns:repeat(auto-fill,minmax(40px,1fr));gap:4px;
|
||||||
|
max-height:220px;overflow-y:auto;margin-top:8px;
|
||||||
|
}
|
||||||
|
.iconcell{height:40px;border-radius:8px;display:grid;place-items:center;color:var(--dim)}
|
||||||
|
.iconcell:hover,.iconcell[aria-selected="true"]{background:var(--raise);color:var(--fg)}
|
||||||
|
.iconcell .ficon{width:20px;height:20px}
|
||||||
/* New signal, at the end of the folder's row. */
|
/* New signal, at the end of the folder's row. */
|
||||||
.sigadd{width:26px;height:26px;margin-left:-4px}
|
.sigadd{width:26px;height:26px;margin-left:-4px}
|
||||||
.sigadd .i{width:12px;height:12px}
|
.sigadd .i{width:12px;height:12px}
|
||||||
|
|||||||
@@ -38,6 +38,23 @@ const hash = s => crypto.createHash('sha256').update(s).digest('hex').slice(0, 1
|
|||||||
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
|
const HLS = fs.readFileSync(path.join(here, '..', 'node_modules', 'hls.js', 'dist', 'hls.light.min.js'), 'utf8');
|
||||||
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
|
const HLS_URL = `/hls.js?v=${hash(HLS)}`;
|
||||||
|
|
||||||
|
// Font Awesome Free, the set the page's own icons come from, whole, for a Signal's icon (#261):
|
||||||
|
// each icon a file of its own, /icons/<style>/<name>.svg, drawn as a mask so it takes the theme's
|
||||||
|
// colour, and an index of names and search terms the picker fetches when it opens. The page's
|
||||||
|
// script is given the index's address, named by its hash, in place of __ICONS__.
|
||||||
|
const FA = path.join(here, '..', 'node_modules', '@fortawesome', 'fontawesome-free');
|
||||||
|
const FA_STYLES = ['solid', 'regular', 'brands'];
|
||||||
|
/// [`style/name`, label, search terms] for every icon, solid first.
|
||||||
|
export function iconIndex() {
|
||||||
|
const meta = JSON.parse(fs.readFileSync(path.join(FA, 'metadata', 'icon-families.json'), 'utf8'));
|
||||||
|
return FA_STYLES.flatMap(style => fs.readdirSync(path.join(FA, 'svgs', style)).sort().map(f => {
|
||||||
|
const name = f.slice(0, -4), m = meta[name] || {};
|
||||||
|
return [`${style}/${name}`, m.label || name, (m.search?.terms || []).join(' ')];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const ICONS = JSON.stringify(iconIndex());
|
||||||
|
const ICONS_URL = `/icons/index.json?v=${hash(ICONS)}`;
|
||||||
|
|
||||||
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
|
/// The shared stylesheet, minified. @swc/html minifies CSS inside a page, so it goes through as
|
||||||
/// one; the doctype only keeps it from complaining that a fragment has none.
|
/// one; the doctype only keeps it from complaining that a fragment has none.
|
||||||
export function buildStyle({ minify = true } = {}) {
|
export function buildStyle({ minify = true } = {}) {
|
||||||
@@ -61,6 +78,7 @@ export function buildPage(name, { minify = true } = {}) {
|
|||||||
const { script, src: files } = PAGES[name];
|
const { script, src: files } = PAGES[name];
|
||||||
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
|
const src = files.map(f => fs.readFileSync(path.join(here, 'src', f + '.ts'), 'utf8')).join('\n')
|
||||||
.replaceAll('__HLS_JS__', HLS_URL)
|
.replaceAll('__HLS_JS__', HLS_URL)
|
||||||
|
.replaceAll('__ICONS__', ICONS_URL)
|
||||||
.replaceAll('__WEB_VERSION__', WEB_VERSION);
|
.replaceAll('__WEB_VERSION__', WEB_VERSION);
|
||||||
const js = swc.transformSync(src, {
|
const js = swc.transformSync(src, {
|
||||||
filename: name + '.ts',
|
filename: name + '.ts',
|
||||||
@@ -109,6 +127,12 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|||||||
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
|
['apple-touch-icon.png', 'apple-touch-icon.png'], ['apple-touch-icon.png', 'apple-touch-icon-precomposed.png'],
|
||||||
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
|
['ipodderx-icon.png', 'icon.png'], ['boot.js', 'boot.js'], ['InterVariable.woff2', 'inter.woff2'], ['Inter-LICENSE.txt', 'Inter-LICENSE.txt'],
|
||||||
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
]) fs.copyFileSync(path.join(here, from), path.join(out, to));
|
||||||
|
for (const style of FA_STYLES) {
|
||||||
|
fs.mkdirSync(path.join(out, 'icons', style), { recursive: true });
|
||||||
|
for (const f of fs.readdirSync(path.join(FA, 'svgs', style))) fs.copyFileSync(path.join(FA, 'svgs', style, f), path.join(out, 'icons', style, f));
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(out, 'icons', 'index.json'), ICONS);
|
||||||
|
fs.copyFileSync(path.join(FA, 'LICENSE.txt'), path.join(out, 'icons', 'LICENSE.txt'));
|
||||||
for (const name of Object.keys(PAGES)) {
|
for (const name of Object.keys(PAGES)) {
|
||||||
const { html, js, script } = buildPage(name);
|
const { html, js, script } = buildPage(name);
|
||||||
fs.writeFileSync(path.join(out, name), html);
|
fs.writeFileSync(path.join(out, name), html);
|
||||||
|
|||||||
@@ -30,6 +30,15 @@ function feedRow(id: string, cls: string, html: string, open: ()=>void){
|
|||||||
el.onclick=()=>{ open(); nav(false); };
|
el.onclick=()=>{ open(); nav(false); };
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
/// A Signal's icon, drawn from /icons as a mask so it takes the theme's colour, or its initials.
|
||||||
|
/// The name goes into an address and a style: only a style and a plain name are drawn (the server
|
||||||
|
/// takes nothing else either).
|
||||||
|
const ICON_ID=/^(solid|regular|brands)\/[a-z0-9-]{1,64}$/;
|
||||||
|
const iconHTML=(icon: string)=>`<span class="ficon" style="--icon:url('/icons/${icon}.svg')"></span>`;
|
||||||
|
function signalTile(sg: {name: string, icon: string|null}){
|
||||||
|
return sg.icon&&ICON_ID.test(sg.icon)
|
||||||
|
? `<div class="art ini sig" style="--tint:${tint(sg.name)}">${iconHTML(sg.icon)}</div>` : tileHTML(sg.name);
|
||||||
|
}
|
||||||
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
const badgeHTML=(n: number)=>`<span class="badge${n?'':' zero'}" title="${n} unread">${n>999?'999+':n}</span>`;
|
||||||
function signalFolder(list: HTMLElement, q: string){
|
function signalFolder(list: HTMLElement, q: string){
|
||||||
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
const mine=S.signals.filter(s=>!q||s.name.toLowerCase().includes(q));
|
||||||
@@ -48,7 +57,7 @@ function signalFolder(list: HTMLElement, q: string){
|
|||||||
if(!open) return;
|
if(!open) return;
|
||||||
for(const sg of mine){
|
for(const sg of mine){
|
||||||
const el=feedRow(':signal:'+sg.id,'child',
|
const el=feedRow(':signal:'+sg.id,'child',
|
||||||
`<span class="fart">${tileHTML(sg.name)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
|
`<span class="fart">${signalTile(sg)}</span><div class="txt"><b>${esc(sg.name)}</b><small>${esc(sg.keywords.join(', '))}</small></div>`+badgeHTML(sg.unread),
|
||||||
()=>selectFeed(':signal:'+sg.id));
|
()=>selectFeed(':signal:'+sg.id));
|
||||||
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
|
el.onpointerenter=el.ontouchstart=()=>prefetch(':signal:'+sg.id);
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
@@ -84,7 +93,7 @@ function renderSignals(){
|
|||||||
for(const sg of S.signals){
|
for(const sg of S.signals){
|
||||||
const el=document.createElement('div');
|
const el=document.createElement('div');
|
||||||
el.className='childrow';
|
el.className='childrow';
|
||||||
el.innerHTML=tileHTML(sg.name)+`<div class="txt"><b>${esc(sg.name)}</b>`+
|
el.innerHTML=signalTile(sg)+`<div class="txt"><b>${esc(sg.name)}</b>`+
|
||||||
`<small class="meta">${esc(sg.keywords.join(', '))}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
|
`<small class="meta">${esc(sg.keywords.join(', '))}</small></div><span class="badge${sg.unread?'':' zero'}">${sg.unread}</span>`;
|
||||||
el.onclick=()=>selectFeed(':signal:'+sg.id);
|
el.onclick=()=>selectFeed(':signal:'+sg.id);
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
@@ -95,7 +104,7 @@ function signalHead(sg: Signal){
|
|||||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
const nokey=S.me&&!S.me.typesafe_key_set;
|
||||||
return `
|
return `
|
||||||
<div class="fhead slim">
|
<div class="fhead slim">
|
||||||
<div class="art">${ICON.signal}</div>
|
${signalTile(sg)}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<h2>${esc(sg.name)}</h2>
|
<h2>${esc(sg.name)}</h2>
|
||||||
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
|
<div class="sub">Picked out of the noise of your ${plural(S.feeds.length,'feed')}: ${esc(sg.description||sg.keywords.join(', '))} · ${sg.unread} unread</div>
|
||||||
@@ -124,7 +133,14 @@ function signalModal(sg?: Signal){
|
|||||||
const nokey=S.me&&!S.me.typesafe_key_set;
|
const nokey=S.me&&!S.me.typesafe_key_set;
|
||||||
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
openModal(`<h3>${sg?'Edit signal':'New signal'}</h3>
|
||||||
<div class="field"><label for="sgname">Name</label>
|
<div class="field"><label for="sgname">Name</label>
|
||||||
|
<div class="inline"><button type="button" class="iconpick" id="sgicon" title="Choose an icon" aria-label="Choose an icon"
|
||||||
|
aria-expanded="false"></button>
|
||||||
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
<input type="text" id="sgname" value="${esc(sg?.name||'')}" placeholder="Formula 1"></div>
|
||||||
|
<div id="sgpicker" hidden>
|
||||||
|
<div class="inline"><input type="search" id="sgiconq" placeholder="Search icons: robot, car, music…" autocomplete="off">
|
||||||
|
<button type="button" class="dlink" id="sgnoicon">Initials</button></div>
|
||||||
|
<div class="icongrid" id="sgicons" role="listbox" aria-label="Icons"></div>
|
||||||
|
</div></div>
|
||||||
<div class="field"><label for="sgkwin">Listen for</label>
|
<div class="field"><label for="sgkwin">Listen for</label>
|
||||||
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
|
<div class="chips" id="sgkw">${chipsHTML(sg?.keywords||[],'sgkwin')}</div></div>
|
||||||
<div class="field"><label for="sgdesc">In your own words (optional)</label>
|
<div class="field"><label for="sgdesc">In your own words (optional)</label>
|
||||||
@@ -137,12 +153,38 @@ function signalModal(sg?: Signal){
|
|||||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">${ICON.close}</button>
|
||||||
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
|
<button class="btn ico primary" id="sgsave" title="${sg?'Save':'Make signal'}" aria-label="${sg?'Save':'Make signal'}">${sg?ICON.check:ICON.plus}</button></div>`);
|
||||||
wireChips($('#sgkw'));
|
wireChips($('#sgkw'));
|
||||||
|
let icon=sg?.icon??null;
|
||||||
|
const drawPick=()=>{ $('#sgicon').innerHTML=signalTile({name:$<HTMLInputElement>('#sgname').value||'?',icon}); };
|
||||||
|
drawPick();
|
||||||
|
$<HTMLInputElement>('#sgname').oninput=drawPick;
|
||||||
|
const grid=$('#sgicons');
|
||||||
|
const drawGrid=async()=>{
|
||||||
|
const all=await iconList();
|
||||||
|
const shown=iconMatches(all,$<HTMLInputElement>('#sgiconq').value,ICON_CELLS);
|
||||||
|
grid.innerHTML=shown.length ? shown.map(([id,label])=>`<button type="button" class="iconcell" role="option" data-ic="${id}"
|
||||||
|
aria-selected="${id===icon}" title="${esc(label)}" aria-label="${esc(label)}">${iconHTML(id)}</button>`).join('')
|
||||||
|
: '<p class="hint">No icon matches that.</p>';
|
||||||
|
};
|
||||||
|
$<HTMLButtonElement>('#sgicon').onclick=()=>{
|
||||||
|
const p=$('#sgpicker'), open=p.hidden;
|
||||||
|
p.hidden=!open; $('#sgicon').setAttribute('aria-expanded',String(open));
|
||||||
|
if(open){ drawGrid().catch(e=>toast(why(e),true)); $<HTMLInputElement>('#sgiconq').focus(); }
|
||||||
|
};
|
||||||
|
$<HTMLInputElement>('#sgiconq').oninput=()=>{ drawGrid().catch(()=>{}); };
|
||||||
|
grid.onclick=(e: MouseEvent)=>{
|
||||||
|
const b=(e.target as Element).closest<HTMLElement>('[data-ic]'); if(!b) return;
|
||||||
|
icon=b.dataset.ic!; drawPick(); $('#sgpicker').hidden=true; $('#sgicon').setAttribute('aria-expanded','false');
|
||||||
|
};
|
||||||
|
$<HTMLButtonElement>('#sgnoicon').onclick=()=>{ icon=null; drawPick(); $('#sgpicker').hidden=true; };
|
||||||
$<HTMLInputElement>('#sgname').focus();
|
$<HTMLInputElement>('#sgname').focus();
|
||||||
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
|
$<HTMLButtonElement>('#sgsave').onclick=async()=>{
|
||||||
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw'));
|
const name=$<HTMLInputElement>('#sgname').value.trim(), keywords=chipWords($('#sgkw')).filter((w): w is string=>!!w);
|
||||||
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
|
if(!name){ $<HTMLInputElement>('#sgname').focus(); return; }
|
||||||
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
|
if(!keywords.length){ $<HTMLInputElement>('#sgkwin').focus(); return; }
|
||||||
const body=JSON.stringify({name,keywords,description:$<HTMLInputElement>('#sgdesc').value.trim()||null});
|
const description=$<HTMLInputElement>('#sgdesc').value.trim()||null;
|
||||||
|
// Only what changed: a new name, words or sentence has Jev read everything again, and saving
|
||||||
|
// a new icon alone did that too while the dialog sent them all back each time.
|
||||||
|
const body=JSON.stringify(sg ? signalChanges(sg,{name,keywords,description,icon}) : {name,keywords,description,icon});
|
||||||
try{
|
try{
|
||||||
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
|
const r=sg ? await api(`/api/signals/${sg.id}`,{method:'PATCH',body})
|
||||||
: await api('/api/signals',{method:'POST',body});
|
: await api('/api/signals',{method:'POST',body});
|
||||||
@@ -153,3 +195,38 @@ function signalModal(sg?: Signal){
|
|||||||
}catch(e){ toast(why(e),true); }
|
}catch(e){ toast(why(e),true); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What differs between a Signal and the dialog's fields, as PATCH takes it.
|
||||||
|
function signalChanges(sg: Signal, now: {name: string, keywords: string[], description: string|null, icon: string|null}){
|
||||||
|
const out: Record<string, unknown>={};
|
||||||
|
if(now.name!==sg.name) out.name=now.name;
|
||||||
|
if(now.keywords.join('\n')!==sg.keywords.join('\n')) out.keywords=now.keywords;
|
||||||
|
if(now.description!==sg.description) out.description=now.description;
|
||||||
|
if(now.icon!==sg.icon) out.icon=now.icon;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font Awesome Free's index, `[style/name, label, terms]`, fetched once, when the picker first
|
||||||
|
/// opens: 2,883 icons are about 90 KB compressed, which the page has no other use for.
|
||||||
|
const ICON_CELLS=120;
|
||||||
|
let icons: Promise<[string,string,string][]>|null=null;
|
||||||
|
function iconList(){
|
||||||
|
if(!icons) icons=api('__ICONS__').catch((e: unknown)=>{ icons=null; throw e; });
|
||||||
|
return icons!;
|
||||||
|
}
|
||||||
|
/// The icons with a word starting with each word typed, in their name, label or search terms. The
|
||||||
|
/// first word typed ranks them: the icon named it, then one with it as a word of its name, then as
|
||||||
|
/// a search term, then the rest. Matching anywhere in a word, "car" filled the grid with caret and
|
||||||
|
/// cart (#261). At most `max`; nothing typed is the first `max`.
|
||||||
|
function iconMatches(all: [string,string,string][], q: string, max: number){
|
||||||
|
const words=q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if(!words.length) return all.slice(0,max);
|
||||||
|
const split=(s: string)=>s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
||||||
|
const rank=([id,label,terms]: [string,string,string])=>{
|
||||||
|
const name=id.slice(id.indexOf('/')+1), own=split(name), hay=[...own,...split(label),...split(terms)];
|
||||||
|
if(!words.every(w=>hay.some(h=>h.startsWith(w)))) return -1;
|
||||||
|
const w=words[0];
|
||||||
|
return name===w ? 0 : own.includes(w) ? 1 : hay.includes(w) ? 2 : 3;
|
||||||
|
};
|
||||||
|
return all.map(i=>[rank(i),i] as const).filter(([r])=>r>=0).sort((a,b)=>a[0]-b[0]).slice(0,max).map(([,i])=>i);
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ interface Signal {
|
|||||||
keywords: string[];
|
keywords: string[];
|
||||||
/// What its owner means by it, in their words (#259).
|
/// What its owner means by it, in their words (#259).
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
/// A Font Awesome Free icon, `solid/robot`, in place of its initials (#261).
|
||||||
|
icon: string | null;
|
||||||
unread: number;
|
unread: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user