diff --git a/bun.lock b/bun.lock index 89dfd9a..49a6051 100644 --- a/bun.lock +++ b/bun.lock @@ -7,13 +7,13 @@ "devDependencies": { "@playwright/test": "^1.59.1", "@types/better-sqlite3": "^7.6.13", - "@types/bun": "^1.3.11", - "better-sqlite3": "^12.8.0", - "bun-types": "^1.3.11", + "@types/bun": "^1.3.12", + "better-sqlite3": "^12.9.0", + "bun-types": "^1.3.12", "drizzle-kit": "^0.31.10", "oxfmt": "^0.43.0", "typescript": "^6.0.2", - "vitest": "^4.1.3", + "vitest": "^4.1.4", }, }, "packages/app": { diff --git a/drizzle/0004_melted_brother_voodoo.sql b/drizzle/0004_melted_brother_voodoo.sql new file mode 100644 index 0000000..ec026ce --- /dev/null +++ b/drizzle/0004_melted_brother_voodoo.sql @@ -0,0 +1,22 @@ +CREATE TABLE `entity_labels` ( + `label_id` text NOT NULL, + `entity_id` text NOT NULL, + `entity_type` text NOT NULL, + PRIMARY KEY(`label_id`, `entity_id`), + FOREIGN KEY (`label_id`) REFERENCES `sphere_labels`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_entity_labels_entity` ON `entity_labels` (`entity_id`,`entity_type`);--> statement-breakpoint +CREATE TABLE `sphere_labels` ( + `id` text PRIMARY KEY NOT NULL, + `sphere_id` text NOT NULL, + `name` text NOT NULL, + `description` text, + `color` text NOT NULL, + `position` integer DEFAULT 0 NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`sphere_id`) REFERENCES `spheres`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_sphere_labels_sphere_name` ON `sphere_labels` (`sphere_id`,`name`);--> statement-breakpoint +CREATE INDEX `idx_sphere_labels_sphere_position` ON `sphere_labels` (`sphere_id`,`position`); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..a1ac23d --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1387 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "36c47020-9349-4833-8827-8c5bde6e1b17", + "prevId": "496173cb-98e0-46b7-baaf-47929d4cb526", + "tables": { + "oauth_sessions": { + "name": "oauth_sessions", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session": { + "name": "session", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_states": { + "name": "oauth_states", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "indexer_cursor": { + "name": "indexer_cursor", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": "'jetstream'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_entry_counter": { + "name": "sphere_entry_counter", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_number": { + "name": "last_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "sphere_entry_counter_sphere_id_spheres_id_fk": { + "name": "sphere_entry_counter_sphere_id_spheres_id_fk", + "tableFrom": "sphere_entry_counter", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entity_labels": { + "name": "entity_labels", + "columns": { + "label_id": { + "name": "label_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_entity_labels_entity": { + "name": "idx_entity_labels_entity", + "columns": ["entity_id", "entity_type"], + "isUnique": false + } + }, + "foreignKeys": { + "entity_labels_label_id_sphere_labels_id_fk": { + "name": "entity_labels_label_id_sphere_labels_id_fk", + "tableFrom": "entity_labels", + "tableTo": "sphere_labels", + "columnsFrom": ["label_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entity_labels_label_id_entity_id_pk": { + "columns": ["label_id", "entity_id"], + "name": "entity_labels_label_id_entity_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_labels": { + "name": "sphere_labels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_sphere_labels_sphere_name": { + "name": "idx_sphere_labels_sphere_name", + "columns": ["sphere_id", "name"], + "isUnique": true + }, + "idx_sphere_labels_sphere_position": { + "name": "idx_sphere_labels_sphere_position", + "columns": ["sphere_id", "position"], + "isUnique": false + } + }, + "foreignKeys": { + "sphere_labels_sphere_id_spheres_id_fk": { + "name": "sphere_labels_sphere_id_spheres_id_fk", + "tableFrom": "sphere_labels", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_members": { + "name": "sphere_members", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "did": { + "name": "did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'invited'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approval_pds_uri": { + "name": "approval_pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_sphere_members_did": { + "name": "idx_sphere_members_did", + "columns": ["did"], + "isUnique": false + } + }, + "foreignKeys": { + "sphere_members_sphere_id_spheres_id_fk": { + "name": "sphere_members_sphere_id_spheres_id_fk", + "tableFrom": "sphere_members", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sphere_members_sphere_id_did_pk": { + "columns": ["sphere_id", "did"], + "name": "sphere_members_sphere_id_did_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_modules": { + "name": "sphere_modules", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "module_name": { + "name": "module_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled_at": { + "name": "enabled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "sphere_modules_sphere_id_spheres_id_fk": { + "name": "sphere_modules_sphere_id_spheres_id_fk", + "tableFrom": "sphere_modules", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sphere_modules_sphere_id_module_name_pk": { + "columns": ["sphere_id", "module_name"], + "name": "sphere_modules_sphere_id_module_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sphere_permissions": { + "name": "sphere_permissions", + "columns": { + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action_key": { + "name": "action_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "min_role": { + "name": "min_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "sphere_permissions_sphere_id_spheres_id_fk": { + "name": "sphere_permissions_sphere_id_spheres_id_fk", + "tableFrom": "sphere_permissions", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sphere_permissions_sphere_id_action_key_pk": { + "columns": ["sphere_id", "action_key"], + "name": "sphere_permissions_sphere_id_action_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "spheres": { + "name": "spheres", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'public'" + }, + "owner_did": { + "name": "owner_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "spheres_handle_unique": { + "name": "spheres_handle_unique", + "columns": ["handle"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_comment_votes": { + "name": "feature_request_comment_votes", + "columns": { + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feature_request_comment_votes_comment": { + "name": "idx_feature_request_comment_votes_comment", + "columns": ["comment_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_comment_votes_comment_id_feature_request_comments_id_fk": { + "name": "feature_request_comment_votes_comment_id_feature_request_comments_id_fk", + "tableFrom": "feature_request_comment_votes", + "tableTo": "feature_request_comments", + "columnsFrom": ["comment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feature_request_comment_votes_comment_id_author_did_pk": { + "columns": ["comment_id", "author_did"], + "name": "feature_request_comment_votes_comment_id_author_did_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_comments": { + "name": "feature_request_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_feature_request_comments_request": { + "name": "idx_feature_request_comments_request", + "columns": ["request_id"], + "isUnique": false + }, + "idx_feature_request_comments_author_request": { + "name": "idx_feature_request_comments_author_request", + "columns": ["author_did", "request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_comments_request_id_feature_requests_id_fk": { + "name": "feature_request_comments_request_id_feature_requests_id_fk", + "tableFrom": "feature_request_comments", + "tableTo": "feature_requests", + "columnsFrom": ["request_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_statuses": { + "name": "feature_request_statuses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_feature_request_statuses_request": { + "name": "idx_feature_request_statuses_request", + "columns": ["request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_statuses_request_id_feature_requests_id_fk": { + "name": "feature_request_statuses_request_id_feature_requests_id_fk", + "tableFrom": "feature_request_statuses", + "tableTo": "feature_requests", + "columnsFrom": ["request_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_request_votes": { + "name": "feature_request_votes", + "columns": { + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feature_request_votes_request": { + "name": "idx_feature_request_votes_request", + "columns": ["request_id"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_request_votes_request_id_feature_requests_id_fk": { + "name": "feature_request_votes_request_id_feature_requests_id_fk", + "tableFrom": "feature_request_votes", + "tableTo": "feature_requests", + "columnsFrom": ["request_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "feature_request_votes_request_id_author_did_pk": { + "columns": ["request_id", "author_did"], + "name": "feature_request_votes_request_id_author_did_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feature_requests": { + "name": "feature_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'general'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requested'" + }, + "duplicate_of_id": { + "name": "duplicate_of_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feature_requests_sphere_number": { + "name": "idx_feature_requests_sphere_number", + "columns": ["sphere_id", "number"], + "isUnique": true + }, + "idx_feature_requests_sphere": { + "name": "idx_feature_requests_sphere", + "columns": ["sphere_id"], + "isUnique": false + }, + "idx_feature_requests_status": { + "name": "idx_feature_requests_status", + "columns": ["status"], + "isUnique": false + }, + "idx_feature_requests_category": { + "name": "idx_feature_requests_category", + "columns": ["category"], + "isUnique": false + } + }, + "foreignKeys": { + "feature_requests_sphere_id_spheres_id_fk": { + "name": "feature_requests_sphere_id_spheres_id_fk", + "tableFrom": "feature_requests", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feed_posts": { + "name": "feed_posts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_feed_posts_parent": { + "name": "idx_feed_posts_parent", + "columns": ["parent_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_columns": { + "name": "kanban_columns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_kanban_columns_sphere_slug": { + "name": "idx_kanban_columns_sphere_slug", + "columns": ["sphere_id", "slug"], + "isUnique": true + }, + "idx_kanban_columns_sphere_position": { + "name": "idx_kanban_columns_sphere_position", + "columns": ["sphere_id", "position"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_columns_sphere_id_spheres_id_fk": { + "name": "kanban_columns_sphere_id_spheres_id_fk", + "tableFrom": "kanban_columns", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_task_comments": { + "name": "kanban_task_comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_kanban_task_comments_task": { + "name": "idx_kanban_task_comments_task", + "columns": ["task_id"], + "isUnique": false + }, + "idx_kanban_task_comments_author_task": { + "name": "idx_kanban_task_comments_author_task", + "columns": ["author_did", "task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_task_comments_task_id_kanban_tasks_id_fk": { + "name": "kanban_task_comments_task_id_kanban_tasks_id_fk", + "tableFrom": "kanban_task_comments", + "tableTo": "kanban_tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_task_status_changes": { + "name": "kanban_task_status_changes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_kanban_task_status_changes_task": { + "name": "idx_kanban_task_status_changes_task", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_task_status_changes_task_id_kanban_tasks_id_fk": { + "name": "kanban_task_status_changes_task_id_kanban_tasks_id_fk", + "tableFrom": "kanban_task_status_changes", + "tableTo": "kanban_tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "kanban_tasks": { + "name": "kanban_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sphere_id": { + "name": "sphere_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_did": { + "name": "author_did", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'backlog'" + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "assignee_did": { + "name": "assignee_did", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pds_uri": { + "name": "pds_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_kanban_tasks_sphere_number": { + "name": "idx_kanban_tasks_sphere_number", + "columns": ["sphere_id", "number"], + "isUnique": true + }, + "idx_kanban_tasks_sphere": { + "name": "idx_kanban_tasks_sphere", + "columns": ["sphere_id"], + "isUnique": false + }, + "idx_kanban_tasks_status": { + "name": "idx_kanban_tasks_status", + "columns": ["status"], + "isUnique": false + }, + "idx_kanban_tasks_sphere_status_position": { + "name": "idx_kanban_tasks_sphere_status_position", + "columns": ["sphere_id", "status", "position"], + "isUnique": false + } + }, + "foreignKeys": { + "kanban_tasks_sphere_id_spheres_id_fk": { + "name": "kanban_tasks_sphere_id_spheres_id_fk", + "tableFrom": "kanban_tasks", + "tableTo": "spheres", + "columnsFrom": ["sphere_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 495837e..0ee8afd 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1775304470263, "tag": "0003_soft_tigra", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1776203212384, + "tag": "0004_melted_brother_voodoo", + "breakpoints": true } ] } diff --git a/packages/app/src/api/spheres.ts b/packages/app/src/api/spheres.ts index 5bf2ed7..bccde26 100644 --- a/packages/app/src/api/spheres.ts +++ b/packages/app/src/api/spheres.ts @@ -127,3 +127,56 @@ export function updateMemberRole(handle: string, did: string, role: "admin" | "m body: JSON.stringify({ role }), }); } + +// ---- Labels ---- + +export type { LabelData } from "@exosphere/client/api/labels"; +import type { LabelData } from "@exosphere/client/api/labels"; + +export function getSphereLabels(handle: string) { + return apiFetch<{ labels: LabelData[] }>(`/api/spheres/${encodeURIComponent(handle)}/labels`); +} + +export function createSphereLabel( + handle: string, + body: { name: string; description?: string; color: string }, +) { + return apiFetch<{ label: LabelData }>(`/api/spheres/${encodeURIComponent(handle)}/labels`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export function updateSphereLabel( + handle: string, + id: string, + body: { name?: string; description?: string | null; color?: string }, +) { + return apiFetch<{ label: LabelData }>( + `/api/spheres/${encodeURIComponent(handle)}/labels/${encodeURIComponent(id)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +export function deleteSphereLabel(handle: string, id: string) { + return apiFetch<{ ok: true }>( + `/api/spheres/${encodeURIComponent(handle)}/labels/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); +} + +export function reorderSphereLabels(handle: string, labelIds: string[]) { + return apiFetch<{ labels: LabelData[] }>( + `/api/spheres/${encodeURIComponent(handle)}/labels/reorder`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ labelIds }), + }, + ); +} diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 75af550..098f850 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -14,6 +14,7 @@ import { CreateSphere } from "./pages/create-sphere.tsx"; import { SpherePage } from "./pages/sphere.tsx"; import { SphereMembersPage } from "./pages/sphere-members.tsx"; import { SpherePermissionsPage } from "./pages/sphere-permissions.tsx"; +import { SphereLabelsPage } from "./pages/sphere-labels.tsx"; import { Dashboard } from "./pages/dashboard.tsx"; import type { ModuleRoute } from "@exosphere/client/types"; import { feedsModule } from "@exosphere/feeds/client"; @@ -138,6 +139,10 @@ function MainContent({ moduleRoutes }: { moduleRoutes: ModuleRoute[] }) { path="/s/:sphereHandle/settings/permissions" component={withSphereLoader(SpherePermissionsPage)} /> + @@ -172,7 +177,10 @@ function MainContent({ moduleRoutes }: { moduleRoutes: ModuleRoute[] }) { {[0, 1].map((i) => (
-
+
))}
@@ -187,6 +195,7 @@ function MainContent({ moduleRoutes }: { moduleRoutes: ModuleRoute[] }) { + {moduleRoutes.map((r) => ( ))} diff --git a/packages/app/src/pages/color-picker.css.ts b/packages/app/src/pages/color-picker.css.ts new file mode 100644 index 0000000..a300d2b --- /dev/null +++ b/packages/app/src/pages/color-picker.css.ts @@ -0,0 +1,31 @@ +import { style } from "@vanilla-extract/css"; +import { vars } from "@exosphere/client/theme.css"; + +export const colorPickerRow = style({ + display: "flex", + alignItems: "center", + gap: vars.space.sm, + flexWrap: "wrap", +}); + +export const colorSwatch = style({ + inlineSize: "28px", + blockSize: "28px", + borderRadius: "50%", + cursor: "pointer", + padding: 0, + border: "2px solid transparent", + transition: "border-color 0.1s", +}); + +export const colorSwatchSelected = style({ + border: "3px solid currentColor", +}); + +export const colorInput = style({ + inlineSize: "28px", + blockSize: "28px", + padding: 0, + border: "none", + cursor: "pointer", +}); diff --git a/packages/app/src/pages/sphere-labels.tsx b/packages/app/src/pages/sphere-labels.tsx new file mode 100644 index 0000000..3f0788e --- /dev/null +++ b/packages/app/src/pages/sphere-labels.tsx @@ -0,0 +1,304 @@ +import { useSignal } from "@preact/signals"; +import { sphereState, sphereHandle } from "@exosphere/client/sphere"; +import { canDo } from "@exosphere/client/permissions"; +import { useQuery } from "@exosphere/client/hooks"; +import { spherePath } from "@exosphere/client/router"; +import { LabelBadge } from "@exosphere/client/components/label-badge"; +import * as ui from "@exosphere/client/ui.css"; +import * as cpUi from "./color-picker.css.ts"; +import { + getSphereLabels, + createSphereLabel, + updateSphereLabel, + deleteSphereLabel, + type LabelData, +} from "../api/spheres.ts"; + +const presetColors = [ + "#6b7280", + "#ef4444", + "#f59e0b", + "#22c55e", + "#3b82f6", + "#8b5cf6", + "#ec4899", + "#14b8a6", +]; + +export function SphereLabelsPage() { + const { data } = sphereState.value; + const handle = sphereHandle.value; + + if (!data || !handle) return null; + if (!canDo("sphere", "manageLabels")) return null; + + return ; +} + +function LabelsContent({ handle }: { handle: string }) { + const { data, refetch } = useQuery(() => getSphereLabels(handle), [handle]); + + const newName = useSignal(""); + const newDescription = useSignal(""); + const newColor = useSignal(presetColors[0]); + const creating = useSignal(false); + const createError = useSignal(""); + + const editingId = useSignal(null); + const editName = useSignal(""); + const editDescription = useSignal(""); + const editColor = useSignal(""); + const saving = useSignal(false); + const listError = useSignal(""); + + const handleCreate = async (e: Event) => { + e.preventDefault(); + if (!newName.value.trim()) { + createError.value = "Name is required."; + return; + } + creating.value = true; + createError.value = ""; + try { + await createSphereLabel(handle, { + name: newName.value.trim(), + description: newDescription.value.trim() || undefined, + color: newColor.value, + }); + newName.value = ""; + newDescription.value = ""; + newColor.value = presetColors[0]; + refetch(); + } catch (err) { + createError.value = err instanceof Error ? err.message : "Failed to create label."; + } finally { + creating.value = false; + } + }; + + const startEdit = (label: LabelData) => { + editingId.value = label.id; + editName.value = label.name; + editDescription.value = label.description ?? ""; + editColor.value = label.color; + listError.value = ""; + }; + + const cancelEdit = () => { + editingId.value = null; + listError.value = ""; + }; + + const handleSave = async () => { + if (!editingId.value || !editName.value.trim()) return; + saving.value = true; + listError.value = ""; + try { + await updateSphereLabel(handle, editingId.value, { + name: editName.value.trim(), + description: editDescription.value.trim() || null, + color: editColor.value, + }); + editingId.value = null; + refetch(); + } catch (err) { + listError.value = err instanceof Error ? err.message : "Failed to update label."; + } finally { + saving.value = false; + } + }; + + const confirmingDeleteId = useSignal(null); + + const handleDelete = async (id: string) => { + if (confirmingDeleteId.value !== id) { + confirmingDeleteId.value = id; + return; + } + confirmingDeleteId.value = null; + try { + await deleteSphereLabel(handle, id); + refetch(); + } catch (err) { + listError.value = err instanceof Error ? err.message : "Failed to delete label."; + } + }; + + return ( +
+
+ +

Labels

+

Create and manage labels for feature requests and tasks.

+
+ +
+

Create label

+
+
+ + (newName.value = (e.target as HTMLInputElement).value)} + /> +
+
+ + (newDescription.value = (e.target as HTMLInputElement).value)} + /> +
+
+ + (newColor.value = c)} /> +
+ {createError.value &&

{createError.value}

} +
+ +
+
+
+ +
+

Current labels

+ {listError.value &&

{listError.value}

} + {!data ? ( +

Loading...

+ ) : data.labels.length === 0 ? ( +

No labels yet.

+ ) : ( +
+ {data.labels.map((label) => ( +
+ {editingId.value === label.id ? ( +
+
+ + (editName.value = (e.target as HTMLInputElement).value)} + /> +
+
+ + + (editDescription.value = (e.target as HTMLInputElement).value) + } + /> +
+
+ + (editColor.value = c)} + /> +
+
+ + +
+
+ ) : ( +
+
+ + {label.description && ( + + {label.description} + + )} +
+
+ + {confirmingDeleteId.value === label.id ? ( + <> + + + + ) : ( + + )} +
+
+ )} +
+ ))} +
+ )} +
+
+ ); +} + +function ColorPicker({ value, onChange }: { value: string; onChange: (color: string) => void }) { + return ( +
+ {presetColors.map((color) => ( +
+ ); +} diff --git a/packages/app/src/pages/sphere.tsx b/packages/app/src/pages/sphere.tsx index f154b44..81d9e09 100644 --- a/packages/app/src/pages/sphere.tsx +++ b/packages/app/src/pages/sphere.tsx @@ -51,7 +51,10 @@ export function SpherePage() { {[0, 1].map((i) => (
-
+
))}
@@ -150,7 +153,9 @@ export function SpherePage() {
{/* Settings — visible to users with relevant permissions */} - {(canManageMembers || canDo("sphere", "updatePermissions")) && ( + {(canManageMembers || + canDo("sphere", "updatePermissions") || + canDo("sphere", "manageLabels")) && (

Settings

@@ -166,6 +171,12 @@ export function SpherePage() {

Configure who can perform actions in this sphere.

)} + {canDo("sphere", "manageLabels") && ( + + Labels +

Create and manage labels for feature requests and tasks.

+
+ )}
)} diff --git a/packages/client/package.json b/packages/client/package.json index 51143e9..f9521db 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -20,7 +20,10 @@ "./components/collapsible-section": "./src/components/collapsible-section.tsx", "./components/invitation-banner": "./src/components/invitation-banner.tsx", "./components/modal": "./src/components/modal.tsx", - "./components/theme-toggle": "./src/components/theme-toggle.tsx" + "./components/theme-toggle": "./src/components/theme-toggle.tsx", + "./components/label-badge": "./src/components/label-badge.tsx", + "./components/label-picker": "./src/components/label-picker.tsx", + "./api/labels": "./src/api/labels.ts" }, "peerDependencies": { "@preact/signals": "catalog:", diff --git a/packages/client/src/api/labels.ts b/packages/client/src/api/labels.ts new file mode 100644 index 0000000..37645cf --- /dev/null +++ b/packages/client/src/api/labels.ts @@ -0,0 +1,20 @@ +import { apiFetch } from "../api.ts"; +import { sphereHandle } from "../sphere.ts"; + +export interface LabelData { + id: string; + name: string; + description: string | null; + color: string; + position: number; +} + +function labelsUrl() { + const handle = sphereHandle.value; + if (!handle) throw new Error("No sphere loaded"); + return `/api/spheres/${encodeURIComponent(handle)}/labels`; +} + +export function getLabels() { + return apiFetch<{ labels: LabelData[] }>(labelsUrl()); +} diff --git a/packages/client/src/components/label-badge.tsx b/packages/client/src/components/label-badge.tsx new file mode 100644 index 0000000..8a13f5c --- /dev/null +++ b/packages/client/src/components/label-badge.tsx @@ -0,0 +1,22 @@ +import * as ui from "../ui.css.ts"; + +function hexToRgba(hex: string, alpha: number): string { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +export function LabelBadge({ label }: { label: { name: string; color: string } }) { + return ( + + {label.name} + + ); +} diff --git a/packages/client/src/components/label-picker.css.ts b/packages/client/src/components/label-picker.css.ts new file mode 100644 index 0000000..7652028 --- /dev/null +++ b/packages/client/src/components/label-picker.css.ts @@ -0,0 +1,55 @@ +import { style } from "@vanilla-extract/css"; +import { vars } from "../theme.css.ts"; + +export const wrapper = style({ + position: "relative", +}); + +export const trigger = style({ + boxSizing: "border-box", + display: "flex", + alignItems: "center", + gap: vars.space.xs, + inlineSize: "100%", + minBlockSize: "38px", + paddingBlock: vars.space.xs, + paddingInline: vars.space.sm, + backgroundColor: vars.color.surface, + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.sm, + cursor: "pointer", + textAlign: "start", + transition: "border-color 0.15s", + ":hover": { + borderColor: vars.color.primary, + }, +}); + +export const dropdown = style({ + position: "absolute", + zIndex: 10, + insetBlockStart: "100%", + insetInlineStart: 0, + marginBlockStart: vars.space.xs, + minInlineSize: "200px", + maxBlockSize: "240px", + overflowY: "auto", + backgroundColor: vars.color.surface, + border: `1px solid ${vars.color.border}`, + borderRadius: vars.radius.sm, + boxShadow: `0 4px 12px ${vars.color.shadow}`, + paddingBlock: vars.space.xs, +}); + +export const option = style({ + display: "flex", + alignItems: "center", + gap: vars.space.sm, + paddingBlock: vars.space.xs, + paddingInline: vars.space.sm, + cursor: "pointer", + transition: "background-color 0.1s", + ":hover": { + backgroundColor: vars.color.surfaceHover, + }, +}); diff --git a/packages/client/src/components/label-picker.tsx b/packages/client/src/components/label-picker.tsx new file mode 100644 index 0000000..0f4aec9 --- /dev/null +++ b/packages/client/src/components/label-picker.tsx @@ -0,0 +1,79 @@ +import { useSignal } from "@preact/signals"; +import { useEffect, useRef } from "preact/hooks"; +import * as ui from "../ui.css.ts"; +import * as s from "./label-picker.css.ts"; +import { LabelBadge } from "./label-badge.tsx"; + +export interface LabelOption { + id: string; + name: string; + color: string; +} + +export function LabelPicker({ + labels, + selectedIds, + onChange, +}: { + labels: LabelOption[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; +}) { + const open = useSignal(false); + const wrapperRef = useRef(null); + + // Close dropdown on outside click + useEffect(() => { + if (!open.value) return; + const handler = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + open.value = false; + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open.value]); + + if (labels.length === 0) return null; + + const toggle = (id: string) => { + if (selectedIds.includes(id)) { + onChange(selectedIds.filter((i) => i !== id)); + } else { + onChange([...selectedIds, id]); + } + }; + + const selectedLabels = labels.filter((l) => selectedIds.includes(l.id)); + + return ( +
+ + + {open.value && ( +
+ {labels.map((label) => ( + + ))} +
+ )} +
+ ); +} diff --git a/packages/core/src/db/migrate.ts b/packages/core/src/db/migrate.ts index 65b43ff..44acab6 100644 --- a/packages/core/src/db/migrate.ts +++ b/packages/core/src/db/migrate.ts @@ -10,6 +10,10 @@ migrate(getDb(), { migrationsFolder }); // shared sphere_entry_counter. Runs only once — skips if counter rows exist. seedEntryCounters(); +// Data migration: convert hardcoded feature request categories into +// sphere_labels + entity_labels. Runs only once — skips if labels already exist. +seedLabelsFromCategories(); + function seedEntryCounters() { const dbPath = process.env.DATABASE_PATH || "exosphere.sqlite"; const db = new Database(dbPath); @@ -84,3 +88,93 @@ function seedEntryCounters() { db.run("PRAGMA foreign_keys = ON;"); db.close(); } + +const categoryDefaults = [ + { name: "General", slug: "general", color: "#6b7280", position: 0 }, + { name: "Enhancement", slug: "enhancement", color: "#3b82f6", position: 1 }, + { name: "Bug", slug: "bug", color: "#ef4444", position: 2 }, + { name: "Integration", slug: "integration", color: "#8b5cf6", position: 3 }, + { name: "UI / UX", slug: "ui-ux", color: "#f59e0b", position: 4 }, +] as const; + +function seedLabelsFromCategories() { + const dbPath = process.env.DATABASE_PATH || "exosphere.sqlite"; + const db = new Database(dbPath); + + // Check if the sphere_labels table exists + const tableExists = db + .query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE type='table' AND name='sphere_labels'", + ) + .get(); + if (!tableExists) { + db.close(); + return; + } + + // Skip if already seeded + const existing = db.query<{ c: number }, []>("SELECT count(*) as c FROM sphere_labels").get(); + if (existing && existing.c > 0) { + db.close(); + return; + } + + // Check if feature_requests table exists (module may not be enabled) + const frTableExists = db + .query<{ name: string }, []>( + "SELECT name FROM sqlite_master WHERE type='table' AND name='feature_requests'", + ) + .get(); + if (!frTableExists) { + db.close(); + return; + } + + // Find spheres that have feature requests + const spheres = db + .query<{ sphere_id: string }, []>("SELECT DISTINCT sphere_id FROM feature_requests") + .all(); + + if (spheres.length === 0) { + db.close(); + return; + } + + let labelCounter = 0; + + for (const { sphere_id } of spheres) { + const tx = db.transaction(() => { + // Create labels for this sphere + const labelIds: Record = {}; + for (const cat of categoryDefaults) { + const id = `label_migration_${sphere_id}_${labelCounter++}`; + db.run( + "INSERT INTO sphere_labels (id, sphere_id, name, description, color, position) VALUES (?, ?, ?, NULL, ?, ?)", + [id, sphere_id, cat.name, cat.color, cat.position], + ); + labelIds[cat.slug] = id; + } + + // Link existing feature requests to their corresponding labels + const requests = db + .query<{ id: string; category: string }, [string]>( + "SELECT id, category FROM feature_requests WHERE sphere_id = ? AND category IS NOT NULL", + ) + .all(sphere_id); + + for (const req of requests) { + const labelId = labelIds[req.category]; + if (labelId) { + db.run( + "INSERT OR IGNORE INTO entity_labels (label_id, entity_id, entity_type) VALUES (?, ?, ?)", + [labelId, req.id, "feature-request"], + ); + } + } + }); + tx(); + console.log(`[migrate] Sphere ${sphere_id}: created labels from categories`); + } + + db.close(); +} diff --git a/packages/core/src/db/schema/index.ts b/packages/core/src/db/schema/index.ts index cc41f4f..c51c32d 100644 --- a/packages/core/src/db/schema/index.ts +++ b/packages/core/src/db/schema/index.ts @@ -3,3 +3,5 @@ export type { Sphere, SphereMember, SphereModule, SpherePermission } from "./sph export { oauthStates, oauthSessions } from "./auth.ts"; export { indexerCursor } from "./cursor.ts"; export { sphereEntryCounter } from "./entry-counter.ts"; +export { sphereLabels, entityLabels } from "./labels.ts"; +export type { SphereLabel, EntityLabel, EntityType } from "./labels.ts"; diff --git a/packages/core/src/db/schema/labels.ts b/packages/core/src/db/schema/labels.ts new file mode 100644 index 0000000..93d2de9 --- /dev/null +++ b/packages/core/src/db/schema/labels.ts @@ -0,0 +1,51 @@ +import { + sqliteTable, + text, + integer, + primaryKey, + index, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; +import type { InferSelectModel } from "drizzle-orm"; +import { spheres } from "./spheres.ts"; + +export const sphereLabels = sqliteTable( + "sphere_labels", + { + id: text("id").primaryKey(), + sphereId: text("sphere_id") + .notNull() + .references(() => spheres.id), + name: text("name").notNull(), + description: text("description"), + color: text("color").notNull(), + position: integer("position").notNull().default(0), + createdAt: text("created_at") + .notNull() + .default(sql`(datetime('now'))`), + }, + (table) => [ + uniqueIndex("idx_sphere_labels_sphere_name").on(table.sphereId, table.name), + index("idx_sphere_labels_sphere_position").on(table.sphereId, table.position), + ], +); + +export const entityLabels = sqliteTable( + "entity_labels", + { + labelId: text("label_id") + .notNull() + .references(() => sphereLabels.id, { onDelete: "cascade" }), + entityId: text("entity_id").notNull(), + entityType: text("entity_type", { enum: ["feature-request", "kanban-task"] }).notNull(), + }, + (table) => [ + primaryKey({ columns: [table.labelId, table.entityId] }), + index("idx_entity_labels_entity").on(table.entityId, table.entityType), + ], +); + +export type SphereLabel = InferSelectModel; +export type EntityLabel = InferSelectModel; +export type EntityType = EntityLabel["entityType"]; diff --git a/packages/core/src/permissions/core.ts b/packages/core/src/permissions/core.ts index c060c37..1df4469 100644 --- a/packages/core/src/permissions/core.ts +++ b/packages/core/src/permissions/core.ts @@ -14,4 +14,5 @@ export const corePermissions = { enableModule: { label: "Enable modules", defaultRole: "owner" }, disableModule: { label: "Disable modules", defaultRole: "owner" }, updatePermissions: { label: "Update permissions", defaultRole: "owner" }, + manageLabels: { label: "Manage labels", defaultRole: "admin" }, } satisfies Record; diff --git a/packages/core/src/sphere/api/labels.ts b/packages/core/src/sphere/api/labels.ts new file mode 100644 index 0000000..4f3e54c --- /dev/null +++ b/packages/core/src/sphere/api/labels.ts @@ -0,0 +1,130 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { requireAuth, optionalAuth, type AuthEnv } from "../../auth/index.ts"; +import { checkPermission } from "../../permissions/check.ts"; +import { getActiveMemberRole } from "../operations.ts"; +import { findSphere } from "./helpers.ts"; +import { createLabelSchema, updateLabelSchema, reorderLabelsSchema } from "../schemas.ts"; +import { + getLabelsForSphere, + getLabelById, + insertLabel, + updateLabel, + deleteLabel, + reorderLabels, +} from "../label-operations.ts"; + +const app = new Hono(); + +// List labels for a sphere (public) +app.get("/:handle/labels", optionalAuth, (c) => { + const sphere = findSphere(c.req.param("handle")); + if (!sphere) { + return c.json({ error: "Sphere not found" }, 404); + } + + const labels = getLabelsForSphere(sphere.id); + return c.json({ labels }); +}); + +// Create a label +app.post("/:handle/labels", requireAuth, async (c) => { + const sphere = findSphere(c.req.param("handle")); + if (!sphere) { + return c.json({ error: "Sphere not found" }, 404); + } + + const role = getActiveMemberRole(sphere.id, c.var.did); + if (!checkPermission(sphere.id, "sphere", "manageLabels", role)) { + return c.json({ error: "Forbidden" }, 403); + } + + const body = await c.req.json(); + const parsed = createLabelSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: z.flattenError(parsed.error) }, 400); + } + + const label = insertLabel({ + sphereId: sphere.id, + name: parsed.data.name, + description: parsed.data.description, + color: parsed.data.color, + }); + + return c.json({ label }, 201); +}); + +// Update a label +app.put("/:handle/labels/:id", requireAuth, async (c) => { + const sphere = findSphere(c.req.param("handle")); + if (!sphere) { + return c.json({ error: "Sphere not found" }, 404); + } + + const role = getActiveMemberRole(sphere.id, c.var.did); + if (!checkPermission(sphere.id, "sphere", "manageLabels", role)) { + return c.json({ error: "Forbidden" }, 403); + } + + const existing = getLabelById(c.req.param("id")); + if (!existing || existing.sphereId !== sphere.id) { + return c.json({ error: "Label not found" }, 404); + } + + const body = await c.req.json(); + const parsed = updateLabelSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: z.flattenError(parsed.error) }, 400); + } + + updateLabel(existing.id, parsed.data); + const updated = getLabelById(existing.id)!; + return c.json({ label: updated }); +}); + +// Delete a label +app.delete("/:handle/labels/:id", requireAuth, (c) => { + const sphere = findSphere(c.req.param("handle")); + if (!sphere) { + return c.json({ error: "Sphere not found" }, 404); + } + + const role = getActiveMemberRole(sphere.id, c.var.did); + if (!checkPermission(sphere.id, "sphere", "manageLabels", role)) { + return c.json({ error: "Forbidden" }, 403); + } + + const existing = getLabelById(c.req.param("id")); + if (!existing || existing.sphereId !== sphere.id) { + return c.json({ error: "Label not found" }, 404); + } + + deleteLabel(existing.id); + return c.json({ ok: true }); +}); + +// Reorder labels +app.post("/:handle/labels/reorder", requireAuth, async (c) => { + const sphere = findSphere(c.req.param("handle")); + if (!sphere) { + return c.json({ error: "Sphere not found" }, 404); + } + + const role = getActiveMemberRole(sphere.id, c.var.did); + if (!checkPermission(sphere.id, "sphere", "manageLabels", role)) { + return c.json({ error: "Forbidden" }, 403); + } + + const body = await c.req.json(); + const parsed = reorderLabelsSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: z.flattenError(parsed.error) }, 400); + } + + reorderLabels(sphere.id, parsed.data.labelIds); + const labels = getLabelsForSphere(sphere.id); + return c.json({ labels }); +}); + +export { app as labelsApi }; diff --git a/packages/core/src/sphere/index.ts b/packages/core/src/sphere/index.ts index 5ef6bf5..84f809c 100644 --- a/packages/core/src/sphere/index.ts +++ b/packages/core/src/sphere/index.ts @@ -8,3 +8,6 @@ export { createCoreIndexer } from "./indexer.ts"; export { getActiveMemberRole, registerModerationHandler, findSphereByAtUri } from "./operations.ts"; export type { ModerationHandler } from "./operations.ts"; export { sphereContext } from "./middleware.ts"; +export { getLabelsForSphere, getLabelsForEntities, setEntityLabels } from "./label-operations.ts"; +export type { LabelInfo } from "./label-operations.ts"; +export { setEntityLabelsSchema } from "./schemas.ts"; diff --git a/packages/core/src/sphere/label-operations.ts b/packages/core/src/sphere/label-operations.ts new file mode 100644 index 0000000..aa773a7 --- /dev/null +++ b/packages/core/src/sphere/label-operations.ts @@ -0,0 +1,157 @@ +import { eq, and, max, inArray, asc, count } from "../db/drizzle.ts"; +import { getDb } from "../db/index.ts"; +import { sphereLabels, entityLabels } from "../db/schema/index.ts"; +import type { SphereLabel, EntityType } from "../db/schema/index.ts"; +import { generateRkey } from "../pds.ts"; + +export function getLabelsForSphere(sphereId: string): SphereLabel[] { + return getDb() + .select() + .from(sphereLabels) + .where(eq(sphereLabels.sphereId, sphereId)) + .orderBy(asc(sphereLabels.position)) + .all(); +} + +export function getLabelById(id: string): SphereLabel | undefined { + return getDb().select().from(sphereLabels).where(eq(sphereLabels.id, id)).get(); +} + +export function insertLabel(params: { + sphereId: string; + name: string; + description?: string; + color: string; +}): SphereLabel { + const db = getDb(); + const id = generateRkey(); + + // Position race is safe under SQLite's single-writer WAL mode + const maxPos = db + .select({ max: max(sphereLabels.position) }) + .from(sphereLabels) + .where(eq(sphereLabels.sphereId, params.sphereId)) + .get(); + const position = (maxPos?.max ?? -1) + 1; + + db.insert(sphereLabels) + .values({ + id, + sphereId: params.sphereId, + name: params.name, + description: params.description ?? null, + color: params.color, + position, + }) + .run(); + + const inserted = db.select().from(sphereLabels).where(eq(sphereLabels.id, id)).get(); + if (!inserted) throw new Error("Failed to insert label"); + return inserted; +} + +export function updateLabel( + id: string, + updates: { name?: string; description?: string | null; color?: string }, +): void { + const set: Record = {}; + if (updates.name !== undefined) set.name = updates.name; + if (updates.description !== undefined) set.description = updates.description; + if (updates.color !== undefined) set.color = updates.color; + + if (Object.keys(set).length === 0) return; + + getDb().update(sphereLabels).set(set).where(eq(sphereLabels.id, id)).run(); +} + +export function deleteLabel(id: string): void { + // entity_labels rows cascade-deleted via FK + getDb().delete(sphereLabels).where(eq(sphereLabels.id, id)).run(); +} + +export function reorderLabels(sphereId: string, labelIds: string[]): void { + const db = getDb(); + db.transaction((tx) => { + // Verify the provided IDs cover all labels in this sphere + const total = tx + .select({ count: count() }) + .from(sphereLabels) + .where(eq(sphereLabels.sphereId, sphereId)) + .get(); + if (total && total.count !== labelIds.length) { + throw new Error("labelIds must include all labels for this sphere"); + } + + for (let i = 0; i < labelIds.length; i++) { + tx.update(sphereLabels) + .set({ position: i }) + .where(and(eq(sphereLabels.id, labelIds[i]), eq(sphereLabels.sphereId, sphereId))) + .run(); + } + }); +} + +export type LabelInfo = { id: string; name: string; color: string }; + +/** Batch-fetch labels for multiple entities. Returns a Map keyed by entity ID. */ +export function getLabelsForEntities( + entityIds: string[], + entityType: EntityType, +): Map { + if (entityIds.length === 0) return new Map(); + + const db = getDb(); + const rows = db + .select({ + entityId: entityLabels.entityId, + labelId: sphereLabels.id, + name: sphereLabels.name, + color: sphereLabels.color, + }) + .from(entityLabels) + .innerJoin(sphereLabels, eq(sphereLabels.id, entityLabels.labelId)) + .where(and(inArray(entityLabels.entityId, entityIds), eq(entityLabels.entityType, entityType))) + .orderBy(asc(sphereLabels.position)) + .all(); + + const result = new Map(); + for (const row of rows) { + const list = result.get(row.entityId) ?? []; + list.push({ id: row.labelId, name: row.name, color: row.color }); + result.set(row.entityId, list); + } + return result; +} + +/** Replace all labels for an entity with the given label IDs. + * Validates that every label belongs to the specified sphere. */ +export function setEntityLabels( + sphereId: string, + entityId: string, + entityType: EntityType, + labelIds: string[], +): void { + const uniqueIds = [...new Set(labelIds)]; + const db = getDb(); + db.transaction((tx) => { + tx.delete(entityLabels) + .where(and(eq(entityLabels.entityId, entityId), eq(entityLabels.entityType, entityType))) + .run(); + + if (uniqueIds.length === 0) return; + + // Verify all labels belong to this sphere + const valid = tx + .select({ id: sphereLabels.id }) + .from(sphereLabels) + .where(and(eq(sphereLabels.sphereId, sphereId), inArray(sphereLabels.id, uniqueIds))) + .all(); + if (valid.length !== uniqueIds.length) { + throw new Error("One or more labels do not belong to this sphere"); + } + + for (const labelId of uniqueIds) { + tx.insert(entityLabels).values({ labelId, entityId, entityType }).onConflictDoNothing().run(); + } + }); +} diff --git a/packages/core/src/sphere/routes.ts b/packages/core/src/sphere/routes.ts index d35f64e..6af5ca2 100644 --- a/packages/core/src/sphere/routes.ts +++ b/packages/core/src/sphere/routes.ts @@ -4,6 +4,7 @@ import { spheresCrudApi } from "./api/spheres.ts"; import { createModuleRoutes } from "./api/modules.ts"; import { membersApi } from "./api/members.ts"; import { permissionsApi } from "./api/permissions.ts"; +import { labelsApi } from "./api/labels.ts"; export { getCurrentSphere, getMemberSpheres, getPendingInvitations } from "./api/spheres.ts"; @@ -14,6 +15,7 @@ export function createSphereRoutes(availableModules: string[]) { app.route("/", createModuleRoutes(availableModules)); app.route("/", membersApi); app.route("/", permissionsApi); + app.route("/", labelsApi); return app; } diff --git a/packages/core/src/sphere/schemas.ts b/packages/core/src/sphere/schemas.ts index 3c24ed3..30b6d0f 100644 --- a/packages/core/src/sphere/schemas.ts +++ b/packages/core/src/sphere/schemas.ts @@ -20,3 +20,26 @@ export const inviteMemberSchema = z.object({ identifier: z.string().min(1), role: z.enum(["admin", "member"]).default("member"), }); + +export const createLabelSchema = z.object({ + name: z.string().min(1).max(50), + description: z.string().max(200).optional(), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/), +}); + +export const updateLabelSchema = z.object({ + name: z.string().min(1).max(50).optional(), + description: z.string().max(200).nullable().optional(), + color: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .optional(), +}); + +export const reorderLabelsSchema = z.object({ + labelIds: z.array(z.string().min(1)), +}); + +export const setEntityLabelsSchema = z.object({ + labelIds: z.array(z.string().min(1)), +}); diff --git a/packages/feature-requests/src/api/requests.ts b/packages/feature-requests/src/api/requests.ts index 245fbc4..14bf50b 100644 --- a/packages/feature-requests/src/api/requests.ts +++ b/packages/feature-requests/src/api/requests.ts @@ -3,7 +3,8 @@ import { z } from "zod"; import { getDb } from "@exosphere/core/db"; import { eq, and, count, sql, inArray, like, desc, asc } from "@exosphere/core/db/drizzle"; import { requireAuth, type AuthEnv } from "@exosphere/core/auth"; -import { requirePermission } from "@exosphere/core/permissions"; +import { requirePermission, checkPermission } from "@exosphere/core/permissions"; +import { getActiveMemberRole } from "@exosphere/core/sphere"; import { putPdsRecord, deletePdsRecord, generateRkey, tidToDate } from "@exosphere/core/pds"; import { resolveDidHandles } from "@exosphere/core/identity"; import type { SphereEnv } from "@exosphere/core/types"; @@ -16,6 +17,11 @@ import { hideFeatureRequest, unhideFeatureRequest, } from "../db/operations.ts"; +import { + getLabelsForEntities, + setEntityLabels, + setEntityLabelsSchema, +} from "@exosphere/core/sphere"; const COLLECTION = "site.exosphere.featureRequest.entry"; const MODERATION_COLLECTION = "site.exosphere.moderation"; @@ -86,8 +92,11 @@ app.get("/:number{[0-9]+}", async (c) => { const handleMap = await resolveDidHandles([row.authorDid]); const authorHandle = handleMap.get(row.authorDid) ?? null; + const labelMap = getLabelsForEntities([row.id], "feature-request"); + const labels = labelMap.get(row.id) ?? []; + return c.json({ - featureRequest: { ...row, createdAt: tidToDate(row.id), authorHandle }, + featureRequest: { ...row, createdAt: tidToDate(row.id), authorHandle, labels }, duplicateOf, duplicateCount, }); @@ -140,7 +149,15 @@ app.get("/", (c) => { .groupBy(featureRequests.id) .orderBy(sortBy === "votes" ? orderDir(voteCountCol) : orderDir(featureRequests.id)) .all(); - return c.json({ featureRequests: rows.map((r) => ({ ...r, createdAt: tidToDate(r.id) })) }); + const ids = rows.map((r) => r.id); + const labelMap = getLabelsForEntities(ids, "feature-request"); + return c.json({ + featureRequests: rows.map((r) => ({ + ...r, + createdAt: tidToDate(r.id), + labels: labelMap.get(r.id) ?? [], + })), + }); }); app.post("/", requireAuth, requirePermission("feature-requests", "create"), async (c) => { @@ -150,7 +167,7 @@ app.post("/", requireAuth, requirePermission("feature-requests", "create"), asyn return c.json({ error: z.flattenError(result.error) }, 400); } - const { title, description, category } = result.data; + const { title, description, category, labelIds } = result.data; const sphereId = c.var.sphereId; const sphereOwnerDid = c.var.sphereOwnerDid; const sphereVisibility = c.var.sphereVisibility; @@ -180,7 +197,15 @@ app.post("/", requireAuth, requirePermission("feature-requests", "create"), asyn pdsUri, }); - return c.json({ featureRequest: row ? { ...row, createdAt: tidToDate(id) } : row }, 201); + if (labelIds && labelIds.length > 0) { + setEntityLabels(sphereId, id, "feature-request", labelIds); + } + + const labels = labelIds?.length + ? (getLabelsForEntities([id], "feature-request").get(id) ?? []) + : []; + + return c.json({ featureRequest: row ? { ...row, createdAt: tidToDate(id), labels } : row }, 201); }); // Author-only: delete own feature request (local DB + PDS) @@ -369,4 +394,39 @@ app.get("/search", (c) => { return c.json({ results: rows }); }); +// Update labels on a feature request. +// Reuses changeStatus permission — admins who can triage status can also manage labels. +app.post("/:id/labels", requireAuth, async (c) => { + const id = c.req.param("id"); + const db = getDb(); + const did = c.var.did; + const sphereId = c.var.sphereId; + + const existing = db + .select({ id: featureRequests.id, authorDid: featureRequests.authorDid }) + .from(featureRequests) + .where(and(eq(featureRequests.id, id), eq(featureRequests.sphereId, sphereId))) + .get(); + if (!existing) { + return c.json({ error: "Feature request not found" }, 404); + } + + if (existing.authorDid !== did) { + const role = getActiveMemberRole(sphereId, did); + if (!checkPermission(sphereId, "feature-requests", "changeStatus", role)) { + return c.json({ error: "Forbidden" }, 403); + } + } + + const body = await c.req.json(); + const parsed = setEntityLabelsSchema.safeParse(body); + if (!parsed.success) { + return c.json({ error: z.flattenError(parsed.error) }, 400); + } + + setEntityLabels(sphereId, id, "feature-request", parsed.data.labelIds); + const labels = getLabelsForEntities([id], "feature-request").get(id) ?? []; + return c.json({ labels }); +}); + export { app as requestsApi }; diff --git a/packages/feature-requests/src/db/operations.ts b/packages/feature-requests/src/db/operations.ts index 154348a..830f4ba 100644 --- a/packages/feature-requests/src/db/operations.ts +++ b/packages/feature-requests/src/db/operations.ts @@ -1,5 +1,6 @@ import { getDb } from "@exosphere/core/db"; import { eq, and, inArray } from "@exosphere/core/db/drizzle"; +import { entityLabels } from "@exosphere/core/db/schema"; import { nextEntryNumber } from "@exosphere/core/db/entry-number"; import { tidToDate } from "@exosphere/core/pds"; import type { ModerationHandler } from "@exosphere/core/sphere"; @@ -68,6 +69,7 @@ export function deleteFeatureRequestCascade(id: string): void { tx.delete(featureRequestComments).where(eq(featureRequestComments.requestId, id)).run(); tx.delete(featureRequestStatuses).where(eq(featureRequestStatuses.requestId, id)).run(); tx.delete(featureRequestVotes).where(eq(featureRequestVotes.requestId, id)).run(); + tx.delete(entityLabels).where(eq(entityLabels.entityId, id)).run(); tx.delete(featureRequests).where(eq(featureRequests.id, id)).run(); }); } diff --git a/packages/feature-requests/src/schemas/feature-request.ts b/packages/feature-requests/src/schemas/feature-request.ts index 26ac2d9..e57e873 100644 --- a/packages/feature-requests/src/schemas/feature-request.ts +++ b/packages/feature-requests/src/schemas/feature-request.ts @@ -55,6 +55,7 @@ export const createFeatureRequestSchema = z.object({ title: z.string().min(1).max(200), description: z.string().min(1).max(10000), category: z.enum(categories).default("general"), + labelIds: z.array(z.string()).default([]), }); export type CreateFeatureRequest = z.infer; diff --git a/packages/feature-requests/src/types.ts b/packages/feature-requests/src/types.ts index 21264c3..6ed6da9 100644 --- a/packages/feature-requests/src/types.ts +++ b/packages/feature-requests/src/types.ts @@ -9,6 +9,7 @@ export { } from "./schemas/feature-request.ts"; import type { FeatureRequest, FeatureRequestComment } from "./db/schema.ts"; +import type { LabelInfo } from "@exosphere/core/sphere"; /** Shape returned by the GET /feature-requests list/detail endpoints (includes vote count). */ export type FeatureRequestListItem = FeatureRequest & { @@ -16,6 +17,7 @@ export type FeatureRequestListItem = FeatureRequest & { voteCount: number; commentCount: number; authorHandle?: string | null; + labels: LabelInfo[]; }; /** Shape returned by the GET comments endpoint (includes vote count and resolved handle). */ diff --git a/packages/feature-requests/src/ui/api/feature-requests.ts b/packages/feature-requests/src/ui/api/feature-requests.ts index 7803d66..e810bdc 100644 --- a/packages/feature-requests/src/ui/api/feature-requests.ts +++ b/packages/feature-requests/src/ui/api/feature-requests.ts @@ -27,7 +27,9 @@ export function getFeatureRequests(statuses?: string[], sort?: SortBy, order?: S export function createFeatureRequest(body: { title: string; description: string; - category: string; + /** @deprecated Kept for backward compat with PDS indexer; defaults to "general" server-side. */ + category?: string; + labelIds?: string[]; }) { return moduleFetch<{ featureRequest: FeatureRequest }>("/feature-requests", { method: "POST", @@ -36,6 +38,17 @@ export function createFeatureRequest(body: { }); } +export function updateFeatureRequestLabels(id: string, labelIds: string[]) { + return moduleFetch<{ labels: Array<{ id: string; name: string; color: string }> }>( + `/feature-requests/${encodeURIComponent(id)}/labels`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ labelIds }), + }, + ); +} + export function deleteFeatureRequest(id: string) { return moduleFetch<{ ok: true }>(`/feature-requests/${encodeURIComponent(id)}`, { method: "DELETE", diff --git a/packages/feature-requests/src/ui/components/request-card.tsx b/packages/feature-requests/src/ui/components/request-card.tsx index 526c9f0..1f0470c 100644 --- a/packages/feature-requests/src/ui/components/request-card.tsx +++ b/packages/feature-requests/src/ui/components/request-card.tsx @@ -1,15 +1,11 @@ import { useSignal } from "@preact/signals"; import * as ui from "@exosphere/client/ui.css"; import { spherePath } from "@exosphere/client/router"; +import { LabelBadge } from "@exosphere/client/components/label-badge"; import * as frUi from "../ui.css.ts"; import { statusLabels, type FeatureRequestListItem, type Status } from "../api/index.ts"; -import { categoryLabels, type Category } from "../../schemas/feature-request.ts"; import { formatDate } from "@exosphere/client/format"; -export function categoryLabel(value: string): string { - return categoryLabels[value as Category] ?? value; -} - export function RequestCard({ fr, isAuthor = false, @@ -83,7 +79,9 @@ export function RequestCard({ )}
- {categoryLabel(fr.category)} + {fr.labels?.map((label) => ( + + ))} {fr.status !== "requested" && ( {statusLabels[fr.status as Status] ?? fr.status} diff --git a/packages/feature-requests/src/ui/pages/feature-requests.tsx b/packages/feature-requests/src/ui/pages/feature-requests.tsx index b16afea..359b630 100644 --- a/packages/feature-requests/src/ui/pages/feature-requests.tsx +++ b/packages/feature-requests/src/ui/pages/feature-requests.tsx @@ -15,25 +15,30 @@ import { unvoteFeatureRequest, getMyVotes, } from "../api/index.ts"; -import { categories, categoryLabels } from "../../schemas/feature-request.ts"; import { Modal } from "@exosphere/client/components/modal"; +import { LabelPicker, type LabelOption } from "@exosphere/client/components/label-picker"; +import { getLabels } from "@exosphere/client/api/labels"; import { RequestCard } from "../components/request-card.tsx"; import { SortControls } from "../components/sort-controls.tsx"; import { useSortParams } from "../hooks/use-sort-params.ts"; import { useEffect } from "preact/hooks"; -const categoryOptions = categories.map((value) => ({ - value, - label: categoryLabels[value], -})); - function SubmitForm({ onCreated }: { onCreated: () => void }) { const title = useSignal(""); const description = useSignal(""); - const category = useSignal("general"); + const selectedLabelIds = useSignal([]); + const availableLabels = useSignal([]); const error = useSignal(""); const submitting = useSignal(false); + useEffect(() => { + getLabels() + .then((res) => { + availableLabels.value = res.labels; + }) + .catch(() => {}); + }, []); + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); @@ -59,11 +64,11 @@ function SubmitForm({ onCreated }: { onCreated: () => void }) { await createFeatureRequest({ title: title.value.trim(), description: description.value.trim(), - category: category.value, + labelIds: selectedLabelIds.value, }); title.value = ""; description.value = ""; - category.value = "general"; + selectedLabelIds.value = []; onCreated(); } catch (err) { error.value = err instanceof Error ? err.message : "Something went wrong."; @@ -89,23 +94,16 @@ function SubmitForm({ onCreated }: { onCreated: () => void }) { />
-
- - -
+ {availableLabels.value.length > 0 && ( +
+ + (selectedLabelIds.value = ids)} + /> +
+ )}