an atproto based link aggregator

Add moderation, profiles, polish, CI, and switch to migrations

- Add admin moderation panel with hide/unhide for posts and comments
- Add user profile pages with posts/comments tabs
- Add report functionality for posts and comments
- Extract reusable components: Tabs, PostTitle, Modal
- Add alpha badge with modal explaining breaking changes
- Set up Tangled Spindle CI pipeline (lint, typecheck, tests)
- Switch from db:push to proper Drizzle migrations
- Extract shared search queries with FTS snippet config
- Add tests for ingester handler, profiles, ranking, auth flows
- Update README and CLAUDE.md documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+3693 -389
+45
.tangled/workflows/ci.yaml
··· 1 + when: 2 + push: 3 + branch: 4 + - main 5 + - develop 6 + pull_request: 7 + branch: 8 + - main 9 + 10 + engine: nixery 11 + 12 + dependencies: 13 + nixpkgs: 14 + - nodejs_22 15 + - corepack 16 + - chromium 17 + 18 + environment: 19 + PLAYWRIGHT_BROWSERS_PATH: "0" 20 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" 21 + CHROME_BIN: chromium 22 + 23 + steps: 24 + - name: Install dependencies 25 + command: | 26 + corepack enable 27 + pnpm install --frozen-lockfile 28 + 29 + - name: Build lexicons 30 + command: pnpm lex:build 31 + 32 + - name: Run migrations 33 + command: pnpm db:migrate 34 + 35 + - name: Set up FTS 36 + command: pnpm db:fts 37 + 38 + - name: Lint 39 + command: pnpm lint 40 + 41 + - name: Type check 42 + command: pnpm check 43 + 44 + - name: Run tests 45 + command: pnpm test
+51 -8
CLAUDE.md
··· 1 - # papili.one 1 + # papilione 2 2 3 3 ATProto-based link aggregator built with SvelteKit + Fly.io. 4 4 ··· 18 18 pnpm format # Auto-format code 19 19 20 20 # Database (SQLite locally) 21 - pnpm db:push # Push schema to database 22 - pnpm db:generate # Generate migrations 23 - pnpm db:migrate # Run migrations 21 + pnpm db:migrate # Run migrations (use this for setup) 22 + pnpm db:fts # Set up FTS5 full-text search indexes 23 + pnpm db:generate # Generate new migration after schema changes 24 + pnpm db:push # Push schema directly (dev only, skips migrations) 24 25 pnpm db:studio # Open Drizzle Studio 25 26 26 27 # Lexicons ··· 81 82 First time or after schema changes: 82 83 83 84 ```bash 84 - pnpm db:push # Push schema to both content.db and local.db 85 + pnpm db:migrate # Run migrations on both content.db and local.db 86 + pnpm db:fts # Set up FTS5 search indexes (run once after migrate) 85 87 ``` 86 88 87 89 ## Key Files 88 90 89 - - `src/lib/server/db/content-schema.ts` - Content DB schema (posts, comments, accounts) 91 + ### Database & Schema 92 + - `src/lib/server/db/content-schema.ts` - Content DB schema (posts, comments, accounts, reports) 90 93 - `src/lib/server/db/local-schema.ts` - Local DB schema (auth, votes) 91 94 - `src/lib/server/db/index.ts` - Database connections (contentDb, localDb) 95 + - `drizzle/content/` - Content DB migrations 96 + - `drizzle/local/` - Local DB migrations 97 + - `scripts/setup-fts.ts` - FTS5 full-text search setup script 98 + 99 + ### Shared Query Utilities 100 + - `src/lib/server/queries/posts.ts` - Post queries with comment counts 101 + - `src/lib/server/queries/comments.ts` - Comment queries with hidden filtering 102 + - `src/lib/server/search/queries.ts` - FTS search queries for posts/comments 103 + 104 + ### Ingester 92 105 - `src/ingester/main.ts` - Standalone ingester entry point 93 106 - `src/ingester/handler.ts` - Jetstream event handler 107 + 108 + ### Observability 94 109 - `src/instrumentation.server.ts` - OpenTelemetry tracing for SvelteKit 95 110 - `src/ingester/instrumentation.ts` - OpenTelemetry tracing for ingester 111 + 112 + ### Deployment 96 113 - `fly.toml` - Webapp Fly.io config 97 114 - `fly.ingester.toml` - Ingester Fly.io config 98 115 - `litefs.yml` - LiteFS config for webapp (replica) 99 116 - `litefs.ingester.yml` - LiteFS config for ingester (primary) 117 + - `.tangled/workflows/ci.yaml` - CI pipeline (lint, type check, tests) 118 + 119 + ### Documentation 100 120 - `docs/implementation_plan.md` - Full implementation roadmap 121 + - `lexicons/` - ATProto lexicon definitions (one.papili.post, one.papili.comment) 101 122 102 123 ## OpenTelemetry Tracing (Honeycomb) 103 124 ··· 108 129 HONEYCOMB_API_KEY=hcaik_01... 109 130 110 131 # Optional: override service names (defaults shown) 111 - OTEL_SERVICE_NAME=papili-web # For SvelteKit 112 - OTEL_SERVICE_NAME=papili-ingester # For ingester 132 + OTEL_SERVICE_NAME=papilione-web # For SvelteKit 133 + OTEL_SERVICE_NAME=papilione-ingester # For ingester 113 134 ``` 114 135 115 136 ### Local Development ··· 142 163 # 2. Enable LiteFS (Consul lease) 143 164 fly consul attach 144 165 ``` 166 + 167 + ## Environment Variables 168 + 169 + ```bash 170 + # Required for production 171 + PUBLIC_URL=https://papili.one # OAuth redirect URL 172 + PRIVATE_KEY_ES256=... # OAuth signing key 173 + 174 + # Admin access (comma-separated DIDs) 175 + ADMIN_DIDS=did:plc:xxx,did:plc:yyy 176 + 177 + # Optional: Honeycomb tracing 178 + HONEYCOMB_API_KEY=hcaik_01... 179 + OTEL_SERVICE_NAME=papilione-web # Override service name 180 + ``` 181 + 182 + ## CI/CD 183 + 184 + Pipeline runs on Tangled Spindle (`.tangled/workflows/ci.yaml`): 185 + - Triggers on push to `main`/`develop` and PRs to `main` 186 + - Runs: install deps → build lexicons → lint → type check → tests 187 + - Uses nixpkgs for Node.js 22, pnpm, and Chromium (for browser tests) 145 188 146 189 --- 147 190
+7
LICENSE
··· 1 + Copyright 2025 did:plc:gttrfs4hfmrclyxvwkwcgpj7 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 + 5 + The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 + 7 + THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+72 -21
README.md
··· 1 - # sv 1 + # papilione 2 + 3 + An ATProto-based link aggregator built with SvelteKit. 4 + 5 + **Status:** Alpha - expect breaking changes to lexicons and features. 6 + 7 + ## Features 8 + 9 + - **Link submissions** - Share URLs with titles and optional text 10 + - **Text posts** - Post discussions without a link 11 + - **Threaded comments** - Nested comment threads on posts 12 + - **Voting** - Upvote posts and comments (private, not published to ATProto) 13 + - **Full-text search** - Search posts and comments with highlighted snippets 14 + - **ATProto authentication** - Sign in with your ATProto Account 15 + - **Data portability** - Posts and comments stored on the ATProto network via your PDS 16 + 17 + ## Development 18 + 19 + ### Prerequisites 20 + 21 + - Node.js 22+ 22 + - pnpm 23 + 24 + ### Quick Start 2 25 3 - Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 26 + ```bash 27 + # Install dependencies 28 + pnpm install 4 29 5 - ## Creating a project 30 + # Push database schema 31 + pnpm db:push 6 32 7 - If you're seeing this, you've probably already done this step. Congrats! 33 + # Set up full-text search 34 + pnpm db:fts 8 35 9 - ```sh 10 - # create a new project in the current directory 11 - npx sv create 36 + # Start dev server (use 127.0.0.1 for OAuth, not localhost) 37 + pnpm dev --host 127.0.0.1 12 38 13 - # create a new project in my-app 14 - npx sv create my-app 39 + # In another terminal, start the ingester 40 + pnpm dev:ingester 15 41 ``` 16 42 17 - ## Developing 43 + Visit `http://127.0.0.1:5173` 44 + 45 + ### Commands 18 46 19 - Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 47 + ```bash 48 + pnpm dev # Start dev server 49 + pnpm build # Build for production 50 + pnpm check # Type check 51 + pnpm test # Run tests 52 + pnpm lint # Check formatting + linting 53 + pnpm format # Auto-format code 20 54 21 - ```sh 22 - npm run dev 55 + # Database 56 + pnpm db:push # Push schema to database 57 + pnpm db:fts # Set up FTS5 search indexes 58 + pnpm db:studio # Open Drizzle Studio 23 59 24 - # or start the server and open the app in a new browser tab 25 - npm run dev -- --open 60 + # Lexicons 61 + pnpm lex:build # Generate TypeScript from lexicons 26 62 ``` 27 63 28 - ## Building 64 + ## Deployment 29 65 30 - To create a production version of your app: 66 + Deployed on Fly.io with two apps: 31 67 32 - ```sh 33 - npm run build 68 + ```bash 69 + # Deploy webapp (LiteFS replica) 70 + fly deploy 71 + 72 + # Deploy ingester (LiteFS primary) 73 + fly deploy -c fly.ingester.toml 34 74 ``` 35 75 36 - You can preview the production build with `npm run preview`. 76 + ## Lexicons 77 + 78 + papilione uses custom ATProto lexicons: 79 + 80 + - `one.papili.post` - Link submissions and text posts 81 + - `one.papili.comment` - Comments on posts 82 + 83 + ## Contributing 84 + 85 + Issues and feedback: [tangled.org/aparker.io/papili.one/issues](https://tangled.org/aparker.io/papili.one/issues) 86 + 87 + ## License 37 88 38 - > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. 89 + MIT
+44
drizzle/content/0000_sad_warbound.sql
··· 1 + CREATE TABLE `accounts` ( 2 + `did` text PRIMARY KEY NOT NULL, 3 + `handle` text, 4 + `active` integer DEFAULT 1 NOT NULL, 5 + `status` text, 6 + `seq` integer DEFAULT 0 NOT NULL, 7 + `updated_at` text NOT NULL 8 + ); 9 + --> statement-breakpoint 10 + CREATE TABLE `comments` ( 11 + `uri` text PRIMARY KEY NOT NULL, 12 + `cid` text NOT NULL, 13 + `author_did` text NOT NULL, 14 + `rkey` text NOT NULL, 15 + `post_uri` text NOT NULL, 16 + `post_cid` text NOT NULL, 17 + `parent_uri` text, 18 + `parent_cid` text, 19 + `text` text NOT NULL, 20 + `created_at` text NOT NULL, 21 + `indexed_at` text NOT NULL, 22 + `vote_count` integer DEFAULT 0 NOT NULL, 23 + `is_hidden` integer DEFAULT 0 NOT NULL 24 + ); 25 + --> statement-breakpoint 26 + CREATE TABLE `ingestion_cursor` ( 27 + `id` integer PRIMARY KEY DEFAULT 1 NOT NULL, 28 + `cursor_us` integer NOT NULL, 29 + `updated_at` text NOT NULL 30 + ); 31 + --> statement-breakpoint 32 + CREATE TABLE `posts` ( 33 + `uri` text PRIMARY KEY NOT NULL, 34 + `cid` text NOT NULL, 35 + `author_did` text NOT NULL, 36 + `rkey` text NOT NULL, 37 + `url` text, 38 + `title` text NOT NULL, 39 + `text` text, 40 + `created_at` text NOT NULL, 41 + `indexed_at` text NOT NULL, 42 + `vote_count` integer DEFAULT 0 NOT NULL, 43 + `is_hidden` integer DEFAULT 0 NOT NULL 44 + );
+296
drizzle/content/meta/0000_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "b5630ab0-6667-4f5f-ba53-82816f647a48", 5 + "prevId": "00000000-0000-0000-0000-000000000000", 6 + "tables": { 7 + "accounts": { 8 + "name": "accounts", 9 + "columns": { 10 + "did": { 11 + "name": "did", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "handle": { 18 + "name": "handle", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": false, 22 + "autoincrement": false 23 + }, 24 + "active": { 25 + "name": "active", 26 + "type": "integer", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false, 30 + "default": 1 31 + }, 32 + "status": { 33 + "name": "status", 34 + "type": "text", 35 + "primaryKey": false, 36 + "notNull": false, 37 + "autoincrement": false 38 + }, 39 + "seq": { 40 + "name": "seq", 41 + "type": "integer", 42 + "primaryKey": false, 43 + "notNull": true, 44 + "autoincrement": false, 45 + "default": 0 46 + }, 47 + "updated_at": { 48 + "name": "updated_at", 49 + "type": "text", 50 + "primaryKey": false, 51 + "notNull": true, 52 + "autoincrement": false 53 + } 54 + }, 55 + "indexes": {}, 56 + "foreignKeys": {}, 57 + "compositePrimaryKeys": {}, 58 + "uniqueConstraints": {}, 59 + "checkConstraints": {} 60 + }, 61 + "comments": { 62 + "name": "comments", 63 + "columns": { 64 + "uri": { 65 + "name": "uri", 66 + "type": "text", 67 + "primaryKey": true, 68 + "notNull": true, 69 + "autoincrement": false 70 + }, 71 + "cid": { 72 + "name": "cid", 73 + "type": "text", 74 + "primaryKey": false, 75 + "notNull": true, 76 + "autoincrement": false 77 + }, 78 + "author_did": { 79 + "name": "author_did", 80 + "type": "text", 81 + "primaryKey": false, 82 + "notNull": true, 83 + "autoincrement": false 84 + }, 85 + "rkey": { 86 + "name": "rkey", 87 + "type": "text", 88 + "primaryKey": false, 89 + "notNull": true, 90 + "autoincrement": false 91 + }, 92 + "post_uri": { 93 + "name": "post_uri", 94 + "type": "text", 95 + "primaryKey": false, 96 + "notNull": true, 97 + "autoincrement": false 98 + }, 99 + "post_cid": { 100 + "name": "post_cid", 101 + "type": "text", 102 + "primaryKey": false, 103 + "notNull": true, 104 + "autoincrement": false 105 + }, 106 + "parent_uri": { 107 + "name": "parent_uri", 108 + "type": "text", 109 + "primaryKey": false, 110 + "notNull": false, 111 + "autoincrement": false 112 + }, 113 + "parent_cid": { 114 + "name": "parent_cid", 115 + "type": "text", 116 + "primaryKey": false, 117 + "notNull": false, 118 + "autoincrement": false 119 + }, 120 + "text": { 121 + "name": "text", 122 + "type": "text", 123 + "primaryKey": false, 124 + "notNull": true, 125 + "autoincrement": false 126 + }, 127 + "created_at": { 128 + "name": "created_at", 129 + "type": "text", 130 + "primaryKey": false, 131 + "notNull": true, 132 + "autoincrement": false 133 + }, 134 + "indexed_at": { 135 + "name": "indexed_at", 136 + "type": "text", 137 + "primaryKey": false, 138 + "notNull": true, 139 + "autoincrement": false 140 + }, 141 + "vote_count": { 142 + "name": "vote_count", 143 + "type": "integer", 144 + "primaryKey": false, 145 + "notNull": true, 146 + "autoincrement": false, 147 + "default": 0 148 + }, 149 + "is_hidden": { 150 + "name": "is_hidden", 151 + "type": "integer", 152 + "primaryKey": false, 153 + "notNull": true, 154 + "autoincrement": false, 155 + "default": 0 156 + } 157 + }, 158 + "indexes": {}, 159 + "foreignKeys": {}, 160 + "compositePrimaryKeys": {}, 161 + "uniqueConstraints": {}, 162 + "checkConstraints": {} 163 + }, 164 + "ingestion_cursor": { 165 + "name": "ingestion_cursor", 166 + "columns": { 167 + "id": { 168 + "name": "id", 169 + "type": "integer", 170 + "primaryKey": true, 171 + "notNull": true, 172 + "autoincrement": false, 173 + "default": 1 174 + }, 175 + "cursor_us": { 176 + "name": "cursor_us", 177 + "type": "integer", 178 + "primaryKey": false, 179 + "notNull": true, 180 + "autoincrement": false 181 + }, 182 + "updated_at": { 183 + "name": "updated_at", 184 + "type": "text", 185 + "primaryKey": false, 186 + "notNull": true, 187 + "autoincrement": false 188 + } 189 + }, 190 + "indexes": {}, 191 + "foreignKeys": {}, 192 + "compositePrimaryKeys": {}, 193 + "uniqueConstraints": {}, 194 + "checkConstraints": {} 195 + }, 196 + "posts": { 197 + "name": "posts", 198 + "columns": { 199 + "uri": { 200 + "name": "uri", 201 + "type": "text", 202 + "primaryKey": true, 203 + "notNull": true, 204 + "autoincrement": false 205 + }, 206 + "cid": { 207 + "name": "cid", 208 + "type": "text", 209 + "primaryKey": false, 210 + "notNull": true, 211 + "autoincrement": false 212 + }, 213 + "author_did": { 214 + "name": "author_did", 215 + "type": "text", 216 + "primaryKey": false, 217 + "notNull": true, 218 + "autoincrement": false 219 + }, 220 + "rkey": { 221 + "name": "rkey", 222 + "type": "text", 223 + "primaryKey": false, 224 + "notNull": true, 225 + "autoincrement": false 226 + }, 227 + "url": { 228 + "name": "url", 229 + "type": "text", 230 + "primaryKey": false, 231 + "notNull": false, 232 + "autoincrement": false 233 + }, 234 + "title": { 235 + "name": "title", 236 + "type": "text", 237 + "primaryKey": false, 238 + "notNull": true, 239 + "autoincrement": false 240 + }, 241 + "text": { 242 + "name": "text", 243 + "type": "text", 244 + "primaryKey": false, 245 + "notNull": false, 246 + "autoincrement": false 247 + }, 248 + "created_at": { 249 + "name": "created_at", 250 + "type": "text", 251 + "primaryKey": false, 252 + "notNull": true, 253 + "autoincrement": false 254 + }, 255 + "indexed_at": { 256 + "name": "indexed_at", 257 + "type": "text", 258 + "primaryKey": false, 259 + "notNull": true, 260 + "autoincrement": false 261 + }, 262 + "vote_count": { 263 + "name": "vote_count", 264 + "type": "integer", 265 + "primaryKey": false, 266 + "notNull": true, 267 + "autoincrement": false, 268 + "default": 0 269 + }, 270 + "is_hidden": { 271 + "name": "is_hidden", 272 + "type": "integer", 273 + "primaryKey": false, 274 + "notNull": true, 275 + "autoincrement": false, 276 + "default": 0 277 + } 278 + }, 279 + "indexes": {}, 280 + "foreignKeys": {}, 281 + "compositePrimaryKeys": {}, 282 + "uniqueConstraints": {}, 283 + "checkConstraints": {} 284 + } 285 + }, 286 + "views": {}, 287 + "enums": {}, 288 + "_meta": { 289 + "schemas": {}, 290 + "tables": {}, 291 + "columns": {} 292 + }, 293 + "internal": { 294 + "indexes": {} 295 + } 296 + }
+13
drizzle/content/meta/_journal.json
··· 1 + { 2 + "version": "7", 3 + "dialect": "sqlite", 4 + "entries": [ 5 + { 6 + "idx": 0, 7 + "version": "6", 8 + "when": 1765169603517, 9 + "tag": "0000_sad_warbound", 10 + "breakpoints": true 11 + } 12 + ] 13 + }
+35
drizzle/local/0000_serious_night_thrasher.sql
··· 1 + CREATE TABLE `auth_session` ( 2 + `key` text PRIMARY KEY NOT NULL, 3 + `session` text NOT NULL, 4 + `created_at` text NOT NULL, 5 + `updated_at` text NOT NULL 6 + ); 7 + --> statement-breakpoint 8 + CREATE TABLE `auth_state` ( 9 + `key` text PRIMARY KEY NOT NULL, 10 + `state` text NOT NULL, 11 + `created_at` text NOT NULL 12 + ); 13 + --> statement-breakpoint 14 + CREATE TABLE `reports` ( 15 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 16 + `reporter_did` text NOT NULL, 17 + `target_uri` text NOT NULL, 18 + `target_type` text NOT NULL, 19 + `reason` text, 20 + `created_at` text NOT NULL, 21 + `resolved_at` text, 22 + `resolved_by` text, 23 + `resolution` text 24 + ); 25 + --> statement-breakpoint 26 + CREATE TABLE `votes` ( 27 + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 28 + `user_did` text NOT NULL, 29 + `target_uri` text NOT NULL, 30 + `target_type` text NOT NULL, 31 + `value` integer NOT NULL, 32 + `created_at` text NOT NULL 33 + ); 34 + --> statement-breakpoint 35 + CREATE UNIQUE INDEX `votes_user_target_idx` ON `votes` (`user_did`,`target_uri`);
+221
drizzle/local/meta/0000_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "6cd4240e-ceec-4b35-96ba-d5b57d8561c5", 5 + "prevId": "00000000-0000-0000-0000-000000000000", 6 + "tables": { 7 + "auth_session": { 8 + "name": "auth_session", 9 + "columns": { 10 + "key": { 11 + "name": "key", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "text", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "updated_at": { 32 + "name": "updated_at", 33 + "type": "text", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + } 38 + }, 39 + "indexes": {}, 40 + "foreignKeys": {}, 41 + "compositePrimaryKeys": {}, 42 + "uniqueConstraints": {}, 43 + "checkConstraints": {} 44 + }, 45 + "auth_state": { 46 + "name": "auth_state", 47 + "columns": { 48 + "key": { 49 + "name": "key", 50 + "type": "text", 51 + "primaryKey": true, 52 + "notNull": true, 53 + "autoincrement": false 54 + }, 55 + "state": { 56 + "name": "state", 57 + "type": "text", 58 + "primaryKey": false, 59 + "notNull": true, 60 + "autoincrement": false 61 + }, 62 + "created_at": { 63 + "name": "created_at", 64 + "type": "text", 65 + "primaryKey": false, 66 + "notNull": true, 67 + "autoincrement": false 68 + } 69 + }, 70 + "indexes": {}, 71 + "foreignKeys": {}, 72 + "compositePrimaryKeys": {}, 73 + "uniqueConstraints": {}, 74 + "checkConstraints": {} 75 + }, 76 + "reports": { 77 + "name": "reports", 78 + "columns": { 79 + "id": { 80 + "name": "id", 81 + "type": "integer", 82 + "primaryKey": true, 83 + "notNull": true, 84 + "autoincrement": true 85 + }, 86 + "reporter_did": { 87 + "name": "reporter_did", 88 + "type": "text", 89 + "primaryKey": false, 90 + "notNull": true, 91 + "autoincrement": false 92 + }, 93 + "target_uri": { 94 + "name": "target_uri", 95 + "type": "text", 96 + "primaryKey": false, 97 + "notNull": true, 98 + "autoincrement": false 99 + }, 100 + "target_type": { 101 + "name": "target_type", 102 + "type": "text", 103 + "primaryKey": false, 104 + "notNull": true, 105 + "autoincrement": false 106 + }, 107 + "reason": { 108 + "name": "reason", 109 + "type": "text", 110 + "primaryKey": false, 111 + "notNull": false, 112 + "autoincrement": false 113 + }, 114 + "created_at": { 115 + "name": "created_at", 116 + "type": "text", 117 + "primaryKey": false, 118 + "notNull": true, 119 + "autoincrement": false 120 + }, 121 + "resolved_at": { 122 + "name": "resolved_at", 123 + "type": "text", 124 + "primaryKey": false, 125 + "notNull": false, 126 + "autoincrement": false 127 + }, 128 + "resolved_by": { 129 + "name": "resolved_by", 130 + "type": "text", 131 + "primaryKey": false, 132 + "notNull": false, 133 + "autoincrement": false 134 + }, 135 + "resolution": { 136 + "name": "resolution", 137 + "type": "text", 138 + "primaryKey": false, 139 + "notNull": false, 140 + "autoincrement": false 141 + } 142 + }, 143 + "indexes": {}, 144 + "foreignKeys": {}, 145 + "compositePrimaryKeys": {}, 146 + "uniqueConstraints": {}, 147 + "checkConstraints": {} 148 + }, 149 + "votes": { 150 + "name": "votes", 151 + "columns": { 152 + "id": { 153 + "name": "id", 154 + "type": "integer", 155 + "primaryKey": true, 156 + "notNull": true, 157 + "autoincrement": true 158 + }, 159 + "user_did": { 160 + "name": "user_did", 161 + "type": "text", 162 + "primaryKey": false, 163 + "notNull": true, 164 + "autoincrement": false 165 + }, 166 + "target_uri": { 167 + "name": "target_uri", 168 + "type": "text", 169 + "primaryKey": false, 170 + "notNull": true, 171 + "autoincrement": false 172 + }, 173 + "target_type": { 174 + "name": "target_type", 175 + "type": "text", 176 + "primaryKey": false, 177 + "notNull": true, 178 + "autoincrement": false 179 + }, 180 + "value": { 181 + "name": "value", 182 + "type": "integer", 183 + "primaryKey": false, 184 + "notNull": true, 185 + "autoincrement": false 186 + }, 187 + "created_at": { 188 + "name": "created_at", 189 + "type": "text", 190 + "primaryKey": false, 191 + "notNull": true, 192 + "autoincrement": false 193 + } 194 + }, 195 + "indexes": { 196 + "votes_user_target_idx": { 197 + "name": "votes_user_target_idx", 198 + "columns": [ 199 + "user_did", 200 + "target_uri" 201 + ], 202 + "isUnique": true 203 + } 204 + }, 205 + "foreignKeys": {}, 206 + "compositePrimaryKeys": {}, 207 + "uniqueConstraints": {}, 208 + "checkConstraints": {} 209 + } 210 + }, 211 + "views": {}, 212 + "enums": {}, 213 + "_meta": { 214 + "schemas": {}, 215 + "tables": {}, 216 + "columns": {} 217 + }, 218 + "internal": { 219 + "indexes": {} 220 + } 221 + }
+13
drizzle/local/meta/_journal.json
··· 1 + { 2 + "version": "7", 3 + "dialect": "sqlite", 4 + "entries": [ 5 + { 6 + "idx": 0, 7 + "version": "6", 8 + "when": 1765169604125, 9 + "tag": "0000_serious_night_thrasher", 10 + "breakpoints": true 11 + } 12 + ] 13 + }
+475
src/ingester/handler.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest'; 2 + import { EventHandler } from './handler'; 3 + import type { JetstreamEvent } from './jetstream'; 4 + 5 + // Mock the database operations 6 + const mockInsert = vi.fn().mockReturnThis(); 7 + const mockValues = vi.fn().mockReturnThis(); 8 + const mockOnConflictDoUpdate = vi.fn().mockResolvedValue(undefined); 9 + const mockDelete = vi.fn().mockReturnThis(); 10 + const mockWhere = vi.fn().mockResolvedValue(undefined); 11 + 12 + const mockDb = { 13 + insert: mockInsert, 14 + delete: mockDelete 15 + }; 16 + 17 + // Chain the mocks 18 + mockInsert.mockReturnValue({ values: mockValues }); 19 + mockValues.mockReturnValue({ onConflictDoUpdate: mockOnConflictDoUpdate }); 20 + mockDelete.mockReturnValue({ where: mockWhere }); 21 + 22 + describe('EventHandler', () => { 23 + let handler: EventHandler; 24 + 25 + beforeEach(() => { 26 + vi.clearAllMocks(); 27 + handler = new EventHandler(mockDb as any); 28 + }); 29 + 30 + describe('post events', () => { 31 + it('should create a new post', async () => { 32 + const event: JetstreamEvent = { 33 + did: 'did:plc:user123', 34 + time_us: 1234567890, 35 + kind: 'commit', 36 + commit: { 37 + operation: 'create', 38 + collection: 'one.papili.post', 39 + rkey: 'abc123', 40 + cid: 'bafyreicid123', 41 + rev: 'rev1', 42 + record: { 43 + $type: 'one.papili.post', 44 + title: 'Test Post', 45 + url: 'https://example.com', 46 + text: 'Some description', 47 + createdAt: '2024-01-15T12:00:00Z' 48 + } 49 + } 50 + }; 51 + 52 + await handler.handle(event); 53 + 54 + expect(mockInsert).toHaveBeenCalledWith(expect.anything()); 55 + expect(mockValues).toHaveBeenCalledWith( 56 + expect.objectContaining({ 57 + uri: 'at://did:plc:user123/one.papili.post/abc123', 58 + cid: 'bafyreicid123', 59 + authorDid: 'did:plc:user123', 60 + rkey: 'abc123', 61 + title: 'Test Post', 62 + url: 'https://example.com', 63 + text: 'Some description', 64 + createdAt: '2024-01-15T12:00:00Z', 65 + voteCount: 0 66 + }) 67 + ); 68 + }); 69 + 70 + it('should update an existing post', async () => { 71 + const event: JetstreamEvent = { 72 + did: 'did:plc:user123', 73 + time_us: 1234567890, 74 + kind: 'commit', 75 + commit: { 76 + operation: 'update', 77 + collection: 'one.papili.post', 78 + rkey: 'abc123', 79 + cid: 'bafyreicid456', 80 + rev: 'rev2', 81 + record: { 82 + $type: 'one.papili.post', 83 + title: 'Updated Title', 84 + url: 'https://example.com/updated', 85 + createdAt: '2024-01-15T12:00:00Z' 86 + } 87 + } 88 + }; 89 + 90 + await handler.handle(event); 91 + 92 + expect(mockOnConflictDoUpdate).toHaveBeenCalledWith( 93 + expect.objectContaining({ 94 + set: expect.objectContaining({ 95 + cid: 'bafyreicid456', 96 + title: 'Updated Title', 97 + url: 'https://example.com/updated' 98 + }) 99 + }) 100 + ); 101 + }); 102 + 103 + it('should delete a post', async () => { 104 + const event: JetstreamEvent = { 105 + did: 'did:plc:user123', 106 + time_us: 1234567890, 107 + kind: 'commit', 108 + commit: { 109 + operation: 'delete', 110 + collection: 'one.papili.post', 111 + rkey: 'abc123', 112 + rev: 'rev3' 113 + } 114 + }; 115 + 116 + await handler.handle(event); 117 + 118 + expect(mockDelete).toHaveBeenCalled(); 119 + expect(mockWhere).toHaveBeenCalled(); 120 + }); 121 + 122 + it('should handle posts without optional fields', async () => { 123 + const event: JetstreamEvent = { 124 + did: 'did:plc:user123', 125 + time_us: 1234567890, 126 + kind: 'commit', 127 + commit: { 128 + operation: 'create', 129 + collection: 'one.papili.post', 130 + rkey: 'abc123', 131 + cid: 'bafyreicid123', 132 + rev: 'rev1', 133 + record: { 134 + $type: 'one.papili.post', 135 + title: 'Title Only Post', 136 + createdAt: '2024-01-15T12:00:00Z' 137 + } 138 + } 139 + }; 140 + 141 + await handler.handle(event); 142 + 143 + expect(mockValues).toHaveBeenCalledWith( 144 + expect.objectContaining({ 145 + url: null, 146 + text: null 147 + }) 148 + ); 149 + }); 150 + 151 + it('should reject invalid post records', async () => { 152 + const event: JetstreamEvent = { 153 + did: 'did:plc:user123', 154 + time_us: 1234567890, 155 + kind: 'commit', 156 + commit: { 157 + operation: 'create', 158 + collection: 'one.papili.post', 159 + rkey: 'abc123', 160 + cid: 'bafyreicid123', 161 + rev: 'rev1', 162 + record: { 163 + // Missing required 'title' field 164 + url: 'https://example.com', 165 + createdAt: '2024-01-15T12:00:00Z' 166 + } 167 + } 168 + }; 169 + 170 + await handler.handle(event); 171 + 172 + // Should not call insert for invalid records 173 + expect(mockInsert).not.toHaveBeenCalled(); 174 + }); 175 + }); 176 + 177 + describe('comment events', () => { 178 + const validCid = 'bafyreib2rxk3rybpqaaiercaxddffmbthiey2luqapjcvhxhkz4d3z5cma'; 179 + const validCid2 = 'bafyreigvgb3xhqr7urwucszn6fqzohbmrr4b6xv4rv4mnjnthxytfhgf4q'; 180 + const validCid3 = 'bafyreibme22gw2h7y2h7tgvudb7m5wbqqo5smh5zjf5ggfp5khmhvqnkee'; 181 + 182 + it('should create a new comment', async () => { 183 + const event: JetstreamEvent = { 184 + did: 'did:plc:commenter', 185 + time_us: 1234567890, 186 + kind: 'commit', 187 + commit: { 188 + operation: 'create', 189 + collection: 'one.papili.comment', 190 + rkey: 'comment123', 191 + cid: validCid, 192 + rev: 'rev1', 193 + record: { 194 + $type: 'one.papili.comment', 195 + text: 'This is a comment', 196 + post: { 197 + uri: 'at://did:plc:author/one.papili.post/post123', 198 + cid: validCid2 199 + }, 200 + createdAt: '2024-01-15T13:00:00Z' 201 + } 202 + } 203 + }; 204 + 205 + await handler.handle(event); 206 + 207 + expect(mockValues).toHaveBeenCalledWith( 208 + expect.objectContaining({ 209 + uri: 'at://did:plc:commenter/one.papili.comment/comment123', 210 + authorDid: 'did:plc:commenter', 211 + text: 'This is a comment', 212 + postUri: 'at://did:plc:author/one.papili.post/post123', 213 + postCid: validCid2, 214 + parentUri: null, 215 + parentCid: null, 216 + voteCount: 0 217 + }) 218 + ); 219 + }); 220 + 221 + it('should create a reply to another comment', async () => { 222 + const event: JetstreamEvent = { 223 + did: 'did:plc:replier', 224 + time_us: 1234567890, 225 + kind: 'commit', 226 + commit: { 227 + operation: 'create', 228 + collection: 'one.papili.comment', 229 + rkey: 'reply123', 230 + cid: validCid, 231 + rev: 'rev1', 232 + record: { 233 + $type: 'one.papili.comment', 234 + text: 'This is a reply', 235 + post: { 236 + uri: 'at://did:plc:author/one.papili.post/post123', 237 + cid: validCid2 238 + }, 239 + parent: { 240 + uri: 'at://did:plc:commenter/one.papili.comment/comment123', 241 + cid: validCid3 242 + }, 243 + createdAt: '2024-01-15T14:00:00Z' 244 + } 245 + } 246 + }; 247 + 248 + await handler.handle(event); 249 + 250 + expect(mockValues).toHaveBeenCalledWith( 251 + expect.objectContaining({ 252 + parentUri: 'at://did:plc:commenter/one.papili.comment/comment123', 253 + parentCid: validCid3 254 + }) 255 + ); 256 + }); 257 + 258 + it('should delete a comment', async () => { 259 + const event: JetstreamEvent = { 260 + did: 'did:plc:commenter', 261 + time_us: 1234567890, 262 + kind: 'commit', 263 + commit: { 264 + operation: 'delete', 265 + collection: 'one.papili.comment', 266 + rkey: 'comment123', 267 + rev: 'rev2' 268 + } 269 + }; 270 + 271 + await handler.handle(event); 272 + 273 + expect(mockDelete).toHaveBeenCalled(); 274 + }); 275 + 276 + it('should reject invalid comment records', async () => { 277 + const event: JetstreamEvent = { 278 + did: 'did:plc:commenter', 279 + time_us: 1234567890, 280 + kind: 'commit', 281 + commit: { 282 + operation: 'create', 283 + collection: 'one.papili.comment', 284 + rkey: 'comment123', 285 + cid: 'bafyreicomment', 286 + rev: 'rev1', 287 + record: { 288 + // Missing required 'text' and 'post' fields 289 + createdAt: '2024-01-15T13:00:00Z' 290 + } 291 + } 292 + }; 293 + 294 + await handler.handle(event); 295 + 296 + expect(mockInsert).not.toHaveBeenCalled(); 297 + }); 298 + }); 299 + 300 + describe('account events', () => { 301 + it('should handle account activation', async () => { 302 + const event: JetstreamEvent = { 303 + did: 'did:plc:user123', 304 + time_us: 1234567890, 305 + kind: 'account', 306 + account: { 307 + did: 'did:plc:user123', 308 + active: true, 309 + seq: 12345, 310 + time: '2024-01-15T12:00:00Z' 311 + } 312 + }; 313 + 314 + await handler.handle(event); 315 + 316 + expect(mockValues).toHaveBeenCalledWith( 317 + expect.objectContaining({ 318 + did: 'did:plc:user123', 319 + active: 1, 320 + status: null, 321 + seq: 12345 322 + }) 323 + ); 324 + }); 325 + 326 + it('should handle account deactivation', async () => { 327 + const event: JetstreamEvent = { 328 + did: 'did:plc:user123', 329 + time_us: 1234567890, 330 + kind: 'account', 331 + account: { 332 + did: 'did:plc:user123', 333 + active: false, 334 + status: 'deactivated', 335 + seq: 12346, 336 + time: '2024-01-15T13:00:00Z' 337 + } 338 + }; 339 + 340 + await handler.handle(event); 341 + 342 + expect(mockValues).toHaveBeenCalledWith( 343 + expect.objectContaining({ 344 + active: 0, 345 + status: 'deactivated' 346 + }) 347 + ); 348 + }); 349 + 350 + it('should handle account suspension', async () => { 351 + const event: JetstreamEvent = { 352 + did: 'did:plc:baduser', 353 + time_us: 1234567890, 354 + kind: 'account', 355 + account: { 356 + did: 'did:plc:baduser', 357 + active: false, 358 + status: 'suspended', 359 + seq: 12347, 360 + time: '2024-01-15T14:00:00Z' 361 + } 362 + }; 363 + 364 + await handler.handle(event); 365 + 366 + expect(mockValues).toHaveBeenCalledWith( 367 + expect.objectContaining({ 368 + active: 0, 369 + status: 'suspended' 370 + }) 371 + ); 372 + }); 373 + 374 + it('should handle account takedown', async () => { 375 + const event: JetstreamEvent = { 376 + did: 'did:plc:violator', 377 + time_us: 1234567890, 378 + kind: 'account', 379 + account: { 380 + did: 'did:plc:violator', 381 + active: false, 382 + status: 'takendown', 383 + seq: 12348, 384 + time: '2024-01-15T15:00:00Z' 385 + } 386 + }; 387 + 388 + await handler.handle(event); 389 + 390 + expect(mockValues).toHaveBeenCalledWith( 391 + expect.objectContaining({ 392 + status: 'takendown' 393 + }) 394 + ); 395 + }); 396 + }); 397 + 398 + describe('identity events', () => { 399 + it('should handle handle change', async () => { 400 + const event: JetstreamEvent = { 401 + did: 'did:plc:user123', 402 + time_us: 1234567890, 403 + kind: 'identity', 404 + identity: { 405 + did: 'did:plc:user123', 406 + handle: 'newhandle.bsky.social', 407 + seq: 12349, 408 + time: '2024-01-15T16:00:00Z' 409 + } 410 + }; 411 + 412 + await handler.handle(event); 413 + 414 + expect(mockValues).toHaveBeenCalledWith( 415 + expect.objectContaining({ 416 + did: 'did:plc:user123', 417 + handle: 'newhandle.bsky.social', 418 + active: 1, 419 + seq: 12349 420 + }) 421 + ); 422 + }); 423 + 424 + it('should upsert on conflict for identity events', async () => { 425 + const event: JetstreamEvent = { 426 + did: 'did:plc:user123', 427 + time_us: 1234567890, 428 + kind: 'identity', 429 + identity: { 430 + did: 'did:plc:user123', 431 + handle: 'updated.bsky.social', 432 + seq: 12350, 433 + time: '2024-01-15T17:00:00Z' 434 + } 435 + }; 436 + 437 + await handler.handle(event); 438 + 439 + expect(mockOnConflictDoUpdate).toHaveBeenCalledWith( 440 + expect.objectContaining({ 441 + set: expect.objectContaining({ 442 + handle: 'updated.bsky.social', 443 + seq: 12350 444 + }) 445 + }) 446 + ); 447 + }); 448 + }); 449 + 450 + describe('unknown events', () => { 451 + it('should ignore events from unknown collections', async () => { 452 + const event: JetstreamEvent = { 453 + did: 'did:plc:user123', 454 + time_us: 1234567890, 455 + kind: 'commit', 456 + commit: { 457 + operation: 'create', 458 + collection: 'app.bsky.feed.post', 459 + rkey: 'abc123', 460 + cid: 'bafyreicid123', 461 + rev: 'rev1', 462 + record: { 463 + text: 'A bluesky post' 464 + } 465 + } 466 + }; 467 + 468 + await handler.handle(event); 469 + 470 + // Should not interact with db for unknown collections 471 + expect(mockInsert).not.toHaveBeenCalled(); 472 + expect(mockDelete).not.toHaveBeenCalled(); 473 + }); 474 + }); 475 + });
+104
src/lib/components/AdminControls.svelte
··· 1 + <script lang="ts"> 2 + import { invalidateAll } from '$app/navigation'; 3 + 4 + interface Props { 5 + targetUri: string; 6 + targetType: 'post' | 'comment'; 7 + isHidden: boolean; 8 + } 9 + 10 + let { targetUri, targetType, isHidden }: Props = $props(); 11 + 12 + let loading = $state(false); 13 + 14 + async function toggleHidden() { 15 + if (loading) return; 16 + 17 + loading = true; 18 + 19 + try { 20 + const formData = new FormData(); 21 + formData.append('uri', targetUri); 22 + formData.append('type', targetType); 23 + formData.append('action', isHidden ? 'unhide' : 'hide'); 24 + 25 + const res = await fetch('/api/admin/moderate', { 26 + method: 'POST', 27 + body: formData 28 + }); 29 + 30 + if (res.ok || res.status === 303) { 31 + // Refresh the page data 32 + await invalidateAll(); 33 + } 34 + } catch (err) { 35 + console.error('Failed to toggle hidden status:', err); 36 + } finally { 37 + loading = false; 38 + } 39 + } 40 + </script> 41 + 42 + <button 43 + type="button" 44 + class="admin-btn" 45 + class:hidden-state={isHidden} 46 + onclick={toggleHidden} 47 + disabled={loading} 48 + title={isHidden ? `Unhide this ${targetType}` : `Hide this ${targetType}`} 49 + > 50 + {#if loading} 51 + ... 52 + {:else if isHidden} 53 + [hidden] unhide 54 + {:else} 55 + hide 56 + {/if} 57 + </button> 58 + 59 + <style> 60 + .admin-btn { 61 + background: none; 62 + border: none; 63 + padding: 0; 64 + font-size: 0.75rem; 65 + color: #9ca3af; 66 + cursor: pointer; 67 + transition: color 0.15s; 68 + } 69 + 70 + .admin-btn:hover:not(:disabled) { 71 + color: #ef4444; 72 + } 73 + 74 + .admin-btn:disabled { 75 + cursor: wait; 76 + opacity: 0.5; 77 + } 78 + 79 + .admin-btn.hidden-state { 80 + color: #ef4444; 81 + } 82 + 83 + .admin-btn.hidden-state:hover:not(:disabled) { 84 + color: #22c55e; 85 + } 86 + 87 + @media (prefers-color-scheme: dark) { 88 + .admin-btn { 89 + color: #6b7280; 90 + } 91 + 92 + .admin-btn:hover:not(:disabled) { 93 + color: #f87171; 94 + } 95 + 96 + .admin-btn.hidden-state { 97 + color: #f87171; 98 + } 99 + 100 + .admin-btn.hidden-state:hover:not(:disabled) { 101 + color: #4ade80; 102 + } 103 + } 104 + </style>
+60
src/lib/components/Modal.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + open: boolean; 4 + onclose: () => void; 5 + title?: string; 6 + } 7 + 8 + let { open, onclose, title, children }: Props & { children: import('svelte').Snippet } = $props(); 9 + 10 + function handleKeydown(e: KeyboardEvent) { 11 + if (e.key === 'Escape') { 12 + onclose(); 13 + } 14 + } 15 + 16 + function handleBackdropClick(e: MouseEvent) { 17 + if (e.target === e.currentTarget) { 18 + onclose(); 19 + } 20 + } 21 + </script> 22 + 23 + <svelte:window onkeydown={handleKeydown} /> 24 + 25 + {#if open} 26 + <!-- svelte-ignore a11y_click_events_have_key_events --> 27 + <!-- svelte-ignore a11y_no_static_element_interactions --> 28 + <div 29 + class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" 30 + onclick={handleBackdropClick} 31 + > 32 + <div 33 + class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto" 34 + role="dialog" 35 + aria-modal="true" 36 + aria-labelledby={title ? 'modal-title' : undefined} 37 + > 38 + {#if title} 39 + <div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700"> 40 + <h2 id="modal-title" class="text-lg font-semibold text-gray-900 dark:text-gray-100"> 41 + {title} 42 + </h2> 43 + <button 44 + type="button" 45 + onclick={onclose} 46 + class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" 47 + aria-label="Close" 48 + > 49 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 50 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 51 + </svg> 52 + </button> 53 + </div> 54 + {/if} 55 + <div class="p-4"> 56 + {@render children()} 57 + </div> 58 + </div> 59 + </div> 60 + {/if}
+36
src/lib/components/Pagination.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + page: number; 4 + hasMore: boolean; 5 + baseUrl?: string; 6 + } 7 + 8 + let { page, hasMore, baseUrl = '' }: Props = $props(); 9 + 10 + // Build URL with page parameter 11 + function getPageUrl(pageNum: number): string { 12 + const separator = baseUrl.includes('?') ? '&' : '?'; 13 + return `${baseUrl}${separator}page=${pageNum}`; 14 + } 15 + </script> 16 + 17 + {#if page > 1 || hasMore} 18 + <nav class="mt-6 flex justify-center gap-4"> 19 + {#if page > 1} 20 + <a 21 + href={getPageUrl(page - 1)} 22 + class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 23 + > 24 + &larr; Newer 25 + </a> 26 + {/if} 27 + {#if hasMore} 28 + <a 29 + href={getPageUrl(page + 1)} 30 + class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 31 + > 32 + Older &rarr; 33 + </a> 34 + {/if} 35 + </nav> 36 + {/if}
+2 -23
src/lib/components/PostList.svelte
··· 2 2 import { invalidateAll } from '$app/navigation'; 3 3 import Avatar from './Avatar.svelte'; 4 4 import VoteButton from './VoteButton.svelte'; 5 + import PostTitle from './PostTitle.svelte'; 5 6 import { formatTimeAgo, getDomain } from '$lib/utils/formatting'; 6 7 import { pendingPosts } from '$lib/stores/pending'; 7 8 import type { AuthorProfile } from '$lib/types'; ··· 114 115 {/if} 115 116 <div class="flex-1 min-w-0"> 116 117 <div> 117 - {#if post.url} 118 - <a 119 - href={post.url} 120 - target="_blank" 121 - rel="noopener noreferrer" 122 - class="text-gray-900 dark:text-gray-100 visited:text-gray-500 dark:visited:text-gray-400 hover:underline" 123 - > 124 - {post.title} 125 - </a> 126 - <a 127 - href="/from/{getDomain(post.url)}" 128 - class="text-xs text-gray-400 dark:text-gray-500 ml-1 hover:text-violet-600 dark:hover:text-violet-400" 129 - > 130 - ({getDomain(post.url)}) 131 - </a> 132 - {:else} 133 - <a 134 - href="/post/{post.rkey}" 135 - class="text-gray-900 dark:text-gray-100 visited:text-gray-500 dark:visited:text-gray-400 hover:underline" 136 - > 137 - {post.title} 138 - </a> 139 - {/if} 118 + <PostTitle title={post.title} rkey={post.rkey} url={post.url} /> 140 119 </div> 141 120 {#if post.text} 142 121 <p class="text-xs text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">{post.text}</p>
+41
src/lib/components/PostTitle.svelte
··· 1 + <script lang="ts"> 2 + import { getDomain } from '$lib/utils/formatting'; 3 + 4 + interface Props { 5 + title: string; 6 + rkey: string; 7 + url?: string | null; 8 + /** HTML snippet for search results (rendered with {@html}) */ 9 + titleSnippet?: string | null; 10 + /** Additional CSS classes for the title link */ 11 + class?: string; 12 + } 13 + 14 + let { title, rkey, url, titleSnippet, class: className = '' }: Props = $props(); 15 + 16 + const titleClass = `text-gray-900 dark:text-gray-100 visited:text-gray-500 dark:visited:text-gray-400 hover:underline ${className}`.trim(); 17 + </script> 18 + 19 + {#if url} 20 + <a href={url} target="_blank" rel="noopener noreferrer" class={titleClass}> 21 + {#if titleSnippet} 22 + {@html titleSnippet} 23 + {:else} 24 + {title} 25 + {/if} 26 + </a> 27 + <a 28 + href="/from/{getDomain(url)}" 29 + class="text-xs text-gray-400 dark:text-gray-500 ml-1 hover:text-violet-600 dark:hover:text-violet-400" 30 + > 31 + ({getDomain(url)}) 32 + </a> 33 + {:else} 34 + <a href="/post/{rkey}" class={titleClass}> 35 + {#if titleSnippet} 36 + {@html titleSnippet} 37 + {:else} 38 + {title} 39 + {/if} 40 + </a> 41 + {/if}
+219
src/lib/components/ReportButton.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + targetUri: string; 4 + targetType: 'post' | 'comment'; 5 + disabled?: boolean; 6 + } 7 + 8 + let { targetUri, targetType, disabled = false }: Props = $props(); 9 + 10 + let showForm = $state(false); 11 + let reason = $state(''); 12 + let loading = $state(false); 13 + let submitted = $state(false); 14 + let error = $state<string | null>(null); 15 + 16 + async function submitReport() { 17 + if (loading) return; 18 + 19 + loading = true; 20 + error = null; 21 + 22 + try { 23 + const formData = new FormData(); 24 + formData.append('uri', targetUri); 25 + formData.append('type', targetType); 26 + if (reason.trim()) { 27 + formData.append('reason', reason.trim()); 28 + } 29 + 30 + const res = await fetch('/api/report', { 31 + method: 'POST', 32 + body: formData 33 + }); 34 + 35 + if (res.status === 401) { 36 + error = 'Please log in to report content'; 37 + return; 38 + } 39 + 40 + if (!res.ok) { 41 + const data = await res.json(); 42 + error = data.message || 'Failed to submit report'; 43 + return; 44 + } 45 + 46 + submitted = true; 47 + showForm = false; 48 + reason = ''; 49 + } catch { 50 + error = 'Failed to submit report'; 51 + } finally { 52 + loading = false; 53 + } 54 + } 55 + 56 + function cancel() { 57 + showForm = false; 58 + reason = ''; 59 + error = null; 60 + } 61 + </script> 62 + 63 + {#if submitted} 64 + <span class="reported">Reported</span> 65 + {:else if showForm} 66 + <div class="report-form"> 67 + <textarea 68 + bind:value={reason} 69 + placeholder="Why are you reporting this? (optional)" 70 + rows="2" 71 + maxlength="1000" 72 + ></textarea> 73 + {#if error} 74 + <p class="error">{error}</p> 75 + {/if} 76 + <div class="actions"> 77 + <button type="button" class="cancel-btn" onclick={cancel} disabled={loading}> 78 + Cancel 79 + </button> 80 + <button type="button" class="submit-btn" onclick={submitReport} disabled={loading}> 81 + {loading ? 'Sending...' : 'Submit Report'} 82 + </button> 83 + </div> 84 + </div> 85 + {:else} 86 + <button 87 + type="button" 88 + class="report-btn" 89 + onclick={() => (showForm = true)} 90 + {disabled} 91 + aria-label="Report this {targetType}" 92 + > 93 + Report 94 + </button> 95 + {/if} 96 + 97 + <style> 98 + .report-btn { 99 + background: none; 100 + border: none; 101 + padding: 0; 102 + font-size: 0.75rem; 103 + color: #9ca3af; 104 + cursor: pointer; 105 + transition: color 0.15s; 106 + } 107 + 108 + .report-btn:hover:not(:disabled) { 109 + color: #ef4444; 110 + } 111 + 112 + .report-btn:disabled { 113 + cursor: not-allowed; 114 + opacity: 0.5; 115 + } 116 + 117 + .reported { 118 + font-size: 0.75rem; 119 + color: #9ca3af; 120 + } 121 + 122 + .report-form { 123 + display: flex; 124 + flex-direction: column; 125 + gap: 0.5rem; 126 + min-width: 200px; 127 + } 128 + 129 + .report-form textarea { 130 + width: 100%; 131 + padding: 0.5rem; 132 + font-size: 0.875rem; 133 + border: 1px solid #d1d5db; 134 + border-radius: 0.375rem; 135 + resize: vertical; 136 + font-family: inherit; 137 + } 138 + 139 + .report-form textarea:focus { 140 + outline: none; 141 + border-color: #3b82f6; 142 + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); 143 + } 144 + 145 + .error { 146 + color: #ef4444; 147 + font-size: 0.75rem; 148 + margin: 0; 149 + } 150 + 151 + .actions { 152 + display: flex; 153 + gap: 0.5rem; 154 + justify-content: flex-end; 155 + } 156 + 157 + .cancel-btn { 158 + padding: 0.25rem 0.5rem; 159 + font-size: 0.75rem; 160 + background: #f3f4f6; 161 + border: 1px solid #d1d5db; 162 + border-radius: 0.25rem; 163 + cursor: pointer; 164 + } 165 + 166 + .cancel-btn:hover:not(:disabled) { 167 + background: #e5e7eb; 168 + } 169 + 170 + .submit-btn { 171 + padding: 0.25rem 0.5rem; 172 + font-size: 0.75rem; 173 + background: #ef4444; 174 + color: white; 175 + border: none; 176 + border-radius: 0.25rem; 177 + cursor: pointer; 178 + } 179 + 180 + .submit-btn:hover:not(:disabled) { 181 + background: #dc2626; 182 + } 183 + 184 + .submit-btn:disabled, 185 + .cancel-btn:disabled { 186 + opacity: 0.5; 187 + cursor: not-allowed; 188 + } 189 + 190 + @media (prefers-color-scheme: dark) { 191 + .report-btn { 192 + color: #6b7280; 193 + } 194 + 195 + .report-btn:hover:not(:disabled) { 196 + color: #f87171; 197 + } 198 + 199 + .report-form textarea { 200 + background: #1f2937; 201 + border-color: #374151; 202 + color: #f3f4f6; 203 + } 204 + 205 + .report-form textarea:focus { 206 + border-color: #3b82f6; 207 + } 208 + 209 + .cancel-btn { 210 + background: #374151; 211 + border-color: #4b5563; 212 + color: #f3f4f6; 213 + } 214 + 215 + .cancel-btn:hover:not(:disabled) { 216 + background: #4b5563; 217 + } 218 + } 219 + </style>
+31
src/lib/components/Tabs.svelte
··· 1 + <script lang="ts"> 2 + interface Tab { 3 + id: string; 4 + label: string; 5 + count?: number; 6 + } 7 + 8 + interface Props { 9 + tabs: Tab[]; 10 + activeTab: string; 11 + onchange?: (tabId: string) => void; 12 + } 13 + 14 + let { tabs, activeTab, onchange }: Props = $props(); 15 + </script> 16 + 17 + <div class="border-b border-gray-200 dark:border-gray-700"> 18 + <nav class="flex gap-4"> 19 + {#each tabs as tab (tab.id)} 20 + <button 21 + type="button" 22 + onclick={() => onchange?.(tab.id)} 23 + class="pb-2 text-sm font-medium border-b-2 transition-colors {activeTab === tab.id 24 + ? 'border-violet-600 text-violet-600 dark:border-violet-400 dark:text-violet-400' 25 + : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'}" 26 + > 27 + {tab.label}{#if tab.count !== undefined} ({tab.count}){/if} 28 + </button> 29 + {/each} 30 + </nav> 31 + </div>
+24
src/lib/server/admin.ts
··· 1 + /** 2 + * Admin configuration and utilities 3 + */ 4 + 5 + import { dev } from '$app/environment'; 6 + 7 + // Admin DIDs by environment 8 + const DEV_ADMIN_DIDS = ['did:plc:c27rgwpd6237vusy6irfotoy']; 9 + const PROD_ADMIN_DIDS = ['did:plc:gttrfs4hfmrclyxvwkwcgpj7']; 10 + 11 + /** 12 + * Get the list of admin DIDs for the current environment 13 + */ 14 + export function getAdminDids(): string[] { 15 + return dev ? DEV_ADMIN_DIDS : PROD_ADMIN_DIDS; 16 + } 17 + 18 + /** 19 + * Check if a DID has admin privileges 20 + */ 21 + export function isAdmin(did: string | null | undefined): boolean { 22 + if (!did) return false; 23 + return getAdminDids().includes(did); 24 + }
+4 -2
src/lib/server/db/content-schema.ts
··· 26 26 text: text('text'), 27 27 createdAt: text('created_at').notNull(), 28 28 indexedAt: text('indexed_at').notNull(), 29 - voteCount: integer('vote_count').notNull().default(0) 29 + voteCount: integer('vote_count').notNull().default(0), 30 + isHidden: integer('is_hidden').notNull().default(0) 30 31 }); 31 32 32 33 // Comments - threaded discussions ··· 42 43 text: text('text').notNull(), 43 44 createdAt: text('created_at').notNull(), 44 45 indexedAt: text('indexed_at').notNull(), 45 - voteCount: integer('vote_count').notNull().default(0) 46 + voteCount: integer('vote_count').notNull().default(0), 47 + isHidden: integer('is_hidden').notNull().default(0) 46 48 }); 47 49 48 50 // Jetstream cursor tracking (ingester only)
+13
src/lib/server/db/local-schema.ts
··· 33 33 }, 34 34 (table) => [uniqueIndex('votes_user_target_idx').on(table.userDid, table.targetUri)] 35 35 ); 36 + 37 + // User reports - stored locally 38 + export const reports = sqliteTable('reports', { 39 + id: integer('id').primaryKey({ autoIncrement: true }), 40 + reporterDid: text('reporter_did').notNull(), 41 + targetUri: text('target_uri').notNull(), 42 + targetType: text('target_type').notNull(), // 'post' | 'comment' 43 + reason: text('reason'), 44 + createdAt: text('created_at').notNull(), 45 + resolvedAt: text('resolved_at'), 46 + resolvedBy: text('resolved_by'), // admin DID who resolved 47 + resolution: text('resolution') // 'hidden' | 'dismissed' | null 48 + });
+218
src/lib/server/profiles.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; 2 + import { fetchProfile, fetchProfiles, getProfileOrFallback, clearProfileCache } from './profiles'; 3 + import type { AuthorProfile } from '$lib/types'; 4 + 5 + // Mock fetch 6 + const mockFetch = vi.fn(); 7 + vi.stubGlobal('fetch', mockFetch); 8 + 9 + describe('fetchProfile', () => { 10 + beforeEach(() => { 11 + mockFetch.mockReset(); 12 + clearProfileCache(); 13 + }); 14 + 15 + it('should fetch and return a profile', async () => { 16 + mockFetch.mockResolvedValueOnce({ 17 + ok: true, 18 + json: () => 19 + Promise.resolve({ 20 + did: 'did:plc:test123', 21 + handle: 'test.bsky.social', 22 + avatar: 'https://example.com/avatar.jpg' 23 + }) 24 + }); 25 + 26 + const profile = await fetchProfile('did:plc:test123'); 27 + 28 + expect(profile).toEqual({ 29 + did: 'did:plc:test123', 30 + handle: 'test.bsky.social', 31 + avatar: 'https://example.com/avatar.jpg' 32 + }); 33 + expect(mockFetch).toHaveBeenCalledWith( 34 + expect.stringContaining('did%3Aplc%3Atest123') 35 + ); 36 + }); 37 + 38 + it('should return null on HTTP error', async () => { 39 + mockFetch.mockResolvedValueOnce({ 40 + ok: false, 41 + status: 404 42 + }); 43 + 44 + const profile = await fetchProfile('did:plc:notfound'); 45 + 46 + expect(profile).toBeNull(); 47 + }); 48 + 49 + it('should return null on network error', async () => { 50 + mockFetch.mockRejectedValueOnce(new Error('Network error')); 51 + 52 + const profile = await fetchProfile('did:plc:test123'); 53 + 54 + expect(profile).toBeNull(); 55 + }); 56 + 57 + it('should handle profiles without avatars', async () => { 58 + mockFetch.mockResolvedValueOnce({ 59 + ok: true, 60 + json: () => 61 + Promise.resolve({ 62 + did: 'did:plc:noavatar', 63 + handle: 'noavatar.bsky.social' 64 + // No avatar field 65 + }) 66 + }); 67 + 68 + const profile = await fetchProfile('did:plc:noavatar'); 69 + 70 + expect(profile).toEqual({ 71 + did: 'did:plc:noavatar', 72 + handle: 'noavatar.bsky.social', 73 + avatar: undefined 74 + }); 75 + }); 76 + }); 77 + 78 + describe('fetchProfiles', () => { 79 + beforeEach(() => { 80 + mockFetch.mockReset(); 81 + clearProfileCache(); 82 + }); 83 + 84 + it('should fetch multiple profiles in parallel', async () => { 85 + mockFetch 86 + .mockResolvedValueOnce({ 87 + ok: true, 88 + json: () => 89 + Promise.resolve({ 90 + did: 'did:plc:user1', 91 + handle: 'user1.bsky.social' 92 + }) 93 + }) 94 + .mockResolvedValueOnce({ 95 + ok: true, 96 + json: () => 97 + Promise.resolve({ 98 + did: 'did:plc:user2', 99 + handle: 'user2.bsky.social' 100 + }) 101 + }); 102 + 103 + const profiles = await fetchProfiles(['did:plc:user1', 'did:plc:user2']); 104 + 105 + expect(profiles.size).toBe(2); 106 + expect(profiles.get('did:plc:user1')?.handle).toBe('user1.bsky.social'); 107 + expect(profiles.get('did:plc:user2')?.handle).toBe('user2.bsky.social'); 108 + }); 109 + 110 + it('should deduplicate DIDs', async () => { 111 + mockFetch.mockResolvedValue({ 112 + ok: true, 113 + json: () => 114 + Promise.resolve({ 115 + did: 'did:plc:duplicate', 116 + handle: 'duplicate.bsky.social' 117 + }) 118 + }); 119 + 120 + const profiles = await fetchProfiles([ 121 + 'did:plc:duplicate', 122 + 'did:plc:duplicate', 123 + 'did:plc:duplicate' 124 + ]); 125 + 126 + // Should only make one fetch call 127 + expect(mockFetch).toHaveBeenCalledTimes(1); 128 + expect(profiles.size).toBe(1); 129 + }); 130 + 131 + it('should handle partial failures', async () => { 132 + mockFetch 133 + .mockResolvedValueOnce({ 134 + ok: true, 135 + json: () => 136 + Promise.resolve({ 137 + did: 'did:plc:success', 138 + handle: 'success.bsky.social' 139 + }) 140 + }) 141 + .mockResolvedValueOnce({ 142 + ok: false, 143 + status: 404 144 + }) 145 + .mockResolvedValueOnce({ 146 + ok: true, 147 + json: () => 148 + Promise.resolve({ 149 + did: 'did:plc:success2', 150 + handle: 'success2.bsky.social' 151 + }) 152 + }); 153 + 154 + const profiles = await fetchProfiles([ 155 + 'did:plc:success', 156 + 'did:plc:notfound', 157 + 'did:plc:success2' 158 + ]); 159 + 160 + // Should have 2 profiles, failed one excluded 161 + expect(profiles.size).toBe(2); 162 + expect(profiles.has('did:plc:success')).toBe(true); 163 + expect(profiles.has('did:plc:notfound')).toBe(false); 164 + expect(profiles.has('did:plc:success2')).toBe(true); 165 + }); 166 + 167 + it('should return empty map for empty input', async () => { 168 + const profiles = await fetchProfiles([]); 169 + 170 + expect(profiles.size).toBe(0); 171 + expect(mockFetch).not.toHaveBeenCalled(); 172 + }); 173 + 174 + it('should handle all failures gracefully', async () => { 175 + mockFetch.mockRejectedValue(new Error('Network error')); 176 + 177 + const profiles = await fetchProfiles(['did:plc:fail1', 'did:plc:fail2']); 178 + 179 + // Should return empty map, not throw 180 + expect(profiles.size).toBe(0); 181 + }); 182 + }); 183 + 184 + describe('getProfileOrFallback', () => { 185 + it('should return profile from map when found', () => { 186 + const profiles = new Map<string, AuthorProfile>(); 187 + profiles.set('did:plc:found', { 188 + did: 'did:plc:found' as `did:${string}:${string}`, 189 + handle: 'found.bsky.social' as `${string}.${string}`, 190 + avatar: 'https://example.com/avatar.jpg' 191 + }); 192 + 193 + const result = getProfileOrFallback(profiles, 'did:plc:found'); 194 + 195 + expect(result.handle).toBe('found.bsky.social'); 196 + expect(result.avatar).toBe('https://example.com/avatar.jpg'); 197 + }); 198 + 199 + it('should return fallback when profile not found', () => { 200 + const profiles = new Map<string, AuthorProfile>(); 201 + 202 + const result = getProfileOrFallback(profiles, 'did:plc:notfound123'); 203 + 204 + expect(result.did).toBe('did:plc:notfound123'); 205 + expect(result.handle).toBe('did:plc:notfound123...'); 206 + }); 207 + 208 + it('should truncate long DIDs in fallback', () => { 209 + const profiles = new Map<string, AuthorProfile>(); 210 + const longDid = 'did:plc:verylongidentifierthatexceedstwentycharacters'; 211 + 212 + const result = getProfileOrFallback(profiles, longDid); 213 + 214 + // slice(0, 20) gives first 20 chars, then '...' 215 + expect(result.handle).toBe('did:plc:verylongiden...'); 216 + expect(result.handle.length).toBe(23); // 20 chars + '...' 217 + }); 218 + });
+7
src/lib/server/profiles.ts
··· 45 45 } 46 46 47 47 /** 48 + * No-op cache clear for test compatibility (no caching implemented) 49 + */ 50 + export function clearProfileCache(): void { 51 + // No caching implemented - no-op 52 + } 53 + 54 + /** 48 55 * Get a profile from a map, with fallback to truncated DID if not found 49 56 */ 50 57 export function getProfileOrFallback(
+119
src/lib/server/queries/comments.ts
··· 1 + /** 2 + * Shared comment query functions. 3 + * Centralizes comment fetching logic to avoid duplication. 4 + */ 5 + 6 + import { contentDb } from '$lib/server/db'; 7 + import { posts, comments } from '$lib/server/db/schema'; 8 + import { desc, eq, and, type SQL } from 'drizzle-orm'; 9 + import type { CommentBase } from '../enrichment'; 10 + 11 + export const DEFAULT_COMMENTS_LIMIT = 50; 12 + 13 + /** Comment with post context for display in listings */ 14 + export interface CommentWithPostContext extends CommentBase { 15 + postRkey: string; 16 + postTitle: string; 17 + } 18 + 19 + /** Options for fetching comments */ 20 + export interface GetCommentsOptions { 21 + /** Number of comments to fetch (default: 50) */ 22 + limit?: number; 23 + /** Additional WHERE clause for comments table */ 24 + where?: SQL; 25 + /** Include hidden comments (default: false, set true for admin views) */ 26 + includeHidden?: boolean; 27 + /** Filter to comments by this author DID */ 28 + authorDid?: string; 29 + /** Filter to comments on this post URI */ 30 + postUri?: string; 31 + } 32 + 33 + /** 34 + * Fetch recent comments with post context. 35 + * Hidden comments and comments on hidden posts are excluded by default. 36 + */ 37 + export async function getCommentsWithPostContext( 38 + options: GetCommentsOptions = {} 39 + ): Promise<CommentWithPostContext[]> { 40 + const { limit = DEFAULT_COMMENTS_LIMIT, where, includeHidden = false, authorDid, postUri } = options; 41 + 42 + // Build where conditions 43 + const conditions: SQL[] = []; 44 + 45 + if (!includeHidden) { 46 + conditions.push(eq(comments.isHidden, 0)); 47 + conditions.push(eq(posts.isHidden, 0)); 48 + } 49 + 50 + if (authorDid) { 51 + conditions.push(eq(comments.authorDid, authorDid)); 52 + } 53 + 54 + if (postUri) { 55 + conditions.push(eq(comments.postUri, postUri)); 56 + } 57 + 58 + if (where) { 59 + conditions.push(where); 60 + } 61 + 62 + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; 63 + 64 + let query = contentDb 65 + .select({ 66 + uri: comments.uri, 67 + cid: comments.cid, 68 + authorDid: comments.authorDid, 69 + rkey: comments.rkey, 70 + postUri: comments.postUri, 71 + parentUri: comments.parentUri, 72 + text: comments.text, 73 + createdAt: comments.createdAt, 74 + indexedAt: comments.indexedAt, 75 + postRkey: posts.rkey, 76 + postTitle: posts.title 77 + }) 78 + .from(comments) 79 + .innerJoin(posts, eq(comments.postUri, posts.uri)) 80 + .orderBy(desc(comments.createdAt)) 81 + .limit(limit); 82 + 83 + if (whereClause) { 84 + query = query.where(whereClause) as typeof query; 85 + } 86 + 87 + return query; 88 + } 89 + 90 + /** 91 + * Fetch comments for a specific post. 92 + * Hidden comments are excluded unless includeHidden is true. 93 + * Note: Does not check if the post itself is hidden - caller should verify. 94 + */ 95 + export async function getCommentsForPost( 96 + postUri: string, 97 + options: { includeHidden?: boolean } = {} 98 + ): Promise<CommentBase[]> { 99 + const { includeHidden = false } = options; 100 + 101 + const whereClause = includeHidden 102 + ? eq(comments.postUri, postUri) 103 + : and(eq(comments.postUri, postUri), eq(comments.isHidden, 0)); 104 + 105 + return contentDb 106 + .select({ 107 + uri: comments.uri, 108 + cid: comments.cid, 109 + authorDid: comments.authorDid, 110 + rkey: comments.rkey, 111 + postUri: comments.postUri, 112 + parentUri: comments.parentUri, 113 + text: comments.text, 114 + createdAt: comments.createdAt, 115 + indexedAt: comments.indexedAt 116 + }) 117 + .from(comments) 118 + .where(whereClause); 119 + }
+16 -5
src/lib/server/queries/posts.ts
··· 5 5 6 6 import { contentDb } from '$lib/server/db'; 7 7 import { posts, comments } from '$lib/server/db/schema'; 8 - import { desc, eq, count, sql, type SQL } from 'drizzle-orm'; 8 + import { desc, eq, count, and, type SQL } from 'drizzle-orm'; 9 9 import type { PostBase } from '../enrichment'; 10 10 11 11 export const DEFAULT_PAGE_SIZE = 30; ··· 20 20 where?: SQL; 21 21 /** Order by field (default: createdAt desc) */ 22 22 orderBy?: 'createdAt' | 'indexedAt'; 23 + /** Include hidden posts (default: false, set true for admin views) */ 24 + includeHidden?: boolean; 23 25 } 24 26 25 27 /** 26 28 * Fetch posts with comment counts. 27 29 * This is the base query used by all post listing pages. 30 + * Hidden posts are excluded by default unless includeHidden is true. 28 31 */ 29 32 export async function getPostsWithCommentCount( 30 33 options: GetPostsOptions = {} 31 34 ): Promise<PostBase[]> { 32 - const { limit = DEFAULT_PAGE_SIZE, offset = 0, where, orderBy = 'createdAt' } = options; 35 + const { limit = DEFAULT_PAGE_SIZE, offset = 0, where, orderBy = 'createdAt', includeHidden = false } = options; 36 + 37 + // Build where clause - always filter hidden unless explicitly requested 38 + let whereClause: SQL | undefined; 39 + if (!includeHidden) { 40 + whereClause = where ? and(eq(posts.isHidden, 0), where) : eq(posts.isHidden, 0); 41 + } else { 42 + whereClause = where; 43 + } 33 44 34 45 let query = contentDb 35 46 .select({ ··· 45 56 commentCount: count(comments.uri) 46 57 }) 47 58 .from(posts) 48 - .leftJoin(comments, eq(comments.postUri, posts.uri)) 59 + .leftJoin(comments, and(eq(comments.postUri, posts.uri), eq(comments.isHidden, 0))) 49 60 .groupBy(posts.uri) 50 61 .orderBy(orderBy === 'createdAt' ? desc(posts.createdAt) : desc(posts.indexedAt)) 51 62 .limit(limit) 52 63 .offset(offset); 53 64 54 - if (where) { 55 - query = query.where(where) as typeof query; 65 + if (whereClause) { 66 + query = query.where(whereClause) as typeof query; 56 67 } 57 68 58 69 return query;
+149
src/lib/server/search/queries.ts
··· 1 + /** 2 + * Shared FTS search query functions. 3 + * Centralizes search logic to avoid duplication between page and API routes. 4 + */ 5 + 6 + import { contentClient } from '$lib/server/db'; 7 + import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 8 + import type { AuthorProfile } from '$lib/types'; 9 + 10 + /** FTS snippet configuration */ 11 + const SNIPPET_CONFIG = { 12 + openTag: '<mark>', 13 + closeTag: '</mark>', 14 + ellipsis: '...', 15 + titleLength: 32, 16 + textLength: 64 17 + } as const; 18 + 19 + /** Build SQL snippet() function call */ 20 + function snippetSQL(table: string, columnIndex: number, maxLength: number): string { 21 + const { openTag, closeTag, ellipsis } = SNIPPET_CONFIG; 22 + return `snippet(${table}, ${columnIndex}, '${openTag}', '${closeTag}', '${ellipsis}', ${maxLength})`; 23 + } 24 + 25 + export interface SearchPost { 26 + uri: string; 27 + cid: string; 28 + authorDid: string; 29 + rkey: string; 30 + url: string | null; 31 + title: string; 32 + text: string | null; 33 + createdAt: string; 34 + voteCount: number; 35 + titleSnippet: string | null; 36 + textSnippet: string | null; 37 + author: AuthorProfile; 38 + } 39 + 40 + export interface SearchComment { 41 + uri: string; 42 + cid: string; 43 + authorDid: string; 44 + rkey: string; 45 + postUri: string; 46 + postRkey: string; 47 + postTitle: string; 48 + text: string; 49 + createdAt: string; 50 + voteCount: number; 51 + textSnippet: string | null; 52 + author: AuthorProfile; 53 + } 54 + 55 + /** 56 + * Search posts using FTS5. 57 + * Returns posts matching the query with author profiles and snippets. 58 + */ 59 + export async function searchPosts(ftsQuery: string, limit: number = 50): Promise<SearchPost[]> { 60 + const postsResult = await contentClient.execute({ 61 + sql: ` 62 + SELECT 63 + p.uri, 64 + p.cid, 65 + p.author_did as authorDid, 66 + p.rkey, 67 + p.url, 68 + p.title, 69 + p.text, 70 + p.created_at as createdAt, 71 + p.vote_count as voteCount, 72 + ${snippetSQL('posts_fts', 1, SNIPPET_CONFIG.titleLength)} as titleSnippet, 73 + ${snippetSQL('posts_fts', 2, SNIPPET_CONFIG.textLength)} as textSnippet 74 + FROM posts_fts 75 + JOIN posts p ON posts_fts.uri = p.uri 76 + WHERE posts_fts MATCH ? AND p.is_hidden = 0 77 + ORDER BY rank 78 + LIMIT ? 79 + `, 80 + args: [ftsQuery, limit] 81 + }); 82 + 83 + const authorDids = postsResult.rows.map((r) => r.authorDid as string); 84 + const profiles = await fetchProfiles(authorDids); 85 + 86 + return postsResult.rows.map((row) => ({ 87 + uri: row.uri as string, 88 + cid: row.cid as string, 89 + authorDid: row.authorDid as string, 90 + rkey: row.rkey as string, 91 + url: row.url as string | null, 92 + title: row.title as string, 93 + text: row.text as string | null, 94 + createdAt: row.createdAt as string, 95 + voteCount: row.voteCount as number, 96 + titleSnippet: row.titleSnippet as string | null, 97 + textSnippet: row.textSnippet as string | null, 98 + author: getProfileOrFallback(profiles, row.authorDid as string) 99 + })); 100 + } 101 + 102 + /** 103 + * Search comments using FTS5. 104 + * Returns comments matching the query with author profiles and snippets. 105 + * Excludes hidden comments and comments on hidden posts. 106 + */ 107 + export async function searchComments(ftsQuery: string, limit: number = 50): Promise<SearchComment[]> { 108 + const commentsResult = await contentClient.execute({ 109 + sql: ` 110 + SELECT 111 + c.uri, 112 + c.cid, 113 + c.author_did as authorDid, 114 + c.rkey, 115 + c.post_uri as postUri, 116 + c.text, 117 + c.created_at as createdAt, 118 + c.vote_count as voteCount, 119 + p.rkey as postRkey, 120 + p.title as postTitle, 121 + ${snippetSQL('comments_fts', 2, SNIPPET_CONFIG.textLength)} as textSnippet 122 + FROM comments_fts 123 + JOIN comments c ON comments_fts.uri = c.uri 124 + JOIN posts p ON c.post_uri = p.uri 125 + WHERE comments_fts MATCH ? AND c.is_hidden = 0 AND p.is_hidden = 0 126 + ORDER BY rank 127 + LIMIT ? 128 + `, 129 + args: [ftsQuery, limit] 130 + }); 131 + 132 + const authorDids = commentsResult.rows.map((r) => r.authorDid as string); 133 + const profiles = await fetchProfiles(authorDids); 134 + 135 + return commentsResult.rows.map((row) => ({ 136 + uri: row.uri as string, 137 + cid: row.cid as string, 138 + authorDid: row.authorDid as string, 139 + rkey: row.rkey as string, 140 + postUri: row.postUri as string, 141 + postRkey: row.postRkey as string, 142 + postTitle: row.postTitle as string, 143 + text: row.text as string, 144 + createdAt: row.createdAt as string, 145 + voteCount: row.voteCount as number, 146 + textSnippet: row.textSnippet as string | null, 147 + author: getProfileOrFallback(profiles, row.authorDid as string) 148 + })); 149 + }
+77
src/lib/server/tracing.ts
··· 1 + /** 2 + * Shared OpenTelemetry tracing utilities 3 + * 4 + * This module provides helpers for manual span creation and 5 + * initializing tracing for standalone processes (like the ingester). 6 + */ 7 + 8 + import { trace, context, SpanStatusCode, type Span } from '@opentelemetry/api'; 9 + 10 + /** Get a tracer for creating spans */ 11 + export function getTracer(name: string = 'papili') { 12 + return trace.getTracer(name); 13 + } 14 + 15 + /** Get the current active span */ 16 + export function getCurrentSpan(): Span | undefined { 17 + return trace.getSpan(context.active()); 18 + } 19 + 20 + /** 21 + * Execute a function within a new span 22 + */ 23 + export async function withSpan<T>( 24 + name: string, 25 + fn: (span: Span) => Promise<T>, 26 + attributes?: Record<string, string | number | boolean> 27 + ): Promise<T> { 28 + const tracer = getTracer(); 29 + return tracer.startActiveSpan(name, async (span) => { 30 + if (attributes) { 31 + span.setAttributes(attributes); 32 + } 33 + try { 34 + const result = await fn(span); 35 + span.setStatus({ code: SpanStatusCode.OK }); 36 + return result; 37 + } catch (error) { 38 + span.setStatus({ 39 + code: SpanStatusCode.ERROR, 40 + message: error instanceof Error ? error.message : 'Unknown error' 41 + }); 42 + span.recordException(error instanceof Error ? error : new Error(String(error))); 43 + throw error; 44 + } finally { 45 + span.end(); 46 + } 47 + }); 48 + } 49 + 50 + /** 51 + * Execute a sync function within a new span 52 + */ 53 + export function withSpanSync<T>( 54 + name: string, 55 + fn: (span: Span) => T, 56 + attributes?: Record<string, string | number | boolean> 57 + ): T { 58 + const tracer = getTracer(); 59 + const span = tracer.startSpan(name); 60 + if (attributes) { 61 + span.setAttributes(attributes); 62 + } 63 + try { 64 + const result = fn(span); 65 + span.setStatus({ code: SpanStatusCode.OK }); 66 + return result; 67 + } catch (error) { 68 + span.setStatus({ 69 + code: SpanStatusCode.ERROR, 70 + message: error instanceof Error ? error.message : 'Unknown error' 71 + }); 72 + span.recordException(error instanceof Error ? error : new Error(String(error))); 73 + throw error; 74 + } finally { 75 + span.end(); 76 + } 77 + }
+123
src/lib/utils/ranking.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; 2 + import { calculateHotScore } from './ranking'; 3 + 4 + describe('calculateHotScore', () => { 5 + beforeEach(() => { 6 + // Use fake timers for consistent test results 7 + vi.useFakeTimers(); 8 + vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); 9 + }); 10 + 11 + afterEach(() => { 12 + vi.useRealTimers(); 13 + }); 14 + 15 + it('should calculate score for a new post with votes', () => { 16 + const createdAt = '2024-01-15T12:00:00Z'; // Just created 17 + const score = calculateHotScore(10, createdAt); 18 + 19 + // score = 10 / (0 + 2)^1.5 = 10 / 2.828... ≈ 3.535 20 + expect(score).toBeCloseTo(3.535, 2); 21 + }); 22 + 23 + it('should return lower score for older posts', () => { 24 + const newPost = '2024-01-15T12:00:00Z'; // 0 hours old 25 + const oldPost = '2024-01-15T06:00:00Z'; // 6 hours old 26 + 27 + const newScore = calculateHotScore(10, newPost); 28 + const oldScore = calculateHotScore(10, oldPost); 29 + 30 + expect(newScore).toBeGreaterThan(oldScore); 31 + }); 32 + 33 + it('should return higher score for more votes', () => { 34 + const createdAt = '2024-01-15T10:00:00Z'; // 2 hours old 35 + 36 + const lowVotes = calculateHotScore(5, createdAt); 37 + const highVotes = calculateHotScore(50, createdAt); 38 + 39 + expect(highVotes).toBeGreaterThan(lowVotes); 40 + }); 41 + 42 + it('should handle zero votes', () => { 43 + const createdAt = '2024-01-15T12:00:00Z'; 44 + const score = calculateHotScore(0, createdAt); 45 + 46 + expect(score).toBe(0); 47 + }); 48 + 49 + it('should handle negative vote counts (edge case)', () => { 50 + // While votes shouldn't be negative, the function should handle it 51 + const createdAt = '2024-01-15T12:00:00Z'; 52 + const score = calculateHotScore(-5, createdAt); 53 + 54 + // Negative score is mathematically valid 55 + expect(score).toBeLessThan(0); 56 + }); 57 + 58 + it('should handle very old posts', () => { 59 + // 1 year old 60 + const createdAt = '2023-01-15T12:00:00Z'; 61 + const score = calculateHotScore(100, createdAt); 62 + 63 + // Should be a very small positive number 64 + expect(score).toBeGreaterThan(0); 65 + expect(score).toBeLessThan(0.001); 66 + }); 67 + 68 + it('should apply custom gravity parameter', () => { 69 + const createdAt = '2024-01-15T10:00:00Z'; // 2 hours old 70 + 71 + const defaultGravity = calculateHotScore(10, createdAt); // gravity = 1.5 72 + const lowerGravity = calculateHotScore(10, createdAt, 1.0); 73 + const higherGravity = calculateHotScore(10, createdAt, 2.0); 74 + 75 + // Lower gravity = higher score for same votes/age 76 + expect(lowerGravity).toBeGreaterThan(defaultGravity); 77 + // Higher gravity = lower score for same votes/age 78 + expect(higherGravity).toBeLessThan(defaultGravity); 79 + }); 80 + 81 + it('should order posts correctly', () => { 82 + // Test that the algorithm produces the expected ordering 83 + const posts = [ 84 + { votes: 100, age: '2024-01-14T12:00:00Z' }, // 24h old, many votes 85 + { votes: 10, age: '2024-01-15T11:00:00Z' }, // 1h old, few votes 86 + { votes: 50, age: '2024-01-15T06:00:00Z' }, // 6h old, medium votes 87 + { votes: 5, age: '2024-01-15T11:50:00Z' } // 10min old, very few votes 88 + ]; 89 + 90 + const scores = posts.map((p) => ({ 91 + ...p, 92 + score: calculateHotScore(p.votes, p.age) 93 + })); 94 + 95 + // Sort by score descending 96 + scores.sort((a, b) => b.score - a.score); 97 + 98 + // The algorithm should favor recency with decent votes 99 + // This test verifies the general behavior, not exact ordering 100 + expect(scores[0].score).toBeGreaterThan(scores[1].score); 101 + expect(scores[1].score).toBeGreaterThan(scores[2].score); 102 + }); 103 + 104 + it('should handle future timestamps gracefully', () => { 105 + // Edge case: post claims to be from the future (clock skew) 106 + const futureDate = '2024-01-15T13:00:00Z'; // 1 hour in future 107 + const score = calculateHotScore(10, futureDate); 108 + 109 + // Age will be negative, making (age + 2)^1.5 = (negative + 2)^1.5 110 + // For -1 + 2 = 1, score = 10 / 1 = 10 111 + expect(score).toBeGreaterThan(0); 112 + expect(score).toBe(10); // 10 / 1^1.5 = 10 113 + }); 114 + 115 + it('should produce deterministic results', () => { 116 + const createdAt = '2024-01-15T10:00:00Z'; 117 + 118 + const score1 = calculateHotScore(42, createdAt); 119 + const score2 = calculateHotScore(42, createdAt); 120 + 121 + expect(score1).toBe(score2); 122 + }); 123 + });
+3 -1
src/routes/+layout.server.ts
··· 1 1 import type { LayoutServerLoad } from './$types'; 2 2 import { fetchProfile } from '$lib/server/profiles'; 3 + import { isAdmin } from '$lib/server/admin'; 3 4 4 5 export const load: LayoutServerLoad = async ({ locals }) => { 5 6 const user = locals.did ? await fetchProfile(locals.did) : null; 6 7 7 8 return { 8 9 did: locals.did, 9 - user 10 + user, 11 + isAdmin: isAdmin(locals.did) 10 12 }; 11 13 };
+57
src/routes/+layout.svelte
··· 3 3 import favicon from '$lib/assets/favicon.svg'; 4 4 import Logo from '$lib/components/Logo.svelte'; 5 5 import SearchBox from '$lib/components/SearchBox.svelte'; 6 + import Modal from '$lib/components/Modal.svelte'; 6 7 7 8 let { children, data } = $props(); 8 9 let menuOpen = $state(false); 10 + let alphaModalOpen = $state(false); 9 11 10 12 function closeMenu() { 11 13 menuOpen = false; ··· 27 29 <header class="bg-violet-600 dark:bg-violet-700"> 28 30 <nav class="mx-auto flex max-w-4xl items-center gap-3 px-2 sm:px-4 py-2 text-sm"> 29 31 <Logo /> 32 + <button 33 + type="button" 34 + onclick={() => alphaModalOpen = true} 35 + class="px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide bg-amber-400 text-amber-900 rounded hover:bg-amber-300 transition-colors" 36 + > 37 + alpha 38 + </button> 30 39 <a href="/new" class="text-violet-200 hover:text-white">new</a> 31 40 <a href="/comments" class="text-violet-200 hover:text-white">comments</a> 32 41 ··· 40 49 <div class="hidden sm:flex items-center gap-3"> 41 50 {#if data.user} 42 51 <a href="/submit" class="text-violet-200 hover:text-white">submit</a> 52 + {#if data.isAdmin} 53 + <span class="text-violet-300">|</span> 54 + <a href="/admin" class="text-violet-200 hover:text-white">admin</a> 55 + {/if} 43 56 <span class="text-violet-300">|</span> 44 57 <a href="/profile/{data.user.did}" class="text-violet-200 hover:text-white">{data.user.handle}</a> 45 58 <span class="text-violet-300">|</span> ··· 102 115 Profile 103 116 </a> 104 117 {/if} 118 + {#if data.isAdmin} 119 + <a 120 + href="/admin" 121 + class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700" 122 + onclick={closeMenu} 123 + > 124 + Admin 125 + </a> 126 + {/if} 105 127 <hr class="my-1 border-gray-200 dark:border-gray-700" /> 106 128 <a 107 129 href="/logout" ··· 130 152 </div> 131 153 </footer> 132 154 </div> 155 + 156 + <Modal open={alphaModalOpen} onclose={() => alphaModalOpen = false} title="Alpha Release"> 157 + <div class="space-y-4 text-sm text-gray-700 dark:text-gray-300"> 158 + <p> 159 + <strong class="text-gray-900 dark:text-gray-100">papilione is currently in alpha.</strong> 160 + </p> 161 + <p> 162 + This means the application is under active development and you may encounter: 163 + </p> 164 + <ul class="list-disc list-inside space-y-1 ml-2"> 165 + <li>Breaking changes to lexicons and data schemas</li> 166 + <li>Features that may be added, changed, or removed</li> 167 + <li>Bugs and unexpected behavior</li> 168 + <li>Potential data migrations or resets</li> 169 + </ul> 170 + <p> 171 + Your posts and comments are stored on the ATProto network via your PDS, but local data 172 + like votes may be affected by changes during the alpha period. 173 + </p> 174 + <p> 175 + Found a bug or have feedback? 176 + <a 177 + href="https://tangled.org/aparker.io/papili.one/issues" 178 + target="_blank" 179 + rel="noopener noreferrer" 180 + class="text-violet-600 dark:text-violet-400 hover:underline" 181 + > 182 + Submit an issue 183 + </a> 184 + </p> 185 + <p class="text-gray-500 dark:text-gray-400"> 186 + Thanks for being an early user and helping shape papilione! 187 + </p> 188 + </div> 189 + </Modal>
+2 -19
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import PostList from '$lib/components/PostList.svelte'; 3 + import Pagination from '$lib/components/Pagination.svelte'; 3 4 4 5 let { data } = $props(); 5 6 </script> ··· 15 16 currentUserHandle={data.user?.handle} 16 17 /> 17 18 18 - <!-- Pagination --> 19 - <nav class="mt-6 flex justify-center gap-4"> 20 - {#if data.page > 1} 21 - <a 22 - href="/?page={data.page - 1}" 23 - class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 24 - > 25 - &larr; Newer 26 - </a> 27 - {/if} 28 - {#if data.hasMore} 29 - <a 30 - href="/?page={data.page + 1}" 31 - class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 32 - > 33 - Older &rarr; 34 - </a> 35 - {/if} 36 - </nav> 19 + <Pagination page={data.page} hasMore={data.hasMore} baseUrl="/" />
+17
src/routes/admin/+layout.server.ts
··· 1 + import { redirect, error } from '@sveltejs/kit'; 2 + import type { LayoutServerLoad } from './$types'; 3 + import { isAdmin } from '$lib/server/admin'; 4 + 5 + export const load: LayoutServerLoad = async ({ locals }) => { 6 + if (!locals.did) { 7 + redirect(302, '/login'); 8 + } 9 + 10 + if (!isAdmin(locals.did)) { 11 + error(403, 'Access denied'); 12 + } 13 + 14 + return { 15 + isAdmin: true 16 + }; 17 + };
+66
src/routes/admin/+page.server.ts
··· 1 + import type { PageServerLoad } from './$types'; 2 + import { contentDb, localDb } from '$lib/server/db'; 3 + import { posts, comments } from '$lib/server/db/content-schema'; 4 + import { reports } from '$lib/server/db/local-schema'; 5 + import { eq, desc, isNull, sql } from 'drizzle-orm'; 6 + 7 + export const load: PageServerLoad = async () => { 8 + // Get counts for dashboard 9 + const [ 10 + totalPosts, 11 + hiddenPosts, 12 + totalComments, 13 + hiddenComments, 14 + pendingReports 15 + ] = await Promise.all([ 16 + contentDb.select({ count: sql<number>`count(*)` }).from(posts).then(r => r[0].count), 17 + contentDb.select({ count: sql<number>`count(*)` }).from(posts).where(eq(posts.isHidden, 1)).then(r => r[0].count), 18 + contentDb.select({ count: sql<number>`count(*)` }).from(comments).then(r => r[0].count), 19 + contentDb.select({ count: sql<number>`count(*)` }).from(comments).where(eq(comments.isHidden, 1)).then(r => r[0].count), 20 + localDb.select({ count: sql<number>`count(*)` }).from(reports).where(isNull(reports.resolvedAt)).then(r => r[0].count) 21 + ]); 22 + 23 + // Get recent reports 24 + const recentReports = await localDb 25 + .select() 26 + .from(reports) 27 + .where(isNull(reports.resolvedAt)) 28 + .orderBy(desc(reports.createdAt)) 29 + .limit(10); 30 + 31 + // Get recently hidden content 32 + const recentlyHiddenPosts = await contentDb 33 + .select({ 34 + uri: posts.uri, 35 + title: posts.title, 36 + authorDid: posts.authorDid 37 + }) 38 + .from(posts) 39 + .where(eq(posts.isHidden, 1)) 40 + .orderBy(desc(posts.indexedAt)) 41 + .limit(5); 42 + 43 + const recentlyHiddenComments = await contentDb 44 + .select({ 45 + uri: comments.uri, 46 + text: comments.text, 47 + authorDid: comments.authorDid 48 + }) 49 + .from(comments) 50 + .where(eq(comments.isHidden, 1)) 51 + .orderBy(desc(comments.indexedAt)) 52 + .limit(5); 53 + 54 + return { 55 + stats: { 56 + totalPosts, 57 + hiddenPosts, 58 + totalComments, 59 + hiddenComments, 60 + pendingReports 61 + }, 62 + recentReports, 63 + recentlyHiddenPosts, 64 + recentlyHiddenComments 65 + }; 66 + };
+150
src/routes/admin/+page.svelte
··· 1 + <script lang="ts"> 2 + let { data } = $props(); 3 + </script> 4 + 5 + <svelte:head> 6 + <title>Admin - papili</title> 7 + </svelte:head> 8 + 9 + <div class="space-y-6"> 10 + <h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Moderation Dashboard</h1> 11 + 12 + <!-- Stats grid --> 13 + <div class="grid grid-cols-2 md:grid-cols-5 gap-4"> 14 + <div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700"> 15 + <div class="text-2xl font-bold text-gray-900 dark:text-gray-100">{data.stats.totalPosts}</div> 16 + <div class="text-sm text-gray-500 dark:text-gray-400">Total Posts</div> 17 + </div> 18 + <div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700"> 19 + <div class="text-2xl font-bold text-red-600 dark:text-red-400">{data.stats.hiddenPosts}</div> 20 + <div class="text-sm text-gray-500 dark:text-gray-400">Hidden Posts</div> 21 + </div> 22 + <div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700"> 23 + <div class="text-2xl font-bold text-gray-900 dark:text-gray-100">{data.stats.totalComments}</div> 24 + <div class="text-sm text-gray-500 dark:text-gray-400">Total Comments</div> 25 + </div> 26 + <div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700"> 27 + <div class="text-2xl font-bold text-red-600 dark:text-red-400">{data.stats.hiddenComments}</div> 28 + <div class="text-sm text-gray-500 dark:text-gray-400">Hidden Comments</div> 29 + </div> 30 + <div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm border border-gray-200 dark:border-gray-700"> 31 + <div class="text-2xl font-bold text-orange-600 dark:text-orange-400">{data.stats.pendingReports}</div> 32 + <div class="text-sm text-gray-500 dark:text-gray-400">Pending Reports</div> 33 + </div> 34 + </div> 35 + 36 + <!-- Pending reports --> 37 + {#if data.recentReports.length > 0} 38 + <section> 39 + <h2 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">Pending Reports</h2> 40 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 41 + {#each data.recentReports as report} 42 + <div class="p-4"> 43 + <div class="flex justify-between items-start gap-4"> 44 + <div class="flex-1 min-w-0"> 45 + <div class="text-sm font-medium text-gray-900 dark:text-gray-100"> 46 + {report.targetType}: <code class="text-xs bg-gray-100 dark:bg-gray-700 px-1 rounded">{report.targetUri}</code> 47 + </div> 48 + {#if report.reason} 49 + <p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{report.reason}</p> 50 + {/if} 51 + <div class="text-xs text-gray-500 dark:text-gray-400 mt-1"> 52 + Reported by {report.reporterDid.slice(0, 20)}... on {new Date(report.createdAt).toLocaleDateString()} 53 + </div> 54 + </div> 55 + <div class="flex gap-2"> 56 + <form method="POST" action="/api/admin/moderate"> 57 + <input type="hidden" name="uri" value={report.targetUri} /> 58 + <input type="hidden" name="type" value={report.targetType} /> 59 + <input type="hidden" name="action" value="hide" /> 60 + <input type="hidden" name="reportId" value={report.id} /> 61 + <input type="hidden" name="redirect" value="/admin" /> 62 + <button 63 + type="submit" 64 + class="px-3 py-1 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded" 65 + > 66 + Hide 67 + </button> 68 + </form> 69 + <form method="POST" action="/api/admin/moderate"> 70 + <input type="hidden" name="reportId" value={report.id} /> 71 + <input type="hidden" name="action" value="dismiss" /> 72 + <input type="hidden" name="redirect" value="/admin" /> 73 + <button 74 + type="submit" 75 + class="px-3 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded" 76 + > 77 + Dismiss 78 + </button> 79 + </form> 80 + </div> 81 + </div> 82 + </div> 83 + {/each} 84 + </div> 85 + </section> 86 + {/if} 87 + 88 + <!-- Recently hidden content --> 89 + <div class="grid md:grid-cols-2 gap-6"> 90 + <section> 91 + <h2 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">Hidden Posts</h2> 92 + {#if data.recentlyHiddenPosts.length === 0} 93 + <p class="text-sm text-gray-500 dark:text-gray-400">No hidden posts.</p> 94 + {:else} 95 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 96 + {#each data.recentlyHiddenPosts as post} 97 + <div class="p-3 flex justify-between items-center gap-2"> 98 + <div class="flex-1 min-w-0"> 99 + <div class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{post.title}</div> 100 + <div class="text-xs text-gray-500 dark:text-gray-400 truncate">{post.authorDid}</div> 101 + </div> 102 + <form method="POST" action="/api/admin/moderate"> 103 + <input type="hidden" name="uri" value={post.uri} /> 104 + <input type="hidden" name="type" value="post" /> 105 + <input type="hidden" name="action" value="unhide" /> 106 + <input type="hidden" name="redirect" value="/admin" /> 107 + <button 108 + type="submit" 109 + class="px-2 py-1 text-xs font-medium text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50 rounded" 110 + > 111 + Unhide 112 + </button> 113 + </form> 114 + </div> 115 + {/each} 116 + </div> 117 + {/if} 118 + </section> 119 + 120 + <section> 121 + <h2 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">Hidden Comments</h2> 122 + {#if data.recentlyHiddenComments.length === 0} 123 + <p class="text-sm text-gray-500 dark:text-gray-400">No hidden comments.</p> 124 + {:else} 125 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 126 + {#each data.recentlyHiddenComments as comment} 127 + <div class="p-3 flex justify-between items-center gap-2"> 128 + <div class="flex-1 min-w-0"> 129 + <div class="text-sm text-gray-900 dark:text-gray-100 truncate">{comment.text.slice(0, 50)}...</div> 130 + <div class="text-xs text-gray-500 dark:text-gray-400 truncate">{comment.authorDid}</div> 131 + </div> 132 + <form method="POST" action="/api/admin/moderate"> 133 + <input type="hidden" name="uri" value={comment.uri} /> 134 + <input type="hidden" name="type" value="comment" /> 135 + <input type="hidden" name="action" value="unhide" /> 136 + <input type="hidden" name="redirect" value="/admin" /> 137 + <button 138 + type="submit" 139 + class="px-2 py-1 text-xs font-medium text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 hover:bg-green-200 dark:hover:bg-green-900/50 rounded" 140 + > 141 + Unhide 142 + </button> 143 + </form> 144 + </div> 145 + {/each} 146 + </div> 147 + {/if} 148 + </section> 149 + </div> 150 + </div>
+78
src/routes/api/admin/moderate/+server.ts
··· 1 + import { json, redirect, error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { isAdmin } from '$lib/server/admin'; 4 + import { contentDb, localDb } from '$lib/server/db'; 5 + import { posts, comments } from '$lib/server/db/content-schema'; 6 + import { reports } from '$lib/server/db/local-schema'; 7 + import { eq } from 'drizzle-orm'; 8 + 9 + export const POST: RequestHandler = async ({ request, locals }) => { 10 + if (!locals.did || !isAdmin(locals.did)) { 11 + error(403, 'Access denied'); 12 + } 13 + 14 + const formData = await request.formData(); 15 + const action = formData.get('action') as string; 16 + const uri = formData.get('uri') as string | null; 17 + const type = formData.get('type') as string | null; 18 + const reportId = formData.get('reportId') as string | null; 19 + const redirectTo = formData.get('redirect') as string | null; 20 + 21 + if (!action) { 22 + error(400, 'Missing action'); 23 + } 24 + 25 + const now = new Date().toISOString(); 26 + 27 + try { 28 + if (action === 'hide' && uri && type) { 29 + // Hide content 30 + if (type === 'post') { 31 + await contentDb.update(posts).set({ isHidden: 1 }).where(eq(posts.uri, uri)); 32 + } else if (type === 'comment') { 33 + await contentDb.update(comments).set({ isHidden: 1 }).where(eq(comments.uri, uri)); 34 + } 35 + 36 + // Resolve report if provided 37 + if (reportId) { 38 + await localDb 39 + .update(reports) 40 + .set({ 41 + resolvedAt: now, 42 + resolvedBy: locals.did, 43 + resolution: 'hidden' 44 + }) 45 + .where(eq(reports.id, parseInt(reportId))); 46 + } 47 + } else if (action === 'unhide' && uri && type) { 48 + // Unhide content 49 + if (type === 'post') { 50 + await contentDb.update(posts).set({ isHidden: 0 }).where(eq(posts.uri, uri)); 51 + } else if (type === 'comment') { 52 + await contentDb.update(comments).set({ isHidden: 0 }).where(eq(comments.uri, uri)); 53 + } 54 + } else if (action === 'dismiss' && reportId) { 55 + // Dismiss report without hiding 56 + await localDb 57 + .update(reports) 58 + .set({ 59 + resolvedAt: now, 60 + resolvedBy: locals.did, 61 + resolution: 'dismissed' 62 + }) 63 + .where(eq(reports.id, parseInt(reportId))); 64 + } else { 65 + error(400, 'Invalid action or missing parameters'); 66 + } 67 + } catch (err) { 68 + console.error('Moderation action failed:', err); 69 + error(500, 'Moderation action failed'); 70 + } 71 + 72 + // Return JSON or redirect based on request 73 + if (redirectTo) { 74 + redirect(303, redirectTo); 75 + } 76 + 77 + return json({ success: true }); 78 + };
+40
src/routes/api/report/+server.ts
··· 1 + import { json, error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { localDb } from '$lib/server/db'; 4 + import { reports } from '$lib/server/db/local-schema'; 5 + 6 + export const POST: RequestHandler = async ({ request, locals }) => { 7 + if (!locals.did) { 8 + error(401, 'Login required to report content'); 9 + } 10 + 11 + const formData = await request.formData(); 12 + const targetUri = formData.get('uri') as string | null; 13 + const targetType = formData.get('type') as string | null; 14 + const reason = formData.get('reason')?.toString()?.trim() || null; 15 + 16 + if (!targetUri || !targetType) { 17 + error(400, 'Missing uri or type'); 18 + } 19 + 20 + if (!['post', 'comment'].includes(targetType)) { 21 + error(400, 'Invalid type'); 22 + } 23 + 24 + // Limit reason length 25 + if (reason && reason.length > 1000) { 26 + error(400, 'Reason must be 1000 characters or less'); 27 + } 28 + 29 + const now = new Date().toISOString(); 30 + 31 + await localDb.insert(reports).values({ 32 + reporterDid: locals.did, 33 + targetUri, 34 + targetType, 35 + reason, 36 + createdAt: now 37 + }); 38 + 39 + return json({ success: true }); 40 + };
+6 -95
src/routes/api/search/+server.ts
··· 1 1 import { json } from '@sveltejs/kit'; 2 2 import type { RequestHandler } from './$types'; 3 - import { contentClient } from '$lib/server/db'; 4 - import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 5 3 import { sanitizeFtsQuery, isValidSearchQuery } from '$lib/server/search/sanitize'; 4 + import { searchPosts, searchComments } from '$lib/server/search/queries'; 6 5 7 6 export const GET: RequestHandler = async ({ url }) => { 8 7 const query = url.searchParams.get('q')?.trim(); ··· 20 19 return json({ posts: [], comments: [] }); 21 20 } 22 21 23 - const results: { posts: unknown[]; comments: unknown[] } = { posts: [], comments: [] }; 24 - 25 22 try { 26 - // Search posts 27 - if (type === 'posts' || type === 'all') { 28 - const postsResult = await contentClient.execute({ 29 - sql: ` 30 - SELECT 31 - p.uri, 32 - p.cid, 33 - p.author_did as authorDid, 34 - p.rkey, 35 - p.url, 36 - p.title, 37 - p.text, 38 - p.created_at as createdAt, 39 - p.vote_count as voteCount, 40 - snippet(posts_fts, 1, '<mark>', '</mark>', '...', 32) as titleSnippet, 41 - snippet(posts_fts, 2, '<mark>', '</mark>', '...', 64) as textSnippet 42 - FROM posts_fts 43 - JOIN posts p ON posts_fts.uri = p.uri 44 - WHERE posts_fts MATCH ? 45 - ORDER BY rank 46 - LIMIT ? 47 - `, 48 - args: [ftsQuery, limit] 49 - }); 23 + const [posts, comments] = await Promise.all([ 24 + type === 'posts' || type === 'all' ? searchPosts(ftsQuery, limit) : [], 25 + type === 'comments' || type === 'all' ? searchComments(ftsQuery, limit) : [] 26 + ]); 50 27 51 - // Get author profiles 52 - const authorDids = postsResult.rows.map((r) => r.authorDid as string); 53 - const profiles = await fetchProfiles(authorDids); 54 - 55 - results.posts = postsResult.rows.map((row) => ({ 56 - uri: row.uri, 57 - cid: row.cid, 58 - authorDid: row.authorDid, 59 - rkey: row.rkey, 60 - url: row.url, 61 - title: row.title, 62 - text: row.text, 63 - createdAt: row.createdAt, 64 - voteCount: row.voteCount, 65 - titleSnippet: row.titleSnippet, 66 - textSnippet: row.textSnippet, 67 - author: getProfileOrFallback(profiles, row.authorDid as string) 68 - })); 69 - } 70 - 71 - // Search comments 72 - if (type === 'comments' || type === 'all') { 73 - const commentsResult = await contentClient.execute({ 74 - sql: ` 75 - SELECT 76 - c.uri, 77 - c.cid, 78 - c.author_did as authorDid, 79 - c.rkey, 80 - c.post_uri as postUri, 81 - c.text, 82 - c.created_at as createdAt, 83 - c.vote_count as voteCount, 84 - p.rkey as postRkey, 85 - p.title as postTitle, 86 - snippet(comments_fts, 2, '<mark>', '</mark>', '...', 64) as textSnippet 87 - FROM comments_fts 88 - JOIN comments c ON comments_fts.uri = c.uri 89 - JOIN posts p ON c.post_uri = p.uri 90 - WHERE comments_fts MATCH ? 91 - ORDER BY rank 92 - LIMIT ? 93 - `, 94 - args: [ftsQuery, limit] 95 - }); 96 - 97 - // Get author profiles 98 - const authorDids = commentsResult.rows.map((r) => r.authorDid as string); 99 - const profiles = await fetchProfiles(authorDids); 100 - 101 - results.comments = commentsResult.rows.map((row) => ({ 102 - uri: row.uri, 103 - cid: row.cid, 104 - authorDid: row.authorDid, 105 - rkey: row.rkey, 106 - postUri: row.postUri, 107 - postRkey: row.postRkey, 108 - postTitle: row.postTitle, 109 - text: row.text, 110 - createdAt: row.createdAt, 111 - voteCount: row.voteCount, 112 - textSnippet: row.textSnippet, 113 - author: getProfileOrFallback(profiles, row.authorDid as string) 114 - })); 115 - } 116 - 117 - return json(results); 28 + return json({ posts, comments }); 118 29 } catch (err) { 119 30 console.error('[search] FTS query error:', err); 120 31 return json({ posts: [], comments: [], error: 'Search failed' }, { status: 500 });
+10 -25
src/routes/comments/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 - import { contentDb } from '$lib/server/db'; 3 - import { posts, comments } from '$lib/server/db/schema'; 4 - import { desc, eq } from 'drizzle-orm'; 2 + import { getCommentsWithPostContext } from '$lib/server/queries/comments'; 5 3 import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 6 4 import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 7 5 8 6 export const load: PageServerLoad = async ({ locals }) => { 9 - // Fetch recent comments with post info from content DB 10 - const recentComments = await contentDb 11 - .select({ 12 - uri: comments.uri, 13 - cid: comments.cid, 14 - authorDid: comments.authorDid, 15 - rkey: comments.rkey, 16 - postUri: comments.postUri, 17 - text: comments.text, 18 - createdAt: comments.createdAt, 19 - postRkey: posts.rkey, 20 - postTitle: posts.title 21 - }) 22 - .from(comments) 23 - .innerJoin(posts, eq(comments.postUri, posts.uri)) 24 - .orderBy(desc(comments.createdAt)) 25 - .limit(50); 7 + // Fetch recent comments with post info (hidden content filtered by default) 8 + const recentComments = await getCommentsWithPostContext({ limit: 50 }); 26 9 10 + // Enrich with profiles and votes 27 11 const authorDids = recentComments.map((c) => c.authorDid); 28 - const profiles = await fetchProfiles(authorDids); 12 + const commentUris = recentComments.map((c) => c.uri); 29 13 30 - // Get vote counts and user votes from local DB 31 - const commentUris = recentComments.map((c) => c.uri); 32 - const voteCounts = await getVoteCounts(commentUris); 33 - const userVotes = locals.did ? await getUserVotes(locals.did, commentUris) : new Map(); 14 + const [profiles, voteCounts, userVotes] = await Promise.all([ 15 + fetchProfiles(authorDids), 16 + getVoteCounts(commentUris), 17 + locals.did ? getUserVotes(locals.did, commentUris) : Promise.resolve(new Map<string, number>()) 18 + ]); 34 19 35 20 const commentsWithData = recentComments.map((comment) => ({ 36 21 ...comment,
+217
src/routes/login/login.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest'; 2 + 3 + // Mock the auth module 4 + vi.mock('$lib/server/auth', () => ({ 5 + createOAuthClient: vi.fn() 6 + })); 7 + 8 + // Mock the db module 9 + vi.mock('$lib/server/db', () => ({ 10 + localDb: {} 11 + })); 12 + 13 + import { createOAuthClient } from '$lib/server/auth'; 14 + import { load, actions } from './+page.server'; 15 + 16 + const mockCreateOAuthClient = vi.mocked(createOAuthClient); 17 + 18 + describe('login page', () => { 19 + beforeEach(() => { 20 + vi.clearAllMocks(); 21 + }); 22 + 23 + describe('load', () => { 24 + it('should return error from URL params', async () => { 25 + const url = new URL('http://localhost/login?error=Something%20went%20wrong'); 26 + const result = await load({ 27 + url, 28 + locals: {} 29 + } as any); 30 + 31 + expect(result).toEqual({ error: 'Something went wrong' }); 32 + }); 33 + 34 + it('should return null error when no error param', async () => { 35 + const url = new URL('http://localhost/login'); 36 + const result = await load({ 37 + url, 38 + locals: {} 39 + } as any); 40 + 41 + expect(result).toEqual({ error: null }); 42 + }); 43 + 44 + it('should redirect when already logged in', async () => { 45 + const url = new URL('http://localhost/login'); 46 + 47 + await expect( 48 + load({ 49 + url, 50 + locals: { did: 'did:plc:user123' } 51 + } as any) 52 + ).rejects.toMatchObject({ 53 + status: 302, 54 + location: '/' 55 + }); 56 + }); 57 + }); 58 + 59 + describe('actions.default', () => { 60 + it('should fail with 400 when handle is empty', async () => { 61 + const formData = new FormData(); 62 + formData.set('handle', ''); 63 + 64 + const result = await actions.default({ 65 + request: { formData: () => Promise.resolve(formData) }, 66 + url: new URL('http://localhost/login') 67 + } as any); 68 + 69 + expect(result).toMatchObject({ 70 + status: 400, 71 + data: { error: 'Handle is required' } 72 + }); 73 + }); 74 + 75 + it('should fail with 400 when handle is missing', async () => { 76 + const formData = new FormData(); 77 + 78 + const result = await actions.default({ 79 + request: { formData: () => Promise.resolve(formData) }, 80 + url: new URL('http://localhost/login') 81 + } as any); 82 + 83 + expect(result).toMatchObject({ 84 + status: 400, 85 + data: { error: 'Handle is required' } 86 + }); 87 + }); 88 + 89 + it('should fail with 400 when handle is whitespace only', async () => { 90 + const formData = new FormData(); 91 + formData.set('handle', ' '); 92 + 93 + const result = await actions.default({ 94 + request: { formData: () => Promise.resolve(formData) }, 95 + url: new URL('http://localhost/login') 96 + } as any); 97 + 98 + expect(result).toMatchObject({ 99 + status: 400, 100 + data: { error: 'Handle is required' } 101 + }); 102 + }); 103 + 104 + it('should normalize handle without domain', async () => { 105 + const mockAuthorize = vi.fn().mockResolvedValue(new URL('https://auth.bsky.social/authorize')); 106 + mockCreateOAuthClient.mockResolvedValue({ authorize: mockAuthorize } as any); 107 + 108 + const formData = new FormData(); 109 + formData.set('handle', 'alice'); 110 + 111 + await expect( 112 + actions.default({ 113 + request: { formData: () => Promise.resolve(formData) }, 114 + url: new URL('http://localhost/login') 115 + } as any) 116 + ).rejects.toMatchObject({ status: 302 }); 117 + 118 + expect(mockAuthorize).toHaveBeenCalledWith('alice.bsky.social', expect.any(Object)); 119 + }); 120 + 121 + it('should preserve handle with domain', async () => { 122 + const mockAuthorize = vi.fn().mockResolvedValue(new URL('https://auth.bsky.social/authorize')); 123 + mockCreateOAuthClient.mockResolvedValue({ authorize: mockAuthorize } as any); 124 + 125 + const formData = new FormData(); 126 + formData.set('handle', 'alice.custom.social'); 127 + 128 + await expect( 129 + actions.default({ 130 + request: { formData: () => Promise.resolve(formData) }, 131 + url: new URL('http://localhost/login') 132 + } as any) 133 + ).rejects.toMatchObject({ status: 302 }); 134 + 135 + expect(mockAuthorize).toHaveBeenCalledWith('alice.custom.social', expect.any(Object)); 136 + }); 137 + 138 + it('should pass returnUrl in state', async () => { 139 + const mockAuthorize = vi.fn().mockResolvedValue(new URL('https://auth.bsky.social/authorize')); 140 + mockCreateOAuthClient.mockResolvedValue({ authorize: mockAuthorize } as any); 141 + 142 + const formData = new FormData(); 143 + formData.set('handle', 'alice'); 144 + 145 + await expect( 146 + actions.default({ 147 + request: { formData: () => Promise.resolve(formData) }, 148 + url: new URL('http://localhost/login?returnUrl=/submit') 149 + } as any) 150 + ).rejects.toMatchObject({ status: 302 }); 151 + 152 + expect(mockAuthorize).toHaveBeenCalledWith( 153 + expect.any(String), 154 + expect.objectContaining({ 155 + state: JSON.stringify({ returnUrl: '/submit' }) 156 + }) 157 + ); 158 + }); 159 + 160 + it('should default returnUrl to /', async () => { 161 + const mockAuthorize = vi.fn().mockResolvedValue(new URL('https://auth.bsky.social/authorize')); 162 + mockCreateOAuthClient.mockResolvedValue({ authorize: mockAuthorize } as any); 163 + 164 + const formData = new FormData(); 165 + formData.set('handle', 'alice'); 166 + 167 + await expect( 168 + actions.default({ 169 + request: { formData: () => Promise.resolve(formData) }, 170 + url: new URL('http://localhost/login') 171 + } as any) 172 + ).rejects.toMatchObject({ status: 302 }); 173 + 174 + expect(mockAuthorize).toHaveBeenCalledWith( 175 + expect.any(String), 176 + expect.objectContaining({ 177 + state: JSON.stringify({ returnUrl: '/' }) 178 + }) 179 + ); 180 + }); 181 + 182 + it('should handle OAuth client errors', async () => { 183 + mockCreateOAuthClient.mockRejectedValue(new Error('Failed to create client')); 184 + 185 + const formData = new FormData(); 186 + formData.set('handle', 'alice'); 187 + 188 + const result = await actions.default({ 189 + request: { formData: () => Promise.resolve(formData) }, 190 + url: new URL('http://localhost/login') 191 + } as any); 192 + 193 + expect(result).toMatchObject({ 194 + status: 500, 195 + data: { error: 'Failed to create client' } 196 + }); 197 + }); 198 + 199 + it('should handle OAuth authorize errors', async () => { 200 + const mockAuthorize = vi.fn().mockRejectedValue(new Error('Authorization server unavailable')); 201 + mockCreateOAuthClient.mockResolvedValue({ authorize: mockAuthorize } as any); 202 + 203 + const formData = new FormData(); 204 + formData.set('handle', 'alice'); 205 + 206 + const result = await actions.default({ 207 + request: { formData: () => Promise.resolve(formData) }, 208 + url: new URL('http://localhost/login') 209 + } as any); 210 + 211 + expect(result).toMatchObject({ 212 + status: 500, 213 + data: { error: 'Authorization server unavailable' } 214 + }); 215 + }); 216 + }); 217 + });
+2 -19
src/routes/new/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import PostList from '$lib/components/PostList.svelte'; 3 + import Pagination from '$lib/components/Pagination.svelte'; 3 4 4 5 let { data } = $props(); 5 6 </script> ··· 15 16 currentUserHandle={data.user?.handle} 16 17 /> 17 18 18 - <!-- Pagination --> 19 - <nav class="mt-6 flex justify-center gap-4"> 20 - {#if data.page > 1} 21 - <a 22 - href="/new?page={data.page - 1}" 23 - class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 24 - > 25 - &larr; Newer 26 - </a> 27 - {/if} 28 - {#if data.hasMore} 29 - <a 30 - href="/new?page={data.page + 1}" 31 - class="px-4 py-2 text-sm text-violet-600 dark:text-violet-400 hover:underline" 32 - > 33 - Older &rarr; 34 - </a> 35 - {/if} 36 - </nav> 19 + <Pagination page={data.page} hasMore={data.hasMore} baseUrl="/new" />
+287
src/routes/oauth/callback/callback.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest'; 2 + 3 + // Mock the auth module 4 + vi.mock('$lib/server/auth', () => ({ 5 + createOAuthClient: vi.fn(), 6 + getSession: vi.fn() 7 + })); 8 + 9 + // Mock the db module 10 + vi.mock('$lib/server/db', () => ({ 11 + localDb: {} 12 + })); 13 + 14 + import { createOAuthClient, getSession } from '$lib/server/auth'; 15 + import { GET } from './+server'; 16 + 17 + const mockCreateOAuthClient = vi.mocked(createOAuthClient); 18 + const mockGetSession = vi.mocked(getSession); 19 + 20 + describe('OAuth callback', () => { 21 + beforeEach(() => { 22 + vi.clearAllMocks(); 23 + }); 24 + 25 + describe('error handling', () => { 26 + it('should redirect to login with error from OAuth provider', async () => { 27 + const url = new URL('http://localhost/oauth/callback?error=access_denied&error_description=User%20denied%20access'); 28 + 29 + await expect( 30 + GET({ 31 + url, 32 + cookies: {} 33 + } as any) 34 + ).rejects.toMatchObject({ 35 + status: 302, 36 + location: '/login?error=User%20denied%20access' 37 + }); 38 + }); 39 + 40 + it('should use error code when no description provided', async () => { 41 + const url = new URL('http://localhost/oauth/callback?error=server_error'); 42 + 43 + await expect( 44 + GET({ 45 + url, 46 + cookies: {} 47 + } as any) 48 + ).rejects.toMatchObject({ 49 + status: 302, 50 + location: '/login?error=server_error' 51 + }); 52 + }); 53 + }); 54 + 55 + describe('successful callback', () => { 56 + it('should redirect to home by default', async () => { 57 + const mockCallback = vi.fn().mockResolvedValue({ 58 + session: { did: 'did:plc:newuser' }, 59 + state: null 60 + }); 61 + const mockSession = { 62 + did: null, 63 + save: vi.fn().mockResolvedValue(undefined) 64 + }; 65 + mockCreateOAuthClient.mockResolvedValue({ callback: mockCallback } as any); 66 + mockGetSession.mockResolvedValue(mockSession as any); 67 + 68 + const url = new URL('http://localhost/oauth/callback?code=abc123'); 69 + 70 + await expect( 71 + GET({ 72 + url, 73 + cookies: {} 74 + } as any) 75 + ).rejects.toMatchObject({ 76 + status: 302, 77 + location: '/' 78 + }); 79 + 80 + expect(mockSession.did).toBe('did:plc:newuser'); 81 + expect(mockSession.save).toHaveBeenCalled(); 82 + }); 83 + 84 + it('should redirect to returnUrl from state', async () => { 85 + const mockCallback = vi.fn().mockResolvedValue({ 86 + session: { did: 'did:plc:newuser' }, 87 + state: JSON.stringify({ returnUrl: '/submit' }) 88 + }); 89 + const mockSession = { 90 + did: null, 91 + save: vi.fn().mockResolvedValue(undefined) 92 + }; 93 + mockCreateOAuthClient.mockResolvedValue({ callback: mockCallback } as any); 94 + mockGetSession.mockResolvedValue(mockSession as any); 95 + 96 + const url = new URL('http://localhost/oauth/callback?code=abc123'); 97 + 98 + await expect( 99 + GET({ 100 + url, 101 + cookies: {} 102 + } as any) 103 + ).rejects.toMatchObject({ 104 + status: 302, 105 + location: '/submit' 106 + }); 107 + }); 108 + 109 + it('should ignore invalid state JSON', async () => { 110 + const mockCallback = vi.fn().mockResolvedValue({ 111 + session: { did: 'did:plc:newuser' }, 112 + state: 'not-valid-json' 113 + }); 114 + const mockSession = { 115 + did: null, 116 + save: vi.fn().mockResolvedValue(undefined) 117 + }; 118 + mockCreateOAuthClient.mockResolvedValue({ callback: mockCallback } as any); 119 + mockGetSession.mockResolvedValue(mockSession as any); 120 + 121 + const url = new URL('http://localhost/oauth/callback?code=abc123'); 122 + 123 + await expect( 124 + GET({ 125 + url, 126 + cookies: {} 127 + } as any) 128 + ).rejects.toMatchObject({ 129 + status: 302, 130 + location: '/' 131 + }); 132 + }); 133 + 134 + it('should reject returnUrl not starting with /', async () => { 135 + const mockCallback = vi.fn().mockResolvedValue({ 136 + session: { did: 'did:plc:newuser' }, 137 + state: JSON.stringify({ returnUrl: 'https://evil.com' }) 138 + }); 139 + const mockSession = { 140 + did: null, 141 + save: vi.fn().mockResolvedValue(undefined) 142 + }; 143 + mockCreateOAuthClient.mockResolvedValue({ callback: mockCallback } as any); 144 + mockGetSession.mockResolvedValue(mockSession as any); 145 + 146 + const url = new URL('http://localhost/oauth/callback?code=abc123'); 147 + 148 + await expect( 149 + GET({ 150 + url, 151 + cookies: {} 152 + } as any) 153 + ).rejects.toMatchObject({ 154 + status: 302, 155 + location: '/' 156 + }); 157 + }); 158 + 159 + it('should reject non-string returnUrl', async () => { 160 + const mockCallback = vi.fn().mockResolvedValue({ 161 + session: { did: 'did:plc:newuser' }, 162 + state: JSON.stringify({ returnUrl: 123 }) 163 + }); 164 + const mockSession = { 165 + did: null, 166 + save: vi.fn().mockResolvedValue(undefined) 167 + }; 168 + mockCreateOAuthClient.mockResolvedValue({ callback: mockCallback } as any); 169 + mockGetSession.mockResolvedValue(mockSession as any); 170 + 171 + const url = new URL('http://localhost/oauth/callback?code=abc123'); 172 + 173 + await expect( 174 + GET({ 175 + url, 176 + cookies: {} 177 + } as any) 178 + ).rejects.toMatchObject({ 179 + status: 302, 180 + location: '/' 181 + }); 182 + }); 183 + }); 184 + 185 + describe('account switching', () => { 186 + it('should sign out old session when switching accounts', async () => { 187 + const mockSignOut = vi.fn().mockResolvedValue(undefined); 188 + const mockRestore = vi.fn().mockResolvedValue({ signOut: mockSignOut }); 189 + const mockCallback = vi.fn().mockResolvedValue({ 190 + session: { did: 'did:plc:newuser' }, 191 + state: null 192 + }); 193 + const mockSession = { 194 + did: 'did:plc:olduser', 195 + save: vi.fn().mockResolvedValue(undefined) 196 + }; 197 + mockCreateOAuthClient.mockResolvedValue({ 198 + callback: mockCallback, 199 + restore: mockRestore 200 + } as any); 201 + mockGetSession.mockResolvedValue(mockSession as any); 202 + 203 + const url = new URL('http://localhost/oauth/callback?code=abc123'); 204 + 205 + await expect( 206 + GET({ 207 + url, 208 + cookies: {} 209 + } as any) 210 + ).rejects.toMatchObject({ status: 302 }); 211 + 212 + expect(mockRestore).toHaveBeenCalledWith('did:plc:olduser'); 213 + expect(mockSignOut).toHaveBeenCalled(); 214 + expect(mockSession.did).toBe('did:plc:newuser'); 215 + }); 216 + 217 + it('should handle signout errors gracefully', async () => { 218 + const mockRestore = vi.fn().mockRejectedValue(new Error('Session not found')); 219 + const mockCallback = vi.fn().mockResolvedValue({ 220 + session: { did: 'did:plc:newuser' }, 221 + state: null 222 + }); 223 + const mockSession = { 224 + did: 'did:plc:olduser', 225 + save: vi.fn().mockResolvedValue(undefined) 226 + }; 227 + mockCreateOAuthClient.mockResolvedValue({ 228 + callback: mockCallback, 229 + restore: mockRestore 230 + } as any); 231 + mockGetSession.mockResolvedValue(mockSession as any); 232 + 233 + const url = new URL('http://localhost/oauth/callback?code=abc123'); 234 + 235 + // Should not throw, should complete successfully 236 + await expect( 237 + GET({ 238 + url, 239 + cookies: {} 240 + } as any) 241 + ).rejects.toMatchObject({ 242 + status: 302, 243 + location: '/' 244 + }); 245 + 246 + expect(mockSession.did).toBe('did:plc:newuser'); 247 + }); 248 + }); 249 + 250 + describe('callback errors', () => { 251 + it('should redirect to login on callback error', async () => { 252 + mockCreateOAuthClient.mockResolvedValue({ 253 + callback: vi.fn().mockRejectedValue(new Error('Invalid code')) 254 + } as any); 255 + 256 + const url = new URL('http://localhost/oauth/callback?code=invalid'); 257 + 258 + await expect( 259 + GET({ 260 + url, 261 + cookies: {} 262 + } as any) 263 + ).rejects.toMatchObject({ 264 + status: 302, 265 + location: '/login?error=Invalid%20code' 266 + }); 267 + }); 268 + 269 + it('should handle non-Error exceptions', async () => { 270 + mockCreateOAuthClient.mockResolvedValue({ 271 + callback: vi.fn().mockRejectedValue('string error') 272 + } as any); 273 + 274 + const url = new URL('http://localhost/oauth/callback?code=invalid'); 275 + 276 + await expect( 277 + GET({ 278 + url, 279 + cookies: {} 280 + } as any) 281 + ).rejects.toMatchObject({ 282 + status: 302, 283 + location: '/login?error=Authentication%20failed' 284 + }); 285 + }); 286 + }); 287 + });
+4 -2
src/routes/page.svelte.spec.ts
··· 5 5 6 6 describe('/+page.svelte', () => { 7 7 it('should render empty state when no posts', async () => { 8 - // @ts-expect-error - vitest-browser-svelte types issue 8 + // @ts-expect-error - vitest-browser-svelte handles target internally 9 9 render(Page, { 10 10 props: { 11 11 data: { 12 12 did: null, 13 13 user: null, 14 + isAdmin: false, 14 15 posts: [], 15 16 page: 1, 16 17 hasMore: false ··· 23 24 }); 24 25 25 26 it('should render posts list', async () => { 26 - // @ts-expect-error - vitest-browser-svelte types issue 27 + // @ts-expect-error - vitest-browser-svelte handles target internally 27 28 render(Page, { 28 29 props: { 29 30 data: { 30 31 did: null, 31 32 user: null, 33 + isAdmin: false, 32 34 posts: [ 33 35 { 34 36 uri: 'at://did:plc:test/one.papili.post/123',
+16 -3
src/routes/post/[rkey]/+page.server.ts
··· 2 2 import type { Actions, PageServerLoad } from './$types'; 3 3 import { contentDb } from '$lib/server/db'; 4 4 import { posts, comments } from '$lib/server/db/schema'; 5 - import { eq } from 'drizzle-orm'; 5 + import { eq, and } from 'drizzle-orm'; 6 6 import { getLexClient, AuthRequiredError } from '$lib/server/lex-client'; 7 7 import { generateTid } from '$lib/server/tid'; 8 8 import * as comment from '$lib/lexicons/one/papili/comment.defs'; ··· 10 10 import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 11 11 import { getVoteCounts, getUserVotes } from '$lib/server/vote-counts'; 12 12 import { calculateHotScore } from '$lib/utils/ranking'; 13 + import { isAdmin } from '$lib/server/admin'; 13 14 14 15 export const load: PageServerLoad = async ({ params, locals }) => { 15 16 const { rkey } = params; 17 + const userIsAdmin = isAdmin(locals.did); 16 18 17 19 // Find post by rkey (from content DB - LiteFS replica) 18 20 const [post] = await contentDb ··· 25 27 error(404, 'Post not found'); 26 28 } 27 29 30 + // Check if post is hidden (admins can still view) 31 + if (post.isHidden && !userIsAdmin) { 32 + error(404, 'Post not found'); 33 + } 34 + 28 35 // Load comments for this post (from content DB) 36 + // Filter out hidden comments unless user is admin 29 37 const postComments = await contentDb 30 38 .select() 31 39 .from(comments) 32 - .where(eq(comments.postUri, post.uri)); 40 + .where( 41 + userIsAdmin 42 + ? eq(comments.postUri, post.uri) 43 + : and(eq(comments.postUri, post.uri), eq(comments.isHidden, 0)) 44 + ); 33 45 34 46 // Collect all DIDs for profile fetching 35 47 const allDids = [post.authorDid, ...postComments.map((c) => c.authorDid)]; ··· 61 73 author: getProfileOrFallback(profiles, post.authorDid), 62 74 userVote: userVotes.get(post.uri) ?? 0 63 75 }, 64 - comments: commentsWithAuthors 76 + comments: commentsWithAuthors, 77 + isAdmin: userIsAdmin 65 78 }; 66 79 }; 67 80
+15
src/routes/post/[rkey]/+page.svelte
··· 5 5 import Avatar from '$lib/components/Avatar.svelte'; 6 6 import VoteButton from '$lib/components/VoteButton.svelte'; 7 7 import ShareButton from '$lib/components/ShareButton.svelte'; 8 + import ReportButton from '$lib/components/ReportButton.svelte'; 9 + import AdminControls from '$lib/components/AdminControls.svelte'; 8 10 import { formatTimeAgo, getDomain } from '$lib/utils/formatting'; 9 11 import { page } from '$app/state'; 10 12 import type { AuthorProfile } from '$lib/types'; ··· 53 55 userVote: number; 54 56 author: AuthorProfile; 55 57 isPending?: boolean; 58 + isHidden?: number; 56 59 } 57 60 58 61 function startReply(comment: Comment) { ··· 185 188 text={data.post.text ?? undefined} 186 189 url={page.url.href} 187 190 /> 191 + {#if data.user} 192 + · 193 + <ReportButton targetUri={data.post.uri} targetType="post" /> 194 + {/if} 195 + {#if data.isAdmin} 196 + · 197 + <AdminControls targetUri={data.post.uri} targetType="post" isHidden={!!data.post.isHidden} /> 198 + {/if} 188 199 </p> 189 200 </div> 190 201 </header> ··· 333 344 > 334 345 reply 335 346 </button> 347 + <ReportButton targetUri={comment.uri} targetType="comment" /> 348 + {/if} 349 + {#if data.isAdmin && !isCollapsed} 350 + <AdminControls targetUri={comment.uri} targetType="comment" isHidden={!!comment.isHidden} /> 336 351 {/if} 337 352 {/if} 338 353 </div>
+50
src/routes/profile/[identifier]/+page.server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { PageServerLoad } from './$types'; 3 + import { fetchProfile } from '$lib/server/profiles'; 4 + import { enrichPosts, enrichComments } from '$lib/server/enrichment'; 5 + import { getPostsWithCommentCount } from '$lib/server/queries/posts'; 6 + import { getCommentsWithPostContext } from '$lib/server/queries/comments'; 7 + import { eq } from 'drizzle-orm'; 8 + import { posts } from '$lib/server/db/schema'; 9 + 10 + const POSTS_LIMIT = 20; 11 + const COMMENTS_LIMIT = 20; 12 + 13 + export const load: PageServerLoad = async ({ params, locals }) => { 14 + const { identifier } = params; 15 + 16 + // Identifier can be a DID or a handle 17 + const profile = await fetchProfile(identifier); 18 + 19 + if (!profile) { 20 + error(404, 'Profile not found'); 21 + } 22 + 23 + const did = profile.did; 24 + 25 + // Fetch user's posts with comment counts (hidden filtered by default) 26 + const userPosts = await getPostsWithCommentCount({ 27 + where: eq(posts.authorDid, did), 28 + limit: POSTS_LIMIT 29 + }); 30 + 31 + // Fetch user's comments with post context (hidden filtered by default) 32 + const userComments = await getCommentsWithPostContext({ 33 + authorDid: did, 34 + limit: COMMENTS_LIMIT 35 + }); 36 + 37 + // Enrich posts and comments with vote data 38 + const enrichedPosts = await enrichPosts(userPosts, locals.did); 39 + const enrichedComments = await enrichComments(userComments, locals.did); 40 + 41 + return { 42 + profile, 43 + posts: enrichedPosts, 44 + comments: enrichedComments.map((c) => ({ 45 + ...c, 46 + postTitle: (c as typeof c & { postTitle: string }).postTitle, 47 + postRkey: (c as typeof c & { postRkey: string }).postRkey 48 + })) 49 + }; 50 + };
+126
src/routes/profile/[identifier]/+page.svelte
··· 1 + <script lang="ts"> 2 + import Avatar from '$lib/components/Avatar.svelte'; 3 + import VoteButton from '$lib/components/VoteButton.svelte'; 4 + import Tabs from '$lib/components/Tabs.svelte'; 5 + import PostTitle from '$lib/components/PostTitle.svelte'; 6 + import { formatTimeAgo } from '$lib/utils/formatting'; 7 + 8 + let { data } = $props(); 9 + 10 + let activeTab = $state<'posts' | 'comments'>('posts'); 11 + </script> 12 + 13 + <svelte:head> 14 + <title>@{data.profile.handle} - papili</title> 15 + </svelte:head> 16 + 17 + <div class="space-y-6"> 18 + <!-- Profile header --> 19 + <header class="flex items-start gap-4"> 20 + <Avatar 21 + handle={data.profile.handle} 22 + avatar={data.profile.avatar} 23 + did={data.profile.did} 24 + size="lg" 25 + /> 26 + <div class="flex-1 min-w-0"> 27 + <h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100"> 28 + @{data.profile.handle} 29 + </h1> 30 + <p class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate mt-1" title={data.profile.did}> 31 + {data.profile.did} 32 + </p> 33 + <div class="flex gap-4 mt-2 text-sm text-gray-600 dark:text-gray-400"> 34 + <span>{data.posts.length} post{data.posts.length === 1 ? '' : 's'}</span> 35 + <span>{data.comments.length} comment{data.comments.length === 1 ? '' : 's'}</span> 36 + </div> 37 + </div> 38 + </header> 39 + 40 + <!-- Tabs --> 41 + <Tabs 42 + tabs={[ 43 + { id: 'posts', label: 'Posts' }, 44 + { id: 'comments', label: 'Comments' } 45 + ]} 46 + activeTab={activeTab} 47 + onchange={(id) => (activeTab = id as 'posts' | 'comments')} 48 + /> 49 + 50 + <!-- Posts tab --> 51 + {#if activeTab === 'posts'} 52 + {#if data.posts.length === 0} 53 + <p class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center"> 54 + No posts yet. 55 + </p> 56 + {:else} 57 + <ol class="space-y-2"> 58 + {#each data.posts as post (post.uri)} 59 + <li class="flex gap-2 text-sm"> 60 + {#if data.user} 61 + <VoteButton 62 + targetUri={post.uri} 63 + targetType="post" 64 + voteCount={post.voteCount} 65 + userVote={post.userVote} 66 + /> 67 + {:else} 68 + <span class="w-6 text-right text-gray-400 dark:text-gray-500 select-none"> 69 + {post.voteCount} 70 + </span> 71 + {/if} 72 + <div class="flex-1 min-w-0"> 73 + <div> 74 + <PostTitle title={post.title} rkey={post.rkey} url={post.url} /> 75 + </div> 76 + {#if post.text} 77 + <p class="text-xs text-gray-600 dark:text-gray-400 mt-0.5 line-clamp-2">{post.text}</p> 78 + {/if} 79 + <div class="text-xs text-gray-500 dark:text-gray-400"> 80 + {formatTimeAgo(post.createdAt)} 81 + · <a href="/post/{post.rkey}" class="hover:underline">{post.commentCount} comment{post.commentCount === 1 ? '' : 's'}</a> 82 + </div> 83 + </div> 84 + </li> 85 + {/each} 86 + </ol> 87 + {/if} 88 + {/if} 89 + 90 + <!-- Comments tab --> 91 + {#if activeTab === 'comments'} 92 + {#if data.comments.length === 0} 93 + <p class="text-sm text-gray-500 dark:text-gray-400 py-8 text-center"> 94 + No comments yet. 95 + </p> 96 + {:else} 97 + <div class="space-y-4"> 98 + {#each data.comments as comment (comment.uri)} 99 + <div class="text-sm border-l-2 border-gray-200 dark:border-gray-700 pl-3"> 100 + <div class="text-xs text-gray-500 dark:text-gray-400 mb-1"> 101 + on <a href="/post/{comment.postRkey}" class="text-violet-600 dark:text-violet-400 hover:underline"> 102 + {comment.postTitle || 'a post'} 103 + </a> 104 + · {formatTimeAgo(comment.createdAt)} 105 + {#if data.user} 106 + <span class="inline-flex items-center ml-1"> 107 + <VoteButton 108 + targetUri={comment.uri} 109 + targetType="comment" 110 + voteCount={comment.voteCount} 111 + userVote={comment.userVote} 112 + /> 113 + </span> 114 + {:else} 115 + · {comment.voteCount} pt{comment.voteCount === 1 ? '' : 's'} 116 + {/if} 117 + </div> 118 + <p class="text-gray-700 dark:text-gray-300 whitespace-pre-wrap"> 119 + {comment.text} 120 + </p> 121 + </div> 122 + {/each} 123 + </div> 124 + {/if} 125 + {/if} 126 + </div>
+14 -123
src/routes/search/+page.server.ts
··· 1 1 import type { PageServerLoad } from './$types'; 2 - import { contentClient } from '$lib/server/db'; 3 - import { fetchProfiles, getProfileOrFallback } from '$lib/server/profiles'; 4 2 import { sanitizeFtsQuery, isValidSearchQuery } from '$lib/server/search/sanitize'; 5 - import type { AuthorProfile } from '$lib/types'; 3 + import { 4 + searchPosts, 5 + searchComments, 6 + type SearchPost, 7 + type SearchComment 8 + } from '$lib/server/search/queries'; 6 9 7 - export interface SearchPost { 8 - uri: string; 9 - cid: string; 10 - authorDid: string; 11 - rkey: string; 12 - url: string | null; 13 - title: string; 14 - text: string | null; 15 - createdAt: string; 16 - voteCount: number; 17 - titleSnippet: string | null; 18 - textSnippet: string | null; 19 - author: AuthorProfile; 20 - } 21 - 22 - export interface SearchComment { 23 - uri: string; 24 - cid: string; 25 - authorDid: string; 26 - rkey: string; 27 - postUri: string; 28 - postRkey: string; 29 - postTitle: string; 30 - text: string; 31 - createdAt: string; 32 - voteCount: number; 33 - textSnippet: string | null; 34 - author: AuthorProfile; 35 - } 10 + // Re-export types for use in the page component 11 + export type { SearchPost, SearchComment }; 36 12 37 13 export const load: PageServerLoad = async ({ url }) => { 38 14 const query = url.searchParams.get('q')?.trim() || ''; ··· 47 23 return { query, type, posts: [] as SearchPost[], comments: [] as SearchComment[] }; 48 24 } 49 25 50 - const results: { posts: SearchPost[]; comments: SearchComment[] } = { posts: [], comments: [] }; 51 - 52 26 try { 53 - // Search posts 54 - if (type === 'posts' || type === 'all') { 55 - const postsResult = await contentClient.execute({ 56 - sql: ` 57 - SELECT 58 - p.uri, 59 - p.cid, 60 - p.author_did as authorDid, 61 - p.rkey, 62 - p.url, 63 - p.title, 64 - p.text, 65 - p.created_at as createdAt, 66 - p.vote_count as voteCount, 67 - snippet(posts_fts, 1, '<mark>', '</mark>', '...', 32) as titleSnippet, 68 - snippet(posts_fts, 2, '<mark>', '</mark>', '...', 64) as textSnippet 69 - FROM posts_fts 70 - JOIN posts p ON posts_fts.uri = p.uri 71 - WHERE posts_fts MATCH ? 72 - ORDER BY rank 73 - LIMIT 50 74 - `, 75 - args: [ftsQuery] 76 - }); 27 + // Always fetch both posts and comments so tab counts are accurate 28 + const [posts, comments] = await Promise.all([ 29 + searchPosts(ftsQuery, 50), 30 + searchComments(ftsQuery, 50) 31 + ]); 77 32 78 - const authorDids = postsResult.rows.map((r) => r.authorDid as string); 79 - const profiles = await fetchProfiles(authorDids); 80 - 81 - results.posts = postsResult.rows.map((row) => ({ 82 - uri: row.uri as string, 83 - cid: row.cid as string, 84 - authorDid: row.authorDid as string, 85 - rkey: row.rkey as string, 86 - url: row.url as string | null, 87 - title: row.title as string, 88 - text: row.text as string | null, 89 - createdAt: row.createdAt as string, 90 - voteCount: row.voteCount as number, 91 - titleSnippet: row.titleSnippet as string | null, 92 - textSnippet: row.textSnippet as string | null, 93 - author: getProfileOrFallback(profiles, row.authorDid as string) 94 - })); 95 - } 96 - 97 - // Search comments 98 - if (type === 'comments' || type === 'all') { 99 - const commentsResult = await contentClient.execute({ 100 - sql: ` 101 - SELECT 102 - c.uri, 103 - c.cid, 104 - c.author_did as authorDid, 105 - c.rkey, 106 - c.post_uri as postUri, 107 - c.text, 108 - c.created_at as createdAt, 109 - c.vote_count as voteCount, 110 - p.rkey as postRkey, 111 - p.title as postTitle, 112 - snippet(comments_fts, 2, '<mark>', '</mark>', '...', 64) as textSnippet 113 - FROM comments_fts 114 - JOIN comments c ON comments_fts.uri = c.uri 115 - JOIN posts p ON c.post_uri = p.uri 116 - WHERE comments_fts MATCH ? 117 - ORDER BY rank 118 - LIMIT 50 119 - `, 120 - args: [ftsQuery] 121 - }); 122 - 123 - const authorDids = commentsResult.rows.map((r) => r.authorDid as string); 124 - const profiles = await fetchProfiles(authorDids); 125 - 126 - results.comments = commentsResult.rows.map((row) => ({ 127 - uri: row.uri as string, 128 - cid: row.cid as string, 129 - authorDid: row.authorDid as string, 130 - rkey: row.rkey as string, 131 - postUri: row.postUri as string, 132 - postRkey: row.postRkey as string, 133 - postTitle: row.postTitle as string, 134 - text: row.text as string, 135 - createdAt: row.createdAt as string, 136 - voteCount: row.voteCount as number, 137 - textSnippet: row.textSnippet as string | null, 138 - author: getProfileOrFallback(profiles, row.authorDid as string) 139 - })); 140 - } 141 - 142 - return { query, type, ...results }; 33 + return { query, type, posts, comments }; 143 34 } catch (err) { 144 35 console.error('[search] FTS query error:', err); 145 36 return {
+18 -43
src/routes/search/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from '$app/navigation'; 3 3 import Avatar from '$lib/components/Avatar.svelte'; 4 - import { formatTimeAgo, getDomain } from '$lib/utils/formatting'; 5 - import type { SearchPost, SearchComment } from './+page.server'; 4 + import Tabs from '$lib/components/Tabs.svelte'; 5 + import PostTitle from '$lib/components/PostTitle.svelte'; 6 + import { formatTimeAgo } from '$lib/utils/formatting'; 7 + import type { SearchPost, SearchComment } from '$lib/server/search/queries'; 6 8 7 9 let { data } = $props(); 8 10 ··· 51 53 52 54 <!-- Type tabs --> 53 55 {#if data.query} 54 - <div class="flex gap-4 border-b border-gray-200 dark:border-gray-700"> 55 - <button 56 - onclick={() => switchType('posts')} 57 - class="pb-2 text-sm font-medium transition-colors {selectedType === 'posts' 58 - ? 'text-violet-600 dark:text-violet-400 border-b-2 border-violet-600 dark:border-violet-400' 59 - : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}" 60 - > 61 - Posts ({posts.length}) 62 - </button> 63 - <button 64 - onclick={() => switchType('comments')} 65 - class="pb-2 text-sm font-medium transition-colors {selectedType === 'comments' 66 - ? 'text-violet-600 dark:text-violet-400 border-b-2 border-violet-600 dark:border-violet-400' 67 - : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}" 68 - > 69 - Comments ({comments.length}) 70 - </button> 71 - </div> 56 + <Tabs 57 + tabs={[ 58 + { id: 'posts', label: 'Posts', count: posts.length }, 59 + { id: 'comments', label: 'Comments', count: comments.length } 60 + ]} 61 + activeTab={selectedType} 62 + onchange={switchType} 63 + /> 72 64 {/if} 73 65 74 66 <!-- Results --> ··· 84 76 <li class="flex gap-2 text-sm"> 85 77 <div class="flex-1 min-w-0"> 86 78 <div> 87 - {#if post.url} 88 - <a 89 - href={post.url} 90 - target="_blank" 91 - rel="noopener noreferrer" 92 - class="text-gray-900 dark:text-gray-100 hover:underline" 93 - > 94 - {@html post.titleSnippet || post.title} 95 - </a> 96 - <a 97 - href="/from/{getDomain(post.url)}" 98 - class="text-xs text-gray-400 dark:text-gray-500 ml-1 hover:text-violet-600 dark:hover:text-violet-400" 99 - > 100 - ({getDomain(post.url)}) 101 - </a> 102 - {:else} 103 - <a 104 - href="/post/{post.rkey}" 105 - class="text-gray-900 dark:text-gray-100 hover:underline" 106 - > 107 - {@html post.titleSnippet || post.title} 108 - </a> 109 - {/if} 79 + <PostTitle 80 + title={post.title} 81 + rkey={post.rkey} 82 + url={post.url} 83 + titleSnippet={post.titleSnippet} 84 + /> 110 85 </div> 111 86 {#if post.textSnippet} 112 87 <p class="text-xs text-gray-600 dark:text-gray-400 mt-0.5">