diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index fe6bfd2..fefacf5 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -124,6 +124,10 @@ func main() { auth.Post("/refresh", handlers.RefreshToken) auth.Post("/logout", middleware.Protected(), handlers.Logout) + // Public avatar/media routes (no auth needed for tags) + api.Get("/avatar/:userId", handlers.ServePublicAvatar) + api.Get("/team-media/:teamId/:imageType", handlers.ServePublicTeamImage) + users := api.Group("/users", middleware.Protected()) users.Get("/me", handlers.GetMe) users.Put("/me", handlers.UpdateMe) diff --git a/server/fpmb_server b/server/fpmb_server new file mode 100755 index 0000000..85ae5fb Binary files /dev/null and b/server/fpmb_server differ diff --git a/server/internal/handlers/chat.go b/server/internal/handlers/chat.go index 9fa79b0..6b4496a 100644 --- a/server/internal/handlers/chat.go +++ b/server/internal/handlers/chat.go @@ -184,28 +184,30 @@ func TeamChatWS(c *websocket.Conn) { } var incoming struct { - Type string `json:"type"` - Content string `json:"content"` + Type string `json:"type"` + Content string `json:"content"` + MessageID string `json:"message_id,omitempty"` + ReplyTo string `json:"reply_to,omitempty"` } if json.Unmarshal(msg, &incoming) != nil { continue } + teamOID, err := primitive.ObjectIDFromHex(teamID) + if err != nil { + continue + } + userOID, err := primitive.ObjectIDFromHex(userID) + if err != nil { + continue + } + if incoming.Type == "message" { content := strings.TrimSpace(incoming.Content) if content == "" || len(content) > 5000 { continue } - teamOID, err := primitive.ObjectIDFromHex(teamID) - if err != nil { - continue - } - userOID, err := primitive.ObjectIDFromHex(userID) - if err != nil { - continue - } - chatMsg := models.ChatMessage{ ID: primitive.NewObjectID(), TeamID: teamOID, @@ -215,6 +217,12 @@ func TeamChatWS(c *websocket.Conn) { CreatedAt: time.Now(), } + if incoming.ReplyTo != "" { + if replyID, err := primitive.ObjectIDFromHex(incoming.ReplyTo); err == nil { + chatMsg.ReplyTo = &replyID + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) _, _ = database.GetCollection("chat_messages").InsertOne(ctx, chatMsg) cancel() @@ -224,6 +232,56 @@ func TeamChatWS(c *websocket.Conn) { "message": chatMsg, }) room.broadcastAll(outMsg) + } else if incoming.Type == "edit" { + content := strings.TrimSpace(incoming.Content) + if content == "" || len(content) > 5000 || incoming.MessageID == "" { + continue + } + msgID, err := primitive.ObjectIDFromHex(incoming.MessageID) + if err != nil { + continue + } + + now := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + res, err := database.GetCollection("chat_messages").UpdateOne(ctx, + bson.M{"_id": msgID, "user_id": userOID, "team_id": teamOID, "deleted": bson.M{"$ne": true}}, + bson.M{"$set": bson.M{"content": content, "edited_at": now}}, + ) + cancel() + + if err == nil && res.ModifiedCount > 0 { + outMsg, _ := json.Marshal(map[string]interface{}{ + "type": "edit", + "message_id": msgID.Hex(), + "content": content, + "edited_at": now, + }) + room.broadcastAll(outMsg) + } + } else if incoming.Type == "delete" { + if incoming.MessageID == "" { + continue + } + msgID, err := primitive.ObjectIDFromHex(incoming.MessageID) + if err != nil { + continue + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + res, err := database.GetCollection("chat_messages").UpdateOne(ctx, + bson.M{"_id": msgID, "user_id": userOID, "team_id": teamOID}, + bson.M{"$set": bson.M{"deleted": true, "content": ""}}, + ) + cancel() + + if err == nil && res.ModifiedCount > 0 { + outMsg, _ := json.Marshal(map[string]interface{}{ + "type": "delete", + "message_id": msgID.Hex(), + }) + room.broadcastAll(outMsg) + } } if incoming.Type == "typing" { diff --git a/server/internal/handlers/teams.go b/server/internal/handlers/teams.go index 7c34769..9709bea 100644 --- a/server/internal/handlers/teams.go +++ b/server/internal/handlers/teams.go @@ -526,12 +526,12 @@ func uploadTeamImage(c *fiber.Ctx, imageType string) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save image"}) } - imageURL := fmt.Sprintf("/api/teams/%s/%s", teamID.Hex(), imageType) + imageURL := fmt.Sprintf("/api/team-media/%s/%s", teamID.Hex(), imageType) field := imageType + "_url" col := database.GetCollection("teams") if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": bson.M{ - field: imageURL, + field: imageURL, "updated_at": time.Now(), }}); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update team"}) @@ -600,3 +600,25 @@ func ServeTeamAvatar(c *fiber.Ctx) error { func ServeTeamBanner(c *fiber.Ctx) error { return serveTeamImage(c, "banner") } + +func ServePublicTeamImage(c *fiber.Ctx) error { + teamID := c.Params("teamId") + imageType := c.Params("imageType") + if _, err := primitive.ObjectIDFromHex(teamID); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"}) + } + if imageType != "avatar" && imageType != "banner" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid image type"}) + } + + dir := filepath.Join("../data/teams", teamID) + for ext := range allowedImageExts { + p := filepath.Join(dir, imageType+ext) + if _, err := os.Stat(p); err == nil { + c.Set("Cache-Control", "public, max-age=3600") + return c.SendFile(p) + } + } + + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Image not found"}) +} diff --git a/server/internal/handlers/users.go b/server/internal/handlers/users.go index ff8b3ef..ac727ea 100644 --- a/server/internal/handlers/users.go +++ b/server/internal/handlers/users.go @@ -153,7 +153,7 @@ func UploadUserAvatar(c *fiber.Ctx) error { col := database.GetCollection("users") if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{ - "avatar_url": "/api/users/me/avatar", + "avatar_url": "/api/avatar/" + userID.Hex(), "updated_at": time.Now(), }}); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update user"}) @@ -184,6 +184,24 @@ func ServeUserAvatar(c *fiber.Ctx) error { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Avatar not found"}) } +func ServePublicAvatar(c *fiber.Ctx) error { + userID := c.Params("userId") + if _, err := primitive.ObjectIDFromHex(userID); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"}) + } + + dir := filepath.Join("../data/users", userID) + for ext := range allowedImageExts { + p := filepath.Join(dir, "avatar"+ext) + if _, err := os.Stat(p); err == nil { + c.Set("Cache-Control", "public, max-age=3600") + return c.SendFile(p) + } + } + + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Avatar not found"}) +} + func ChangePassword(c *fiber.Ctx) error { userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string)) if err != nil { diff --git a/server/internal/models/models.go b/server/internal/models/models.go index bf55cfc..f2a79e6 100644 --- a/server/internal/models/models.go +++ b/server/internal/models/models.go @@ -73,20 +73,22 @@ type Subtask struct { } type Card struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - ColumnID primitive.ObjectID `bson:"column_id" json:"column_id"` - ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"` - Title string `bson:"title" json:"title"` - Description string `bson:"description" json:"description"` - Priority string `bson:"priority" json:"priority"` - Color string `bson:"color" json:"color"` - DueDate *time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"` - Assignees []string `bson:"assignees" json:"assignees"` - Subtasks []Subtask `bson:"subtasks" json:"subtasks"` - Position int `bson:"position" json:"position"` - CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` - UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + ColumnID primitive.ObjectID `bson:"column_id" json:"column_id"` + ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"` + Title string `bson:"title" json:"title"` + Description string `bson:"description" json:"description"` + Priority string `bson:"priority" json:"priority"` + Color string `bson:"color" json:"color"` + DueDate *time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"` + Assignees []string `bson:"assignees" json:"assignees"` + EstimatedMinutes *int `bson:"estimated_minutes,omitempty" json:"estimated_minutes,omitempty"` + ActualMinutes *int `bson:"actual_minutes,omitempty" json:"actual_minutes,omitempty"` + Subtasks []Subtask `bson:"subtasks" json:"subtasks"` + Position int `bson:"position" json:"position"` + CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` } type Event struct { @@ -175,10 +177,13 @@ type APIKey struct { } type ChatMessage struct { - ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` - TeamID primitive.ObjectID `bson:"team_id" json:"team_id"` - UserID primitive.ObjectID `bson:"user_id" json:"user_id"` - UserName string `bson:"user_name" json:"user_name"` - Content string `bson:"content" json:"content"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` + ID primitive.ObjectID `bson:"_id,omitempty" json:"id"` + TeamID primitive.ObjectID `bson:"team_id" json:"team_id"` + UserID primitive.ObjectID `bson:"user_id" json:"user_id"` + UserName string `bson:"user_name" json:"user_name"` + Content string `bson:"content" json:"content"` + ReplyTo *primitive.ObjectID `bson:"reply_to,omitempty" json:"reply_to,omitempty"` + EditedAt *time.Time `bson:"edited_at,omitempty" json:"edited_at,omitempty"` + Deleted bool `bson:"deleted,omitempty" json:"deleted,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 023575c..e2edbf4 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -276,7 +276,7 @@ export const board = { createCard: ( projectId: string, columnId: string, - data: Pick + data: Pick ) => apiFetch(`/projects/${projectId}/columns/${columnId}/cards`, { method: 'POST', @@ -287,7 +287,7 @@ export const board = { export const cards = { update: ( cardId: string, - data: Partial> + data: Partial> ) => apiFetch(`/cards/${cardId}`, { method: 'PUT', body: JSON.stringify(data) }), move: (cardId: string, column_id: string, position: number) => diff --git a/src/lib/components/FileViewer/FileViewer.svelte b/src/lib/components/FileViewer/FileViewer.svelte index 4d1bf3d..7fd8509 100644 --- a/src/lib/components/FileViewer/FileViewer.svelte +++ b/src/lib/components/FileViewer/FileViewer.svelte @@ -1,70 +1,131 @@ {#if isOpen} -
+
- -
-
+ +
+

{title}

-
- +
{@render children()}
diff --git a/src/lib/types/api.ts b/src/lib/types/api.ts index 4899eb7..766dfdb 100644 --- a/src/lib/types/api.ts +++ b/src/lib/types/api.ts @@ -70,8 +70,10 @@ export interface Card { description: string; priority: string; color: string; - due_date: string; + due_date?: string; assignees: string[]; + estimated_minutes?: number; + actual_minutes?: number; subtasks: Subtask[]; position: number; created_by: string; @@ -150,6 +152,7 @@ export interface Webhook { type: string; url: string; status: string; + active?: boolean; last_triggered?: string; created_by: string; created_at: string; @@ -189,5 +192,8 @@ export interface ChatMessage { user_id: string; user_name: string; content: string; + reply_to?: string; + edited_at?: string; + deleted?: boolean; created_at: string; } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 450c1f8..4994f51 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -12,6 +12,7 @@ authStore.user?.name?.charAt(0).toUpperCase() ?? "U", ); let unreadCount = $state(0); + let isMobileMenuOpen = $state(false); onMount(async () => { await authStore.init(); @@ -162,8 +163,9 @@ +
+ {/if} +
diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 1bd51c2..15cce25 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -182,10 +182,13 @@
(showNewTeam = false)} + onkeydown={(e) => e.key === "Escape" && (showNewTeam = false)} + role="dialog" + aria-label="Create Team dialog" + tabindex="-1" >
e.stopPropagation()} >
(null); + let originalColumnIdForTask = $state(null); let editingCardId = $state(null); let newTask = $state({ @@ -148,6 +149,8 @@ color: "neutral", dueDate: "", assignees: [] as string[], + estimatedMinutes: "", + actualMinutes: "", subtasks: [] as { id: number; text: string; done: boolean }[], }); @@ -200,6 +203,7 @@ function openCreateTaskModal(columnId: string) { activeColumnIdForNewTask = columnId; + originalColumnIdForTask = columnId; editingCardId = null; newTask = { title: "", @@ -208,6 +212,8 @@ color: "neutral", dueDate: "", assignees: [], + estimatedMinutes: "", + actualMinutes: "", subtasks: [], }; assigneeInput = ""; @@ -217,6 +223,7 @@ function openEditTaskModal(columnId: string, card: LocalCard) { activeColumnIdForNewTask = columnId; + originalColumnIdForTask = columnId; editingCardId = card.id; newTask = { title: card.title, @@ -225,6 +232,8 @@ color: card.color || "neutral", dueDate: card.due_date ? card.due_date.split("T")[0] : "", assignees: [...(card.assignees || [])], + estimatedMinutes: card.estimated_minutes?.toString() || "", + actualMinutes: card.actual_minutes?.toString() || "", subtasks: card.subtasks.map((st) => ({ ...st })), }; assigneeInput = ""; @@ -257,15 +266,42 @@ color: newTask.color, due_date: newTask.dueDate, assignees: newTask.assignees, + estimated_minutes: newTask.estimatedMinutes + ? parseInt(newTask.estimatedMinutes) + : undefined, + actual_minutes: newTask.actualMinutes + ? parseInt(newTask.actualMinutes) + : undefined, subtasks: newTask.subtasks.map((st) => ({ id: st.id, text: st.text, done: st.done, })), }); - targetCol.cards = targetCol.cards.map((card) => - card.id === editingCardId - ? { + + if (originalColumnIdForTask === activeColumnIdForNewTask) { + targetCol.cards = targetCol.cards.map((card) => + card.id === editingCardId + ? { + ...card, + ...updated, + subtasks: (updated.subtasks ?? []).map((st) => ({ + id: st.id, + text: st.text, + done: st.done, + })), + } + : card, + ); + } else { + const oldCol = columns.find((c) => c.id === originalColumnIdForTask); + if (oldCol) { + const cardIndex = oldCol.cards.findIndex( + (c) => c.id === editingCardId, + ); + if (cardIndex !== -1) { + const [card] = oldCol.cards.splice(cardIndex, 1); + const formattedCard = { ...card, ...updated, subtasks: (updated.subtasks ?? []).map((st) => ({ @@ -273,9 +309,18 @@ text: st.text, done: st.done, })), - } - : card, - ); + }; + targetCol.cards.push(formattedCard); + await cardsApi + .move( + editingCardId, + activeColumnIdForNewTask!, + targetCol.cards.length - 1, + ) + .catch(() => {}); + } + } + } } else { const created = await boardApi.createCard( boardId, @@ -287,6 +332,12 @@ color: newTask.color, due_date: newTask.dueDate || "", assignees: newTask.assignees, + estimated_minutes: newTask.estimatedMinutes + ? parseInt(newTask.estimatedMinutes) + : undefined, + actual_minutes: newTask.actualMinutes + ? parseInt(newTask.actualMinutes) + : undefined, }, ); targetCol.cards = [...targetCol.cards, { ...created, subtasks: [] }]; @@ -561,8 +612,8 @@ ondragstart={(e) => handleDragStart(card.id, column.id, e)} class="bg-neutral-750 p-4 rounded-md border border-neutral-600 shadow-sm {isArchived ? 'cursor-default' - : 'cursor-grab active:cursor-grabbing'} hover:border-neutral-500 transition-colors flex flex-col gap-2 group relative overflow-hidden" - role="listitem" + : 'cursor-pointer hover:border-neutral-500'} transition-colors flex flex-col gap-2 group relative overflow-hidden" + role="button" onclick={() => !isArchived && openEditTaskModal(column.id, card)} onkeydown={(e) => @@ -642,6 +693,20 @@ )}
{/if} + {#if card.estimated_minutes || card.actual_minutes} +
+ + {card.actual_minutes || 0}m / {card.estimated_minutes || + "?"}m +
+ {/if}
{#if card.assignees && card.assignees.length > 0}
@@ -696,6 +761,8 @@ Status Priority Due Date + Est. (m) + Act. (m) Assignees Subtasks @@ -751,6 +818,20 @@ {/if} + + {#if card.estimated_minutes != null} + {card.estimated_minutes} + {:else} + + {/if} + + + {#if card.actual_minutes != null} + {card.actual_minutes} + {:else} + + {/if} + {#if card.assignees?.length}
@@ -777,7 +858,7 @@ {:else} No tasks yet. @@ -793,7 +874,7 @@ ? new Date( Math.min( ...datedCards.map((c) => - new Date(c.created_at || c.due_date).getTime(), + new Date(c.created_at || c.due_date!).getTime(), ), now.getTime() - 7 * 86400000, ), @@ -802,7 +883,7 @@ {@const ganttEnd = datedCards.length ? new Date( Math.max( - ...datedCards.map((c) => new Date(c.due_date).getTime()), + ...datedCards.map((c) => new Date(c.due_date!).getTime()), now.getTime() + 7 * 86400000, ), ) @@ -847,8 +928,8 @@
{:else} {#each datedCards as card (card.id)} - {@const created = new Date(card.created_at || card.due_date)} - {@const due = new Date(card.due_date)} + {@const created = new Date(card.created_at || card.due_date!)} + {@const due = new Date(card.due_date!)} {@const startOffset = Math.max( 0, ((created.getTime() - ganttStart.getTime()) / @@ -868,6 +949,12 @@ class="w-56 shrink-0 px-4 py-3 border-r border-neutral-700 flex items-center gap-2 cursor-pointer" onclick={() => !isArchived && openEditTaskModal(card.columnId, card)} + onkeydown={(e) => + e.key === "Enter" && + !isArchived && + openEditTaskModal(card.columnId, card)} + role="button" + tabindex="0" > {#if card.color && card.color !== "neutral"}
!isArchived && openEditTaskModal(column.id, card)} + onkeydown={(e) => + e.key === "Enter" && + !isArchived && + openEditTaskModal(column.id, card)} + role="button" + tabindex="0" >
@@ -971,7 +1064,7 @@ {#if card.due_date} - {new Date(card.due_date).toLocaleDateString( + {new Date(card.due_date!).toLocaleDateString( "en-US", { month: "short", day: "numeric" }, )} @@ -1024,225 +1117,338 @@ -
-
-
- + +
+
+
-
-
- - -
-
- - -
-
-
-
-
- -
- - -
-
-
- {#if previewMarkdown} -
- {#if newTask.description} - - {:else} -

- No description provided. -

- {/if} -
- {:else} - - {/if} -
-
- -
- - -
-
- - {#if newTask.assignees.length > 0} -
- {#each newTask.assignees as email} - - {email} - - - {/each} -
- {/if} - - {#if showUserDropdown && userSearchResults.length > 0} -
+ - {#each userSearchResults as user} - - {/each} -
- {/if} -
-
- -
- -
    - {#each newTask.subtasks as subtask, i} -
  • - - {subtask.text} + - - -
  • - {/each} -
-
- -
+
- Add - - + {#if previewMarkdown} +
+ {#if newTask.description} + + {:else} +

No description provided.

+ {/if} +
+ {:else} + + {/if} +
+
+ +
+
+ + + {newTask.subtasks.filter((s) => s.done).length} / {newTask.subtasks + .length} + +
+
+ {#each newTask.subtasks as subtask, i} +
+ + {subtask.text} + +
+ {:else} +
+

+ Break this task into smaller steps. +

+
+ {/each} +
+
+ + +
+
-
- - + +
+
+

+ Properties +

+ + + {#if editingCardId && activeColumnIdForNewTask} +
+ + +
+ {/if} + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + {#if newTask.assignees.length > 0} +
+ {#each newTask.assignees as email} +
+
+
+ {email.charAt(0)} +
+ {email} +
+ +
+ {/each} +
+ {/if} + +
+ + {#if showUserDropdown && userSearchResults.length > 0} +
+ {#each userSearchResults as user} + + {/each} +
+ {/if} +
+
+
+ + +
+ + +
diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte index 71f6f3a..dd47528 100644 --- a/src/routes/(app)/calendar/+page.svelte +++ b/src/routes/(app)/calendar/+page.svelte @@ -64,7 +64,7 @@ .filter((c) => c.due_date) .map((c) => ({ id: c.id, - date: c.due_date.split("T")[0], + date: c.due_date!.split("T")[0], title: c.title, time: "", color: priorityColor[c.priority] ?? "blue", @@ -129,11 +129,8 @@

- Organization Calendar + Personal Calendar

-

- Overview of all team events and milestones. -