From eb0abbbfc3a3f9b6063f08586f2cecc102ac6de1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:16:48 +0000 Subject: [PATCH] Polish Task Workspace UX: XOR link select, id chip, Portal tokens - Label section exactly Workspace: for teacher and student - Empty state uses short Enable/Link buttons; link mode swaps in Add Class-style select - Dedupe linkable workspaces by workspace_id from board tasks via ViewTask parent prop - Active header is a compact workspace id chip linked to Drive folder URL when available - XOR chrome: empty actions, link picker, or dropzone/files/footer (Destroy only in footer) - Connect Drive uses quiet button_pointer_text; reuse Portal action tokens throughout Co-authored-by: Sander Vonk --- src/common/workspace.ts | 44 +++++ src/components/Portal/TaskWorkspace.vue | 240 ++++++++++++------------ src/views/Portal/ViewTask.vue | 46 +++++ 3 files changed, 213 insertions(+), 117 deletions(-) diff --git a/src/common/workspace.ts b/src/common/workspace.ts index 53131ef7..5d40a8e3 100644 --- a/src/common/workspace.ts +++ b/src/common/workspace.ts @@ -22,9 +22,41 @@ export interface Workspace { kind: "task" | "teacher" | string; files: WorkspaceFile[]; drive_connected?: boolean; + drive_folder_id?: string | null; + drive_folder_url?: string | null; linked_task_refs?: string[]; } +/** Board task row suitable for workspace link picker (deduped by workspace_id). */ +export interface LinkableWorkspace { + workspace_id: string; + task_name: string; + class_name?: string; + label: string; +} + +/** Human-readable option label: task name primary, optional "class · task". */ +export function linkableWorkspaceLabel(entry: { + task_name: string; + class_name?: string | null; +}): string { + const taskName = entry.task_name?.trim() || "Task"; + const className = entry.class_name?.trim(); + return className ? `${className} · ${taskName}` : taskName; +} + +/** Google Drive folder URL for a workspace chip link. */ +export function workspaceDriveFolderUrl(workspace: Workspace | null | undefined): string | null { + if (!workspace) return null; + if (typeof workspace.drive_folder_url === "string" && workspace.drive_folder_url.trim()) { + return workspace.drive_folder_url.trim(); + } + if (typeof workspace.drive_folder_id === "string" && workspace.drive_folder_id.trim()) { + return `https://drive.google.com/drive/folders/${encodeURIComponent(workspace.drive_folder_id.trim())}`; + } + return null; +} + const stubWorkspaces = new Map(); const stubTaskWorkspaceLinks = new Map(); @@ -62,6 +94,18 @@ function normalizeWorkspace(raw: unknown): Workspace | null { kind: typeof row.kind === "string" ? row.kind : "task", files, drive_connected: row.drive_connected === true, + drive_folder_id: + typeof row.drive_folder_id === "string" + ? row.drive_folder_id + : row.drive_folder_id === null + ? null + : undefined, + drive_folder_url: + typeof row.drive_folder_url === "string" + ? row.drive_folder_url + : row.drive_folder_url === null + ? null + : undefined, linked_task_refs: Array.isArray(row.linked_task_refs) ? row.linked_task_refs.filter((r): r is string => typeof r === "string") : undefined, diff --git a/src/components/Portal/TaskWorkspace.vue b/src/components/Portal/TaskWorkspace.vue index 0b91760e..b2de4b68 100644 --- a/src/components/Portal/TaskWorkspace.vue +++ b/src/components/Portal/TaskWorkspace.vue @@ -1,53 +1,87 @@