diff --git a/drizzle/0003_lumpy_jamie_braddock.sql b/drizzle/0003_lumpy_jamie_braddock.sql new file mode 100644 index 000000000..700219b3e --- /dev/null +++ b/drizzle/0003_lumpy_jamie_braddock.sql @@ -0,0 +1,17 @@ +ALTER TABLE "workspace_items" ADD COLUMN "sort_order" integer;--> statement-breakpoint +WITH "ranked_workspace_items" AS ( + SELECT + "workspace_id", + "item_id", + ROW_NUMBER() OVER ( + PARTITION BY "workspace_id", "folder_id", ("type" = 'folder') + ORDER BY "created_at" ASC, "item_id" ASC + ) - 1 AS "sort_order" + FROM "workspace_items" +) +UPDATE "workspace_items" AS "workspace_items" +SET "sort_order" = "ranked_workspace_items"."sort_order" +FROM "ranked_workspace_items" +WHERE "workspace_items"."workspace_id" = "ranked_workspace_items"."workspace_id" + AND "workspace_items"."item_id" = "ranked_workspace_items"."item_id";--> statement-breakpoint +CREATE INDEX "idx_workspace_items_workspace_folder_type_sort_order" ON "workspace_items" USING btree ("workspace_id" uuid_ops,"folder_id" text_ops,"type" text_ops,"sort_order" int4_ops); diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 000000000..b157025d6 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2119 @@ +{ + "id": "3f6d5193-71d1-4f7f-98f7-4ab9c405d12f", + "prevId": "77a2e4b0-3244-4eda-8ffd-41ba7999b3e1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_account_user_id": { + "name": "idx_account_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_messages": { + "name": "chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_chat_messages_thread": { + "name": "idx_chat_messages_thread", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_messages_thread_created": { + "name": "idx_chat_messages_thread_created", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_messages_thread_id_chat_threads_id_fk": { + "name": "chat_messages_thread_id_chat_threads_id_fk", + "tableFrom": "chat_messages", + "tableTo": "chat_threads", + "columnsFrom": ["thread_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_messages_thread_message_key": { + "name": "chat_messages_thread_message_key", + "nullsNotDistinct": false, + "columns": ["thread_id", "message_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_threads": { + "name": "chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "head_message_id": { + "name": "head_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_chat_threads_workspace": { + "name": "idx_chat_threads_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_user": { + "name": "idx_chat_threads_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_threads_last_message": { + "name": "idx_chat_threads_last_message", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_threads_workspace_id_workspaces_id_fk": { + "name": "chat_threads_workspace_id_workspaces_id_fk", + "tableFrom": "chat_threads", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_threads_user_id_user_id_fk": { + "name": "chat_threads_user_id_user_id_fk", + "tableFrom": "chat_threads", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "chat_threads_user_scoped": { + "name": "chat_threads_user_scoped", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["authenticated"], + "using": "((chat_threads.user_id = (auth.jwt() ->> 'sub'::text))\n AND ((EXISTS ( SELECT 1 FROM workspaces w\n WHERE ((w.id = chat_threads.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))\n OR (EXISTS ( SELECT 1 FROM workspace_collaborators c\n WHERE ((c.workspace_id = chat_threads.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_session_user_id": { + "name": "idx_session_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_token": { + "name": "idx_session_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_email": { + "name": "idx_user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_user_profiles_user_id": { + "name": "idx_user_profiles_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_user_id_key": { + "name": "user_profiles_user_id_key", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": { + "Users can insert their own profile": { + "name": "Users can insert their own profile", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"], + "withCheck": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can update their own profile": { + "name": "Users can update their own profile", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["authenticated"] + }, + "Users can view their own profile": { + "name": "Users can view their own profile", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_identifier": { + "name": "idx_verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_collaborators": { + "name": "workspace_collaborators", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_collaborators_lookup": { + "name": "idx_workspace_collaborators_lookup", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_workspace": { + "name": "idx_workspace_collaborators_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_collaborators_last_opened_at": { + "name": "idx_workspace_collaborators_last_opened_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_collaborators_workspace_id_fkey": { + "name": "workspace_collaborators_workspace_id_fkey", + "tableFrom": "workspace_collaborators", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_collaborators_invite_token_unique": { + "name": "workspace_collaborators_invite_token_unique", + "nullsNotDistinct": false, + "columns": ["invite_token"] + }, + "workspace_collaborators_workspace_user_unique": { + "name": "workspace_collaborators_workspace_user_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id", "user_id"] + } + }, + "policies": { + "Owners can manage collaborators": { + "name": "Owners can manage collaborators", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["authenticated"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_collaborators.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Collaborators can view their access": { + "name": "Collaborators can view their access", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"], + "using": "(user_id = (auth.jwt() ->> 'sub'::text))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invites": { + "name": "workspace_invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_invites_token": { + "name": "idx_workspace_invites_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_email": { + "name": "idx_workspace_invites_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_invites_workspace": { + "name": "idx_workspace_invites_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_fkey": { + "name": "workspace_invites_workspace_id_fkey", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invites_token_key": { + "name": "workspace_invites_token_key", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": { + "Public can view invite by token": { + "name": "Public can view invite by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "true" + }, + "Users can insert invites for workspaces they own/edit": { + "name": "Users can insert invites for workspaces they own/edit", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"], + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_invites.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_invites.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_content": { + "name": "workspace_item_content", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_schema_version": { + "name": "data_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "structured_data": { + "name": "structured_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "asset_data": { + "name": "asset_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embed_data": { + "name": "embed_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_data": { + "name": "source_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_content_workspace": { + "name": "idx_workspace_item_content_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_content_workspace_item_fkey": { + "name": "workspace_item_content_workspace_item_fkey", + "tableFrom": "workspace_item_content", + "tableTo": "workspace_items", + "columnsFrom": ["workspace_id", "item_id"], + "columnsTo": ["workspace_id", "item_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_content_pkey": { + "name": "workspace_item_content_pkey", + "columns": ["workspace_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read workspace item content they have access to": { + "name": "Users can read workspace item content they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write workspace item content they have write access to": { + "name": "Users can write workspace item content they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))", + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_content.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_content.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_item_extracted": { + "name": "workspace_item_extracted", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content_preview": { + "name": "content_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ocr_text": { + "name": "ocr_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_text": { + "name": "transcript_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ocr_pages": { + "name": "ocr_pages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "transcript_segments": { + "name": "transcript_segments", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_item_extracted_workspace": { + "name": "idx_workspace_item_extracted_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_item_extracted_workspace_item_fkey": { + "name": "workspace_item_extracted_workspace_item_fkey", + "tableFrom": "workspace_item_extracted", + "tableTo": "workspace_items", + "columnsFrom": ["workspace_id", "item_id"], + "columnsTo": ["workspace_id", "item_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_item_extracted_pkey": { + "name": "workspace_item_extracted_pkey", + "columns": ["workspace_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read workspace item extracted data they have access to": { + "name": "Users can read workspace item extracted data they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write workspace item extracted data they have write access to": { + "name": "Users can write workspace item extracted data they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))", + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_item_extracted.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_item_extracted.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_items": { + "name": "workspace_items", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data_schema_version": { + "name": "data_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_ocr": { + "name": "has_ocr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ocr_status": { + "name": "ocr_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ocr_page_count": { + "name": "ocr_page_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "has_transcript": { + "name": "has_transcript", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_count": { + "name": "source_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_items_workspace": { + "name": "idx_workspace_items_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_folder": { + "name": "idx_workspace_items_workspace_folder", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_type": { + "name": "idx_workspace_items_workspace_type", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_folder_type_sort_order": { + "name": "idx_workspace_items_workspace_folder_type_sort_order", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_updated": { + "name": "idx_workspace_items_workspace_updated", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_version": { + "name": "idx_workspace_items_workspace_version", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "source_version", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_processing": { + "name": "idx_workspace_items_workspace_processing", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_ocr": { + "name": "idx_workspace_items_workspace_ocr", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "has_ocr", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + }, + { + "expression": "ocr_status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_ocr_page_count": { + "name": "idx_workspace_items_workspace_ocr_page_count", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "ocr_page_count", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_has_transcript": { + "name": "idx_workspace_items_workspace_has_transcript", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "has_transcript", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_items_workspace_source_count": { + "name": "idx_workspace_items_workspace_source_count", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + }, + { + "expression": "source_count", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_items_workspace_id_fkey": { + "name": "workspace_items_workspace_id_fkey", + "tableFrom": "workspace_items", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_items_pkey": { + "name": "workspace_items_pkey", + "columns": ["workspace_id", "item_id"] + } + }, + "uniqueConstraints": {}, + "policies": { + "Users can read workspace items they have access to": { + "name": "Users can read workspace items they have access to", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)))))" + }, + "Users can write workspace items they have write access to": { + "name": "Users can write workspace items they have write access to", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["public"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))", + "withCheck": "(EXISTS ( SELECT 1\n FROM workspaces\n WHERE ((workspaces.id = workspace_items.workspace_id) AND (workspaces.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_items.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_share_links": { + "name": "workspace_share_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_level": { + "name": "permission_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'editor'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_workspace_share_links_token": { + "name": "idx_workspace_share_links_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspace_share_links_workspace": { + "name": "idx_workspace_share_links_workspace", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "uuid_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_share_links_workspace_id_fkey": { + "name": "workspace_share_links_workspace_id_fkey", + "tableFrom": "workspace_share_links", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_share_links_token_key": { + "name": "workspace_share_links_token_key", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "workspace_share_links_workspace_key": { + "name": "workspace_share_links_workspace_key", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": { + "Public can view share link by token": { + "name": "Public can view share link by token", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["public"], + "using": "true" + }, + "Owners and editors can manage share links": { + "name": "Owners and editors can manage share links", + "as": "PERMISSIVE", + "for": "ALL", + "to": ["authenticated"], + "using": "(EXISTS ( SELECT 1\n FROM workspaces w\n WHERE ((w.id = workspace_share_links.workspace_id) AND (w.user_id = (auth.jwt() ->> 'sub'::text))))) OR (EXISTS ( SELECT 1\n FROM workspace_collaborators c\n WHERE ((c.workspace_id = workspace_share_links.workspace_id) AND (c.user_id = (auth.jwt() ->> 'sub'::text)) AND (c.permission_level = 'editor'::text))))" + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'blank'" + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_workspaces_created_at": { + "name": "idx_workspaces_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_slug": { + "name": "idx_workspaces_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_id": { + "name": "idx_workspaces_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_slug": { + "name": "idx_workspaces_user_slug", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_user_sort_order": { + "name": "idx_workspaces_user_sort_order", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workspaces_last_opened_at": { + "name": "idx_workspaces_last_opened_at", + "columns": [ + { + "expression": "last_opened_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamptz_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "Users can delete their own workspaces": { + "name": "Users can delete their own workspaces", + "as": "PERMISSIVE", + "for": "DELETE", + "to": ["authenticated"], + "using": "(( SELECT (auth.jwt() ->> 'sub'::text)) = user_id)" + }, + "Users can insert their own workspaces": { + "name": "Users can insert their own workspaces", + "as": "PERMISSIVE", + "for": "INSERT", + "to": ["authenticated"] + }, + "Users can update their own workspaces": { + "name": "Users can update their own workspaces", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": ["authenticated"] + }, + "Users can view their own workspaces": { + "name": "Users can view their own workspaces", + "as": "PERMISSIVE", + "for": "SELECT", + "to": ["authenticated"] + } + }, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index c5478bf09..6ad060dd6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1775793600000, "tag": "0002_zero_publication", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1777697286614, + "tag": "0003_lumpy_jamie_braddock", + "breakpoints": true } ] } diff --git a/next.config.ts b/next.config.ts index fe5a58719..1c2c19946 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,8 +4,6 @@ import { withWorkflow } from "workflow/next"; const nextConfig: NextConfig = { reactCompiler: true, - // Transpile git-installed packages to ensure proper module resolution - transpilePackages: ["react-grid-layout"], images: { remotePatterns: [ { diff --git a/package.json b/package.json index b95893fe2..365a8f3f8 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,9 @@ "@ai-sdk/react": "^3.0.170", "@contextcompany/otel": "^1.0.15", "@databuddy/sdk": "^2.4.11", + "@dnd-kit/dom": "0.4.1-beta-20260430231058", + "@dnd-kit/helpers": "0.4.1-beta-20260430231058", + "@dnd-kit/react": "0.4.1-beta-20260430231058", "@e2b/code-interpreter": "^2.4.0", "@embedpdf/core": "^2.14.0", "@embedpdf/engines": "^2.14.0", @@ -144,6 +147,7 @@ "next-themes": "^0.4.6", "papaparse": "^5.5.3", "parse-diff": "^0.11.1", + "pdfjs-dist": "4.10.38", "postcss": "^8.5.8", "postgres": "^3.4.9", "posthog-js": "^1.369.5", @@ -155,12 +159,10 @@ "react-dom": "19.2.5", "react-dropzone": "^15.0.0", "react-email": "^6.0.0", - "react-grid-layout": "git+https://github.com/ThinkEx-OSS/thinkex-grid.git", "react-hotkeys-hook": "^5.2.4", "react-icons": "^5.5.0", "react-jsx-parser": "^2.4.1", "react-markdown": "^10.1.0", - "react-quizlet-flashcard": "^4.0.22", "react-resizable-panels": "^4.9.0", "react-shiki": "^0.9.1", "react-zoom-pan-pinch": "^4.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e50aa89e8..7a0275513 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,15 @@ importers: '@databuddy/sdk': specifier: ^2.4.11 version: 2.4.11(react@19.2.5)(vue@3.5.32(typescript@6.0.3)) + '@dnd-kit/dom': + specifier: 0.4.1-beta-20260430231058 + version: 0.4.1-beta-20260430231058 + '@dnd-kit/helpers': + specifier: 0.4.1-beta-20260430231058 + version: 0.4.1-beta-20260430231058 + '@dnd-kit/react': + specifier: 0.4.1-beta-20260430231058 + version: 0.4.1-beta-20260430231058(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@e2b/code-interpreter': specifier: ^2.4.0 version: 2.4.0 @@ -338,6 +347,9 @@ importers: parse-diff: specifier: ^0.11.1 version: 0.11.1 + pdfjs-dist: + specifier: 4.10.38 + version: 4.10.38 postcss: specifier: ^8.5.8 version: 8.5.10 @@ -371,9 +383,6 @@ importers: react-email: specifier: ^6.0.0 version: 6.0.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-grid-layout: - specifier: git+https://github.com/ThinkEx-OSS/thinkex-grid.git - version: https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-hotkeys-hook: specifier: ^5.2.4 version: 5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -386,9 +395,6 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.5) - react-quizlet-flashcard: - specifier: ^4.0.22 - version: 4.0.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-resizable-panels: specifier: ^4.9.0 version: 4.10.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -900,6 +906,30 @@ packages: vue: optional: true + '@dnd-kit/abstract@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-59hnowyFzR7sfUuX3scjiRIBHbBtj87zMMyxD1w2Aj1LykbsM5igi4gMoPM7N6ZEQbIO2+ov/LvepOtQAeAMNw==} + + '@dnd-kit/collision@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-rL5sVmRhJNcgjXPapD504x5wpDYNXcb79uVdMreyc0eSlFL5COlxxGi/GrBGfoZpsLJBsslfLcFyGvh0EdqLoQ==} + + '@dnd-kit/dom@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-hpvIQEsgHXwvWeJrtMySIPGfzFNlsHNJjChyssVRrseR9LXx8pNt2bsqmSZ7RfaCBhu/fslOPE47edxf+2iBFA==} + + '@dnd-kit/geometry@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-QqRhwKubUFAg4CxzX/Hxf/Q3TcZsZiLRQF6xQZjwq5KWgItq5qaEjL5VRhECqLCyYiM5jWohzmVMVmtS+MFw/A==} + + '@dnd-kit/helpers@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-cjJ4EMxMUOOydMOITWknkvgidUZkUG6pt5T8VeQa+niCbrs265BS7hSvKaNoLpakebAL+m/kKLiFXRKO+A37nA==} + + '@dnd-kit/react@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-VcS5ZIIYDs+WPws5ZW/bGRfT30q+t6qeFSi1lv8kypQFXJxohW3wdMRAbo++xetMXtzDpAR+KGhOcEzcofvnjQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.4.1-beta-20260430231058': + resolution: {integrity: sha512-QtNxyFGsGlvR32BeNShCPowRx/h9tQNQqWgQs8+UKjEp70k4phGMk8o6YfUCruqyF1yMFEyNxAh+ScAOdVXg8g==} + '@dotenvx/dotenvx@1.61.1': resolution: {integrity: sha512-2OUX4KDKvQA6oa7oESG8eNcV4K/2C5jgrbxUcT0VoH9Zelg6dT+rDYew4w2GmXRV3db0tUaM4QZG3MyJL3fU5Q==} hasBin: true @@ -2096,6 +2126,76 @@ packages: '@mermaid-js/parser@1.1.0': resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@napi-rs/nice-android-arm-eabi@1.1.1': resolution: {integrity: sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==} engines: {node: '>= 10'} @@ -3521,6 +3621,9 @@ packages: peerDependencies: webpack: ^5 + '@preact/signals-core@1.14.1': + resolution: {integrity: sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -7182,9 +7285,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@4.0.3: - resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} - fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} @@ -8822,6 +8922,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdfjs-dist@4.10.38: + resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} + engines: {node: '>=20'} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -9159,12 +9263,6 @@ packages: peerDependencies: react: ^19.2.5 - react-draggable@4.5.0: - resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - react-dropzone@15.0.0: resolution: {integrity: sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==} engines: {node: '>= 10.13'} @@ -9176,13 +9274,6 @@ packages: engines: {node: '>=20.0.0'} hasBin: true - react-grid-layout@https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6: - resolution: {tarball: https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6} - version: 2.2.3 - peerDependencies: - react: '>= 16.3.0' - react-dom: '>= 16.3.0' - react-hotkeys-hook@5.2.4: resolution: {integrity: sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==} peerDependencies: @@ -9210,12 +9301,6 @@ packages: '@types/react': '>=18' react: '>=18' - react-quizlet-flashcard@4.0.22: - resolution: {integrity: sha512-YbkAY4BWi8anBNinqjuLIK6cLddlFw6iRgzcoJEXgXBR3Of/cO0uxfPN9Pr3+fO0fUTk8d1hc/tXQAupL+LhcQ==} - peerDependencies: - react: ^19.1.0 - react-dom: ^19.1.0 - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -9242,12 +9327,6 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 - react-resizable@3.1.3: - resolution: {integrity: sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==} - peerDependencies: - react: '>= 16.3' - react-dom: '>= 16.3' - react-shiki@0.9.3: resolution: {integrity: sha512-F2Uju1/BeUTFQeS+3v3HM0Ry4p+8gcLC4ssObmXxwrzlwPJYq5RGAKcA1r5JBEnJCpEVKf9PajnwM+JMwZnzGg==} peerDependencies: @@ -9393,9 +9472,6 @@ packages: '@react-email/render': optional: true - resize-observer-polyfill@1.5.1: - resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} - resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -11020,6 +11096,50 @@ snapshots: react: 19.2.5 vue: 3.5.32(typescript@6.0.3) + '@dnd-kit/abstract@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/geometry': 0.4.1-beta-20260430231058 + '@dnd-kit/state': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/collision@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/abstract': 0.4.1-beta-20260430231058 + '@dnd-kit/geometry': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/dom@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/abstract': 0.4.1-beta-20260430231058 + '@dnd-kit/collision': 0.4.1-beta-20260430231058 + '@dnd-kit/geometry': 0.4.1-beta-20260430231058 + '@dnd-kit/state': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/geometry@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/state': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/helpers@0.4.1-beta-20260430231058': + dependencies: + '@dnd-kit/abstract': 0.4.1-beta-20260430231058 + tslib: 2.8.1 + + '@dnd-kit/react@0.4.1-beta-20260430231058(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@dnd-kit/abstract': 0.4.1-beta-20260430231058 + '@dnd-kit/dom': 0.4.1-beta-20260430231058 + '@dnd-kit/state': 0.4.1-beta-20260430231058 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + tslib: 2.8.1 + + '@dnd-kit/state@0.4.1-beta-20260430231058': + dependencies: + '@preact/signals-core': 1.14.1 + tslib: 2.8.1 + '@dotenvx/dotenvx@1.61.1': dependencies: commander: 11.1.0 @@ -11924,6 +12044,54 @@ snapshots: dependencies: langium: 4.2.2 + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@napi-rs/nice-android-arm-eabi@1.1.1': optional: true @@ -13347,6 +13515,8 @@ snapshots: transitivePeerDependencies: - debug + '@preact/signals-core@1.14.1': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -17495,8 +17665,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-equals@4.0.3: {} - fast-equals@5.4.0: {} fast-fifo@1.3.2: {} @@ -19437,6 +19605,10 @@ snapshots: pathe@2.0.3: {} + pdfjs-dist@4.10.38: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + peberminta@0.9.0: {} pend@1.2.0: {} @@ -19874,13 +20046,6 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 - react-draggable@4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - clsx: 2.1.1 - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-dropzone@15.0.0(react@19.2.5): dependencies: attr-accept: 2.2.5 @@ -19919,17 +20084,6 @@ snapshots: - supports-color - utf-8-validate - react-grid-layout@https://codeload.github.com/ThinkEx-OSS/thinkex-grid/tar.gz/7e5d3734e67a2782fc0d9cf4086beb50ca51e4a6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - clsx: 2.1.1 - fast-equals: 4.0.3 - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-resizable: 3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - resize-observer-polyfill: 1.5.1 - react-hotkeys-hook@5.2.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: react: 19.2.5 @@ -19969,11 +20123,6 @@ snapshots: transitivePeerDependencies: - supports-color - react-quizlet-flashcard@4.0.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): dependencies: react: 19.2.5 @@ -19998,13 +20147,6 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - react-resizable@3.1.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5): - dependencies: - prop-types: 15.8.1 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - react-draggable: 4.5.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - react-shiki@0.9.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: clsx: 2.1.1 @@ -20182,8 +20324,6 @@ snapshots: optionalDependencies: '@react-email/render': 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - resize-observer-polyfill@1.5.1: {} - resolve-alpn@1.2.1: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/app/api/workspaces/autogen/route.ts b/src/app/api/workspaces/autogen/route.ts index 9de67682b..5b2fac2c3 100644 --- a/src/app/api/workspaces/autogen/route.ts +++ b/src/app/api/workspaces/autogen/route.ts @@ -2,17 +2,15 @@ import { NextRequest } from "next/server"; import { streamText, generateText, Output } from "ai"; import { z } from "zod"; import { executeWebSearch } from "@/lib/ai/tools/web-search"; -import { randomUUID } from "crypto"; -import { desc, eq, sql } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { requireAuthWithUserInfo } from "@/lib/api/workspace-helpers"; import { db, workspaces } from "@/lib/db/client"; import { generateSlug } from "@/lib/workspace/slug"; import { workspaceWorker, type CreateItemParams } from "@/lib/ai/workers"; import { searchVideos } from "@/lib/youtube"; import { FirecrawlClient } from "@/lib/ai/utils/firecrawl"; -import { findNextAvailablePosition } from "@/lib/workspace-state/grid-layout-helpers"; import { generateItemId } from "@/lib/workspace-state/item-helpers"; -import type { Item, QuizQuestion } from "@/lib/workspace-state/types"; +import type { QuizQuestion } from "@/lib/workspace-state/types"; import { quizQuestionInputSchema } from "@/lib/workspace-state/item-data-schemas"; import { materializeQuizQuestion } from "@/lib/workspace-state/quiz-shuffle"; import { CANVAS_CARD_COLORS } from "@/lib/workspace-state/colors"; @@ -45,14 +43,6 @@ function truncateForLog(s: string, max = LOG_TRUNCATE): string { return s.slice(0, max) + "..."; } -/** Layout positions for autogen items (matches desired workspace arrangement) */ -const AUTOGEN_LAYOUTS = { - youtube: { x: 0, y: 0, w: 2, h: 7 }, - document: { x: 2, y: 0, w: 2, h: 9 }, - quiz: { x: 0, y: 7, w: 2, h: 13 }, - pdf: { w: 1, h: 4 }, - image: { w: 2, h: 8 }, -} as const; type FileUrlItem = { url: string; @@ -603,21 +593,6 @@ export async function POST(request: NextRequest) { workspaceId, }); - // Create PDF and image items immediately so OCR can start (runs in parallel with Phase 1) - // Seed with known content-item positions so files are placed around them (matching pre-restructuring layout) - // Always reserve YouTube footprint so a later searched YouTube item (AUTOGEN_LAYOUTS.youtube) never overlaps early PDFs/images - const pdfItemLayouts: Pick[] = [ - { - type: "document", - layout: AUTOGEN_LAYOUTS.document as Item["layout"], - }, - { type: "quiz", layout: AUTOGEN_LAYOUTS.quiz as Item["layout"] }, - { - type: "youtube", - layout: AUTOGEN_LAYOUTS.youtube as Item["layout"], - }, - ]; - const documentAssets: UploadedAsset[] = documentFileUrls.map((pdf) => createUploadedAsset({ fileUrl: pdf.url, @@ -637,15 +612,9 @@ export async function POST(request: NextRequest) { }), ); + // Create PDF and image items immediately so OCR can start (runs in parallel with Phase 1) const pdfCreateParams: CreateItemParams[] = []; for (const asset of documentAssets) { - const position = findNextAvailablePosition( - pdfItemLayouts as Item[], - "pdf", - 4, - AUTOGEN_LAYOUTS.pdf.w, - AUTOGEN_LAYOUTS.pdf.h, - ); const pdfItemId = generateItemId(); const itemDefinition = buildWorkspaceItemDefinitionFromAsset(asset); if (itemDefinition.type !== "pdf") continue; @@ -654,20 +623,11 @@ export async function POST(request: NextRequest) { title: asset.name, itemType: "pdf", pdfData: itemDefinition.initialData as CreateItemParams["pdfData"], - layout: position, }); - pdfItemLayouts.push({ type: "pdf", layout: position }); } const imageCreateParams: CreateItemParams[] = []; for (const asset of imageAssets) { - const position = findNextAvailablePosition( - pdfItemLayouts as Item[], - "image", - 4, - AUTOGEN_LAYOUTS.image.w, - AUTOGEN_LAYOUTS.image.h, - ); const imgItemId = generateItemId(); const itemDefinition = buildWorkspaceItemDefinitionFromAsset(asset); if (itemDefinition.type !== "image") continue; @@ -677,9 +637,7 @@ export async function POST(request: NextRequest) { itemType: "image", imageData: itemDefinition.initialData as CreateItemParams["imageData"], - layout: position, }); - pdfItemLayouts.push({ type: "image", layout: position }); } const fileCreateParams = [...pdfCreateParams, ...imageCreateParams]; @@ -921,12 +879,10 @@ export async function POST(request: NextRequest) { document: { title: output.document.title, content: output.document.content, - layout: AUTOGEN_LAYOUTS.document, }, quiz: { title: output.quiz.title, questions, - layout: AUTOGEN_LAYOUTS.quiz, }, }; }; @@ -945,7 +901,6 @@ export async function POST(request: NextRequest) { return { title: "YouTube Video", url: youtubeUrlFromLinks, - layout: AUTOGEN_LAYOUTS.youtube, }; } const videos = await searchVideos(youtubeSearchTerm, 3); @@ -958,7 +913,6 @@ export async function POST(request: NextRequest) { return { title: video.title, url: video.url, - layout: AUTOGEN_LAYOUTS.youtube, }; } catch (err) { logger.warn("[AUTOGEN] YouTube search failed", { @@ -984,13 +938,11 @@ export async function POST(request: NextRequest) { title: generatedDocument.title, content: generatedDocument.content, itemType: "document", - layout: generatedDocument.layout, }, { title: quizContent.title, itemType: "quiz", quizData: { questions: quizContent.questions }, - layout: quizContent.layout, }, ...(youtubeResult ? [ @@ -998,7 +950,6 @@ export async function POST(request: NextRequest) { title: youtubeResult.title, itemType: "youtube" as const, youtubeData: { url: youtubeResult.url }, - layout: youtubeResult.layout, }, ] : []), diff --git a/src/app/globals.css b/src/app/globals.css index f4e0508fb..912b33c2b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -511,400 +511,11 @@ body.marquee-selecting iframe { } } -/* React Grid Layout Overrides */ -.react-grid-layout { - position: relative; -} - -/* No transition by default — prevents fly-in animation on mount */ -.react-grid-item { - transition: none !important; -} - -/* Enable smooth transitions only after initial layout is complete */ -.workspace-grid-mounted .react-grid-item { - transition: transform 250ms ease-out, - width 250ms ease-out, - height 250ms ease-out !important; -} - -/* Flashcard flip animation isolation - prevent grid transitions from affecting flip */ -.react-grid-item .flip-card, -.react-grid-item .flip-card * { - /* Ensure flip card manages its own transitions */ - transition: none; -} - -.react-grid-item .flip-card-inner { - /* Only the inner container should have the vertical flip transition */ - transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1) !important; - transform-style: preserve-3d !important; -} - -/* Prevent any inherited transforms from affecting flip card content */ -.react-grid-item .flip-card-front, -.react-grid-item .flip-card-back { - /* Content should not have any transitions that could cause flicker */ - transition: none !important; -} - -/* Ensure flip card content is isolated from parent compositing layers */ .flip-card-content { isolation: isolate; contain: layout style; } -/* Cursor styles for draggable grid items */ -.react-grid-item.react-draggable { - cursor: grab; -} - -.react-grid-item.react-draggable-dragging { - cursor: grabbing !important; - touch-action: none !important; - /* Prevent scrolling during drag on mobile */ -} - -.react-grid-item.react-draggable-dragging * { - cursor: grabbing !important; - touch-action: none !important; -} - -/* Improve resize handles for mobile */ -@media (max-width: 768px) { - .react-grid-item>.react-resizable-handle { - width: 25px; - height: 25px; - } - - .react-grid-item>.react-resizable-handle::after { - width: 14px; - height: 14px; - } -} - -.react-grid-item.react-grid-placeholder { - transition: all 200ms ease-out; -} - -.react-grid-item img { - pointer-events: none; - user-select: none; -} - -.react-grid-item>.react-resizable-handle { - position: absolute; - width: 25px; - height: 25px; -} - -.react-grid-item>.react-resizable-handle::after { - content: ""; - position: absolute; - right: 0px; - bottom: 0px; - width: 14px; - height: 14px; - border-right: 3px solid rgba(255, 255, 255, 1); - border-bottom: 3px solid rgba(255, 255, 255, 1); - border-radius: 0 0 10px 0; - opacity: 0; - transition: opacity 0.2s ease, border-color 0.2s ease; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.dark .react-grid-item>.react-resizable-handle::after { - border-right-color: rgba(255, 255, 255, 1); - border-bottom-color: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-sw { - bottom: -3px; - left: -3px; - cursor: sw-resize; - transform: rotate(90deg); -} - -.react-grid-item>.react-resizable-handle-se { - bottom: -3px; - right: -3px; - cursor: se-resize; -} - -.react-grid-item>.react-resizable-handle-nw { - top: -3px; - left: -3px; - cursor: nw-resize; - transform: rotate(180deg); -} - -.react-grid-item>.react-resizable-handle-ne { - top: -3px; - right: -3px; - cursor: ne-resize; - transform: rotate(270deg); -} - -/* Edge handles (N, S, E, W) - span nearly full edge for large hit area */ -.react-grid-item>.react-resizable-handle-w, -.react-grid-item>.react-resizable-handle-e { - top: 30px; - height: calc(100% - 60px); - width: 15px; - cursor: ew-resize; - margin-top: 0; -} - -.react-grid-item>.react-resizable-handle-w { - left: -3px; -} - -.react-grid-item>.react-resizable-handle-e { - right: -3px; -} - -.react-grid-item>.react-resizable-handle-n, -.react-grid-item>.react-resizable-handle-s { - left: 30px; - width: calc(100% - 60px); - height: 15px; - cursor: ns-resize; - margin-left: 0; -} - -.react-grid-item>.react-resizable-handle-n { - top: -3px; -} - -.react-grid-item>.react-resizable-handle-s { - bottom: -3px; -} - -/* Make edge handles straight lines (horizontal for N/S, vertical for E/W) */ -.react-grid-item>.react-resizable-handle-n::after { - content: ""; - position: absolute; - left: 50%; - transform: translateX(-50%); - width: 20px; - height: 3px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - top: 0; - bottom: auto; - right: auto; - margin-top: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-s::after { - content: ""; - position: absolute; - left: 50%; - transform: translateX(-50%); - width: 20px; - height: 3px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - bottom: 0; - top: auto; - right: auto; - margin-top: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-e::after { - content: ""; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 20px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - right: 0; - left: auto; - bottom: auto; - margin-left: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.react-grid-item>.react-resizable-handle-w::after { - content: ""; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 3px; - height: 20px; - background: rgba(255, 255, 255, 1); - border: none; - border-radius: 0; - left: 0; - right: auto; - bottom: auto; - margin-left: 0; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 1)) drop-shadow(0 0 3px rgba(0, 0, 0, 0.8)); -} - -.dark .react-grid-item>.react-resizable-handle-n::after, -.dark .react-grid-item>.react-resizable-handle-s::after { - background: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); -} - -.dark .react-grid-item>.react-resizable-handle-e::after, -.dark .react-grid-item>.react-resizable-handle-w::after { - background: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 1px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 1px rgba(0, 0, 0, 0.8)); -} - -/* Show resize handles on hover - corner handles */ -.react-grid-item:hover>.react-resizable-handle-se::after, -.react-grid-item:hover>.react-resizable-handle-sw::after, -.react-grid-item:hover>.react-resizable-handle-ne::after, -.react-grid-item:hover>.react-resizable-handle-nw::after { - opacity: 1; -} - -/* Show resize handles on hover - edge handles */ -.react-grid-item:hover>.react-resizable-handle-n::after, -.react-grid-item:hover>.react-resizable-handle-s::after, -.react-grid-item:hover>.react-resizable-handle-e::after, -.react-grid-item:hover>.react-resizable-handle-w::after { - opacity: 1; -} - -/* Placeholder styling during drag */ -.react-grid-item.react-grid-placeholder { - background: rgba(59, 130, 246, 0.15); - border: 2px dashed rgba(59, 130, 246, 0.5); - border-radius: 1rem; - opacity: 0.7; - transition: all 200ms ease-out !important; - z-index: 2; - user-select: none; -} - -.react-grid-item.resizing { - opacity: 0.9; - z-index: 100; -} - -.react-grid-item.static { - background: transparent; -} - -.react-grid-item .text, -.react-grid-item button { - user-select: none; -} - -/* Disable text selection during drag */ -.react-grid-item.react-draggable-dragging { - transition: none !important; - z-index: 1000; - opacity: 0.5; -} - -.react-grid-item.react-draggable-dragging * { - user-select: none; -} - -/* Ensure YouTube and PDF preview cards show opacity during drag */ -/* The data-item-type is on the article element inside the grid item */ -.react-grid-item.react-draggable-dragging article[data-item-type="youtube"], -.react-grid-item.react-draggable-dragging article[data-item-type="pdf"][data-has-preview="true"] { - opacity: 0.5 !important; -} - -/* Ensure iframes and images in YouTube/PDF cards show opacity during drag */ -.react-grid-item.react-draggable-dragging article[data-item-type="youtube"] iframe, -.react-grid-item.react-draggable-dragging article[data-item-type="youtube"] img, -.react-grid-item.react-draggable-dragging article[data-item-type="pdf"][data-has-preview="true"] iframe, -.react-grid-item.react-draggable-dragging article[data-item-type="pdf"][data-has-preview="true"] img { - opacity: 0.5 !important; -} - -/* Disable transitions when manually resizing */ -.react-grid-item.resizing { - transition: none !important; -} - -/* Allow dragging beyond scroll boundaries */ -.react-grid-layout:has(.react-draggable-dragging) { - overflow: visible !important; -} - -/* Allow parent containers to overflow during drag */ -.layout:has(.react-draggable-dragging) { - overflow: visible !important; -} - -/* NOTE: Removed overly broad div selector that was breaking scroll containers */ -/* The grid layout itself handles overflow properly */ - -/* Reset overflow for the article content inside cards */ -/* Only apply scrolling to non-flex articles */ -.react-grid-item article:not(.flex) { - overflow-y: auto !important; - overflow-x: hidden !important; -} - -/* Card content layout with overflow handling */ -/* Flex containers handle overflow internally */ -.react-grid-item article.flex { - overflow: hidden !important; -} - -.react-grid-item article:not(.flex) { - display: block; - height: 100%; - overflow-y: auto; - overflow-x: hidden; -} - -/* Custom scrollbar for cards */ -.react-grid-item article::-webkit-scrollbar { - width: 6px; -} - -.react-grid-item article::-webkit-scrollbar-track { - background: transparent; -} - -.react-grid-item article::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: 3px; -} - -.dark .react-grid-item article::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); -} - -.react-grid-item article::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); -} - -.dark .react-grid-item article::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); -} - -/* Ensure tag containers respect boundaries */ -.react-grid-item article [class*="flex-wrap"] { - max-width: 100%; - word-break: break-word; -} - -/* Constrain textareas within cards */ -.react-grid-item article textarea { - max-height: 400px; - overflow-y: auto; -} - /* Word breaking with character fallback for workspace card item headers only */ .workspace-card-text { /* Try to break at word boundaries first */ @@ -1084,6 +695,15 @@ body.marquee-selecting iframe { } } +/* @dnd-kit clone feedback — in-grid placeholder only (`data-dnd-placeholder="clone"`). + Floated preview is the same draggable node tagged `data-dnd-dragging` — not matched here. + Observer copies inline opacity from SortableWorkspaceGridItem; !important shadows that + only on the slot clone. Avoid filter here: it can create stacking-context quirks beside + the fixed drag layer on some GPUs. */ +.workspace-grid-container [data-dnd-placeholder="clone"]:not([data-dnd-dragging]) { + opacity: 0.42 !important; +} + /* Hide workspace cards when detail modal is open */ body:has(.card-detail-modal) .workspace-grid-container { opacity: 0; @@ -1165,3 +785,4 @@ body:has(.card-detail-modal) .workspace-grid-container { opacity: 1; } } + diff --git a/src/components/layout/WorkspaceLayout.tsx b/src/components/layout/WorkspaceLayout.tsx index 703192f95..25ac0a7e7 100644 --- a/src/components/layout/WorkspaceLayout.tsx +++ b/src/components/layout/WorkspaceLayout.tsx @@ -12,6 +12,7 @@ import { ChatPanel } from "@/components/chat/ChatPanel"; import { ChatProvider } from "@/components/chat/ChatProvider"; import { ComposerProvider } from "@/components/chat/composer-context"; import { WorkspaceCanvasDropzone } from "@/components/workspace-canvas/WorkspaceCanvasDropzone"; +import { WorkspaceDragDropProvider } from "@/components/workspace-canvas/WorkspaceDragDropProvider"; import { PANEL_DEFAULTS } from "@/lib/layout-constants"; import { useUIStore } from "@/lib/stores/ui-store"; import { useWorkspaceContext } from "@/contexts/WorkspaceContext"; @@ -76,30 +77,32 @@ export function WorkspaceLayout({ : "100%" } > -
- {isWorkspaceReady ? workspaceHeader : } -
- - - - - - {workspaceSection} - - + +
+ {isWorkspaceReady ? workspaceHeader : } +
+ + + + + + {workspaceSection} + + +
-
+ {effectiveChatExpanded && ( diff --git a/src/components/modals/CreateWebsiteDialog.tsx b/src/components/modals/CreateWebsiteDialog.tsx deleted file mode 100644 index f8eb93f5b..000000000 --- a/src/components/modals/CreateWebsiteDialog.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import { useState, useCallback, useEffect, useRef } from "react"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { toast } from "sonner"; - -interface CreateWebsiteDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onCreate: (url: string, name: string, favicon?: string) => void; -} - -/** - * Validates if a string is a valid HTTP/HTTPS URL - */ -function isValidUrl(str: string): boolean { - try { - const url = new URL(str.trim()); - return url.protocol === "http:" || url.protocol === "https:"; - } catch { - return false; - } -} - -/** - * Get the favicon URL for a given website URL using Google's favicon service - * Note: We use sz=64 to detect default globe icons (they stay 16x16 while real favicons scale) - */ -function getFaviconUrl(websiteUrl: string): string | undefined { - try { - const url = new URL(websiteUrl.trim()); - return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`; - } catch { - return undefined; - } -} - -/** - * Extract a display name from a URL (domain without www) - */ -function getDisplayName(websiteUrl: string): string { - try { - const url = new URL(websiteUrl.trim()); - return url.hostname.replace(/^www\./, ""); - } catch { - return "Website"; - } -} - -export function CreateWebsiteDialog({ - open, - onOpenChange, - onCreate, -}: CreateWebsiteDialogProps) { - const [url, setUrl] = useState(""); - const [name, setName] = useState(""); - const [isValid, setIsValid] = useState(false); - const [isLoadingTitle, setIsLoadingTitle] = useState(false); - const debounceTimerRef = useRef(null); - - // Validate URL as user types - useEffect(() => { - setIsValid(isValidUrl(url)); - }, [url]); - - // Auto-fill name from domain when a valid URL is entered - useEffect(() => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - - if (isValid && url.trim() && !name.trim()) { - debounceTimerRef.current = setTimeout(() => { - setName(getDisplayName(url)); - }, 300); - } - - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - }; - }, [url, isValid]); - - const handleSubmit = useCallback(() => { - if (!isValid || !url.trim()) { - toast.error("Please enter a valid website URL"); - return; - } - - const cardName = name.trim() || getDisplayName(url); - const favicon = getFaviconUrl(url); - onCreate(url.trim(), cardName, favicon); - - // Reset form - setUrl(""); - setName(""); - onOpenChange(false); - }, [url, name, isValid, onCreate, onOpenChange]); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && isValid) { - e.preventDefault(); - handleSubmit(); - } else if (e.key === 'Escape') { - onOpenChange(false); - } - }, [isValid, handleSubmit, onOpenChange]); - - // Reset form when dialog opens - useEffect(() => { - if (open) { - setUrl(""); - setName(""); - setIsValid(false); - setIsLoadingTitle(false); - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - } - }, [open]); - - return ( - - - - Embed Website - - Enter a website URL to embed it in your workspace. - - - -
-
- - setUrl(e.target.value)} - autoFocus - /> - {!isValid && url && ( -

- Please enter a valid URL (http:// or https://) -

- )} -
- -
- - setName(e.target.value)} - /> -

- Leave empty to use the domain name -

-
-
- - - - - -
-
- ); -} diff --git a/src/components/workspace-canvas/AudioCardContent.tsx b/src/components/workspace-canvas/AudioCardContent.tsx index 7a8c51ce1..9cee26fca 100644 --- a/src/components/workspace-canvas/AudioCardContent.tsx +++ b/src/components/workspace-canvas/AudioCardContent.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { useCurrentWorkspaceId } from "@/contexts/WorkspaceContext"; import { + AudioLines, Mic, AlertCircle, Loader2, @@ -43,6 +44,25 @@ function parseTimestamp(ts: string): number { return 0; } +function formatAudioDuration(seconds: number | undefined): string { + if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) { + return "Audio"; + } + + const totalSeconds = Math.round(seconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const remainingSeconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${remainingSeconds + .toString() + .padStart(2, "0")}`; + } + + return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; +} + const SPEAKER_COLORS = [ "bg-blue-500/15 text-blue-700 dark:text-blue-400", "bg-purple-500/15 text-purple-700 dark:text-purple-400", @@ -75,6 +95,33 @@ export function AudioCardContent({ const audioData = item.data as AudioData; const [isRetrying, setIsRetrying] = useState(false); + if (isCompact) { + const compactLabel = + audioData.processingStatus === "uploading" + ? "Uploading..." + : audioData.processingStatus === "processing" + ? "Processing..." + : audioData.processingStatus === "failed" + ? "Preview unavailable" + : formatAudioDuration(audioData.duration); + + return ( +
+
+ {audioData.processingStatus === "uploading" || + audioData.processingStatus === "processing" ? ( + + ) : audioData.processingStatus === "failed" ? ( + + ) : ( + + )} +
+

{compactLabel}

+
+ ); + } + const handleRetry = useCallback(() => { if (!audioData.fileUrl || !workspaceId || isRetrying) return; setIsRetrying(true); @@ -345,53 +392,6 @@ function AudioCardComplete({ // ── Compact view ──────────────────────────────────────────────────────── - if (isCompact) { - return ( -
- {audioData.processingStatus === "complete" && ( -
-

- Timeline -

- {isLoadingSegments && segments.length === 0 ? ( - - ) : transcriptError ? ( -
- Failed to load transcript. -
- ) : segments.length > 0 ? ( -
- {segments.slice(0, 3).map((segment, idx) => ( - - ))} - {segments.length > 3 && ( -
- ... {segments.length - 3} more timestamps -
- )} -
- ) : null} -
- )} -
- ); - } - // ── Non-compact view ──────────────────────────────────────────────────── return ( diff --git a/src/components/workspace-canvas/CardDialogs.tsx b/src/components/workspace-canvas/CardDialogs.tsx new file mode 100644 index 000000000..6dc58476a --- /dev/null +++ b/src/components/workspace-canvas/CardDialogs.tsx @@ -0,0 +1,266 @@ +"use client"; + +import type { ColorResult } from "react-color"; +import { SwatchesPicker } from "react-color"; +import type { Item } from "@/lib/workspace-state/types"; +import { SWATCHES_COLOR_GROUPS } from "@/lib/workspace-state/colors"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import MoveToDialog from "@/components/modals/MoveToDialog"; +import RenameDialog from "@/components/modals/RenameDialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; + +interface SharedCardDialogsProps { + item: Item; + allItems: Item[]; + workspaceName: string; + workspaceIcon?: string | null; + workspaceColor?: string | null; + isColorPickerOpen: boolean; + onColorPickerOpenChange: (open: boolean) => void; + showMoveDialog: boolean; + onMoveDialogChange: (open: boolean) => void; + showRenameDialog: boolean; + onRenameDialogChange: (open: boolean) => void; + onColorChange: (color: ColorResult) => void; + onRename: (newName: string) => void; + onMove?: (folderId: string | null) => void; + colorDialogTitle?: string; +} + +export function SharedCardDialogs({ + item, + allItems, + workspaceName, + workspaceIcon, + workspaceColor, + isColorPickerOpen, + onColorPickerOpenChange, + showMoveDialog, + onMoveDialogChange, + showRenameDialog, + onRenameDialogChange, + onColorChange, + onRename, + onMove, + colorDialogTitle = "Choose a Color", +}: SharedCardDialogsProps) { + return ( + <> + + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + > + + {colorDialogTitle} + +
event.stopPropagation()} + > + +
+
+
+ + + + {onMove ? ( + + ) : null} + + ); +} + +interface SimpleDeleteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + confirmLabel?: string; + onConfirm: () => void; +} + +export function SimpleDeleteDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = "Delete", + onConfirm, +}: SimpleDeleteDialogProps) { + return ( + + + + {title} + {description} + + + Cancel + + {confirmLabel} + + + + + ); +} + +interface FolderDeleteDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + itemName: string; + itemCount: number; + canDeleteContents: boolean; + deleteOption: "keep" | "delete" | null; + onDeleteOptionChange: (value: "keep" | "delete") => void; + onConfirm: () => void; + onCancel: () => void; +} + +export function FolderDeleteDialog({ + open, + onOpenChange, + itemName, + itemCount, + canDeleteContents, + deleteOption, + onDeleteOptionChange, + onConfirm, + onCancel, +}: FolderDeleteDialogProps) { + return ( + + e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + Delete Folder + +
+
+ Choose what happens to the {itemCount}{" "} + {itemCount === 1 ? "item" : "items"} in "{itemName} + ": +
+
+ + +
+
+
+
+ + { + e.stopPropagation(); + onCancel(); + }} + onMouseDown={(e) => e.stopPropagation()} + > + Cancel + + { + e.stopPropagation(); + onConfirm(); + }} + onMouseDown={(e) => e.stopPropagation()} + disabled={deleteOption === null} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50" + > + Delete + + +
+
+ ); +} diff --git a/src/components/workspace-canvas/CardRenderer.tsx b/src/components/workspace-canvas/CardRenderer.tsx deleted file mode 100644 index 70ebbb8b9..000000000 --- a/src/components/workspace-canvas/CardRenderer.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import type { - Item, - ItemData, - PdfData, - FlashcardData, - YouTubeData, - ImageData, - DocumentData, -} from "@/lib/workspace-state/types"; -import { DocumentEditor } from "@/components/editor/DocumentEditor"; -import FlashcardContent from "./FlashcardContent"; -import YouTubeCardContent from "./YouTubeCardContent"; -import ImageCardContent from "./ImageCardContent"; -import { AudioCardContent } from "./AudioCardContent"; - -import { QuizContent } from "./QuizContent"; - -export function CardRenderer(props: { - item: Item; - onUpdateData: (updater: (prev: ItemData) => ItemData) => void; - layoutKey?: string | number; - quizClassName?: string; // Optional padding/className for quiz when shown in modal -}) { - const { item, onUpdateData, quizClassName } = props; - - if (item.type === "pdf") { - const pdfData = item.data as PdfData; - return ( -
-

- PDF: {pdfData.filename || "Document"} -

-

- Use the PDF viewer to read this document -

-
- ); - } - - if (item.type === "flashcard") { - return ; - } - - if (item.type === "quiz") { - return ( - - ); - } - - if (item.type === "youtube") { - return ; - } - - if (item.type === "image") { - return ; - } - - if (item.type === "audio") { - return ; - } - - if (item.type === "website") { - // Website cards are rendered via WebsitePanelContent in the panel; - // this fallback is for generic/modal context - const websiteData = - item.data as import("@/lib/workspace-state/types").WebsiteData; - let hostname = ""; - try { - hostname = new URL(websiteData.url).hostname.replace(/^www\./, ""); - } catch {} - return ( -
- {websiteData.favicon ? ( - - ) : ( -
- - {hostname.charAt(0).toUpperCase()} - -
- )} - {hostname && ( - {hostname} - )} -
- ); - } - - if (item.type === "document") { - const documentData = item.data as DocumentData; - const md = documentData.markdown ?? ""; - return ( -
- { - onUpdateData(() => ({ - markdown, - })); - }} - /> -
- ); - } - - return ( -
-

- Unknown card type: {item.type} -

-
- ); -} - -export default CardRenderer; diff --git a/src/components/workspace-canvas/DocumentCardContent.tsx b/src/components/workspace-canvas/DocumentCardContent.tsx new file mode 100644 index 000000000..3c392c708 --- /dev/null +++ b/src/components/workspace-canvas/DocumentCardContent.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useMemo } from "react"; +import type { DocumentData, Item } from "@/lib/workspace-state/types"; +import { extractDocumentPreview } from "@/lib/markdown/extract-document-preview"; + +interface DocumentCardContentProps { + item: Item; +} + +export function DocumentCardContent({ item }: DocumentCardContentProps) { + const documentData = item.data as DocumentData; + const previewText = useMemo( + () => extractDocumentPreview(documentData.markdown ?? ""), + [documentData.markdown], + ); + + const hasPreview = previewText.length > 0; + + return ( +
+ {hasPreview ? ( +

+ {previewText} +

+ ) : ( +

+ Empty document +

+ )} +
+ ); +} + +export default DocumentCardContent; diff --git a/src/components/workspace-canvas/FlashcardCardContent.tsx b/src/components/workspace-canvas/FlashcardCardContent.tsx new file mode 100644 index 000000000..0b6a77b6c --- /dev/null +++ b/src/components/workspace-canvas/FlashcardCardContent.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { PiCardsThreeBold } from "react-icons/pi"; +import type { FlashcardData, Item } from "@/lib/workspace-state/types"; + +interface FlashcardCardContentProps { + item: Item; +} + +export function FlashcardCardContent({ item }: FlashcardCardContentProps) { + const flashcardData = item.data as FlashcardData; + const cardCount = flashcardData.cards?.length ?? 0; + + return ( +
+
+ +
+

+ {cardCount} card{cardCount === 1 ? "" : "s"} +

+
+ ); +} + +export default FlashcardCardContent; diff --git a/src/components/workspace-canvas/FlashcardContent.tsx b/src/components/workspace-canvas/FlashcardContent.tsx index b1c4cb10a..a81888cc6 100644 --- a/src/components/workspace-canvas/FlashcardContent.tsx +++ b/src/components/workspace-canvas/FlashcardContent.tsx @@ -1,94 +1,25 @@ "use client"; -import { useMemo } from "react"; -import type { - Item, - ItemData, - FlashcardData, -} from "@/lib/workspace-state/types"; -import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown"; +import { PiCardsThreeBold } from "react-icons/pi"; +import type { Item } from "@/lib/workspace-state/types"; interface FlashcardContentProps { item: Item; - onUpdateData: (updater: (prev: ItemData) => ItemData) => void; -} - -function FlashcardSidePreview({ - title, - markdown, - emptyLabel, -}: { - title: string; - markdown: string; - emptyLabel: string; -}) { - return ( -
-
- {title} -
-
- {!markdown.trim() ? ( -
- {emptyLabel} -
- ) : ( -
- - {markdown} - -
- )} -
-
- ); } export function FlashcardContent({ item }: FlashcardContentProps) { - const flashcardData = item.data as FlashcardData; - const cards = useMemo(() => flashcardData.cards ?? [], [flashcardData.cards]); - return ( -
-
- {cards.length === 0 ? ( -
- No flashcards available. -
- ) : ( -
- {cards.map((card, index) => ( -
-
-
- #{index + 1} -
-
- -
- - - -
-
- ))} -
- )} +
+
+ +
+

+ {item.name || "Flashcards"} +

+

+ Flashcards are coming soon. +

+
); diff --git a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx b/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx deleted file mode 100644 index 0e8dd24b3..000000000 --- a/src/components/workspace-canvas/FlashcardWorkspaceCard.tsx +++ /dev/null @@ -1,852 +0,0 @@ -"use client"; - -import { useState, useCallback, useEffect, useRef, useMemo, memo } from "react"; -import { toast } from "sonner"; -import { - MoreVertical, - Trash2, - CheckCircle2, - Pencil, - Palette, - ChevronLeft, - ChevronRight, - X, - FolderInput, -} from "lucide-react"; -import { PiMouseScrollFill, PiMouseScrollBold } from "react-icons/pi"; -import { useTheme } from "next-themes"; -import type { - Item, - FlashcardData, - FlashcardItem, -} from "@/lib/workspace-state/types"; -import { FlipCard } from "./FlipCard"; -import { - SWATCHES_COLOR_GROUPS, - getCardColorCSS, - getCardAccentColor, - type CardColor, -} from "@/lib/workspace-state/colors"; -import { SwatchesPicker, ColorResult } from "react-color"; -import { useUIStore, selectItemScrollLocked } from "@/lib/stores/ui-store"; -import { StreamdownMarkdown } from "@/components/ui/streamdown-markdown"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import MoveToDialog from "@/components/modals/MoveToDialog"; -import RenameDialog from "@/components/modals/RenameDialog"; -import { cn } from "@/lib/utils"; -interface FlashcardWorkspaceCardProps { - item: Item; - allItems?: Item[]; // All items for the move dialog tree - workspaceName?: string; - workspaceIcon?: string | null; - workspaceColor?: string | null; - onUpdateItem: (itemId: string, updates: Partial) => void; - onDeleteItem: (itemId: string) => void; - onOpenModal: (itemId: string) => void; - onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move item to folder - // NOTE: isSelected removed - card subscribes directly to store for performance - // onToggleSelection is still passed as a prop for the shift+click handler -} - -const EMPTY_FLASHCARD_PLACEHOLDER: FlashcardItem = { - id: "__empty__", - front: "", - back: "", -}; - -/** Center all markdown blocks on the card; keep code blocks full-width and left-aligned. */ -const FLASHCARD_STREAMDOWN_CLASS = cn( - // Fluid type: size comes from the nearest [container-type:size] ancestor (card face) - "font-medium max-w-none text-center leading-[1.45] text-[length:clamp(0.82rem,0.42rem+3cqmin,2.85rem)]", - // globals.css fixes .streamdown-content at 0.875rem — inherit this wrapper’s fluid size instead - "[&_.streamdown-content]:!text-inherit [&_.streamdown-content]:![font-size:1em]", - "[&_.streamdown-content_p]:!text-inherit [&_.streamdown-content_li]:!text-inherit [&_.streamdown-content_td]:!text-inherit [&_.streamdown-content_th]:!text-inherit", - "[&_.streamdown-content_h1]:!text-[1.32em] [&_.streamdown-content_h2]:!text-[1.2em] [&_.streamdown-content_h3]:!text-[1.1em] [&_.streamdown-content_h4]:!text-[1.04em]", - "[&_.streamdown-content_pre]:!text-[0.9em]", - "[&_.streamdown-content]:flex [&_.streamdown-content]:flex-col [&_.streamdown-content]:items-center [&_.streamdown-content]:text-center", - // Lists: drop outside gutter so the block sits in the centered column - "[&_.streamdown-content_ul]:!ml-0 [&_.streamdown-content_ul]:list-inside", - "[&_.streamdown-content_ol]:!ml-0 [&_.streamdown-content_ol]:list-inside", - // Wide / monospace blocks: stretch and align like normal editors - "[&_.streamdown-content_[data-streamdown=code-block]]:w-full [&_.streamdown-content_[data-streamdown=code-block]]:max-w-full [&_.streamdown-content_[data-streamdown=code-block]]:self-stretch [&_.streamdown-content_[data-streamdown=code-block]]:!text-left", - "[&_.streamdown-content_pre]:w-full [&_.streamdown-content_pre]:max-w-full [&_.streamdown-content_pre]:self-stretch [&_.streamdown-content_pre]:!text-left", - // Tables: use full width when present; cells follow markdown alignment when set - "[&_.streamdown-content_table]:w-full [&_.streamdown-content_table]:max-w-full [&_.streamdown-content_table]:self-stretch", -); - -/** Read-only markdown per side. */ -const FlashcardSideMarkdownView = memo( - function FlashcardSideMarkdownView({ - markdown, - isScrollLocked, - className = "", - }: { - markdown: string; - isScrollLocked: boolean; - className?: string; - }) { - return ( -
-
-
- {!markdown.trim() ? ( -
- Ask the AI or click the pencil icon to add flashcards -
- ) : ( - - {markdown} - - )} -
-
-
- ); - }, - (prev, next) => - prev.markdown === next.markdown && - prev.isScrollLocked === next.isScrollLocked && - prev.className === next.className, -); - -export function FlashcardWorkspaceCard({ - item, - allItems, - workspaceName, - workspaceIcon, - workspaceColor, - onUpdateItem, - onDeleteItem, - onOpenModal, - onMoveItem, -}: FlashcardWorkspaceCardProps) { - // Subscribe directly to this card's selection state from the store - // This prevents full grid re-renders when selection changes - const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); - const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - const { resolvedTheme } = useTheme(); - - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [showMoveDialog, setShowMoveDialog] = useState(false); - const [showRenameDialog, setShowRenameDialog] = useState(false); - const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); - const [isFlipped, setIsFlipped] = useState(false); - const [isFlipping, setIsFlipping] = useState(false); - // Get scroll lock state from Zustand store (persists across interactions) - const isScrollLocked = useUIStore(selectItemScrollLocked(item.id)); - const toggleItemScrollLocked = useUIStore( - (state) => state.toggleItemScrollLocked, - ); - const flashcardData = item.data as FlashcardData; - - // Navigation State - const [currentIndex, setCurrentIndex] = useState(0); - - const cards = useMemo( - () => flashcardData.cards ?? [], - [flashcardData.cards], - ); - - // Ensure index is valid - useEffect(() => { - if (currentIndex >= cards.length) { - setCurrentIndex(0); - } - }, [cards.length, currentIndex]); - - // Persist index change (optional debounce?) - const handleIndexChange = useCallback((newIndex: number) => { - // Don't persist on every click to avoid network spam, or do? - // For now just local state is smooth, maybe updating item is fine. - // Let's keep it local for now, prop update on unmount? - setCurrentIndex(newIndex); - }, []); - - const currentCard = useMemo((): FlashcardItem => { - if (cards.length === 0) { - return EMPTY_FLASHCARD_PLACEHOLDER; - } - const safeIndex = Math.min( - Math.max(0, currentIndex), - cards.length - 1, - ); - return cards[safeIndex] ?? EMPTY_FLASHCARD_PLACEHOLDER; - }, [cards, currentIndex]); - - // Flashcard flip animation duration (matches FlipCard component CSS) - const FLIP_ANIMATION_DURATION = 600; - - // Tracking for flip debounce - const lastFlipTimeRef = useRef(0); - - // Track minimal local drag detection (same pattern as WorkspaceCard) - const mouseDownRef = useRef<{ x: number; y: number } | null>(null); - const hasMovedRef = useRef(false); - const listenersActiveRef = useRef(false); - const DRAG_THRESHOLD = 10; // pixels - movement beyond this prevents flip - - // OPTIMIZED: Store handlers in refs so they can be added/removed dynamically - const handlersRef = useRef<{ - handleGlobalMouseMove: ((e: MouseEvent) => void) | null; - handleGlobalMouseUp: (() => void) | null; - }>({ handleGlobalMouseMove: null, handleGlobalMouseUp: null }); - - // Cleanup listeners on unmount - useEffect(() => { - return () => { - if ( - listenersActiveRef.current && - handlersRef.current.handleGlobalMouseMove && - handlersRef.current.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlersRef.current.handleGlobalMouseMove, - ); - document.removeEventListener( - "mouseup", - handlersRef.current.handleGlobalMouseUp, - ); - listenersActiveRef.current = false; - } - }; - }, []); - - const handleDelete = useCallback(() => { - setShowDeleteDialog(true); - }, []); - - const confirmDelete = useCallback(() => { - onDeleteItem(item.id); - setShowDeleteDialog(false); - }, [item.id, onDeleteItem]); - - const handleColorChange = useCallback( - (color: ColorResult) => { - onUpdateItem(item.id, { color: color.hex as CardColor }); - setIsColorPickerOpen(false); - }, - [item.id, onUpdateItem], - ); - - const handleRename = useCallback( - (newName: string) => { - onUpdateItem(item.id, { name: newName }); - toast.success("Flashcard renamed"); - }, - [item.id, onUpdateItem], - ); - - // Helper function to hide tabs during flip animation - const startFlipAnimation = useCallback(() => { - setIsFlipping(true); - setTimeout(() => setIsFlipping(false), FLIP_ANIMATION_DURATION); - }, []); - - // Debounced flip logic - const handleFlip = useCallback(() => { - const now = Date.now(); - if (now - lastFlipTimeRef.current < 200) return; - lastFlipTimeRef.current = now; - setIsFlipped((prev) => !prev); - startFlipAnimation(); - }, [startFlipAnimation]); - - // Handle mouse down - track initial position for drag detection - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - // Don't track if clicking on interactive elements - const target = e.target; - if (!(target instanceof HTMLElement)) return; - if ( - target.closest("button") || - target.closest(".flashcard-control-button") || - target.closest('[role="menuitem"]') - ) { - return; - } - - // Check if clicking inside a text selection area - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - e.stopPropagation(); - return; - } - - mouseDownRef.current = { x: e.clientX, y: e.clientY }; - hasMovedRef.current = false; - - // Only add global listeners when mouseDown occurs - if (!listenersActiveRef.current) { - const handleGlobalMouseMove = (e: MouseEvent) => { - if (!mouseDownRef.current) return; - - // Calculate movement delta - const deltaX = Math.abs(e.clientX - mouseDownRef.current.x); - const deltaY = Math.abs(e.clientY - mouseDownRef.current.y); - - if (hasMovedRef.current) { - return; - } - - // Check if user is selecting text - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) { - mouseDownRef.current = null; - hasMovedRef.current = false; - return; - } - - // Check if movement exceeds threshold (drag detected) - if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { - hasMovedRef.current = true; - } - }; - - const handleGlobalMouseUp = () => { - mouseDownRef.current = null; - // Clean up listeners - if ( - listenersActiveRef.current && - handlersRef.current.handleGlobalMouseMove && - handlersRef.current.handleGlobalMouseUp - ) { - document.removeEventListener( - "mousemove", - handlersRef.current.handleGlobalMouseMove, - ); - document.removeEventListener( - "mouseup", - handlersRef.current.handleGlobalMouseUp, - ); - listenersActiveRef.current = false; - handlersRef.current.handleGlobalMouseMove = null; - handlersRef.current.handleGlobalMouseUp = null; - } - }; - - handlersRef.current.handleGlobalMouseMove = handleGlobalMouseMove; - handlersRef.current.handleGlobalMouseUp = handleGlobalMouseUp; - document.addEventListener("mousemove", handleGlobalMouseMove); - document.addEventListener("mouseup", handleGlobalMouseUp); - listenersActiveRef.current = true; - } - }, - [DRAG_THRESHOLD], - ); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - // If unlocked, we are in "content mode" - allow text selection/scrolling, disable flip - if (!isScrollLocked) return; - - // Also prevent flip if user was selecting text (fallback check) - const selection = window.getSelection(); - if (selection && selection.toString().length > 0) return; - - // Shift+click toggles card selection - if (e.shiftKey) { - e.stopPropagation(); - onToggleSelection(item.id); - return; - } - - // Prevent flipping if user was dragging - const wasDragging = hasMovedRef.current; - hasMovedRef.current = false; // Reset immediately after checking - - if (wasDragging) { - e.preventDefault(); - e.stopPropagation(); - return; - } - - // Safe to flip - user clicked without dragging - handleFlip(); - }, - [handleFlip, isScrollLocked, onToggleSelection, item.id], - ); - - // Navigation Handlers - const goNext = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - const wasFlipped = isFlipped; // Check if we're currently on the back side - setIsFlipped(false); // Reset flip - // Only hide tabs if we were on the back side (will cause flip animation) - if (wasFlipped) { - startFlipAnimation(); - } - handleIndexChange((currentIndex + 1) % cards.length); - }, - [ - isFlipped, - startFlipAnimation, - handleIndexChange, - currentIndex, - cards.length, - ], - ); - - const goPrev = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - const wasFlipped = isFlipped; // Check if we're currently on the back side - setIsFlipped(false); // Reset flip - // Only hide tabs if we were on the back side (will cause flip animation) - if (wasFlipped) { - startFlipAnimation(); - } - handleIndexChange((currentIndex - 1 + cards.length) % cards.length); - }, - [ - isFlipped, - startFlipAnimation, - handleIndexChange, - currentIndex, - cards.length, - ], - ); - - // Calculate border styling to match WorkspaceCard - const borderColor = isSelected - ? "rgba(255, 255, 255, 0.8)" - : item.color - ? getCardAccentColor(item.color, resolvedTheme === "dark" ? 0.5 : 0.3) - : "transparent"; - const borderWidth = isSelected ? "3px" : "1px"; - const selectedBoxShadow = - isSelected && resolvedTheme !== "dark" - ? "0 0 3px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.5)" - : undefined; - const neutralControlBg = - resolvedTheme === "dark" ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)"; - const neutralControlHoverBg = "rgba(0, 0, 0, 0.5)"; - const selectedControlBg = isSelected - ? "rgba(239, 68, 68, 0.3)" - : neutralControlBg; - const selectedControlHoverBg = isSelected - ? "rgba(239, 68, 68, 0.5)" - : neutralControlHoverBg; - const getControlStyle = (backgroundColor: string) => ({ - backgroundColor, - backdropFilter: "blur(8px)", - }); - const createControlHoverHandlers = (baseColor: string, hoverColor: string) => ({ - onMouseEnter: (e: React.MouseEvent) => { - e.currentTarget.style.backgroundColor = hoverColor; - }, - onMouseLeave: (e: React.MouseEvent) => { - e.currentTarget.style.backgroundColor = baseColor; - }, - }); - - return ( - - -
- {/* Floating Controls */} -
- {/* Scroll Lock/Unlock Button */} - - - - - - - - - - - e.stopPropagation()} - > - setShowRenameDialog(true)}> - - Rename - - {onMoveItem && ( - <> - setShowMoveDialog(true)}> - - Move to - - - - )} - onOpenModal(item.id)}> - - Edit - - setIsColorPickerOpen(true)}> - - Change Color - - - - - Delete - - - -
- - - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - - Choose a Color - -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - -
-
-
- - {/* Navigation Controls - Only show if Multiple Cards */} - {cards.length > 1 && ( - <> - {/* Prev Button */} - - {/* Next Button */} - - - {/* Card Counter */} -
- {currentIndex + 1} / {cards.length} -
- - )} - - {/* Flashcard Stack Container */} -
- {/* Main Flashcard - takes up space minus the tabs */} -
- {/* Check for template-created items awaiting generation */} - {item.name === "Update me" && - (!flashcardData.cards || flashcardData.cards.length === 0) ? ( - // Generating skeleton for template-created flashcards -
-
-
- Generating flashcards... -
-
- ) : ( - - } - back={ - - } - color={item.color} - isFlipped={isFlipped} - borderColor={borderColor} - borderWidth={borderWidth} - selectedBoxShadow={selectedBoxShadow} - /> - )} - {/* Stack Tab 1 (directly below main card) - hidden during flip */} -
- {/* Stack Tab 2 (bottom-most, slightly narrower) - hidden during flip */} -
-
-
- - - e.stopPropagation()}> - - Delete Flashcard - - Are you sure you want to delete " - {item.name || "this flashcard"}"? You can restore from version - history if needed. - - - - Cancel - - Delete - - - - - - {/* Rename Dialog */} - - - {/* Move to Dialog */} - {onMoveItem && allItems && workspaceName && ( - { - onMoveItem(item.id, folderId); - toast.success("Item moved"); - }} - /> - )} -
- - - {/* Right-Click Context Menu */} - - setShowRenameDialog(true)}> - - Rename - - {onMoveItem && ( - <> - setShowMoveDialog(true)}> - - Move to - - - - )} - onOpenModal(item.id)}> - - Edit - - setIsColorPickerOpen(true)}> - - Change Color - - - - - Delete - - - - ); -} - -export default FlashcardWorkspaceCard; diff --git a/src/components/workspace-canvas/FlipCard.tsx b/src/components/workspace-canvas/FlipCard.tsx deleted file mode 100644 index 389c5c5a1..000000000 --- a/src/components/workspace-canvas/FlipCard.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; - -import { memo, type ReactNode, useMemo, useState, useEffect, useRef } from "react"; -import { getCardColorCSS, getCardAccentColor, type CardColor } from "@/lib/workspace-state/colors"; - -interface FlipCardProps { - front: ReactNode; - back: ReactNode; - className?: string; - color?: string; - isFlipped: boolean; - borderColor?: string; - borderWidth?: string; - selectedBoxShadow?: string; -} - -// Memoize the entire FlipCard to prevent unnecessary re-renders -export const FlipCard = memo(function FlipCard({ front, back, className = "", color, isFlipped, borderColor, borderWidth, selectedBoxShadow }: FlipCardProps) { - // Track animation state to control content visibility - const [isAnimating, setIsAnimating] = useState(false); - const [showFront, setShowFront] = useState(!isFlipped); - const animationTimeoutRef = useRef(null); - const prevFlippedRef = useRef(isFlipped); - - // Memoize colors to prevent recalculation - use same colors as WorkspaceCard - const cardBgColor = useMemo(() => - color ? getCardColorCSS(color as CardColor, 0.25) : 'var(--card)', - [color]); - - // Handle flip state changes - useEffect(() => { - if (prevFlippedRef.current !== isFlipped) { - prevFlippedRef.current = isFlipped; - setIsAnimating(true); - - // Clear any existing timeout - if (animationTimeoutRef.current) { - clearTimeout(animationTimeoutRef.current); - } - - // At the midpoint of the animation (300ms), switch which content is visible - animationTimeoutRef.current = setTimeout(() => { - setShowFront(!isFlipped); - }, 280); - - // Animation complete - setTimeout(() => { - setIsAnimating(false); - }, 600); - } - - return () => { - if (animationTimeoutRef.current) { - clearTimeout(animationTimeoutRef.current); - } - }; - }, [isFlipped]); - - // Static box shadow, merge with selected shadow if present - const boxShadow = selectedBoxShadow - ? `0 8px 30px rgba(0, 0, 0, 0.2), ${selectedBoxShadow}` - : "0 8px 30px rgba(0, 0, 0, 0.2)"; - - return ( -
-
- {/* Front Side */} -
- {/* Content wrapper with visibility control */} -
- {front} -
-
- - {/* Back Side */} -
- {/* Content wrapper with visibility control */} -
- {back} -
-
-
-
- ); -}, (prevProps, nextProps) => { - // Only re-render if these specific props change - return ( - prevProps.isFlipped === nextProps.isFlipped && - prevProps.color === nextProps.color && - prevProps.className === nextProps.className && - prevProps.front === nextProps.front && - prevProps.back === nextProps.back && - prevProps.borderColor === nextProps.borderColor && - prevProps.borderWidth === nextProps.borderWidth && - prevProps.selectedBoxShadow === nextProps.selectedBoxShadow - ); -}); - -export default FlipCard; diff --git a/src/components/workspace-canvas/FolderCard.tsx b/src/components/workspace-canvas/FolderCard.tsx index 6ba40c3bc..1641deb09 100644 --- a/src/components/workspace-canvas/FolderCard.tsx +++ b/src/components/workspace-canvas/FolderCard.tsx @@ -1,67 +1,45 @@ "use client"; -import { memo, useState, useCallback, useEffect, useRef } from "react"; -import { MoreVertical, Trash2, Palette, CheckCircle2, FolderInput, X, Pencil } from "lucide-react"; -import ItemHeader from "@/components/workspace-canvas/ItemHeader"; -import { SwatchesPicker, ColorResult } from "react-color"; +import { useDroppable } from "@dnd-kit/react"; +import { memo, useState, useCallback, useEffect, useMemo } from "react"; +import { type ColorResult } from "react-color"; import { cn } from "@/lib/utils"; import type { Item } from "@/lib/workspace-state/types"; -import { getCardColorCSS, getCardAccentColor, SWATCHES_COLOR_GROUPS, type CardColor } from "@/lib/workspace-state/colors"; +import { + getCardColorCSS, + getCardAccentColor, + type CardColor, +} from "@/lib/workspace-state/colors"; import { useTheme } from "next-themes"; - import { useUIStore } from "@/lib/stores/ui-store"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { ContextMenu, ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import MoveToDialog from "@/components/modals/MoveToDialog"; -import RenameDialog from "@/components/modals/RenameDialog"; import { toast } from "sonner"; +import { + WorkspaceCardContextMenuItems, + WorkspaceCardControls, +} from "./WorkspaceCardActions"; +import { FolderDeleteDialog, SharedCardDialogs } from "./CardDialogs"; +import { useCardActionState } from "./useCardActionState"; interface FolderCardProps { - item: Item; // Folder-type item + item: Item; itemCount: number; - allItems: Item[]; // All items for the move dialog tree + allItems: Item[]; workspaceName: string; workspaceIcon?: string | null; workspaceColor?: string | null; onOpenFolder: (folderId: string) => void; onUpdateItem: (itemId: string, updates: Partial) => void; onDeleteItem: (itemId: string) => void; - onDeleteFolderWithContents?: (folderId: string) => void; // Callback to delete folder and all items inside - onMoveItem?: (itemId: string, folderId: string | null) => void; // Callback to move folder to another location + onDeleteFolderWithContents?: (folderId: string) => void; + onMoveItem?: (itemId: string, folderId: string | null) => void; + itemDropTargetId?: string; } -/** - * FolderCard - A folder-shaped card that displays in the workspace grid - * Now uses Item type with type: 'folder' instead of separate Folder type - */ function FolderCardComponent({ item, itemCount, @@ -74,517 +52,329 @@ function FolderCardComponent({ onDeleteItem, onDeleteFolderWithContents, onMoveItem, + itemDropTargetId, }: FolderCardProps) { - const [showColorPicker, setShowColorPicker] = useState(false); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [deleteOption, setDeleteOption] = useState<'keep' | 'delete' | null>(null); - const [showMoveDialog, setShowMoveDialog] = useState(false); - const [showRenameDialog, setShowRenameDialog] = useState(false); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isDragHover, setIsDragHover] = useState(false); - const [selectedCount, setSelectedCount] = useState(null); - const [isEditingTitle, setIsEditingTitle] = useState(false); - - // Subscribe directly to this folder's selection state from the store - const isSelected = useUIStore( - (state) => state.selectedCardIds.has(item.id) + const { + isColorPickerOpen, + setIsColorPickerOpen, + showDeleteDialog, + setShowDeleteDialog, + showMoveDialog, + setShowMoveDialog, + showRenameDialog, + setShowRenameDialog, + openColorPicker, + openDeleteDialog, + openMoveDialog, + openRenameDialog, + } = useCardActionState(); + const [deleteOption, setDeleteOption] = useState<"keep" | "delete" | null>( + null, ); - const onToggleSelection = useUIStore((state) => state.toggleCardSelection); - // Track drag movement to prevent opening folder after drag - const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null); - const hasMovedRef = useRef(false); - const DRAG_THRESHOLD = 5; // pixels - - const folderColor = item.color || "#6366F1"; // Default to indigo + const isSelected = useUIStore((state) => state.selectedCardIds.has(item.id)); + const onToggleSelection = useUIStore((state) => state.toggleCardSelection); + const { resolvedTheme } = useTheme(); - // Listen for drag hover events - useEffect(() => { - const handleDragHover = (e: Event) => { - const customEvent = e as CustomEvent; - const { folderId, isHovering, selectedCount: count } = customEvent.detail || {}; - if (folderId === item.id) { - setIsDragHover(isHovering); - setSelectedCount(count ?? null); - } else { - setIsDragHover(false); - setSelectedCount(null); - } - }; + const folderColor = item.color || "#6366F1"; + + const pointerOnlyCollision = useMemo( + () => + ({ + dragOperation, + droppable, + }: { + dragOperation: { + source?: { id?: string | number } | null; + position: { current: { x: number; y: number } | null }; + }; + droppable: { + id: string | number; + shape?: { + containsPoint: (point: { x: number; y: number }) => boolean; + center: { x: number; y: number }; + } | null; + }; + }) => { + if (dragOperation.source?.id === item.id) return null; + const pointer = dragOperation.position.current; + if (!pointer || !droppable.shape) return null; + if (!droppable.shape.containsPoint(pointer)) return null; + const cx = droppable.shape.center.x - pointer.x; + const cy = droppable.shape.center.y - pointer.y; + return { + id: droppable.id, + value: 1 / Math.sqrt(cx * cx + cy * cy), + type: 2, + priority: 3, + }; + }, + [item.id], + ); - window.addEventListener('folder-drag-hover', handleDragHover); - return () => { - window.removeEventListener('folder-drag-hover', handleDragHover); - }; - }, [item.id]); + const { ref: dropTargetRef, isDropTarget: isItemDropTarget } = useDroppable({ + id: itemDropTargetId ?? `folder-drop:${item.id}`, + accept: ["item", "folder"], + collisionPriority: 4, + collisionDetector: pointerOnlyCollision, + data: { + kind: "folder-card-drop-target", + folderId: item.id, + }, + }); - // Handle mouse down - track initial position for drag detection - const handleMouseDown = useCallback((e: React.MouseEvent) => { - // Don't track if clicking on interactive elements - const target = e.target; - if (!(target instanceof HTMLElement)) return; - if ( - target.closest('button') || - target.closest('input') || - target.closest('textarea') || - target.closest('[role="menuitem"]') - ) { - return; + useEffect(() => { + if (!showDeleteDialog) { + setDeleteOption(null); } - mouseDownPosRef.current = { x: e.clientX, y: e.clientY }; - hasMovedRef.current = false; + }, [showDeleteDialog]); + + const isInteractiveTarget = useCallback((target: HTMLElement) => { + return Boolean( + target.closest("button") || + target.closest("input") || + target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || + target.closest('[role="menuitem"]') || + target.closest('[data-slot="dropdown-menu-content"]') || + target.closest('[data-slot="dropdown-menu-trigger"]') || + target.closest('[data-slot="popover-content"]') || + target.closest('[data-slot="popover"]') || + target.closest('[data-slot="dialog-content"]') || + target.closest('[data-slot="dialog-close"]') || + target.closest('[data-slot="dialog-overlay"]'), + ); }, []); - // Handle mouse move - detect if user moved beyond threshold - const handleMouseMove = useCallback((e: React.MouseEvent) => { - if (!mouseDownPosRef.current || hasMovedRef.current) return; + const handleClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { + return; + } - const deltaX = Math.abs(e.clientX - mouseDownPosRef.current.x); - const deltaY = Math.abs(e.clientY - mouseDownPosRef.current.y); + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + return; + } - if (deltaX > DRAG_THRESHOLD || deltaY > DRAG_THRESHOLD) { - hasMovedRef.current = true; - } - }, []); + if (e.shiftKey) { + e.stopPropagation(); + onToggleSelection(item.id); + return; + } - // Handle click - only open folder if it wasn't a drag - const handleClick = useCallback((e: React.MouseEvent) => { - // Don't open if clicking on interactive elements - const target = e.target; - if (!(target instanceof HTMLElement)) return; - if ( - target.closest('button') || - target.closest('[data-slot="dropdown-menu-content"]') || - target.closest('[data-slot="dropdown-menu-trigger"]') || - target.closest('[data-slot="dialog-content"]') || - target.closest('[data-slot="dialog-close"]') || - target.closest('[data-slot="dialog-overlay"]') - ) { - return; - } + onOpenFolder(item.id); + }, + [isInteractiveTarget, item.id, onOpenFolder, onToggleSelection], + ); - // Shift+click toggles folder selection - if (e.shiftKey) { - e.stopPropagation(); - onToggleSelection(item.id); - return; - } + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Enter" && e.key !== " ") { + return; + } - // Don't open if user was dragging or is editing title - if (hasMovedRef.current || isEditingTitle) { - hasMovedRef.current = false; - mouseDownPosRef.current = null; - return; - } + const target = e.target as HTMLElement; + if (isInteractiveTarget(target)) { + return; + } + + e.preventDefault(); - mouseDownPosRef.current = null; - onOpenFolder(item.id); - }, [item.id, onOpenFolder, onToggleSelection, isEditingTitle]); + if (e.shiftKey) { + onToggleSelection(item.id); + return; + } + + onOpenFolder(item.id); + }, + [isInteractiveTarget, item.id, onOpenFolder, onToggleSelection], + ); const handleColorChange = useCallback( (color: ColorResult) => { onUpdateItem(item.id, { color: color.hex as CardColor }); - setShowColorPicker(false); + setIsColorPickerOpen(false); }, - [item.id, onUpdateItem] + [item.id, onUpdateItem, setIsColorPickerOpen], ); - // Handlers for inline title editing (like WorkspaceCard) - const handleNameChange = useCallback((v: string) => { - onUpdateItem(item.id, { name: v }); - }, [item.id, onUpdateItem]); - - const handleNameCommit = useCallback((v: string) => { - onUpdateItem(item.id, { name: v }); - }, [item.id, onUpdateItem]); + const handleRename = useCallback( + (newName: string) => { + onUpdateItem(item.id, { name: newName }); + toast.success("Folder renamed"); + }, + [item.id, onUpdateItem], + ); const handleDelete = useCallback(() => { - if (deleteOption === 'delete' && onDeleteFolderWithContents) { + if (deleteOption === "delete" && onDeleteFolderWithContents) { onDeleteFolderWithContents(item.id); } else { onDeleteItem(item.id); } - setShowDeleteConfirm(false); + setShowDeleteDialog(false); setDeleteOption(null); - }, [item.id, onDeleteItem, onDeleteFolderWithContents, deleteOption]); - - const handleRename = useCallback((newName: string) => { - onUpdateItem(item.id, { name: newName }); - toast.success("Folder renamed"); - }, [item.id, onUpdateItem]); - - // Reset delete option when dialog closes - useEffect(() => { - if (!showDeleteConfirm) { - setDeleteOption(null); - } - }, [showDeleteConfirm]); - - const { resolvedTheme } = useTheme(); - - // Calculate colors using the same utilities as WorkspaceCard - const bodyBgColor = getCardColorCSS(folderColor, 0.25); // Body is more transparent - const tabBgColor = getCardColorCSS(folderColor, 0.35); // Tab is slightly less transparent - const borderColor = isSelected ? 'rgba(255, 255, 255, 0.8)' : getCardAccentColor(folderColor, 0.5); - // Selection ring on outer wrapper (like normal cards) – avoids overflow-hidden clipping - // Light mode: match resize-handle style with layered dark shadow for visibility - const selectedRingStyle = isSelected - ? { - boxShadow: resolvedTheme === 'dark' - ? '0 0 0 3px rgba(255, 255, 255, 0.8)' - : '0 0 0 3px rgba(255, 255, 255, 0.8), 0 0 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.8), 0 0 8px rgba(0, 0, 0, 0.6), 0 0 12px rgba(0, 0, 0, 0.4)', - } + }, [ + deleteOption, + item.id, + onDeleteFolderWithContents, + onDeleteItem, + setShowDeleteDialog, + ]); + + const bodyBgColor = getCardColorCSS(folderColor, 0.25); + const tabBgColor = getCardColorCSS(folderColor, 0.35); + const borderColor = isSelected + ? "rgba(255, 255, 255, 0.8)" + : getCardAccentColor(folderColor, 0.5); + const folderTitle = item.name || "Folder"; + const selectedOutlineWidth = isSelected ? "3px" : "1px"; + const selectedFolderGlow = isSelected + ? resolvedTheme === "dark" + ? "0 0 10px rgba(255, 255, 255, 0.2)" + : "0 0 2px rgba(0, 0, 0, 0.7), 0 0 6px rgba(0, 0, 0, 0.35)" : undefined; + const itemCountLabel = `${itemCount} item${itemCount === 1 ? "" : "s"}`; return (
void} + className="group flex size-full min-h-0 flex-col rounded-md transition-[box-shadow,transform] duration-150" onClick={handleClick} + onKeyDown={handleKeyDown} + role="button" + tabIndex={0} >
- {/* Folder tab - top left, more transparent than body */}
- {/* Main folder body - starts where tab ends, less transparent */}
- {/* Selection Button */} - - - e.stopPropagation()}> - { - e.stopPropagation(); - setShowRenameDialog(true); - }} - > - - Rename - - {onMoveItem && ( - <> - { - e.stopPropagation(); - setShowMoveDialog(true); - }} - > - - Move to - - - - )} - { - e.stopPropagation(); - setShowColorPicker(true); - }} - > - - Change Color - - - { - e.stopPropagation(); - setShowDeleteConfirm(true); - }} - className="text-destructive focus:text-destructive" - > - - Delete Folder - - - - - {/* Content container */} -
- - {/* Editable title - like WorkspaceCard */} -
- { }} - onTitleFocus={() => setIsEditingTitle(true)} - onTitleBlur={() => setIsEditingTitle(false)} - readOnly={false} - noMargin={true} - /> - {/* Item count as subtext */} -

- {itemCount} {itemCount === 1 ? "item" : "items"} -

-
-
- - {/* Hover overlay - covers the main body area */} -
- - {/* Drag hover overlay - shows when item is dragged over folder */} - {isDragHover && ( -
-
- Move items here ({(selectedCount ?? 1)} {(selectedCount ?? 1) === 1 ? 'item' : 'items'}) -
-
- )} -
+ /> + {isItemDropTarget ? ( +
+ ) : null} - {/* Color Picker Dialog - same styling as WorkspaceCard */} - - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} +
- - Choose Folder Color - -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - -
- -
+ onToggleSelection(item.id)} + onOpenRename={openRenameDialog} + onOpenMove={openMoveDialog} + onOpenColorPicker={openColorPicker} + onDelete={openDeleteDialog} + /> +
- {/* Delete Confirmation Dialog */} - - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - - Delete Folder - -
-
- Choose what happens to the {itemCount} {itemCount === 1 ? 'item' : 'items'} in "{item.name}": -
-
- - -
-
-
-
- - { - e.stopPropagation(); - setDeleteOption(null); - }} - onMouseDown={(e) => e.stopPropagation()} - > - Cancel - - +
+
{ + e.preventDefault(); e.stopPropagation(); - handleDelete(); + openRenameDialog(); }} - onMouseDown={(e) => e.stopPropagation()} - disabled={deleteOption === null} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 disabled:cursor-not-allowed" > - Delete - - - - + {folderTitle} +
+
+ {itemCountLabel} +
+
+
+
- {/* Rename Dialog */} - { + onMoveItem(item.id, folderId); + toast.success("Folder moved"); + } + : undefined + } + colorDialogTitle="Choose Folder Color" + /> + setDeleteOption(null)} /> - - {/* Move to Dialog */} - {onMoveItem && ( - { - onMoveItem(item.id, folderId); - toast.success('Folder moved'); - }} - /> - )}
- {/* Right-Click Context Menu */} - setShowRenameDialog(true)}> - - Rename - - {onMoveItem && ( - <> - setShowMoveDialog(true)}> - - Move to - - - - )} - setShowColorPicker(true)}> - - Change Color - - - setShowDeleteConfirm(true)} - className="text-destructive focus:text-destructive" - > - - Delete Folder - + - + ); } export const FolderCard = memo(FolderCardComponent); - diff --git a/src/components/workspace-canvas/ImageCardContent.tsx b/src/components/workspace-canvas/ImageCardContent.tsx index 51d786498..b0765f0b8 100644 --- a/src/components/workspace-canvas/ImageCardContent.tsx +++ b/src/components/workspace-canvas/ImageCardContent.tsx @@ -1,63 +1,48 @@ "use client"; -import { useState } from "react"; +import Image from "next/image"; import { ImageIcon, ImageOffIcon } from "lucide-react"; -import type { Item, ImageData } from "@/lib/workspace-state/types"; -import { cn } from "@/lib/utils"; +import { useState } from "react"; +import type { ImageData, Item } from "@/lib/workspace-state/types"; interface ImageCardContentProps { - item: Item; + item: Item; } export function ImageCardContent({ item }: ImageCardContentProps) { - const imageData = item.data as ImageData; - const [isHovering, setIsHovering] = useState(false); - const [loaded, setLoaded] = useState(false); - const [hasError, setHasError] = useState(false); + const imageData = item.data as ImageData; + const [hasError, setHasError] = useState(false); - const src = imageData.url; - const alt = imageData.altText || item.name || "Image"; + const src = imageData.url; + const alt = imageData.altText || item.name || "Image"; + if (hasError) { return ( -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - > - {!loaded && !hasError && ( -
- -
- )} - {hasError ? ( -
- -

- Image couldn't be displayed. HEIC/HEIF may not work in all browsers. -

-
- ) : ( - {alt} setLoaded(true)} - onError={() => setHasError(true)} - /> - )} +
+ +

Preview unavailable

+
+ ); + } - {/* Optional: Caption overlay on hover */} - {imageData.caption && isHovering && !hasError && ( -
- {imageData.caption} -
- )} + return ( +
+ {!src ? ( +
+
- ); + ) : ( + {alt} setHasError(true)} + /> + )} +
+ ); } export default ImageCardContent; diff --git a/src/components/workspace-canvas/ItemHeader.tsx b/src/components/workspace-canvas/ItemHeader.tsx index bc4a17cf7..1127c91c3 100644 --- a/src/components/workspace-canvas/ItemHeader.tsx +++ b/src/components/workspace-canvas/ItemHeader.tsx @@ -8,8 +8,8 @@ export function ItemHeader(props: { name: string; subtitle: string; description: string; - onNameChange: (value: string) => void; - onSubtitleChange: (value: string) => void; + onNameChange?: (value: string) => void; + onSubtitleChange?: (value: string) => void; onNameCommit?: (value: string) => void; onDescriptionCommit?: (value: string) => void; readOnly?: boolean; @@ -115,7 +115,7 @@ export function ItemHeader(props: { if (value !== name) { if (onNameCommit) { onNameCommit(value); - } else { + } else if (onNameChange) { onNameChange(value); } } @@ -235,4 +235,3 @@ export function ItemHeader(props: { export default ItemHeader; - diff --git a/src/components/workspace-canvas/ItemPanelContent.tsx b/src/components/workspace-canvas/ItemPanelContent.tsx index 88ebed0ad..fdedb584f 100644 --- a/src/components/workspace-canvas/ItemPanelContent.tsx +++ b/src/components/workspace-canvas/ItemPanelContent.tsx @@ -1,21 +1,17 @@ "use client"; -import { useState } from "react"; import { X } from "lucide-react"; import { LuMinimize2 } from "react-icons/lu"; import ChatFloatingButton from "@/components/chat/ChatFloatingButton"; import ItemHeader from "@/components/workspace-canvas/ItemHeader"; -import CardRenderer from "@/components/workspace-canvas/CardRenderer"; -import LazyAppPdfViewer from "@/components/pdf/LazyAppPdfViewer"; -import LazyImageViewer from "@/components/image-viewer/LazyImageViewer"; -import { ImagePanelHeader } from "@/components/image-viewer/ImagePanelHeader"; -import { YouTubePanelContent } from "@/components/workspace-canvas/YouTubePanelContent"; -import { WebsitePanelContent } from "@/components/workspace-canvas/WebsitePanelContent"; -import { PdfPanelHeader } from "@/components/pdf/PdfPanelHeader"; import { getCardColorCSS, getWhiteTintedColor } from "@/lib/workspace-state/colors"; -import type { Item, ItemData, PdfData, ImageData } from "@/lib/workspace-state/types"; +import type { Item, ItemData, PdfData } from "@/lib/workspace-state/types"; import { useUIStore } from "@/lib/stores/ui-store"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { + usesManagedWorkspacePanelLayout, + WorkspacePanelItemPreview, +} from "@/components/workspace-canvas/WorkspaceItemView"; interface ItemPanelContentProps { item: Item; @@ -40,15 +36,10 @@ export function ItemPanelContent({ const isDesktop = true; const isPdf = item.type === 'pdf'; - const isYouTube = item.type === 'youtube'; - const isWebsite = item.type === 'website'; - const isImage = item.type === 'image'; const pdfPreviewUrl = isPdf ? (item.data as PdfData).fileUrl : undefined; /** PDF cards still processing upload have no viewer yet — need a local title bar. */ const showPdfAwaitingFileHeader = isPdf && !pdfPreviewUrl; - const [showThumbnails, setShowThumbnails] = useState(false); - const renderPdfAwaitingFileHeader = () => (
@@ -122,83 +113,20 @@ export function ItemPanelContent({ {showPdfAwaitingFileHeader && renderPdfAwaitingFileHeader()}
- {isPdf && pdfPreviewUrl ? ( -
- = 1 && - !citationHighlightQuery?.query?.trim() - ? citationHighlightQuery.pageNumber - : undefined - } - isMaximized={true} - renderHeader={(documentId, annotationControls) => ( -
- setShowThumbnails(!showThumbnails)} - renderInPortal={true} - /> -
- )} - /> -
- ) : isYouTube ? ( - - ) : isWebsite ? ( - - ) : isImage ? ( -
- ( - - )} - /> -
- ) : ( - - )} +
); diff --git a/src/components/workspace-canvas/MarqueeSelector.tsx b/src/components/workspace-canvas/MarqueeSelector.tsx index 3952b1173..36ca9ad9c 100644 --- a/src/components/workspace-canvas/MarqueeSelector.tsx +++ b/src/components/workspace-canvas/MarqueeSelector.tsx @@ -1,27 +1,24 @@ "use client"; -import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import React, { useState, useCallback, useRef, useEffect } from "react"; import { createPortal } from "react-dom"; import { useUIStore } from "@/lib/stores/ui-store"; import { useSelectedCardIds } from "@/hooks/ui/use-selected-card-ids"; -import { createRectFromPoints, getIntersectingCards, type Rectangle } from "@/lib/utils/marquee-utils"; +import { + createRectFromPoints, + getIntersectingCards, + type Rectangle, +} from "@/lib/utils/marquee-utils"; import { useAutoScroll } from "@/hooks/ui/use-auto-scroll"; interface MarqueeSelectorProps { scrollContainerRef: React.RefObject; cardIds: string[]; - isGridDragging: boolean; } -/** - * MarqueeSelector component for rectangular card selection - * Handles mouse events to create a selection rectangle and select intersecting cards - * Features autoscroll when selecting near container edges - */ export function MarqueeSelector({ scrollContainerRef, cardIds, - isGridDragging, }: MarqueeSelectorProps) { const [isSelecting, setIsSelecting] = useState(false); const [marqueeRect, setMarqueeRect] = useState(null); @@ -32,260 +29,251 @@ export function MarqueeSelector({ const isDraggingRef = useRef(false); const startPointRef = useRef({ x: 0, y: 0 }); - // Use autoscroll hook for smooth scrolling during selection - const { handleDragStart: startAutoScroll, handleDragStop: stopAutoScroll } = useAutoScroll( - scrollContainerRef as React.RefObject - ); + const { handleDragStart: startAutoScroll, handleDragStop: stopAutoScroll } = + useAutoScroll(scrollContainerRef as React.RefObject); + + const isInteractiveTarget = useCallback((target: HTMLElement) => { + return Boolean( + target.closest('[id^="item-"]') || + target.closest("button") || + target.closest('[role="button"]') || + target.closest("input") || + target.closest("textarea") || + target.closest("select") || + target.closest("a") || + target.closest("label") || + target.closest('[role="menuitem"]') || + target.closest('[contenteditable="true"]') || + target.closest('[data-slot="dropdown-menu-content"]') || + target.closest('[data-slot="dropdown-menu-trigger"]') || + target.closest('[data-slot="popover-content"]') || + target.closest('[data-slot="popover"]') || + target.closest('[data-slot="dialog-content"]') || + target.closest('[data-slot="dialog-close"]') || + target.closest('[data-slot="dialog-overlay"]') || + target.isContentEditable, + ); + }, []); - // Cancel marquee selection if grid starts dragging - useEffect(() => { - if (isGridDragging && isSelecting) { - setIsSelecting(false); - setMarqueeRect(null); - isDraggingRef.current = false; - stopAutoScroll(); // Stop autoscroll when canceling - } - }, [isGridDragging, isSelecting, stopAutoScroll]); + const canStartMarqueeFromEvent = useCallback( + (e: MouseEvent) => { + const container = scrollContainerRef.current; + const target = e.target; + + if (!container || !(target instanceof HTMLElement)) { + return false; + } + + if (!container.contains(target)) { + return false; + } + + return !isInteractiveTarget(target); + }, + [isInteractiveTarget, scrollContainerRef], + ); - // Add/remove class on body to disable pointer events on iframes during marquee selection useEffect(() => { if (isSelecting) { - document.body.classList.add('marquee-selecting'); + document.body.classList.add("marquee-selecting"); } else { - document.body.classList.remove('marquee-selecting'); + document.body.classList.remove("marquee-selecting"); } return () => { - document.body.classList.remove('marquee-selecting'); + document.body.classList.remove("marquee-selecting"); }; }, [isSelecting]); - // Helper to start marquee selection - const startMarqueeSelection = useCallback((e: MouseEvent) => { - const container = scrollContainerRef.current; - if (!container) return false; - - const containerRect = container.getBoundingClientRect(); - - // Check if click is within the container bounds - if ( - e.clientX < containerRect.left || - e.clientX > containerRect.right || - e.clientY < containerRect.top || - e.clientY > containerRect.bottom - ) { - return false; - } - - // Robust scrollbar detection: calculate actual scrollbar dimensions - // offsetWidth/Height includes scrollbar, clientWidth/Height excludes it - const scrollbarWidth = container.offsetWidth - container.clientWidth; - const scrollbarHeight = container.offsetHeight - container.clientHeight; - - // Check if clicking on scrollbar by comparing click position with client area - // For vertical scrollbar: check if click is to the right of the client area - const clickXRelativeToContainer = e.clientX - containerRect.left; - const isVerticalScrollbar = scrollbarWidth > 0 && clickXRelativeToContainer > container.clientWidth; + const startMarqueeSelection = useCallback( + (e: MouseEvent) => { + const container = scrollContainerRef.current; + if (!container) return false; + + const containerRect = container.getBoundingClientRect(); + if ( + e.clientX < containerRect.left || + e.clientX > containerRect.right || + e.clientY < containerRect.top || + e.clientY > containerRect.bottom + ) { + return false; + } - // For horizontal scrollbar: check if click is below the client area - const clickYRelativeToContainer = e.clientY - containerRect.top; - const isHorizontalScrollbar = scrollbarHeight > 0 && clickYRelativeToContainer > container.clientHeight; + const scrollbarWidth = container.offsetWidth - container.clientWidth; + const scrollbarHeight = container.offsetHeight - container.clientHeight; + const clickXRelativeToContainer = e.clientX - containerRect.left; + const clickYRelativeToContainer = e.clientY - containerRect.top; + const isVerticalScrollbar = + scrollbarWidth > 0 && clickXRelativeToContainer > container.clientWidth; + const isHorizontalScrollbar = + scrollbarHeight > 0 && clickYRelativeToContainer > container.clientHeight; + + if (isHorizontalScrollbar || isVerticalScrollbar) { + return false; + } - // Don't start marquee if clicking on scrollbar - if (isHorizontalScrollbar || isVerticalScrollbar) { - return false; - } + const x = e.clientX - containerRect.left + container.scrollLeft; + const y = e.clientY - containerRect.top + container.scrollTop; - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; - - startPointRef.current = { x, y }; - setIsSelecting(true); - isDraggingRef.current = true; - - // Start autoscroll for marquee selection - startAutoScroll(); - - // Prevent text selection and other drag behaviors during marquee - e.preventDefault(); - e.stopPropagation(); - - return true; - }, [scrollContainerRef, startAutoScroll]); - - const handleMouseDown = useCallback((e: MouseEvent) => { - // Only start marquee on left click - if (e.button !== 0) return; - - // Don't start if clicking on a card or interactive element - const target = e.target; - if (!(target instanceof HTMLElement)) return; - - // Clear native text selection when clicking on workspace background - // (MarqueeSelector captures background clicks before they reach WorkspaceSection) - window.getSelection()?.removeAllRanges(); - - // Check if clicking on any interactive or grid element - if ( - target.closest('article') || // Card itself - target.closest('button') || // Any button - target.closest('[role="button"]') || // Button role elements - target.closest('.react-grid-item') || // Grid item wrapper - target.classList.contains('drag-handle') || // Drag handle - target.tagName === 'INPUT' || // Input fields - target.tagName === 'TEXTAREA' // Text areas - ) { - return; - } + startPointRef.current = { x, y }; + setIsSelecting(true); + isDraggingRef.current = true; + startAutoScroll(); - startMarqueeSelection(e); - }, [startMarqueeSelection]); + e.preventDefault(); + e.stopPropagation(); - // Global shift+drag handler - activates marquee from ANYWHERE in the app when Shift is held - // Takes precedence over card dragging and other interactions - const handleGlobalShiftMouseDown = useCallback((e: MouseEvent) => { - // Only activate on left click with Shift held - if (e.button !== 0 || !e.shiftKey) return; + return true; + }, + [scrollContainerRef, startAutoScroll], + ); - // Don't interfere with text inputs - const target = e.target; - if (!(target instanceof HTMLElement)) return; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { - return; - } + const handleMouseDown = useCallback( + (e: MouseEvent) => { + if (e.button !== 0) return; - const container = scrollContainerRef.current; - if (!container) return; + window.getSelection()?.removeAllRanges(); - const containerRect = container.getBoundingClientRect(); + if (!canStartMarqueeFromEvent(e)) { + return; + } - // Use raw mouse position - marquee can start from anywhere - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; + startMarqueeSelection(e); + }, + [canStartMarqueeFromEvent, startMarqueeSelection], + ); - startPointRef.current = { x, y }; - setIsSelecting(true); - isDraggingRef.current = true; + const handleGlobalShiftMouseDown = useCallback( + (e: MouseEvent) => { + if (e.button !== 0 || !e.shiftKey) return; - // Start autoscroll for marquee selection - startAutoScroll(); + if (!canStartMarqueeFromEvent(e)) { + return; + } - // Prevent text selection and other drag behaviors during marquee - e.preventDefault(); - e.stopPropagation(); - }, [scrollContainerRef, startAutoScroll]); + startMarqueeSelection(e); + }, + [canStartMarqueeFromEvent, startMarqueeSelection], + ); - const handleMouseMove = useCallback((e: MouseEvent) => { - if (!isDraggingRef.current || isGridDragging) return; + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isDraggingRef.current) return; - const container = scrollContainerRef.current; - if (!container) return; + const container = scrollContainerRef.current; + if (!container) return; - const containerRect = container.getBoundingClientRect(); - const x = e.clientX - containerRect.left + container.scrollLeft; - const y = e.clientY - containerRect.top + container.scrollTop; + const containerRect = container.getBoundingClientRect(); + const x = e.clientX - containerRect.left + container.scrollLeft; + const y = e.clientY - containerRect.top + container.scrollTop; - // Calculate marquee rectangle - const rect = createRectFromPoints( - startPointRef.current.x, - startPointRef.current.y, - x, - y - ); - setMarqueeRect(rect); - }, [scrollContainerRef, isGridDragging]); + const rect = createRectFromPoints( + startPointRef.current.x, + startPointRef.current.y, + x, + y, + ); + setMarqueeRect(rect); + }, + [scrollContainerRef], + ); const handleMouseUp = useCallback(() => { if (!isDraggingRef.current) return; isDraggingRef.current = false; setIsSelecting(false); - - // Stop autoscroll when selection ends stopAutoScroll(); - // Get intersecting cards and add to selection if (marqueeRect && marqueeRect.width > 3 && marqueeRect.height > 3) { const intersecting = getIntersectingCards( marqueeRect, cardIds, - scrollContainerRef.current + scrollContainerRef.current, ); if (intersecting.length > 0) { - // Combine with existing selection (always add) const newSelection = [...Array.from(selectedCardIds), ...intersecting]; selectMultipleCards(newSelection); } } setMarqueeRect(null); - }, [marqueeRect, cardIds, scrollContainerRef, selectMultipleCards, selectedCardIds, stopAutoScroll]); + }, [ + cardIds, + marqueeRect, + scrollContainerRef, + selectMultipleCards, + selectedCardIds, + stopAutoScroll, + ]); - // Set up mouse event listeners on scroll container (normal marquee) useEffect(() => { const container = scrollContainerRef.current; if (!container) return; - container.addEventListener('mousedown', handleMouseDown); + container.addEventListener("mousedown", handleMouseDown); return () => { - container.removeEventListener('mousedown', handleMouseDown); + container.removeEventListener("mousedown", handleMouseDown); }; }, [handleMouseDown, scrollContainerRef]); - // Set up global shift+drag listener (takes precedence, captures phase) useEffect(() => { - // Use capture phase to intercept before other handlers (like card drag) - document.addEventListener('mousedown', handleGlobalShiftMouseDown, { capture: true }); + document.addEventListener("mousedown", handleGlobalShiftMouseDown, { + capture: true, + }); return () => { - document.removeEventListener('mousedown', handleGlobalShiftMouseDown, { capture: true }); + document.removeEventListener("mousedown", handleGlobalShiftMouseDown, { + capture: true, + }); }; }, [handleGlobalShiftMouseDown]); - // Set up global mouse event listeners for move and up useEffect(() => { - if (isSelecting) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + if (!isSelecting) return; - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - } + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; }, [isSelecting, handleMouseMove, handleMouseUp]); - // Calculate display rectangle (relative to viewport) - const displayRect = marqueeRect && scrollContainerRef.current ? { - left: marqueeRect.left - scrollContainerRef.current.scrollLeft, - top: marqueeRect.top - scrollContainerRef.current.scrollTop, - width: marqueeRect.width, - height: marqueeRect.height, - } : null; - - // Render marquee via portal to escape stacking context and appear above sidebar - const marqueeElement = isSelecting && displayRect && displayRect.width > 0 && displayRect.height > 0 ? ( -
- {displayRect.width > 120 && displayRect.height > 30 && ( - - Select items in workspace - - )} -
- ) : null; - - return typeof document !== 'undefined' && marqueeElement + const displayRect = + marqueeRect && scrollContainerRef.current + ? { + left: marqueeRect.left - scrollContainerRef.current.scrollLeft, + top: marqueeRect.top - scrollContainerRef.current.scrollTop, + width: marqueeRect.width, + height: marqueeRect.height, + } + : null; + + const marqueeElement = + isSelecting && displayRect && displayRect.width > 0 && displayRect.height > 0 ? ( +
+ {displayRect.width > 120 && displayRect.height > 30 && ( + + Select items in workspace + + )} +
+ ) : null; + + return typeof document !== "undefined" && marqueeElement ? createPortal(marqueeElement, document.body) : null; } - diff --git a/src/components/workspace-canvas/PdfCardContent.tsx b/src/components/workspace-canvas/PdfCardContent.tsx new file mode 100644 index 000000000..82a9a8b60 --- /dev/null +++ b/src/components/workspace-canvas/PdfCardContent.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import { FileText, Loader2 } from "lucide-react"; +import { generatePdfThumbnail } from "@/lib/pdf/generate-pdf-thumbnail"; +import { enqueuePdfThumbnailBackfill } from "@/lib/pdf/pdf-thumbnail-backfill"; +import { uploadFileDirect } from "@/lib/uploads/client-upload"; +import type { Item, PdfData } from "@/lib/workspace-state/types"; + +interface PdfCardContentProps { + item: Item; + onUpdateItem?: (itemId: string, updates: Partial) => void; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +export function PdfCardContent({ item, onUpdateItem }: PdfCardContentProps) { + const pdfData = item.data as PdfData; + const thumbnailUrl = pdfData.thumbnailUrl; + const fileUrl = pdfData.fileUrl; + const isPending = pdfData.thumbnailStatus === "pending"; + const [isVisible, setIsVisible] = useState(false); + const [isQueuedLocally, setIsQueuedLocally] = useState(false); + const containerRef = useRef(null); + const hasScheduledRef = useRef(false); + + useEffect(() => { + const node = containerRef.current; + if (!node) { + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + setIsVisible(entry?.isIntersecting ?? false); + }, + { + rootMargin: "240px 0px", + threshold: 0.01, + }, + ); + + observer.observe(node); + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + if (thumbnailUrl) { + hasScheduledRef.current = true; + setIsQueuedLocally(false); + return; + } + + if (!isVisible || !fileUrl || !onUpdateItem || hasScheduledRef.current) { + return; + } + + const nextData = { + ...pdfData, + thumbnailStatus: "pending", + } satisfies PdfData; + + const wasQueued = enqueuePdfThumbnailBackfill({ + itemId: item.id, + run: async () => { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const generatedThumbnail = await generatePdfThumbnail({ + filename: pdfData.filename || `${item.name || "document"}.pdf`, + url: fileUrl, + }); + const thumbnailUpload = await uploadFileDirect( + generatedThumbnail.file, + ); + + onUpdateItem(item.id, { + data: { + ...pdfData, + thumbnailUrl: thumbnailUpload.url, + thumbnailWidth: generatedThumbnail.width, + thumbnailHeight: generatedThumbnail.height, + thumbnailStatus: "ready", + } satisfies PdfData, + }); + setIsQueuedLocally(false); + return; + } catch (error) { + if (attempt === 2) { + onUpdateItem(item.id, { + data: { + ...pdfData, + thumbnailStatus: "failed", + } satisfies PdfData, + }); + setIsQueuedLocally(false); + throw error; + } + + await wait(1200); + } + } + }, + }); + + if (!wasQueued) { + return; + } + + hasScheduledRef.current = true; + setIsQueuedLocally(true); + onUpdateItem(item.id, { data: nextData }); + }, [fileUrl, isVisible, item.id, item.name, onUpdateItem, pdfData, thumbnailUrl]); + + const showPendingState = isPending || isQueuedLocally; + + if (thumbnailUrl) { + return ( +
+ {item.name +
+ ); + } + + return ( +
+
+ {showPendingState ? ( + + ) : ( + + )} +
+

+ {showPendingState ? "Generating preview..." : "Preview unavailable"} +

+
+ ); +} + +export default PdfCardContent; diff --git a/src/components/workspace-canvas/QuizCardContent.tsx b/src/components/workspace-canvas/QuizCardContent.tsx new file mode 100644 index 000000000..54f8d598c --- /dev/null +++ b/src/components/workspace-canvas/QuizCardContent.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Brain } from "lucide-react"; +import type { Item, QuizData } from "@/lib/workspace-state/types"; + +interface QuizCardContentProps { + item: Item; +} + +export function QuizCardContent({ item }: QuizCardContentProps) { + const quizData = item.data as QuizData; + const questionCount = quizData.questions?.length ?? 0; + + return ( +
+
+ +
+

+ {questionCount} question{questionCount === 1 ? "" : "s"} +

+
+ ); +} + +export default QuizCardContent; diff --git a/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx new file mode 100644 index 000000000..1f0f9afc7 --- /dev/null +++ b/src/components/workspace-canvas/SortableWorkspaceGridItem.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Feedback } from "@dnd-kit/dom"; +import { useSortable } from "@dnd-kit/react/sortable"; +import type { Item } from "@/lib/workspace-state/types"; +import { cn } from "@/lib/utils"; + +export type WorkspaceGridLane = "folders" | "items"; + +export function getWorkspaceSortableGroup( + containerId: string | null | undefined, + lane: WorkspaceGridLane, +): string { + return `${containerId ?? "root"}:${lane}`; +} + +interface SortableWorkspaceGridItemProps { + item: Item; + index: number; + lane: WorkspaceGridLane; + containerId: string | null; + className?: string; + children: (props: { + isDragging: boolean; + }) => ReactNode; +} + +export function SortableWorkspaceGridItem({ + item, + index, + lane, + containerId, + className, + children, +}: SortableWorkspaceGridItemProps) { + const group = getWorkspaceSortableGroup(containerId, lane); + const sortableType = lane === "folders" ? "folder" : "item"; + const { ref, isDragging } = useSortable({ + id: item.id, + index, + group, + type: sortableType, + accept: sortableType, + plugins: (defaults) => [ + ...defaults, + Feedback.configure({ + feedback: "clone", + dropAnimation: null, + }), + ], + data: { + group, + lane, + containerId, + itemId: item.id, + }, + }); + + return ( +
void} + className={cn( + "size-full rounded-md transition-[box-shadow,opacity,transform] duration-150", + className, + isDragging && "z-20 opacity-70", + )} + style={{ + opacity: isDragging ? 0.7 : undefined, + transform: isDragging ? "scale(0.985)" : undefined, + filter: isDragging + ? "drop-shadow(0 12px 24px rgba(0, 0, 0, 0.18))" + : undefined, + }} + > + {children({ + isDragging, + })} +
+ ); +} diff --git a/src/components/workspace-canvas/WebsitePanelContent.tsx b/src/components/workspace-canvas/WebsitePanelContent.tsx deleted file mode 100644 index a9ed6c9e4..000000000 --- a/src/components/workspace-canvas/WebsitePanelContent.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import type { Item, WebsiteData } from "@/lib/workspace-state/types"; - -interface WebsitePanelContentProps { - item: Item; -} - -export function WebsitePanelContent({ - item, -}: WebsitePanelContentProps) { - const websiteData = item.data as WebsiteData; - - if (!websiteData.url) { - return ( -
-
- No URL provided -
-
- ); - } - - return ( -
-