From 4e4bf34393fbe362dd69ff997416248d9c9ab3b9 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 29 Mar 2026 15:31:19 +0000 Subject: [PATCH] EAS build config + Collaboration UI + i18n updates - eas.json for Android APK/AAB + iOS builds - Collaboration page: assign, transfer, claim, subtasks - Task detail: assignee avatars, subtask progress - Updated translations (cs,he,ru,ua) Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/routes/auth.js | 11 + .../tasks/app/tasks/[id]/collaborate/page.tsx | 634 ++++++++++++++++++ apps/tasks/app/tasks/[id]/page.tsx | 93 ++- apps/tasks/lib/api.ts | 58 ++ apps/tasks/messages/cs.json | 30 +- apps/tasks/messages/he.json | 30 +- apps/tasks/messages/ru.json | 30 +- apps/tasks/messages/ua.json | 30 +- mobile/eas.json | 23 + 9 files changed, 934 insertions(+), 5 deletions(-) create mode 100644 apps/tasks/app/tasks/[id]/collaborate/page.tsx create mode 100644 mobile/eas.json diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index aee64f2..c5c27fb 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -114,6 +114,17 @@ async function authRoutes(app) { return { status: 'not_implemented', provider, message: `${provider} OAuth coming soon. Configure at Settings > Connections.` }; }); + // Search users by name or email (for collaboration) + app.get('/auth/users/search', async (req) => { + const q = (req.query.q || '').trim(); + if (q.length < 2) return { data: [] }; + const { rows } = await app.db.query( + "SELECT id, name, email, avatar_url FROM users WHERE (name ILIKE $1 OR email ILIKE $1) ORDER BY name LIMIT 20", + ["%" + q + "%"] + ); + return { data: rows }; + }); + } module.exports = authRoutes; diff --git a/apps/tasks/app/tasks/[id]/collaborate/page.tsx b/apps/tasks/app/tasks/[id]/collaborate/page.tsx new file mode 100644 index 0000000..0f9d836 --- /dev/null +++ b/apps/tasks/app/tasks/[id]/collaborate/page.tsx @@ -0,0 +1,634 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { useAuth } from "@/lib/auth"; +import { useTranslation } from "@/lib/i18n"; +import { + getTask, + Task, + Subtask, + CollabRequest, + getSubtasks, + createSubtask, + updateSubtask, + deleteSubtask, + getCollaborationHistory, + sendCollabRequest, + searchUsers, +} from "@/lib/api"; + +interface UserResult { + id: string; + name: string; + email: string; + avatar_url: string | null; +} + +function UserAvatar({ name, avatarUrl, size = "sm" }: { name: string; avatarUrl?: string | null; size?: "sm" | "md" }) { + const sz = size === "md" ? "w-10 h-10 text-sm" : "w-8 h-8 text-xs"; + const initials = name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + if (avatarUrl) { + return {name}; + } + const colors = ["bg-blue-500", "bg-green-500", "bg-purple-500", "bg-orange-500", "bg-pink-500", "bg-teal-500"]; + const color = colors[name.charCodeAt(0) % colors.length]; + return ( +
+ {initials} +
+ ); +} + +function UserSearchDropdown({ + token, + onSelect, + placeholder, +}: { + token: string; + onSelect: (user: UserResult) => void; + placeholder: string; +}) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + + useEffect(() => { + if (query.length < 2) { + setResults([]); + return; + } + const timer = setTimeout(async () => { + setSearching(true); + try { + const res = await searchUsers(token, query); + setResults(res.data || []); + } catch { + setResults([]); + } finally { + setSearching(false); + } + }, 300); + return () => clearTimeout(timer); + }, [query, token]); + + return ( +
+ setQuery(e.target.value)} + placeholder={placeholder} + className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + {searching && ( +
+
+
+ )} + {results.length > 0 && ( +
+ {results.map((user) => ( + + ))} +
+ )} +
+ ); +} + +export default function CollaboratePage() { + const { token, user } = useAuth(); + const { t } = useTranslation(); + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + + const [task, setTask] = useState(null); + const [subtasks, setSubtasks] = useState([]); + const [history, setHistory] = useState([]); + const [assignees, setAssignees] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + // Forms + const [showAssignSearch, setShowAssignSearch] = useState(false); + const [showTransferSearch, setShowTransferSearch] = useState(false); + const [transferMessage, setTransferMessage] = useState(""); + const [newSubtaskTitle, setNewSubtaskTitle] = useState(""); + const [showSubtaskForm, setShowSubtaskForm] = useState(false); + const [subtaskAssignee, setSubtaskAssignee] = useState(null); + const [actionLoading, setActionLoading] = useState(false); + + const loadAll = useCallback(async () => { + if (!token || !id) return; + setLoading(true); + try { + const [taskData, subtasksData, historyData] = await Promise.all([ + getTask(token, id), + getSubtasks(token, id), + getCollaborationHistory(token, id), + ]); + setTask(taskData); + setSubtasks(subtasksData.data || []); + setHistory(historyData.data || []); + + // Load assignee details + const assignedIds: string[] = taskData.assigned_to || []; + if (assignedIds.length > 0) { + try { + const usersRes = await searchUsers(token, ""); + // Filter by IDs — the workload endpoint returns all users, filter client-side + // For now just show what we can from collaboration history + const knownUsers = new Map(); + (historyData.data || []).forEach((h: CollabRequest) => { + if (h.from_user_id && h.from_name) knownUsers.set(h.from_user_id, { id: h.from_user_id, name: h.from_name, email: "", avatar_url: null }); + if (h.to_user_id && h.to_name) knownUsers.set(h.to_user_id, { id: h.to_user_id, name: h.to_name, email: "", avatar_url: null }); + }); + // Merge with any search results + (usersRes.data || []).forEach((u: UserResult) => knownUsers.set(u.id, u)); + setAssignees(assignedIds.map((uid) => knownUsers.get(uid) || { id: uid, name: uid.slice(0, 8), email: "", avatar_url: null })); + } catch { + setAssignees(assignedIds.map((uid) => ({ id: uid, name: uid.slice(0, 8), email: "", avatar_url: null }))); + } + } else { + setAssignees([]); + } + } catch (err) { + setError(err instanceof Error ? err.message : t("tasks.loadError")); + } finally { + setLoading(false); + } + }, [token, id, t]); + + useEffect(() => { + if (!token) { + router.replace("/login"); + return; + } + loadAll(); + }, [token, router, loadAll]); + + async function handleAssign(selectedUser: UserResult) { + if (!token || !id || !user) return; + setActionLoading(true); + try { + await sendCollabRequest(token, id, { + from_user_id: user.id, + to_user_id: selectedUser.id, + type: "assign", + message: "", + }); + setShowAssignSearch(false); + await loadAll(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } finally { + setActionLoading(false); + } + } + + async function handleTransfer(selectedUser: UserResult) { + if (!token || !id || !user) return; + setActionLoading(true); + try { + await sendCollabRequest(token, id, { + from_user_id: user.id, + to_user_id: selectedUser.id, + type: "transfer", + message: transferMessage, + }); + setShowTransferSearch(false); + setTransferMessage(""); + await loadAll(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } finally { + setActionLoading(false); + } + } + + async function handleClaim() { + if (!token || !id || !user) return; + setActionLoading(true); + try { + await sendCollabRequest(token, id, { + from_user_id: user.id, + type: "claim", + }); + await loadAll(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } finally { + setActionLoading(false); + } + } + + async function handleAddSubtask() { + if (!token || !id || !newSubtaskTitle.trim()) return; + setActionLoading(true); + try { + await createSubtask(token, id, { + title: newSubtaskTitle.trim(), + assigned_to: subtaskAssignee?.id, + }); + setNewSubtaskTitle(""); + setSubtaskAssignee(null); + setShowSubtaskForm(false); + await loadAll(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } finally { + setActionLoading(false); + } + } + + async function handleToggleSubtask(subtask: Subtask) { + if (!token || !id) return; + const newStatus = subtask.status === "done" || subtask.status === "completed" ? "pending" : "done"; + try { + await updateSubtask(token, id, subtask.id, { status: newStatus }); + await loadAll(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } + } + + async function handleDeleteSubtask(subtaskId: string) { + if (!token || !id) return; + try { + await deleteSubtask(token, id, subtaskId); + await loadAll(); + } catch (err) { + setError(err instanceof Error ? err.message : t("common.error")); + } + } + + if (!token) return null; + + if (loading) { + return ( +
+
+
+ ); + } + + if (error && !task) { + return ( +
+

{error}

+ +
+ ); + } + + const subtasksDone = subtasks.filter((s) => s.status === "done" || s.status === "completed").length; + const subtasksTotal = subtasks.length; + const progressPct = subtasksTotal > 0 ? Math.round((subtasksDone / subtasksTotal) * 100) : 0; + + const collabTypeLabels: Record = { + assign: { icon: "+", label: t("collab.assigned"), color: "text-blue-600" }, + transfer: { icon: "->", label: t("collab.transferred"), color: "text-orange-600" }, + claim: { icon: "!", label: t("collab.claimed"), color: "text-green-600" }, + collaborate: { icon: "&", label: t("collab.collaborated"), color: "text-purple-600" }, + }; + + return ( +
+ {/* Header */} +
+ +

{t("collab.title")}

+
+ + {/* Task title */} + {task && ( +
+

{task.title}

+ {task.description &&

{task.description}

} +
+ )} + + {error && ( +
+ {error} + +
+ )} + + {/* Assignees section */} +
+

+ {t("collab.assignees")} +

+ + {assignees.length > 0 ? ( +
+ {assignees.map((a) => ( +
+ + {a.name} +
+ ))} +
+ ) : ( +

{t("collab.noAssignees")}

+ )} + + {/* Action buttons */} +
+ + + + + +
+ + {/* Assign search dropdown */} + {showAssignSearch && ( +
+ +
+ )} + + {/* Transfer search dropdown */} + {showTransferSearch && ( +
+ +