clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

Folders, as folds

Dami committed 3c3ff98
A folder is a name on a site, not a thing of its own. `--folder Games` files one,
leaving the flag off takes it out — the same rule as sharing, where what the
command names is the whole state of the thing it names.

Choosing a nullable column over a folders table is most of the design. A folder
then exists exactly as long as something is in it: nothing to create, nothing to
delete, no way to be left holding an empty one, and renaming is retagging. The
set of folders is derived at render time from the entries the viewer may see, so
a folder holding only private sites is not an empty fold to a stranger — it is
never built.

Folds are <details open>. A profile's job is to show what is on it, so a folder
groups without hiding, and collapsing is the reader's choice rather than the
owner's. Pico already draws the marker and handles the state; the summary carries
the name and a count.

Loose sites come first in publication order, then a fold per folder in the order
those first appear.

Blank folder names are the same as none. Otherwise a profile grows a fold with no
label, and since the only way to remove a folder is to stop naming it, there
would be no way to get rid of it.
commit
3c3ff9868636bc69ebf7319b69d903a44c686813
parent
8f35b413c930888152a3973a8d2c6372d289a1d9
author
Dami <dami.etoile@gmail.com>
7 files changed, +251 -36
MREADME.md13++++-
Mcrates/devsite-cli/src/main.rs33+++++++++++--
Mcrates/devsite-server/src/api.rs64+++++++++++++++++++++++++-
Mcrates/devsite-server/src/db.rs77+++++++++++++++++++++++-------
Mdocs/profile-template.md24+++++++++-
Mweb/app.js55++++++++++++++++++---
Mweb/site.css21++++++++-
M README.md +10 -3
M crates/devsite-cli/src/main.rs +29 -4
@@ -50,6 +50,10 @@ enum Command {
5050 /// Share with a specific user, e.g. --share @bob. Repeatable.5151 #[arg(long, value_name = "@handle")]5252 share: Vec<String>,53+ /// File it under a folder on your profile. Leaving it off takes it out of54+ /// whatever folder it was in.55+ #[arg(long, value_name = "NAME")]56+ folder: Option<String>,5357 },5458 /// Stop exposing a local service and take it off your profile.5559 Unexpose {
@@ -81,6 +85,10 @@ enum LinkCommand {
8185 url: String,8286 #[arg(long)]8387 public: bool,88+ /// File it under a folder on your profile. Leaving it off takes it out of89+ /// whatever folder it was in.90+ #[arg(long, value_name = "NAME")]91+ folder: Option<String>,8492 },8593 /// Take a link off your profile. Re-running `add` with the same name edits8694 /// it in place; this is for when it should not be there at all.
@@ -170,8 +178,8 @@ async fn main() -> Result<()> {
170178 Command::Profile(ProfileCommand::Create { handle }) => {171179 create_profile(&paths, &cli.server, &handle).await172180 }173- Command::Link(LinkCommand::Add { name, url, public }) => {174- add_link(&paths, &cli.server, &name, &url, public).await181+ Command::Link(LinkCommand::Add { name, url, public, folder }) => {182+ add_link(&paths, &cli.server, &name, &url, public, folder).await175183 }176184 Command::Link(LinkCommand::Remove { name }) => {177185 remove_link(&paths, &cli.server, &name).await
@@ -183,7 +191,8 @@ async fn main() -> Result<()> {
183191 public,184192 private,185193 share,186- } => expose(&paths, &cli.server, origin, &name, public, private, share).await,194+ folder,195+ } => expose(&paths, &cli.server, origin, &name, public, private, share, folder).await,187196 Command::Theme(command) => theme(&paths, &cli.server, command).await,188197 Command::Status => status(&paths),189198 Command::Daemon(DaemonCommand::Run) => run_daemon(&paths, &cli.server).await,
@@ -253,7 +262,14 @@ async fn create_profile(paths: &Paths, server: &str, handle: &str) -> Result<()>
253262 Ok(())254263 }255264 256-async fn add_link(paths: &Paths, server: &str, name: &str, url: &str, public: bool) -> Result<()> {265+async fn add_link(266+ paths: &Paths,267+ server: &str,268+ name: &str,269+ url: &str,270+ public: bool,271+ folder: Option<String>,272+) -> Result<()> {257273 if !public {258274 bail!("links are external URLs and are always public; pass --public to confirm");259275 }
@@ -267,10 +283,14 @@ async fn add_link(paths: &Paths, server: &str, name: &str, url: &str, public: bo
267283 "kind": "link",268284 "visibility": "public",269285 "url": url,286+ "folder": folder,270287 }),271288 )272289 .await?;273290 println!("added link {name} → {url}");291+ if let Some(folder) = &folder {292+ println!(" in {folder}");293+ }274294 Ok(())275295 }276296
@@ -335,6 +355,7 @@ async fn expose(
335355 public: bool,336356 private: bool,337357 share: Vec<String>,358+ folder: Option<String>,338359 ) -> Result<()> {339360 // Refuse to proxy to the public internet: dev.site exposes local services, and an340361 // arbitrary upstream would make the daemon a traffic launderer.
@@ -360,6 +381,7 @@ async fn expose(
360381 "visibility": visibility_str(visibility),361382 // Deliberately absent: the local origin never leaves this machine.362383 "share_with": share.iter().map(|h| h.trim_start_matches('@')).collect::<Vec<_>>(),384+ "folder": folder,363385 }),364386 )365387 .await?;
@@ -380,6 +402,9 @@ async fn expose(
380402 paths.save_config(&config)?;381403 382404 println!("exposed {name} → {origin} ({})", visibility_str(visibility));405+ if let Some(folder) = &folder {406+ println!(" in {folder}");407+ }383408 if !share.is_empty() {384409 println!(" shared with {}", share.join(", "));385410 }
M crates/devsite-server/src/api.rs +63 -1
@@ -37,6 +37,7 @@ pub fn now_secs() -> u64 {
3737 3838 // -- errors -------------------------------------------------------------------3939 40+#[derive(Debug)]4041 pub struct ApiError(StatusCode, String);4142 4243 impl ApiError {
@@ -136,6 +137,11 @@ pub struct CreateResourceRequest {
136137 /// Handles to share with. Only meaningful when `visibility` is `shared`.137138 #[serde(default)]138139 pub share_with: Vec<String>,140+ /// The folder to file it under. Absent means loose on the profile — and141+ /// absent on a re-add means "take it out of the folder it was in", because a142+ /// command names the whole state of the thing it names.143+ #[serde(default)]144+ pub folder: Option<String>,139145 }140146 141147 #[derive(Serialize)]
@@ -155,6 +161,7 @@ pub struct ProfileEntry {
155161 pub kind: &'static str,156162 pub visibility: &'static str,157163 pub url: Option<String>,164+ pub folder: Option<String>,158165 /// Present only on "shared with me" entries.159166 #[serde(skip_serializing_if = "Option::is_none")]160167 pub owner_handle: Option<String>,
@@ -346,6 +353,8 @@ async fn create_resource(
346353 _ => {}347354 }348355 356+ let folder = validate_folder(body.folder.as_deref())?;357+349358 let mut db = state.db.lock().unwrap();350359 351360 // Resolve every share target *before* creating anything. Creating first and failing
@@ -363,7 +372,15 @@ async fn create_resource(
363372 }364373 365374 let resource = db366- .create_resource(owner, &body.name, kind, visibility, body.url.as_deref(), now_secs())375+ .create_resource(376+ owner,377+ &body.name,378+ kind,379+ visibility,380+ body.url.as_deref(),381+ folder.as_deref(),382+ now_secs(),383+ )367384 .map_err(ApiError::internal)?;368385 369386 // The share list this request names is the whole share list afterwards.
@@ -376,6 +393,31 @@ async fn create_resource(
376393 }))377394 }378395 396+/// Longest folder name. It is a label on a fold, not a description.397+const MAX_FOLDER: usize = 40;398+399+/// Check a folder name, treating blank as none.400+///401+/// A folder has no existence of its own — it is this string, repeated across the402+/// resources that share it — so "" and absent have to mean the same thing, or a403+/// profile would grow a nameless fold that nothing could remove.404+fn validate_folder(folder: Option<&str>) -> ApiResult<Option<String>> {405+ let Some(folder) = folder.map(str::trim).filter(|f| !f.is_empty()) else {406+ return Ok(None);407+ };408+ if folder.chars().count() > MAX_FOLDER {409+ return Err(ApiError::bad_request(format!(410+ "a folder name may be at most {MAX_FOLDER} characters"411+ )));412+ }413+ // Control characters would survive HTML-escaping and come out as invisible414+ // damage to the summary line.415+ if folder.chars().any(char::is_control) {416+ return Err(ApiError::bad_request("a folder name may not contain control characters"));417+ }418+ Ok(Some(folder.to_string()))419+}420+379421 /// Remove a resource. Only its owner can, and only their own.380422 ///381423 /// A resource that is not yours is 404 rather than 403, as everywhere else here:
@@ -412,6 +454,7 @@ async fn list_resources(
412454 "kind": r.kind.as_str(),413455 "visibility": r.visibility.as_str(),414456 "url": r.url,457+ "folder": r.folder,415458 })).collect::<Vec<_>>()416459 })))417460 }
@@ -565,6 +608,7 @@ fn to_entry(resource: &crate::db::Resource, owner_handle: Option<String>) -> Pro
565608 kind: resource.kind.as_str(),566609 visibility: resource.visibility.as_str(),567610 url: resource.url.clone(),611+ folder: resource.folder.clone(),568612 owner_handle,569613 }570614 }
@@ -660,6 +704,24 @@ mod tests {
660704 }661705 }662706 707+ #[test]708+ fn blank_folder_names_are_the_same_as_none() {709+ // Otherwise a profile grows a fold with no label that nothing can remove,710+ // because the only way to remove a folder is to stop naming it.711+ for blank in [Some(""), Some(" "), Some("\t"), None] {712+ assert_eq!(validate_folder(blank).unwrap(), None, "{blank:?}");713+ }714+ assert_eq!(validate_folder(Some(" Games ")).unwrap().as_deref(), Some("Games"));715+ }716+717+ #[test]718+ fn folder_names_are_bounded_and_printable() {719+ assert!(validate_folder(Some(&"x".repeat(MAX_FOLDER))).is_ok());720+ assert!(validate_folder(Some(&"x".repeat(MAX_FOLDER + 1))).is_err());721+ assert!(validate_folder(Some("Games\u{0}")).is_err());722+ assert!(validate_folder(Some("two\nlines")).is_err());723+ }724+663725 #[test]664726 fn reads_the_session_cookie_among_others() {665727 let mut headers = HeaderMap::new();
M crates/devsite-server/src/db.rs +59 -18
@@ -83,6 +83,11 @@ const MIGRATIONS: &[&str] = &[
8383 "ALTER TABLE daemons DROP COLUMN relay_url;8484 ALTER TABLE daemons DROP COLUMN serving;8585 ALTER TABLE daemons DROP COLUMN last_seen;",86+ // Folders. Deliberately a name on the resource rather than a table of its87+ // own: a folder then exists exactly as long as something is in it. Nothing88+ // to create, nothing to delete, no way to be left holding an empty one, and89+ // renaming is retagging.90+ "ALTER TABLE resources ADD COLUMN folder TEXT;",8691 ];8792 8893 fn migrate(conn: &Connection) -> Result<()> {
@@ -113,6 +118,9 @@ pub struct Resource {
113118 pub kind: ResourceKind,114119 pub visibility: Visibility,115120 pub url: Option<String>,121+ /// The folder it sits in, if any. A folder is just this name repeated across122+ /// the resources that share it.123+ pub folder: Option<String>,116124 }117125 118126 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -302,6 +310,7 @@ impl Db {
302310 kind: ResourceKind,303311 visibility: Visibility,304312 url: Option<&str>,313+ folder: Option<&str>,305314 now: u64,306315 ) -> Result<ResourceId> {307316 let existing: Option<String> = self
@@ -316,16 +325,16 @@ impl Db {
316325 if let Some(id) = existing {317326 let id = ResourceId::from_str(&id)?;318327 self.conn.execute(319- "UPDATE resources SET visibility = ?1, url = ?2 WHERE id = ?3",320- params![visibility.as_str(), url, id.to_string()],328+ "UPDATE resources SET visibility = ?1, url = ?2, folder = ?3 WHERE id = ?4",329+ params![visibility.as_str(), url, folder, id.to_string()],321330 )?;322331 return Ok(id);323332 }324333 325334 let id = ResourceId::generate();326335 self.conn.execute(327- "INSERT INTO resources (id, owner_id, name, kind, visibility, url, created_at)328- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",336+ "INSERT INTO resources (id, owner_id, name, kind, visibility, url, folder, created_at)337+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",329338 params![330339 id.to_string(),331340 owner.to_string(),
@@ -333,6 +342,7 @@ impl Db {
333342 kind.as_str(),334343 visibility.as_str(),335344 url,345+ folder,336346 now as i64337347 ],338348 )?;
@@ -342,7 +352,7 @@ impl Db {
342352 pub fn resource(&self, id: ResourceId) -> Result<Option<Resource>> {343353 self.conn344354 .query_row(345- "SELECT id, owner_id, name, kind, visibility, url FROM resources WHERE id = ?1",355+ "SELECT id, owner_id, name, kind, visibility, url, folder FROM resources WHERE id = ?1",346356 params![id.to_string()],347357 row_to_resource,348358 )
@@ -352,7 +362,7 @@ impl Db {
352362 353363 pub fn resources_owned_by(&self, owner: AccountId) -> Result<Vec<Resource>> {354364 let mut stmt = self.conn.prepare(355- "SELECT id, owner_id, name, kind, visibility, url FROM resources365+ "SELECT id, owner_id, name, kind, visibility, url, folder FROM resources356366 WHERE owner_id = ?1 ORDER BY created_at",357367 )?;358368 let rows = stmt
@@ -364,7 +374,7 @@ impl Db {
364374 /// Resources belonging to other people that were explicitly shared with `viewer`.365375 pub fn resources_shared_with(&self, viewer: AccountId) -> Result<Vec<Resource>> {366376 let mut stmt = self.conn.prepare(367- "SELECT r.id, r.owner_id, r.name, r.kind, r.visibility, r.url377+ "SELECT r.id, r.owner_id, r.name, r.kind, r.visibility, r.url, r.folder368378 FROM resources r369379 JOIN shares s ON s.resource_id = r.id370380 WHERE s.viewer_id = ?1 AND r.owner_id != ?1
@@ -494,6 +504,7 @@ fn row_to_resource(row: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Resource>
494504 let kind: String = row.get(3)?;495505 let visibility: String = row.get(4)?;496506 let url: Option<String> = row.get(5)?;507+ let folder: Option<String> = row.get(6)?;497508 498509 Ok((|| {499510 Ok(Resource {
@@ -503,6 +514,7 @@ fn row_to_resource(row: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Resource>
503514 kind: ResourceKind::parse(&kind).context("unknown resource kind")?,504515 visibility: Visibility::parse(&visibility).context("unknown visibility")?,505516 url,517+ folder,506518 })507519 })())508520 }
@@ -538,7 +550,7 @@ mod tests {
538550 db.set_handle(carol.id, "carol").unwrap();539551 540552 let agent = db541- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, 0)553+ .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)542554 .unwrap();543555 544556 db.set_shares(agent, &[bob.id]).unwrap();
@@ -557,11 +569,40 @@ mod tests {
557569 assert!(db.resources_shared_with(carol.id).unwrap().is_empty());558570 }559571 572+ #[test]573+ fn a_folder_is_set_replaced_and_cleared_by_naming_it_or_not() {574+ // A folder has no independent existence — it is this string on however575+ // many resources carry it — so re-adding without one takes the resource576+ // out of it, the same way re-sharing without a handle revokes.577+ let (db, alice, _) = seeded();578+579+ let id = db580+ .create_resource(alice.id, "klot.ski", ResourceKind::Link, Visibility::Public,581+ Some("https://klot.ski"), Some("Games"), 0)582+ .unwrap();583+ assert_eq!(db.resource(id).unwrap().unwrap().folder.as_deref(), Some("Games"));584+585+ let same = db586+ .create_resource(alice.id, "klot.ski", ResourceKind::Link, Visibility::Public,587+ Some("https://klot.ski"), Some("Toys"), 0)588+ .unwrap();589+ assert_eq!(same, id, "re-adding must not make a second entry");590+ assert_eq!(db.resource(id).unwrap().unwrap().folder.as_deref(), Some("Toys"));591+592+ db.create_resource(alice.id, "klot.ski", ResourceKind::Link, Visibility::Public,593+ Some("https://klot.ski"), None, 0)594+ .unwrap();595+ assert!(596+ db.resource(id).unwrap().unwrap().folder.is_none(),597+ "naming no folder should take it out of the one it was in"598+ );599+ }600+560601 #[test]561602 fn deleting_a_resource_takes_its_shares_with_it() {562603 let (mut db, alice, bob) = seeded();563604 let agent = db564- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, 0)605+ .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)565606 .unwrap();566607 db.set_shares(agent, &[bob.id]).unwrap();567608
@@ -580,7 +621,7 @@ mod tests {
580621 fn you_can_only_delete_your_own_resources() {581622 let (mut db, alice, bob) = seeded();582623 let hermes = db583- .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Shared, None, 0)624+ .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Shared, None, None, 0)584625 .unwrap();585626 // Shared with Bob, so he can see it — which must not extend to removing it.586627 db.set_shares(hermes, &[bob.id]).unwrap();
@@ -593,10 +634,10 @@ mod tests {
593634 fn shares_are_visible_to_the_named_viewer_only() {594635 let (mut db, alice, bob) = seeded();595636 let agent = db596- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, 0)637+ .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)597638 .unwrap();598639 let hermes = db599- .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Private, None, 0)640+ .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Private, None, None, 0)600641 .unwrap();601642 db.set_shares(agent, &[bob.id]).unwrap();602643
@@ -613,7 +654,7 @@ mod tests {
613654 fn owners_do_not_see_their_own_resources_as_shared_with_them() {614655 let (mut db, alice, bob) = seeded();615656 let agent = db616- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, 0)657+ .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)617658 .unwrap();618659 db.set_shares(agent, &[bob.id, alice.id]).unwrap();619660 assert!(
@@ -701,10 +742,10 @@ mod tests {
701742 // the same thing.702743 let (db, alice, _) = seeded();703744 let first = db704- .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Private, None, 0)745+ .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Private, None, None, 0)705746 .unwrap();706747 let second = db707- .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Shared, None, 10)748+ .create_resource(alice.id, "Hermes", ResourceKind::Service, Visibility::Shared, None, None, 10)708749 .unwrap();709750 710751 assert_eq!(first, second, "re-exposing must reuse the resource id");
@@ -717,10 +758,10 @@ mod tests {
717758 fn different_people_may_use_the_same_service_name() {718759 let (db, alice, bob) = seeded();719760 let a = db720- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Private, None, 0)761+ .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Private, None, None, 0)721762 .unwrap();722763 let b = db723- .create_resource(bob.id, "Agent", ResourceKind::Service, Visibility::Private, None, 0)764+ .create_resource(bob.id, "Agent", ResourceKind::Service, Visibility::Private, None, None, 0)724765 .unwrap();725766 assert_ne!(a, b, "names are scoped to their owner");726767 }
@@ -732,7 +773,7 @@ mod tests {
732773 // it. A share pointing at a deleted resource would have been left behind.733774 let (mut db, alice, bob) = seeded();734775 let agent = db735- .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, 0)776+ .create_resource(alice.id, "Agent", ResourceKind::Service, Visibility::Shared, None, None, 0)736777 .unwrap();737778 db.set_shares(agent, &[bob.id]).unwrap();738779
M docs/profile-template.md +23 -1
@@ -59,7 +59,7 @@ changes here and changes to the whitelist belong in the same commit.
5959 <p>3 sites</p>6060 </hgroup>6161 62- <!-- one list, in the order things were published -->62+ <!-- loose sites first, in the order they were published -->6363 <ul class="entries">6464 <!-- reached at its own address, so it is an anchor and takes you away -->6565 <li class="entry" data-kind="link" data-visibility="public">
@@ -75,6 +75,12 @@ changes here and changes to the whitelist belong in the same commit.
7575 </li>7676 </ul>7777 78+ <!-- then a fold per folder, in the order those first appear -->79+ <details class="folder" open>80+ <summary>Games <small>3</small></summary>81+ <ul class="entries">…</ul>82+ </details>83+7884 <!-- other people's sites, on your own profile only -->7985 <section class="group" data-visibility="shared-with-me">8086 <h2>Shared with me</h2>
@@ -101,6 +107,22 @@ offered.
101107 There is deliberately no reachability state to style: the page does not know102108 whether a daemon is running, and says so by saying nothing.103109 110+## Folders111+112+A folder is a name on a site, not a thing of its own — `devsite link add --folder113+Games` files one, and leaving `--folder` off takes it out again. There is nothing114+to create and nothing to delete, so a profile can never be left holding an empty115+one, and renaming a folder is retagging what is in it.116+117+That falls out of the rendering too. The set of folders is derived from the118+entries the viewer is allowed to see, so a folder holding only private sites does119+not appear to a stranger as an empty fold — it is never built at all.120+121+Folds are `<details open>`. A profile's job is to show what is on it, so a folder122+groups without hiding, and collapsing is the reader's choice rather than the123+owner's. Pico draws the marker and handles the open state; `.folder > summary`124+is the label.125+104126 `--pico-ins-color` and `--pico-del-color` are still in the whitelist, and are105127 what the viewer's success and failure messages are drawn with.106128
M web/app.js +47 -8
@@ -224,6 +224,43 @@ function linkHost(item) {
224224 return same ? '↗' : `${host} ↗`;225225 }226226 227+function entryList(items, options = {}) {228+ const list = document.createElement('ul');229+ list.className = 'entries';230+ for (const item of items) {231+ list.append(siteRow(item, { ...options, onClick: () => openService(item) }));232+ }233+ return list;234+}235+236+/// The folders on a profile, in the order they first appear.237+///238+/// Derived from the entries rather than stored anywhere: a folder is a name239+/// repeated across the sites that share it, so this is the only place the set of240+/// them exists. A folder that a viewer may see nothing inside does not appear at241+/// all, because it never gets built.242+function foldersOf(entries) {243+ const folders = new Map();244+ for (const item of entries) {245+ if (!item.folder) continue;246+ if (!folders.has(item.folder)) folders.set(item.folder, []);247+ folders.get(item.folder).push(item);248+ }249+ return folders;250+}251+252+/// One fold. `<details open>` — a profile's job is to show what is on it, so a253+/// folder groups without hiding, and collapsing is the reader's choice.254+function folder(name, items) {255+ const details = document.createElement('details');256+ details.className = 'folder';257+ details.open = true;258+ details.innerHTML =259+ `<summary>${esc(name)} <small>${items.length}</small></summary>`;260+ details.append(entryList(items));261+ return details;262+}263+227264 /// A titled list. Used only for "shared with me", which really is a different228265 /// thing — other people's sites, on your page — rather than a subdivision of229266 /// yours.
@@ -253,16 +290,18 @@ function renderProfile(profile) {
253290 + `<p>${total === 1 ? '1 site' : `${total} sites`}</p>`;254291 article.append(heading);255292 256- // One list, in the order things were published. No sections by kind or by257- // visibility: a profile is a list of sites, and sorting it into "links" and258- // "services" would be publishing an implementation detail as a heading.293+ // Loose sites first, in the order they were published, then a fold per folder294+ // in the order those first appear. No sections by kind or by visibility: a295+ // profile is a list of sites, and sorting it into "links" and "services" would296+ // be publishing an implementation detail as a heading. Folders are different —297+ // they are the owner's own grouping, and they say so in the owner's words.259298 if (total) {260- const list = document.createElement('ul');261- list.className = 'entries';262- for (const item of profile.entries) {263- list.append(siteRow(item, { onClick: () => openService(item) }));299+ const loose = profile.entries.filter((item) => !item.folder);300+ if (loose.length) article.append(entryList(loose));301+302+ for (const [name, items] of foldersOf(profile.entries)) {303+ article.append(folder(name, items));264304 }265- article.append(list);266305 } else {267306 const empty = document.createElement('p');268307 empty.innerHTML = '<small>Nothing published yet.</small>';
M web/site.css +20 -1