[{"data":1,"prerenderedAt":3259},["ShallowReactive",2],{"blog-posts":3},[4,216,305,345,1302,1404,1440,2510,2717,2780,2837,2867,2950,3200],{"id":5,"title":6,"body":7,"createdAt":207,"description":208,"extension":209,"meta":210,"navigation":211,"path":212,"seo":213,"stem":214,"__hash__":215},"posts\u002Fpost\u002Fmigrating-beta-bot-to-indexeddb.md","Why I migrated Beta-bot from localStorage to IndexedDB",{"type":8,"value":9,"toc":197},"minimark",[10,21,30,38,43,48,67,70,73,77,80,83,100,103,106,110,113,116,139,142,145,149,152,169,175,179,182,185,189,194],[11,12,13,20],"p",{},[14,15,19],"a",{"href":16,"rel":17},"https:\u002F\u002Fwww.beta-bot.net\u002F",[18],"nofollow","Beta-bot"," is a local-first AI beta reader for fiction writers. It stores books, chapters, summaries, reviews, wiki pages, and other project data on the user's device instead of sending the manuscript to an application server.",[11,22,23,24,29],{},"The browser version has always used SQLite through ",[14,25,28],{"href":26,"rel":27},"https:\u002F\u002Fsql.js.org\u002F",[18],"sql.js",", which compiles SQLite to WebAssembly. SQLite runs in memory, and the app exports its database when it needs to persist changes.",[11,31,32,33,37],{},"Originally, I saved that exported database in ",[34,35,36],"code",{},"localStorage",". That was simple and worked well while projects were mostly text. It stopped being a good fit when I wanted the browser version to support the same image features as the Electron desktop app.",[39,40,42],"h2",{"id":41},"why-localstorage-became-a-problem","Why localStorage became a problem",[11,44,45,47],{},[34,46,36],{}," is useful for small preferences, but it has several limitations for application data:",[49,50,51,55,58,61,64],"ul",{},[52,53,54],"li",{},"It only stores strings.",[52,56,57],{},"Browsers usually give it a relatively small quota.",[52,59,60],{},"Binary data has to be converted to text, usually base64, which makes it larger.",[52,62,63],{},"Its API is synchronous, so large reads and writes can block the interface.",[52,65,66],{},"Saving an exported SQLite database means rewriting the entire string whenever the database changes.",[11,68,69],{},"A manuscript database can fit within those limits for a while. Images cannot. A few chapter illustrations—especially after base64 encoding adds roughly one-third to their size—can use the available space very quickly.",[11,71,72],{},"This created an awkward platform difference. Electron could store image files on disk, but the browser could not safely persist them. A browser user could restore a backup containing images and view them, but full image management was not practical.",[39,74,76],{"id":75},"why-indexeddb-was-a-better-fit","Why IndexedDB was a better fit",[11,78,79],{},"IndexedDB is an asynchronous browser storage API designed for larger, structured data. More importantly for Beta-bot, it can store binary values directly.",[11,81,82],{},"The migration uses IndexedDB in two ways:",[84,85,86,93],"ol",{},[52,87,88,89,92],{},"The exported SQLite database is stored as a ",[34,90,91],{},"Uint8Array"," instead of being encoded as a string.",[52,94,95,96,99],{},"Image content is stored as native ",[34,97,98],{},"Blob"," records, keyed by image ID.",[11,101,102],{},"SQLite still owns the relational data: books, chapters, wiki pages, image metadata, ordering, and relationships. IndexedDB is the persistence layer around it. This let me keep the existing schema and database queries rather than rewriting the application around IndexedDB object stores.",[11,104,105],{},"The image metadata stays in SQLite while the larger binary content lives separately. That is similar to the Electron build, where SQLite stores metadata and the image itself lives in the app-data filesystem.",[39,107,109],{"id":108},"migrating-without-risking-manuscripts","Migrating without risking manuscripts",[11,111,112],{},"The hardest part was not writing to IndexedDB. It was making sure an upgrade could not silently lose someone's work.",[11,114,115],{},"On startup, the browser now:",[84,117,118,121,127,130,133,136],{},[52,119,120],{},"Looks for an IndexedDB database snapshot.",[52,122,123,124,126],{},"Falls back to the old ",[34,125,36],{}," value when no IndexedDB snapshot exists.",[52,128,129],{},"Opens the legacy SQLite data before attempting to migrate it.",[52,131,132],{},"Writes the exported bytes to IndexedDB.",[52,134,135],{},"Reads the new copy back and opens it with SQLite to verify it.",[52,137,138],{},"Retains the legacy copy as a recovery path instead of immediately deleting it.",[11,140,141],{},"Database writes are also serialized so that an older, slower save cannot finish after a newer one and overwrite it. Errors are surfaced instead of replacing an unreadable database with an empty one.",[11,143,144],{},"Those details matter more than the happy-path write call. Local-first software makes the browser responsible for data that would normally be protected by a server-side database and backups.",[39,146,148],{"id":147},"what-the-migration-unlocked","What the migration unlocked",[11,150,151],{},"With IndexedDB in place, the browser can now support the same core image workflow as Electron:",[49,153,154,157,160,163,166],{},[52,155,156],{},"Add chapter illustrations",[52,158,159],{},"Use images as book, part, or chapter covers",[52,161,162],{},"View, download, and delete images",[52,164,165],{},"Associate images with wiki pages",[52,167,168],{},"Include image data in user-initiated encrypted Google Drive backups",[11,170,171,172,174],{},"The change also gives larger text-only projects more room to grow and moves database persistence off the synchronous ",[34,173,36],{}," API.",[39,176,178],{"id":177},"what-did-not-change","What did not change",[11,180,181],{},"IndexedDB did not introduce a backend. Beta-bot is still one local-first codebase for the browser, Electron, and Android. AI requests go directly to OpenAI using the API key configured by the user, and Google Drive is only contacted when the user chooses to back up or restore an encrypted snapshot.",[11,183,184],{},"It also did not replace SQLite. Keeping sql.js meant preserving the same relational model and most of the existing application logic. The migration changed where browser data is persisted, not how the rest of the app thinks about that data.",[39,186,188],{"id":187},"the-main-lesson","The main lesson",[11,190,191,193],{},[34,192,36],{}," was not a bad initial choice. It helped the browser version get working quickly, and it was adequate for early text-focused projects. The mistake would have been forcing it to keep doing a job it was not designed for.",[11,195,196],{},"IndexedDB became the right choice when the data changed: larger projects, binary images, cross-platform backups, and a need for closer feature parity between the web and desktop builds. By putting it behind the existing SQLite layer, I could make that change without turning it into a rewrite.",{"title":198,"searchDepth":199,"depth":199,"links":200},"",2,[201,202,203,204,205,206],{"id":41,"depth":199,"text":42},{"id":75,"depth":199,"text":76},{"id":108,"depth":199,"text":109},{"id":147,"depth":199,"text":148},{"id":177,"depth":199,"text":178},{"id":187,"depth":199,"text":188},"2026-07-15","Why Beta-bot moved its local-first SQLite persistence from localStorage to IndexedDB, and how the migration protects manuscripts.","md",{},true,"\u002Fpost\u002Fmigrating-beta-bot-to-indexeddb",{"title":6,"description":208},"post\u002Fmigrating-beta-bot-to-indexeddb","lwODtE7k4nwG8bNrH04VuMedC68ZQM3DJud8DZkuVmo",{"id":217,"title":218,"body":219,"createdAt":298,"description":299,"extension":209,"meta":300,"navigation":211,"path":301,"seo":302,"stem":303,"__hash__":304},"posts\u002Fpost\u002Fnew-feature-custom-filters.md","New feature September 2025 - Downloads and custom filters for each forum",{"type":8,"value":220,"toc":296},[221,224,227,230,241,248,251,257,260,266,269,275,278,284,287,293],[11,222,223],{},"Although these features are still in progress, I wanted to share a status update about what I've been working on recently because I am pleased with the progress so far.",[11,225,226],{},"I've been picturing this platform as a place that sparks creativity. Forums will have files available to download in a niche subject matter area, such as ebooks or video game mods - and right next to that, in the same forum, will be a wiki in which super-beginners can learn the technical details on how to get started making such creations themselves. Ideally, in the future, Multiforum will inspire people not just to be consumers of creative works, but to provide active financial support to creators and learn how to be creators themselves.",[11,228,229],{},"But allowing a bunch of files to be shared on this platform has created the need for additional features to be added for navigation, organization and security. For this blog post, the feature I want to focus on is the newly added ability to add forum-specific filters for downloads.",[11,231,232,233,236,237,240],{},"In this example forum, ",[34,234,235],{},"sims4_builds",", you can see how there are several lots that can be downloaded there. In the Sims 4, the most common lot type is ",[34,238,239],{},"Residential",". You can see how this forum has specific filters to allow users to find the kind of lots they are looking for:",[11,242,243],{},[244,245],"img",{"alt":246,"src":247},"download_list_with_filters_in_side_bar.png","\u002Fposts\u002Fnew-feature-custom-filters\u002Fdownload_list_with_filters_in_side_bar.png",[11,249,250],{},"If you apply a filter such as 'Residential' in the left side bar, the results are filtered in-place, and the commercial lots disappear:",[11,252,253],{},[244,254],{"alt":255,"src":256},"one_active_filter.png","\u002Fposts\u002Fnew-feature-custom-filters\u002Fone_active_filter.png",[11,258,259],{},"If you apply two filters, the results are filtered further. In this case we are looking at only residential lots of size 20 by 20:",[11,261,262],{},[244,263],{"alt":264,"src":265},"two_active_filters.png","\u002Fposts\u002Fnew-feature-custom-filters\u002Ftwo_active_filters.png",[11,267,268],{},"These filters correspond to labels that can be seen on the download detail page. In this case, we can see the lot size and lot type in the sidebar on the right:",[11,270,271],{},[244,272],{"alt":273,"src":274},"labels_on_detail_page.png","\u002Fposts\u002Fnew-feature-custom-filters\u002Flabels_on_detail_page.png",[11,276,277],{},"You might be thinking, those filters are oddly specific to a more general platform like Multiforum. That's because they are configured at the individual forum scope, and you can edit the available filters in the settings. At the moment, the form is kind of dense; I'm thinking of how to make it more streamlined, but here it is:",[11,279,280],{},[244,281],{"alt":282,"src":283},"filter_group_form1.png","\u002Fposts\u002Fnew-feature-custom-filters\u002Ffilter_group_form1.png",[11,285,286],{},"Another look at the editable filters:",[11,288,289],{},[244,290],{"alt":291,"src":292},"filter_group_form2.png","\u002Fposts\u002Fnew-feature-custom-filters\u002Ffilter_group_form2.png",[11,294,295],{},"Currently, the labels have to be applied to individual downloads manually, by filling out a form. I think that is not ideal because it can be laborious or tiresome to manually label things, especially if you don't know off the top of your head what type of lot you have, or other details like that. That is why I am currently working on an auto-labeler - something that is enabled by an admin and configured at the forum scope, which will automatically apply forum specific labels to allow the results to be filtered properly. More on that next.",{"title":198,"searchDepth":199,"depth":199,"links":297},[],"2025-09-13","A progress update on Multiforum downloads, forum-specific filters, labels, and planned automatic labeling.",{},"\u002Fpost\u002Fnew-feature-custom-filters",{"title":218,"description":299},"post\u002Fnew-feature-custom-filters","3kprjte_pUpvDSjXbEFC2OD90LB_PSHmRwcXfXfPGDA",{"id":306,"title":307,"body":308,"createdAt":338,"description":339,"extension":209,"meta":340,"navigation":211,"path":341,"seo":342,"stem":343,"__hash__":344},"posts\u002Fpost\u002Fnew-feature-clustering.md","New feature August 2025 - Map marker clustering",{"type":8,"value":309,"toc":336},[310,318,321,327,330],[11,311,312,313,317],{},"I have installed the marker clusterer tool (",[14,314,315],{"href":315,"rel":316},"https:\u002F\u002Fdevelopers.google.com\u002Fmaps\u002Fdocumentation\u002Fjavascript\u002Fmarker-clustering",[18],") to make the event search map look much cleaner. Here's the before and after.",[11,319,320],{},"Before:",[11,322,323],{},[244,324],{"alt":325,"src":326},"marker clustering before - way too many markers","\u002Fposts\u002Fnew-feature-clustering\u002Fmarker-clustering-before.png",[11,328,329],{},"After:",[11,331,332],{},[244,333],{"alt":334,"src":335},"marker clustering after - much fewer markers","\u002Fposts\u002Fnew-feature-clustering\u002Fmarker-clustering-after.png",{"title":198,"searchDepth":199,"depth":199,"links":337},[],"2025-08-22","A before-and-after look at how marker clustering made Multiforum's event search map cleaner and easier to scan.",{},"\u002Fpost\u002Fnew-feature-clustering",{"title":307,"description":339},"post\u002Fnew-feature-clustering","EIn9L8NMRl3lJjR1pcKkBIHbWAb6uiK-E8uQ5oVVZg8",{"id":346,"title":347,"body":348,"createdAt":1295,"description":1296,"extension":209,"meta":1297,"navigation":211,"path":1298,"seo":1299,"stem":1300,"__hash__":1301},"posts\u002Fpost\u002Fnotifications.md","Real-Time Notifications with Neo4j GraphQL",{"type":8,"value":349,"toc":1285},[350,353,357,360,371,375,378,382,387,511,515,791,795,798,1229,1233,1236,1239,1242,1248,1278,1281],[11,351,352],{},"I recently built a notification system using Neo4j's GraphQL library and Change Data Capture. It turned out way cleaner than expected, so sharing what I learned.",[39,354,356],{"id":355},"the-problem","The Problem",[11,358,359],{},"Users want instant notifications when someone comments on their posts. My first approach was handling notifications directly in the API handler. There were some downsides to this approach:",[49,361,362,365,368],{},[52,363,364],{},"Slow API responses waiting for notification processing",[52,366,367],{},"Brittle - email failures broke comment creation",[52,369,370],{},"Messy coupling of business and notification logic",[39,372,374],{"id":373},"the-solution","The Solution",[11,376,377],{},"Neo4j's GraphQL subscriptions with Change Data Capture. Instead of blocking the API, let the database emit events and handle notifications separately.",[39,379,381],{"id":380},"how-it-works","How It Works",[383,384,386],"h3",{"id":385},"schema-setup","Schema Setup",[388,389,393],"pre",{"className":390,"code":391,"language":392,"meta":198,"style":198},"language-typescript shiki shiki-themes github-light github-dark","extend schema @subscription\n\ntype Notification {\n  id: ID! @id\n  createdAt: DateTime! @timestamp(operations: [CREATE])\n  read: Boolean\n  text: String\n}\n\ntype User {\n  Notifications: [Notification!]! @relationship(type: \"HAS_NOTIFICATION\", direction: OUT)\n}\n\ntype DiscussionChannel {\n  SubscribedToNotifications: [User!]!\n    @relationship(type: \"SUBSCRIBED_TO_NOTIFICATIONS\", direction: IN)\n}\n","typescript",[34,394,395,404,409,423,429,435,441,447,453,458,468,474,479,484,494,500,506],{"__ignoreMap":198},[396,397,400],"span",{"class":398,"line":399},"line",1,[396,401,403],{"class":402},"sVt8B","extend schema @subscription\n",[396,405,406],{"class":398,"line":199},[396,407,408],{"emptyLinePlaceholder":211},"\n",[396,410,412,416,420],{"class":398,"line":411},3,[396,413,415],{"class":414},"szBVR","type",[396,417,419],{"class":418},"sScJk"," Notification",[396,421,422],{"class":402}," {\n",[396,424,426],{"class":398,"line":425},4,[396,427,428],{"class":402},"  id: ID! @id\n",[396,430,432],{"class":398,"line":431},5,[396,433,434],{"class":402},"  createdAt: DateTime! @timestamp(operations: [CREATE])\n",[396,436,438],{"class":398,"line":437},6,[396,439,440],{"class":402},"  read: Boolean\n",[396,442,444],{"class":398,"line":443},7,[396,445,446],{"class":402},"  text: String\n",[396,448,450],{"class":398,"line":449},8,[396,451,452],{"class":402},"}\n",[396,454,456],{"class":398,"line":455},9,[396,457,408],{"emptyLinePlaceholder":211},[396,459,461,463,466],{"class":398,"line":460},10,[396,462,415],{"class":414},[396,464,465],{"class":418}," User",[396,467,422],{"class":402},[396,469,471],{"class":398,"line":470},11,[396,472,473],{"class":402},"  Notifications: [Notification!]! @relationship(type: \"HAS_NOTIFICATION\", direction: OUT)\n",[396,475,477],{"class":398,"line":476},12,[396,478,452],{"class":402},[396,480,482],{"class":398,"line":481},13,[396,483,408],{"emptyLinePlaceholder":211},[396,485,487,489,492],{"class":398,"line":486},14,[396,488,415],{"class":414},[396,490,491],{"class":418}," DiscussionChannel",[396,493,422],{"class":402},[396,495,497],{"class":398,"line":496},15,[396,498,499],{"class":402},"  SubscribedToNotifications: [User!]!\n",[396,501,503],{"class":398,"line":502},16,[396,504,505],{"class":402},"    @relationship(type: \"SUBSCRIBED_TO_NOTIFICATIONS\", direction: IN)\n",[396,507,509],{"class":398,"line":508},17,[396,510,452],{"class":402},[383,512,514],{"id":513},"event-processing","Event Processing",[388,516,518],{"className":390,"code":517,"language":392,"meta":198,"style":198},"private async processCommentNotification(commentId: string) {\n  const fullComment = await CommentModel.find({\n    where: { id: commentId },\n    selectionSet: `{\n      id text\n      DiscussionChannel { SubscribedToNotifications { username } Discussion { title } }\n      Event { SubscribedToNotifications { username } title }\n      ParentComment { SubscribedToNotifications { username } }\n    }`\n  });\n\n  \u002F\u002F Generate appropriate email content\n  let emailContent;\n  if (fullComment.DiscussionChannel) {\n    emailContent = createCommentNotificationEmail(\n      fullComment.text, fullComment.DiscussionChannel.Discussion.title,\n      commenterUsername, channelName, discussionId, commentId\n    );\n    await this.processDiscussionCommentNotification(fullComment, emailContent);\n  } else if (fullComment.Event) {\n    emailContent = createEventCommentNotificationEmail(\n      fullComment.text, fullComment.Event.title,\n      commenterUsername, channelName, eventId, commentId\n    );\n    await this.processEventCommentNotification(fullComment, emailContent);\n  } else if (fullComment.ParentComment) {\n    emailContent = createCommentReplyNotificationEmail(\n      fullComment.text, contentTitle, commenterUsername, contentUrl\n    );\n    await this.processCommentReplyNotification(fullComment, emailContent);\n  }\n}\n",[34,519,520,531,555,560,569,574,579,584,589,594,599,603,609,617,625,639,644,649,655,673,688,700,706,712,717,731,743,755,761,766,780,786],{"__ignoreMap":198},[396,521,522,525,528],{"class":398,"line":399},[396,523,524],{"class":402},"private async ",[396,526,527],{"class":418},"processCommentNotification",[396,529,530],{"class":402},"(commentId: string) {\n",[396,532,533,536,540,543,546,549,552],{"class":398,"line":199},[396,534,535],{"class":414},"  const",[396,537,539],{"class":538},"sj4cs"," fullComment",[396,541,542],{"class":414}," =",[396,544,545],{"class":414}," await",[396,547,548],{"class":402}," CommentModel.",[396,550,551],{"class":418},"find",[396,553,554],{"class":402},"({\n",[396,556,557],{"class":398,"line":411},[396,558,559],{"class":402},"    where: { id: commentId },\n",[396,561,562,565],{"class":398,"line":425},[396,563,564],{"class":402},"    selectionSet: ",[396,566,568],{"class":567},"sZZnC","`{\n",[396,570,571],{"class":398,"line":431},[396,572,573],{"class":567},"      id text\n",[396,575,576],{"class":398,"line":437},[396,577,578],{"class":567},"      DiscussionChannel { SubscribedToNotifications { username } Discussion { title } }\n",[396,580,581],{"class":398,"line":443},[396,582,583],{"class":567},"      Event { SubscribedToNotifications { username } title }\n",[396,585,586],{"class":398,"line":449},[396,587,588],{"class":567},"      ParentComment { SubscribedToNotifications { username } }\n",[396,590,591],{"class":398,"line":455},[396,592,593],{"class":567},"    }`\n",[396,595,596],{"class":398,"line":460},[396,597,598],{"class":402},"  });\n",[396,600,601],{"class":398,"line":470},[396,602,408],{"emptyLinePlaceholder":211},[396,604,605],{"class":398,"line":476},[396,606,608],{"class":607},"sJ8bj","  \u002F\u002F Generate appropriate email content\n",[396,610,611,614],{"class":398,"line":481},[396,612,613],{"class":414},"  let",[396,615,616],{"class":402}," emailContent;\n",[396,618,619,622],{"class":398,"line":486},[396,620,621],{"class":414},"  if",[396,623,624],{"class":402}," (fullComment.DiscussionChannel) {\n",[396,626,627,630,633,636],{"class":398,"line":496},[396,628,629],{"class":402},"    emailContent ",[396,631,632],{"class":414},"=",[396,634,635],{"class":418}," createCommentNotificationEmail",[396,637,638],{"class":402},"(\n",[396,640,641],{"class":398,"line":502},[396,642,643],{"class":402},"      fullComment.text, fullComment.DiscussionChannel.Discussion.title,\n",[396,645,646],{"class":398,"line":508},[396,647,648],{"class":402},"      commenterUsername, channelName, discussionId, commentId\n",[396,650,652],{"class":398,"line":651},18,[396,653,654],{"class":402},"    );\n",[396,656,658,661,664,667,670],{"class":398,"line":657},19,[396,659,660],{"class":414},"    await",[396,662,663],{"class":538}," this",[396,665,666],{"class":402},".",[396,668,669],{"class":418},"processDiscussionCommentNotification",[396,671,672],{"class":402},"(fullComment, emailContent);\n",[396,674,676,679,682,685],{"class":398,"line":675},20,[396,677,678],{"class":402},"  } ",[396,680,681],{"class":414},"else",[396,683,684],{"class":414}," if",[396,686,687],{"class":402}," (fullComment.Event) {\n",[396,689,691,693,695,698],{"class":398,"line":690},21,[396,692,629],{"class":402},[396,694,632],{"class":414},[396,696,697],{"class":418}," createEventCommentNotificationEmail",[396,699,638],{"class":402},[396,701,703],{"class":398,"line":702},22,[396,704,705],{"class":402},"      fullComment.text, fullComment.Event.title,\n",[396,707,709],{"class":398,"line":708},23,[396,710,711],{"class":402},"      commenterUsername, channelName, eventId, commentId\n",[396,713,715],{"class":398,"line":714},24,[396,716,654],{"class":402},[396,718,720,722,724,726,729],{"class":398,"line":719},25,[396,721,660],{"class":414},[396,723,663],{"class":538},[396,725,666],{"class":402},[396,727,728],{"class":418},"processEventCommentNotification",[396,730,672],{"class":402},[396,732,734,736,738,740],{"class":398,"line":733},26,[396,735,678],{"class":402},[396,737,681],{"class":414},[396,739,684],{"class":414},[396,741,742],{"class":402}," (fullComment.ParentComment) {\n",[396,744,746,748,750,753],{"class":398,"line":745},27,[396,747,629],{"class":402},[396,749,632],{"class":414},[396,751,752],{"class":418}," createCommentReplyNotificationEmail",[396,754,638],{"class":402},[396,756,758],{"class":398,"line":757},28,[396,759,760],{"class":402},"      fullComment.text, contentTitle, commenterUsername, contentUrl\n",[396,762,764],{"class":398,"line":763},29,[396,765,654],{"class":402},[396,767,769,771,773,775,778],{"class":398,"line":768},30,[396,770,660],{"class":414},[396,772,663],{"class":538},[396,774,666],{"class":402},[396,776,777],{"class":418},"processCommentReplyNotification",[396,779,672],{"class":402},[396,781,783],{"class":398,"line":782},31,[396,784,785],{"class":402},"  }\n",[396,787,789],{"class":398,"line":788},32,[396,790,452],{"class":402},[383,792,794],{"id":793},"bulk-notifications-email","Bulk Notifications + Email",[11,796,797],{},"The system handles both in-app notifications and emails in a single batch operation:",[388,799,801],{"className":390,"code":800,"language":392,"meta":198,"style":198},"private async createBatchNotifications(entityType, entityId, notificationText, commenterUsername, emailContent?) {\n  \u002F\u002F 1. Get subscribers with email addresses\n  const entity = await EntityModel.find({\n    where: { id: entityId },\n    selectionSet: `{\n      SubscribedToNotifications {\n        username\n        Email { address }\n      }\n    }`\n  });\n\n  const usersToNotify = entity.SubscribedToNotifications\n    .filter(user => user.username !== commenterUsername);\n\n  \u002F\u002F 2. Send batch emails first\n  if (emailContent) {\n    await this.sendBatchEmails(usersToNotify, emailContent);\n  }\n\n  \u002F\u002F 3. Create in-app notifications\n  const cypherQuery = `\n    MATCH (entity:${entityType} {id: $entityId})\n    MATCH (entity)\u003C-[:SUBSCRIBED_TO_NOTIFICATIONS]-(user:User)\n    WHERE user.username \u003C> $commenterUsername\n    CREATE (notification:Notification {\n      id: randomUUID(),\n      createdAt: datetime(),\n      read: false,\n      text: $notificationText\n    })\n    CREATE (user)-[:HAS_NOTIFICATION]->(notification)\n  `;\n\n  await session.run(cypherQuery, { entityId, commenterUsername, notificationText });\n}\n\nprivate async sendBatchEmails(usersToNotify, emailContent) {\n  try {\n    const emailsToSend = usersToNotify\n      .filter(user => user.email)\n      .map(user => ({\n        to: user.email,\n        from: process.env.SENDGRID_FROM_EMAIL,\n        subject: emailContent.subject,\n        text: emailContent.plainText,\n        html: emailContent.html\n      }));\n\n    await sgMail.send(emailsToSend);\n  } catch (error) {\n    console.error('Email sending failed:', error);\n    \u002F\u002F Continue with in-app notifications even if emails fail\n  }\n}\n",[34,802,803,819,824,840,845,851,856,861,866,871,875,879,883,893,914,918,923,935,949,953,957,962,972,983,988,993,998,1003,1008,1013,1018,1023,1028,1037,1042,1048,1053,1058,1068,1074,1088,1107,1124,1130,1142,1148,1154,1160,1166,1171,1185,1191,1213,1219,1224],{"__ignoreMap":198},[396,804,805,807,810,813,816],{"class":398,"line":399},[396,806,524],{"class":402},[396,808,809],{"class":418},"createBatchNotifications",[396,811,812],{"class":402},"(entityType, entityId, notificationText, commenterUsername, emailContent",[396,814,815],{"class":414},"?",[396,817,818],{"class":402},") {\n",[396,820,821],{"class":398,"line":199},[396,822,823],{"class":607},"  \u002F\u002F 1. Get subscribers with email addresses\n",[396,825,826,829,831,833,836,838],{"class":398,"line":411},[396,827,828],{"class":402},"  const entity ",[396,830,632],{"class":414},[396,832,545],{"class":414},[396,834,835],{"class":402}," EntityModel.",[396,837,551],{"class":418},[396,839,554],{"class":402},[396,841,842],{"class":398,"line":425},[396,843,844],{"class":402},"    where: { id: entityId },\n",[396,846,847,849],{"class":398,"line":431},[396,848,564],{"class":402},[396,850,568],{"class":567},[396,852,853],{"class":398,"line":437},[396,854,855],{"class":567},"      SubscribedToNotifications {\n",[396,857,858],{"class":398,"line":443},[396,859,860],{"class":567},"        username\n",[396,862,863],{"class":398,"line":449},[396,864,865],{"class":567},"        Email { address }\n",[396,867,868],{"class":398,"line":455},[396,869,870],{"class":567},"      }\n",[396,872,873],{"class":398,"line":460},[396,874,593],{"class":567},[396,876,877],{"class":398,"line":470},[396,878,598],{"class":402},[396,880,881],{"class":398,"line":476},[396,882,408],{"emptyLinePlaceholder":211},[396,884,885,888,890],{"class":398,"line":481},[396,886,887],{"class":402},"  const usersToNotify ",[396,889,632],{"class":414},[396,891,892],{"class":402}," entity.SubscribedToNotifications\n",[396,894,895,898,902,905,908,911],{"class":398,"line":486},[396,896,897],{"class":402},"    .filter(",[396,899,901],{"class":900},"s4XuR","user",[396,903,904],{"class":414}," =>",[396,906,907],{"class":402}," user.username ",[396,909,910],{"class":414},"!==",[396,912,913],{"class":402}," commenterUsername);\n",[396,915,916],{"class":398,"line":496},[396,917,408],{"emptyLinePlaceholder":211},[396,919,920],{"class":398,"line":502},[396,921,922],{"class":607},"  \u002F\u002F 2. Send batch emails first\n",[396,924,925,927,930,933],{"class":398,"line":508},[396,926,621],{"class":418},[396,928,929],{"class":402}," (",[396,931,932],{"class":900},"emailContent",[396,934,818],{"class":402},[396,936,937,939,941,943,946],{"class":398,"line":651},[396,938,660],{"class":414},[396,940,663],{"class":538},[396,942,666],{"class":402},[396,944,945],{"class":418},"sendBatchEmails",[396,947,948],{"class":402},"(usersToNotify, emailContent);\n",[396,950,951],{"class":398,"line":657},[396,952,785],{"class":402},[396,954,955],{"class":398,"line":675},[396,956,408],{"emptyLinePlaceholder":211},[396,958,959],{"class":398,"line":690},[396,960,961],{"class":607},"  \u002F\u002F 3. Create in-app notifications\n",[396,963,964,967,969],{"class":398,"line":702},[396,965,966],{"class":402},"  const cypherQuery ",[396,968,632],{"class":414},[396,970,971],{"class":567}," `\n",[396,973,974,977,980],{"class":398,"line":708},[396,975,976],{"class":567},"    MATCH (entity:${",[396,978,979],{"class":402},"entityType",[396,981,982],{"class":567},"} {id: $entityId})\n",[396,984,985],{"class":398,"line":714},[396,986,987],{"class":567},"    MATCH (entity)\u003C-[:SUBSCRIBED_TO_NOTIFICATIONS]-(user:User)\n",[396,989,990],{"class":398,"line":719},[396,991,992],{"class":567},"    WHERE user.username \u003C> $commenterUsername\n",[396,994,995],{"class":398,"line":733},[396,996,997],{"class":567},"    CREATE (notification:Notification {\n",[396,999,1000],{"class":398,"line":745},[396,1001,1002],{"class":567},"      id: randomUUID(),\n",[396,1004,1005],{"class":398,"line":757},[396,1006,1007],{"class":567},"      createdAt: datetime(),\n",[396,1009,1010],{"class":398,"line":763},[396,1011,1012],{"class":567},"      read: false,\n",[396,1014,1015],{"class":398,"line":768},[396,1016,1017],{"class":567},"      text: $notificationText\n",[396,1019,1020],{"class":398,"line":782},[396,1021,1022],{"class":567},"    })\n",[396,1024,1025],{"class":398,"line":788},[396,1026,1027],{"class":567},"    CREATE (user)-[:HAS_NOTIFICATION]->(notification)\n",[396,1029,1031,1034],{"class":398,"line":1030},33,[396,1032,1033],{"class":567},"  `",[396,1035,1036],{"class":402},";\n",[396,1038,1040],{"class":398,"line":1039},34,[396,1041,408],{"emptyLinePlaceholder":211},[396,1043,1045],{"class":398,"line":1044},35,[396,1046,1047],{"class":402},"  await session.run(cypherQuery, { entityId, commenterUsername, notificationText });\n",[396,1049,1051],{"class":398,"line":1050},36,[396,1052,452],{"class":402},[396,1054,1056],{"class":398,"line":1055},37,[396,1057,408],{"emptyLinePlaceholder":211},[396,1059,1061,1063,1065],{"class":398,"line":1060},38,[396,1062,524],{"class":402},[396,1064,945],{"class":418},[396,1066,1067],{"class":402},"(usersToNotify, emailContent) {\n",[396,1069,1071],{"class":398,"line":1070},39,[396,1072,1073],{"class":402},"  try {\n",[396,1075,1077,1080,1083,1085],{"class":398,"line":1076},40,[396,1078,1079],{"class":414},"    const",[396,1081,1082],{"class":538}," emailsToSend",[396,1084,542],{"class":414},[396,1086,1087],{"class":402}," usersToNotify\n",[396,1089,1091,1094,1097,1100,1102,1104],{"class":398,"line":1090},41,[396,1092,1093],{"class":402},"      .",[396,1095,1096],{"class":418},"filter",[396,1098,1099],{"class":402},"(",[396,1101,901],{"class":900},[396,1103,904],{"class":414},[396,1105,1106],{"class":402}," user.email)\n",[396,1108,1110,1112,1115,1117,1119,1121],{"class":398,"line":1109},42,[396,1111,1093],{"class":402},[396,1113,1114],{"class":418},"map",[396,1116,1099],{"class":402},[396,1118,901],{"class":900},[396,1120,904],{"class":414},[396,1122,1123],{"class":402}," ({\n",[396,1125,1127],{"class":398,"line":1126},43,[396,1128,1129],{"class":402},"        to: user.email,\n",[396,1131,1133,1136,1139],{"class":398,"line":1132},44,[396,1134,1135],{"class":402},"        from: process.env.",[396,1137,1138],{"class":538},"SENDGRID_FROM_EMAIL",[396,1140,1141],{"class":402},",\n",[396,1143,1145],{"class":398,"line":1144},45,[396,1146,1147],{"class":402},"        subject: emailContent.subject,\n",[396,1149,1151],{"class":398,"line":1150},46,[396,1152,1153],{"class":402},"        text: emailContent.plainText,\n",[396,1155,1157],{"class":398,"line":1156},47,[396,1158,1159],{"class":402},"        html: emailContent.html\n",[396,1161,1163],{"class":398,"line":1162},48,[396,1164,1165],{"class":402},"      }));\n",[396,1167,1169],{"class":398,"line":1168},49,[396,1170,408],{"emptyLinePlaceholder":211},[396,1172,1174,1176,1179,1182],{"class":398,"line":1173},50,[396,1175,660],{"class":414},[396,1177,1178],{"class":402}," sgMail.",[396,1180,1181],{"class":418},"send",[396,1183,1184],{"class":402},"(emailsToSend);\n",[396,1186,1188],{"class":398,"line":1187},51,[396,1189,1190],{"class":402},"  } catch (error) {\n",[396,1192,1194,1197,1200,1202,1205,1208,1210],{"class":398,"line":1193},52,[396,1195,1196],{"class":402},"    console.",[396,1198,1199],{"class":418},"error",[396,1201,1099],{"class":402},[396,1203,1204],{"class":567},"'Email sending failed:'",[396,1206,1207],{"class":402},", ",[396,1209,1199],{"class":900},[396,1211,1212],{"class":402},");\n",[396,1214,1216],{"class":398,"line":1215},53,[396,1217,1218],{"class":607},"    \u002F\u002F Continue with in-app notifications even if emails fail\n",[396,1220,1222],{"class":398,"line":1221},54,[396,1223,785],{"class":402},[396,1225,1227],{"class":398,"line":1226},55,[396,1228,452],{"class":402},[39,1230,1232],{"id":1231},"why-this-works","Why This Works",[11,1234,1235],{},"Your APIs stay fast because notifications happen asynchronously in the background. Bulk operations make it efficient because creating 100 notifications takes about the same time as creating one.",[11,1237,1238],{},"CDC guarantees you won't lose events since they're captured at the database level. If email sending fails, in-app notifications still work because they're handled separately.",[11,1240,1241],{},"The architecture stays clean with notification logic isolated in its own service. TypeScript provides type safety, and each component can be tested independently.",[11,1243,1244],{},[1245,1246,1247],"strong",{},"The Complete Flow",[84,1249,1250,1260,1266,1272],{},[52,1251,1252,1255,1256,1259],{},[1245,1253,1254],{},"Comment created"," → Neo4j CDC automatically triggers a ",[34,1257,1258],{},"commentCreated"," event when someone adds a comment",[52,1261,1262,1265],{},[1245,1263,1264],{},"Event received"," → The notification service receives this event, fetches the comment details, and generates the appropriate email content",[52,1267,1268,1271],{},[1245,1269,1270],{},"Batch processing"," → The system processes everything in batches, using a single operation to send emails and create in-app notifications simultaneously",[52,1273,1274,1277],{},[1245,1275,1276],{},"Graceful failures"," → When email problems occur, they don't affect the in-app notifications because these are handled as separate operations",[11,1279,1280],{},"The key advantage is batching - one SendGrid API call can notify hundreds of users at once, and email failures stay isolated from your core notification system.",[1282,1283,1284],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":198,"searchDepth":199,"depth":199,"links":1286},[1287,1288,1289,1294],{"id":355,"depth":199,"text":356},{"id":373,"depth":199,"text":374},{"id":380,"depth":199,"text":381,"children":1290},[1291,1292,1293],{"id":385,"depth":411,"text":386},{"id":513,"depth":411,"text":514},{"id":793,"depth":411,"text":794},{"id":1231,"depth":199,"text":1232},"2025-07-28T08:04:39.315Z","How I built asynchronous, batched notifications with Neo4j GraphQL subscriptions, Change Data Capture, and SendGrid.",{},"\u002Fpost\u002Fnotifications",{"title":347,"description":1296},"post\u002Fnotifications","CbtMf_-E0ob7SQh7jsYEpkeH4hJxNVfyY23EBFmE0ik",{"id":1303,"title":1304,"body":1305,"createdAt":1397,"description":1398,"extension":209,"meta":1399,"navigation":211,"path":1400,"seo":1401,"stem":1402,"__hash__":1403},"posts\u002Fpost\u002Fnew-feature-wikis.md","New Multiforum Feature - Wikis",{"type":8,"value":1306,"toc":1390},[1307,1310,1314,1317,1321,1324,1330,1334,1337,1343,1347,1350,1356,1359,1365,1369,1372,1378,1381,1387],[11,1308,1309],{},"I'm excited to announce a new collaborative feature for Multiforum: community wikis. This addition allows forum owners to create shared knowledge bases where community members can contribute and maintain information together.",[39,1311,1313],{"id":1312},"getting-started-with-wikis","Getting Started with Wikis",[11,1315,1316],{},"Forum owners can enable the wiki feature by navigating to their forum settings and checking the \"Enable Wiki\" option. Once activated, a new Wiki tab will appear in the forum navigation, giving members easy access to the collaborative documentation space.",[39,1318,1320],{"id":1319},"single-page-wikis","Single Page Wikis",[11,1322,1323],{},"For simpler use cases, wikis can consist of a single page that serves as a centralized information hub:",[11,1325,1326],{},[244,1327],{"alt":1328,"src":1329},"one-page-wiki-example","\u002Fposts\u002Fnew-feature-wikis\u002Fone-page-wiki-example.png",[39,1331,1333],{"id":1332},"multi-page-wiki-structure","Multi-Page Wiki Structure",[11,1335,1336],{},"More complex wikis can be organized into multiple interconnected pages. The interface includes a helpful sidebar that lists all available pages for easy navigation:",[11,1338,1339],{},[244,1340],{"alt":1341,"src":1342},"multiple-page-wiki-example","\u002Fposts\u002Fnew-feature-wikis\u002Fwiki-multiple-pages.png",[39,1344,1346],{"id":1345},"editing-experience","Editing Experience",[11,1348,1349],{},"The wiki editing interface provides a clean, intuitive environment for content creation and modification:",[11,1351,1352],{},[244,1353],{"alt":1354,"src":1355},"edit wiki example","\u002Fposts\u002Fnew-feature-wikis\u002Fedit-wiki-example.png",[11,1357,1358],{},"For users who prefer more screen real estate, the editor includes a full-screen mode accessible via the expand button in the top-right corner:",[11,1360,1361],{},[244,1362],{"alt":1363,"src":1364},"wiki edit fullscreen","\u002Fposts\u002Fnew-feature-wikis\u002Fwiki-edit-fullscreen.png",[39,1366,1368],{"id":1367},"version-control-and-history","Version Control and History",[11,1370,1371],{},"One of the most powerful aspects of the wiki system is its built-in revision tracking. Users can view the complete edit history of any page by clicking \"see edits\":",[11,1373,1374],{},[244,1375],{"alt":1376,"src":1377},"wiki revision history","\u002Fposts\u002Fnew-feature-wikis\u002Fwiki-revision-history.png",[11,1379,1380],{},"Each revision can be examined in detail, with a diff view that clearly highlights what content was added, modified, or removed:",[11,1382,1383],{},[244,1384],{"alt":1385,"src":1386},"wiki revision diff view","\u002Fposts\u002Fnew-feature-wikis\u002Fwiki-diff-view.png",[11,1388,1389],{},"This tool can be used to help trace back to the source of an error if needed.",{"title":198,"searchDepth":199,"depth":199,"links":1391},[1392,1393,1394,1395,1396],{"id":1312,"depth":199,"text":1313},{"id":1319,"depth":199,"text":1320},{"id":1332,"depth":199,"text":1333},{"id":1345,"depth":199,"text":1346},{"id":1367,"depth":199,"text":1368},"2025-07-28","A tour of Multiforum community wikis, including multi-page navigation, editing, revision history, and diffs.",{},"\u002Fpost\u002Fnew-feature-wikis",{"title":1304,"description":1398},"post\u002Fnew-feature-wikis","Z81FINEPNBgcomedZwZxUujo6XCfkJA1pBgx3keqXEc",{"id":1405,"title":1406,"body":1407,"createdAt":1433,"description":1434,"extension":209,"meta":1435,"navigation":211,"path":1436,"seo":1437,"stem":1438,"__hash__":1439},"posts\u002Fpost\u002Fimmigration-video.md","Don't deport my high school classmates",{"type":8,"value":1408,"toc":1431},[1409,1419,1422,1425,1428],[11,1410,1411],{},[14,1412,1415],{"href":1413,"rel":1414},"https:\u002F\u002Fwww.youtube.com\u002Fwatch?v=xotwTMLiASE",[18],[244,1416],{"alt":1417,"src":1418},"Video thumbnail with me holding a sign that says \"Don't deport my high school classmates\"","\u002Fposts\u002Fimmigration-video\u002Fimmigration-video.png",[11,1420,1421],{},"I normally avoid making political posts because I don't want to fight. But recently emotions have built up inside me to the extent that I need to express them. So I made a video to express my emotions about illegal immigration.",[11,1423,1424],{},"I graduated from high school in 2008. During senior year - specifically, in October 2007 - we, the students, got our hopes up that the Dream Act might pass, but it did not. The topic of illegal immigration has been close to my mind ever since then and I still feel that we need more enhanced protections for undocumented immigrants, not more deportations.",[11,1426,1427],{},"In case you'd rather just read text instead of watching the video, here's the full text:",[11,1429,1430],{},"DON'T DEPORT MY HIGH SCHOOL CLASSMATES\nTHE SMARTEST KID IN PHYSICS CLASS\nTHE FUNNIEST KID IN BAND\nTHE BEST SINGER IN THE SCHOOL CHOIR\nI SEE THOSE CLASSMATES AS BETTER PEOPLE THAN ME, A U.S. CITIZEN.\nTHEY DESERVE TO LIVE IN THE U.S. AS MUCH AS I DO\nI BEG YOU, DON'T DEPORT THEM!\nIF YOU DEPORT ALL UNDOCUMENTED IMMIGRANTS, YOU WOULD BE DEPORTING...\nSOME OF THE BEST PEOPLE THAT I HAVE EVER HAD THE PRIVILEGE OF KNOWING IN MY LIFE.\nEVERY DAY THAT I WAS IN SCHOOL, I PUT MY HAND OVER MY HEART AND SWORE ALLEGIANCE TO THE FLAG...\nAND TO THIS CONCEPT OF \"LIBERTY AND JUSTICE FOR ALL\"\nAND MY UNDOCUMENTED CLASSMATES SWORE IT TOO.\nIN A FAIR AND \"JUST\" WORLD, WE WOULD APPRECIATE PEOPLE FOR THE WORK THAT THEY DO.\nAND I DON'T KNOW IF YOU'VE NOTICED BUT...\nA LOT OF UNDOCUMENTED IMMIGRANTS HAVE WORKED EXTREMELY HARD.\nTHEY DESERVE TO BE PROTECTED, NOT DEPORTED.\nSOMETIMES THE \"RULE OF LAW\" AND TRUE JUSTICE ARE NOT THE SAME...\nBECAUSE THE LAWS THEMSELVES ARE UNJUST.\nTHIS IS ONE OF THOSE TIMES.\nTHANK YOU FOR WATCHING",{"title":198,"searchDepth":199,"depth":199,"links":1432},[],"2025-02-19T03:22:08.768Z","A personal video and essay about undocumented classmates, immigration, and the lasting need for stronger protections.",{},"\u002Fpost\u002Fimmigration-video",{"title":1406,"description":1434},"post\u002Fimmigration-video","IDjHhoaS2NSJJmzhuex9qmsGj2_1OqVVLPWTkC8eCmM",{"id":1441,"title":1442,"body":1443,"createdAt":2503,"description":2504,"extension":209,"meta":2505,"navigation":211,"path":2506,"seo":2507,"stem":2508,"__hash__":2509},"posts\u002Fpost\u002Fbasic-content-filter.md","I added a basic content filter and learned something nifty",{"type":8,"value":1444,"toc":2483},[1445,1449,1452,1455,1458,1462,1465,1476,1480,1489,1493,1496,1499,1503,1506,1509,1513,1516,1520,1523,1531,1534,1540,1551,1557,1561,1564,1673,1680,1686,1690,1693,1739,1743,1746,2255,2259,2262,2268,2272,2275,2278,2282,2285,2375,2378,2387,2393,2397,2400,2403,2406,2415,2418,2421,2427,2430,2435,2446,2452,2456,2459,2465,2474,2477,2480],[39,1446,1448],{"id":1447},"introduction","Introduction",[11,1450,1451],{},"Recently I thought to myself, \"Since my side project is linked on my resume and LinkedIn profile, it would probably be bad if somebody uploaded porn to it.\"",[11,1453,1454],{},"Content filtering is not a serious concern to me at this time because my app has no users other than myself. But with that said, it's still technically possible for a random stranger to log into topical.space, make an account and upload pictures of... whatever. So I felt a little bit nervous about tying my professional reputation to a website that allows user-generated content without a filter.",[11,1456,1457],{},"I decided to write this post because I found a solution that I think is pretty cool, since it filters content on the Google Cloud Platform side without actually requiring any code changes to my app.",[39,1459,1461],{"id":1460},"current-image-upload-process","Current Image Upload Process",[11,1463,1464],{},"For context, here's how image uploads currently work in topical.space:",[84,1466,1467,1470,1473],{},[52,1468,1469],{},"A user clicks Add Image or pastes an image into the text editor.",[52,1471,1472],{},"The client calls a backend resolver called createSignedStorageURL. The backend uses\nits Google Cloud credentials to call the Google Cloud Storage API to create a special URL.\nThis special URL, called the \"signed storage URL\", is the URL where the image\nwill be available after it is uploaded to GCS by the client. It comes with permissions\nfor the client to upload an image to that URL for a limited amount of time.",[52,1474,1475],{},"The client calls the GCS API to upload the image to the signed storage URL.",[39,1477,1479],{"id":1478},"implementing-the-content-filter","Implementing the Content Filter",[11,1481,1482,1483,1488],{},"So I started researching automated image scanners, with the intent of trying to make it difficult for any potentially explicit user-generated content to see the light of day. I figured that since I already hosted images on GCS (Google Cloud Storage), I may as well try another Google service, ",[14,1484,1487],{"href":1485,"rel":1486},"https:\u002F\u002Fcloud.google.com\u002Fvision\u002Fdocs\u002Fdetecting-safe-search",[18],"SafeSearch,"," for image scanning.",[383,1490,1492],{"id":1491},"initial-considerations","Initial Considerations",[11,1494,1495],{},"At first I thought that when the client calls createSignedStorageURL, that should trigger an event that causes my backend app to poll the signed storage URL on GCS to see if an image was uploaded there. If it existed, then the backend would order a scan on the image at that URL. If it didn't exist, the polling would eventually time out.",[11,1497,1498],{},"But then my question was \"How long should the backend poll GCS until it times out?\" I didn't like the idea of setting the time to an arbitrary limit like 30 seconds or a minute, because then an image could bypass the content filter if the user had a slow connection and it took a long time to upload the picture.",[383,1500,1502],{"id":1501},"the-gcs-event-based-solution","The GCS Event-Based Solution",[11,1504,1505],{},"So then I asked another question: \"How hard would it be to make GCS itself trigger the scan as soon as an image is uploaded?\"",[11,1507,1508],{},"And the answer is, it's easy! With a few commands, it's possible to make an event get emitted to Google's event bus system when a file is uploaded to GCS. And it is also easy to use a Google Cloud Function that listens for that event and executes when that event happens.",[39,1510,1512],{"id":1511},"setting-up-the-cloud-function","Setting Up the Cloud Function",[11,1514,1515],{},"Since I already had the gcloud CLI installed, I set up the content filter with gcloud commands, which is a better user experience than clicking around in the cloud console.",[383,1517,1519],{"id":1518},"creating-the-event-pipeline","Creating the Event Pipeline",[11,1521,1522],{},"First I created a pub\u002Fsub topic like this:",[388,1524,1529],{"className":1525,"code":1527,"language":1528},[1526],"language-text","gcloud pubsub topics create image-uploads-topic\n","text",[34,1530,1527],{"__ignoreMap":198},[11,1532,1533],{},"Then I linked the pub\u002Fsub topic to my GCS bucket:",[388,1535,1538],{"className":1536,"code":1537,"language":1528},[1526],"gcloud storage buckets notifications create gs:\u002F\u002Flistical-dev \\\n  --topic=image-uploads-topic \\\n  --event-types=OBJECT_FINALIZE\n",[34,1539,1537],{"__ignoreMap":198},[11,1541,1542,1543,1546,1547,1550],{},"Then I made a cloud function called ",[34,1544,1545],{},"scanImage"," get triggered by ",[34,1548,1549],{},"image-uploads-topic",":",[388,1552,1555],{"className":1553,"code":1554,"language":1528},[1526],"gcloud functions deploy scanImage \\\n  --runtime nodejs20 \\\n  --trigger-topic=image-uploads-topic \\\n  --allow-unauthenticated\n",[34,1556,1554],{"__ignoreMap":198},[383,1558,1560],{"id":1559},"implementing-the-cloud-function","Implementing the Cloud Function",[11,1562,1563],{},"Then I made the cloud function in a local directory. This cloud function is what scans the newly uploaded file and will delete the image if it doesn't pass the content filter. The Google SafeSearch scanner responds in this format:",[388,1565,1569],{"className":1566,"code":1567,"language":1568,"meta":198,"style":198},"language-json shiki shiki-themes github-light github-dark","{\n  \"responses\": [\n    {\n      \"safeSearchAnnotation\": {\n        \"adult\": \"UNLIKELY\",\n        \"spoof\": \"VERY_UNLIKELY\",\n        \"medical\": \"VERY_UNLIKELY\",\n        \"violence\": \"LIKELY\",\n        \"racy\": \"POSSIBLE\"\n      }\n    }\n  ]\n}\n","json",[34,1570,1571,1576,1584,1589,1597,1610,1622,1633,1645,1655,1659,1664,1669],{"__ignoreMap":198},[396,1572,1573],{"class":398,"line":399},[396,1574,1575],{"class":402},"{\n",[396,1577,1578,1581],{"class":398,"line":199},[396,1579,1580],{"class":538},"  \"responses\"",[396,1582,1583],{"class":402},": [\n",[396,1585,1586],{"class":398,"line":411},[396,1587,1588],{"class":402},"    {\n",[396,1590,1591,1594],{"class":398,"line":425},[396,1592,1593],{"class":538},"      \"safeSearchAnnotation\"",[396,1595,1596],{"class":402},": {\n",[396,1598,1599,1602,1605,1608],{"class":398,"line":431},[396,1600,1601],{"class":538},"        \"adult\"",[396,1603,1604],{"class":402},": ",[396,1606,1607],{"class":567},"\"UNLIKELY\"",[396,1609,1141],{"class":402},[396,1611,1612,1615,1617,1620],{"class":398,"line":437},[396,1613,1614],{"class":538},"        \"spoof\"",[396,1616,1604],{"class":402},[396,1618,1619],{"class":567},"\"VERY_UNLIKELY\"",[396,1621,1141],{"class":402},[396,1623,1624,1627,1629,1631],{"class":398,"line":443},[396,1625,1626],{"class":538},"        \"medical\"",[396,1628,1604],{"class":402},[396,1630,1619],{"class":567},[396,1632,1141],{"class":402},[396,1634,1635,1638,1640,1643],{"class":398,"line":449},[396,1636,1637],{"class":538},"        \"violence\"",[396,1639,1604],{"class":402},[396,1641,1642],{"class":567},"\"LIKELY\"",[396,1644,1141],{"class":402},[396,1646,1647,1650,1652],{"class":398,"line":455},[396,1648,1649],{"class":538},"        \"racy\"",[396,1651,1604],{"class":402},[396,1653,1654],{"class":567},"\"POSSIBLE\"\n",[396,1656,1657],{"class":398,"line":460},[396,1658,870],{"class":402},[396,1660,1661],{"class":398,"line":470},[396,1662,1663],{"class":402},"    }\n",[396,1665,1666],{"class":398,"line":476},[396,1667,1668],{"class":402},"  ]\n",[396,1670,1671],{"class":398,"line":481},[396,1672,452],{"class":402},[11,1674,1675,1676,1679],{},"Therefore, the cloud function checks the ",[34,1677,1678],{},"safeSearchAnnotation"," in the SafeSearch response to decide whether to delete the file or not. I set up the file structure of my cloud function like this:",[388,1681,1684],{"className":1682,"code":1683,"language":1528},[1526],"scanImage\n  - index.js\n  - package.json\n",[34,1685,1683],{"__ignoreMap":198},[383,1687,1689],{"id":1688},"package-configuration","Package Configuration",[11,1691,1692],{},"The package.json adds the Google Cloud Storage and Vision SDKs as dependencies:",[388,1694,1696],{"className":1566,"code":1695,"language":1568,"meta":198,"style":198},"{\n    \"dependencies\": {\n      \"@google-cloud\u002Fstorage\": \"^6.9.0\",\n      \"@google-cloud\u002Fvision\": \"^3.0.0\"\n    }\n  }\n  \n",[34,1697,1698,1702,1709,1721,1731,1735],{"__ignoreMap":198},[396,1699,1700],{"class":398,"line":399},[396,1701,1575],{"class":402},[396,1703,1704,1707],{"class":398,"line":199},[396,1705,1706],{"class":538},"    \"dependencies\"",[396,1708,1596],{"class":402},[396,1710,1711,1714,1716,1719],{"class":398,"line":411},[396,1712,1713],{"class":538},"      \"@google-cloud\u002Fstorage\"",[396,1715,1604],{"class":402},[396,1717,1718],{"class":567},"\"^6.9.0\"",[396,1720,1141],{"class":402},[396,1722,1723,1726,1728],{"class":398,"line":425},[396,1724,1725],{"class":538},"      \"@google-cloud\u002Fvision\"",[396,1727,1604],{"class":402},[396,1729,1730],{"class":567},"\"^3.0.0\"\n",[396,1732,1733],{"class":398,"line":431},[396,1734,1663],{"class":402},[396,1736,1737],{"class":398,"line":437},[396,1738,785],{"class":402},[383,1740,1742],{"id":1741},"cloud-function-code","Cloud Function Code",[11,1744,1745],{},"And the index.js of the cloud function is as follows (with a note to remind my future self what I'm looking at):",[388,1747,1751],{"className":1748,"code":1749,"language":1750,"meta":198,"style":198},"language-js shiki shiki-themes github-light github-dark","\u002F\u002F This file is used for setting up a cloud function via the gcloud CLI.\n\u002F\u002F I used it to set up the function to be triggered by a Pub\u002FSub message,\n\u002F\u002F specifically the message that is triggered by a new file being uploaded to\n\u002F\u002F a GCS bucket.\nconst { Storage } = require('@google-cloud\u002Fstorage');\nconst vision = require('@google-cloud\u002Fvision');\nconst storage = new Storage();\nconst client = new vision.ImageAnnotatorClient();\n\nexports.scanImage = async (event, context) => {\n  const data = JSON.parse(Buffer.from(event.data, 'base64').toString());\n  const bucketName = data.bucket;\n  const fileName = data.name;\n\n  try {\n    \u002F\u002F This is where we have the newly uploaded image scanned by Google SafeSearch.\n    const [result] = await client.safeSearchDetection(`gs:\u002F\u002F${bucketName}\u002F${fileName}`);\n    const safeSearch = result.safeSearchAnnotation;\n\n    if (\n        safeSearch.adult === 'VERY_LIKELY' || \n        safeSearch.adult === 'LIKELY' ||\n        safeSearch.racy === 'VERY_LIKELY' ||\n        safeSearch.medical === 'VERY_LIKELY' ||\n        safeSearch.violence === 'VERY_LIKELY' ||\n        safeSearch.violence === 'LIKELY'\n    ) {\n      \u002F\u002F And this is where we delete an image that doesn't pass the check.\n      await storage.bucket(bucketName).file(fileName).delete();\n\n      \u002F\u002F These logs show in the Google cloud console whenever the cloud function\n      \u002F\u002F is triggered.\n      \u002F\u002F In the future I will also update this area of the code so that it triggers a \n      \u002F\u002F notification, which will tell the user that their image was deleted because it didn't \n      \u002F\u002F pass the content filter.\n      console.log(`Deleted unsafe image: ${fileName}`);\n    } else {\n      console.log(`Image passed moderation: ${fileName}`);\n    }\n  } catch (error) {\n    console.error(`Error scanning image: ${fileName}`, error);\n  }\n};\n\n","js",[34,1752,1753,1758,1763,1768,1773,1799,1817,1835,1854,1858,1890,1928,1940,1952,1956,1963,1968,2010,2022,2026,2034,2051,2063,2074,2085,2096,2105,2110,2115,2140,2144,2149,2154,2159,2164,2169,2188,2197,2214,2218,2228,2246,2250],{"__ignoreMap":198},[396,1754,1755],{"class":398,"line":399},[396,1756,1757],{"class":607},"\u002F\u002F This file is used for setting up a cloud function via the gcloud CLI.\n",[396,1759,1760],{"class":398,"line":199},[396,1761,1762],{"class":607},"\u002F\u002F I used it to set up the function to be triggered by a Pub\u002FSub message,\n",[396,1764,1765],{"class":398,"line":411},[396,1766,1767],{"class":607},"\u002F\u002F specifically the message that is triggered by a new file being uploaded to\n",[396,1769,1770],{"class":398,"line":425},[396,1771,1772],{"class":607},"\u002F\u002F a GCS bucket.\n",[396,1774,1775,1778,1781,1784,1787,1789,1792,1794,1797],{"class":398,"line":431},[396,1776,1777],{"class":414},"const",[396,1779,1780],{"class":402}," { ",[396,1782,1783],{"class":538},"Storage",[396,1785,1786],{"class":402}," } ",[396,1788,632],{"class":414},[396,1790,1791],{"class":418}," require",[396,1793,1099],{"class":402},[396,1795,1796],{"class":567},"'@google-cloud\u002Fstorage'",[396,1798,1212],{"class":402},[396,1800,1801,1803,1806,1808,1810,1812,1815],{"class":398,"line":437},[396,1802,1777],{"class":414},[396,1804,1805],{"class":538}," vision",[396,1807,542],{"class":414},[396,1809,1791],{"class":418},[396,1811,1099],{"class":402},[396,1813,1814],{"class":567},"'@google-cloud\u002Fvision'",[396,1816,1212],{"class":402},[396,1818,1819,1821,1824,1826,1829,1832],{"class":398,"line":443},[396,1820,1777],{"class":414},[396,1822,1823],{"class":538}," storage",[396,1825,542],{"class":414},[396,1827,1828],{"class":414}," new",[396,1830,1831],{"class":418}," Storage",[396,1833,1834],{"class":402},"();\n",[396,1836,1837,1839,1842,1844,1846,1849,1852],{"class":398,"line":449},[396,1838,1777],{"class":414},[396,1840,1841],{"class":538}," client",[396,1843,542],{"class":414},[396,1845,1828],{"class":414},[396,1847,1848],{"class":402}," vision.",[396,1850,1851],{"class":418},"ImageAnnotatorClient",[396,1853,1834],{"class":402},[396,1855,1856],{"class":398,"line":455},[396,1857,408],{"emptyLinePlaceholder":211},[396,1859,1860,1863,1865,1867,1869,1872,1874,1877,1879,1882,1885,1888],{"class":398,"line":460},[396,1861,1862],{"class":538},"exports",[396,1864,666],{"class":402},[396,1866,1545],{"class":418},[396,1868,542],{"class":414},[396,1870,1871],{"class":414}," async",[396,1873,929],{"class":402},[396,1875,1876],{"class":900},"event",[396,1878,1207],{"class":402},[396,1880,1881],{"class":900},"context",[396,1883,1884],{"class":402},") ",[396,1886,1887],{"class":414},"=>",[396,1889,422],{"class":402},[396,1891,1892,1894,1897,1899,1902,1904,1907,1910,1913,1916,1919,1922,1925],{"class":398,"line":470},[396,1893,535],{"class":414},[396,1895,1896],{"class":538}," data",[396,1898,542],{"class":414},[396,1900,1901],{"class":538}," JSON",[396,1903,666],{"class":402},[396,1905,1906],{"class":418},"parse",[396,1908,1909],{"class":402},"(Buffer.",[396,1911,1912],{"class":418},"from",[396,1914,1915],{"class":402},"(event.data, ",[396,1917,1918],{"class":567},"'base64'",[396,1920,1921],{"class":402},").",[396,1923,1924],{"class":418},"toString",[396,1926,1927],{"class":402},"());\n",[396,1929,1930,1932,1935,1937],{"class":398,"line":476},[396,1931,535],{"class":414},[396,1933,1934],{"class":538}," bucketName",[396,1936,542],{"class":414},[396,1938,1939],{"class":402}," data.bucket;\n",[396,1941,1942,1944,1947,1949],{"class":398,"line":481},[396,1943,535],{"class":414},[396,1945,1946],{"class":538}," fileName",[396,1948,542],{"class":414},[396,1950,1951],{"class":402}," data.name;\n",[396,1953,1954],{"class":398,"line":486},[396,1955,408],{"emptyLinePlaceholder":211},[396,1957,1958,1961],{"class":398,"line":496},[396,1959,1960],{"class":414},"  try",[396,1962,422],{"class":402},[396,1964,1965],{"class":398,"line":502},[396,1966,1967],{"class":607},"    \u002F\u002F This is where we have the newly uploaded image scanned by Google SafeSearch.\n",[396,1969,1970,1972,1975,1978,1981,1983,1985,1988,1991,1993,1996,1999,2002,2005,2008],{"class":398,"line":508},[396,1971,1079],{"class":414},[396,1973,1974],{"class":402}," [",[396,1976,1977],{"class":538},"result",[396,1979,1980],{"class":402},"] ",[396,1982,632],{"class":414},[396,1984,545],{"class":414},[396,1986,1987],{"class":402}," client.",[396,1989,1990],{"class":418},"safeSearchDetection",[396,1992,1099],{"class":402},[396,1994,1995],{"class":567},"`gs:\u002F\u002F${",[396,1997,1998],{"class":402},"bucketName",[396,2000,2001],{"class":567},"}\u002F${",[396,2003,2004],{"class":402},"fileName",[396,2006,2007],{"class":567},"}`",[396,2009,1212],{"class":402},[396,2011,2012,2014,2017,2019],{"class":398,"line":651},[396,2013,1079],{"class":414},[396,2015,2016],{"class":538}," safeSearch",[396,2018,542],{"class":414},[396,2020,2021],{"class":402}," result.safeSearchAnnotation;\n",[396,2023,2024],{"class":398,"line":657},[396,2025,408],{"emptyLinePlaceholder":211},[396,2027,2028,2031],{"class":398,"line":675},[396,2029,2030],{"class":414},"    if",[396,2032,2033],{"class":402}," (\n",[396,2035,2036,2039,2042,2045,2048],{"class":398,"line":690},[396,2037,2038],{"class":402},"        safeSearch.adult ",[396,2040,2041],{"class":414},"===",[396,2043,2044],{"class":567}," 'VERY_LIKELY'",[396,2046,2047],{"class":414}," ||",[396,2049,2050],{"class":402}," \n",[396,2052,2053,2055,2057,2060],{"class":398,"line":702},[396,2054,2038],{"class":402},[396,2056,2041],{"class":414},[396,2058,2059],{"class":567}," 'LIKELY'",[396,2061,2062],{"class":414}," ||\n",[396,2064,2065,2068,2070,2072],{"class":398,"line":708},[396,2066,2067],{"class":402},"        safeSearch.racy ",[396,2069,2041],{"class":414},[396,2071,2044],{"class":567},[396,2073,2062],{"class":414},[396,2075,2076,2079,2081,2083],{"class":398,"line":714},[396,2077,2078],{"class":402},"        safeSearch.medical ",[396,2080,2041],{"class":414},[396,2082,2044],{"class":567},[396,2084,2062],{"class":414},[396,2086,2087,2090,2092,2094],{"class":398,"line":719},[396,2088,2089],{"class":402},"        safeSearch.violence ",[396,2091,2041],{"class":414},[396,2093,2044],{"class":567},[396,2095,2062],{"class":414},[396,2097,2098,2100,2102],{"class":398,"line":733},[396,2099,2089],{"class":402},[396,2101,2041],{"class":414},[396,2103,2104],{"class":567}," 'LIKELY'\n",[396,2106,2107],{"class":398,"line":745},[396,2108,2109],{"class":402},"    ) {\n",[396,2111,2112],{"class":398,"line":757},[396,2113,2114],{"class":607},"      \u002F\u002F And this is where we delete an image that doesn't pass the check.\n",[396,2116,2117,2120,2123,2126,2129,2132,2135,2138],{"class":398,"line":763},[396,2118,2119],{"class":414},"      await",[396,2121,2122],{"class":402}," storage.",[396,2124,2125],{"class":418},"bucket",[396,2127,2128],{"class":402},"(bucketName).",[396,2130,2131],{"class":418},"file",[396,2133,2134],{"class":402},"(fileName).",[396,2136,2137],{"class":418},"delete",[396,2139,1834],{"class":402},[396,2141,2142],{"class":398,"line":768},[396,2143,408],{"emptyLinePlaceholder":211},[396,2145,2146],{"class":398,"line":782},[396,2147,2148],{"class":607},"      \u002F\u002F These logs show in the Google cloud console whenever the cloud function\n",[396,2150,2151],{"class":398,"line":788},[396,2152,2153],{"class":607},"      \u002F\u002F is triggered.\n",[396,2155,2156],{"class":398,"line":1030},[396,2157,2158],{"class":607},"      \u002F\u002F In the future I will also update this area of the code so that it triggers a \n",[396,2160,2161],{"class":398,"line":1039},[396,2162,2163],{"class":607},"      \u002F\u002F notification, which will tell the user that their image was deleted because it didn't \n",[396,2165,2166],{"class":398,"line":1044},[396,2167,2168],{"class":607},"      \u002F\u002F pass the content filter.\n",[396,2170,2171,2174,2177,2179,2182,2184,2186],{"class":398,"line":1050},[396,2172,2173],{"class":402},"      console.",[396,2175,2176],{"class":418},"log",[396,2178,1099],{"class":402},[396,2180,2181],{"class":567},"`Deleted unsafe image: ${",[396,2183,2004],{"class":402},[396,2185,2007],{"class":567},[396,2187,1212],{"class":402},[396,2189,2190,2193,2195],{"class":398,"line":1055},[396,2191,2192],{"class":402},"    } ",[396,2194,681],{"class":414},[396,2196,422],{"class":402},[396,2198,2199,2201,2203,2205,2208,2210,2212],{"class":398,"line":1060},[396,2200,2173],{"class":402},[396,2202,2176],{"class":418},[396,2204,1099],{"class":402},[396,2206,2207],{"class":567},"`Image passed moderation: ${",[396,2209,2004],{"class":402},[396,2211,2007],{"class":567},[396,2213,1212],{"class":402},[396,2215,2216],{"class":398,"line":1070},[396,2217,1663],{"class":402},[396,2219,2220,2222,2225],{"class":398,"line":1076},[396,2221,678],{"class":402},[396,2223,2224],{"class":414},"catch",[396,2226,2227],{"class":402}," (error) {\n",[396,2229,2230,2232,2234,2236,2239,2241,2243],{"class":398,"line":1090},[396,2231,1196],{"class":402},[396,2233,1199],{"class":418},[396,2235,1099],{"class":402},[396,2237,2238],{"class":567},"`Error scanning image: ${",[396,2240,2004],{"class":402},[396,2242,2007],{"class":567},[396,2244,2245],{"class":402},", error);\n",[396,2247,2248],{"class":398,"line":1109},[396,2249,785],{"class":402},[396,2251,2252],{"class":398,"line":1126},[396,2253,2254],{"class":402},"};\n",[383,2256,2258],{"id":2257},"deploying-the-function","Deploying the Function",[11,2260,2261],{},"Then, with my terminal in the same directory as my new cloud function code, I deployed the cloud function with this command:",[388,2263,2266],{"className":2264,"code":2265,"language":1528},[1526],"gcloud functions deploy scanImage \\\n  --runtime nodejs20 \\\n  --trigger-topic=image-uploads-topic \\\n  --entry-point=scanImage \\\n  --allow-unauthenticated\n",[34,2267,2265],{"__ignoreMap":198},[39,2269,2271],{"id":2270},"testing-the-content-filter","Testing the Content Filter",[11,2273,2274],{},"I was glad to learn that this approach was possible, because from a technical or architectural perspective, it's nifty. With only four commands and a few lines of code, my app already has a basic level of protection from vandalism. The best thing about it is that I never had to change a single line of code in my app, because the content filter is handled entirely on the GCP side.",[11,2276,2277],{},"Then there was only one thing left to do: test it to make sure that it works.",[383,2279,2281],{"id":2280},"understanding-safesearch-labels","Understanding SafeSearch Labels",[11,2283,2284],{},"Now, if you're like me, when you see that SafeSearch returns a response like this, you have questions:",[388,2286,2289],{"className":2287,"code":1567,"language":2288,"meta":198,"style":198},"language-JSON shiki shiki-themes github-light github-dark","JSON",[34,2290,2291,2295,2301,2305,2311,2321,2331,2341,2351,2359,2363,2367,2371],{"__ignoreMap":198},[396,2292,2293],{"class":398,"line":399},[396,2294,1575],{"class":402},[396,2296,2297,2299],{"class":398,"line":199},[396,2298,1580],{"class":538},[396,2300,1583],{"class":402},[396,2302,2303],{"class":398,"line":411},[396,2304,1588],{"class":402},[396,2306,2307,2309],{"class":398,"line":425},[396,2308,1593],{"class":538},[396,2310,1596],{"class":402},[396,2312,2313,2315,2317,2319],{"class":398,"line":431},[396,2314,1601],{"class":538},[396,2316,1604],{"class":402},[396,2318,1607],{"class":567},[396,2320,1141],{"class":402},[396,2322,2323,2325,2327,2329],{"class":398,"line":437},[396,2324,1614],{"class":538},[396,2326,1604],{"class":402},[396,2328,1619],{"class":567},[396,2330,1141],{"class":402},[396,2332,2333,2335,2337,2339],{"class":398,"line":443},[396,2334,1626],{"class":538},[396,2336,1604],{"class":402},[396,2338,1619],{"class":567},[396,2340,1141],{"class":402},[396,2342,2343,2345,2347,2349],{"class":398,"line":449},[396,2344,1637],{"class":538},[396,2346,1604],{"class":402},[396,2348,1642],{"class":567},[396,2350,1141],{"class":402},[396,2352,2353,2355,2357],{"class":398,"line":455},[396,2354,1649],{"class":538},[396,2356,1604],{"class":402},[396,2358,1654],{"class":567},[396,2360,2361],{"class":398,"line":460},[396,2362,870],{"class":402},[396,2364,2365],{"class":398,"line":470},[396,2366,1663],{"class":402},[396,2368,2369],{"class":398,"line":476},[396,2370,1668],{"class":402},[396,2372,2373],{"class":398,"line":481},[396,2374,452],{"class":402},[11,2376,2377],{},"The top question on my mind was, \"Where does it draw the line? What's the difference between 'LIKELY' and 'VERY_LIKELY' racy?\"",[11,2379,2380,2381,2386],{},"I checked the reference documentation for SafeSearchAnnotation, which is located ",[14,2382,2385],{"href":2383,"rel":2384},"https:\u002F\u002Fcloud.google.com\u002Fvision\u002Fdocs\u002Freference\u002Frest\u002Fv1\u002FAnnotateImageResponse#safesearchannotation",[18],"here."," As you can see, the reference material is fairly sparse. It defines \"racy\" as the following:",[2388,2389,2390],"blockquote",{},[11,2391,2392],{},"Likelihood that the request image contains racy content. Racy content may include (but is not limited to) skimpy or sheer clothing, strategically covered nudity, lewd or provocative poses, or close-ups of sensitive body areas.",[383,2394,2396],{"id":2395},"almost-real-world-testing","(Almost) Real-World Testing",[11,2398,2399],{},"These reference materials did not contain what I was really looking for, which was a helpful chart with three columns - POSSIBLE, LIKELY and VERY_LIKELY - with rows for adult, spoof, medical, violence and racy content, with an example picture in each table cell.",[11,2401,2402],{},"I wonder why they did not include that???",[11,2404,2405],{},"Jokes aside, it became clear that the only way to really understand those labels is to test it with our own images. So in the following test, I wanted to both a) check if the new filter could successfully remove images and b) get an idea of where the dividing line is between different levels of raciness.",[11,2407,2408,2409,2414],{},"So I decided to test the filter with racy content. I felt uncomfortable about using pictures of real people for the purpose of testing my content filter, so I decided this would be a good time to use ",[14,2410,2413],{"href":2411,"rel":2412},"https:\u002F\u002Fx.com\u002Fi\u002Fgrok?focus=1",[18],"Grok"," to generate racy images of fake people as test data.",[11,2416,2417],{},"After requesting these AI images from Grok, I had two pictures in my test data. Test picture 1 is somewhat of a milder level of raciness, while test picture 2 is more explicit.",[11,2419,2420],{},"Test picture 1:",[11,2422,2423],{},[244,2424],{"alt":2425,"src":2426},"Woman posing while wearing lingerie","\u002Fposts\u002Fbasic-content-filter\u002Ftest-picture-1.jpg",[11,2428,2429],{},"Test picture 2:",[11,2431,2432],{},[396,2433,2434],{},"The second test picture was of a similar photo, in which a woman is also wearing lingerie, while her hand was implied to be doing a sexual act outside of the frame.",[11,2436,2437,2438,2441,2442,2445],{},"To test it, I ran my database locally, created a forum ",[34,2439,2440],{},"image_test"," and a test post called ",[34,2443,2444],{},"Image test."," In Multiforum, the text editor is similar to the text editor in GitHub, which lets you drag and drop images into the text editor to upload them, so that you can easily interleave images in text. So to do this, I dragged the test pictures 1 and 2 into the text editor, and you can see here that they were uploaded because the new image URLs are included there in the markdown:",[11,2447,2448],{},[244,2449],{"alt":2450,"src":2451},"Text editor with URLs in markdown, indicating successful upload","\u002Fposts\u002Fbasic-content-filter\u002Fdoing-the-image-test.png",[39,2453,2455],{"id":2454},"results-and-conclusion","Results and Conclusion",[11,2457,2458],{},"Then I saved the post, and voilà! Test picture 1 passed the filter, while test picture 2 failed the content check, and it got automatically deleted even before I clicked the save button:",[11,2460,2461],{},[244,2462],{"alt":2463,"src":2464},"Post with one image showing successfully and one filename of an image that was deleted","\u002Fposts\u002Fbasic-content-filter\u002Fcontent-filter-success.png",[11,2466,2467,2468,2470,2471,666],{},"On top of that, I can confirm that it worked by checking the logs in the Google Cloud Platform console, in which I can see that the ",[34,2469,1545],{}," cloud function logged ",[34,2472,2473],{},"Deleted unsafe image: 1736991660484-cluse-explicit-test.jpg",[11,2475,2476],{},"And that's it. Huge success. In the future, when an image is deleted because of this automated check, I'll send a notification to the user who uploaded it, saying \"This image was automatically removed by an AI content filter. AI makes mistakes, so if you think this decision was made in error, please open a support ticket\" or something along those lines.",[11,2478,2479],{},"For now, I'm okay with the line being drawn somewhere in between those two test pictures. At least now the line is being drawn somewhere.",[1282,2481,2482],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":198,"searchDepth":199,"depth":199,"links":2484},[2485,2486,2487,2491,2498,2502],{"id":1447,"depth":199,"text":1448},{"id":1460,"depth":199,"text":1461},{"id":1478,"depth":199,"text":1479,"children":2488},[2489,2490],{"id":1491,"depth":411,"text":1492},{"id":1501,"depth":411,"text":1502},{"id":1511,"depth":199,"text":1512,"children":2492},[2493,2494,2495,2496,2497],{"id":1518,"depth":411,"text":1519},{"id":1559,"depth":411,"text":1560},{"id":1688,"depth":411,"text":1689},{"id":1741,"depth":411,"text":1742},{"id":2257,"depth":411,"text":2258},{"id":2270,"depth":199,"text":2271,"children":2499},[2500,2501],{"id":2280,"depth":411,"text":2281},{"id":2395,"depth":411,"text":2396},{"id":2454,"depth":199,"text":2455},"2025-01-16T21:25:10.072Z","How I added image moderation to Multiforum using Google Cloud storage events, Cloud Run, and the Vision API.",{},"\u002Fpost\u002Fbasic-content-filter",{"title":1442,"description":2504},"post\u002Fbasic-content-filter","7Ep9ygrORONLIFdNTpWEWjvC0QbOeK3OB0kKE17JzIc",{"id":2511,"title":2512,"body":2513,"createdAt":2710,"description":2711,"extension":209,"meta":2712,"navigation":211,"path":2713,"seo":2714,"stem":2715,"__hash__":2716},"posts\u002Fpost\u002Fjan-2025-voting-feedback.md","Multiforum Demo Part 4: Voting and Feedback",{"type":8,"value":2514,"toc":2701},[2515,2518,2522,2525,2536,2539,2545,2548,2552,2555,2559,2562,2568,2571,2580,2586,2592,2598,2604,2610,2613,2616,2619,2630,2633,2637,2640,2648,2651,2659,2670,2676,2680,2683,2687,2690],[11,2516,2517],{},"I’m excited to share a new feature in Multiforum: the voting and feedback system. Multiforum is a reddit-like platform where you can create and participate in multiple forums. You can submit posts, vote on them, and now, leave meaningful feedback. Here’s how it works.",[39,2519,2521],{"id":2520},"the-voting-system","The Voting System",[11,2523,2524],{},"The voting system in Multiforum is designed to surface the most relevant and engaging content. Posts and comments can be upvoted, and their ranking depends on a few factors:",[49,2526,2527,2530,2533],{},[52,2528,2529],{},"Hot: A balance of upvotes and recency determines the ranking.",[52,2531,2532],{},"New: Posts sorted in chronological order.",[52,2534,2535],{},"Top: Posts with the highest weighted upvotes, where votes from older accounts with more reputation carry more weight.",[11,2537,2538],{},"Let's say we have an example forum with posts about ChatGPT. In this screenshot, you can see how the post \"ChatGPT just yassified me...\" comes first because it has two votes (instead of one, like the post below it). In this picture, the upvote button is blue because the logged-in user voted.",[11,2540,2541],{},[244,2542],{"alt":2543,"src":2544},"Screenshot of posts in a forum with votes, voting button, and feedback button visible.","\u002Fposts\u002Fjan-2025-voting-feedback\u002Fforum-with-votes-showing.png",[11,2546,2547],{},"This system ensures that quality content is highlighted while giving weight to the contributions of trusted, long-time users.",[39,2549,2551],{"id":2550},"the-feedback-system","The Feedback System",[11,2553,2554],{},"This feature addresses some of the common issues with downvotes on platforms like Reddit. Downvotes can feel ambiguous and discouraging. They’re often misused to express disagreement or worse, to attack specific users. To solve this, Multiforum replaces downvotes with a feedback system.",[383,2556,2558],{"id":2557},"how-feedback-works","How Feedback Works",[11,2560,2561],{},"On the detail page of a post, you can see that next to the upvote button, there’s a thumbs-down button:",[11,2563,2564],{},[244,2565],{"alt":2566,"src":2567},"Screenshot of discussion detail page with feedback button","\u002Fposts\u002Fjan-2025-voting-feedback\u002Fdiscussion-detail-page-with-feedback-button.png",[11,2569,2570],{},"This thumbs-down button is not a downvote. Clicking it opens a modal where you can type constructive feedback into a text box. You can’t leave the box blank.",[11,2572,2573,2574,2579],{},"In the actual app, if you click on an image that is embedded in the body of a post like in ",[14,2575,2578],{"href":2576,"rel":2577},"https:\u002F\u002Fwww.topical.space\u002Fforums\u002FchatGPT\u002Fdiscussions\u002F3e4e6302-45c0-4f24-8b9b-d98bc99363a1",[18],"this example",", it will open in an overlay with the full-size image. But some people find it annoying when they find pictures of text, especially if they are vision impaired or blind. So in this example, let's say you don't like the submission and you want to give feedback to that effect. So you click the thumbs-down button and the feedback modal appears:",[11,2581,2582],{},[244,2583],{"alt":2584,"src":2585},"Screenshot of the feedback modal which warns that feedback is intended to be a helpful tool for the author.","\u002Fposts\u002Fjan-2025-voting-feedback\u002Ffeedback-modal.png",[11,2587,2588,2589,1550],{},"After the feedback is submitted, anyone can see the feedback by clicking the action menu on that post, then clicking ",[1245,2590,2591],{},"View Feedback",[11,2593,2594],{},[244,2595],{"alt":2596,"src":2597},"Post with action menu clicked, View Feedback button showing","\u002Fposts\u002Fjan-2025-voting-feedback\u002Fview-feedback.png",[11,2599,2600,2601,2603],{},"When ",[1245,2602,2591],{}," is clicked, you go to the feedback page, where you can see all feedback on the post in the context of the original post:",[11,2605,2606],{},[244,2607],{"alt":2608,"src":2609},"Feedback page for discussion, showing feedback comments","\u002Fposts\u002Fjan-2025-voting-feedback\u002Ffeedback-page-for-discussion.png",[11,2611,2612],{},"This way, if someone doesn’t like your post or comment, at least you’ll know why. The goal is to turn negative reactions into actionable advice.",[11,2614,2615],{},"This feedback is also public for increased accountability for the person who gave the feedback. Someone who is new to the forum might also benefit from seeing feedback on other people's posts because then they could learn from another person's good-faith mistake.",[11,2617,2618],{},"There are some aspects of this feature that are still unfinished:",[49,2620,2621,2624,2627],{},[52,2622,2623],{},"I haven't added any notifications yet, but the idea is that if someone gives feedback on your post, you will get a notification that says you got feedback, and it will link to the feedback page for your post. At that point you will be able to read the feedback or just ignore it.",[52,2625,2626],{},"If feedback is rude or inflammatory, it will be able to be reported just like any other comment.",[52,2628,2629],{},"Individual forums will be able to turn off the feature completely.",[11,2631,2632],{},"If the author made the mistake in good faith, the idea is that they'd edit their post based on the feedback, as opposed to just being left to wonder why people don't like their post.",[39,2634,2636],{"id":2635},"mod-profiles","Mod Profiles",[11,2638,2639],{},"To make feedback less personal and reduce the potential for conflict, feedback is attributed to a semi-anonymous mod profile. (It's semi-anonymous because you can recognize the same person posting multiple times; fully anonymous would mean you can't tell if it's the same person or a different person every time.) Every user has two identities:",[49,2641,2642,2645],{},[52,2643,2644],{},"Regular Username: Used for normal participation.",[52,2646,2647],{},"Mod Profile: A randomly generated identifier (e.g., “miniatureDeafeningMysteriousTeacher”) used for moderation actions, including giving feedback.",[11,2649,2650],{},"This serves two purposes:",[49,2652,2653,2656],{},[52,2654,2655],{},"It allows the person receiving feedback to save face, as they won’t know who gave the feedback.",[52,2657,2658],{},"It ensures accountability, as mod profiles have histories showing all feedback given and other moderation actions.",[11,2660,2661,2662,2665,2666,2669],{},"In the above example, a user named ",[34,2663,2664],{},"alice"," left feedback. But because feedback is considered a moderation action, the feedback was tracked under her mod profile, ",[34,2667,2668],{},"miniatureDeafeningMysteriousTeacher",". Here you can see all of alice's activity on her mod profile. From here it should be easy to detect any pattern of abuse. The mod profile is still in progress and it's still not perfect, but the idea is that each of these list items will link to the original context of her actions:",[11,2671,2672],{},[244,2673],{"alt":2674,"src":2675},"Mod profile showing feedback","\u002Fposts\u002Fjan-2025-voting-feedback\u002Fmod-profile-showing-feedback.png",[39,2677,2679],{"id":2678},"feedback-vs-votes","Feedback vs. Votes",[11,2681,2682],{},"Feedback is not tied to ranking or visibility. It’s a separate tool meant to help authors improve their contributions.",[39,2684,2686],{"id":2685},"why-it-matters","Why It Matters",[11,2688,2689],{},"This system is intended to encourage constructive communication. By replacing ambiguous downvotes with clear feedback, it's designed to to:",[49,2691,2692,2695,2698],{},[52,2693,2694],{},"Provide clarity and helpfulness.",[52,2696,2697],{},"Reduce the misuse of negative interactions.",[52,2699,2700],{},"Support authors in improving their posts and comments.",{"title":198,"searchDepth":199,"depth":199,"links":2702},[2703,2704,2707,2708,2709],{"id":2520,"depth":199,"text":2521},{"id":2550,"depth":199,"text":2551,"children":2705},[2706],{"id":2557,"depth":411,"text":2558},{"id":2635,"depth":199,"text":2636},{"id":2678,"depth":199,"text":2679},{"id":2685,"depth":199,"text":2686},"2025-01-11T18:55:06.318Z","How Multiforum combines weighted voting, constructive feedback, and semi-anonymous moderation profiles.",{},"\u002Fpost\u002Fjan-2025-voting-feedback",{"title":2512,"description":2711},"post\u002Fjan-2025-voting-feedback","2l5i_59Q8WR_rfzdFJfV09U-hrbTyRZPHGayq3hikA0",{"id":2718,"title":2719,"body":2720,"createdAt":2773,"description":2774,"extension":209,"meta":2775,"navigation":211,"path":2776,"seo":2777,"stem":2778,"__hash__":2779},"posts\u002Fpost\u002Foct-2024-update.md","Multiforum - October Update",{"type":8,"value":2721,"toc":2769},[2722,2725,2728,2731,2737,2740,2743,2747,2753,2757,2760,2766],[11,2723,2724],{},"I decided to migrate Multiforum from Vue to Nuxt so that I could learn more about server-side rendering while also making it easier to take advantage of the SEO features of Nuxt down the road. The migration took most of October but it's nearly done.",[11,2726,2727],{},"I took a break from the migration at some points to improve the UI. There are enhancements to the layout for event search and for filtering by forums.",[11,2729,2730],{},"Here's the new event search layout with the filters on top instead of on the left:",[11,2732,2733],{},[244,2734],{"alt":2735,"src":2736},"Event search with centered filters","\u002Fposts\u002Foct-2024-update\u002Fevent-search-after.png",[11,2738,2739],{},"I like this arrangement more as it looks more symmetrical, so it's more aesthetically pleasing and has better glanceability; I think this makes it easier to understand the search and filtering functionality at first glance. Putting the forum picker on the left of the search bar helps it become more prominent, which is important because on this platform, everything is organized into forums.",[11,2741,2742],{},"I also improved the forum picker. Before, you couldn't search it and if there were many forums in the platforms, it looked too disorganized and huge.",[39,2744,2746],{"id":2745},"before","Before",[11,2748,2749],{},[244,2750],{"alt":2751,"src":2752},"Filtering map by multiple forums","\u002Fposts\u002Fevents\u002Ffiltering-map-by-multiple-forums.png",[39,2754,2756],{"id":2755},"after","After",[11,2758,2759],{},"The new version lets you search the forum picker to filter down the options and select one or multiple forums with check boxes:",[11,2761,2762],{},[244,2763],{"alt":2764,"src":2765},"Forum picker after","\u002Fposts\u002Foct-2024-update\u002Fforum-picker-after.png",[11,2767,2768],{},"As soon as I finish the Nuxt migration these changes will be live.",{"title":198,"searchDepth":199,"depth":199,"links":2770},[2771,2772],{"id":2745,"depth":199,"text":2746},{"id":2755,"depth":199,"text":2756},"2024-10-30T05:19:10.220Z","An October 2024 Multiforum update covering the Nuxt migration and improvements to event search and forum filtering.",{},"\u002Fpost\u002Foct-2024-update",{"title":2719,"description":2774},"post\u002Foct-2024-update","hsEmJdafPnj5qfaN-HF9KWlTKqtFvUKsNVR3HKKgnC0",{"id":2781,"title":2782,"body":2783,"createdAt":2830,"description":2831,"extension":209,"meta":2832,"navigation":211,"path":2833,"seo":2834,"stem":2835,"__hash__":2836},"posts\u002Fpost\u002Fabout_multiforum.md","About Multiforum",{"type":8,"value":2784,"toc":2827},[2785,2788,2791,2794,2797,2800,2804,2824],[11,2786,2787],{},"This is a work in progress that intended to be an open-source, self-hosted platform that lets you host multiple forums.",[11,2789,2790],{},"Each forum has two sections, a discussion section and a calendar. In the discussion section, content\ncan be upvoted so that the best content rises to the top. In the event section, anyone can post\nan event that participants in the community may be interested in.",[11,2792,2793],{},"Events can be submitted to multiple forums to increase visibility of them and\nhelp promote them. The same can be done with text-based discussion posts.",[11,2795,2796],{},"To solve the problem where you're bored on the weekend but you don't know what to do in your area, events can be searched across multiple forums based on location, tags and keyword. Screenshots are below.",[11,2798,2799],{},"When the project is finished, I will add documentation so that anyone can deploy their own Multiforum with custom branding.",[39,2801,2803],{"id":2802},"technology-stack","Technology Stack",[11,2805,2806,2807,2811,2812,2817,2818,2823],{},"On the backend (",[14,2808,2809],{"href":2809,"rel":2810},"https:\u002F\u002Fgithub.com\u002Fgennit-project\u002Fmultiforum-backend",[18],"), an Apollo server fetches data from the database (a graph database, Neo4j). Some resolvers are auto-generated using the ",[14,2813,2816],{"href":2814,"rel":2815},"https:\u002F\u002Fneo4j.com\u002Fdocs\u002Fgraphql\u002Fcurrent\u002F",[18],"Neo4j graphql library",", while more complex resolvers are implemented using a combination of the ",[14,2819,2822],{"href":2820,"rel":2821},"https:\u002F\u002Fneo4j.com\u002Fdocs\u002Fgraphql\u002Fcurrent\u002Fogm\u002F",[18],"OGM"," and custom Cypher queries.",[11,2825,2826],{},"The frontend is a Vue application that makes GraphQL queries to the Apollo server.",{"title":198,"searchDepth":199,"depth":199,"links":2828},[2829],{"id":2802,"depth":199,"text":2803},"2024-08-03T02:30:36.714Z","An introduction to Multiforum, an open-source platform for hosting discussions, event calendars, and community content.",{},"\u002Fpost\u002Fabout_multiforum",{"title":2782,"description":2831},"post\u002Fabout_multiforum","rZ-WWtirMCFceGHTjZlpE3q76owu6kChMaUTZozH89g",{"id":2838,"title":2839,"body":2840,"createdAt":2860,"description":2861,"extension":209,"meta":2862,"navigation":211,"path":2863,"seo":2864,"stem":2865,"__hash__":2866},"posts\u002Fpost\u002Fvideo.md","Video Demo of Multiforum",{"type":8,"value":2841,"toc":2858},[2842,2845,2855],[11,2843,2844],{},"I made a video walkthrough of Multiforum to explain the project in a problem-and-solution format.",[11,2846,2847],{},[14,2848,2851],{"href":2849,"rel":2850},"https:\u002F\u002Fwww.loom.com\u002Fshare\u002Fc94dcfcad181448abf6501584e01f9d2?sid=5d53630c-813f-4cfe-bb22-1d7289effc2e",[18],[244,2852],{"alt":2853,"src":2854},"Video demo of Multiforum","\u002Fposts\u002Fvideo\u002Fvideo-demo-thumbnail.png",[11,2856,2857],{},"(If I was making a video demo of my project for general purposes, the video would have been two minutes, not 18 minutes. I recorded this because I was applying for a job and they told me they wouldn't consider my application unless I sent them a project demo video first. They rejected my application. But I thought I did a pretty good job of explaining the project here, so I've added it here, just in case something good comes out of it.)",{"title":198,"searchDepth":199,"depth":199,"links":2859},[],"2024-07-25T23:10:08.831Z","An eighteen-minute video walkthrough of Multiforum, presented as a problem-and-solution product demonstration.",{},"\u002Fpost\u002Fvideo",{"title":2839,"description":2861},"post\u002Fvideo","dm8AvKjlg1qdieyrHZxpIlpWdbqy96hJiOwEuAozRhA",{"id":2868,"title":2869,"body":2870,"createdAt":2943,"description":2944,"extension":209,"meta":2945,"navigation":211,"path":2946,"seo":2947,"stem":2948,"__hash__":2949},"posts\u002Fpost\u002Fdiscussions.md","Multiforum Demo Part 2: Discussions",{"type":8,"value":2871,"toc":2939},[2872,2875,2878,2881,2887,2890,2896,2900,2903,2908,2912,2915,2921,2924,2930,2933],[11,2873,2874],{},"Welcome back! In this second part of my post, I'm diving into the discussion features within forums. Forums can host both events and discussions, making them ideal for communities that would like to have one foot in the real world and one foot in the online world.",[11,2876,2877],{},"Let's take a look at how these discussion features work. Below, you'll find some screenshots that show the discussion detail pages, discussion lists, and forums that might focus solely on discussions without any events.",[11,2879,2880],{},"A birdwatching forum is an example of a forum that could make use of both in-person events\nand online discussions with people who may never attend any events. For example, someone\nwho takes a picture of an unfamiliar bird in Phoenix might ask the Phoenix birdwatchers what it is.\nThat's when the Discussions tab within a forum would come in handy:",[11,2882,2883],{},[244,2884],{"alt":2885,"src":2886},"Discussion list within a forum","\u002Fposts\u002Fdiscussions\u002Fphx-bird-lovers-discussion-list.png",[11,2888,2889],{},"If you click an item in the discussion list, it goes to the discussion detail view, which contains\nthe comments. In the case of a birdwatching group, maybe there's a comment identifying the bird:",[11,2891,2892],{},[244,2893],{"alt":2894,"src":2895},"Phoenix bird lovers discussion detail","\u002Fposts\u002Fdiscussions\u002Fphx-bird-lovers-discussion-detail.png",[383,2897,2899],{"id":2898},"forum-without-any-events","Forum without any events",[11,2901,2902],{},"Events are optional for forums. I intend to make it possible for a forum to turn\noff the events tab. The Discussions tab is the main landing page, especially for forums\nthat could be focused on technical questions and answers, which would\nhave no need for events:",[11,2904,2905],{},[244,2906],{"alt":2899,"src":2907},"\u002Fposts\u002Fdiscussions\u002Fforum-without-any-events.png",[383,2909,2911],{"id":2910},"discussion-list-views-within-a-forum","Discussion list views within a forum",[11,2913,2914],{},"Here's the discussion list within a single forum, at mobile width:",[11,2916,2917],{},[244,2918],{"alt":2919,"src":2920},"Discussion list view at mobile width","\u002Fposts\u002Fdiscussions\u002Fdiscussion-list-within-channel-at-mobile-width.png",[11,2922,2923],{},"Here's another example of a discussion list view at mobile width:",[11,2925,2926],{},[244,2927],{"alt":2928,"src":2929},"Another discussion list view at mobile width","\u002Fposts\u002Fdiscussions\u002Fanother-forum-discussion-list-at-mobile-width.png",[11,2931,2932],{},"This screenshot shows how a discussion detail page looks at mobile width:",[11,2934,2935],{},[244,2936],{"alt":2937,"src":2938},"Discussion detail page at mobile width","\u002Fposts\u002Fdiscussions\u002Fdiscussion-detail-at-mobile-width.png",{"title":198,"searchDepth":199,"depth":199,"links":2940},[2941,2942],{"id":2898,"depth":411,"text":2899},{"id":2910,"depth":411,"text":2911},"2024-06-20T00:34:16.027Z","A visual walkthrough of Multiforum discussion lists, detail pages, comments, and mobile layouts.",{},"\u002Fpost\u002Fdiscussions",{"title":2869,"description":2944},"post\u002Fdiscussions","i-KuoW9KalUrPuzrE_uccmH1tpLweV0m5B38oryjU1I",{"id":2951,"title":2952,"body":2953,"createdAt":2943,"description":3194,"extension":209,"meta":3195,"navigation":211,"path":3196,"seo":3197,"stem":3198,"__hash__":3199},"posts\u002Fpost\u002Fevents.md","Multiforum Demo Part 3: Event Search",{"type":8,"value":2954,"toc":3180},[2955,2958,2964,2969,2972,2976,2979,2985,2988,2994,2998,3001,3005,3008,3011,3016,3020,3023,3029,3032,3037,3040,3044,3047,3053,3056,3062,3066,3069,3075,3079,3082,3088,3091,3094,3097,3103,3106,3112,3116,3119,3122,3128,3131,3137,3141,3145,3148,3154,3158,3161,3167,3171,3174],[11,2956,2957],{},"Anyone can post events on the platform, with a title, description, and address. If you include an address, the event will show up on a map so you can see what's happening nearby. There are also features for filtering events by time and forum, highlighting events on the map, and viewing detailed information about each event. My goal was to make it simple and effective for everyone to find and share local events.",[11,2959,2960],{},[244,2961],{"alt":2962,"src":2963},"All in person events filtered by next weekend","\u002Fposts\u002Fevents\u002Fall-in-person-events-filtered-by-next-weekend.png",[2388,2965,2966],{},[11,2967,2968],{},"Note: The above screenshot shows how it looks if you filter all in-person events by 'next weekend.' If today is during the week of Monday, June 17, then the events are filtered to show events on Saturday the 22nd and Sunday the 23rd:",[11,2970,2971],{},"In this blog post, I'll show you the different features of the tool with some screenshots. It's still a work on progress, but I plan to host it soon. Until it's hosted, hopefully this walkthrough can give you a good idea of how my project can be used to discover and share events.",[39,2973,2975],{"id":2974},"highlighting-events-on-the-map","Highlighting events on the map",[11,2977,2978],{},"If you mouse over an event list item or map marker, an info window pops up on the map\nand the list item is also highlighted. This is supposed to make it easier to draw a connection\nbetween the two:",[11,2980,2981],{},[244,2982],{"alt":2983,"src":2984},"Highlighting an item on the map filtered by forums","\u002Fposts\u002Fevents\u002Fhighlighting-item-on-map-filtered-by-forums.png",[11,2986,2987],{},"Here's another example showing what happens if you hover over an event in the map view:",[11,2989,2990],{},[244,2991],{"alt":2992,"src":2993},"Map view hover on list item","\u002Fposts\u002Fevents\u002Fmap-view-hover-on-list-item.png",[39,2995,2997],{"id":2996},"filtering-the-map-by-forums","Filtering the map by forums",[11,2999,3000],{},"If I'm only interested in events from a few specific forums, I filter the\nmap by those forums:",[11,3002,3003],{},[244,3004],{"alt":2751,"src":2752},[11,3006,3007],{},"Note: The above component for selecting forums is unwieldy and I'll be replacing it\nwith something more compact.",[11,3009,3010],{},"The resulting event list is now filtered by the two forums I selected - the writers group\nand the birdwatching one. All of the concerts are no longer in the list and their map\nmarkers are no longer on the map:",[11,3012,3013],{},[244,3014],{"alt":3015,"src":2984},"Highlighting item on map filtered by forums",[39,3017,3019],{"id":3018},"clicking-forum-name-in-event-drawer","Clicking forum name in event drawer",[11,3021,3022],{},"If you're looking at events from the map view, and you click on one, the details\nwill show up in a drawer:",[11,3024,3025],{},[244,3026],{"alt":3027,"src":3028},"Clicking an event list item","\u002Fposts\u002Fevents\u002Fclicking-an-event-list-item.png",[11,3030,3031],{},"In that drawer you can see what forums that event was\nsubmitted to. If you click the forum name it will take you to the event page in\nthe context of that forum:",[11,3033,3034],{},[244,3035],{"alt":3019,"src":3036},"\u002Fposts\u002Fevents\u002Fclicking-the-forum-that-the-event-was-submitted-to.png",[11,3038,3039],{},"Screenshots of event detail pages within a forum are below.",[39,3041,3043],{"id":3042},"multiple-events-at-the-same-location","Multiple events at the same location",[11,3045,3046],{},"Some map markers indicate that there are multiple events at the same location. If\nyou click that, you can see the list of events that are taking place there at different times:",[11,3048,3049],{},[244,3050],{"alt":3051,"src":3052},"Clicking different map marker with multiple events","\u002Fposts\u002Fevents\u002Fclicking-different-map-marker-with-multiple-events.png",[11,3054,3055],{},"Here's another example of how it looks when you click on a location with multiple events. In\nthis case, the events are both at the same concert venue, Crescent Ballroom:",[11,3057,3058],{},[244,3059],{"alt":3060,"src":3061},"Clicking map marker with multiple events","\u002Fposts\u002Fevents\u002Fclicking-map-marker-with-multiple-events.png",[39,3063,3065],{"id":3064},"clicking-a-single-event","Clicking a single event",[11,3067,3068],{},"If you click on an event list item or map marker for a single event, the details of that event show\nin a drawer (the drawer also contains permanent links to the event's detail page, useful for sharing event details):",[11,3070,3071],{},[244,3072],{"alt":3073,"src":3074},"Map view when you click on a list item","\u002Fposts\u002Fevents\u002Fmap-view-click-on-list-item.png",[39,3076,3078],{"id":3077},"event-list-within-a-forum","Event list within a forum",[11,3080,3081],{},"Each forum can have its own list of upcoming events. In this example, a forum about\nrock music in Phoenix is promoting events at multiple venues. Meanwhile, the\nforum sidebar shows the handful of events which are coming up the soonest, so that\nthey are visible even when the Discussions tab is active:",[11,3083,3084],{},[244,3085],{"alt":3086,"src":3087},"Phoenix rock event list","\u002Fposts\u002Fevents\u002Fphx-rock-event-list.png",[11,3089,3090],{},"In this particular example, hypothetically, the venues may host a variety of events\nin multiple musical genres but these particular ones would be of interest to people\nwho like rock music. So in that way, the forum can be used as a way to organize\npublic information about events and promote them to the people who find them most relevant.",[11,3092,3093],{},"(The screenshots may not show the best examples. Morphia Slow categorizes\nherself as \"Folk-Murder-Pop\", but you get the idea.)",[11,3095,3096],{},"Events can be filtered within a forum. This screenshot shows how it looks when\nevents in \"Phoenix Bird Lovers\" are filtered to show only events next weekend:",[11,3098,3099],{},[244,3100],{"alt":3101,"src":3102},"Phoenix bird lovers filtered by next weekend","\u002Fposts\u002Fevents\u002Fphx-bird-lovers-filtered-by-next-weekend.png",[11,3104,3105],{},"Here are the events filtered by location. In this case they are filtered to show\nevents within 10 miles of Tempe:",[11,3107,3108],{},[244,3109],{"alt":3110,"src":3111},"Phoenix bird lovers events filtered by location","\u002Fposts\u002Fevents\u002Fphx-bird-lovers-events-filtered-by-location.png",[39,3113,3115],{"id":3114},"submitting-an-event-to-forums","Submitting an event to forums",[11,3117,3118],{},"You can share an event to one or more forums. In a typical use case, you would link to an official\nevent page with the full details and information about how to buy tickets, if applicable.",[11,3120,3121],{},"Submitting an event to multiple forums is a good way to increase the visibility of the event. This one will now be visible in the context of both of the selected forums:",[11,3123,3124],{},[244,3125],{"alt":3126,"src":3127},"Submitting an event to multiple forums","\u002Fposts\u002Fevents\u002Fsubmitting-event-to-multiple-forums.png",[11,3129,3130],{},"If you add an address, the event will be discoverable from the sitewide event search page (the map view):",[11,3132,3133],{},[244,3134],{"alt":3135,"src":3136},"Adding an address for so that the event shows up on the map","\u002Fposts\u002Fevents\u002Fadding-an-address-makes-the-event-discoverable-from-sitewide-search.png",[39,3138,3140],{"id":3139},"screenshots-mobile-width","Screenshots - Mobile width",[383,3142,3144],{"id":3143},"event-list-view-within-a-forum","Event list view within a forum",[11,3146,3147],{},"Here's the list of events within a specific forum:",[11,3149,3150],{},[244,3151],{"alt":3152,"src":3153},"Forum event list at mobile width","\u002Fposts\u002Fevents\u002Fforum-event-list-at-mobile-width.png",[383,3155,3157],{"id":3156},"event-detail-page","Event detail page",[11,3159,3160],{},"This screenshot shows how an event detail page looks at mobile width, if you\ncome to it from within the context of an individual forum:",[11,3162,3163],{},[244,3164],{"alt":3165,"src":3166},"Event detail page at mobile width","\u002Fposts\u002Fevents\u002Fevent-detail-within-forum-at-mobile-width.png",[383,3168,3170],{"id":3169},"sitewide-event-list","Sitewide event list",[11,3172,3173],{},"Here's the sitewide in-person event list with an active filter,\nshown here at mobile width. All the same filtering features work at mobile width\nas well. Here, the events are filtered by the birdwatching forum,\nso not all of the map markers are displayed.",[11,3175,3176],{},[244,3177],{"alt":3178,"src":3179},"Sitewide filtered event list at mobile width","\u002Fposts\u002Fevents\u002Fsitewide-filtered-event-list-at-mobile-width.png",{"title":198,"searchDepth":199,"depth":199,"links":3181},[3182,3183,3184,3185,3186,3187,3188,3189],{"id":2974,"depth":199,"text":2975},{"id":2996,"depth":199,"text":2997},{"id":3018,"depth":199,"text":3019},{"id":3042,"depth":199,"text":3043},{"id":3064,"depth":199,"text":3065},{"id":3077,"depth":199,"text":3078},{"id":3114,"depth":199,"text":3115},{"id":3139,"depth":199,"text":3140,"children":3190},[3191,3192,3193],{"id":3143,"depth":411,"text":3144},{"id":3156,"depth":411,"text":3157},{"id":3169,"depth":411,"text":3170},"A walkthrough of Multiforum event discovery, maps, filters, forum submissions, and responsive layouts.",{},"\u002Fpost\u002Fevents",{"title":2952,"description":3194},"post\u002Fevents","aDwwBXEveKPdssPA76b0Sm44seb0K7m4_SKoMeBzhTY",{"id":3201,"title":3202,"body":3203,"createdAt":2943,"description":3253,"extension":209,"meta":3254,"navigation":211,"path":3255,"seo":3256,"stem":3257,"__hash__":3258},"posts\u002Fpost\u002Fforums.md","Multiforum Demo Part 1: Finding forums",{"type":8,"value":3204,"toc":3250},[3205,3208,3211,3214,3220,3223,3229,3232,3238,3242,3245],[11,3206,3207],{},"Hey everyone! I'm excited to share my latest side project with you - a local event finder tool that I've been working on. It's designed to help people find and promote events happening around them. Whether you're a local business owner wanting to attract more customers to your bar or coffee shop, or just someone who wants to share your love for rock music, trivia, or bird watching, this tool should make it easier.",[11,3209,3210],{},"I'll break this into multiple posts, but in this part, we'll explore the forum list features. These allow you to find and navigate forums easily, especially on mobile devices.",[11,3212,3213],{},"Here is the list of forums at mobile width:",[11,3215,3216],{},[244,3217],{"alt":3218,"src":3219},"Forum list at mobile width","\u002Fposts\u002Fforums\u002Fforum-list-at-mobile-width.png",[11,3221,3222],{},"The list of forums can be filtered by tag:",[11,3224,3225],{},[244,3226],{"alt":3227,"src":3228},"The forum list can be filtered by tag","\u002Fposts\u002Fforums\u002Fforum-list-filtered-by-tag.png",[11,3230,3231],{},"The forum list can be filtered by search terms as well:",[11,3233,3234],{},[244,3235],{"alt":3236,"src":3237},"Forum list filtered by search terms","\u002Fposts\u002Fforums\u002Fforum-list-filtered-by-search-terms.png",[39,3239,3241],{"id":3240},"recently-visited-forums","Recently visited forums",[11,3243,3244],{},"If you click the menu button on the top left of any page, it shows recently visited forums to support easy context switching.",[11,3246,3247],{},[244,3248],{"alt":3241,"src":3249},"\u002Fposts\u002Fforums\u002Frecently-visited-forums.png",{"title":198,"searchDepth":199,"depth":199,"links":3251},[3252],{"id":3240,"depth":199,"text":3241},"A tour of Multiforum's forum discovery, filtering, search, and recently visited forum features.",{},"\u002Fpost\u002Fforums",{"title":3202,"description":3253},"post\u002Fforums","n6paBMzYlXueEwlQ6qQzd0ItB3r6oJm52jMdQFYpETw",1785377801307]