← back to Bubbesblock
Build out every button with real UX: sessions + claim gate, write endpoints (posts/comments/reactions/respond/unlock/rsvp/join/bookmark), search, notifications, inbox, events, groups, content pages; live home-history wired; interaction layer (app.js) + modals/toasts
7cbd55e9042382d3001dc3403161e2ae2e10d583 · 2026-06-19 16:19:19 -0700 · Steve
Files touched
M .gitignoreA data/community.jsonM db/schema.sqlD import/nextdoor-extracted.jsonD import/nextdoor-screens/nd-01.jpegD import/nextdoor-screens/nd-02.jpegD import/nextdoor-screens/nd-03.jpegD import/nextdoor-screens/nd-04.jpegD import/nextdoor-screens/nd-05.jpegD import/nextdoor-screens/nd-06.jpegD import/nextdoor-screens/nd-07.jpegD import/nextdoor-screens/nd-08.jpegD import/nextdoor-screens/nd-09.jpegD import/nextdoor-screens/nd-10.jpegD import/nextdoor-screens/nd-11.jpegD import/nextdoor-screens/nd-12.jpegD import/nextdoor-screens/nd-13.jpegD import/nextdoor-screens/nd-14.jpegD import/nextdoor-screens/nd-15.jpegD import/nextdoor-screens/nd-16.jpegM package-lock.jsonM package.jsonA public/app.jsA public/bookmarks.htmlM public/chrome.jsA public/content.htmlA public/events.htmlA public/groups.htmlA public/inbox.htmlM public/index.htmlA public/notifications.htmlM public/opportunities.htmlM public/post.htmlA public/search.htmlM public/styles.cssM scripts/migrate.jsM server.js
Diff
commit 7cbd55e9042382d3001dc3403161e2ae2e10d583
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 19 16:19:19 2026 -0700
Build out every button with real UX: sessions + claim gate, write endpoints (posts/comments/reactions/respond/unlock/rsvp/join/bookmark), search, notifications, inbox, events, groups, content pages; live home-history wired; interaction layer (app.js) + modals/toasts
---
.gitignore | 1 +
data/community.json | 15 ++
db/schema.sql | 94 ++++++++++
import/nextdoor-extracted.json | 23 ---
import/nextdoor-screens/nd-01.jpeg | Bin 28001 -> 0 bytes
import/nextdoor-screens/nd-02.jpeg | Bin 25678 -> 0 bytes
import/nextdoor-screens/nd-03.jpeg | Bin 33649 -> 0 bytes
import/nextdoor-screens/nd-04.jpeg | Bin 36010 -> 0 bytes
import/nextdoor-screens/nd-05.jpeg | Bin 23174 -> 0 bytes
import/nextdoor-screens/nd-06.jpeg | Bin 51344 -> 0 bytes
import/nextdoor-screens/nd-07.jpeg | Bin 42649 -> 0 bytes
import/nextdoor-screens/nd-08.jpeg | Bin 94523 -> 0 bytes
import/nextdoor-screens/nd-09.jpeg | Bin 40654 -> 0 bytes
import/nextdoor-screens/nd-10.jpeg | Bin 57139 -> 0 bytes
import/nextdoor-screens/nd-11.jpeg | Bin 48615 -> 0 bytes
import/nextdoor-screens/nd-12.jpeg | Bin 29008 -> 0 bytes
import/nextdoor-screens/nd-13.jpeg | Bin 29035 -> 0 bytes
import/nextdoor-screens/nd-14.jpeg | Bin 40139 -> 0 bytes
import/nextdoor-screens/nd-15.jpeg | Bin 76952 -> 0 bytes
import/nextdoor-screens/nd-16.jpeg | Bin 71649 -> 0 bytes
package-lock.json | 182 +++++++++++++++++-
package.json | 1 +
public/app.js | 256 +++++++++++++++++++++++++
public/bookmarks.html | 20 ++
public/chrome.js | 34 ++--
public/content.html | 26 +++
public/events.html | 34 ++++
public/groups.html | 34 ++++
public/inbox.html | 41 ++++
public/index.html | 20 +-
public/notifications.html | 23 +++
public/opportunities.html | 73 +++++---
public/post.html | 43 +++--
public/search.html | 29 +++
public/styles.css | 61 ++++++
scripts/migrate.js | 54 +++++-
server.js | 370 +++++++++++++++++++++++++++++--------
37 files changed, 1268 insertions(+), 166 deletions(-)
diff --git a/.gitignore b/.gitignore
index 26ff4df..ed0646d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ dist/
build/
.next/
.deploy.conf
+import/
diff --git a/data/community.json b/data/community.json
new file mode 100644
index 0000000..7a782de
--- /dev/null
+++ b/data/community.json
@@ -0,0 +1,15 @@
+{
+ "groups": [
+ { "slug": "babka-bagel-swap", "name": "Babka & Bagel Swap", "emoji": "🥧", "color": "#3a7d5d", "members": 48, "blurb": "Bake too much? Swap it. Standing Saturday-morning trade on Ruth's stoop." },
+ { "slug": "tools-ladders", "name": "Tools & Ladders to Borrow", "emoji": "🔧", "color": "#b8862f", "members": 61, "blurb": "The block's shared shed — borrow a ladder, drill, or folding table before you buy one." },
+ { "slug": "block-dog-walkers", "name": "Block Dog Walkers", "emoji": "🐕", "color": "#5566b5", "members": 27, "blurb": "Morning and evening walking crews, lost-dog alerts, and the good-treat list." },
+ { "slug": "shabbat-dinner-club", "name": "Shabbat Dinner Club", "emoji": "🕯️", "color": "#c2557a", "members": 34, "blurb": "Open table every Friday at sundown. New faces always welcome — just RSVP." },
+ { "slug": "holiday-sukkah-help", "name": "Holiday & Sukkah Help", "emoji": "🌿", "color": "#a05236", "members": 22, "blurb": "Hands, bamboo, and decorations for sukkah season — and every holiday after." }
+ ],
+ "events": [
+ { "id": "ev-shabbat", "title": "Shabbat Dinner at the Abrams'", "emoji": "🕯️", "when_label": "Fri Jun 20 · 6:30 PM", "location": "112 Beekman Ave", "going": 12, "body": "Open table, candles at sundown. Bring wine if you like, bring yourself either way.", "position": 0 },
+ { "id": "ev-sukkah", "title": "Sukkah Build & Decorate", "emoji": "🌿", "when_label": "Sun Jun 22 · 9:00 AM", "location": "119 Beekman Ave (side yard)", "going": 8, "body": "Hour of bamboo-roof raising, then the kids decorate. Tools + lunch provided.", "position": 1 },
+ { "id": "ev-blockparty", "title": "Beekman Ave Block Party", "emoji": "🎉", "when_label": "Sun Jun 22 · 1:00 PM", "location": "Beekman Ave (street closed)", "going": 32, "body": "Potluck, grill, Ruth's dessert table, Mr. Reuben's speaker. Bring a dish + a chair.", "position": 2 },
+ { "id": "ev-babka", "title": "Monthly Babka & Bagel Swap", "emoji": "🥯", "when_label": "Sat Jun 28 · 10:00 AM", "location": "108 Beekman stoop", "going": 19, "body": "Bring a baked thing, leave with a different baked thing. Foil provided.", "position": 3 }
+ ]
+}
diff --git a/db/schema.sql b/db/schema.sql
index 03852bb..7952e26 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -61,3 +61,97 @@ CREATE TABLE IF NOT EXISTS opportunities (
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_opps_position ON opportunities(position);
+
+-- ============ Phase A: identity + interaction tables ============
+CREATE TABLE IF NOT EXISTS users (
+ id text PRIMARY KEY,
+ name text NOT NULL,
+ initials text,
+ color text,
+ address text,
+ verified boolean DEFAULT false,
+ created_at timestamptz DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS reactions (
+ id bigserial PRIMARY KEY,
+ target_type text NOT NULL, -- post | comment | opportunity
+ target_id text NOT NULL,
+ user_id text NOT NULL,
+ kind text DEFAULT 'thank',
+ created_at timestamptz DEFAULT now(),
+ UNIQUE(target_type, target_id, user_id, kind)
+);
+
+CREATE TABLE IF NOT EXISTS opp_responses (
+ id bigserial PRIMARY KEY,
+ opportunity_id text NOT NULL,
+ user_id text NOT NULL,
+ body text,
+ created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS opp_unlocks (
+ opportunity_id text NOT NULL,
+ user_id text NOT NULL,
+ created_at timestamptz DEFAULT now(),
+ PRIMARY KEY (opportunity_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS notifications (
+ id bigserial PRIMARY KEY,
+ user_id text NOT NULL,
+ type text,
+ actor text,
+ text text,
+ link text,
+ read boolean DEFAULT false,
+ created_at timestamptz DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS conversations (
+ id bigserial PRIMARY KEY,
+ user_id text NOT NULL,
+ peer text,
+ peer_initials text,
+ peer_color text,
+ created_at timestamptz DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS messages (
+ id bigserial PRIMARY KEY,
+ conv_id bigint NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+ from_me boolean DEFAULT false,
+ body text,
+ created_at timestamptz DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS events (
+ id text PRIMARY KEY,
+ title text,
+ emoji text,
+ when_label text,
+ location text,
+ going int DEFAULT 0,
+ body text,
+ position int
+);
+CREATE TABLE IF NOT EXISTS event_rsvps (
+ event_id text NOT NULL, user_id text NOT NULL, created_at timestamptz DEFAULT now(),
+ PRIMARY KEY (event_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS groups (
+ slug text PRIMARY KEY, name text, emoji text, color text, members int DEFAULT 0, blurb text, position int
+);
+CREATE TABLE IF NOT EXISTS group_members (
+ slug text NOT NULL, user_id text NOT NULL, created_at timestamptz DEFAULT now(),
+ PRIMARY KEY (slug, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS bookmarks (
+ user_id text NOT NULL, post_id text NOT NULL, created_at timestamptz DEFAULT now(),
+ PRIMARY KEY (user_id, post_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_react_target ON reactions(target_type, target_id);
+CREATE INDEX IF NOT EXISTS idx_notif_user ON notifications(user_id, read);
+CREATE INDEX IF NOT EXISTS idx_oppresp_opp ON opp_responses(opportunity_id);
diff --git a/import/nextdoor-extracted.json b/import/nextdoor-extracted.json
deleted file mode 100644
index 0a02131..0000000
--- a/import/nextdoor-extracted.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "_note": "Structured extraction of 16 Nextdoor-for-Business screenshots (synced from iPhone after 3:04pm 2026-06-19), read by 4 parallel agents. Source of truth for the 'similar but not exact' BubbesBlock model.",
- "business": "Designer Wallcoverings",
- "service_area_address": "15442 Ventura Boulevard, Sherman Oaks",
- "home_address": "18406 Bessemer Street, Tarzana CA 91406",
- "recommended_category": "Interior decoration",
- "opp_alerts_price_month": 109.0,
- "ads_price_month": 150.0,
- "surfaces": ["ads-flow", "chats", "dashboard", "opportunity-alerts", "service-area-map", "service-category-optin", "neighbor-feed", "my-activity"],
- "real_opportunity_leads": [
- "Looking for a home interior decorator starting out and willing to provide ideas for a small townhome at a reasonable price.",
- "Searching for a drapery shop that makes custom fabric draperies. Interested in ordering by size, with specific lining and pleating needs.",
- "Looking for a great decor person or team to help with party decorations.",
- "Looking for recommendations for custom wardrobe or closet installations. Interested in hearing about experiences.",
- "Looking for recommendations for excellent kitchen cabinet refinishers.",
- "Looking for recommendations for upgrading a closet with shelves, drawers, and hanging space.",
- "Looking for help with window treatments and curtain hanging. Seeking affordable recommendations.",
- "Seeking recommendations for a designer or architect to help with home expansion or ADU design. Looking for expert assistance for home upgrades."
- ],
- "service_categories_offered": ["Interior decoration", "Carpentry", "Flooring", "General contractor", "Handyman", "Home cleaners", "Painting", "Window services"],
- "neighbor_feed_themes": ["local development/zoning alerts", "Ventura Blvd traffic/Trader Joe's", "Jerry's Deli closing after 50 years", "overnight police/helicopter activity", "lost & found", "dog attack report", "garage break-in"],
- "my_activity_note": "Steve's own most-popular neighbor posts are about DUCKS — 'Mr and Mrs Quacksalot have returned', 'What the duck?', 'Now ducks!' (100-160+ reactions each)."
-}
diff --git a/import/nextdoor-screens/nd-01.jpeg b/import/nextdoor-screens/nd-01.jpeg
deleted file mode 100644
index 3099345..0000000
Binary files a/import/nextdoor-screens/nd-01.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-02.jpeg b/import/nextdoor-screens/nd-02.jpeg
deleted file mode 100644
index 08bcdc7..0000000
Binary files a/import/nextdoor-screens/nd-02.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-03.jpeg b/import/nextdoor-screens/nd-03.jpeg
deleted file mode 100644
index 9d6d115..0000000
Binary files a/import/nextdoor-screens/nd-03.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-04.jpeg b/import/nextdoor-screens/nd-04.jpeg
deleted file mode 100644
index c27e811..0000000
Binary files a/import/nextdoor-screens/nd-04.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-05.jpeg b/import/nextdoor-screens/nd-05.jpeg
deleted file mode 100644
index a11c244..0000000
Binary files a/import/nextdoor-screens/nd-05.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-06.jpeg b/import/nextdoor-screens/nd-06.jpeg
deleted file mode 100644
index 54b791d..0000000
Binary files a/import/nextdoor-screens/nd-06.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-07.jpeg b/import/nextdoor-screens/nd-07.jpeg
deleted file mode 100644
index fb1b140..0000000
Binary files a/import/nextdoor-screens/nd-07.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-08.jpeg b/import/nextdoor-screens/nd-08.jpeg
deleted file mode 100644
index 15bbd1a..0000000
Binary files a/import/nextdoor-screens/nd-08.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-09.jpeg b/import/nextdoor-screens/nd-09.jpeg
deleted file mode 100644
index 034b9fa..0000000
Binary files a/import/nextdoor-screens/nd-09.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-10.jpeg b/import/nextdoor-screens/nd-10.jpeg
deleted file mode 100644
index 815ef5f..0000000
Binary files a/import/nextdoor-screens/nd-10.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-11.jpeg b/import/nextdoor-screens/nd-11.jpeg
deleted file mode 100644
index 8a4970d..0000000
Binary files a/import/nextdoor-screens/nd-11.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-12.jpeg b/import/nextdoor-screens/nd-12.jpeg
deleted file mode 100644
index 91959c7..0000000
Binary files a/import/nextdoor-screens/nd-12.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-13.jpeg b/import/nextdoor-screens/nd-13.jpeg
deleted file mode 100644
index 9c24f34..0000000
Binary files a/import/nextdoor-screens/nd-13.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-14.jpeg b/import/nextdoor-screens/nd-14.jpeg
deleted file mode 100644
index 16e5fad..0000000
Binary files a/import/nextdoor-screens/nd-14.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-15.jpeg b/import/nextdoor-screens/nd-15.jpeg
deleted file mode 100644
index 5a66de1..0000000
Binary files a/import/nextdoor-screens/nd-15.jpeg and /dev/null differ
diff --git a/import/nextdoor-screens/nd-16.jpeg b/import/nextdoor-screens/nd-16.jpeg
deleted file mode 100644
index 3301730..0000000
Binary files a/import/nextdoor-screens/nd-16.jpeg and /dev/null differ
diff --git a/package-lock.json b/package-lock.json
index 55d6274..f166220 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,10 @@
"name": "bubbesblock",
"version": "0.1.0",
"dependencies": {
- "express": "^4.19.2"
+ "cookie-parser": "^1.4.6",
+ "dotenv": "^16.4.5",
+ "express": "^4.19.2",
+ "pg": "^8.12.0"
}
},
"node_modules/accepts": {
@@ -122,6 +125,25 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-parser/node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -156,6 +178,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -564,6 +598,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
+ "node_modules/pg": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+ "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.15.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+ "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -765,6 +927,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -822,6 +993,15 @@
"engines": {
"node": ">= 0.8"
}
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
diff --git a/package.json b/package.json
index 06d8ffe..57f787a 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,7 @@
"dev": "node server.js"
},
"dependencies": {
+ "cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"pg": "^8.12.0"
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..a38e805
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,256 @@
+/* BubbesBlock interaction layer — makes every control real. Loaded after chrome.js. */
+(function () {
+ const BB = window.BB = { you: null };
+ const api = {
+ get: (u) => fetch(u, { credentials: 'same-origin' }).then(r => r.json()),
+ post: (u, body) => fetch(u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok) throw Object.assign(new Error(j.error || r.status), { status: r.status, data: j }); return j; })
+ };
+ BB.api = api;
+
+ /* ---------- toast ---------- */
+ function toast(msg) {
+ let t = document.getElementById('bb-toast');
+ if (!t) { t = document.createElement('div'); t.id = 'bb-toast'; document.body.appendChild(t); }
+ t.textContent = msg; t.className = 'show';
+ clearTimeout(t._t); t._t = setTimeout(() => t.className = '', 2600);
+ }
+ BB.toast = toast;
+
+ /* ---------- modal ---------- */
+ function modal(title, inner, opts = {}) {
+ close();
+ const back = document.createElement('div'); back.className = 'bb-modal-back'; back.id = 'bb-modal';
+ back.innerHTML = `<div class="bb-modal" role="dialog" aria-modal="true">
+ <div class="bb-modal-hd"><b>${title}</b><button class="bb-x" aria-label="Close">×</button></div>
+ <div class="bb-modal-bd">${inner}</div></div>`;
+ document.body.appendChild(back);
+ back.querySelector('.bb-x').onclick = close;
+ back.onclick = e => { if (e.target === back) close(); };
+ document.addEventListener('keydown', escClose);
+ if (opts.onOpen) opts.onOpen(back);
+ const f = back.querySelector('input,textarea'); if (f) f.focus();
+ return back;
+ }
+ function close() { const m = document.getElementById('bb-modal'); if (m) m.remove(); document.removeEventListener('keydown', escClose); }
+ function escClose(e) { if (e.key === 'Escape') close(); }
+ BB.modal = modal; BB.closeModal = close;
+
+ /* ---------- session / auth gates ---------- */
+ BB.refresh = async () => { const s = await api.get('/api/session'); BB.you = s.you; paintYou(); return BB.you; };
+ function paintYou() {
+ // reflect verified state on any composer placeholders, etc.
+ document.querySelectorAll('[data-when-auth]').forEach(el => { el.style.display = BB.you ? '' : 'none'; });
+ document.querySelectorAll('[data-when-anon]').forEach(el => { el.style.display = BB.you ? 'none' : ''; });
+ }
+
+ BB.ensureAuth = function () {
+ return new Promise(resolve => {
+ if (BB.you) return resolve(BB.you);
+ modal('Sign in to your block', `
+ <p class="bb-sub">Join your neighbors on Beekman Ave. Enter your name to continue.</p>
+ <input id="bb-name" placeholder="Your name (e.g. Steve Abrams)" />
+ <button class="bb-btn" id="bb-login">Continue</button>
+ <p class="bb-fine">Posting also needs a claimed address — one tap after this.</p>`, {
+ onOpen(m) {
+ const go = async () => {
+ const name = m.querySelector('#bb-name').value.trim(); if (!name) return;
+ try { const r = await api.post('/api/session/login', { name }); BB.you = r.you; paintYou(); close(); toast('Welcome, ' + r.you.name.split(' ')[0] + '!'); resolve(r.you); }
+ catch (e) { toast('Sign-in failed'); }
+ };
+ m.querySelector('#bb-login').onclick = go;
+ m.querySelector('#bb-name').addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
+ }
+ });
+ });
+ };
+
+ BB.ensureVerified = async function () {
+ const you = await BB.ensureAuth(); if (!you) return null;
+ if (you.verified) return you;
+ return new Promise(resolve => {
+ modal('Claim your address', `
+ <p class="bb-sub">Verify you live on the block to post & reply. (One-tap demo of the claimmyaddress.com flow.)</p>
+ <input id="bb-addr" placeholder="Your address" value="${you.address || ''}" />
+ <button class="bb-btn" id="bb-claim">Claim & continue</button>
+ <a class="bb-fine" href="https://claimmyaddress.com" target="_blank">Powered by claimmyaddress.com →</a>`, {
+ onOpen(m) {
+ m.querySelector('#bb-claim').onclick = async () => {
+ const address = m.querySelector('#bb-addr').value.trim(); if (!address) return;
+ try { const r = await api.post('/api/claim', { address }); BB.you = r.you; paintYou(); close(); toast('Address claimed ✓'); resolve(r.you); }
+ catch (e) { toast('Claim failed'); }
+ };
+ }
+ });
+ });
+ };
+
+ /* ---------- composer ---------- */
+ const CATS = [['📣 Block Post', 'plum'], ['🍯 Cup of Sugar', 'green'], ['🕯️ Shabbat Dinner Club', 'plum'], ['🌿 Block Events', 'green'], ['🐱 Lost & Found', 'amber'], ['🏚️ Who Lived Here', 'green']];
+ BB.composer = async function () {
+ const you = await BB.ensureVerified(); if (!you) return;
+ modal('Share with the block', `
+ <select id="bb-cat" class="bb-input">${CATS.map(c => `<option value="${c[0]}|${c[1]}">${c[0]}</option>`).join('')}</select>
+ <textarea id="bb-body" rows="5" placeholder="What's happening on the block, ${you.name.split(' ')[0]}?"></textarea>
+ <button class="bb-btn" id="bb-post">Post to the block</button>`, {
+ onOpen(m) {
+ m.querySelector('#bb-post').onclick = async () => {
+ const body = m.querySelector('#bb-body').value.trim(); if (!body) return;
+ const [category, catColor] = m.querySelector('#bb-cat').value.split('|');
+ try { await api.post('/api/posts', { body, category, catColor }); close(); toast('Posted ✓'); if (location.pathname === '/') location.reload(); }
+ catch (e) { toast(e.status === 403 ? 'Claim your address first' : 'Post failed'); }
+ };
+ }
+ });
+ };
+
+ /* ---------- reactions / share / bookmark ---------- */
+ BB.react = async function (btn) {
+ const you = await BB.ensureAuth(); if (!you) return;
+ const [targetType, targetId] = btn.dataset.react.split(':');
+ try {
+ const r = await api.post('/api/react', { targetType, targetId, kind: 'thank' });
+ btn.classList.toggle('on', r.on);
+ const c = btn.querySelector('.cnt'); if (c) c.textContent = r.count;
+ else if (r.count) btn.insertAdjacentHTML('beforeend', ` <span class="cnt">${r.count}</span>`);
+ } catch (e) { toast('Try again'); }
+ };
+ BB.share = async function (id) {
+ try { const r = await api.post(`/api/posts/${id}/share`, {}); } catch {}
+ const url = location.origin + '/p/' + id;
+ modal('Share this post', `
+ <p class="bb-sub">Copy the link or send it to a neighbor.</p>
+ <input id="bb-url" class="bb-input" readonly value="${url}" />
+ <button class="bb-btn" id="bb-copy">Copy link</button>`, {
+ onOpen(m) { m.querySelector('#bb-copy').onclick = () => { m.querySelector('#bb-url').select(); navigator.clipboard?.writeText(url); toast('Link copied'); close(); }; }
+ });
+ };
+
+ /* ---------- comments ---------- */
+ BB.sendComment = async function (postId, input, isReply) {
+ const you = await BB.ensureVerified(); if (!you) { return; }
+ const body = input.value.trim(); if (!body) return;
+ try {
+ const r = await api.post(`/api/posts/${postId}/comments`, { body, reply: !!isReply });
+ input.value = '';
+ const list = document.getElementById('bb-comments');
+ if (list && window.renderComment) list.insertAdjacentHTML('beforeend', window.renderComment(r.comment));
+ toast('Reply posted ✓');
+ } catch (e) { toast(e.status === 403 ? 'Claim your address first' : 'Reply failed'); }
+ };
+
+ /* ---------- opportunities ---------- */
+ BB.respond = async function (id, neighbor) {
+ const you = await BB.ensureVerified(); if (!you) return;
+ modal('Respond to ' + (neighbor || 'this neighbor'), `
+ <p class="bb-sub">Introduce yourself and your services. They'll see this in their inbox.</p>
+ <textarea id="bb-msg" rows="4" placeholder="Hi! I'd love to help with this…"></textarea>
+ <button class="bb-btn" id="bb-send">Send response</button>`, {
+ onOpen(m) {
+ m.querySelector('#bb-send').onclick = async () => {
+ const body = m.querySelector('#bb-msg').value.trim(); if (!body) return;
+ try { const r = await api.post(`/api/opportunities/${id}/respond`, { body }); close(); toast('Response sent ✓'); document.dispatchEvent(new CustomEvent('opp-responded', { detail: { id, responses: r.responses } })); }
+ catch (e) { toast(e.status === 403 ? 'Claim your address first' : 'Failed'); }
+ };
+ }
+ });
+ };
+ BB.unlock = async function (id, card) {
+ const you = await BB.ensureAuth(); if (!you) return;
+ try { await api.post(`/api/opportunities/${id}/unlock`, {}); document.dispatchEvent(new CustomEvent('opp-unlocked', { detail: { id } })); toast('Lead unlocked ✓'); }
+ catch (e) { toast('Failed'); }
+ };
+
+ /* ---------- search ---------- */
+ let searchT;
+ BB.wireSearch = function (input) {
+ if (!input) return;
+ let dd = document.createElement('div'); dd.className = 'bb-search-dd'; input.parentNode.appendChild(dd);
+ input.addEventListener('input', () => {
+ clearTimeout(searchT); const term = input.value.trim();
+ if (term.length < 2) { dd.classList.remove('show'); return; }
+ searchT = setTimeout(async () => {
+ const r = await api.get('/api/search?q=' + encodeURIComponent(term));
+ const rows = [
+ ...r.posts.map(p => `<a href="/p/${p.id}"><b>${p.category || 'Post'}</b> — ${(Array.isArray(p.body) ? p.body[0] : p.body || '').slice(0, 60)}</a>`),
+ ...r.opportunities.map(o => `<a href="/opportunities"><b>${o.category}</b> — ${o.body.slice(0, 60)}</a>`),
+ ...r.neighbors.map(n => `<a href="/search?q=${encodeURIComponent(n.name)}"><b>👤 ${n.name}</b> ${n.address || ''}</a>`)
+ ];
+ dd.innerHTML = rows.length ? rows.join('') : '<div class="bb-empty">No matches</div>';
+ dd.classList.add('show');
+ }, 220);
+ });
+ input.addEventListener('keydown', e => { if (e.key === 'Enter' && input.value.trim()) location.href = '/search?q=' + encodeURIComponent(input.value.trim()); });
+ document.addEventListener('click', e => { if (!input.parentNode.contains(e.target)) dd.classList.remove('show'); });
+ };
+
+ /* ---------- notifications + inbox dropdowns ---------- */
+ function dropdown(btn, html) {
+ document.querySelectorAll('.bb-dd-pop').forEach(x => x.remove());
+ const pop = document.createElement('div'); pop.className = 'bb-dd-pop'; pop.innerHTML = html;
+ document.body.appendChild(pop);
+ const r = btn.getBoundingClientRect();
+ pop.style.top = (r.bottom + 6) + 'px'; pop.style.right = (window.innerWidth - r.right) + 'px';
+ setTimeout(() => document.addEventListener('click', function h(e) { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('click', h); } }), 0);
+ return pop;
+ }
+ BB.openNotifications = async function (btn) {
+ const you = await BB.ensureAuth(); if (!you) return;
+ const r = await api.get('/api/notifications');
+ const items = r.items.length ? r.items.map(n => `<a href="${n.link || '#'}" class="bb-noti ${n.read ? '' : 'unread'}"><b>${n.actor}</b> ${n.text}</a>`).join('') : '<div class="bb-empty">No notifications yet</div>';
+ dropdown(btn, `<div class="bb-dd-hd">Notifications<button id="bb-mark">Mark all read</button></div>${items}`);
+ const mk = document.getElementById('bb-mark'); if (mk) mk.onclick = async () => { await api.post('/api/notifications/read', {}); document.querySelectorAll('.bb-noti').forEach(x => x.classList.remove('unread')); BB.updateBadges(); };
+ };
+ BB.openInbox = async function (btn) {
+ const you = await BB.ensureAuth(); if (!you) return;
+ const r = await api.get('/api/inbox');
+ const items = r.conversations.length ? r.conversations.map(c => `<a href="/inbox" class="bb-noti"><b>${c.peer}</b> <span>${(c.last || '').slice(0, 48)}</span></a>`).join('') : '<div class="bb-empty">Your inbox is empty</div>';
+ dropdown(btn, `<div class="bb-dd-hd">Messages</div>${items}`);
+ };
+
+ BB.updateBadges = async function () {
+ if (!BB.you) return;
+ const n = await api.get('/api/notifications');
+ document.querySelectorAll('[data-badge="notifications"]').forEach(b => { b.textContent = n.unread || ''; b.style.display = n.unread ? '' : 'none'; });
+ };
+
+ /* ---------- avatar menu ---------- */
+ BB.avatarMenu = async function (btn) {
+ const you = BB.you;
+ const html = you
+ ? `<div class="bb-dd-hd">${you.name}</div><a href="/bookmarks" class="bb-noti">🔖 Bookmarks</a><a href="#" class="bb-noti" id="bb-logout">↪ Log out</a>`
+ : `<div class="bb-dd-hd">Welcome</div><a href="#" class="bb-noti" id="bb-signin">Sign in</a>`;
+ dropdown(btn, html);
+ const lo = document.getElementById('bb-logout'); if (lo) lo.onclick = async e => { e.preventDefault(); await api.post('/api/session/logout', {}); location.reload(); };
+ const si = document.getElementById('bb-signin'); if (si) si.onclick = e => { e.preventDefault(); BB.ensureAuth(); };
+ };
+
+ /* ---------- global delegation ---------- */
+ document.addEventListener('click', e => {
+ const t = e.target.closest('[data-react],[data-share],[data-reply],[data-respond],[data-unlock],[data-composer],[data-panel],[data-avatar],[data-more]');
+ if (!t) return;
+ if (t.dataset.react !== undefined) { e.preventDefault(); BB.react(t); }
+ else if (t.dataset.share !== undefined) { e.preventDefault(); BB.share(t.dataset.share); }
+ else if (t.dataset.reply !== undefined) { e.preventDefault(); const ci = document.getElementById('bb-comment-input'); if (ci) ci.focus(); }
+ else if (t.dataset.respond !== undefined) { e.preventDefault(); BB.respond(t.dataset.respond, t.dataset.neighbor); }
+ else if (t.dataset.unlock !== undefined) { e.preventDefault(); BB.unlock(t.dataset.unlock, t.closest('.opp')); }
+ else if (t.dataset.composer !== undefined) { e.preventDefault(); BB.composer(); }
+ else if (t.dataset.panel === 'notifications') { e.preventDefault(); BB.openNotifications(t); }
+ else if (t.dataset.panel === 'inbox') { e.preventDefault(); BB.openInbox(t); }
+ else if (t.dataset.avatar !== undefined) { e.preventDefault(); BB.avatarMenu(t); }
+ else if (t.dataset.more !== undefined) { e.preventDefault(); BB.moreMenu(t); }
+ });
+
+ BB.moreMenu = function (btn) {
+ const id = (btn.dataset.more || '').split(':')[1];
+ dropdown(btn, `<a href="#" class="bb-noti" data-bm="${id}">🔖 Save post</a><a href="#" class="bb-noti" data-share="${id}">↗ Share</a><a href="/p/${id}" class="bb-noti">🔗 Open post</a>`);
+ const bm = document.querySelector(`[data-bm="${id}"]`); if (bm) bm.onclick = async e => { e.preventDefault(); const you = await BB.ensureAuth(); if (!you) return; try { const r = await BB.api.post(`/api/posts/${id}/bookmark`, {}); toast(r.on ? 'Saved 🔖' : 'Removed'); } catch { toast('Try again'); } };
+ };
+
+ /* ---------- init ---------- */
+ BB.init = async function () {
+ await BB.refresh();
+ BB.wireSearch(document.querySelector('.search input'));
+ BB.updateBadges();
+ };
+ document.addEventListener('DOMContentLoaded', () => { if (window.__bbAutoInit !== false) BB.init(); });
+})();
diff --git a/public/bookmarks.html b/public/bookmarks.html
new file mode 100644
index 0000000..e127083
--- /dev/null
+++ b/public/bookmarks.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Bookmarks</title><link rel="stylesheet" href="/styles.css" />
+</head><body>
+<div id="hdr"></div>
+<div class="wrap"><div id="left"></div><main class="col-center" id="main"></main><div id="right"></div></div>
+<script>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/bookmarks',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+ document.getElementById('hdr').innerHTML=topbar(m.me);
+ document.getElementById('left').innerHTML=leftRail(m.me);
+ document.getElementById('right').innerHTML=rightRail(m.me);
+ const cards = (d.posts||[]).map(p=>`<article class="card feedcard" onclick="location.href='/p/${p.id}'"><div class="post">${postHead(p)}${postBody(p)}</div>${photoBlock(p.photo)}</article>`).join('');
+ const empty = `<div class="prose"><h1>🔖 No bookmarks yet</h1><p>Tap the ⋯ menu on any post and choose <b>Save post</b> to keep it here.${m.you?'':' Sign in first.'}</p></div>`;
+ document.getElementById('main').innerHTML='<div class="listhd">🔖 Bookmarks</div>'+(d.posts&&d.posts.length?cards:empty);
+ BB.init();
+});
+</script></body></html>
diff --git a/public/chrome.js b/public/chrome.js
index d88f29e..e575e75 100644
--- a/public/chrome.js
+++ b/public/chrome.js
@@ -25,9 +25,9 @@ function topbar(me){
<nav class="topnav">
<button class="navbtn ${PATH==='/'?'active':''}" title="Block Feed" onclick="location.href='/'">${ICON.house}</button>
<button class="navbtn ${PATH.indexOf('/opportunities')===0?'active':''}" title="Opp Alerts" onclick="location.href='/opportunities'"><span class="badge">12</span>${ICON.opps}</button>
- <button class="navbtn" title="Notifications"><span class="badge">5</span>${ICON.bell}</button>
- <button class="navbtn" title="Inbox"><span class="badge">2</span>${ICON.inbox}</button>
- <button class="avatar-btn"><span class="ava ${me.color}" style="width:100%;height:100%;border-radius:0;font-size:15px">${me.initials}</span></button>
+ <button class="navbtn" title="Notifications" data-panel="notifications"><span class="badge" data-badge="notifications" style="display:none"></span>${ICON.bell}</button>
+ <button class="navbtn" title="Inbox" data-panel="inbox">${ICON.inbox}</button>
+ <button class="avatar-btn" data-avatar><span class="ava ${me.color}" style="width:100%;height:100%;border-radius:0;font-size:15px">${me.initials}</span></button>
</nav>
</header>`;
}
@@ -41,20 +41,20 @@ function leftRail(me){
<div class="menu">
<a class="${PATH==='/'?'active':''}" href="/">${ICON.house}Block Feed</a>
<a class="${PATH.indexOf('/opportunities')===0?'active':''}" href="/opportunities">${ICON.opps}Opp Alerts<span class="pill">12</span></a>
- <a href="#">${ICON.bell}Notifications<span class="pill">5</span></a>
- <a href="#">${ICON.sugar}Cup of Sugar</a>
- <a href="#">${ICON.cal}Block Events</a>
+ <a class="${PATH==='/notifications'?'active':''}" href="/notifications">${ICON.bell}Notifications</a>
+ <a class="${PATH==='/groups'?'active':''}" href="/groups">${ICON.sugar}Groups</a>
+ <a class="${PATH==='/events'?'active':''}" href="/events">${ICON.cal}Block Events</a>
<a href="https://wholivedthere.com">${ICON.history}Who Lived Here</a>
- <a href="#">${ICON.book}Bookmarks</a>
+ <a class="${PATH==='/bookmarks'?'active':''}" href="/bookmarks">${ICON.book}Bookmarks</a>
</div>
<div class="menu">
<div class="grouphd">Your Block · Beekman Ave</div>
<div class="grp">
- <a href="#"><span class="dot" style="background:#3a7d5d">🥧</span> Babka & Bagel Swap</a>
- <a href="#"><span class="dot" style="background:#b8862f">🔧</span> Tools & Ladders to Borrow</a>
- <a href="#"><span class="dot" style="background:#5566b5">🐕</span> Block Dog Walkers</a>
- <a href="#"><span class="dot" style="background:#c2557a">🕯️</span> Shabbat Dinner Club</a>
- <a href="#"><span class="dot" style="background:#a05236">🌿</span> Holiday & Sukkah Help</a>
+ <a href="/groups"><span class="dot" style="background:#3a7d5d">🥧</span> Babka & Bagel Swap</a>
+ <a href="/groups"><span class="dot" style="background:#b8862f">🔧</span> Tools & Ladders to Borrow</a>
+ <a href="/groups"><span class="dot" style="background:#5566b5">🐕</span> Block Dog Walkers</a>
+ <a href="/groups"><span class="dot" style="background:#c2557a">🕯️</span> Shabbat Dinner Club</a>
+ <a href="/groups"><span class="dot" style="background:#a05236">🌿</span> Holiday & Sukkah Help</a>
</div>
</div>
</aside>`;
@@ -94,11 +94,11 @@ function reactStack(reactions){
const cls=['#f3603c','#f7b500','#00874b','#5566b5'];
return `<div class="react">${reactions.map((r,i)=>`<span style="background:${cls[i%cls.length]}">${r}</span>`).join('')}</div>`;
}
-function actionBar(){
+function actionBar(id, youThanked, thanks){
return `<div class="actions">
- <button class="on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>Thank</button>
- <button><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9 9 0 0 1-4-1L3 20l1.2-4A8.4 8.4 0 1 1 21 11.5z"/></svg>Reply</button>
- <button><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8h16v-8"/><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/></svg>Share</button>
+ <button class="${youThanked?'on':''}" data-react="post:${id}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>Thank <span class="cnt">${thanks||''}</span></button>
+ <button data-reply="${id}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9 9 0 0 1-4-1L3 20l1.2-4A8.4 8.4 0 1 1 21 11.5z"/></svg>Reply</button>
+ <button data-share="${id}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8h16v-8"/><path d="M12 16V4"/><path d="M8 8l4-4 4 4"/></svg>Share</button>
</div>`;
}
function postHead(p){
@@ -107,7 +107,7 @@ function postHead(p){
<div class="who"><b>${p.author}</b>
<div class="meta"><span class="gd">${p.address}</span> · ${p.time}${p.edited?' · <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg> Edited':''}</div>
</div>
- <button class="more"><svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg></button>
+ <button class="more" data-more="post:${p.id}"><svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg></button>
</div>`;
}
function postBody(p){
diff --git a/public/content.html b/public/content.html
new file mode 100644
index 0000000..ca71631
--- /dev/null
+++ b/public/content.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock</title><link rel="stylesheet" href="/styles.css" />
+</head><body>
+<div id="hdr"></div>
+<div class="wrap"><div id="left"></div><main class="col-center" id="main"></main><div id="right"></div></div>
+<script>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+const page = location.pathname.replace('/','') || 'about';
+const C = {
+ about: ['About BubbesBlock', `<p>BubbesBlock is your block, the warm way — a neighborhood community where neighbors trade a cup of sugar, host Shabbat dinners, raise a sukkah, find a lost cat, and discover who lived in their home before them.</p><p>It's the community corner of the home-history family: <a href="https://wholivedthere.com" target="_blank">wholivedthere.com</a> tells your home's story and <a href="https://claimmyaddress.com" target="_blank">claimmyaddress.com</a> verifies you live here.</p><p>Local pros get <b>Opportunity Alerts</b> — real leads when a neighbor needs wallcoverings, decor, drapery, or window treatments.</p>`],
+ guidelines: ['Block Guidelines', `<p>Be a good neighbor. Keep it kind, keep it local, keep it real.</p><p>1. Real neighbors only — claim your address to post.<br>2. No spam, no resale of leads, no harassment.<br>3. Lost & found, favors, events, and recommendations welcome.<br>4. Businesses: respond to opportunities honestly and once.</p>`],
+ privacy: ['Privacy', `<p>BubbesBlock stores only what you share to make the block work: your name, claimed address, posts, comments, and reactions.</p><p>Home-history lookups use public records via wholivedthere.com. We never sell your data.</p>`],
+ help: ['Help', `<p>Need a hand? Common things:</p><p>• <b>Post</b> — tap "Share something with the block" (claim your address first).<br>• <b>Claim your address</b> — verifies you live here; one tap.<br>• <b>Opportunity Alerts</b> — leads matched to your services.<br>• Questions? Email <a href="mailto:info@bubbesblock.com">info@bubbesblock.com</a>.</p>`]
+};
+const c = C[page] || C.about;
+fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()).then(m=>{
+ document.getElementById('hdr').innerHTML=topbar(m.me);
+ document.getElementById('left').innerHTML=leftRail(m.me);
+ document.getElementById('right').innerHTML=rightRail(m.me);
+ document.getElementById('main').innerHTML=`<div class="prose"><h1>${c[0]}</h1>${c[1]}</div>`;
+ BB.init();
+});
+</script></body></html>
diff --git a/public/events.html b/public/events.html
new file mode 100644
index 0000000..b9c1bd6
--- /dev/null
+++ b/public/events.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Block Events</title><link rel="stylesheet" href="/styles.css" />
+</head><body>
+<div id="hdr"></div>
+<div class="wrap"><div id="left"></div><main class="col-center" id="main"></main><div id="right"></div></div>
+<script>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+function tile(e){
+ return `<div class="tile" data-ev="${e.id}">
+ <span class="lg" style="background:var(--green)">${e.emoji||'📅'}</span>
+ <div class="main"><b>${e.title}</b><div class="meta2">${e.when_label} · ${e.location} · <span class="going">${e.going} going</span></div>
+ <div class="meta2" style="margin-top:4px">${e.body||''}</div></div>
+ <div class="act"><button class="${e.youGoing?'on':''}" data-ev-btn="${e.id}">${e.youGoing?'Going ✓':'RSVP'}</button></div>
+ </div>`;
+}
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/events',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+ document.getElementById('hdr').innerHTML=topbar(m.me);
+ document.getElementById('left').innerHTML=leftRail(m.me);
+ document.getElementById('right').innerHTML=rightRail(m.me);
+ document.getElementById('main').innerHTML='<div class="listhd">🎉 Block Events</div>'+(d.events||[]).map(tile).join('');
+ BB.init();
+});
+document.addEventListener('click', async e=>{
+ const b=e.target.closest('[data-ev-btn]'); if(!b) return;
+ if(!(await BB.ensureAuth())) return;
+ try{ const r=await BB.api.post(`/api/events/${b.dataset.evBtn}/rsvp`,{});
+ b.classList.toggle('on',r.going); b.textContent=r.going?'Going ✓':'RSVP';
+ const g=b.closest('.tile').querySelector('.going'); if(g) g.textContent=r.count+' going';
+ }catch{ BB.toast('Try again'); }
+});
+</script></body></html>
diff --git a/public/groups.html b/public/groups.html
new file mode 100644
index 0000000..fb3ef22
--- /dev/null
+++ b/public/groups.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Groups</title><link rel="stylesheet" href="/styles.css" />
+</head><body>
+<div id="hdr"></div>
+<div class="wrap"><div id="left"></div><main class="col-center" id="main"></main><div id="right"></div></div>
+<script>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+function tile(g){
+ return `<div class="tile" data-grp="${g.slug}">
+ <span class="lg" style="background:${g.color||'#3a7d5d'}">${g.emoji||'👥'}</span>
+ <div class="main"><b>${g.name}</b><div class="meta2"><span class="mem">${g.members}</span> members</div>
+ <div class="meta2" style="margin-top:4px">${g.blurb||''}</div></div>
+ <div class="act"><button class="${g.youJoined?'on':''}" data-join="${g.slug}">${g.youJoined?'Joined ✓':'Join'}</button></div>
+ </div>`;
+}
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/groups',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+ document.getElementById('hdr').innerHTML=topbar(m.me);
+ document.getElementById('left').innerHTML=leftRail(m.me);
+ document.getElementById('right').innerHTML=rightRail(m.me);
+ document.getElementById('main').innerHTML='<div class="listhd">👥 Your Block · Groups</div>'+(d.groups||[]).map(tile).join('');
+ BB.init();
+});
+document.addEventListener('click', async e=>{
+ const b=e.target.closest('[data-join]'); if(!b) return;
+ if(!(await BB.ensureAuth())) return;
+ try{ const r=await BB.api.post(`/api/groups/${b.dataset.join}/join`,{});
+ b.classList.toggle('on',r.joined); b.textContent=r.joined?'Joined ✓':'Join';
+ const m=b.closest('.tile').querySelector('.mem'); if(m) m.textContent=r.members;
+ }catch{ BB.toast('Try again'); }
+});
+</script></body></html>
diff --git a/public/inbox.html b/public/inbox.html
new file mode 100644
index 0000000..51f7667
--- /dev/null
+++ b/public/inbox.html
@@ -0,0 +1,41 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Inbox</title><link rel="stylesheet" href="/styles.css" />
+<style>
+.thread{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:14px;margin-top:12px}
+.msg{max-width:75%;padding:9px 13px;border-radius:16px;margin:6px 0;font-size:14.5px;line-height:1.4}
+.msg.them{background:#f0f2f4}
+.msg.me{background:var(--green);color:#fff;margin-left:auto}
+.threadbox{display:flex;gap:8px;margin-top:10px}
+.threadbox input{flex:1;border:1px solid var(--line);border-radius:22px;padding:10px 15px;outline:none}
+.threadbox button{background:var(--green);color:#fff;font-weight:700;padding:0 18px;border-radius:22px}
+</style>
+</head><body>
+<div id="hdr"></div>
+<div class="wrap"><div id="left"></div><main class="col-center" id="main"></main><div id="right"></div></div>
+<script>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+let convId=null;
+async function openThread(id, peer){
+ convId=id;
+ const r=await BB.api.get('/api/inbox/'+id);
+ document.getElementById('thread').innerHTML=`<b>${peer}</b>`+r.messages.map(m=>`<div class="msg ${m.from_me?'me':'them'}">${m.body}</div>`).join('')+
+ `<div class="threadbox"><input id="bb-msg" placeholder="Message…" /><button id="bb-msend">Send</button></div>`;
+ const inp=document.getElementById('bb-msg'); const send=async()=>{ const b=inp.value.trim(); if(!b) return; await BB.api.post(`/api/inbox/${id}/messages`,{body:b}); openThread(id,peer); };
+ document.getElementById('bb-msend').onclick=send; inp.addEventListener('keydown',e=>{if(e.key==='Enter')send();});
+}
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/inbox',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+ document.getElementById('hdr').innerHTML=topbar(m.me);
+ document.getElementById('left').innerHTML=leftRail(m.me);
+ document.getElementById('right').innerHTML=rightRail(m.me);
+ let body;
+ if(!m.you){ body='<div class="prose"><h1>✉️ Messages</h1><p>Sign in to see your inbox.</p><button class="bb-btn" style="max-width:220px" onclick="BB.ensureAuth().then(()=>location.reload())">Sign in</button></div>'; }
+ else if(!d.conversations.length){ body='<div class="prose"><h1>✉️ Messages</h1><p>Your inbox is empty. Respond to an Opportunity Alert to start a conversation.</p></div>'; }
+ else { body='<div class="listhd">✉️ Messages</div>'+d.conversations.map(c=>`<div class="tile" data-conv="${c.id}" data-peer="${c.peer}"><span class="ava ${c.peer_color||'a3'}" style="width:48px;height:48px;font-size:17px">${c.peer_initials||'?'}</span><div class="main"><b>${c.peer}</b><div class="meta2">${(c.last||'').slice(0,60)}</div></div></div><div id="thread-${c.id}"></div>`).join('')+'<div class="thread" id="thread" style="display:none"></div>'; }
+ document.getElementById('main').innerHTML=body;
+ BB.init();
+});
+document.addEventListener('click', e=>{ const t=e.target.closest('[data-conv]'); if(!t) return; const th=document.getElementById('thread'); th.style.display='block'; openThread(t.dataset.conv, t.dataset.peer); th.scrollIntoView({behavior:'smooth'}); });
+</script></body></html>
diff --git a/public/index.html b/public/index.html
index ec674b1..3ba2a5d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -15,33 +15,31 @@
<div id="right"></div>
</div>
+<script>window.__bbAutoInit = false;</script>
<script src="/chrome.js"></script>
+<script src="/app.js"></script>
<script>
-fetch('/api/posts').then(r=>r.json()).then(d=>{
+fetch('/api/posts', { credentials: 'same-origin' }).then(r=>r.json()).then(d=>{
document.getElementById('hdr').innerHTML = topbar(d.me);
document.getElementById('left').innerHTML = leftRail(d.me);
document.getElementById('right').innerHTML = rightRail(d.me);
const newpost = `<div class="newpost">
<span class="ava ${d.me.color}" style="width:42px;height:42px;font-size:15px">${d.me.initials}</span>
- <div class="fake">Share something with the block, ${d.me.name.split(' ')[0]}…</div>
+ <div class="fake" data-composer>Share something with the block, ${d.me.name.split(' ')[0]}…</div>
</div>`;
const cards = d.posts.map(p=>`
- <article class="card feedcard" onclick="location.href='/p/${p.id}'">
- <div class="post">
- ${postHead(p)}
- ${postBody(p)}
- </div>
+ <article class="card feedcard" onclick="if(!event.target.closest('button,a,[data-react],[data-share],[data-reply],.actions,.more')) location.href='/p/${p.id}'">
+ <div class="post">${postHead(p)}${postBody(p)}</div>
${photoBlock(p.photo)}
${statRow(p, p.commentCount)}
- ${actionBar()}
+ ${actionBar(p.id, p.youThanked, p.thanks)}
</article>`).join('');
document.getElementById('feed').innerHTML = newpost + cards;
-}).catch(e=>{
- document.getElementById('feed').innerHTML = '<div class="card"><div class="post">Could not load the block feed.</div></div>';
-});
+ BB.init();
+}).catch(()=>{ document.getElementById('feed').innerHTML = '<div class="card"><div class="post">Could not load the block feed.</div></div>'; });
</script>
</body>
</html>
diff --git a/public/notifications.html b/public/notifications.html
new file mode 100644
index 0000000..04a6b5b
--- /dev/null
+++ b/public/notifications.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Notifications</title><link rel="stylesheet" href="/styles.css" />
+</head><body>
+<div id="hdr"></div>
+<div class="wrap"><div id="left"></div><main class="col-center" id="main"></main><div id="right"></div></div>
+<script>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+Promise.all([fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()), fetch('/api/notifications',{credentials:'same-origin'}).then(r=>r.json())]).then(([m,d])=>{
+ document.getElementById('hdr').innerHTML=topbar(m.me);
+ document.getElementById('left').innerHTML=leftRail(m.me);
+ document.getElementById('right').innerHTML=rightRail(m.me);
+ let body;
+ if(!m.you){ body='<div class="prose"><h1>🔔 Notifications</h1><p>Sign in to see your notifications.</p><button class="bb-btn" style="max-width:220px" onclick="BB.ensureAuth().then(()=>location.reload())">Sign in</button></div>'; }
+ else if(!d.items.length){ body='<div class="prose"><h1>🔔 Notifications</h1><p>Nothing new yet — check back after the next block party.</p></div>'; }
+ else { body='<div class="listhd">🔔 Notifications</div>'+d.items.map(n=>`<a class="tile ${n.read?'':'unread'}" href="${n.link||'#'}" style="${n.read?'':'background:var(--green-tint)'}"><span class="lg" style="background:var(--plum)">${({react:'❤️',comment:'💬',opportunity:'💼',event:'📅'})[n.type]||'🔔'}</span><div class="main"><b>${n.actor}</b> <span class="meta2" style="display:inline">${n.text}</span></div></a>`).join(''); }
+ document.getElementById('main').innerHTML=body;
+ BB.init();
+ if(m.you && d.items.length) BB.api.post('/api/notifications/read',{}).then(()=>BB.updateBadges());
+});
+</script></body></html>
diff --git a/public/opportunities.html b/public/opportunities.html
index d549336..d24e6e9 100644
--- a/public/opportunities.html
+++ b/public/opportunities.html
@@ -15,40 +15,50 @@
<div id="right"></div>
</div>
+<script>window.__bbAutoInit = false;</script>
<script src="/chrome.js"></script>
+<script src="/app.js"></script>
<script>
const lockSvg = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
const pinSvg = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-7-5.2-7-11a7 7 0 0 1 14 0c0 5.8-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/></svg>';
+function respCta(o){
+ return o.youResponded
+ ? `<button class="go done" disabled>Responded ✓</button>`
+ : `<button class="go" data-respond="${o.id}" data-neighbor="${o.neighbor}">Respond</button>`;
+}
function oppCard(o){
- const cta = o.locked
- ? `<button class="lock">${lockSvg} Unlock</button>`
- : `<button class="go">Respond</button>`;
- // wholivedthere enrichment hook — links the lead's address into the home-history flow
+ const action = o.locked ? `<button class="lock" data-unlock="${o.id}">${lockSvg} Unlock</button>` : respCta(o);
const wlt = o.address
- ? `<a class="wlt" href="https://wholivedthere.com/?address=${encodeURIComponent(o.address)}" target="_blank">🏚️ See who lived at ${o.area} →</a>`
+ ? `<button class="wlt" data-wlt="${encodeURIComponent(o.address)}">🏚️ See who lived at ${o.area} →</button><div class="wlt-mount"></div>`
: '';
- return `<div class="opp ${o.forYou?'you':''}">
+ return `<div class="opp ${o.forYou?'you':''}" data-opp="${o.id}" data-neighbor="${o.neighbor}">
<span class="ava ${o.color}">${o.initials}</span>
<div class="main">
- <div class="toprow">
- <span class="who2">${o.neighbor}</span>
- <span class="cat2">${o.category}</span>
- ${o.forYou?'<span class="youtag">★ For you</span>':''}
- </div>
+ <div class="toprow"><span class="who2">${o.neighbor}</span><span class="cat2">${o.category}</span>${o.forYou?'<span class="youtag">★ For you</span>':''}</div>
<div class="body2">${o.body}</div>
${wlt}
<div class="foot">
- <span class="loc">${pinSvg} ${o.area}</span>
- <span>· ${o.time}</span>
- <span>· ${o.responses} responded</span>
- <span class="resp">${cta}</span>
+ <span class="loc">${pinSvg} ${o.area}</span><span>· ${o.time}</span>
+ <span class="rc">· ${o.responses} responded</span>
+ <span class="resp">${action}</span>
</div>
</div>
</div>`;
}
+function renderHistory(h){
+ if(!h || h.source==='unavailable') return `<div class="wlt-panel"><h5>🏚️ ${h.normalized||h.address||''}</h5><p class="res">Full home history isn't in yet. <a href="${h.link||'https://wholivedthere.com'}" target="_blank">Search wholivedthere.com →</a></p></div>`;
+ const res = (h.residents||[]).map(r=>`${r.name}${r.years?` (${r.years})`:''}`).join(' · ');
+ return `<div class="wlt-panel"><h5>🏚️ ${h.normalized||h.address}</h5>
+ ${h.summary?`<p>${h.summary}</p>`:''}
+ ${h.builtYear?`<p class="res">Built ${h.builtYear}${h.sqft?` · ${h.sqft} sqft`:''}${h.beds?` · ${h.beds}bd/${h.baths||'?'}ba`:''}</p>`:''}
+ ${h.lastSold?`<p class="res">Last sold ${h.lastSold.date||''}${h.lastSold.price?` · ${h.lastSold.price}`:''}</p>`:''}
+ ${res?`<p class="res">Past residents: ${res}</p>`:''}
+ <a class="wlt" href="https://wholivedthere.com/?address=${encodeURIComponent(h.address||'')}" target="_blank">Full record on wholivedthere.com →</a>
+ </div>`;
+}
-fetch('/api/opportunities').then(r=>r.json()).then(d=>{
+fetch('/api/opportunities', { credentials:'same-origin' }).then(r=>r.json()).then(d=>{
document.getElementById('hdr').innerHTML = topbar(d.me);
document.getElementById('left').innerHTML = leftRail(d.me);
document.getElementById('right').innerHTML = rightRail(d.me);
@@ -56,19 +66,34 @@ fetch('/api/opportunities').then(r=>r.json()).then(d=>{
const sa = d.serviceArea || {};
const chips = (d.categoriesForYou||[]).map(c=>`<span>${c}</span>`).join('');
const forYou = d.opportunities.filter(o=>o.forYou).length;
-
const hero = `<div class="opp-hero">
<h2>Opportunity Alerts</h2>
<p>Real-time leads when neighbors near you ask for services like yours.</p>
<div class="area">${pinSvg} ${sa.radius_miles||20}-mile radius · ${sa.center||''}</div>
- <div class="opp-chips">${chips}</div>
- </div>`;
-
+ <div class="opp-chips">${chips}</div></div>`;
const count = `<div class="opp-count">${d.opportunities.length} open opportunities · ${forYou} matched to your services</div>`;
- const cards = d.opportunities.map(oppCard).join('');
- document.getElementById('main').innerHTML = hero + count + cards;
-}).catch(()=>{
- document.getElementById('main').innerHTML = '<div class="card"><div class="post">Could not load opportunities.</div></div>';
+ document.getElementById('main').innerHTML = hero + count + d.opportunities.map(oppCard).join('');
+ BB.init();
+}).catch(()=>{ document.getElementById('main').innerHTML = '<div class="card"><div class="post">Could not load opportunities.</div></div>'; });
+
+// inline home-history toggle
+document.addEventListener('click', async e=>{
+ const b = e.target.closest('[data-wlt]'); if(!b) return;
+ const mount = b.nextElementSibling;
+ if(b.dataset.open){ mount.innerHTML=''; delete b.dataset.open; b.textContent = b.textContent.replace('Hide','See'); return; }
+ b.dataset.open='1'; mount.innerHTML='<div class="wlt-panel loading">Looking up home history…</div>';
+ try{ const h = await BB.api.get('/api/home-history?address='+b.dataset.wlt); mount.innerHTML = renderHistory(h); }
+ catch{ mount.innerHTML='<div class="wlt-panel">Could not load history.</div>'; }
+});
+// reflect respond/unlock results
+document.addEventListener('opp-responded', e=>{
+ const card = document.querySelector(`.opp[data-opp="${e.detail.id}"]`); if(!card) return;
+ const rc = card.querySelector('.rc'); if(rc) rc.textContent = `· ${e.detail.responses} responded`;
+ card.querySelector('.resp').innerHTML = `<button class="go done" disabled>Responded ✓</button>`;
+});
+document.addEventListener('opp-unlocked', e=>{
+ const card = document.querySelector(`.opp[data-opp="${e.detail.id}"]`); if(!card) return;
+ card.querySelector('.resp').innerHTML = `<button class="go" data-respond="${e.detail.id}" data-neighbor="${card.dataset.neighbor||''}">Respond</button>`;
});
</script>
</body>
diff --git a/public/post.html b/public/post.html
index 0981cdf..9f1e2cc 100644
--- a/public/post.html
+++ b/public/post.html
@@ -14,25 +14,26 @@
<div id="right"></div>
</div>
+<script>window.__bbAutoInit = false;</script>
<script src="/chrome.js"></script>
+<script src="/app.js"></script>
<script>
-// id from /p/:id path, or ?id= fallback
const parts = location.pathname.split('/').filter(Boolean);
const id = (parts[0]==='p' && parts[1]) ? parts[1] : new URLSearchParams(location.search).get('id');
-function comment(c){
+// global so app.js can append a freshly-posted comment
+window.renderComment = function (c){
return `<div class="cmt${c.reply?' reply':''}">
<span class="ava ${c.color}" style="width:36px;height:36px;font-size:${c.reply?13:14}px">${c.initials}</span>
<div>
- <div class="bub"><b>${c.author}</b> <span class="nb">· ${c.nb}</span><p>${c.body}</p></div>
- <div class="meta"><span>${c.time}</span><a>Thank · ${c.thanks}</a><a>Reply</a></div>
+ <div class="bub"><b>${c.author}</b> <span class="nb">· ${c.nb||''}</span><p>${c.body}</p></div>
+ <div class="meta"><span>${c.time}</span><a data-react="comment:${c.id}">Thank <span class="cnt">${c.thanks||''}</span></a><a data-reply="${id}">Reply</a></div>
</div>
</div>`;
-}
+};
-fetch('/api/posts/'+encodeURIComponent(id)).then(r=>{
- if(!r.ok) throw new Error('not found');
- return r.json();
+fetch('/api/posts/'+encodeURIComponent(id), { credentials:'same-origin' }).then(r=>{
+ if(!r.ok) throw new Error('not found'); return r.json();
}).then(d=>{
const p = d.post;
document.title = `BubbesBlock — ${p.author}`;
@@ -40,8 +41,7 @@ fetch('/api/posts/'+encodeURIComponent(id)).then(r=>{
document.getElementById('left').innerHTML = leftRail(d.me);
document.getElementById('right').innerHTML = rightRail(d.me);
- const cmts = (p.comments||[]).map(comment).join('');
- const more = p.moreComments ? `<div class="morecmt">View ${p.moreComments} more comments</div>` : '';
+ const cmts = (p.comments||[]).map(window.renderComment).join('');
document.getElementById('main').innerHTML = `
<div class="backbar"><a href="/">
@@ -51,19 +51,32 @@ fetch('/api/posts/'+encodeURIComponent(id)).then(r=>{
<div class="post">${postHead(p)}${postBody(p)}</div>
${photoBlock(p.photo)}
${statRow(p, (p.comments?p.comments.length:0)+(p.moreComments||0))}
- ${actionBar()}
+ ${actionBar(p.id, p.youThanked, p.thanks)}
<div class="comments">
<div class="compose">
<span class="ava ${d.me.color}" style="width:36px;height:36px;font-size:13px">${d.me.initials}</span>
<div class="field">
- <input placeholder="Add a neighborly reply…" />
- <button title="Photo"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M21 17l-5-5-9 7"/></svg></button>
- <button title="Send"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg></button>
+ <input id="bb-comment-input" placeholder="Add a neighborly reply…" />
+ <button id="bb-comment-send" title="Send"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg></button>
</div>
</div>
- ${cmts}${more}
+ <div id="bb-comments">${cmts}</div>
+ ${p.moreComments ? `<div class="morecmt" id="bb-morecmt">View ${p.moreComments} more comments</div>` : ''}
</div>
</article>`;
+
+ // wire comment send (button + Enter)
+ const input = document.getElementById('bb-comment-input');
+ const send = () => BB.sendComment(id, input, false);
+ document.getElementById('bb-comment-send').onclick = send;
+ input.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });
+ const mc = document.getElementById('bb-morecmt');
+ if (mc) mc.onclick = async () => {
+ const r = await BB.api.get(`/api/posts/${id}/comments`);
+ document.getElementById('bb-comments').innerHTML = r.comments.map(window.renderComment).join('');
+ mc.remove();
+ };
+ BB.init();
}).catch(()=>{
document.getElementById('hdr').innerHTML = '';
document.getElementById('main').innerHTML = '<div class="card"><div class="post"><b>Post not found.</b><br><a href="/" style="color:var(--green-dark);font-weight:700">← Back to the block feed</a></div></div>';
diff --git a/public/search.html b/public/search.html
new file mode 100644
index 0000000..0360ce9
--- /dev/null
+++ b/public/search.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en"><head>
+<meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<title>BubbesBlock — Search</title><link rel="stylesheet" href="/styles.css" />
+</head><body>
+<div id="hdr"></div>
+<div class="wrap"><div id="left"></div><main class="col-center" id="main"></main><div id="right"></div></div>
+<script>window.__bbAutoInit = false;</script>
+<script src="/chrome.js"></script><script src="/app.js"></script>
+<script>
+const term = new URLSearchParams(location.search).get('q') || '';
+Promise.all([
+ fetch('/api/me',{credentials:'same-origin'}).then(r=>r.json()),
+ fetch('/api/search?q='+encodeURIComponent(term),{credentials:'same-origin'}).then(r=>r.json())
+]).then(([m,d])=>{
+ document.getElementById('hdr').innerHTML=topbar(m.me);
+ document.getElementById('left').innerHTML=leftRail(m.me);
+ document.getElementById('right').innerHTML=rightRail(m.me);
+ const sec=(title,rows)=> rows.length?`<div class="listhd" style="font-size:16px">${title}</div>${rows.join('')}`:'';
+ const posts=(d.posts||[]).map(p=>`<a class="tile" href="/p/${p.id}"><span class="lg" style="background:var(--plum)">📣</span><div class="main"><b>${p.category||'Post'}</b><div class="meta2">${(Array.isArray(p.body)?p.body[0]:p.body||'').slice(0,90)}</div></div></a>`);
+ const opps=(d.opportunities||[]).map(o=>`<a class="tile" href="/opportunities"><span class="lg" style="background:var(--green)">💼</span><div class="main"><b>${o.category}</b><div class="meta2">${o.body.slice(0,90)}</div></div></a>`);
+ const nb=(d.neighbors||[]).map(n=>`<div class="tile"><span class="ava ${n.color||'a1'}" style="width:48px;height:48px;font-size:17px">${n.initials||'?'}</span><div class="main"><b>${n.name}</b><div class="meta2">${n.address||'neighbor'}</div></div></div>`);
+ const any = posts.length+opps.length+nb.length;
+ document.getElementById('main').innerHTML=`<div class="listhd">Search${term?`: “${term}”`:''}</div>`+
+ (any? sec('Posts',posts)+sec('Opportunities',opps)+sec('Neighbors',nb) : '<div class="prose"><h1>No matches</h1><p>Try a different term.</p></div>');
+ BB.init();
+ const si=document.querySelector('.search input'); if(si) si.value=term;
+});
+</script></body></html>
diff --git a/public/styles.css b/public/styles.css
index d676f6f..fbbc317 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -177,3 +177,64 @@ nav.topnav{margin-left:auto;display:flex;align-items:center;gap:6px}
.opp .resp .lock:hover{background:#e7e9ec}
.opp .wlt{display:block;margin-top:8px;font-size:12.5px;font-weight:600;color:var(--green-dark)}
.opp .wlt:hover{text-decoration:underline}
+
+/* ---------- interaction layer: toast / modal / dropdowns / buttons ---------- */
+#bb-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%) translateY(20px);background:#1b1b1b;color:#fff;
+ padding:11px 20px;border-radius:24px;font-size:14px;font-weight:600;z-index:300;opacity:0;pointer-events:none;transition:.25s;box-shadow:0 6px 24px rgba(0,0,0,.25)}
+#bb-toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
+.bb-modal-back{position:fixed;inset:0;background:rgba(20,12,18,.55);z-index:250;display:grid;place-items:center;padding:18px;animation:bbfade .15s}
+@keyframes bbfade{from{opacity:0}to{opacity:1}}
+.bb-modal{background:#fff;border-radius:16px;width:100%;max-width:440px;box-shadow:0 20px 60px rgba(0,0,0,.3);overflow:hidden;animation:bbpop .18s}
+@keyframes bbpop{from{transform:translateY(10px) scale(.98);opacity:.6}to{transform:none;opacity:1}}
+.bb-modal-hd{display:flex;align-items:center;justify-content:space-between;padding:15px 18px;border-bottom:1px solid var(--line)}
+.bb-modal-hd b{font-size:16px}
+.bb-x{font-size:24px;color:var(--muted);width:32px;height:32px;border-radius:50%;line-height:1}
+.bb-x:hover{background:#f0f2f4}
+.bb-modal-bd{padding:18px}
+.bb-modal-bd .bb-sub{margin:0 0 14px;color:var(--muted);font-size:14px}
+.bb-modal-bd input,.bb-modal-bd textarea,.bb-modal-bd select,.bb-input{width:100%;border:1px solid var(--line);border-radius:10px;padding:11px 13px;font-size:15px;font-family:inherit;outline:none;margin-bottom:12px;background:#fff}
+.bb-modal-bd input:focus,.bb-modal-bd textarea:focus,.bb-modal-bd select:focus{border-color:var(--green)}
+.bb-modal-bd textarea{resize:vertical}
+.bb-btn{width:100%;background:var(--green);color:#fff;font-weight:800;font-size:15px;padding:12px;border-radius:24px}
+.bb-btn:hover{background:var(--green-dark)}
+.bb-fine{display:block;margin:12px 0 0;font-size:12px;color:var(--muted);text-align:center}
+a.bb-fine:hover{text-decoration:underline}
+/* dropdown popovers (notifications, inbox, avatar, more) */
+.bb-dd-pop{position:fixed;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 14px 40px rgba(0,0,0,.18);z-index:240;min-width:300px;max-width:360px;max-height:70vh;overflow:auto;padding:6px}
+.bb-dd-hd{display:flex;align-items:center;justify-content:space-between;font-weight:800;font-size:13px;padding:9px 11px;color:var(--ink)}
+.bb-dd-hd button{font-size:12px;font-weight:700;color:var(--green)}
+.bb-noti{display:block;padding:10px 11px;border-radius:9px;font-size:13.5px;color:#3a3b3c;line-height:1.4}
+.bb-noti:hover{background:#f0f2f4}
+.bb-noti.unread{background:var(--green-tint)}
+.bb-noti b{color:var(--ink)}
+.bb-noti span{color:var(--muted)}
+.bb-empty{padding:16px;text-align:center;color:var(--muted);font-size:13px}
+/* search dropdown */
+.search{position:relative}
+.bb-search-dd{position:absolute;top:46px;left:0;right:0;background:#fff;border:1px solid var(--line);border-radius:12px;box-shadow:0 14px 40px rgba(0,0,0,.16);z-index:60;overflow:hidden;display:none}
+.bb-search-dd.show{display:block}
+.bb-search-dd a{display:block;padding:10px 14px;font-size:13.5px;border-bottom:1px solid var(--line);color:#3a3b3c}
+.bb-search-dd a:last-child{border-bottom:none}
+.bb-search-dd a:hover{background:#f0f2f4}
+/* reaction/thank "on" state + counts */
+.actions button.on,.cmt .meta a.on{color:var(--green)}
+.actions .cnt{font-weight:700}
+.opp .resp .go.done{background:#eef0f2;color:var(--muted)}
+/* inline home-history panel */
+.wlt-panel{margin-top:9px;background:#faf6f0;border:1px solid var(--line);border-radius:10px;padding:11px 13px;font-size:13px}
+.wlt-panel h5{margin:0 0 5px;font-size:13px;color:var(--green-dark)}
+.wlt-panel .res{color:var(--muted)}
+.wlt-panel .loading{color:var(--muted)}
+/* generic list pages (events/groups/bookmarks/search/content) */
+.listhd{font-size:22px;font-weight:800;margin:2px 2px 14px}
+.tile{display:flex;gap:13px;align-items:center;background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:15px 16px;margin-bottom:12px}
+.tile .lg{width:48px;height:48px;border-radius:12px;display:grid;place-items:center;font-size:22px;flex:none;color:#fff}
+.tile .main{flex:1;min-width:0}
+.tile b{font-size:15px}
+.tile .meta2{font-size:13px;color:var(--muted);margin-top:2px}
+.tile .act{margin-left:auto}
+.tile .act button{font-weight:700;font-size:13px;padding:8px 16px;border-radius:20px;background:var(--green);color:#fff}
+.tile .act button.on{background:#eef0f2;color:var(--muted)}
+.prose{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:24px 26px}
+.prose h1{margin:0 0 12px;font-size:24px}
+.prose p{color:#333;line-height:1.6;margin:0 0 12px}
diff --git a/scripts/migrate.js b/scripts/migrate.js
index 3c8734f..4be35fe 100644
--- a/scripts/migrate.js
+++ b/scripts/migrate.js
@@ -75,9 +75,61 @@ const seed = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'post
}
}
+ // ---- users: me + every author/neighbor seen across content (idempotent) ----
+ const uid = name => 'u-' + String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
+ const users = new Map();
+ const addUser = (name, initials, color, address, verified) => {
+ if (!name) return; const id = uid(name);
+ if (!users.has(id)) users.set(id, { id, name, initials: initials || name.slice(0, 2).toUpperCase(), color: color || 'a1', address: address || null, verified: !!verified });
+ };
+ const me = seed.me; addUser(me.name, me.initials, me.color, me.address, me.verified);
+ for (const p of seed.posts) { addUser(p.author, p.initials, p.color, p.address, true); for (const m of (p.comments || [])) addUser(m.author, m.initials, m.color, null, true); }
+ if (fs.existsSync(path.join(__dirname, '..', 'data', 'opportunities.json'))) {
+ const od = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'opportunities.json'), 'utf8'));
+ for (const o of (od.opportunities || [])) addUser(o.neighbor, o.initials, o.color, o.address, true);
+ }
+ for (const u of users.values()) {
+ await c.query(`INSERT INTO users(id,name,initials,color,address,verified) VALUES($1,$2,$3,$4,$5,$6)
+ ON CONFLICT(id) DO UPDATE SET name=EXCLUDED.name,initials=EXCLUDED.initials,color=EXCLUDED.color,
+ address=COALESCE(EXCLUDED.address,users.address),verified=users.verified OR EXCLUDED.verified`,
+ [u.id, u.name, u.initials, u.color, u.address, u.verified]);
+ }
+
+ // ---- groups + events ----
+ const commPath = path.join(__dirname, '..', 'data', 'community.json');
+ if (fs.existsSync(commPath)) {
+ const cm = JSON.parse(fs.readFileSync(commPath, 'utf8'));
+ for (let i = 0; i < (cm.groups || []).length; i++) { const g = cm.groups[i];
+ await c.query(`INSERT INTO groups(slug,name,emoji,color,members,blurb,position) VALUES($1,$2,$3,$4,$5,$6,$7)
+ ON CONFLICT(slug) DO UPDATE SET name=EXCLUDED.name,emoji=EXCLUDED.emoji,color=EXCLUDED.color,members=EXCLUDED.members,blurb=EXCLUDED.blurb,position=EXCLUDED.position`,
+ [g.slug, g.name, g.emoji, g.color, g.members, g.blurb, i]); }
+ for (let i = 0; i < (cm.events || []).length; i++) { const e = cm.events[i];
+ await c.query(`INSERT INTO events(id,title,emoji,when_label,location,going,body,position) VALUES($1,$2,$3,$4,$5,$6,$7,$8)
+ ON CONFLICT(id) DO UPDATE SET title=EXCLUDED.title,emoji=EXCLUDED.emoji,when_label=EXCLUDED.when_label,location=EXCLUDED.location,going=EXCLUDED.going,body=EXCLUDED.body,position=EXCLUDED.position`,
+ [e.id, e.title, e.emoji, e.when_label, e.location, e.going, e.body, i]); }
+ }
+
+ // ---- starter notifications + one inbox conversation for the demo user (only if none yet) ----
+ const meId = uid(me.name);
+ if ((await c.query('SELECT count(*) FROM notifications WHERE user_id=$1', [meId])).rows[0].count === '0') {
+ const notifs = [
+ ['react', 'Goldie Fein', 'thanked your comment on “Honey cake on the stoop”', '/p/honey-cake'],
+ ['comment', 'Sol Katz', 'replied to the Block Party post', '/p/block-party'],
+ ['opportunity', 'BubbesBlock', 'New lead matched to Wallcoverings near you', '/opportunities'],
+ ['event', 'Shabbat Dinner Club', 'reminded you: Shabbat dinner Friday 6:30', '/events']
+ ];
+ for (const [t, a, x, l] of notifs) await c.query('INSERT INTO notifications(user_id,type,actor,text,link) VALUES($1,$2,$3,$4,$5)', [meId, t, a, x, l]);
+ const conv = await c.query('INSERT INTO conversations(user_id,peer,peer_initials,peer_color) VALUES($1,$2,$3,$4) RETURNING id', [meId, 'Ruth Bernstein', 'RB', 'a3']);
+ const cid = conv.rows[0].id;
+ await c.query('INSERT INTO messages(conv_id,from_me,body) VALUES($1,$2,$3),($1,$4,$5)', [cid, false, 'Steve! Can I still borrow that folding table for Sunday? 🙏', true, 'Of course — I’ll drop it by Saturday. Save me a slice of honey cake 😉']);
+ }
+
const np = await c.query('SELECT count(*) FROM posts');
const nc = await c.query('SELECT count(*) FROM comments');
const no = await c.query('SELECT count(*) FROM opportunities');
- console.log(`migrated: ${np.rows[0].count} posts, ${nc.rows[0].count} comments, ${no.rows[0].count} opportunities`);
+ const nu = await c.query('SELECT count(*) FROM users');
+ const ng = await c.query('SELECT count(*) FROM groups');
+ const ne = await c.query('SELECT count(*) FROM events');
+ console.log(`migrated: ${np.rows[0].count} posts, ${nc.rows[0].count} comments, ${no.rows[0].count} opportunities, ${nu.rows[0].count} users, ${ng.rows[0].count} groups, ${ne.rows[0].count} events`);
await c.end();
})().catch(e => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
index 45af1ba..c6dd115 100644
--- a/server.js
+++ b/server.js
@@ -2,136 +2,358 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
+const cookieParser = require('cookie-parser');
require('dotenv').config({ path: path.join(__dirname, '.env') });
const app = express();
const PORT = process.env.PORT || 3201;
const DATA = path.join(__dirname, 'data', 'posts.json');
+const SECRET = process.env.SESSION_SECRET || 'bubbesblock-dev-secret';
let pool = null;
if (process.env.DATABASE_URL) {
const { Pool } = require('pg');
- pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
+ pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 6 });
pool.on('error', e => console.error('pg pool error:', e.message));
}
+// optional home-history module (built by la-research-agent); tolerate absence
+let homeHistory = null;
+try { homeHistory = require('./lib/home-history.js'); } catch { /* not built yet */ }
app.use(express.json());
+app.use(cookieParser(SECRET));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/healthz', (_req, res) => res.json({ ok: true, service: 'bubbesblock', db: !!pool }));
-// ---- JSON fallback (used if the DB is unreachable) ----
+// ---------- helpers ----------
+const q = (text, params) => pool.query(text, params);
function jsonAll() { return JSON.parse(fs.readFileSync(DATA, 'utf8')); }
+const uid = name => 'u-' + String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
-// ---- DB shapers (map snake_case rows -> the camelCase the front end expects) ----
-function shapePost(p, commentCount) {
- return {
- id: p.id, author: p.author, initials: p.initials, color: p.color,
- address: p.address, time: p.time_label, edited: p.edited,
- category: p.category, catColor: p.cat_color, body: p.body, photo: p.photo,
- reactions: p.reactions, reactedBy: p.reacted_by, shares: p.shares,
- moreComments: p.more_comments, commentCount
- };
+async function currentUser(req) {
+ if (!pool) return null;
+ const id = req.signedCookies && req.signedCookies.bb_uid;
+ if (!id) return null;
+ const r = await q('SELECT * FROM users WHERE id=$1', [id]);
+ return r.rows[0] || null;
}
-function shapeComment(m) {
+function setSession(res, userId) {
+ res.cookie('bb_uid', userId, { signed: true, httpOnly: true, sameSite: 'lax', maxAge: 1000 * 60 * 60 * 24 * 90 });
+}
+function need(res, code, msg) { res.status(code).json({ error: msg }); return null; }
+
+function shapePost(p, commentCount, extra = {}) {
return {
- author: m.author, initials: m.initials, color: m.color, nb: m.nb,
- time: m.time_label, thanks: m.thanks, reply: m.is_reply, body: m.body
+ id: p.id, author: p.author, initials: p.initials, color: p.color, address: p.address,
+ time: p.time_label, edited: p.edited, category: p.category, catColor: p.cat_color,
+ body: p.body, photo: p.photo, reactions: p.reactions, reactedBy: p.reacted_by,
+ shares: p.shares, moreComments: p.more_comments, commentCount, ...extra
};
}
+const shapeComment = (m, extra = {}) => ({ id: m.id, author: m.author, initials: m.initials, color: m.color, nb: m.nb, time: m.time_label, thanks: m.thanks, reply: m.is_reply, body: m.body, ...extra });
async function config(client) {
- const r = await client.query("SELECT k, v FROM site_config WHERE k IN ('me','neighborhood')");
+ const r = await client.query("SELECT k,v FROM site_config WHERE k IN ('me','neighborhood','service_area','categories_for_you')");
const o = {}; r.rows.forEach(x => (o[x.k] = x.v)); return o;
}
+async function reactionCount(client, type, id) {
+ const r = await client.query("SELECT count(*) FROM reactions WHERE target_type=$1 AND target_id=$2", [type, String(id)]);
+ return Number(r.rows[0].count);
+}
-// ---- Feed ----
-app.get('/api/posts', async (_req, res) => {
+// ===================== READ: feed / post / opportunities =====================
+app.get('/api/posts', async (req, res) => {
if (pool) {
let client;
try {
client = await pool.connect();
+ const me = await currentUser(req);
const cfg = await config(client);
const r = await client.query(
- `SELECT p.*, (SELECT count(*) FROM comments c WHERE c.post_id = p.id) AS live
+ `SELECT p.*, (SELECT count(*) FROM comments c WHERE c.post_id=p.id) AS live,
+ (SELECT count(*) FROM reactions x WHERE x.target_type='post' AND x.target_id=p.id) AS thanks
FROM posts p ORDER BY p.position`);
- const posts = r.rows.map(p => {
- const out = shapePost(p, Number(p.live) + (p.more_comments || 0));
- delete out.moreComments; // feed only needs the total
- return out;
- });
- return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, posts });
- } catch (e) {
- console.error('PG feed failed, JSON fallback:', e.message);
- } finally { if (client) client.release(); }
+ let mine = new Set();
+ if (me) { const yr = await client.query("SELECT target_id FROM reactions WHERE target_type='post' AND user_id=$1", [me.id]); mine = new Set(yr.rows.map(x => x.target_id)); }
+ const posts = r.rows.map(p => shapePost(p, Number(p.live) + (p.more_comments || 0), { thanks: Number(p.thanks), youThanked: mine.has(p.id) }));
+ posts.forEach(p => delete p.moreComments);
+ return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, you: me, posts });
+ } catch (e) { console.error('feed PG fail:', e.message); } finally { if (client) client.release(); }
}
- // fallback
- try {
- const d = jsonAll();
- const posts = d.posts.map(p => ({
- id: p.id, author: p.author, initials: p.initials, color: p.color, address: p.address,
- time: p.time, edited: !!p.edited, category: p.category, catColor: p.catColor, body: p.body,
- photo: p.photo, reactions: p.reactions, reactedBy: p.reactedBy, shares: p.shares,
- commentCount: (p.comments ? p.comments.length : 0) + (p.moreComments || 0)
- }));
- res.json({ neighborhood: d.neighborhood, me: d.me, posts });
- } catch (e) { res.status(500).json({ error: String(e) }); }
-});
-
-// ---- Single post + thread ----
+ try { const d = jsonAll(); res.json({ neighborhood: d.neighborhood, me: d.me, you: null,
+ posts: d.posts.map(p => ({ id: p.id, author: p.author, initials: p.initials, color: p.color, address: p.address, time: p.time, edited: !!p.edited, category: p.category, catColor: p.catColor, body: p.body, photo: p.photo, reactions: p.reactions, reactedBy: p.reactedBy, shares: p.shares, thanks: 0, commentCount: (p.comments ? p.comments.length : 0) + (p.moreComments || 0) })) }); }
+ catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
app.get('/api/posts/:id', async (req, res) => {
if (pool) {
let client;
try {
client = await pool.connect();
+ const me = await currentUser(req);
const cfg = await config(client);
const pr = await client.query('SELECT * FROM posts WHERE id=$1', [req.params.id]);
if (!pr.rows.length) return res.status(404).json({ error: 'not found' });
- const cr = await client.query(
- 'SELECT * FROM comments WHERE post_id=$1 ORDER BY position', [req.params.id]);
- const post = shapePost(pr.rows[0], cr.rows.length + (pr.rows[0].more_comments || 0));
+ const cr = await client.query('SELECT * FROM comments WHERE post_id=$1 ORDER BY position, id', [req.params.id]);
+ const thanks = await reactionCount(client, 'post', req.params.id);
+ let youThanked = false;
+ if (me) youThanked = (await client.query("SELECT 1 FROM reactions WHERE target_type='post' AND target_id=$1 AND user_id=$2", [req.params.id, me.id])).rows.length > 0;
+ const post = shapePost(pr.rows[0], cr.rows.length, { thanks, youThanked });
post.comments = cr.rows.map(shapeComment);
- return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, post });
- } catch (e) {
- console.error('PG post failed, JSON fallback:', e.message);
- } finally { if (client) client.release(); }
+ return res.json({ neighborhood: cfg.neighborhood, me: cfg.me, you: me, post });
+ } catch (e) { console.error('post PG fail:', e.message); } finally { if (client) client.release(); }
}
- try {
- const d = jsonAll();
- const post = d.posts.find(p => p.id === req.params.id);
- if (!post) return res.status(404).json({ error: 'not found' });
- res.json({ neighborhood: d.neighborhood, me: d.me, post });
- } catch (e) { res.status(500).json({ error: String(e) }); }
+ try { const d = jsonAll(); const post = d.posts.find(p => p.id === req.params.id); if (!post) return res.status(404).json({ error: 'not found' }); res.json({ neighborhood: d.neighborhood, me: d.me, you: null, post }); }
+ catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
+app.get('/api/posts/:id/comments', async (req, res) => {
+ if (!pool) return res.json({ comments: [] });
+ try { const r = await q('SELECT * FROM comments WHERE post_id=$1 ORDER BY position, id', [req.params.id]); res.json({ comments: r.rows.map(c => shapeComment(c)) }); }
+ catch (e) { res.status(500).json({ error: String(e) }); }
});
-// ---- Opportunity leads (Nextdoor "Opp Alerts" model) ----
-app.get('/api/opportunities', async (_req, res) => {
+app.get('/api/opportunities', async (req, res) => {
if (pool) {
let client;
try {
client = await pool.connect();
- const cfg = await client.query("SELECT k,v FROM site_config WHERE k IN ('me','service_area','categories_for_you')");
- const c = {}; cfg.rows.forEach(x => (c[x.k] = x.v));
+ const me = await currentUser(req);
+ const cfg = await config(client);
const r = await client.query('SELECT * FROM opportunities ORDER BY position');
- const opportunities = r.rows.map(o => ({
- id: o.id, neighbor: o.neighbor, initials: o.initials, color: o.color, category: o.category,
- forYou: o.for_you, area: o.area, address: o.address, time: o.time_label,
- responses: o.responses, locked: o.locked, body: o.body
- }));
- return res.json({ me: c.me, serviceArea: c.service_area, categoriesForYou: c.categories_for_you || [], opportunities });
- } catch (e) {
- console.error('PG opportunities failed, JSON fallback:', e.message);
- } finally { if (client) client.release(); }
+ let unlocked = new Set(), responded = new Set();
+ if (me) {
+ unlocked = new Set((await client.query('SELECT opportunity_id FROM opp_unlocks WHERE user_id=$1', [me.id])).rows.map(x => x.opportunity_id));
+ responded = new Set((await client.query('SELECT DISTINCT opportunity_id FROM opp_responses WHERE user_id=$1', [me.id])).rows.map(x => x.opportunity_id));
+ }
+ const opportunities = r.rows.map(o => ({ id: o.id, neighbor: o.neighbor, initials: o.initials, color: o.color, category: o.category, forYou: o.for_you, area: o.area, address: o.address, time: o.time_label, responses: o.responses, locked: o.locked && !unlocked.has(o.id), youResponded: responded.has(o.id), body: o.body }));
+ return res.json({ me: cfg.me, you: me, serviceArea: cfg.service_area, categoriesForYou: cfg.categories_for_you || [], opportunities });
+ } catch (e) { console.error('opps PG fail:', e.message); } finally { if (client) client.release(); }
}
+ try { const d = jsonAll(); const od = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'opportunities.json'), 'utf8'));
+ res.json({ me: d.me, you: null, serviceArea: od.service_area, categoriesForYou: od.categories_for_you || [], opportunities: (od.opportunities || []).map(o => ({ ...o, forYou: o.for_you })) }); }
+ catch (e) { res.status(500).json({ error: String(e) }); }
+});
+
+// ===================== SESSION / AUTH / CLAIM =====================
+app.get('/api/session', async (req, res) => { res.json({ you: await currentUser(req) }); });
+
+// chrome bootstrap for auxiliary pages (me for avatar/rails + current session)
+app.get('/api/me', async (req, res) => {
+ let me = { name: 'BubbesBlock', initials: 'BB', color: 'a1', address: 'Beekman Ave' };
+ if (pool) { try { const r = await q("SELECT v FROM site_config WHERE k='me'"); if (r.rows.length) me = r.rows[0].v; } catch {} }
+ res.json({ me, you: await currentUser(req) });
+});
+
+app.post('/api/session/login', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const name = (req.body.name || '').trim();
+ if (!name) return need(res, 400, 'name required');
+ const id = uid(name);
+ const initials = name.split(/\s+/).map(s => s[0]).join('').slice(0, 2).toUpperCase();
+ await q(`INSERT INTO users(id,name,initials,color) VALUES($1,$2,$3,'a1') ON CONFLICT(id) DO NOTHING`, [id, name, initials]);
+ setSession(res, id);
+ res.json({ you: (await q('SELECT * FROM users WHERE id=$1', [id])).rows[0] });
+});
+app.post('/api/session/logout', (_req, res) => { res.clearCookie('bb_uid'); res.json({ ok: true }); });
+
+app.post('/api/claim', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ const address = (req.body.address || '').trim(); if (!address) return need(res, 400, 'address required');
+ await q('UPDATE users SET address=$1, verified=true WHERE id=$2', [address, me.id]);
+ res.json({ you: (await q('SELECT * FROM users WHERE id=$1', [me.id])).rows[0] });
+});
+
+// ===================== WRITES: posts / comments / reactions =====================
+app.post('/api/posts', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ if (!me.verified) return need(res, 403, 'claim your address to post');
+ const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
+ const category = req.body.category || '📣 Block Post';
+ const catColor = req.body.catColor || 'plum';
+ const id = 'p-' + Date.now().toString(36);
try {
- const d = jsonAll();
- const od = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'opportunities.json'), 'utf8'));
- res.json({
- me: d.me, serviceArea: od.service_area, categoriesForYou: od.categories_for_you || [],
- opportunities: (od.opportunities || []).map(o => ({ ...o, forYou: o.for_you }))
- });
- } catch (e) { res.status(500).json({ error: String(e) }); }
+ const pos = (await q('SELECT coalesce(min(position),0)-1 AS n FROM posts')).rows[0].n; // new posts sort to top
+ await q(`INSERT INTO posts(id,position,author,initials,color,address,time_label,edited,category,cat_color,body,photo,reactions,reacted_by,shares,more_comments,source)
+ VALUES($1,$2,$3,$4,$5,$6,'just now',false,$7,$8,$9,$10,'[]'::jsonb,'',0,0,'user')`,
+ [id, pos, me.name, me.initials, me.color, me.address, category, catColor,
+ JSON.stringify(body.split(/\n\n+/).filter(Boolean)), req.body.photo ? JSON.stringify(req.body.photo) : null]);
+ res.json({ ok: true, id });
+ } catch (e) { console.error('create post fail:', e.message); need(res, 500, 'could not post'); }
+});
+
+app.post('/api/posts/:id/comments', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ if (!me.verified) return need(res, 403, 'claim your address to reply');
+ const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
+ const pos = (await q('SELECT coalesce(max(position),-1)+1 AS n FROM comments WHERE post_id=$1', [req.params.id])).rows[0].n;
+ const r = await q(`INSERT INTO comments(post_id,position,author,initials,color,nb,time_label,thanks,is_reply,body)
+ VALUES($1,$2,$3,$4,$5,$6,'just now',0,$7,$8) RETURNING *`,
+ [req.params.id, pos, me.name, me.initials, me.color, me.address || 'neighbor', !!req.body.reply, body]);
+ res.json({ ok: true, comment: shapeComment(r.rows[0]) });
+});
+
+// toggle a reaction (Thank/like) on a post|comment|opportunity
+app.post('/api/react', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ const { targetType, targetId } = req.body; const kind = req.body.kind || 'thank';
+ if (!['post', 'comment', 'opportunity'].includes(targetType) || !targetId) return need(res, 400, 'bad target');
+ const ex = await q('SELECT id FROM reactions WHERE target_type=$1 AND target_id=$2 AND user_id=$3 AND kind=$4', [targetType, String(targetId), me.id, kind]);
+ let on;
+ if (ex.rows.length) { await q('DELETE FROM reactions WHERE id=$1', [ex.rows[0].id]); on = false; }
+ else { await q('INSERT INTO reactions(target_type,target_id,user_id,kind) VALUES($1,$2,$3,$4)', [targetType, String(targetId), me.id, kind]); on = true; }
+ const count = Number((await q('SELECT count(*) FROM reactions WHERE target_type=$1 AND target_id=$2', [targetType, String(targetId)])).rows[0].count);
+ res.json({ ok: true, on, count });
+});
+
+app.post('/api/posts/:id/share', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ await q('UPDATE posts SET shares=shares+1 WHERE id=$1', [req.params.id]);
+ const r = await q('SELECT shares FROM posts WHERE id=$1', [req.params.id]);
+ res.json({ ok: true, shares: r.rows.length ? r.rows[0].shares : 0, url: `/p/${req.params.id}` });
+});
+
+app.post('/api/posts/:id/bookmark', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ const ex = await q('SELECT 1 FROM bookmarks WHERE user_id=$1 AND post_id=$2', [me.id, req.params.id]);
+ let on; if (ex.rows.length) { await q('DELETE FROM bookmarks WHERE user_id=$1 AND post_id=$2', [me.id, req.params.id]); on = false; }
+ else { await q('INSERT INTO bookmarks(user_id,post_id) VALUES($1,$2)', [me.id, req.params.id]); on = true; }
+ res.json({ ok: true, on });
+});
+app.get('/api/bookmarks', async (req, res) => {
+ if (!pool) return res.json({ posts: [] });
+ const me = await currentUser(req); if (!me) return res.json({ posts: [] });
+ const r = await q(`SELECT p.* FROM bookmarks b JOIN posts p ON p.id=b.post_id WHERE b.user_id=$1 ORDER BY b.created_at DESC`, [me.id]);
+ res.json({ posts: r.rows.map(p => shapePost(p, 0)) });
+});
+
+// ===================== OPPORTUNITIES: respond / unlock =====================
+app.post('/api/opportunities/:id/respond', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ if (!me.verified) return need(res, 403, 'claim your address to respond');
+ const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
+ await q('INSERT INTO opp_responses(opportunity_id,user_id,body) VALUES($1,$2,$3)', [req.params.id, me.id, body]);
+ await q('UPDATE opportunities SET responses=responses+1 WHERE id=$1', [req.params.id]);
+ const r = await q('SELECT responses FROM opportunities WHERE id=$1', [req.params.id]);
+ res.json({ ok: true, responses: r.rows.length ? r.rows[0].responses : 0 });
+});
+app.post('/api/opportunities/:id/unlock', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ await q('INSERT INTO opp_unlocks(opportunity_id,user_id) VALUES($1,$2) ON CONFLICT DO NOTHING', [req.params.id, me.id]);
+ res.json({ ok: true, unlocked: true });
+});
+
+// ===================== SEARCH =====================
+app.get('/api/search', async (req, res) => {
+ const term = (req.query.q || '').trim(); if (!term) return res.json({ posts: [], opportunities: [], neighbors: [] });
+ if (!pool) return res.json({ posts: [], opportunities: [], neighbors: [] });
+ const like = '%' + term + '%';
+ const posts = (await q(`SELECT id,author,category,body FROM posts WHERE body::text ILIKE $1 OR author ILIKE $1 OR category ILIKE $1 ORDER BY position LIMIT 8`, [like])).rows;
+ const opps = (await q(`SELECT id,neighbor,category,body FROM opportunities WHERE body ILIKE $1 OR category ILIKE $1 OR neighbor ILIKE $1 ORDER BY position LIMIT 8`, [like])).rows;
+ const neighbors = (await q(`SELECT id,name,initials,color,address FROM users WHERE name ILIKE $1 LIMIT 8`, [like])).rows;
+ res.json({ posts, opportunities: opps, neighbors });
+});
+
+// ===================== NOTIFICATIONS / INBOX =====================
+app.get('/api/notifications', async (req, res) => {
+ if (!pool) return res.json({ items: [], unread: 0 });
+ const me = await currentUser(req); if (!me) return res.json({ items: [], unread: 0 });
+ const r = await q('SELECT * FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT 30', [me.id]);
+ const unread = r.rows.filter(n => !n.read).length;
+ res.json({ items: r.rows, unread });
+});
+app.post('/api/notifications/read', async (req, res) => {
+ if (!pool) return res.json({ ok: true });
+ const me = await currentUser(req); if (!me) return res.json({ ok: true });
+ await q('UPDATE notifications SET read=true WHERE user_id=$1', [me.id]);
+ res.json({ ok: true });
+});
+app.get('/api/inbox', async (req, res) => {
+ if (!pool) return res.json({ conversations: [] });
+ const me = await currentUser(req); if (!me) return res.json({ conversations: [] });
+ const r = await q(`SELECT c.*, (SELECT body FROM messages m WHERE m.conv_id=c.id ORDER BY id DESC LIMIT 1) AS last
+ FROM conversations c WHERE c.user_id=$1 ORDER BY c.id DESC`, [me.id]);
+ res.json({ conversations: r.rows });
+});
+app.get('/api/inbox/:id', async (req, res) => {
+ if (!pool) return res.json({ messages: [] });
+ const me = await currentUser(req); if (!me) return res.json({ messages: [] });
+ const r = await q('SELECT m.* FROM messages m JOIN conversations c ON c.id=m.conv_id WHERE m.conv_id=$1 AND c.user_id=$2 ORDER BY m.id', [req.params.id, me.id]);
+ res.json({ messages: r.rows });
+});
+app.post('/api/inbox/:id/messages', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ const body = (req.body.body || '').trim(); if (!body) return need(res, 400, 'empty');
+ await q('INSERT INTO messages(conv_id,from_me,body) VALUES($1,true,$2)', [req.params.id, body]);
+ res.json({ ok: true });
});
+// ===================== EVENTS / GROUPS =====================
+app.get('/api/events', async (req, res) => {
+ if (!pool) return res.json({ events: [] });
+ const me = await currentUser(req);
+ const r = await q('SELECT * FROM events ORDER BY position', []);
+ let going = new Set();
+ if (me) going = new Set((await q('SELECT event_id FROM event_rsvps WHERE user_id=$1', [me.id])).rows.map(x => x.event_id));
+ res.json({ you: me, events: r.rows.map(e => ({ ...e, youGoing: going.has(e.id) })) });
+});
+app.post('/api/events/:id/rsvp', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ const ex = await q('SELECT 1 FROM event_rsvps WHERE event_id=$1 AND user_id=$2', [req.params.id, me.id]);
+ let going; if (ex.rows.length) { await q('DELETE FROM event_rsvps WHERE event_id=$1 AND user_id=$2', [req.params.id, me.id]); await q('UPDATE events SET going=greatest(going-1,0) WHERE id=$1', [req.params.id]); going = false; }
+ else { await q('INSERT INTO event_rsvps(event_id,user_id) VALUES($1,$2)', [req.params.id, me.id]); await q('UPDATE events SET going=going+1 WHERE id=$1', [req.params.id]); going = true; }
+ const r = await q('SELECT going FROM events WHERE id=$1', [req.params.id]);
+ res.json({ ok: true, going, count: r.rows[0].going });
+});
+app.get('/api/groups', async (req, res) => {
+ if (!pool) return res.json({ groups: [] });
+ const me = await currentUser(req);
+ const r = await q('SELECT * FROM groups ORDER BY position', []);
+ let joined = new Set();
+ if (me) joined = new Set((await q('SELECT slug FROM group_members WHERE user_id=$1', [me.id])).rows.map(x => x.slug));
+ res.json({ you: me, groups: r.rows.map(g => ({ ...g, youJoined: joined.has(g.slug) })) });
+});
+app.get('/api/groups/:slug', async (req, res) => {
+ if (!pool) return res.status(404).json({ error: 'not found' });
+ const r = await q('SELECT * FROM groups WHERE slug=$1', [req.params.slug]);
+ if (!r.rows.length) return res.status(404).json({ error: 'not found' });
+ res.json({ group: r.rows[0] });
+});
+app.post('/api/groups/:slug/join', async (req, res) => {
+ if (!pool) return need(res, 503, 'db required');
+ const me = await currentUser(req); if (!me) return need(res, 401, 'sign in first');
+ const ex = await q('SELECT 1 FROM group_members WHERE slug=$1 AND user_id=$2', [req.params.slug, me.id]);
+ let joined; if (ex.rows.length) { await q('DELETE FROM group_members WHERE slug=$1 AND user_id=$2', [req.params.slug, me.id]); await q('UPDATE groups SET members=greatest(members-1,0) WHERE slug=$1', [req.params.slug]); joined = false; }
+ else { await q('INSERT INTO group_members(slug,user_id) VALUES($1,$2)', [req.params.slug, me.id]); await q('UPDATE groups SET members=members+1 WHERE slug=$1', [req.params.slug]); joined = true; }
+ const r = await q('SELECT members FROM groups WHERE slug=$1', [req.params.slug]);
+ res.json({ ok: true, joined, members: r.rows[0].members });
+});
+
+// ===================== HOME HISTORY (wholivedthere; module optional) =====================
+app.get('/api/home-history', async (req, res) => {
+ const address = (req.query.address || '').trim(); if (!address) return need(res, 400, 'address required');
+ if (homeHistory && homeHistory.getHomeHistory) {
+ try { return res.json(await homeHistory.getHomeHistory(address)); } catch (e) { console.error('home-history fail:', e.message); }
+ }
+ res.json({ address, source: 'unavailable', summary: 'Home history coming soon.', link: `https://wholivedthere.com/?address=${encodeURIComponent(address)}` });
+});
+
+// ===================== page routes =====================
app.get('/p/:id', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'post.html')));
app.get('/opportunities', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'opportunities.html')));
+for (const r of ['events', 'groups', 'bookmarks', 'search', 'inbox', 'notifications']) {
+ app.get('/' + r, (_req, res) => res.sendFile(path.join(__dirname, 'public', r + '.html')));
+}
+for (const r of ['about', 'guidelines', 'privacy', 'help']) {
+ app.get('/' + r, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'content.html')));
+}
app.listen(PORT, () => console.log(`BubbesBlock on http://localhost:${PORT} (db=${!!pool})`));
← 698091f Add home-history module with LA County parcel + LADBS permit
·
back to Bubbesblock
·
Security hardening (code-reviewer pass): server-side XSS esc 977e8e6 →