Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add global customizable labels system

Introduce sphere-scoped labels (name, description, color) that replace
the hardcoded feature request categories and add categorization to
kanban tasks.

- New `sphere_labels` and `entity_labels` tables with migration that
converts existing categories into labels
- CRUD API for labels at sphere level, gated by `manageLabels` permission
- Labels on feature requests and kanban tasks (create, update, display)
- Cross-sphere label injection prevention via sphere ownership validation
- Shared LabelBadge and LabelPicker components
- Admin label management page with color picker and delete confirmation
- Entity label cleanup on cascade delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Hugo 968ea4a4 5bfcb197

+2688 -48
+4 -4
bun.lock
··· 7 7 "devDependencies": { 8 8 "@playwright/test": "^1.59.1", 9 9 "@types/better-sqlite3": "^7.6.13", 10 - "@types/bun": "^1.3.11", 11 - "better-sqlite3": "^12.8.0", 12 - "bun-types": "^1.3.11", 10 + "@types/bun": "^1.3.12", 11 + "better-sqlite3": "^12.9.0", 12 + "bun-types": "^1.3.12", 13 13 "drizzle-kit": "^0.31.10", 14 14 "oxfmt": "^0.43.0", 15 15 "typescript": "^6.0.2", 16 - "vitest": "^4.1.3", 16 + "vitest": "^4.1.4", 17 17 }, 18 18 }, 19 19 "packages/app": {
+22
drizzle/0004_melted_brother_voodoo.sql
··· 1 + CREATE TABLE `entity_labels` ( 2 + `label_id` text NOT NULL, 3 + `entity_id` text NOT NULL, 4 + `entity_type` text NOT NULL, 5 + PRIMARY KEY(`label_id`, `entity_id`), 6 + FOREIGN KEY (`label_id`) REFERENCES `sphere_labels`(`id`) ON UPDATE no action ON DELETE cascade 7 + ); 8 + --> statement-breakpoint 9 + CREATE INDEX `idx_entity_labels_entity` ON `entity_labels` (`entity_id`,`entity_type`);--> statement-breakpoint 10 + CREATE TABLE `sphere_labels` ( 11 + `id` text PRIMARY KEY NOT NULL, 12 + `sphere_id` text NOT NULL, 13 + `name` text NOT NULL, 14 + `description` text, 15 + `color` text NOT NULL, 16 + `position` integer DEFAULT 0 NOT NULL, 17 + `created_at` text DEFAULT (datetime('now')) NOT NULL, 18 + FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action 19 + ); 20 + --> statement-breakpoint 21 + CREATE UNIQUE INDEX `idx_sphere_labels_sphere_name` ON `sphere_labels` (`sphere_id`,`name`);--> statement-breakpoint 22 + CREATE INDEX `idx_sphere_labels_sphere_position` ON `sphere_labels` (`sphere_id`,`position`);
+1387
drizzle/meta/0004_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "36c47020-9349-4833-8827-8c5bde6e1b17", 5 + "prevId": "496173cb-98e0-46b7-baaf-47929d4cb526", 6 + "tables": { 7 + "oauth_sessions": { 8 + "name": "oauth_sessions", 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 + "default": "(datetime('now'))" 31 + }, 32 + "updated_at": { 33 + "name": "updated_at", 34 + "type": "text", 35 + "primaryKey": false, 36 + "notNull": true, 37 + "autoincrement": false, 38 + "default": "(datetime('now'))" 39 + } 40 + }, 41 + "indexes": {}, 42 + "foreignKeys": {}, 43 + "compositePrimaryKeys": {}, 44 + "uniqueConstraints": {}, 45 + "checkConstraints": {} 46 + }, 47 + "oauth_states": { 48 + "name": "oauth_states", 49 + "columns": { 50 + "key": { 51 + "name": "key", 52 + "type": "text", 53 + "primaryKey": true, 54 + "notNull": true, 55 + "autoincrement": false 56 + }, 57 + "state": { 58 + "name": "state", 59 + "type": "text", 60 + "primaryKey": false, 61 + "notNull": true, 62 + "autoincrement": false 63 + }, 64 + "created_at": { 65 + "name": "created_at", 66 + "type": "text", 67 + "primaryKey": false, 68 + "notNull": true, 69 + "autoincrement": false, 70 + "default": "(datetime('now'))" 71 + } 72 + }, 73 + "indexes": {}, 74 + "foreignKeys": {}, 75 + "compositePrimaryKeys": {}, 76 + "uniqueConstraints": {}, 77 + "checkConstraints": {} 78 + }, 79 + "indexer_cursor": { 80 + "name": "indexer_cursor", 81 + "columns": { 82 + "id": { 83 + "name": "id", 84 + "type": "text", 85 + "primaryKey": true, 86 + "notNull": true, 87 + "autoincrement": false, 88 + "default": "'jetstream'" 89 + }, 90 + "cursor": { 91 + "name": "cursor", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": true, 95 + "autoincrement": false 96 + }, 97 + "updated_at": { 98 + "name": "updated_at", 99 + "type": "text", 100 + "primaryKey": false, 101 + "notNull": true, 102 + "autoincrement": false, 103 + "default": "(datetime('now'))" 104 + } 105 + }, 106 + "indexes": {}, 107 + "foreignKeys": {}, 108 + "compositePrimaryKeys": {}, 109 + "uniqueConstraints": {}, 110 + "checkConstraints": {} 111 + }, 112 + "sphere_entry_counter": { 113 + "name": "sphere_entry_counter", 114 + "columns": { 115 + "sphere_id": { 116 + "name": "sphere_id", 117 + "type": "text", 118 + "primaryKey": true, 119 + "notNull": true, 120 + "autoincrement": false 121 + }, 122 + "last_number": { 123 + "name": "last_number", 124 + "type": "integer", 125 + "primaryKey": false, 126 + "notNull": true, 127 + "autoincrement": false, 128 + "default": 0 129 + } 130 + }, 131 + "indexes": {}, 132 + "foreignKeys": { 133 + "sphere_entry_counter_sphere_id_spheres_id_fk": { 134 + "name": "sphere_entry_counter_sphere_id_spheres_id_fk", 135 + "tableFrom": "sphere_entry_counter", 136 + "tableTo": "spheres", 137 + "columnsFrom": ["sphere_id"], 138 + "columnsTo": ["id"], 139 + "onDelete": "no action", 140 + "onUpdate": "no action" 141 + } 142 + }, 143 + "compositePrimaryKeys": {}, 144 + "uniqueConstraints": {}, 145 + "checkConstraints": {} 146 + }, 147 + "entity_labels": { 148 + "name": "entity_labels", 149 + "columns": { 150 + "label_id": { 151 + "name": "label_id", 152 + "type": "text", 153 + "primaryKey": false, 154 + "notNull": true, 155 + "autoincrement": false 156 + }, 157 + "entity_id": { 158 + "name": "entity_id", 159 + "type": "text", 160 + "primaryKey": false, 161 + "notNull": true, 162 + "autoincrement": false 163 + }, 164 + "entity_type": { 165 + "name": "entity_type", 166 + "type": "text", 167 + "primaryKey": false, 168 + "notNull": true, 169 + "autoincrement": false 170 + } 171 + }, 172 + "indexes": { 173 + "idx_entity_labels_entity": { 174 + "name": "idx_entity_labels_entity", 175 + "columns": ["entity_id", "entity_type"], 176 + "isUnique": false 177 + } 178 + }, 179 + "foreignKeys": { 180 + "entity_labels_label_id_sphere_labels_id_fk": { 181 + "name": "entity_labels_label_id_sphere_labels_id_fk", 182 + "tableFrom": "entity_labels", 183 + "tableTo": "sphere_labels", 184 + "columnsFrom": ["label_id"], 185 + "columnsTo": ["id"], 186 + "onDelete": "cascade", 187 + "onUpdate": "no action" 188 + } 189 + }, 190 + "compositePrimaryKeys": { 191 + "entity_labels_label_id_entity_id_pk": { 192 + "columns": ["label_id", "entity_id"], 193 + "name": "entity_labels_label_id_entity_id_pk" 194 + } 195 + }, 196 + "uniqueConstraints": {}, 197 + "checkConstraints": {} 198 + }, 199 + "sphere_labels": { 200 + "name": "sphere_labels", 201 + "columns": { 202 + "id": { 203 + "name": "id", 204 + "type": "text", 205 + "primaryKey": true, 206 + "notNull": true, 207 + "autoincrement": false 208 + }, 209 + "sphere_id": { 210 + "name": "sphere_id", 211 + "type": "text", 212 + "primaryKey": false, 213 + "notNull": true, 214 + "autoincrement": false 215 + }, 216 + "name": { 217 + "name": "name", 218 + "type": "text", 219 + "primaryKey": false, 220 + "notNull": true, 221 + "autoincrement": false 222 + }, 223 + "description": { 224 + "name": "description", 225 + "type": "text", 226 + "primaryKey": false, 227 + "notNull": false, 228 + "autoincrement": false 229 + }, 230 + "color": { 231 + "name": "color", 232 + "type": "text", 233 + "primaryKey": false, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "position": { 238 + "name": "position", 239 + "type": "integer", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false, 243 + "default": 0 244 + }, 245 + "created_at": { 246 + "name": "created_at", 247 + "type": "text", 248 + "primaryKey": false, 249 + "notNull": true, 250 + "autoincrement": false, 251 + "default": "(datetime('now'))" 252 + } 253 + }, 254 + "indexes": { 255 + "idx_sphere_labels_sphere_name": { 256 + "name": "idx_sphere_labels_sphere_name", 257 + "columns": ["sphere_id", "name"], 258 + "isUnique": true 259 + }, 260 + "idx_sphere_labels_sphere_position": { 261 + "name": "idx_sphere_labels_sphere_position", 262 + "columns": ["sphere_id", "position"], 263 + "isUnique": false 264 + } 265 + }, 266 + "foreignKeys": { 267 + "sphere_labels_sphere_id_spheres_id_fk": { 268 + "name": "sphere_labels_sphere_id_spheres_id_fk", 269 + "tableFrom": "sphere_labels", 270 + "tableTo": "spheres", 271 + "columnsFrom": ["sphere_id"], 272 + "columnsTo": ["id"], 273 + "onDelete": "no action", 274 + "onUpdate": "no action" 275 + } 276 + }, 277 + "compositePrimaryKeys": {}, 278 + "uniqueConstraints": {}, 279 + "checkConstraints": {} 280 + }, 281 + "sphere_members": { 282 + "name": "sphere_members", 283 + "columns": { 284 + "sphere_id": { 285 + "name": "sphere_id", 286 + "type": "text", 287 + "primaryKey": false, 288 + "notNull": true, 289 + "autoincrement": false 290 + }, 291 + "did": { 292 + "name": "did", 293 + "type": "text", 294 + "primaryKey": false, 295 + "notNull": true, 296 + "autoincrement": false 297 + }, 298 + "role": { 299 + "name": "role", 300 + "type": "text", 301 + "primaryKey": false, 302 + "notNull": true, 303 + "autoincrement": false, 304 + "default": "'member'" 305 + }, 306 + "status": { 307 + "name": "status", 308 + "type": "text", 309 + "primaryKey": false, 310 + "notNull": true, 311 + "autoincrement": false, 312 + "default": "'invited'" 313 + }, 314 + "invited_by": { 315 + "name": "invited_by", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": false, 319 + "autoincrement": false 320 + }, 321 + "pds_uri": { 322 + "name": "pds_uri", 323 + "type": "text", 324 + "primaryKey": false, 325 + "notNull": false, 326 + "autoincrement": false 327 + }, 328 + "approval_pds_uri": { 329 + "name": "approval_pds_uri", 330 + "type": "text", 331 + "primaryKey": false, 332 + "notNull": false, 333 + "autoincrement": false 334 + }, 335 + "created_at": { 336 + "name": "created_at", 337 + "type": "text", 338 + "primaryKey": false, 339 + "notNull": true, 340 + "autoincrement": false, 341 + "default": "(datetime('now'))" 342 + } 343 + }, 344 + "indexes": { 345 + "idx_sphere_members_did": { 346 + "name": "idx_sphere_members_did", 347 + "columns": ["did"], 348 + "isUnique": false 349 + } 350 + }, 351 + "foreignKeys": { 352 + "sphere_members_sphere_id_spheres_id_fk": { 353 + "name": "sphere_members_sphere_id_spheres_id_fk", 354 + "tableFrom": "sphere_members", 355 + "tableTo": "spheres", 356 + "columnsFrom": ["sphere_id"], 357 + "columnsTo": ["id"], 358 + "onDelete": "no action", 359 + "onUpdate": "no action" 360 + } 361 + }, 362 + "compositePrimaryKeys": { 363 + "sphere_members_sphere_id_did_pk": { 364 + "columns": ["sphere_id", "did"], 365 + "name": "sphere_members_sphere_id_did_pk" 366 + } 367 + }, 368 + "uniqueConstraints": {}, 369 + "checkConstraints": {} 370 + }, 371 + "sphere_modules": { 372 + "name": "sphere_modules", 373 + "columns": { 374 + "sphere_id": { 375 + "name": "sphere_id", 376 + "type": "text", 377 + "primaryKey": false, 378 + "notNull": true, 379 + "autoincrement": false 380 + }, 381 + "module_name": { 382 + "name": "module_name", 383 + "type": "text", 384 + "primaryKey": false, 385 + "notNull": true, 386 + "autoincrement": false 387 + }, 388 + "enabled_at": { 389 + "name": "enabled_at", 390 + "type": "text", 391 + "primaryKey": false, 392 + "notNull": true, 393 + "autoincrement": false, 394 + "default": "(datetime('now'))" 395 + } 396 + }, 397 + "indexes": {}, 398 + "foreignKeys": { 399 + "sphere_modules_sphere_id_spheres_id_fk": { 400 + "name": "sphere_modules_sphere_id_spheres_id_fk", 401 + "tableFrom": "sphere_modules", 402 + "tableTo": "spheres", 403 + "columnsFrom": ["sphere_id"], 404 + "columnsTo": ["id"], 405 + "onDelete": "no action", 406 + "onUpdate": "no action" 407 + } 408 + }, 409 + "compositePrimaryKeys": { 410 + "sphere_modules_sphere_id_module_name_pk": { 411 + "columns": ["sphere_id", "module_name"], 412 + "name": "sphere_modules_sphere_id_module_name_pk" 413 + } 414 + }, 415 + "uniqueConstraints": {}, 416 + "checkConstraints": {} 417 + }, 418 + "sphere_permissions": { 419 + "name": "sphere_permissions", 420 + "columns": { 421 + "sphere_id": { 422 + "name": "sphere_id", 423 + "type": "text", 424 + "primaryKey": false, 425 + "notNull": true, 426 + "autoincrement": false 427 + }, 428 + "action_key": { 429 + "name": "action_key", 430 + "type": "text", 431 + "primaryKey": false, 432 + "notNull": true, 433 + "autoincrement": false 434 + }, 435 + "min_role": { 436 + "name": "min_role", 437 + "type": "text", 438 + "primaryKey": false, 439 + "notNull": true, 440 + "autoincrement": false 441 + }, 442 + "updated_at": { 443 + "name": "updated_at", 444 + "type": "text", 445 + "primaryKey": false, 446 + "notNull": true, 447 + "autoincrement": false, 448 + "default": "(datetime('now'))" 449 + } 450 + }, 451 + "indexes": {}, 452 + "foreignKeys": { 453 + "sphere_permissions_sphere_id_spheres_id_fk": { 454 + "name": "sphere_permissions_sphere_id_spheres_id_fk", 455 + "tableFrom": "sphere_permissions", 456 + "tableTo": "spheres", 457 + "columnsFrom": ["sphere_id"], 458 + "columnsTo": ["id"], 459 + "onDelete": "no action", 460 + "onUpdate": "no action" 461 + } 462 + }, 463 + "compositePrimaryKeys": { 464 + "sphere_permissions_sphere_id_action_key_pk": { 465 + "columns": ["sphere_id", "action_key"], 466 + "name": "sphere_permissions_sphere_id_action_key_pk" 467 + } 468 + }, 469 + "uniqueConstraints": {}, 470 + "checkConstraints": {} 471 + }, 472 + "spheres": { 473 + "name": "spheres", 474 + "columns": { 475 + "id": { 476 + "name": "id", 477 + "type": "text", 478 + "primaryKey": true, 479 + "notNull": true, 480 + "autoincrement": false 481 + }, 482 + "handle": { 483 + "name": "handle", 484 + "type": "text", 485 + "primaryKey": false, 486 + "notNull": true, 487 + "autoincrement": false 488 + }, 489 + "name": { 490 + "name": "name", 491 + "type": "text", 492 + "primaryKey": false, 493 + "notNull": true, 494 + "autoincrement": false 495 + }, 496 + "description": { 497 + "name": "description", 498 + "type": "text", 499 + "primaryKey": false, 500 + "notNull": false, 501 + "autoincrement": false 502 + }, 503 + "visibility": { 504 + "name": "visibility", 505 + "type": "text", 506 + "primaryKey": false, 507 + "notNull": true, 508 + "autoincrement": false, 509 + "default": "'public'" 510 + }, 511 + "owner_did": { 512 + "name": "owner_did", 513 + "type": "text", 514 + "primaryKey": false, 515 + "notNull": true, 516 + "autoincrement": false 517 + }, 518 + "pds_uri": { 519 + "name": "pds_uri", 520 + "type": "text", 521 + "primaryKey": false, 522 + "notNull": false, 523 + "autoincrement": false 524 + }, 525 + "created_at": { 526 + "name": "created_at", 527 + "type": "text", 528 + "primaryKey": false, 529 + "notNull": true, 530 + "autoincrement": false, 531 + "default": "(datetime('now'))" 532 + }, 533 + "updated_at": { 534 + "name": "updated_at", 535 + "type": "text", 536 + "primaryKey": false, 537 + "notNull": true, 538 + "autoincrement": false, 539 + "default": "(datetime('now'))" 540 + } 541 + }, 542 + "indexes": { 543 + "spheres_handle_unique": { 544 + "name": "spheres_handle_unique", 545 + "columns": ["handle"], 546 + "isUnique": true 547 + } 548 + }, 549 + "foreignKeys": {}, 550 + "compositePrimaryKeys": {}, 551 + "uniqueConstraints": {}, 552 + "checkConstraints": {} 553 + }, 554 + "feature_request_comment_votes": { 555 + "name": "feature_request_comment_votes", 556 + "columns": { 557 + "comment_id": { 558 + "name": "comment_id", 559 + "type": "text", 560 + "primaryKey": false, 561 + "notNull": true, 562 + "autoincrement": false 563 + }, 564 + "author_did": { 565 + "name": "author_did", 566 + "type": "text", 567 + "primaryKey": false, 568 + "notNull": true, 569 + "autoincrement": false 570 + }, 571 + "pds_uri": { 572 + "name": "pds_uri", 573 + "type": "text", 574 + "primaryKey": false, 575 + "notNull": false, 576 + "autoincrement": false 577 + }, 578 + "created_at": { 579 + "name": "created_at", 580 + "type": "text", 581 + "primaryKey": false, 582 + "notNull": true, 583 + "autoincrement": false, 584 + "default": "(datetime('now'))" 585 + } 586 + }, 587 + "indexes": { 588 + "idx_feature_request_comment_votes_comment": { 589 + "name": "idx_feature_request_comment_votes_comment", 590 + "columns": ["comment_id"], 591 + "isUnique": false 592 + } 593 + }, 594 + "foreignKeys": { 595 + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { 596 + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", 597 + "tableFrom": "feature_request_comment_votes", 598 + "tableTo": "feature_request_comments", 599 + "columnsFrom": ["comment_id"], 600 + "columnsTo": ["id"], 601 + "onDelete": "no action", 602 + "onUpdate": "no action" 603 + } 604 + }, 605 + "compositePrimaryKeys": { 606 + "feature_request_comment_votes_comment_id_author_did_pk": { 607 + "columns": ["comment_id", "author_did"], 608 + "name": "feature_request_comment_votes_comment_id_author_did_pk" 609 + } 610 + }, 611 + "uniqueConstraints": {}, 612 + "checkConstraints": {} 613 + }, 614 + "feature_request_comments": { 615 + "name": "feature_request_comments", 616 + "columns": { 617 + "id": { 618 + "name": "id", 619 + "type": "text", 620 + "primaryKey": true, 621 + "notNull": true, 622 + "autoincrement": false 623 + }, 624 + "request_id": { 625 + "name": "request_id", 626 + "type": "text", 627 + "primaryKey": false, 628 + "notNull": true, 629 + "autoincrement": false 630 + }, 631 + "author_did": { 632 + "name": "author_did", 633 + "type": "text", 634 + "primaryKey": false, 635 + "notNull": true, 636 + "autoincrement": false 637 + }, 638 + "content": { 639 + "name": "content", 640 + "type": "text", 641 + "primaryKey": false, 642 + "notNull": true, 643 + "autoincrement": false 644 + }, 645 + "pds_uri": { 646 + "name": "pds_uri", 647 + "type": "text", 648 + "primaryKey": false, 649 + "notNull": false, 650 + "autoincrement": false 651 + }, 652 + "updated_at": { 653 + "name": "updated_at", 654 + "type": "text", 655 + "primaryKey": false, 656 + "notNull": true, 657 + "autoincrement": false, 658 + "default": "(datetime('now'))" 659 + }, 660 + "hidden_at": { 661 + "name": "hidden_at", 662 + "type": "text", 663 + "primaryKey": false, 664 + "notNull": false, 665 + "autoincrement": false 666 + }, 667 + "moderated_by": { 668 + "name": "moderated_by", 669 + "type": "text", 670 + "primaryKey": false, 671 + "notNull": false, 672 + "autoincrement": false 673 + } 674 + }, 675 + "indexes": { 676 + "idx_feature_request_comments_request": { 677 + "name": "idx_feature_request_comments_request", 678 + "columns": ["request_id"], 679 + "isUnique": false 680 + }, 681 + "idx_feature_request_comments_author_request": { 682 + "name": "idx_feature_request_comments_author_request", 683 + "columns": ["author_did", "request_id"], 684 + "isUnique": false 685 + } 686 + }, 687 + "foreignKeys": { 688 + "feature_request_comments_request_id_feature_requests_id_fk": { 689 + "name": "feature_request_comments_request_id_feature_requests_id_fk", 690 + "tableFrom": "feature_request_comments", 691 + "tableTo": "feature_requests", 692 + "columnsFrom": ["request_id"], 693 + "columnsTo": ["id"], 694 + "onDelete": "no action", 695 + "onUpdate": "no action" 696 + } 697 + }, 698 + "compositePrimaryKeys": {}, 699 + "uniqueConstraints": {}, 700 + "checkConstraints": {} 701 + }, 702 + "feature_request_statuses": { 703 + "name": "feature_request_statuses", 704 + "columns": { 705 + "id": { 706 + "name": "id", 707 + "type": "text", 708 + "primaryKey": true, 709 + "notNull": true, 710 + "autoincrement": false 711 + }, 712 + "request_id": { 713 + "name": "request_id", 714 + "type": "text", 715 + "primaryKey": false, 716 + "notNull": true, 717 + "autoincrement": false 718 + }, 719 + "author_did": { 720 + "name": "author_did", 721 + "type": "text", 722 + "primaryKey": false, 723 + "notNull": true, 724 + "autoincrement": false 725 + }, 726 + "status": { 727 + "name": "status", 728 + "type": "text", 729 + "primaryKey": false, 730 + "notNull": true, 731 + "autoincrement": false 732 + }, 733 + "pds_uri": { 734 + "name": "pds_uri", 735 + "type": "text", 736 + "primaryKey": false, 737 + "notNull": false, 738 + "autoincrement": false 739 + } 740 + }, 741 + "indexes": { 742 + "idx_feature_request_statuses_request": { 743 + "name": "idx_feature_request_statuses_request", 744 + "columns": ["request_id"], 745 + "isUnique": false 746 + } 747 + }, 748 + "foreignKeys": { 749 + "feature_request_statuses_request_id_feature_requests_id_fk": { 750 + "name": "feature_request_statuses_request_id_feature_requests_id_fk", 751 + "tableFrom": "feature_request_statuses", 752 + "tableTo": "feature_requests", 753 + "columnsFrom": ["request_id"], 754 + "columnsTo": ["id"], 755 + "onDelete": "no action", 756 + "onUpdate": "no action" 757 + } 758 + }, 759 + "compositePrimaryKeys": {}, 760 + "uniqueConstraints": {}, 761 + "checkConstraints": {} 762 + }, 763 + "feature_request_votes": { 764 + "name": "feature_request_votes", 765 + "columns": { 766 + "request_id": { 767 + "name": "request_id", 768 + "type": "text", 769 + "primaryKey": false, 770 + "notNull": true, 771 + "autoincrement": false 772 + }, 773 + "author_did": { 774 + "name": "author_did", 775 + "type": "text", 776 + "primaryKey": false, 777 + "notNull": true, 778 + "autoincrement": false 779 + }, 780 + "pds_uri": { 781 + "name": "pds_uri", 782 + "type": "text", 783 + "primaryKey": false, 784 + "notNull": false, 785 + "autoincrement": false 786 + }, 787 + "created_at": { 788 + "name": "created_at", 789 + "type": "text", 790 + "primaryKey": false, 791 + "notNull": true, 792 + "autoincrement": false, 793 + "default": "(datetime('now'))" 794 + } 795 + }, 796 + "indexes": { 797 + "idx_feature_request_votes_request": { 798 + "name": "idx_feature_request_votes_request", 799 + "columns": ["request_id"], 800 + "isUnique": false 801 + } 802 + }, 803 + "foreignKeys": { 804 + "feature_request_votes_request_id_feature_requests_id_fk": { 805 + "name": "feature_request_votes_request_id_feature_requests_id_fk", 806 + "tableFrom": "feature_request_votes", 807 + "tableTo": "feature_requests", 808 + "columnsFrom": ["request_id"], 809 + "columnsTo": ["id"], 810 + "onDelete": "no action", 811 + "onUpdate": "no action" 812 + } 813 + }, 814 + "compositePrimaryKeys": { 815 + "feature_request_votes_request_id_author_did_pk": { 816 + "columns": ["request_id", "author_did"], 817 + "name": "feature_request_votes_request_id_author_did_pk" 818 + } 819 + }, 820 + "uniqueConstraints": {}, 821 + "checkConstraints": {} 822 + }, 823 + "feature_requests": { 824 + "name": "feature_requests", 825 + "columns": { 826 + "id": { 827 + "name": "id", 828 + "type": "text", 829 + "primaryKey": true, 830 + "notNull": true, 831 + "autoincrement": false 832 + }, 833 + "sphere_id": { 834 + "name": "sphere_id", 835 + "type": "text", 836 + "primaryKey": false, 837 + "notNull": true, 838 + "autoincrement": false 839 + }, 840 + "number": { 841 + "name": "number", 842 + "type": "integer", 843 + "primaryKey": false, 844 + "notNull": true, 845 + "autoincrement": false 846 + }, 847 + "author_did": { 848 + "name": "author_did", 849 + "type": "text", 850 + "primaryKey": false, 851 + "notNull": true, 852 + "autoincrement": false 853 + }, 854 + "title": { 855 + "name": "title", 856 + "type": "text", 857 + "primaryKey": false, 858 + "notNull": true, 859 + "autoincrement": false 860 + }, 861 + "description": { 862 + "name": "description", 863 + "type": "text", 864 + "primaryKey": false, 865 + "notNull": true, 866 + "autoincrement": false 867 + }, 868 + "category": { 869 + "name": "category", 870 + "type": "text", 871 + "primaryKey": false, 872 + "notNull": true, 873 + "autoincrement": false, 874 + "default": "'general'" 875 + }, 876 + "status": { 877 + "name": "status", 878 + "type": "text", 879 + "primaryKey": false, 880 + "notNull": true, 881 + "autoincrement": false, 882 + "default": "'requested'" 883 + }, 884 + "duplicate_of_id": { 885 + "name": "duplicate_of_id", 886 + "type": "text", 887 + "primaryKey": false, 888 + "notNull": false, 889 + "autoincrement": false 890 + }, 891 + "pds_uri": { 892 + "name": "pds_uri", 893 + "type": "text", 894 + "primaryKey": false, 895 + "notNull": false, 896 + "autoincrement": false 897 + }, 898 + "hidden_at": { 899 + "name": "hidden_at", 900 + "type": "text", 901 + "primaryKey": false, 902 + "notNull": false, 903 + "autoincrement": false 904 + }, 905 + "moderated_by": { 906 + "name": "moderated_by", 907 + "type": "text", 908 + "primaryKey": false, 909 + "notNull": false, 910 + "autoincrement": false 911 + }, 912 + "updated_at": { 913 + "name": "updated_at", 914 + "type": "text", 915 + "primaryKey": false, 916 + "notNull": true, 917 + "autoincrement": false, 918 + "default": "(datetime('now'))" 919 + } 920 + }, 921 + "indexes": { 922 + "idx_feature_requests_sphere_number": { 923 + "name": "idx_feature_requests_sphere_number", 924 + "columns": ["sphere_id", "number"], 925 + "isUnique": true 926 + }, 927 + "idx_feature_requests_sphere": { 928 + "name": "idx_feature_requests_sphere", 929 + "columns": ["sphere_id"], 930 + "isUnique": false 931 + }, 932 + "idx_feature_requests_status": { 933 + "name": "idx_feature_requests_status", 934 + "columns": ["status"], 935 + "isUnique": false 936 + }, 937 + "idx_feature_requests_category": { 938 + "name": "idx_feature_requests_category", 939 + "columns": ["category"], 940 + "isUnique": false 941 + } 942 + }, 943 + "foreignKeys": { 944 + "feature_requests_sphere_id_spheres_id_fk": { 945 + "name": "feature_requests_sphere_id_spheres_id_fk", 946 + "tableFrom": "feature_requests", 947 + "tableTo": "spheres", 948 + "columnsFrom": ["sphere_id"], 949 + "columnsTo": ["id"], 950 + "onDelete": "no action", 951 + "onUpdate": "no action" 952 + } 953 + }, 954 + "compositePrimaryKeys": {}, 955 + "uniqueConstraints": {}, 956 + "checkConstraints": {} 957 + }, 958 + "feed_posts": { 959 + "name": "feed_posts", 960 + "columns": { 961 + "id": { 962 + "name": "id", 963 + "type": "text", 964 + "primaryKey": true, 965 + "notNull": true, 966 + "autoincrement": false 967 + }, 968 + "author_did": { 969 + "name": "author_did", 970 + "type": "text", 971 + "primaryKey": false, 972 + "notNull": true, 973 + "autoincrement": false 974 + }, 975 + "content": { 976 + "name": "content", 977 + "type": "text", 978 + "primaryKey": false, 979 + "notNull": true, 980 + "autoincrement": false 981 + }, 982 + "parent_id": { 983 + "name": "parent_id", 984 + "type": "text", 985 + "primaryKey": false, 986 + "notNull": false, 987 + "autoincrement": false 988 + }, 989 + "pds_uri": { 990 + "name": "pds_uri", 991 + "type": "text", 992 + "primaryKey": false, 993 + "notNull": false, 994 + "autoincrement": false 995 + }, 996 + "updated_at": { 997 + "name": "updated_at", 998 + "type": "text", 999 + "primaryKey": false, 1000 + "notNull": true, 1001 + "autoincrement": false, 1002 + "default": "(datetime('now'))" 1003 + } 1004 + }, 1005 + "indexes": { 1006 + "idx_feed_posts_parent": { 1007 + "name": "idx_feed_posts_parent", 1008 + "columns": ["parent_id"], 1009 + "isUnique": false 1010 + } 1011 + }, 1012 + "foreignKeys": {}, 1013 + "compositePrimaryKeys": {}, 1014 + "uniqueConstraints": {}, 1015 + "checkConstraints": {} 1016 + }, 1017 + "kanban_columns": { 1018 + "name": "kanban_columns", 1019 + "columns": { 1020 + "id": { 1021 + "name": "id", 1022 + "type": "text", 1023 + "primaryKey": true, 1024 + "notNull": true, 1025 + "autoincrement": false 1026 + }, 1027 + "sphere_id": { 1028 + "name": "sphere_id", 1029 + "type": "text", 1030 + "primaryKey": false, 1031 + "notNull": true, 1032 + "autoincrement": false 1033 + }, 1034 + "slug": { 1035 + "name": "slug", 1036 + "type": "text", 1037 + "primaryKey": false, 1038 + "notNull": true, 1039 + "autoincrement": false 1040 + }, 1041 + "label": { 1042 + "name": "label", 1043 + "type": "text", 1044 + "primaryKey": false, 1045 + "notNull": true, 1046 + "autoincrement": false 1047 + }, 1048 + "position": { 1049 + "name": "position", 1050 + "type": "integer", 1051 + "primaryKey": false, 1052 + "notNull": true, 1053 + "autoincrement": false 1054 + }, 1055 + "created_at": { 1056 + "name": "created_at", 1057 + "type": "text", 1058 + "primaryKey": false, 1059 + "notNull": true, 1060 + "autoincrement": false, 1061 + "default": "(datetime('now'))" 1062 + } 1063 + }, 1064 + "indexes": { 1065 + "idx_kanban_columns_sphere_slug": { 1066 + "name": "idx_kanban_columns_sphere_slug", 1067 + "columns": ["sphere_id", "slug"], 1068 + "isUnique": true 1069 + }, 1070 + "idx_kanban_columns_sphere_position": { 1071 + "name": "idx_kanban_columns_sphere_position", 1072 + "columns": ["sphere_id", "position"], 1073 + "isUnique": false 1074 + } 1075 + }, 1076 + "foreignKeys": { 1077 + "kanban_columns_sphere_id_spheres_id_fk": { 1078 + "name": "kanban_columns_sphere_id_spheres_id_fk", 1079 + "tableFrom": "kanban_columns", 1080 + "tableTo": "spheres", 1081 + "columnsFrom": ["sphere_id"], 1082 + "columnsTo": ["id"], 1083 + "onDelete": "no action", 1084 + "onUpdate": "no action" 1085 + } 1086 + }, 1087 + "compositePrimaryKeys": {}, 1088 + "uniqueConstraints": {}, 1089 + "checkConstraints": {} 1090 + }, 1091 + "kanban_task_comments": { 1092 + "name": "kanban_task_comments", 1093 + "columns": { 1094 + "id": { 1095 + "name": "id", 1096 + "type": "text", 1097 + "primaryKey": true, 1098 + "notNull": true, 1099 + "autoincrement": false 1100 + }, 1101 + "task_id": { 1102 + "name": "task_id", 1103 + "type": "text", 1104 + "primaryKey": false, 1105 + "notNull": true, 1106 + "autoincrement": false 1107 + }, 1108 + "author_did": { 1109 + "name": "author_did", 1110 + "type": "text", 1111 + "primaryKey": false, 1112 + "notNull": true, 1113 + "autoincrement": false 1114 + }, 1115 + "content": { 1116 + "name": "content", 1117 + "type": "text", 1118 + "primaryKey": false, 1119 + "notNull": true, 1120 + "autoincrement": false 1121 + }, 1122 + "pds_uri": { 1123 + "name": "pds_uri", 1124 + "type": "text", 1125 + "primaryKey": false, 1126 + "notNull": false, 1127 + "autoincrement": false 1128 + }, 1129 + "updated_at": { 1130 + "name": "updated_at", 1131 + "type": "text", 1132 + "primaryKey": false, 1133 + "notNull": true, 1134 + "autoincrement": false, 1135 + "default": "(datetime('now'))" 1136 + }, 1137 + "hidden_at": { 1138 + "name": "hidden_at", 1139 + "type": "text", 1140 + "primaryKey": false, 1141 + "notNull": false, 1142 + "autoincrement": false 1143 + }, 1144 + "moderated_by": { 1145 + "name": "moderated_by", 1146 + "type": "text", 1147 + "primaryKey": false, 1148 + "notNull": false, 1149 + "autoincrement": false 1150 + } 1151 + }, 1152 + "indexes": { 1153 + "idx_kanban_task_comments_task": { 1154 + "name": "idx_kanban_task_comments_task", 1155 + "columns": ["task_id"], 1156 + "isUnique": false 1157 + }, 1158 + "idx_kanban_task_comments_author_task": { 1159 + "name": "idx_kanban_task_comments_author_task", 1160 + "columns": ["author_did", "task_id"], 1161 + "isUnique": false 1162 + } 1163 + }, 1164 + "foreignKeys": { 1165 + "kanban_task_comments_task_id_kanban_tasks_id_fk": { 1166 + "name": "kanban_task_comments_task_id_kanban_tasks_id_fk", 1167 + "tableFrom": "kanban_task_comments", 1168 + "tableTo": "kanban_tasks", 1169 + "columnsFrom": ["task_id"], 1170 + "columnsTo": ["id"], 1171 + "onDelete": "no action", 1172 + "onUpdate": "no action" 1173 + } 1174 + }, 1175 + "compositePrimaryKeys": {}, 1176 + "uniqueConstraints": {}, 1177 + "checkConstraints": {} 1178 + }, 1179 + "kanban_task_status_changes": { 1180 + "name": "kanban_task_status_changes", 1181 + "columns": { 1182 + "id": { 1183 + "name": "id", 1184 + "type": "text", 1185 + "primaryKey": true, 1186 + "notNull": true, 1187 + "autoincrement": false 1188 + }, 1189 + "task_id": { 1190 + "name": "task_id", 1191 + "type": "text", 1192 + "primaryKey": false, 1193 + "notNull": true, 1194 + "autoincrement": false 1195 + }, 1196 + "author_did": { 1197 + "name": "author_did", 1198 + "type": "text", 1199 + "primaryKey": false, 1200 + "notNull": true, 1201 + "autoincrement": false 1202 + }, 1203 + "status": { 1204 + "name": "status", 1205 + "type": "text", 1206 + "primaryKey": false, 1207 + "notNull": true, 1208 + "autoincrement": false 1209 + }, 1210 + "pds_uri": { 1211 + "name": "pds_uri", 1212 + "type": "text", 1213 + "primaryKey": false, 1214 + "notNull": false, 1215 + "autoincrement": false 1216 + } 1217 + }, 1218 + "indexes": { 1219 + "idx_kanban_task_status_changes_task": { 1220 + "name": "idx_kanban_task_status_changes_task", 1221 + "columns": ["task_id"], 1222 + "isUnique": false 1223 + } 1224 + }, 1225 + "foreignKeys": { 1226 + "kanban_task_status_changes_task_id_kanban_tasks_id_fk": { 1227 + "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk", 1228 + "tableFrom": "kanban_task_status_changes", 1229 + "tableTo": "kanban_tasks", 1230 + "columnsFrom": ["task_id"], 1231 + "columnsTo": ["id"], 1232 + "onDelete": "no action", 1233 + "onUpdate": "no action" 1234 + } 1235 + }, 1236 + "compositePrimaryKeys": {}, 1237 + "uniqueConstraints": {}, 1238 + "checkConstraints": {} 1239 + }, 1240 + "kanban_tasks": { 1241 + "name": "kanban_tasks", 1242 + "columns": { 1243 + "id": { 1244 + "name": "id", 1245 + "type": "text", 1246 + "primaryKey": true, 1247 + "notNull": true, 1248 + "autoincrement": false 1249 + }, 1250 + "sphere_id": { 1251 + "name": "sphere_id", 1252 + "type": "text", 1253 + "primaryKey": false, 1254 + "notNull": true, 1255 + "autoincrement": false 1256 + }, 1257 + "number": { 1258 + "name": "number", 1259 + "type": "integer", 1260 + "primaryKey": false, 1261 + "notNull": true, 1262 + "autoincrement": false 1263 + }, 1264 + "author_did": { 1265 + "name": "author_did", 1266 + "type": "text", 1267 + "primaryKey": false, 1268 + "notNull": true, 1269 + "autoincrement": false 1270 + }, 1271 + "title": { 1272 + "name": "title", 1273 + "type": "text", 1274 + "primaryKey": false, 1275 + "notNull": true, 1276 + "autoincrement": false 1277 + }, 1278 + "description": { 1279 + "name": "description", 1280 + "type": "text", 1281 + "primaryKey": false, 1282 + "notNull": true, 1283 + "autoincrement": false, 1284 + "default": "''" 1285 + }, 1286 + "status": { 1287 + "name": "status", 1288 + "type": "text", 1289 + "primaryKey": false, 1290 + "notNull": true, 1291 + "autoincrement": false, 1292 + "default": "'backlog'" 1293 + }, 1294 + "position": { 1295 + "name": "position", 1296 + "type": "integer", 1297 + "primaryKey": false, 1298 + "notNull": true, 1299 + "autoincrement": false, 1300 + "default": 0 1301 + }, 1302 + "assignee_did": { 1303 + "name": "assignee_did", 1304 + "type": "text", 1305 + "primaryKey": false, 1306 + "notNull": false, 1307 + "autoincrement": false 1308 + }, 1309 + "pds_uri": { 1310 + "name": "pds_uri", 1311 + "type": "text", 1312 + "primaryKey": false, 1313 + "notNull": false, 1314 + "autoincrement": false 1315 + }, 1316 + "hidden_at": { 1317 + "name": "hidden_at", 1318 + "type": "text", 1319 + "primaryKey": false, 1320 + "notNull": false, 1321 + "autoincrement": false 1322 + }, 1323 + "moderated_by": { 1324 + "name": "moderated_by", 1325 + "type": "text", 1326 + "primaryKey": false, 1327 + "notNull": false, 1328 + "autoincrement": false 1329 + }, 1330 + "updated_at": { 1331 + "name": "updated_at", 1332 + "type": "text", 1333 + "primaryKey": false, 1334 + "notNull": true, 1335 + "autoincrement": false, 1336 + "default": "(datetime('now'))" 1337 + } 1338 + }, 1339 + "indexes": { 1340 + "idx_kanban_tasks_sphere_number": { 1341 + "name": "idx_kanban_tasks_sphere_number", 1342 + "columns": ["sphere_id", "number"], 1343 + "isUnique": true 1344 + }, 1345 + "idx_kanban_tasks_sphere": { 1346 + "name": "idx_kanban_tasks_sphere", 1347 + "columns": ["sphere_id"], 1348 + "isUnique": false 1349 + }, 1350 + "idx_kanban_tasks_status": { 1351 + "name": "idx_kanban_tasks_status", 1352 + "columns": ["status"], 1353 + "isUnique": false 1354 + }, 1355 + "idx_kanban_tasks_sphere_status_position": { 1356 + "name": "idx_kanban_tasks_sphere_status_position", 1357 + "columns": ["sphere_id", "status", "position"], 1358 + "isUnique": false 1359 + } 1360 + }, 1361 + "foreignKeys": { 1362 + "kanban_tasks_sphere_id_spheres_id_fk": { 1363 + "name": "kanban_tasks_sphere_id_spheres_id_fk", 1364 + "tableFrom": "kanban_tasks", 1365 + "tableTo": "spheres", 1366 + "columnsFrom": ["sphere_id"], 1367 + "columnsTo": ["id"], 1368 + "onDelete": "no action", 1369 + "onUpdate": "no action" 1370 + } 1371 + }, 1372 + "compositePrimaryKeys": {}, 1373 + "uniqueConstraints": {}, 1374 + "checkConstraints": {} 1375 + } 1376 + }, 1377 + "views": {}, 1378 + "enums": {}, 1379 + "_meta": { 1380 + "schemas": {}, 1381 + "tables": {}, 1382 + "columns": {} 1383 + }, 1384 + "internal": { 1385 + "indexes": {} 1386 + } 1387 + }
+7
drizzle/meta/_journal.json
··· 29 29 "when": 1775304470263, 30 30 "tag": "0003_soft_tigra", 31 31 "breakpoints": true 32 + }, 33 + { 34 + "idx": 4, 35 + "version": "6", 36 + "when": 1776203212384, 37 + "tag": "0004_melted_brother_voodoo", 38 + "breakpoints": true 32 39 } 33 40 ] 34 41 }
+53
packages/app/src/api/spheres.ts
··· 127 127 body: JSON.stringify({ role }), 128 128 }); 129 129 } 130 + 131 + // ---- Labels ---- 132 + 133 + export type { LabelData } from "@exosphere/client/api/labels"; 134 + import type { LabelData } from "@exosphere/client/api/labels"; 135 + 136 + export function getSphereLabels(handle: string) { 137 + return apiFetch<{ labels: LabelData[] }>(`/api/spheres/${encodeURIComponent(handle)}/labels`); 138 + } 139 + 140 + export function createSphereLabel( 141 + handle: string, 142 + body: { name: string; description?: string; color: string }, 143 + ) { 144 + return apiFetch<{ label: LabelData }>(`/api/spheres/${encodeURIComponent(handle)}/labels`, { 145 + method: "POST", 146 + headers: { "Content-Type": "application/json" }, 147 + body: JSON.stringify(body), 148 + }); 149 + } 150 + 151 + export function updateSphereLabel( 152 + handle: string, 153 + id: string, 154 + body: { name?: string; description?: string | null; color?: string }, 155 + ) { 156 + return apiFetch<{ label: LabelData }>( 157 + `/api/spheres/${encodeURIComponent(handle)}/labels/${encodeURIComponent(id)}`, 158 + { 159 + method: "PUT", 160 + headers: { "Content-Type": "application/json" }, 161 + body: JSON.stringify(body), 162 + }, 163 + ); 164 + } 165 + 166 + export function deleteSphereLabel(handle: string, id: string) { 167 + return apiFetch<{ ok: true }>( 168 + `/api/spheres/${encodeURIComponent(handle)}/labels/${encodeURIComponent(id)}`, 169 + { method: "DELETE" }, 170 + ); 171 + } 172 + 173 + export function reorderSphereLabels(handle: string, labelIds: string[]) { 174 + return apiFetch<{ labels: LabelData[] }>( 175 + `/api/spheres/${encodeURIComponent(handle)}/labels/reorder`, 176 + { 177 + method: "POST", 178 + headers: { "Content-Type": "application/json" }, 179 + body: JSON.stringify({ labelIds }), 180 + }, 181 + ); 182 + }
+10 -1
packages/app/src/app.tsx
··· 14 14 import { SpherePage } from "./pages/sphere.tsx"; 15 15 import { SphereMembersPage } from "./pages/sphere-members.tsx"; 16 16 import { SpherePermissionsPage } from "./pages/sphere-permissions.tsx"; 17 + import { SphereLabelsPage } from "./pages/sphere-labels.tsx"; 17 18 import { Dashboard } from "./pages/dashboard.tsx"; 18 19 import type { ModuleRoute } from "@exosphere/client/types"; 19 20 import { feedsModule } from "@exosphere/feeds/client"; ··· 138 139 path="/s/:sphereHandle/settings/permissions" 139 140 component={withSphereLoader(SpherePermissionsPage)} 140 141 /> 142 + <Route 143 + path="/s/:sphereHandle/settings/labels" 144 + component={withSphereLoader(SphereLabelsPage)} 145 + /> 141 146 <Route path="/s/:sphereHandle" component={withSphereLoader(SpherePage)} /> 142 147 <Route path="/" component={MultiSphereDefaultPage} /> 143 148 <Route default component={NotFoundPage} /> ··· 172 177 {[0, 1].map((i) => ( 173 178 <div key={i} class={ui.card} style={{ pointerEvents: "none" }}> 174 179 <div class={ui.skeletonLine} style={{ inlineSize: "50%" }} /> 175 - <div class={ui.skeletonLine} style={{ inlineSize: "70%", marginBlockStart: "8px" }} /> 180 + <div 181 + class={ui.skeletonLine} 182 + style={{ inlineSize: "70%", marginBlockStart: "8px" }} 183 + /> 176 184 </div> 177 185 ))} 178 186 </div> ··· 187 195 <Route path="/sign-in" component={SignInPage} /> 188 196 <Route path="/settings/members" component={SphereMembersPage} /> 189 197 <Route path="/settings/permissions" component={SpherePermissionsPage} /> 198 + <Route path="/settings/labels" component={SphereLabelsPage} /> 190 199 {moduleRoutes.map((r) => ( 191 200 <Route key={r.path} path={r.path} component={r.component} /> 192 201 ))}
+31
packages/app/src/pages/color-picker.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "@exosphere/client/theme.css"; 3 + 4 + export const colorPickerRow = style({ 5 + display: "flex", 6 + alignItems: "center", 7 + gap: vars.space.sm, 8 + flexWrap: "wrap", 9 + }); 10 + 11 + export const colorSwatch = style({ 12 + inlineSize: "28px", 13 + blockSize: "28px", 14 + borderRadius: "50%", 15 + cursor: "pointer", 16 + padding: 0, 17 + border: "2px solid transparent", 18 + transition: "border-color 0.1s", 19 + }); 20 + 21 + export const colorSwatchSelected = style({ 22 + border: "3px solid currentColor", 23 + }); 24 + 25 + export const colorInput = style({ 26 + inlineSize: "28px", 27 + blockSize: "28px", 28 + padding: 0, 29 + border: "none", 30 + cursor: "pointer", 31 + });
+304
packages/app/src/pages/sphere-labels.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { sphereState, sphereHandle } from "@exosphere/client/sphere"; 3 + import { canDo } from "@exosphere/client/permissions"; 4 + import { useQuery } from "@exosphere/client/hooks"; 5 + import { spherePath } from "@exosphere/client/router"; 6 + import { LabelBadge } from "@exosphere/client/components/label-badge"; 7 + import * as ui from "@exosphere/client/ui.css"; 8 + import * as cpUi from "./color-picker.css.ts"; 9 + import { 10 + getSphereLabels, 11 + createSphereLabel, 12 + updateSphereLabel, 13 + deleteSphereLabel, 14 + type LabelData, 15 + } from "../api/spheres.ts"; 16 + 17 + const presetColors = [ 18 + "#6b7280", 19 + "#ef4444", 20 + "#f59e0b", 21 + "#22c55e", 22 + "#3b82f6", 23 + "#8b5cf6", 24 + "#ec4899", 25 + "#14b8a6", 26 + ]; 27 + 28 + export function SphereLabelsPage() { 29 + const { data } = sphereState.value; 30 + const handle = sphereHandle.value; 31 + 32 + if (!data || !handle) return null; 33 + if (!canDo("sphere", "manageLabels")) return null; 34 + 35 + return <LabelsContent handle={handle} />; 36 + } 37 + 38 + function LabelsContent({ handle }: { handle: string }) { 39 + const { data, refetch } = useQuery(() => getSphereLabels(handle), [handle]); 40 + 41 + const newName = useSignal(""); 42 + const newDescription = useSignal(""); 43 + const newColor = useSignal(presetColors[0]); 44 + const creating = useSignal(false); 45 + const createError = useSignal(""); 46 + 47 + const editingId = useSignal<string | null>(null); 48 + const editName = useSignal(""); 49 + const editDescription = useSignal(""); 50 + const editColor = useSignal(""); 51 + const saving = useSignal(false); 52 + const listError = useSignal(""); 53 + 54 + const handleCreate = async (e: Event) => { 55 + e.preventDefault(); 56 + if (!newName.value.trim()) { 57 + createError.value = "Name is required."; 58 + return; 59 + } 60 + creating.value = true; 61 + createError.value = ""; 62 + try { 63 + await createSphereLabel(handle, { 64 + name: newName.value.trim(), 65 + description: newDescription.value.trim() || undefined, 66 + color: newColor.value, 67 + }); 68 + newName.value = ""; 69 + newDescription.value = ""; 70 + newColor.value = presetColors[0]; 71 + refetch(); 72 + } catch (err) { 73 + createError.value = err instanceof Error ? err.message : "Failed to create label."; 74 + } finally { 75 + creating.value = false; 76 + } 77 + }; 78 + 79 + const startEdit = (label: LabelData) => { 80 + editingId.value = label.id; 81 + editName.value = label.name; 82 + editDescription.value = label.description ?? ""; 83 + editColor.value = label.color; 84 + listError.value = ""; 85 + }; 86 + 87 + const cancelEdit = () => { 88 + editingId.value = null; 89 + listError.value = ""; 90 + }; 91 + 92 + const handleSave = async () => { 93 + if (!editingId.value || !editName.value.trim()) return; 94 + saving.value = true; 95 + listError.value = ""; 96 + try { 97 + await updateSphereLabel(handle, editingId.value, { 98 + name: editName.value.trim(), 99 + description: editDescription.value.trim() || null, 100 + color: editColor.value, 101 + }); 102 + editingId.value = null; 103 + refetch(); 104 + } catch (err) { 105 + listError.value = err instanceof Error ? err.message : "Failed to update label."; 106 + } finally { 107 + saving.value = false; 108 + } 109 + }; 110 + 111 + const confirmingDeleteId = useSignal<string | null>(null); 112 + 113 + const handleDelete = async (id: string) => { 114 + if (confirmingDeleteId.value !== id) { 115 + confirmingDeleteId.value = id; 116 + return; 117 + } 118 + confirmingDeleteId.value = null; 119 + try { 120 + await deleteSphereLabel(handle, id); 121 + refetch(); 122 + } catch (err) { 123 + listError.value = err instanceof Error ? err.message : "Failed to delete label."; 124 + } 125 + }; 126 + 127 + return ( 128 + <div class={ui.container}> 129 + <div class={ui.section}> 130 + <div> 131 + <a href={spherePath("/")} class={ui.muted}> 132 + &larr; Sphere 133 + </a> 134 + </div> 135 + <h1 class={ui.pageTitle}>Labels</h1> 136 + <p class={ui.description}>Create and manage labels for feature requests and tasks.</p> 137 + </div> 138 + 139 + <div class={ui.section}> 140 + <h2 class={ui.sectionTitle}>Create label</h2> 141 + <form onSubmit={handleCreate} class={ui.formStack}> 142 + <div> 143 + <label class={ui.label} htmlFor="label-name"> 144 + Name 145 + </label> 146 + <input 147 + id="label-name" 148 + class={ui.input} 149 + type="text" 150 + maxLength={50} 151 + placeholder="e.g. Bug, Enhancement, Frontend" 152 + value={newName.value} 153 + onInput={(e) => (newName.value = (e.target as HTMLInputElement).value)} 154 + /> 155 + </div> 156 + <div> 157 + <label class={ui.label} htmlFor="label-desc"> 158 + Description <span class={ui.labelHint}>(optional)</span> 159 + </label> 160 + <input 161 + id="label-desc" 162 + class={ui.input} 163 + type="text" 164 + maxLength={200} 165 + placeholder="Short description" 166 + value={newDescription.value} 167 + onInput={(e) => (newDescription.value = (e.target as HTMLInputElement).value)} 168 + /> 169 + </div> 170 + <div> 171 + <label class={ui.label}>Color</label> 172 + <ColorPicker value={newColor.value} onChange={(c) => (newColor.value = c)} /> 173 + </div> 174 + {createError.value && <p class={ui.errorText}>{createError.value}</p>} 175 + <div> 176 + <button type="submit" class={ui.button} disabled={creating.value}> 177 + {creating.value ? "Creating..." : "Create label"} 178 + </button> 179 + </div> 180 + </form> 181 + </div> 182 + 183 + <div class={ui.section}> 184 + <h2 class={ui.sectionTitle}>Current labels</h2> 185 + {listError.value && <p class={ui.errorText}>{listError.value}</p>} 186 + {!data ? ( 187 + <p class={ui.muted}>Loading...</p> 188 + ) : data.labels.length === 0 ? ( 189 + <p class={ui.emptyState}>No labels yet.</p> 190 + ) : ( 191 + <div class={ui.stackSm}> 192 + {data.labels.map((label) => ( 193 + <div key={label.id} class={ui.card}> 194 + {editingId.value === label.id ? ( 195 + <div class={ui.formStack}> 196 + <div> 197 + <label class={ui.label}>Name</label> 198 + <input 199 + class={ui.input} 200 + type="text" 201 + maxLength={50} 202 + value={editName.value} 203 + onInput={(e) => (editName.value = (e.target as HTMLInputElement).value)} 204 + /> 205 + </div> 206 + <div> 207 + <label class={ui.label}>Description</label> 208 + <input 209 + class={ui.input} 210 + type="text" 211 + maxLength={200} 212 + value={editDescription.value} 213 + onInput={(e) => 214 + (editDescription.value = (e.target as HTMLInputElement).value) 215 + } 216 + /> 217 + </div> 218 + <div> 219 + <label class={ui.label}>Color</label> 220 + <ColorPicker 221 + value={editColor.value} 222 + onChange={(c) => (editColor.value = c)} 223 + /> 224 + </div> 225 + <div class={ui.row}> 226 + <button class={ui.button} onClick={handleSave} disabled={saving.value}> 227 + {saving.value ? "Saving..." : "Save"} 228 + </button> 229 + <button class={ui.buttonInline} onClick={cancelEdit}> 230 + Cancel 231 + </button> 232 + </div> 233 + </div> 234 + ) : ( 235 + <div class={ui.row}> 236 + <div> 237 + <LabelBadge label={label} /> 238 + {label.description && ( 239 + <span class={ui.muted} style={{ marginInlineStart: "8px" }}> 240 + {label.description} 241 + </span> 242 + )} 243 + </div> 244 + <div class={ui.row}> 245 + <button class={ui.buttonInline} onClick={() => startEdit(label)}> 246 + Edit 247 + </button> 248 + {confirmingDeleteId.value === label.id ? ( 249 + <> 250 + <button 251 + class={ui.buttonDangerInline} 252 + onClick={() => handleDelete(label.id)} 253 + > 254 + Confirm 255 + </button> 256 + <button 257 + class={ui.buttonInline} 258 + onClick={() => (confirmingDeleteId.value = null)} 259 + > 260 + Cancel 261 + </button> 262 + </> 263 + ) : ( 264 + <button 265 + class={ui.buttonDangerInline} 266 + onClick={() => handleDelete(label.id)} 267 + > 268 + Delete 269 + </button> 270 + )} 271 + </div> 272 + </div> 273 + )} 274 + </div> 275 + ))} 276 + </div> 277 + )} 278 + </div> 279 + </div> 280 + ); 281 + } 282 + 283 + function ColorPicker({ value, onChange }: { value: string; onChange: (color: string) => void }) { 284 + return ( 285 + <div class={cpUi.colorPickerRow}> 286 + {presetColors.map((color) => ( 287 + <button 288 + key={color} 289 + type="button" 290 + class={`${cpUi.colorSwatch}${value === color ? ` ${cpUi.colorSwatchSelected}` : ""}`} 291 + onClick={() => onChange(color)} 292 + style={{ backgroundColor: color }} 293 + aria-label={color} 294 + /> 295 + ))} 296 + <input 297 + type="color" 298 + class={cpUi.colorInput} 299 + value={value} 300 + onInput={(e) => onChange((e.target as HTMLInputElement).value)} 301 + /> 302 + </div> 303 + ); 304 + }
+13 -2
packages/app/src/pages/sphere.tsx
··· 51 51 {[0, 1].map((i) => ( 52 52 <div key={i} class={ui.card} style={{ pointerEvents: "none" }}> 53 53 <div class={ui.skeletonLine} style={{ inlineSize: "40%" }} /> 54 - <div class={ui.skeletonLine} style={{ inlineSize: "70%", marginBlockStart: "8px" }} /> 54 + <div 55 + class={ui.skeletonLine} 56 + style={{ inlineSize: "70%", marginBlockStart: "8px" }} 57 + /> 55 58 </div> 56 59 ))} 57 60 </div> ··· 150 153 </div> 151 154 152 155 {/* Settings — visible to users with relevant permissions */} 153 - {(canManageMembers || canDo("sphere", "updatePermissions")) && ( 156 + {(canManageMembers || 157 + canDo("sphere", "updatePermissions") || 158 + canDo("sphere", "manageLabels")) && ( 154 159 <div class={ui.section}> 155 160 <h2 class={ui.sectionTitle}>Settings</h2> 156 161 <div class={ui.stackSm}> ··· 164 169 <a href={spherePath("/settings/permissions")} class={ui.cardLink}> 165 170 <strong>Permissions</strong> 166 171 <p class={ui.muted}>Configure who can perform actions in this sphere.</p> 172 + </a> 173 + )} 174 + {canDo("sphere", "manageLabels") && ( 175 + <a href={spherePath("/settings/labels")} class={ui.cardLink}> 176 + <strong>Labels</strong> 177 + <p class={ui.muted}>Create and manage labels for feature requests and tasks.</p> 167 178 </a> 168 179 )} 169 180 </div>
+4 -1
packages/client/package.json
··· 20 20 "./components/collapsible-section": "./src/components/collapsible-section.tsx", 21 21 "./components/invitation-banner": "./src/components/invitation-banner.tsx", 22 22 "./components/modal": "./src/components/modal.tsx", 23 - "./components/theme-toggle": "./src/components/theme-toggle.tsx" 23 + "./components/theme-toggle": "./src/components/theme-toggle.tsx", 24 + "./components/label-badge": "./src/components/label-badge.tsx", 25 + "./components/label-picker": "./src/components/label-picker.tsx", 26 + "./api/labels": "./src/api/labels.ts" 24 27 }, 25 28 "peerDependencies": { 26 29 "@preact/signals": "catalog:",
+20
packages/client/src/api/labels.ts
··· 1 + import { apiFetch } from "../api.ts"; 2 + import { sphereHandle } from "../sphere.ts"; 3 + 4 + export interface LabelData { 5 + id: string; 6 + name: string; 7 + description: string | null; 8 + color: string; 9 + position: number; 10 + } 11 + 12 + function labelsUrl() { 13 + const handle = sphereHandle.value; 14 + if (!handle) throw new Error("No sphere loaded"); 15 + return `/api/spheres/${encodeURIComponent(handle)}/labels`; 16 + } 17 + 18 + export function getLabels() { 19 + return apiFetch<{ labels: LabelData[] }>(labelsUrl()); 20 + }
+22
packages/client/src/components/label-badge.tsx
··· 1 + import * as ui from "../ui.css.ts"; 2 + 3 + function hexToRgba(hex: string, alpha: number): string { 4 + const r = parseInt(hex.slice(1, 3), 16); 5 + const g = parseInt(hex.slice(3, 5), 16); 6 + const b = parseInt(hex.slice(5, 7), 16); 7 + return `rgba(${r}, ${g}, ${b}, ${alpha})`; 8 + } 9 + 10 + export function LabelBadge({ label }: { label: { name: string; color: string } }) { 11 + return ( 12 + <span 13 + class={ui.badge} 14 + style={{ 15 + backgroundColor: hexToRgba(label.color, 0.15), 16 + color: label.color, 17 + }} 18 + > 19 + {label.name} 20 + </span> 21 + ); 22 + }
+55
packages/client/src/components/label-picker.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../theme.css.ts"; 3 + 4 + export const wrapper = style({ 5 + position: "relative", 6 + }); 7 + 8 + export const trigger = style({ 9 + boxSizing: "border-box", 10 + display: "flex", 11 + alignItems: "center", 12 + gap: vars.space.xs, 13 + inlineSize: "100%", 14 + minBlockSize: "38px", 15 + paddingBlock: vars.space.xs, 16 + paddingInline: vars.space.sm, 17 + backgroundColor: vars.color.surface, 18 + border: `1px solid ${vars.color.border}`, 19 + borderRadius: vars.radius.sm, 20 + cursor: "pointer", 21 + textAlign: "start", 22 + transition: "border-color 0.15s", 23 + ":hover": { 24 + borderColor: vars.color.primary, 25 + }, 26 + }); 27 + 28 + export const dropdown = style({ 29 + position: "absolute", 30 + zIndex: 10, 31 + insetBlockStart: "100%", 32 + insetInlineStart: 0, 33 + marginBlockStart: vars.space.xs, 34 + minInlineSize: "200px", 35 + maxBlockSize: "240px", 36 + overflowY: "auto", 37 + backgroundColor: vars.color.surface, 38 + border: `1px solid ${vars.color.border}`, 39 + borderRadius: vars.radius.sm, 40 + boxShadow: `0 4px 12px ${vars.color.shadow}`, 41 + paddingBlock: vars.space.xs, 42 + }); 43 + 44 + export const option = style({ 45 + display: "flex", 46 + alignItems: "center", 47 + gap: vars.space.sm, 48 + paddingBlock: vars.space.xs, 49 + paddingInline: vars.space.sm, 50 + cursor: "pointer", 51 + transition: "background-color 0.1s", 52 + ":hover": { 53 + backgroundColor: vars.color.surfaceHover, 54 + }, 55 + });
+79
packages/client/src/components/label-picker.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + import { useEffect, useRef } from "preact/hooks"; 3 + import * as ui from "../ui.css.ts"; 4 + import * as s from "./label-picker.css.ts"; 5 + import { LabelBadge } from "./label-badge.tsx"; 6 + 7 + export interface LabelOption { 8 + id: string; 9 + name: string; 10 + color: string; 11 + } 12 + 13 + export function LabelPicker({ 14 + labels, 15 + selectedIds, 16 + onChange, 17 + }: { 18 + labels: LabelOption[]; 19 + selectedIds: string[]; 20 + onChange: (ids: string[]) => void; 21 + }) { 22 + const open = useSignal(false); 23 + const wrapperRef = useRef<HTMLDivElement>(null); 24 + 25 + // Close dropdown on outside click 26 + useEffect(() => { 27 + if (!open.value) return; 28 + const handler = (e: MouseEvent) => { 29 + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { 30 + open.value = false; 31 + } 32 + }; 33 + document.addEventListener("mousedown", handler); 34 + return () => document.removeEventListener("mousedown", handler); 35 + }, [open.value]); 36 + 37 + if (labels.length === 0) return null; 38 + 39 + const toggle = (id: string) => { 40 + if (selectedIds.includes(id)) { 41 + onChange(selectedIds.filter((i) => i !== id)); 42 + } else { 43 + onChange([...selectedIds, id]); 44 + } 45 + }; 46 + 47 + const selectedLabels = labels.filter((l) => selectedIds.includes(l.id)); 48 + 49 + return ( 50 + <div class={s.wrapper} ref={wrapperRef}> 51 + <button type="button" class={s.trigger} onClick={() => (open.value = !open.value)}> 52 + {selectedLabels.length > 0 ? ( 53 + <span class={ui.cluster}> 54 + {selectedLabels.map((l) => ( 55 + <LabelBadge key={l.id} label={l} /> 56 + ))} 57 + </span> 58 + ) : ( 59 + <span class={ui.muted}>Select labels...</span> 60 + )} 61 + </button> 62 + 63 + {open.value && ( 64 + <div class={s.dropdown}> 65 + {labels.map((label) => ( 66 + <label key={label.id} class={s.option}> 67 + <input 68 + type="checkbox" 69 + checked={selectedIds.includes(label.id)} 70 + onChange={() => toggle(label.id)} 71 + /> 72 + <LabelBadge label={label} /> 73 + </label> 74 + ))} 75 + </div> 76 + )} 77 + </div> 78 + ); 79 + }
+94
packages/core/src/db/migrate.ts
··· 10 10 // shared sphere_entry_counter. Runs only once — skips if counter rows exist. 11 11 seedEntryCounters(); 12 12 13 + // Data migration: convert hardcoded feature request categories into 14 + // sphere_labels + entity_labels. Runs only once — skips if labels already exist. 15 + seedLabelsFromCategories(); 16 + 13 17 function seedEntryCounters() { 14 18 const dbPath = process.env.DATABASE_PATH || "exosphere.sqlite"; 15 19 const db = new Database(dbPath); ··· 84 88 db.run("PRAGMA foreign_keys = ON;"); 85 89 db.close(); 86 90 } 91 + 92 + const categoryDefaults = [ 93 + { name: "General", slug: "general", color: "#6b7280", position: 0 }, 94 + { name: "Enhancement", slug: "enhancement", color: "#3b82f6", position: 1 }, 95 + { name: "Bug", slug: "bug", color: "#ef4444", position: 2 }, 96 + { name: "Integration", slug: "integration", color: "#8b5cf6", position: 3 }, 97 + { name: "UI / UX", slug: "ui-ux", color: "#f59e0b", position: 4 }, 98 + ] as const; 99 + 100 + function seedLabelsFromCategories() { 101 + const dbPath = process.env.DATABASE_PATH || "exosphere.sqlite"; 102 + const db = new Database(dbPath); 103 + 104 + // Check if the sphere_labels table exists 105 + const tableExists = db 106 + .query<{ name: string }, []>( 107 + "SELECT name FROM sqlite_master WHERE type='table' AND name='sphere_labels'", 108 + ) 109 + .get(); 110 + if (!tableExists) { 111 + db.close(); 112 + return; 113 + } 114 + 115 + // Skip if already seeded 116 + const existing = db.query<{ c: number }, []>("SELECT count(*) as c FROM sphere_labels").get(); 117 + if (existing && existing.c > 0) { 118 + db.close(); 119 + return; 120 + } 121 + 122 + // Check if feature_requests table exists (module may not be enabled) 123 + const frTableExists = db 124 + .query<{ name: string }, []>( 125 + "SELECT name FROM sqlite_master WHERE type='table' AND name='feature_requests'", 126 + ) 127 + .get(); 128 + if (!frTableExists) { 129 + db.close(); 130 + return; 131 + } 132 + 133 + // Find spheres that have feature requests 134 + const spheres = db 135 + .query<{ sphere_id: string }, []>("SELECT DISTINCT sphere_id FROM feature_requests") 136 + .all(); 137 + 138 + if (spheres.length === 0) { 139 + db.close(); 140 + return; 141 + } 142 + 143 + let labelCounter = 0; 144 + 145 + for (const { sphere_id } of spheres) { 146 + const tx = db.transaction(() => { 147 + // Create labels for this sphere 148 + const labelIds: Record<string, string> = {}; 149 + for (const cat of categoryDefaults) { 150 + const id = `label_migration_${sphere_id}_${labelCounter++}`; 151 + db.run( 152 + "INSERT INTO sphere_labels (id, sphere_id, name, description, color, position) VALUES (?, ?, ?, NULL, ?, ?)", 153 + [id, sphere_id, cat.name, cat.color, cat.position], 154 + ); 155 + labelIds[cat.slug] = id; 156 + } 157 + 158 + // Link existing feature requests to their corresponding labels 159 + const requests = db 160 + .query<{ id: string; category: string }, [string]>( 161 + "SELECT id, category FROM feature_requests WHERE sphere_id = ? AND category IS NOT NULL", 162 + ) 163 + .all(sphere_id); 164 + 165 + for (const req of requests) { 166 + const labelId = labelIds[req.category]; 167 + if (labelId) { 168 + db.run( 169 + "INSERT OR IGNORE INTO entity_labels (label_id, entity_id, entity_type) VALUES (?, ?, ?)", 170 + [labelId, req.id, "feature-request"], 171 + ); 172 + } 173 + } 174 + }); 175 + tx(); 176 + console.log(`[migrate] Sphere ${sphere_id}: created labels from categories`); 177 + } 178 + 179 + db.close(); 180 + }
+2
packages/core/src/db/schema/index.ts
··· 3 3 export { oauthStates, oauthSessions } from "./auth.ts"; 4 4 export { indexerCursor } from "./cursor.ts"; 5 5 export { sphereEntryCounter } from "./entry-counter.ts"; 6 + export { sphereLabels, entityLabels } from "./labels.ts"; 7 + export type { SphereLabel, EntityLabel, EntityType } from "./labels.ts";
+51
packages/core/src/db/schema/labels.ts
··· 1 + import { 2 + sqliteTable, 3 + text, 4 + integer, 5 + primaryKey, 6 + index, 7 + uniqueIndex, 8 + } from "drizzle-orm/sqlite-core"; 9 + import { sql } from "drizzle-orm"; 10 + import type { InferSelectModel } from "drizzle-orm"; 11 + import { spheres } from "./spheres.ts"; 12 + 13 + export const sphereLabels = sqliteTable( 14 + "sphere_labels", 15 + { 16 + id: text("id").primaryKey(), 17 + sphereId: text("sphere_id") 18 + .notNull() 19 + .references(() => spheres.id), 20 + name: text("name").notNull(), 21 + description: text("description"), 22 + color: text("color").notNull(), 23 + position: integer("position").notNull().default(0), 24 + createdAt: text("created_at") 25 + .notNull() 26 + .default(sql`(datetime('now'))`), 27 + }, 28 + (table) => [ 29 + uniqueIndex("idx_sphere_labels_sphere_name").on(table.sphereId, table.name), 30 + index("idx_sphere_labels_sphere_position").on(table.sphereId, table.position), 31 + ], 32 + ); 33 + 34 + export const entityLabels = sqliteTable( 35 + "entity_labels", 36 + { 37 + labelId: text("label_id") 38 + .notNull() 39 + .references(() => sphereLabels.id, { onDelete: "cascade" }), 40 + entityId: text("entity_id").notNull(), 41 + entityType: text("entity_type", { enum: ["feature-request", "kanban-task"] }).notNull(), 42 + }, 43 + (table) => [ 44 + primaryKey({ columns: [table.labelId, table.entityId] }), 45 + index("idx_entity_labels_entity").on(table.entityId, table.entityType), 46 + ], 47 + ); 48 + 49 + export type SphereLabel = InferSelectModel<typeof sphereLabels>; 50 + export type EntityLabel = InferSelectModel<typeof entityLabels>; 51 + export type EntityType = EntityLabel["entityType"];
+1
packages/core/src/permissions/core.ts
··· 14 14 enableModule: { label: "Enable modules", defaultRole: "owner" }, 15 15 disableModule: { label: "Disable modules", defaultRole: "owner" }, 16 16 updatePermissions: { label: "Update permissions", defaultRole: "owner" }, 17 + manageLabels: { label: "Manage labels", defaultRole: "admin" }, 17 18 } satisfies Record<string, ModulePermission>;
+130
packages/core/src/sphere/api/labels.ts
··· 1 + import { Hono } from "hono"; 2 + import { z } from "zod"; 3 + import { requireAuth, optionalAuth, type AuthEnv } from "../../auth/index.ts"; 4 + import { checkPermission } from "../../permissions/check.ts"; 5 + import { getActiveMemberRole } from "../operations.ts"; 6 + import { findSphere } from "./helpers.ts"; 7 + import { createLabelSchema, updateLabelSchema, reorderLabelsSchema } from "../schemas.ts"; 8 + import { 9 + getLabelsForSphere, 10 + getLabelById, 11 + insertLabel, 12 + updateLabel, 13 + deleteLabel, 14 + reorderLabels, 15 + } from "../label-operations.ts"; 16 + 17 + const app = new Hono<AuthEnv>(); 18 + 19 + // List labels for a sphere (public) 20 + app.get("/:handle/labels", optionalAuth, (c) => { 21 + const sphere = findSphere(c.req.param("handle")); 22 + if (!sphere) { 23 + return c.json({ error: "Sphere not found" }, 404); 24 + } 25 + 26 + const labels = getLabelsForSphere(sphere.id); 27 + return c.json({ labels }); 28 + }); 29 + 30 + // Create a label 31 + app.post("/:handle/labels", requireAuth, async (c) => { 32 + const sphere = findSphere(c.req.param("handle")); 33 + if (!sphere) { 34 + return c.json({ error: "Sphere not found" }, 404); 35 + } 36 + 37 + const role = getActiveMemberRole(sphere.id, c.var.did); 38 + if (!checkPermission(sphere.id, "sphere", "manageLabels", role)) { 39 + return c.json({ error: "Forbidden" }, 403); 40 + } 41 + 42 + const body = await c.req.json(); 43 + const parsed = createLabelSchema.safeParse(body); 44 + if (!parsed.success) { 45 + return c.json({ error: z.flattenError(parsed.error) }, 400); 46 + } 47 + 48 + const label = insertLabel({ 49 + sphereId: sphere.id, 50 + name: parsed.data.name, 51 + description: parsed.data.description, 52 + color: parsed.data.color, 53 + }); 54 + 55 + return c.json({ label }, 201); 56 + }); 57 + 58 + // Update a label 59 + app.put("/:handle/labels/:id", requireAuth, async (c) => { 60 + const sphere = findSphere(c.req.param("handle")); 61 + if (!sphere) { 62 + return c.json({ error: "Sphere not found" }, 404); 63 + } 64 + 65 + const role = getActiveMemberRole(sphere.id, c.var.did); 66 + if (!checkPermission(sphere.id, "sphere", "manageLabels", role)) { 67 + return c.json({ error: "Forbidden" }, 403); 68 + } 69 + 70 + const existing = getLabelById(c.req.param("id")); 71 + if (!existing || existing.sphereId !== sphere.id) { 72 + return c.json({ error: "Label not found" }, 404); 73 + } 74 + 75 + const body = await c.req.json(); 76 + const parsed = updateLabelSchema.safeParse(body); 77 + if (!parsed.success) { 78 + return c.json({ error: z.flattenError(parsed.error) }, 400); 79 + } 80 + 81 + updateLabel(existing.id, parsed.data); 82 + const updated = getLabelById(existing.id)!; 83 + return c.json({ label: updated }); 84 + }); 85 + 86 + // Delete a label 87 + app.delete("/:handle/labels/:id", requireAuth, (c) => { 88 + const sphere = findSphere(c.req.param("handle")); 89 + if (!sphere) { 90 + return c.json({ error: "Sphere not found" }, 404); 91 + } 92 + 93 + const role = getActiveMemberRole(sphere.id, c.var.did); 94 + if (!checkPermission(sphere.id, "sphere", "manageLabels", role)) { 95 + return c.json({ error: "Forbidden" }, 403); 96 + } 97 + 98 + const existing = getLabelById(c.req.param("id")); 99 + if (!existing || existing.sphereId !== sphere.id) { 100 + return c.json({ error: "Label not found" }, 404); 101 + } 102 + 103 + deleteLabel(existing.id); 104 + return c.json({ ok: true }); 105 + }); 106 + 107 + // Reorder labels 108 + app.post("/:handle/labels/reorder", requireAuth, async (c) => { 109 + const sphere = findSphere(c.req.param("handle")); 110 + if (!sphere) { 111 + return c.json({ error: "Sphere not found" }, 404); 112 + } 113 + 114 + const role = getActiveMemberRole(sphere.id, c.var.did); 115 + if (!checkPermission(sphere.id, "sphere", "manageLabels", role)) { 116 + return c.json({ error: "Forbidden" }, 403); 117 + } 118 + 119 + const body = await c.req.json(); 120 + const parsed = reorderLabelsSchema.safeParse(body); 121 + if (!parsed.success) { 122 + return c.json({ error: z.flattenError(parsed.error) }, 400); 123 + } 124 + 125 + reorderLabels(sphere.id, parsed.data.labelIds); 126 + const labels = getLabelsForSphere(sphere.id); 127 + return c.json({ labels }); 128 + }); 129 + 130 + export { app as labelsApi };
+3
packages/core/src/sphere/index.ts
··· 8 8 export { getActiveMemberRole, registerModerationHandler, findSphereByAtUri } from "./operations.ts"; 9 9 export type { ModerationHandler } from "./operations.ts"; 10 10 export { sphereContext } from "./middleware.ts"; 11 + export { getLabelsForSphere, getLabelsForEntities, setEntityLabels } from "./label-operations.ts"; 12 + export type { LabelInfo } from "./label-operations.ts"; 13 + export { setEntityLabelsSchema } from "./schemas.ts";
+157
packages/core/src/sphere/label-operations.ts
··· 1 + import { eq, and, max, inArray, asc, count } from "../db/drizzle.ts"; 2 + import { getDb } from "../db/index.ts"; 3 + import { sphereLabels, entityLabels } from "../db/schema/index.ts"; 4 + import type { SphereLabel, EntityType } from "../db/schema/index.ts"; 5 + import { generateRkey } from "../pds.ts"; 6 + 7 + export function getLabelsForSphere(sphereId: string): SphereLabel[] { 8 + return getDb() 9 + .select() 10 + .from(sphereLabels) 11 + .where(eq(sphereLabels.sphereId, sphereId)) 12 + .orderBy(asc(sphereLabels.position)) 13 + .all(); 14 + } 15 + 16 + export function getLabelById(id: string): SphereLabel | undefined { 17 + return getDb().select().from(sphereLabels).where(eq(sphereLabels.id, id)).get(); 18 + } 19 + 20 + export function insertLabel(params: { 21 + sphereId: string; 22 + name: string; 23 + description?: string; 24 + color: string; 25 + }): SphereLabel { 26 + const db = getDb(); 27 + const id = generateRkey(); 28 + 29 + // Position race is safe under SQLite's single-writer WAL mode 30 + const maxPos = db 31 + .select({ max: max(sphereLabels.position) }) 32 + .from(sphereLabels) 33 + .where(eq(sphereLabels.sphereId, params.sphereId)) 34 + .get(); 35 + const position = (maxPos?.max ?? -1) + 1; 36 + 37 + db.insert(sphereLabels) 38 + .values({ 39 + id, 40 + sphereId: params.sphereId, 41 + name: params.name, 42 + description: params.description ?? null, 43 + color: params.color, 44 + position, 45 + }) 46 + .run(); 47 + 48 + const inserted = db.select().from(sphereLabels).where(eq(sphereLabels.id, id)).get(); 49 + if (!inserted) throw new Error("Failed to insert label"); 50 + return inserted; 51 + } 52 + 53 + export function updateLabel( 54 + id: string, 55 + updates: { name?: string; description?: string | null; color?: string }, 56 + ): void { 57 + const set: Record<string, unknown> = {}; 58 + if (updates.name !== undefined) set.name = updates.name; 59 + if (updates.description !== undefined) set.description = updates.description; 60 + if (updates.color !== undefined) set.color = updates.color; 61 + 62 + if (Object.keys(set).length === 0) return; 63 + 64 + getDb().update(sphereLabels).set(set).where(eq(sphereLabels.id, id)).run(); 65 + } 66 + 67 + export function deleteLabel(id: string): void { 68 + // entity_labels rows cascade-deleted via FK 69 + getDb().delete(sphereLabels).where(eq(sphereLabels.id, id)).run(); 70 + } 71 + 72 + export function reorderLabels(sphereId: string, labelIds: string[]): void { 73 + const db = getDb(); 74 + db.transaction((tx) => { 75 + // Verify the provided IDs cover all labels in this sphere 76 + const total = tx 77 + .select({ count: count() }) 78 + .from(sphereLabels) 79 + .where(eq(sphereLabels.sphereId, sphereId)) 80 + .get(); 81 + if (total && total.count !== labelIds.length) { 82 + throw new Error("labelIds must include all labels for this sphere"); 83 + } 84 + 85 + for (let i = 0; i < labelIds.length; i++) { 86 + tx.update(sphereLabels) 87 + .set({ position: i }) 88 + .where(and(eq(sphereLabels.id, labelIds[i]), eq(sphereLabels.sphereId, sphereId))) 89 + .run(); 90 + } 91 + }); 92 + } 93 + 94 + export type LabelInfo = { id: string; name: string; color: string }; 95 + 96 + /** Batch-fetch labels for multiple entities. Returns a Map keyed by entity ID. */ 97 + export function getLabelsForEntities( 98 + entityIds: string[], 99 + entityType: EntityType, 100 + ): Map<string, LabelInfo[]> { 101 + if (entityIds.length === 0) return new Map(); 102 + 103 + const db = getDb(); 104 + const rows = db 105 + .select({ 106 + entityId: entityLabels.entityId, 107 + labelId: sphereLabels.id, 108 + name: sphereLabels.name, 109 + color: sphereLabels.color, 110 + }) 111 + .from(entityLabels) 112 + .innerJoin(sphereLabels, eq(sphereLabels.id, entityLabels.labelId)) 113 + .where(and(inArray(entityLabels.entityId, entityIds), eq(entityLabels.entityType, entityType))) 114 + .orderBy(asc(sphereLabels.position)) 115 + .all(); 116 + 117 + const result = new Map<string, LabelInfo[]>(); 118 + for (const row of rows) { 119 + const list = result.get(row.entityId) ?? []; 120 + list.push({ id: row.labelId, name: row.name, color: row.color }); 121 + result.set(row.entityId, list); 122 + } 123 + return result; 124 + } 125 + 126 + /** Replace all labels for an entity with the given label IDs. 127 + * Validates that every label belongs to the specified sphere. */ 128 + export function setEntityLabels( 129 + sphereId: string, 130 + entityId: string, 131 + entityType: EntityType, 132 + labelIds: string[], 133 + ): void { 134 + const uniqueIds = [...new Set(labelIds)]; 135 + const db = getDb(); 136 + db.transaction((tx) => { 137 + tx.delete(entityLabels) 138 + .where(and(eq(entityLabels.entityId, entityId), eq(entityLabels.entityType, entityType))) 139 + .run(); 140 + 141 + if (uniqueIds.length === 0) return; 142 + 143 + // Verify all labels belong to this sphere 144 + const valid = tx 145 + .select({ id: sphereLabels.id }) 146 + .from(sphereLabels) 147 + .where(and(eq(sphereLabels.sphereId, sphereId), inArray(sphereLabels.id, uniqueIds))) 148 + .all(); 149 + if (valid.length !== uniqueIds.length) { 150 + throw new Error("One or more labels do not belong to this sphere"); 151 + } 152 + 153 + for (const labelId of uniqueIds) { 154 + tx.insert(entityLabels).values({ labelId, entityId, entityType }).onConflictDoNothing().run(); 155 + } 156 + }); 157 + }
+2
packages/core/src/sphere/routes.ts
··· 4 4 import { createModuleRoutes } from "./api/modules.ts"; 5 5 import { membersApi } from "./api/members.ts"; 6 6 import { permissionsApi } from "./api/permissions.ts"; 7 + import { labelsApi } from "./api/labels.ts"; 7 8 8 9 export { getCurrentSphere, getMemberSpheres, getPendingInvitations } from "./api/spheres.ts"; 9 10 ··· 14 15 app.route("/", createModuleRoutes(availableModules)); 15 16 app.route("/", membersApi); 16 17 app.route("/", permissionsApi); 18 + app.route("/", labelsApi); 17 19 18 20 return app; 19 21 }
+23
packages/core/src/sphere/schemas.ts
··· 20 20 identifier: z.string().min(1), 21 21 role: z.enum(["admin", "member"]).default("member"), 22 22 }); 23 + 24 + export const createLabelSchema = z.object({ 25 + name: z.string().min(1).max(50), 26 + description: z.string().max(200).optional(), 27 + color: z.string().regex(/^#[0-9a-fA-F]{6}$/), 28 + }); 29 + 30 + export const updateLabelSchema = z.object({ 31 + name: z.string().min(1).max(50).optional(), 32 + description: z.string().max(200).nullable().optional(), 33 + color: z 34 + .string() 35 + .regex(/^#[0-9a-fA-F]{6}$/) 36 + .optional(), 37 + }); 38 + 39 + export const reorderLabelsSchema = z.object({ 40 + labelIds: z.array(z.string().min(1)), 41 + }); 42 + 43 + export const setEntityLabelsSchema = z.object({ 44 + labelIds: z.array(z.string().min(1)), 45 + });
+65 -5
packages/feature-requests/src/api/requests.ts
··· 3 3 import { getDb } from "@exosphere/core/db"; 4 4 import { eq, and, count, sql, inArray, like, desc, asc } from "@exosphere/core/db/drizzle"; 5 5 import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; 6 - import { requirePermission } from "@exosphere/core/permissions"; 6 + import { requirePermission, checkPermission } from "@exosphere/core/permissions"; 7 + import { getActiveMemberRole } from "@exosphere/core/sphere"; 7 8 import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; 8 9 import { resolveDidHandles } from "@exosphere/core/identity"; 9 10 import type { SphereEnv } from "@exosphere/core/types"; ··· 16 17 hideFeatureRequest, 17 18 unhideFeatureRequest, 18 19 } from "../db/operations.ts"; 20 + import { 21 + getLabelsForEntities, 22 + setEntityLabels, 23 + setEntityLabelsSchema, 24 + } from "@exosphere/core/sphere"; 19 25 20 26 const COLLECTION = "site.exosphere.featureRequest.entry"; 21 27 const MODERATION_COLLECTION = "site.exosphere.moderation"; ··· 86 92 const handleMap = await resolveDidHandles([row.authorDid]); 87 93 const authorHandle = handleMap.get(row.authorDid) ?? null; 88 94 95 + const labelMap = getLabelsForEntities([row.id], "feature-request"); 96 + const labels = labelMap.get(row.id) ?? []; 97 + 89 98 return c.json({ 90 - featureRequest: { ...row, createdAt: tidToDate(row.id), authorHandle }, 99 + featureRequest: { ...row, createdAt: tidToDate(row.id), authorHandle, labels }, 91 100 duplicateOf, 92 101 duplicateCount, 93 102 }); ··· 140 149 .groupBy(featureRequests.id) 141 150 .orderBy(sortBy === "votes" ? orderDir(voteCountCol) : orderDir(featureRequests.id)) 142 151 .all(); 143 - return c.json({ featureRequests: rows.map((r) => ({ ...r, createdAt: tidToDate(r.id) })) }); 152 + const ids = rows.map((r) => r.id); 153 + const labelMap = getLabelsForEntities(ids, "feature-request"); 154 + return c.json({ 155 + featureRequests: rows.map((r) => ({ 156 + ...r, 157 + createdAt: tidToDate(r.id), 158 + labels: labelMap.get(r.id) ?? [], 159 + })), 160 + }); 144 161 }); 145 162 146 163 app.post("/", requireAuth, requirePermission("feature-requests", "create"), async (c) => { ··· 150 167 return c.json({ error: z.flattenError(result.error) }, 400); 151 168 } 152 169 153 - const { title, description, category } = result.data; 170 + const { title, description, category, labelIds } = result.data; 154 171 const sphereId = c.var.sphereId; 155 172 const sphereOwnerDid = c.var.sphereOwnerDid; 156 173 const sphereVisibility = c.var.sphereVisibility; ··· 180 197 pdsUri, 181 198 }); 182 199 183 - return c.json({ featureRequest: row ? { ...row, createdAt: tidToDate(id) } : row }, 201); 200 + if (labelIds && labelIds.length > 0) { 201 + setEntityLabels(sphereId, id, "feature-request", labelIds); 202 + } 203 + 204 + const labels = labelIds?.length 205 + ? (getLabelsForEntities([id], "feature-request").get(id) ?? []) 206 + : []; 207 + 208 + return c.json({ featureRequest: row ? { ...row, createdAt: tidToDate(id), labels } : row }, 201); 184 209 }); 185 210 186 211 // Author-only: delete own feature request (local DB + PDS) ··· 367 392 .all(); 368 393 369 394 return c.json({ results: rows }); 395 + }); 396 + 397 + // Update labels on a feature request. 398 + // Reuses changeStatus permission — admins who can triage status can also manage labels. 399 + app.post("/:id/labels", requireAuth, async (c) => { 400 + const id = c.req.param("id"); 401 + const db = getDb(); 402 + const did = c.var.did; 403 + const sphereId = c.var.sphereId; 404 + 405 + const existing = db 406 + .select({ id: featureRequests.id, authorDid: featureRequests.authorDid }) 407 + .from(featureRequests) 408 + .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) 409 + .get(); 410 + if (!existing) { 411 + return c.json({ error: "Feature request not found" }, 404); 412 + } 413 + 414 + if (existing.authorDid !== did) { 415 + const role = getActiveMemberRole(sphereId, did); 416 + if (!checkPermission(sphereId, "feature-requests", "changeStatus", role)) { 417 + return c.json({ error: "Forbidden" }, 403); 418 + } 419 + } 420 + 421 + const body = await c.req.json(); 422 + const parsed = setEntityLabelsSchema.safeParse(body); 423 + if (!parsed.success) { 424 + return c.json({ error: z.flattenError(parsed.error) }, 400); 425 + } 426 + 427 + setEntityLabels(sphereId, id, "feature-request", parsed.data.labelIds); 428 + const labels = getLabelsForEntities([id], "feature-request").get(id) ?? []; 429 + return c.json({ labels }); 370 430 }); 371 431 372 432 export { app as requestsApi };
+2
packages/feature-requests/src/db/operations.ts
··· 1 1 import { getDb } from "@exosphere/core/db"; 2 2 import { eq, and, inArray } from "@exosphere/core/db/drizzle"; 3 + import { entityLabels } from "@exosphere/core/db/schema"; 3 4 import { nextEntryNumber } from "@exosphere/core/db/entry-number"; 4 5 import { tidToDate } from "@exosphere/core/pds"; 5 6 import type { ModerationHandler } from "@exosphere/core/sphere"; ··· 68 69 tx.delete(featureRequestComments).where(eq(featureRequestComments.requestId, id)).run(); 69 70 tx.delete(featureRequestStatuses).where(eq(featureRequestStatuses.requestId, id)).run(); 70 71 tx.delete(featureRequestVotes).where(eq(featureRequestVotes.requestId, id)).run(); 72 + tx.delete(entityLabels).where(eq(entityLabels.entityId, id)).run(); 71 73 tx.delete(featureRequests).where(eq(featureRequests.id, id)).run(); 72 74 }); 73 75 }
+1
packages/feature-requests/src/schemas/feature-request.ts
··· 55 55 title: z.string().min(1).max(200), 56 56 description: z.string().min(1).max(10000), 57 57 category: z.enum(categories).default("general"), 58 + labelIds: z.array(z.string()).default([]), 58 59 }); 59 60 60 61 export type CreateFeatureRequest = z.infer<typeof createFeatureRequestSchema>;
+2
packages/feature-requests/src/types.ts
··· 9 9 } from "./schemas/feature-request.ts"; 10 10 11 11 import type { FeatureRequest, FeatureRequestComment } from "./db/schema.ts"; 12 + import type { LabelInfo } from "@exosphere/core/sphere"; 12 13 13 14 /** Shape returned by the GET /feature-requests list/detail endpoints (includes vote count). */ 14 15 export type FeatureRequestListItem = FeatureRequest & { ··· 16 17 voteCount: number; 17 18 commentCount: number; 18 19 authorHandle?: string | null; 20 + labels: LabelInfo[]; 19 21 }; 20 22 21 23 /** Shape returned by the GET comments endpoint (includes vote count and resolved handle). */
+14 -1
packages/feature-requests/src/ui/api/feature-requests.ts
··· 27 27 export function createFeatureRequest(body: { 28 28 title: string; 29 29 description: string; 30 - category: string; 30 + /** @deprecated Kept for backward compat with PDS indexer; defaults to "general" server-side. */ 31 + category?: string; 32 + labelIds?: string[]; 31 33 }) { 32 34 return moduleFetch<{ featureRequest: FeatureRequest }>("/feature-requests", { 33 35 method: "POST", 34 36 headers: { "Content-Type": "application/json" }, 35 37 body: JSON.stringify(body), 36 38 }); 39 + } 40 + 41 + export function updateFeatureRequestLabels(id: string, labelIds: string[]) { 42 + return moduleFetch<{ labels: Array<{ id: string; name: string; color: string }> }>( 43 + `/feature-requests/${encodeURIComponent(id)}/labels`, 44 + { 45 + method: "POST", 46 + headers: { "Content-Type": "application/json" }, 47 + body: JSON.stringify({ labelIds }), 48 + }, 49 + ); 37 50 } 38 51 39 52 export function deleteFeatureRequest(id: string) {
+4 -6
packages/feature-requests/src/ui/components/request-card.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import * as ui from "@exosphere/client/ui.css"; 3 3 import { spherePath } from "@exosphere/client/router"; 4 + import { LabelBadge } from "@exosphere/client/components/label-badge"; 4 5 import * as frUi from "../ui.css.ts"; 5 6 import { statusLabels, type FeatureRequestListItem, type Status } from "../api/index.ts"; 6 - import { categoryLabels, type Category } from "../../schemas/feature-request.ts"; 7 7 import { formatDate } from "@exosphere/client/format"; 8 - 9 - export function categoryLabel(value: string): string { 10 - return categoryLabels[value as Category] ?? value; 11 - } 12 8 13 9 export function RequestCard({ 14 10 fr, ··· 83 79 </h3> 84 80 )} 85 81 <div class={ui.cluster}> 86 - <span class={ui.badge}>{categoryLabel(fr.category)}</span> 82 + {fr.labels?.map((label) => ( 83 + <LabelBadge key={label.id} label={label} /> 84 + ))} 87 85 {fr.status !== "requested" && ( 88 86 <span class={ui.badge} data-status={fr.status}> 89 87 {statusLabels[fr.status as Status] ?? fr.status}
+24 -26
packages/feature-requests/src/ui/pages/feature-requests.tsx
··· 15 15 unvoteFeatureRequest, 16 16 getMyVotes, 17 17 } from "../api/index.ts"; 18 - import { categories, categoryLabels } from "../../schemas/feature-request.ts"; 19 18 import { Modal } from "@exosphere/client/components/modal"; 19 + import { LabelPicker, type LabelOption } from "@exosphere/client/components/label-picker"; 20 + import { getLabels } from "@exosphere/client/api/labels"; 20 21 import { RequestCard } from "../components/request-card.tsx"; 21 22 import { SortControls } from "../components/sort-controls.tsx"; 22 23 import { useSortParams } from "../hooks/use-sort-params.ts"; 23 24 import { useEffect } from "preact/hooks"; 24 25 25 - const categoryOptions = categories.map((value) => ({ 26 - value, 27 - label: categoryLabels[value], 28 - })); 29 - 30 26 function SubmitForm({ onCreated }: { onCreated: () => void }) { 31 27 const title = useSignal(""); 32 28 const description = useSignal(""); 33 - const category = useSignal("general"); 29 + const selectedLabelIds = useSignal<string[]>([]); 30 + const availableLabels = useSignal<LabelOption[]>([]); 34 31 const error = useSignal(""); 35 32 const submitting = useSignal(false); 36 33 34 + useEffect(() => { 35 + getLabels() 36 + .then((res) => { 37 + availableLabels.value = res.labels; 38 + }) 39 + .catch(() => {}); 40 + }, []); 41 + 37 42 const handleKeyDown = (e: KeyboardEvent) => { 38 43 if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { 39 44 e.preventDefault(); ··· 59 64 await createFeatureRequest({ 60 65 title: title.value.trim(), 61 66 description: description.value.trim(), 62 - category: category.value, 67 + labelIds: selectedLabelIds.value, 63 68 }); 64 69 title.value = ""; 65 70 description.value = ""; 66 - category.value = "general"; 71 + selectedLabelIds.value = []; 67 72 onCreated(); 68 73 } catch (err) { 69 74 error.value = err instanceof Error ? err.message : "Something went wrong."; ··· 89 94 /> 90 95 </div> 91 96 92 - <div> 93 - <label class={ui.label} htmlFor="fr-category"> 94 - Category 95 - </label> 96 - <select 97 - id="fr-category" 98 - class={ui.select} 99 - value={category.value} 100 - onChange={(e) => (category.value = (e.target as HTMLSelectElement).value)} 101 - > 102 - {categoryOptions.map((c) => ( 103 - <option key={c.value} value={c.value}> 104 - {c.label} 105 - </option> 106 - ))} 107 - </select> 108 - </div> 97 + {availableLabels.value.length > 0 && ( 98 + <div> 99 + <label class={ui.label}>Labels</label> 100 + <LabelPicker 101 + labels={availableLabels.value} 102 + selectedIds={selectedLabelIds.value} 103 + onChange={(ids) => (selectedLabelIds.value = ids)} 104 + /> 105 + </div> 106 + )} 109 107 110 108 <div> 111 109 <label class={ui.label} htmlFor="fr-description">
+55 -2
packages/kanban/src/api/tasks.ts
··· 28 28 hideTask, 29 29 unhideTask, 30 30 } from "../db/operations.ts"; 31 + import { 32 + getLabelsForEntities, 33 + setEntityLabels, 34 + setEntityLabelsSchema, 35 + } from "@exosphere/core/sphere"; 31 36 32 37 const COLLECTION = "site.exosphere.kanban.entry"; 33 38 const STATUS_COLLECTION = "site.exosphere.kanban.status"; ··· 81 86 if (row.assigneeDid) dids.push(row.assigneeDid); 82 87 const handleMap = await resolveDidHandles(dids); 83 88 89 + const labelMap = getLabelsForEntities([row.id], "kanban-task"); 90 + 84 91 return c.json({ 85 92 task: { 86 93 ...row, 87 94 createdAt: tidToDate(row.id), 88 95 authorHandle: handleMap.get(row.authorDid) ?? null, 89 96 assigneeHandle: row.assigneeDid ? (handleMap.get(row.assigneeDid) ?? null) : null, 97 + labels: labelMap.get(row.id) ?? [], 90 98 }, 91 99 }); 92 100 }); ··· 127 135 if (r.assigneeDid) allDids.add(r.assigneeDid); 128 136 } 129 137 const handleMap = await resolveDidHandles([...allDids]); 138 + 139 + const ids = rows.map((r) => r.id); 140 + const labelMap = getLabelsForEntities(ids, "kanban-task"); 130 141 131 142 const tasks = rows.map((r) => ({ 132 143 ...r, 133 144 createdAt: tidToDate(r.id), 134 145 authorHandle: handleMap.get(r.authorDid) ?? null, 135 146 assigneeHandle: r.assigneeDid ? (handleMap.get(r.assigneeDid) ?? null) : null, 147 + labels: labelMap.get(r.id) ?? [], 136 148 })); 137 149 138 150 // Group by column ··· 160 172 return c.json({ error: z.flattenError(result.error) }, 400); 161 173 } 162 174 163 - const { title, description, assigneeDid } = result.data; 175 + const { title, description, assigneeDid, labelIds } = result.data; 164 176 let { status } = result.data; 165 177 const sphereId = c.var.sphereId; 166 178 const sphereOwnerDid = c.var.sphereOwnerDid; ··· 198 210 pdsUri, 199 211 }); 200 212 201 - return c.json({ task: row ? { ...row, createdAt: tidToDate(id) } : row }, 201); 213 + if (labelIds && labelIds.length > 0) { 214 + setEntityLabels(sphereId, id, "kanban-task", labelIds); 215 + } 216 + 217 + const labels = labelIds?.length ? (getLabelsForEntities([id], "kanban-task").get(id) ?? []) : []; 218 + 219 + return c.json({ task: row ? { ...row, createdAt: tidToDate(id), labels } : row }, 201); 202 220 }); 203 221 204 222 // Update task (author or manage permission) ··· 544 562 .all(); 545 563 546 564 return c.json({ results: rows }); 565 + }); 566 + 567 + // Update labels on a task. 568 + // Reuses manageTasks permission — admins who can manage tasks can also manage their labels. 569 + app.post("/:id/labels", requireAuth, async (c) => { 570 + const id = c.req.param("id"); 571 + const db = getDb(); 572 + const did = c.var.did; 573 + const sphereId = c.var.sphereId; 574 + 575 + const existing = db 576 + .select({ id: kanbanTasks.id, authorDid: kanbanTasks.authorDid }) 577 + .from(kanbanTasks) 578 + .where(and(eq(kanbanTasks.id, id), eq(kanbanTasks.sphereId, sphereId))) 579 + .get(); 580 + if (!existing) { 581 + return c.json({ error: "Task not found" }, 404); 582 + } 583 + 584 + if (existing.authorDid !== did) { 585 + const role = getActiveMemberRole(sphereId, did); 586 + if (!checkPermission(sphereId, MODULE, "manageTasks", role)) { 587 + return c.json({ error: "Forbidden" }, 403); 588 + } 589 + } 590 + 591 + const body = await c.req.json(); 592 + const parsed = setEntityLabelsSchema.safeParse(body); 593 + if (!parsed.success) { 594 + return c.json({ error: z.flattenError(parsed.error) }, 400); 595 + } 596 + 597 + setEntityLabels(sphereId, id, "kanban-task", parsed.data.labelIds); 598 + const labels = getLabelsForEntities([id], "kanban-task").get(id) ?? []; 599 + return c.json({ labels }); 547 600 }); 548 601 549 602 export { app as tasksApi };
+2
packages/kanban/src/db/operations.ts
··· 1 1 import { getDb } from "@exosphere/core/db"; 2 2 import { eq, and, max, asc } from "@exosphere/core/db/drizzle"; 3 + import { entityLabels } from "@exosphere/core/db/schema"; 3 4 import { nextEntryNumber } from "@exosphere/core/db/entry-number"; 4 5 import { tidToDate, generateRkey } from "@exosphere/core/pds"; 5 6 import type { ModerationHandler } from "@exosphere/core/sphere"; ··· 216 217 db.transaction((tx) => { 217 218 tx.delete(kanbanTaskComments).where(eq(kanbanTaskComments.taskId, id)).run(); 218 219 tx.delete(kanbanTaskStatusChanges).where(eq(kanbanTaskStatusChanges.taskId, id)).run(); 220 + tx.delete(entityLabels).where(eq(entityLabels.entityId, id)).run(); 219 221 tx.delete(kanbanTasks).where(eq(kanbanTasks.id, id)).run(); 220 222 }); 221 223 }
+1
packages/kanban/src/schemas/task.ts
··· 13 13 description: z.string().max(10000).default(""), 14 14 status: z.string().min(1).max(100).default("backlog"), 15 15 assigneeDid: z.string().min(1).nullable().optional(), 16 + labelIds: z.array(z.string()).default([]), 16 17 }); 17 18 18 19 export const updateTaskSchema = z.object({
+2
packages/kanban/src/types.ts
··· 6 6 } from "./db/schema.ts"; 7 7 8 8 import type { KanbanTask, KanbanTaskComment } from "./db/schema.ts"; 9 + import type { LabelInfo } from "@exosphere/core/sphere"; 9 10 10 11 /** Column definition as returned by the API. */ 11 12 export type KanbanColumnDef = { ··· 21 22 commentCount: number; 22 23 authorHandle?: string | null; 23 24 assigneeHandle?: string | null; 25 + labels: LabelInfo[]; 24 26 }; 25 27 26 28 /** Shape returned by the GET /kanban/:number detail endpoint. */
+1
packages/kanban/src/ui/api/tasks.ts
··· 20 20 description?: string; 21 21 status?: string; 22 22 assigneeDid?: string | null; 23 + labelIds?: string[]; 23 24 }) { 24 25 return moduleFetch<{ task: KanbanTaskListItem }>("/kanban", { 25 26 method: "POST",
+8
packages/kanban/src/ui/components/task-card.tsx
··· 1 1 import { spherePath } from "@exosphere/client/router"; 2 + import { LabelBadge } from "@exosphere/client/components/label-badge"; 2 3 import * as kbUi from "../ui.css.ts"; 3 4 import type { KanbanTaskListItem } from "../../types.ts"; 4 5 ··· 30 31 > 31 32 <span class={kbUi.taskNumber}>#{task.number}</span> 32 33 <div class={kbUi.taskTitle}>{task.title}</div> 34 + {task.labels?.length > 0 && ( 35 + <div class={kbUi.taskLabels}> 36 + {task.labels.map((label) => ( 37 + <LabelBadge key={label.id} label={label} /> 38 + ))} 39 + </div> 40 + )} 33 41 <div class={kbUi.taskMeta}> 34 42 {task.assigneeHandle ? ( 35 43 <span>@{task.assigneeHandle}</span>
+26
packages/kanban/src/ui/components/task-form.tsx
··· 1 1 import { useSignal } from "@preact/signals"; 2 2 import * as ui from "@exosphere/client/ui.css"; 3 + import { LabelPicker, type LabelOption } from "@exosphere/client/components/label-picker"; 4 + import { getLabels } from "@exosphere/client/api/labels"; 5 + import { useEffect } from "preact/hooks"; 3 6 import type { KanbanColumnDef } from "../../types.ts"; 4 7 5 8 export function TaskForm({ ··· 14 17 const title = useSignal(""); 15 18 const description = useSignal(""); 16 19 const status = useSignal(initialStatus ?? columns[0]?.slug ?? "backlog"); 20 + const selectedLabelIds = useSignal<string[]>([]); 21 + const availableLabels = useSignal<LabelOption[]>([]); 17 22 const error = useSignal(""); 18 23 const submitting = useSignal(false); 24 + 25 + useEffect(() => { 26 + getLabels() 27 + .then((res) => { 28 + availableLabels.value = res.labels; 29 + }) 30 + .catch(() => {}); 31 + }, []); 19 32 20 33 const handleKeyDown = (e: KeyboardEvent) => { 21 34 if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { ··· 40 53 title: title.value.trim(), 41 54 description: description.value.trim(), 42 55 status: status.value, 56 + labelIds: selectedLabelIds.value, 43 57 }); 44 58 title.value = ""; 45 59 description.value = ""; 46 60 status.value = columns[0]?.slug ?? "backlog"; 61 + selectedLabelIds.value = []; 47 62 onCreated(); 48 63 } catch (err) { 49 64 error.value = err instanceof Error ? err.message : "Something went wrong."; ··· 86 101 ))} 87 102 </select> 88 103 </div> 104 + 105 + {availableLabels.value.length > 0 && ( 106 + <div> 107 + <label class={ui.label}>Labels</label> 108 + <LabelPicker 109 + labels={availableLabels.value} 110 + selectedIds={selectedLabelIds.value} 111 + onChange={(ids) => (selectedLabelIds.value = ids)} 112 + /> 113 + </div> 114 + )} 89 115 90 116 <div> 91 117 <label class={ui.label} htmlFor="kb-description">
+7
packages/kanban/src/ui/ui.css.ts
··· 130 130 color: vars.color.primary, 131 131 }); 132 132 133 + export const taskLabels = style({ 134 + display: "flex", 135 + flexWrap: "wrap", 136 + gap: "4px", 137 + marginBlockStart: vars.space.xs, 138 + }); 139 + 133 140 export const taskMeta = style({ 134 141 display: "flex", 135 142 alignItems: "center",