From 7bb047e5525f67d5d11b43401aecb2aaba80b884 Mon Sep 17 00:00:00 2001
From: Martian <150589141+martian56@users.noreply.github.com>
Date: Mon, 9 Mar 2026 02:47:34 +0400
Subject: [PATCH] Revert "[FEAT] Implement Email service & Send emails for
workspace invitations"
---
api/.env.example | 3 -
api/cmd/api/main.go | 16 +-
api/internal/config/config.go | 3 -
api/internal/handler/auth.go | 9 +
api/internal/handler/favorite.go | 106 ++++++
api/internal/handler/instance.go | 89 +++++
api/internal/handler/project.go | 45 ++-
api/internal/handler/upload.go | 121 +++++++
api/internal/handler/workspace.go | 35 +-
api/internal/mail/mail.go | 110 ------
api/internal/minio/minio.go | 12 +
api/internal/model/project.go | 1 +
api/internal/model/user.go | 1 +
api/internal/model/user_favorite.go | 6 +-
api/internal/router/router.go | 26 +-
api/internal/service/project.go | 14 +-
api/internal/service/workspace.go | 5 +-
api/internal/store/user_favorite.go | 53 +++
ui/src/App.tsx | 5 +-
ui/src/api/types.ts | 16 +
ui/src/components/CoverImageModal.tsx | 292 ++++++++++++++++
ui/src/components/ProjectIconModal.tsx | 323 ++++++++++++++++++
ui/src/components/UploadImageModal.tsx | 168 +++++++++
ui/src/components/layout/PageHeader.tsx | 6 +-
ui/src/components/layout/Sidebar.tsx | 64 ++--
ui/src/components/work-item/CommentEditor.tsx | 2 +-
ui/src/contexts/AuthContext.tsx | 1 +
ui/src/contexts/FavoritesContext.tsx | 28 ++
ui/src/lib/utils.ts | 10 +
ui/src/pages/AnalyticsWorkItemsPage.tsx | 10 +-
ui/src/pages/BoardPage.tsx | 2 +-
ui/src/pages/CyclesPage.tsx | 4 +-
ui/src/pages/InviteAcceptPage.tsx | 138 --------
ui/src/pages/IssueDetailPage.tsx | 4 +-
ui/src/pages/IssueListPage.tsx | 6 +-
ui/src/pages/LoginPage.tsx | 8 +-
ui/src/pages/ProfilePage.tsx | 94 ++++-
ui/src/pages/ProjectsListPage.tsx | 219 ++++++++----
ui/src/pages/SettingsPage.tsx | 274 +++++++++++----
ui/src/pages/WorkspaceHomePage.tsx | 23 +-
ui/src/pages/WorkspaceViewsPage.tsx | 4 +-
.../instance-admin/InstanceAdminAIPage.tsx | 5 +-
.../InstanceAdminAuthenticationPage.tsx | 6 +-
.../instance-admin/InstanceAdminEmailPage.tsx | 5 +-
.../instance-admin/InstanceAdminImagePage.tsx | 5 +-
ui/src/routes/index.tsx | 20 +-
ui/src/services/instanceService.ts | 18 +
ui/src/services/projectService.ts | 3 +
ui/src/services/uploadService.ts | 21 ++
ui/src/services/userService.ts | 4 +-
ui/src/services/workspaceService.ts | 14 +-
ui/src/types/index.ts | 1 +
ui/vite.config.ts | 9 +
53 files changed, 1900 insertions(+), 567 deletions(-)
create mode 100644 api/internal/handler/favorite.go
create mode 100644 api/internal/handler/upload.go
delete mode 100644 api/internal/mail/mail.go
create mode 100644 api/internal/store/user_favorite.go
create mode 100644 ui/src/components/CoverImageModal.tsx
create mode 100644 ui/src/components/ProjectIconModal.tsx
create mode 100644 ui/src/components/UploadImageModal.tsx
create mode 100644 ui/src/contexts/FavoritesContext.tsx
delete mode 100644 ui/src/pages/InviteAcceptPage.tsx
create mode 100644 ui/src/services/uploadService.ts
diff --git a/api/.env.example b/api/.env.example
index 2ec07994..f8d9af2d 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -20,9 +20,6 @@ REDIS_DB=0
# RabbitMQ
RABBITMQ_URL=amqp://guest:guest@localhost:5672/
-# Frontend URL for invite links in emails (e.g. https://app.example.com). If unset, CORS_ORIGIN is used.
-APP_BASE_URL=https://app.example.com
-
# MinIO (S3-compatible)
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY_ID=minioadmin
diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go
index 6be09898..c5c926c9 100644
--- a/api/cmd/api/main.go
+++ b/api/cmd/api/main.go
@@ -12,13 +12,11 @@ import (
"github.com/Devlaner/devlane/api/internal/config"
"github.com/Devlaner/devlane/api/internal/database"
- "github.com/Devlaner/devlane/api/internal/mail"
"github.com/Devlaner/devlane/api/internal/minio"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/rabbitmq"
"github.com/Devlaner/devlane/api/internal/redis"
"github.com/Devlaner/devlane/api/internal/router"
- "github.com/Devlaner/devlane/api/internal/store"
)
func main() {
@@ -71,12 +69,12 @@ func main() {
}
}
- // MinIO
- mc, err := minio.New(cfg, log)
- if err != nil {
+ // MinIO (optional: file uploads for covers, avatars, logos)
+ var mc *minio.Client
+ if client, err := minio.New(cfg, log); err != nil {
log.Warn("minio", "error", err)
} else {
- _ = mc
+ mc = client
}
r := router.New(router.Config{
@@ -84,8 +82,8 @@ func main() {
DB: db,
Redis: rdb,
Queue: queuePublisher,
+ Minio: mc,
CORSAllowOrigin: cfg.CORSAllowOrigin,
- AppBaseURL: cfg.AppBaseURL,
})
// Start task consumer when RabbitMQ is available
@@ -95,9 +93,7 @@ func main() {
if chConsume, err := rmq.NewChannel(); err == nil {
defer chConsume.Close()
consumer := queue.NewConsumer(chConsume, log)
- instanceSettingStore := store.NewInstanceSettingStore(db)
- emailSender := mail.NewSMTPEmailSender(instanceSettingStore, log)
- consumer.Register(queue.QueueEmails, queue.HandleSendEmail(emailSender))
+ consumer.Register(queue.QueueEmails, queue.HandleSendEmail(queue.NoopEmailSender(log)))
consumer.Register(queue.QueueWebhooks, queue.HandleWebhook(queue.NoopWebhookDeliverer(log)))
if err := consumer.Run(consumerCtx, []string{queue.QueueEmails, queue.QueueWebhooks}); err != nil {
log.Warn("queue consumer", "error", err)
diff --git a/api/internal/config/config.go b/api/internal/config/config.go
index b831ee27..fdbaf9f2 100644
--- a/api/internal/config/config.go
+++ b/api/internal/config/config.go
@@ -40,8 +40,6 @@ type Config struct {
MigrationsPath string
CORSAllowOrigin string
- // AppBaseURL is the public URL of the frontend (e.g. https://app.example.com). Used for invite links in emails. If empty, CORSAllowOrigin is used.
- AppBaseURL string
}
func (c *Config) DSN() string {
@@ -86,7 +84,6 @@ func Load() (*Config, error) {
MinIOUseSSL: minioSSL,
MigrationsPath: getEnv("MIGRATIONS_PATH", "migrations"),
CORSAllowOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
- AppBaseURL: getEnv("APP_BASE_URL", ""),
}
return cfg, nil
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 3cce5f28..08a50639 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -181,6 +181,8 @@ type UpdateMeRequest struct {
LastName *string `json:"last_name"`
DisplayName *string `json:"display_name"`
UserTimezone *string `json:"user_timezone"`
+ Avatar *string `json:"avatar"`
+ CoverImage *string `json:"cover_image"`
}
// UpdateMe updates the authenticated user's profile (email is not updatable).
@@ -208,6 +210,12 @@ func (h *AuthHandler) UpdateMe(c *gin.Context) {
if req.UserTimezone != nil {
user.UserTimezone = *req.UserTimezone
}
+ if req.Avatar != nil {
+ user.Avatar = *req.Avatar
+ }
+ if req.CoverImage != nil {
+ user.CoverImage = *req.CoverImage
+ }
if err := h.Auth.UpdateProfile(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Update failed"})
return
@@ -522,6 +530,7 @@ func userResponse(u *model.User) gin.H {
"last_name": u.LastName,
"display_name": u.DisplayName,
"avatar": u.Avatar,
+ "cover_image": u.CoverImage,
"is_active": u.IsActive,
"is_onboarded": u.IsOnboarded,
"date_joined": u.DateJoined,
diff --git a/api/internal/handler/favorite.go b/api/internal/handler/favorite.go
new file mode 100644
index 00000000..3164b3fb
--- /dev/null
+++ b/api/internal/handler/favorite.go
@@ -0,0 +1,106 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/Devlaner/devlane/api/internal/middleware"
+ "github.com/Devlaner/devlane/api/internal/service"
+ "github.com/Devlaner/devlane/api/internal/store"
+ "github.com/gin-gonic/gin"
+)
+
+// FavoriteHandler serves user favorite project endpoints.
+type FavoriteHandler struct {
+ Project *service.ProjectService
+ Favorites *store.UserFavoriteStore
+}
+
+// ListFavoriteProjects returns the list of favorited project IDs for the current user.
+// GET /api/users/me/favorite-projects/
+func (h *FavoriteHandler) ListFavoriteProjects(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ if h.Favorites == nil {
+ c.JSON(http.StatusOK, gin.H{"project_ids": []string{}})
+ return
+ }
+ ids, err := h.Favorites.ListProjectIDsByUser(c.Request.Context(), user.ID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load favorites"})
+ return
+ }
+ strIds := make([]string, 0, len(ids))
+ for _, id := range ids {
+ strIds = append(strIds, id.String())
+ }
+ c.JSON(http.StatusOK, gin.H{"project_ids": strIds})
+}
+
+// AddFavoriteProject adds a project to the current user's favorites.
+// POST /api/workspaces/:slug/projects/:projectId/favorite
+func (h *FavoriteHandler) AddFavoriteProject(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ slug := c.Param("slug")
+ projectID, ok := projectID(c)
+ if !ok {
+ return
+ }
+ if h.Favorites == nil || h.Project == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Favorites not available"})
+ return
+ }
+ project, err := h.Project.GetByID(c.Request.Context(), slug, projectID, user.ID)
+ if err != nil {
+ if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add favorite"})
+ return
+ }
+ if err := h.Favorites.AddProject(c.Request.Context(), user.ID, project.WorkspaceID, projectID); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add favorite"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"project_id": projectID.String()})
+}
+
+// RemoveFavoriteProject removes a project from the current user's favorites.
+// DELETE /api/workspaces/:slug/projects/:projectId/favorite
+func (h *FavoriteHandler) RemoveFavoriteProject(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ projectID, ok := projectID(c)
+ if !ok {
+ return
+ }
+ if h.Favorites == nil || h.Project == nil {
+ c.JSON(http.StatusOK, gin.H{"message": "ok"})
+ return
+ }
+ // Verify user has access by resolving workspace + project
+ slug := c.Param("slug")
+ if _, err := h.Project.GetByID(c.Request.Context(), slug, projectID, user.ID); err != nil {
+ if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove favorite"})
+ return
+ }
+ if err := h.Favorites.RemoveProject(c.Request.Context(), user.ID, projectID); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove favorite"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"message": "ok"})
+}
diff --git a/api/internal/handler/instance.go b/api/internal/handler/instance.go
index cf65f90f..1cbce22b 100644
--- a/api/internal/handler/instance.go
+++ b/api/internal/handler/instance.go
@@ -3,7 +3,10 @@ package handler
import (
"crypto/rand"
"encoding/hex"
+ "encoding/json"
+ "io"
"net/http"
+ "net/url"
"strings"
"github.com/Devlaner/devlane/api/internal/auth"
@@ -292,3 +295,89 @@ func (h *InstanceSettingsHandler) UpdateSetting(c *gin.Context) {
responseValue := decryptSectionSecrets(key, value)
c.JSON(http.StatusOK, gin.H{"key": key, "value": responseValue})
}
+
+// unsplashPhoto is a single photo from Unsplash API response.
+type unsplashPhoto struct {
+ ID string `json:"id"`
+ URLs struct {
+ Full string `json:"full"`
+ Regular string `json:"regular"`
+ Thumb string `json:"thumb"`
+ } `json:"urls"`
+}
+
+// unsplashSearchResponse is the Unsplash search API response.
+type unsplashSearchResponse struct {
+ Results []unsplashPhoto `json:"results"`
+}
+
+// UnsplashSearchResult is a simplified photo returned by our proxy.
+type UnsplashSearchResult struct {
+ ID string `json:"id"`
+ URL string `json:"url"`
+ Thumb string `json:"thumb"`
+}
+
+// UnsplashSearch proxies search to Unsplash API using instance image settings key (auth required).
+// GET /api/instance/unsplash/search?q=...
+func (h *InstanceSettingsHandler) UnsplashSearch(c *gin.Context) {
+ if h.Settings == nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Settings not available"})
+ return
+ }
+ row, err := h.Settings.Get(c.Request.Context(), "image")
+ if err != nil || row == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Unsplash is not configured"})
+ return
+ }
+ decrypted := decryptSectionSecrets("image", row.Value)
+ keyVal, _ := decrypted["unsplash_access_key"].(string)
+ keyVal = strings.TrimSpace(keyVal)
+ if keyVal == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Unsplash is not configured"})
+ return
+ }
+
+ q := strings.TrimSpace(c.Query("q"))
+ if q == "" {
+ c.JSON(http.StatusOK, gin.H{"results": []UnsplashSearchResult{}})
+ return
+ }
+
+ apiURL := "https://api.unsplash.com/search/photos?query=" + url.QueryEscape(q) + "&per_page=20"
+ req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, apiURL, nil)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search"})
+ return
+ }
+ req.Header.Set("Authorization", "Client-ID "+keyVal)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search"})
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ _, _ = io.Copy(io.Discard, resp.Body)
+ c.JSON(http.StatusBadGateway, gin.H{"error": "Unsplash search failed"})
+ return
+ }
+
+ var payload unsplashSearchResponse
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid response"})
+ return
+ }
+
+ results := make([]UnsplashSearchResult, 0, len(payload.Results))
+ for _, p := range payload.Results {
+ u := p.URLs.Regular
+ if u == "" {
+ u = p.URLs.Full
+ }
+ results = append(results, UnsplashSearchResult{ID: p.ID, URL: u, Thumb: p.URLs.Thumb})
+ }
+ c.JSON(http.StatusOK, gin.H{"results": results})
+}
diff --git a/api/internal/handler/project.go b/api/internal/handler/project.go
index 4dc3a1fa..a4c620bf 100644
--- a/api/internal/handler/project.go
+++ b/api/internal/handler/project.go
@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/Devlaner/devlane/api/internal/middleware"
+ "github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -116,19 +117,22 @@ func (h *ProjectHandler) Update(c *gin.Context) {
return
}
var body struct {
- Name string `json:"name"`
- Identifier string `json:"identifier"`
- Description *string `json:"description"`
- Timezone *string `json:"timezone"`
- ProjectLeadID *string `json:"project_lead_id"`
- DefaultAssigneeID *string `json:"default_assignee_id"`
- GuestViewAllFeatures *bool `json:"guest_view_all_features"`
- ModuleView *bool `json:"module_view"`
- CycleView *bool `json:"cycle_view"`
- IssueViewsView *bool `json:"issue_views_view"`
- PageView *bool `json:"page_view"`
- IntakeView *bool `json:"intake_view"`
- IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"`
+ Name string `json:"name"`
+ Identifier string `json:"identifier"`
+ Description *string `json:"description"`
+ Timezone *string `json:"timezone"`
+ CoverImage *string `json:"cover_image"`
+ Emoji *string `json:"emoji"`
+ IconProp map[string]interface{} `json:"icon_prop"`
+ ProjectLeadID *string `json:"project_lead_id"`
+ DefaultAssigneeID *string `json:"default_assignee_id"`
+ GuestViewAllFeatures *bool `json:"guest_view_all_features"`
+ ModuleView *bool `json:"module_view"`
+ CycleView *bool `json:"cycle_view"`
+ IssueViewsView *bool `json:"issue_views_view"`
+ PageView *bool `json:"page_view"`
+ IntakeView *bool `json:"intake_view"`
+ IsTimeTrackingEnabled *bool `json:"is_time_tracking_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "detail": err.Error()})
@@ -147,6 +151,19 @@ func (h *ProjectHandler) Update(c *gin.Context) {
if body.Timezone != nil {
timezone = body.Timezone
}
+ var coverImage *string
+ if body.CoverImage != nil {
+ coverImage = body.CoverImage
+ }
+ var iconProp *model.JSONMap
+ if body.Emoji != nil && *body.Emoji != "" {
+ // When setting emoji, clear icon_prop
+ empty := model.JSONMap{}
+ iconProp = &empty
+ } else if len(body.IconProp) > 0 {
+ ip := model.JSONMap(body.IconProp)
+ iconProp = &ip
+ }
var projectLeadIDPtr *uuid.UUID
if body.ProjectLeadID != nil {
if *body.ProjectLeadID == "" {
@@ -173,7 +190,7 @@ func (h *ProjectHandler) Update(c *gin.Context) {
defaultAssigneeIDPtr = &id
}
}
- p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled)
+ p, err := h.Project.Update(c.Request.Context(), slug, projectID, user.ID, name, identifier, description, timezone, coverImage, body.Emoji, iconProp, body.ProjectLeadID != nil, projectLeadIDPtr, body.DefaultAssigneeID != nil, defaultAssigneeIDPtr, body.GuestViewAllFeatures, body.ModuleView, body.CycleView, body.IssueViewsView, body.PageView, body.IntakeView, body.IsTimeTrackingEnabled)
if err != nil {
if err == service.ErrProjectNotFound || err == service.ErrProjectForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
diff --git a/api/internal/handler/upload.go b/api/internal/handler/upload.go
new file mode 100644
index 00000000..62ffbc2f
--- /dev/null
+++ b/api/internal/handler/upload.go
@@ -0,0 +1,121 @@
+package handler
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/Devlaner/devlane/api/internal/middleware"
+ "github.com/Devlaner/devlane/api/internal/minio"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+// UploadHandler handles file uploads to MinIO.
+type UploadHandler struct {
+ Minio *minio.Client
+}
+
+var allowedImageTypes = map[string]bool{
+ "image/jpeg": true,
+ "image/jpg": true,
+ "image/png": true,
+ "image/webp": true,
+}
+
+// Upload accepts a multipart file and uploads it to MinIO.
+// POST /api/upload
+// Form: file (required). Returns { "url": "/api/files/uploads/..." }.
+func (h *UploadHandler) Upload(c *gin.Context) {
+ if h.Minio == nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "File upload is not configured"})
+ return
+ }
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+
+ file, err := c.FormFile("file")
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided", "detail": err.Error()})
+ return
+ }
+
+ f, err := file.Open()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"})
+ return
+ }
+ defer f.Close()
+
+ // Detect MIME from file bytes instead of trusting client Content-Type
+ buf := make([]byte, 512)
+ n, _ := io.ReadFull(f, buf)
+ buf = buf[:n]
+ contentType := http.DetectContentType(buf)
+ // Go's DetectContentType may not recognize WebP; check magic bytes (RIFF....WEBP)
+ if contentType == "application/octet-stream" && n >= 12 && string(buf[0:4]) == "RIFF" && string(buf[8:12]) == "WEBP" {
+ contentType = "image/webp"
+ }
+ if !allowedImageTypes[contentType] {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file type. Supported: .jpeg, .jpg, .png, .webp"})
+ return
+ }
+
+ // Recombine read bytes with remainder for upload
+ body := io.MultiReader(bytes.NewReader(buf), f)
+
+ ext := strings.ToLower(filepath.Ext(file.Filename))
+ if ext == "" {
+ ext = ".jpg"
+ }
+ if ext != ".jpeg" && ext != ".jpg" && ext != ".png" && ext != ".webp" {
+ ext = ".jpg"
+ }
+
+ now := time.Now().UTC()
+ objectName := fmt.Sprintf("uploads/%d/%02d/%s%s", now.Year(), now.Month(), uuid.New().String(), ext)
+
+ if err := h.Minio.PutObject(c.Request.Context(), objectName, body, file.Size, contentType); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload file"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"url": "/api/files/" + objectName})
+}
+
+// ServeFile streams a file from MinIO by path.
+// GET /api/files/*path
+func (h *UploadHandler) ServeFile(c *gin.Context) {
+ if h.Minio == nil {
+ c.Status(http.StatusServiceUnavailable)
+ return
+ }
+ path := strings.TrimPrefix(c.Param("path"), "/")
+ if path == "" || strings.Contains(path, "..") || !strings.HasPrefix(path, "uploads/") {
+ c.Status(http.StatusBadRequest)
+ return
+ }
+
+ obj, err := h.Minio.GetObject(c.Request.Context(), path)
+ if err != nil {
+ c.Status(http.StatusNotFound)
+ return
+ }
+ defer obj.Close()
+
+ info, err := obj.Stat()
+ if err != nil {
+ c.Status(http.StatusNotFound)
+ return
+ }
+
+ c.Header("Content-Type", info.ContentType)
+ c.DataFromReader(http.StatusOK, info.Size, info.ContentType, obj, nil)
+}
diff --git a/api/internal/handler/workspace.go b/api/internal/handler/workspace.go
index 50a0fb52..9c6489d8 100644
--- a/api/internal/handler/workspace.go
+++ b/api/internal/handler/workspace.go
@@ -1,12 +1,10 @@
package handler
import (
- "fmt"
"net/http"
"strings"
"github.com/Devlaner/devlane/api/internal/middleware"
- "github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
@@ -15,10 +13,8 @@ import (
// WorkspaceHandler serves workspace and member/invite endpoints.
type WorkspaceHandler struct {
- Workspace *service.WorkspaceService
- Settings *store.InstanceSettingStore
- Queue *queue.Publisher // optional: enqueue invite emails
- AppBaseURL string // optional: base URL for invite links (e.g. https://app.example.com)
+ Workspace *service.WorkspaceService
+ Settings *store.InstanceSettingStore
}
// List returns the current user's workspaces.
@@ -117,8 +113,9 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
}
slug := c.Param("slug")
var body struct {
- Name string `json:"name"`
- Slug string `json:"slug"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ Logo *string `json:"logo"`
}
_ = c.ShouldBindJSON(&body)
var name, newSlug *string
@@ -128,7 +125,7 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
if body.Slug != "" {
newSlug = &body.Slug
}
- w, err := h.Workspace.Update(c.Request.Context(), slug, user.ID, name, newSlug)
+ w, err := h.Workspace.Update(c.Request.Context(), slug, user.ID, name, newSlug, body.Logo)
if err != nil {
if err == service.ErrWorkspaceNotFound || err == service.ErrWorkspaceForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"})
@@ -376,26 +373,6 @@ func (h *WorkspaceHandler) CreateInvite(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create invite"})
return
}
-
- // Enqueue invite email when queue and base URL are configured
- if h.Queue != nil && h.AppBaseURL != "" {
- w, _ := h.Workspace.GetBySlug(c.Request.Context(), slug, user.ID)
- workspaceName := slug
- if w != nil {
- workspaceName = w.Name
- }
- inviteLink := strings.TrimSuffix(h.AppBaseURL, "/") + "/invite?token=" + inv.Token
- subject := fmt.Sprintf("You're invited to join %s on Devlane", workspaceName)
- bodyText := fmt.Sprintf("You have been invited to join the workspace \"%s\" on Devlane.\n\nAccept your invitation by visiting:\n%s\n\nIf you don't have an account yet, you can sign up at the same link.\n", workspaceName, inviteLink)
- _ = h.Queue.PublishSendEmail(c.Request.Context(), queue.SendEmailPayload{
- To: inv.Email,
- Subject: subject,
- Body: bodyText,
- Kind: "workspace_invite",
- Extra: map[string]string{"workspace_slug": slug, "invite_id": inv.ID.String()},
- })
- }
-
c.JSON(http.StatusCreated, inv)
}
diff --git a/api/internal/mail/mail.go b/api/internal/mail/mail.go
deleted file mode 100644
index 6573e321..00000000
--- a/api/internal/mail/mail.go
+++ /dev/null
@@ -1,110 +0,0 @@
-package mail
-
-import (
- "context"
- "fmt"
- "log/slog"
- "net/smtp"
- "strconv"
- "strings"
-
- "github.com/Devlaner/devlane/api/internal/crypto"
- "github.com/Devlaner/devlane/api/internal/store"
-)
-
-// smtpSettings from instance "email" section (after decrypting password).
-type smtpSettings struct {
- Host string
- Port int
- SenderEmail string
- Security string
- Username string
- Password string
-}
-
-func getEmailSettings(ctx context.Context, s *store.InstanceSettingStore) (*smtpSettings, error) {
- row, err := s.Get(ctx, "email")
- if err != nil || row == nil {
- return nil, fmt.Errorf("email settings not found")
- }
- v := row.Value
- if v == nil {
- return nil, fmt.Errorf("email settings empty")
- }
- host, _ := v["host"].(string)
- port := 587
- if p, ok := v["port"].(string); ok && p != "" {
- if n, err := strconv.Atoi(p); err == nil {
- port = n
- }
- }
- if p, ok := v["port"].(float64); ok {
- port = int(p)
- }
- sender, _ := v["sender_email"].(string)
- security, _ := v["security"].(string)
- username, _ := v["username"].(string)
- passRaw, _ := v["password"].(string)
- password := crypto.DecryptOrPlain(passRaw)
- host = strings.TrimSpace(host)
- if host == "" {
- return nil, fmt.Errorf("email host not configured")
- }
- return &smtpSettings{
- Host: host,
- Port: port,
- SenderEmail: strings.TrimSpace(sender),
- Security: strings.TrimSpace(security),
- Username: strings.TrimSpace(username),
- Password: password,
- }, nil
-}
-
-// NewSMTPEmailSender returns a sender function that loads SMTP config from instance
-// "email" settings and sends the email. If email is not configured or sending fails,
-// it logs and returns an error.
-func NewSMTPEmailSender(instanceSettings *store.InstanceSettingStore, log *slog.Logger) func(ctx context.Context, to, subject, body string) error {
- return func(ctx context.Context, to, subject, body string) error {
- cfg, err := getEmailSettings(ctx, instanceSettings)
- if err != nil {
- if log != nil {
- log.Warn("mail skip: instance email not configured", "to", to, "error", err)
- }
- return err
- }
- from := cfg.SenderEmail
- if from == "" {
- from = cfg.Username
- }
- if from == "" {
- if log != nil {
- log.Warn("mail skip: sender_email and username empty", "to", to)
- }
- return fmt.Errorf("sender email not configured")
- }
- addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
- auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
- msg := buildMessage(to, from, subject, body)
- if err := smtp.SendMail(addr, auth, from, []string{to}, msg); err != nil {
- if log != nil {
- log.Warn("mail send failed", "to", to, "error", err)
- }
- return err
- }
- if log != nil {
- log.Info("mail sent", "to", to, "subject", subject)
- }
- return nil
- }
-}
-
-func buildMessage(to, from, subject, body string) []byte {
- const crlf = "\r\n"
- h := "To: " + to + crlf +
- "From: " + from + crlf +
- "Subject: " + subject + crlf +
- "Content-Type: text/plain; charset=UTF-8" + crlf +
- "MIME-Version: 1.0" + crlf +
- crlf
- return []byte(h + body)
-}
diff --git a/api/internal/minio/minio.go b/api/internal/minio/minio.go
index 5b6b0542..5c2f7ac8 100644
--- a/api/internal/minio/minio.go
+++ b/api/internal/minio/minio.go
@@ -3,6 +3,7 @@ package minio
import (
"context"
"fmt"
+ "io"
"log/slog"
"github.com/Devlaner/devlane/api/internal/config"
@@ -53,3 +54,14 @@ func New(cfg *config.Config, log *slog.Logger) (*Client, error) {
func (c *Client) Bucket() string {
return c.bucket
}
+
+// PutObject uploads data to the default bucket.
+func (c *Client) PutObject(ctx context.Context, objectName string, reader io.Reader, size int64, contentType string) error {
+ _, err := c.Client.PutObject(ctx, c.bucket, objectName, reader, size, minio.PutObjectOptions{ContentType: contentType})
+ return err
+}
+
+// GetObject returns a reader for the object from the default bucket.
+func (c *Client) GetObject(ctx context.Context, objectName string) (*minio.Object, error) {
+ return c.Client.GetObject(ctx, c.bucket, objectName, minio.GetObjectOptions{})
+}
diff --git a/api/internal/model/project.go b/api/internal/model/project.go
index af788af1..3c6fe192 100644
--- a/api/internal/model/project.go
+++ b/api/internal/model/project.go
@@ -51,6 +51,7 @@ type Project struct {
IntakeView bool `gorm:"default:true" json:"intake_view"`
IsTimeTrackingEnabled bool `gorm:"column:is_time_tracking_enabled;default:false" json:"is_time_tracking_enabled"`
GuestViewAllFeatures bool `gorm:"column:guest_view_all_features;default:false" json:"guest_view_all_features"`
+ CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"`
Timezone string `gorm:"default:UTC" json:"timezone"`
}
diff --git a/api/internal/model/user.go b/api/internal/model/user.go
index f0791687..ef25a062 100644
--- a/api/internal/model/user.go
+++ b/api/internal/model/user.go
@@ -17,6 +17,7 @@ type User struct {
LastName string `gorm:"column:last_name;type:varchar(255);default:''" json:"last_name"`
DisplayName string `gorm:"column:display_name;type:varchar(255)" json:"display_name"`
Avatar string `gorm:"type:text" json:"avatar,omitempty"`
+ CoverImage string `gorm:"column:cover_image;type:text" json:"cover_image,omitempty"`
DateJoined time.Time `gorm:"column:date_joined;not null" json:"date_joined"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
diff --git a/api/internal/model/user_favorite.go b/api/internal/model/user_favorite.go
index c61733a7..47a2867c 100644
--- a/api/internal/model/user_favorite.go
+++ b/api/internal/model/user_favorite.go
@@ -12,11 +12,11 @@ type UserFavorite struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Name string `gorm:"type:varchar(255);not null" json:"name"`
Type string `gorm:"type:varchar(50);not null" json:"type"`
- EntityType string `gorm:"type:varchar(50);not null" json:"entity_type"`
- EntityIdentifier uuid.UUID `gorm:"type:uuid;not null" json:"entity_identifier"`
+ EntityType string `gorm:"type:varchar(50);not null;uniqueIndex:idx_user_fav_entity" json:"entity_type"`
+ EntityIdentifier uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_fav_entity" json:"entity_identifier"`
WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"`
- UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
+ UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex:idx_user_fav_entity" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index ed0a787c..e4f389ff 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -6,6 +6,7 @@ import (
"github.com/Devlaner/devlane/api/internal/auth"
"github.com/Devlaner/devlane/api/internal/handler"
"github.com/Devlaner/devlane/api/internal/middleware"
+ "github.com/Devlaner/devlane/api/internal/minio"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/redis"
"github.com/Devlaner/devlane/api/internal/service"
@@ -20,8 +21,8 @@ type Config struct {
DB *gorm.DB
Redis *redis.Client // optional: cache, locks, magic-link
Queue *queue.Publisher // optional: enqueue emails, webhooks
+ Minio *minio.Client // optional: file uploads (cover images, avatars, logos)
CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev
- AppBaseURL string // optional: base URL for invite links; if empty, CORSAllowOrigin is used
}
// New builds and returns the Gin engine with /api/ and /auth/ routes.
@@ -65,6 +66,7 @@ func New(cfg Config) *gin.Engine {
userRecentVisitStore := store.NewUserRecentVisitStore(cfg.DB)
userNotifPrefStore := store.NewUserNotificationPreferenceStore(cfg.DB)
apiTokenStore := store.NewApiTokenStore(cfg.DB)
+ userFavoriteStore := store.NewUserFavoriteStore(cfg.DB)
// Auth
authSvc := auth.NewService(userStore, sessionStore)
@@ -92,20 +94,10 @@ func New(cfg Config) *gin.Engine {
stickySvc := service.NewStickyService(stickyStore, workspaceStore)
recentVisitSvc := service.NewRecentVisitService(userRecentVisitStore, workspaceStore, issueStore, projectStore, pageStore)
- // Base URL for invite links (e.g. email links to frontend)
- appBaseURL := cfg.AppBaseURL
- if appBaseURL == "" {
- appBaseURL = cfg.CORSAllowOrigin
- }
-
// Handlers
- workspaceHandler := &handler.WorkspaceHandler{
- Workspace: workspaceSvc,
- Settings: instanceSettingStore,
- Queue: cfg.Queue,
- AppBaseURL: appBaseURL,
- }
+ workspaceHandler := &handler.WorkspaceHandler{Workspace: workspaceSvc, Settings: instanceSettingStore}
projectHandler := &handler.ProjectHandler{Project: projectSvc}
+ favoriteHandler := &handler.FavoriteHandler{Project: projectSvc, Favorites: userFavoriteStore}
stateHandler := &handler.StateHandler{State: stateSvc}
labelHandler := &handler.LabelHandler{Label: labelSvc}
issueHandler := &handler.IssueHandler{Issue: issueSvc}
@@ -133,8 +125,14 @@ func New(cfg Config) *gin.Engine {
api.GET("/users/me/tokens/", authHandler.ListTokens)
api.POST("/users/me/tokens/", authHandler.CreateToken)
api.DELETE("/users/me/tokens/:id/", authHandler.RevokeToken)
+ api.GET("/users/me/favorite-projects/", favoriteHandler.ListFavoriteProjects)
api.GET("/instance/settings/", instanceSettingsHandler.GetSettings)
api.PATCH("/instance/settings/:key", instanceSettingsHandler.UpdateSetting)
+ api.GET("/instance/unsplash/search", instanceSettingsHandler.UnsplashSearch)
+
+ uploadHandler := &handler.UploadHandler{Minio: cfg.Minio}
+ api.POST("/upload", uploadHandler.Upload)
+ api.GET("/files/*path", uploadHandler.ServeFile)
api.GET("/users/me/workspaces/", workspaceHandler.List)
api.GET("/users/me/workspaces/invitations/", workspaceHandler.ListUserInvitations)
api.GET("/workspace-slug-check/", workspaceHandler.SlugCheck)
@@ -161,6 +159,8 @@ func New(cfg Config) *gin.Engine {
api.GET("/workspaces/:slug/projects/:projectId/", projectHandler.Get)
api.PATCH("/workspaces/:slug/projects/:projectId/", projectHandler.Update)
api.DELETE("/workspaces/:slug/projects/:projectId/", projectHandler.Delete)
+ api.POST("/workspaces/:slug/projects/:projectId/favorite", favoriteHandler.AddFavoriteProject)
+ api.DELETE("/workspaces/:slug/projects/:projectId/favorite", favoriteHandler.RemoveFavoriteProject)
api.GET("/workspaces/:slug/projects/:projectId/members/", projectHandler.ListMembers)
api.POST("/workspaces/:slug/projects/:projectId/members/leave/", projectHandler.Leave)
api.GET("/workspaces/:slug/projects/:projectId/members/:pk/", projectHandler.GetMember)
diff --git a/api/internal/service/project.go b/api/internal/service/project.go
index 0503300d..bfbfe500 100644
--- a/api/internal/service/project.go
+++ b/api/internal/service/project.go
@@ -78,7 +78,7 @@ func (s *ProjectService) Create(ctx context.Context, workspaceSlug, name, identi
return p, nil
}
-func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone *string, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool) (*model.Project, error) {
+func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID, name, identifier, description, timezone, coverImage *string, emoji *string, iconProp *model.JSONMap, projectLeadIDSet bool, projectLeadID *uuid.UUID, defaultAssigneeIDSet bool, defaultAssigneeID *uuid.UUID, guestViewAllFeatures *bool, moduleView, cycleView, issueViewsView, pageView, intakeView, isTimeTrackingEnabled *bool) (*model.Project, error) {
p, err := s.GetByID(ctx, workspaceSlug, projectID, userID)
if err != nil {
return nil, err
@@ -95,6 +95,18 @@ func (s *ProjectService) Update(ctx context.Context, workspaceSlug string, proje
if timezone != nil {
p.Timezone = *timezone
}
+ if coverImage != nil {
+ p.CoverImage = *coverImage
+ }
+ if emoji != nil {
+ p.Emoji = *emoji
+ }
+ if iconProp != nil {
+ p.IconProp = *iconProp
+ if len(*iconProp) > 0 {
+ p.Emoji = "" // clear emoji when setting icon
+ }
+ }
if projectLeadIDSet {
p.ProjectLeadID = projectLeadID
}
diff --git a/api/internal/service/workspace.go b/api/internal/service/workspace.go
index 2584502e..bf587854 100644
--- a/api/internal/service/workspace.go
+++ b/api/internal/service/workspace.go
@@ -83,7 +83,7 @@ func (s *WorkspaceService) Create(ctx context.Context, name, slug string, ownerI
return w, nil
}
-func (s *WorkspaceService) Update(ctx context.Context, slug string, userID uuid.UUID, name, newSlug *string) (*model.Workspace, error) {
+func (s *WorkspaceService) Update(ctx context.Context, slug string, userID uuid.UUID, name, newSlug, logo *string) (*model.Workspace, error) {
w, err := s.GetBySlug(ctx, slug, userID)
if err != nil {
return nil, err
@@ -91,6 +91,9 @@ func (s *WorkspaceService) Update(ctx context.Context, slug string, userID uuid.
if name != nil {
w.Name = *name
}
+ if logo != nil {
+ w.Logo = *logo
+ }
if newSlug != nil {
slugVal := strings.TrimSpace(strings.ToLower(*newSlug))
if !slugRegex.MatchString(slugVal) {
diff --git a/api/internal/store/user_favorite.go b/api/internal/store/user_favorite.go
new file mode 100644
index 00000000..538f7b70
--- /dev/null
+++ b/api/internal/store/user_favorite.go
@@ -0,0 +1,53 @@
+package store
+
+import (
+ "context"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+const FavoriteEntityTypeProject = "project"
+
+// UserFavoriteStore handles user_favorites persistence.
+type UserFavoriteStore struct{ db *gorm.DB }
+
+func NewUserFavoriteStore(db *gorm.DB) *UserFavoriteStore {
+ return &UserFavoriteStore{db: db}
+}
+
+// ListProjectIDsByUser returns project IDs the user has favorited.
+func (s *UserFavoriteStore) ListProjectIDsByUser(ctx context.Context, userID uuid.UUID) ([]uuid.UUID, error) {
+ var ids []uuid.UUID
+ err := s.db.WithContext(ctx).Model(&model.UserFavorite{}).
+ Where("user_id = ? AND entity_type = ?", userID, FavoriteEntityTypeProject).
+ Pluck("entity_identifier", &ids).Error
+ return ids, err
+}
+
+// AddProject adds a project to the user's favorites. workspaceID is stored for the favorite record.
+// Idempotent: uses ON CONFLICT DO NOTHING so duplicate inserts are ignored.
+func (s *UserFavoriteStore) AddProject(ctx context.Context, userID, workspaceID, projectID uuid.UUID) error {
+ fav := &model.UserFavorite{
+ Name: "project",
+ Type: "project",
+ EntityType: FavoriteEntityTypeProject,
+ EntityIdentifier: projectID,
+ WorkspaceID: workspaceID,
+ ProjectID: &projectID,
+ UserID: userID,
+ }
+ return s.db.WithContext(ctx).Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "user_id"}, {Name: "entity_type"}, {Name: "entity_identifier"}},
+ DoNothing: true,
+ }).Create(fav).Error
+}
+
+// RemoveProject removes a project from the user's favorites.
+func (s *UserFavoriteStore) RemoveProject(ctx context.Context, userID uuid.UUID, projectID uuid.UUID) error {
+ return s.db.WithContext(ctx).
+ Where("user_id = ? AND entity_type = ? AND entity_identifier = ?", userID, FavoriteEntityTypeProject, projectID).
+ Delete(&model.UserFavorite{}).Error
+}
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index b0d87bbb..089c692c 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,5 +1,6 @@
import { RouterProvider } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
+import { FavoritesProvider } from "./contexts/FavoritesContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import { router } from "./routes";
@@ -7,7 +8,9 @@ export default function App() {
return (
-
+
+
+
);
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 2b96523c..9eb77ad2 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -15,6 +15,7 @@ export interface WorkspaceApiResponse {
name: string;
slug: string;
owner_id: string;
+ logo?: string;
created_at?: string;
updated_at?: string;
}
@@ -50,6 +51,12 @@ export interface CreateProjectRequest {
identifier?: string;
}
+/** Project icon_prop from API (name + optional color) */
+export interface ProjectIconProp {
+ name?: string;
+ color?: string;
+}
+
/** Project as returned by the API (list + get) */
export interface ProjectApiResponse {
id: string;
@@ -59,6 +66,9 @@ export interface ProjectApiResponse {
identifier?: string;
slug?: string;
timezone?: string;
+ cover_image?: string;
+ emoji?: string;
+ icon_prop?: ProjectIconProp | null;
project_lead_id?: string | null;
default_assignee_id?: string | null;
guest_view_all_features?: boolean;
@@ -188,6 +198,7 @@ export interface UserApiResponse {
last_name: string;
display_name: string;
avatar?: string;
+ cover_image?: string;
is_active: boolean;
is_onboarded: boolean;
date_joined: string;
@@ -202,6 +213,8 @@ export interface UpdateMeRequest {
last_name?: string;
display_name?: string;
user_timezone?: string;
+ avatar?: string;
+ cover_image?: string;
}
/** POST /api/users/me/change-password/ */
@@ -368,8 +381,11 @@ export interface IssueViewApiResponse {
export interface PageApiResponse {
id: string;
name: string;
+ /** Display title (may equal name); use for list display. */
+ title?: string;
description_html?: string;
owned_by_id: string;
+ updated_by_id?: string | null;
workspace_id: string;
access: number;
parent_id?: string | null;
diff --git a/ui/src/components/CoverImageModal.tsx b/ui/src/components/CoverImageModal.tsx
new file mode 100644
index 00000000..84e25d3b
--- /dev/null
+++ b/ui/src/components/CoverImageModal.tsx
@@ -0,0 +1,292 @@
+import { useCallback, useEffect, useState } from "react";
+import { Button, Modal } from "./ui";
+import {
+ instanceSettingsService,
+ type UnsplashSearchResult,
+} from "../services/instanceService";
+import { uploadImage } from "../services/uploadService";
+
+const TAB_UNSPLASH = "unsplash";
+const TAB_UPLOAD = "upload";
+type Tab = typeof TAB_UNSPLASH | typeof TAB_UPLOAD;
+
+export interface CoverImageModalProps {
+ open: boolean;
+ onClose: () => void;
+ onSelect: (url: string) => void;
+ title?: string;
+}
+
+export function CoverImageModal({
+ open,
+ onClose,
+ onSelect,
+ title = "Select cover image",
+}: CoverImageModalProps) {
+ const [tab, setTab] = useState(TAB_UNSPLASH);
+ const [unsplashQuery, setUnsplashQuery] = useState("");
+ const [unsplashResults, setUnsplashResults] = useState<
+ UnsplashSearchResult[]
+ >([]);
+ const [unsplashLoading, setUnsplashLoading] = useState(false);
+ const [unsplashError, setUnsplashError] = useState(null);
+ const [selectedUrl, setSelectedUrl] = useState(null);
+ const [uploadFile, setUploadFile] = useState(null);
+ const [uploadPreview, setUploadPreview] = useState(null);
+ const [uploadLoading, setUploadLoading] = useState(false);
+ const [uploadError, setUploadError] = useState(null);
+
+ useEffect(() => {
+ if (!open) {
+ setTab(TAB_UNSPLASH);
+ setUnsplashQuery("");
+ setUnsplashResults([]);
+ setUnsplashError(null);
+ setSelectedUrl(null);
+ setUploadFile(null);
+ setUploadPreview(null);
+ setUploadError(null);
+ }
+ }, [open]);
+
+ const handleSearch = useCallback(async () => {
+ if (!unsplashQuery.trim()) return;
+ setUnsplashError(null);
+ setUnsplashLoading(true);
+ try {
+ const { results } = await instanceSettingsService.unsplashSearch(
+ unsplashQuery.trim(),
+ );
+ setUnsplashResults(results);
+ } catch (e) {
+ setUnsplashError(e instanceof Error ? e.message : "Search failed");
+ setUnsplashResults([]);
+ } finally {
+ setUnsplashLoading(false);
+ }
+ }, [unsplashQuery]);
+
+ const handleUnsplashSelect = useCallback(() => {
+ if (selectedUrl) {
+ onSelect(selectedUrl);
+ onClose();
+ }
+ }, [selectedUrl, onSelect, onClose]);
+
+ const handleFileChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
+ if (!allowed.includes(file.type)) {
+ setUploadError(
+ "Invalid file type. Supported: .jpeg, .jpg, .png, .webp",
+ );
+ return;
+ }
+ setUploadError(null);
+ setUploadFile(file);
+ const reader = new FileReader();
+ reader.onload = () => setUploadPreview(reader.result as string);
+ reader.readAsDataURL(file);
+ },
+ [],
+ );
+
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ const file = e.dataTransfer.files?.[0];
+ if (!file) return;
+ const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
+ if (!allowed.includes(file.type)) {
+ setUploadError("Invalid file type. Supported: .jpeg, .jpg, .png, .webp");
+ return;
+ }
+ setUploadError(null);
+ setUploadFile(file);
+ const reader = new FileReader();
+ reader.onload = () => setUploadPreview(reader.result as string);
+ reader.readAsDataURL(file);
+ }, []);
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => e.preventDefault(),
+ [],
+ );
+
+ const handleUploadSave = useCallback(async () => {
+ if (!uploadFile) return;
+ setUploadError(null);
+ setUploadLoading(true);
+ try {
+ const { url } = await uploadImage(uploadFile);
+ onSelect(url);
+ onClose();
+ } catch (e) {
+ setUploadError(e instanceof Error ? e.message : "Upload failed");
+ } finally {
+ setUploadLoading(false);
+ }
+ }, [uploadFile, onSelect, onClose]);
+
+ const handleRemoveUpload = useCallback(() => {
+ setUploadFile(null);
+ setUploadPreview(null);
+ setUploadError(null);
+ }, []);
+
+ const footer = (
+ <>
+
+ {tab === TAB_UNSPLASH && (
+
+ )}
+ {tab === TAB_UPLOAD && (
+
+ )}
+ >
+ );
+
+ return (
+
+
+
+
+
+
+ {tab === TAB_UNSPLASH && (
+
+
+ setUnsplashQuery(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSearch()}
+ placeholder="Search for images"
+ className="min-w-0 flex-1 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] px-3 py-2 text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]"
+ />
+
+
+ {unsplashError && (
+
+ {unsplashError}
+
+ )}
+
+ {unsplashResults.map((r) => (
+
+ ))}
+
+
+ )}
+
+ {tab === TAB_UPLOAD && (
+
+ {!uploadPreview ? (
+
+
+ Drag & drop image here
+
+
+
+ ) : (
+
+

+
+
+
+
+ )}
+
+ File formats supported: .jpeg, .jpg, .png, .webp
+
+ {uploadError && (
+
+ {uploadError}
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/ProjectIconModal.tsx b/ui/src/components/ProjectIconModal.tsx
new file mode 100644
index 00000000..5773018a
--- /dev/null
+++ b/ui/src/components/ProjectIconModal.tsx
@@ -0,0 +1,323 @@
+import { useState, useMemo, useEffect } from "react";
+import { Modal } from "./ui";
+
+const EMOJI_LIST = [
+ "π ",
+ "π",
+ "π",
+ "π‘",
+ "π§",
+ "β",
+ "π―",
+ "π",
+ "π·οΈ",
+ "π",
+ "β
",
+ "π",
+ "π",
+ "π±",
+ "π»",
+ "π",
+ "π¨",
+ "π",
+ "π",
+ "π₯",
+ "β€οΈ",
+ "π",
+ "π¦",
+ "π",
+ "βοΈ",
+ "π",
+ "π",
+ "π",
+ "ποΈ",
+ "π",
+ "πͺ",
+ "π§©",
+ "π",
+ "π οΈ",
+ "πΌ",
+ "π",
+ "π",
+];
+
+const ICON_COLORS = [
+ "#94a3b8",
+ "#64748b",
+ "#6366f1",
+ "#3b82f6",
+ "#0ea5e9",
+ "#22c55e",
+ "#eab308",
+ "#f97316",
+ "#ec4899",
+ "#ffffff",
+];
+
+// Simple line icons (name -> path d)
+const ICON_PATHS: Record = {
+ home: "m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z",
+ star: "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z",
+ folder:
+ "M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z",
+ briefcase: "M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16",
+ zap: "M13 2 3 14h9l-1 8 10-12h-9l1-8z",
+ target:
+ "M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z",
+ flag: "M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z",
+ bookmark: "m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z",
+ box: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z",
+ globe:
+ "M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z",
+ cog: "M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16z",
+ heart:
+ "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z",
+ rocket:
+ "M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z",
+ lightbulb:
+ "M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5",
+ palette:
+ "M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z",
+};
+
+const ICON_NAMES = Object.keys(ICON_PATHS);
+
+function IconSvg({
+ name,
+ color,
+ size = 24,
+}: {
+ name: string;
+ color?: string;
+ size?: number;
+}) {
+ const d = ICON_PATHS[name];
+ if (!d) return null;
+ return (
+
+ );
+}
+
+/** Renders project icon (emoji or icon with color) for display in headers/sidebar. */
+export function ProjectIconDisplay({
+ emoji,
+ icon_prop,
+ size = 20,
+ className = "",
+}: {
+ emoji?: string | null;
+ icon_prop?: { name?: string; color?: string } | null;
+ size?: number;
+ className?: string;
+}) {
+ if (emoji) {
+ return (
+
+ {emoji}
+
+ );
+ }
+ if (icon_prop?.name && ICON_PATHS[icon_prop.name]) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+}
+
+export interface ProjectIconSelection {
+ emoji?: string | null;
+ icon_prop?: { name: string; color?: string } | null;
+}
+
+export interface ProjectIconModalProps {
+ open: boolean;
+ onClose: () => void;
+ onSelect: (selection: ProjectIconSelection) => void;
+ title?: string;
+ currentEmoji?: string | null;
+ currentIconProp?: { name?: string; color?: string } | null;
+}
+
+const TAB_EMOJI = "emoji";
+const TAB_ICON = "icon";
+type Tab = typeof TAB_EMOJI | typeof TAB_ICON;
+
+export function ProjectIconModal({
+ open,
+ onClose,
+ onSelect,
+ title = "Project icon",
+ currentEmoji: _currentEmoji,
+ currentIconProp,
+}: ProjectIconModalProps) {
+ const [tab, setTab] = useState(TAB_EMOJI);
+ const [emojiSearch, setEmojiSearch] = useState("");
+ const [iconColor, setIconColor] = useState(
+ currentIconProp?.color ?? "#6366f1",
+ );
+
+ useEffect(() => {
+ if (open) {
+ setIconColor(currentIconProp?.color ?? "#6366f1");
+ setEmojiSearch("");
+ }
+ }, [open, currentIconProp?.color]);
+
+ const filteredEmojis = useMemo(() => {
+ if (!emojiSearch.trim()) return EMOJI_LIST;
+ const q = emojiSearch.toLowerCase();
+ return EMOJI_LIST.filter(
+ (e) =>
+ e.includes(q) ||
+ String.fromCodePoint(e.codePointAt(0)!).toLowerCase().includes(q),
+ );
+ }, [emojiSearch]);
+
+ const handleEmojiSelect = (emoji: string) => {
+ onSelect({ emoji, icon_prop: null });
+ onClose();
+ };
+
+ const handleIconSelect = (name: string) => {
+ onSelect({ emoji: null, icon_prop: { name, color: iconColor } });
+ onClose();
+ };
+
+ const footer = (
+
+ );
+
+ return (
+
+
+
+
+
+
+ {tab === TAB_EMOJI && (
+
+
setEmojiSearch(e.target.value)}
+ placeholder="Search"
+ className="w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-surface-1)] px-3 py-2 text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none focus:border-[var(--border-strong)]"
+ />
+
+ {filteredEmojis.map((emoji) => (
+
+ ))}
+
+
+ )}
+
+ {tab === TAB_ICON && (
+
+
Color
+
+ {ICON_COLORS.map((c) => (
+
+
Choose an icon
+
+ {ICON_NAMES.map((name) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/ui/src/components/UploadImageModal.tsx b/ui/src/components/UploadImageModal.tsx
new file mode 100644
index 00000000..5a7903e2
--- /dev/null
+++ b/ui/src/components/UploadImageModal.tsx
@@ -0,0 +1,168 @@
+import { useCallback, useEffect, useState } from "react";
+import { Button, Modal } from "./ui";
+import { uploadImage } from "../services/uploadService";
+
+export interface UploadImageModalProps {
+ open: boolean;
+ onClose: () => void;
+ onSave: (url: string) => void;
+ title?: string;
+}
+
+export function UploadImageModal({
+ open,
+ onClose,
+ onSave,
+ title = "Upload image",
+}: UploadImageModalProps) {
+ const [file, setFile] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!open) {
+ setFile(null);
+ setPreview(null);
+ setError(null);
+ }
+ }, [open]);
+
+ const handleFileChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const f = e.target.files?.[0];
+ if (!f) return;
+ const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
+ if (!allowed.includes(f.type)) {
+ setError("Invalid file type. Supported: .jpeg, .jpg, .png, .webp");
+ return;
+ }
+ setError(null);
+ setFile(f);
+ const reader = new FileReader();
+ reader.onload = () => setPreview(reader.result as string);
+ reader.readAsDataURL(f);
+ },
+ [],
+ );
+
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ const f = e.dataTransfer.files?.[0];
+ if (!f) return;
+ const allowed = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
+ if (!allowed.includes(f.type)) {
+ setError("Invalid file type. Supported: .jpeg, .jpg, .png, .webp");
+ return;
+ }
+ setError(null);
+ setFile(f);
+ const reader = new FileReader();
+ reader.onload = () => setPreview(reader.result as string);
+ reader.readAsDataURL(f);
+ }, []);
+
+ const handleDragOver = useCallback(
+ (e: React.DragEvent) => e.preventDefault(),
+ [],
+ );
+
+ const handleRemove = useCallback(() => {
+ setFile(null);
+ setPreview(null);
+ setError(null);
+ }, []);
+
+ const handleUploadSave = useCallback(async () => {
+ if (!file) return;
+ setError(null);
+ setLoading(true);
+ try {
+ const { url } = await uploadImage(file);
+ onSave(url);
+ onClose();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Upload failed");
+ } finally {
+ setLoading(false);
+ }
+ }, [file, onSave, onClose]);
+
+ const footer = (
+ <>
+ {preview && (
+
+ )}
+
+
+ >
+ );
+
+ return (
+
+
+ {!preview ? (
+
+
+ Drag & drop image here
+
+
+
+ ) : (
+
+

+
+
+
+
+ )}
+
+ File formats supported: .jpeg, .jpg, .png, .webp
+
+ {error && (
+
{error}
+ )}
+
+
+ );
+}
diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx
index 39de7e26..a8e4cad1 100644
--- a/ui/src/components/layout/PageHeader.tsx
+++ b/ui/src/components/layout/PageHeader.tsx
@@ -1194,8 +1194,10 @@ export function PageHeader() {
workspaceSlug?: string;
projectId?: string;
}>();
- const [workspace, setWorkspace] = useState(null);
- const [projects, setProjects] = useState([]);
+ const [_workspace, setWorkspace] = useState(
+ null,
+ );
+ const [_projects, setProjects] = useState([]);
const [project, setProject] = useState(null);
const [projectIssueCount, setProjectIssueCount] = useState(0);
diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx
index a14e123c..a7aff584 100644
--- a/ui/src/components/layout/Sidebar.tsx
+++ b/ui/src/components/layout/Sidebar.tsx
@@ -1,14 +1,15 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link, NavLink, useLocation, useParams } from "react-router-dom";
import { workspaceService } from "../../services/workspaceService";
import { projectService } from "../../services/projectService";
+import { favoriteService } from "../../services/favoriteService";
import type { WorkspaceApiResponse, ProjectApiResponse } from "../../api/types";
-import type { Project } from "../../types";
import { CreateWorkItemModal } from "../CreateWorkItemModal";
import { Avatar, Button } from "../ui";
import { useAuth } from "../../contexts/AuthContext";
-import { cn } from "../../lib/utils";
+import { useFavorites } from "../../contexts/FavoritesContext";
+import { cn, getImageUrl } from "../../lib/utils";
const SIDEBAR_WIDTH = 256;
const SIDEBAR_WIDTH_COLLAPSED = 0;
@@ -442,6 +443,7 @@ export function Sidebar() {
const [createWorkItemOpen, setCreateWorkItemOpen] = useState(false);
const [workspaces, setWorkspaces] = useState([]);
const [projects, setProjects] = useState([]);
+ const { favoriteProjectIds, setFavoriteProjectIds } = useFavorites();
const workspaceTriggerRef = useRef(null);
const workspaceDropdownRef = useRef(null);
@@ -465,17 +467,8 @@ export function Sidebar() {
: workspace
? `/${workspace.slug}`
: "";
- const favoriteProjects = projects.slice(0, 1);
- const projectsForModal: Project[] = useMemo(
- () =>
- projects.map((p) => ({
- id: p.id,
- workspaceId: p.workspace_id,
- name: p.name,
- identifier: p.identifier ?? p.id.slice(0, 2),
- description: p.description ?? null,
- })),
- [projects],
+ const favoriteProjects = projects.filter((p) =>
+ favoriteProjectIds.includes(p.id),
);
useEffect(() => {
@@ -509,6 +502,21 @@ export function Sidebar() {
};
}, [slugForProjects]);
+ useEffect(() => {
+ let cancelled = false;
+ favoriteService
+ .getFavoriteProjectIds()
+ .then((ids) => {
+ if (!cancelled) setFavoriteProjectIds(ids);
+ })
+ .catch(() => {
+ if (!cancelled) setFavoriteProjectIds([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
useEffect(() => {
if (!baseUrl) return;
const path = location.pathname;
@@ -596,8 +604,16 @@ export function Sidebar() {
aria-haspopup="true"
aria-label="Workspace menu"
>
-
- {(workspace?.name ?? "β").slice(0, 2).toUpperCase()}
+
+ {workspace?.logo && getImageUrl(workspace.logo) ? (
+
!})
+ ) : (
+ (workspace?.name ?? "β").slice(0, 2).toUpperCase()
+ )}
{workspace?.name ?? "Loadingβ¦"}
@@ -629,7 +645,7 @@ export function Sidebar() {
@@ -656,8 +672,16 @@ export function Sidebar() {
{user?.email}
-
- {(workspace?.name ?? "β").slice(0, 2)}
+
+ {workspace?.logo && getImageUrl(workspace.logo) ? (
+
!})
+ ) : (
+ (workspace?.name ?? "β").slice(0, 2)
+ )}
@@ -1091,7 +1115,7 @@ export function Sidebar() {
open={createWorkItemOpen}
onClose={() => setCreateWorkItemOpen(false)}
workspaceSlug={workspace?.slug ?? ""}
- projects={projectsForModal}
+ projects={projects}
/>
>
);
diff --git a/ui/src/components/work-item/CommentEditor.tsx b/ui/src/components/work-item/CommentEditor.tsx
index 1b06cfe6..d1ec22ce 100644
--- a/ui/src/components/work-item/CommentEditor.tsx
+++ b/ui/src/components/work-item/CommentEditor.tsx
@@ -28,7 +28,7 @@ export function CommentEditor({
StarterKit.configure({
bulletList: { keepMarks: true, keepAttributes: true },
orderedList: { keepMarks: true, keepAttributes: true },
- codeBlock: true,
+ codeBlock: {},
}),
Underline,
Placeholder.configure({
diff --git a/ui/src/contexts/AuthContext.tsx b/ui/src/contexts/AuthContext.tsx
index 6fcaba5a..923f264a 100644
--- a/ui/src/contexts/AuthContext.tsx
+++ b/ui/src/contexts/AuthContext.tsx
@@ -21,6 +21,7 @@ function mapApiUserToUser(api: UserApiResponse): User {
email: api.email ?? "",
name,
avatarUrl: api.avatar ?? null,
+ coverImageUrl: api.cover_image ?? null,
};
}
diff --git a/ui/src/contexts/FavoritesContext.tsx b/ui/src/contexts/FavoritesContext.tsx
new file mode 100644
index 00000000..914b1691
--- /dev/null
+++ b/ui/src/contexts/FavoritesContext.tsx
@@ -0,0 +1,28 @@
+import { createContext, useContext, useState, type ReactNode } from "react";
+
+interface FavoritesContextValue {
+ favoriteProjectIds: string[];
+ setFavoriteProjectIds: (
+ ids: string[] | ((prev: string[]) => string[]),
+ ) => void;
+}
+
+const FavoritesContext = createContext(null);
+
+export function FavoritesProvider({ children }: { children: ReactNode }) {
+ const [favoriteProjectIds, setFavoriteProjectIds] = useState([]);
+ return (
+
+ {children}
+
+ );
+}
+
+export function useFavorites(): FavoritesContextValue {
+ const ctx = useContext(FavoritesContext);
+ if (!ctx)
+ throw new Error("useFavorites must be used within FavoritesProvider");
+ return ctx;
+}
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
index 9996a21e..05f0c52b 100644
--- a/ui/src/lib/utils.ts
+++ b/ui/src/lib/utils.ts
@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
+import { config } from "../config/env";
/**
* Merges Tailwind classes with clsx, resolving conflicts via tailwind-merge.
@@ -7,3 +8,12 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+
+/** Resolve image URL for display (relative API paths get base URL prepended). */
+export function getImageUrl(url: string | null | undefined): string | null {
+ if (!url || typeof url !== "string") return null;
+ if (url.startsWith("http://") || url.startsWith("https://")) return url;
+ const base = (config.apiBaseUrl ?? "").replace(/\/+$/, "");
+ const path = url.startsWith("/") ? url : "/" + url;
+ return base + path;
+}
diff --git a/ui/src/pages/AnalyticsWorkItemsPage.tsx b/ui/src/pages/AnalyticsWorkItemsPage.tsx
index ad7cdf51..bf25c723 100644
--- a/ui/src/pages/AnalyticsWorkItemsPage.tsx
+++ b/ui/src/pages/AnalyticsWorkItemsPage.tsx
@@ -226,17 +226,17 @@ export function AnalyticsWorkItemsPage() {
];
const projectRows = projects.map((p) => {
- const projIssues = issues.filter((i) => i.projectId === p.id);
+ const projIssues = issues.filter((i) => i.project_id === p.id);
return {
project: p,
- backlog: projIssues.filter((i) => getStateName(i.stateId) === "Backlog")
+ backlog: projIssues.filter((i) => getStateName(i.state_id) === "Backlog")
.length,
started: projIssues.filter(
- (i) => getStateName(i.stateId) === "In Progress",
+ (i) => getStateName(i.state_id) === "In Progress",
).length,
- unstarted: projIssues.filter((i) => getStateName(i.stateId) === "Todo")
+ unstarted: projIssues.filter((i) => getStateName(i.state_id) === "Todo")
.length,
- completed: projIssues.filter((i) => getStateName(i.stateId) === "Done")
+ completed: projIssues.filter((i) => getStateName(i.state_id) === "Done")
.length,
cancelled: 0,
};
diff --git a/ui/src/pages/BoardPage.tsx b/ui/src/pages/BoardPage.tsx
index dde8212e..3a76e09e 100644
--- a/ui/src/pages/BoardPage.tsx
+++ b/ui/src/pages/BoardPage.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
-import { Card, CardContent, Badge, Avatar } from "../components/ui";
+import { Card, CardContent, Badge } from "../components/ui";
import { workspaceService } from "../services/workspaceService";
import { projectService } from "../services/projectService";
import { issueService } from "../services/issueService";
diff --git a/ui/src/pages/CyclesPage.tsx b/ui/src/pages/CyclesPage.tsx
index f4dc4a36..0ea4f386 100644
--- a/ui/src/pages/CyclesPage.tsx
+++ b/ui/src/pages/CyclesPage.tsx
@@ -194,7 +194,9 @@ export function CyclesPage() {
const getIssueCount = (cycleId: string) =>
cycles.find((c) => c.id === cycleId)?.issue_count ?? 0;
- const getUser = (_userId: string | null) => null;
+ const getUser = (
+ _userId: string | null,
+ ): { name: string; avatarUrl?: string | null } | null => null;
if (loading) {
return (
diff --git a/ui/src/pages/InviteAcceptPage.tsx b/ui/src/pages/InviteAcceptPage.tsx
deleted file mode 100644
index 67fe4557..00000000
--- a/ui/src/pages/InviteAcceptPage.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import { useEffect, useState } from "react";
-import { useNavigate, useSearchParams } from "react-router-dom";
-import { Button, Card, CardContent } from "../components/ui";
-import { useAuth } from "../contexts/AuthContext";
-import { workspaceService } from "../services/workspaceService";
-
-export function InviteAcceptPage() {
- const [searchParams] = useSearchParams();
- const token = searchParams.get("token");
- const navigate = useNavigate();
- const { user, isLoading: authLoading } = useAuth();
- const [status, setStatus] = useState<
- "idle" | "loading" | "success" | "error"
- >("idle");
- const [error, setError] = useState("");
-
- useEffect(() => {
- if (!token) {
- setStatus("error");
- setError("Invalid or missing invite link.");
- return;
- }
- if (authLoading) return;
- if (!user) {
- navigate("/login", {
- replace: true,
- state: {
- from: {
- pathname: "/invite",
- search: searchParams.toString(),
- },
- },
- });
- return;
- }
- let cancelled = false;
- setStatus("loading");
- setError("");
- workspaceService
- .joinByToken(token)
- .then((workspace) => {
- if (cancelled) return;
- setStatus("success");
- navigate(`/${workspace.slug}`, { replace: true });
- })
- .catch((err) => {
- if (cancelled) return;
- setStatus("error");
- const msg =
- err?.response?.data?.error ??
- (err instanceof Error
- ? err.message
- : "Invalid or expired invite. Please request a new link.");
- setError(String(msg));
- });
- return () => {
- cancelled = true;
- };
- }, [token, user, authLoading, navigate, searchParams]);
-
- if (!token) {
- return (
-
-
-
-
- Invalid invite link
-
-
- This invite link is missing a token. Please use the link from your
- invitation email.
-
-
-
-
-
- );
- }
-
- if (authLoading || status === "loading") {
- return (
-
-
-
-
- Accepting invitationβ¦
-
-
-
-
- );
- }
-
- if (status === "error") {
- return (
-
-
-
-
- Invite could not be accepted
-
-
- {error}
-
-
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
- Redirecting to workspaceβ¦
-
-
-
-
- );
-}
diff --git a/ui/src/pages/IssueDetailPage.tsx b/ui/src/pages/IssueDetailPage.tsx
index ab3730ff..6676bcbf 100644
--- a/ui/src/pages/IssueDetailPage.tsx
+++ b/ui/src/pages/IssueDetailPage.tsx
@@ -982,10 +982,10 @@ export function IssueDetailPage() {
projects={[
{
id: project.id,
- workspaceId: project.workspace_id,
+ workspace_id: project.workspace_id,
name: project.name,
identifier: project.identifier ?? project.id.slice(0, 8),
- description: project.description ?? null,
+ description: project.description ?? undefined,
},
]}
defaultProjectId={project.id}
diff --git a/ui/src/pages/IssueListPage.tsx b/ui/src/pages/IssueListPage.tsx
index 17f24b3b..60de3428 100644
--- a/ui/src/pages/IssueListPage.tsx
+++ b/ui/src/pages/IssueListPage.tsx
@@ -18,6 +18,7 @@ import type {
WorkspaceMemberApiResponse,
} from "../api/types";
import type { Priority } from "../types";
+import { getImageUrl } from "../lib/utils";
const priorityVariant: Record<
Priority,
@@ -195,7 +196,8 @@ export function IssueListPage() {
const display = m?.member_display_name?.trim();
const emailUser = m?.member_email?.split("@")[0]?.trim();
const name = display || emailUser || "Member";
- return { id: userId, name, avatarUrl: null as string | null };
+ const avatarUrl = m?.member_avatar ?? null;
+ return { id: userId, name, avatarUrl };
};
const createParam = searchParams.get("create") === "1";
@@ -375,7 +377,7 @@ export function IssueListPage() {
{assignee ? (
diff --git a/ui/src/pages/LoginPage.tsx b/ui/src/pages/LoginPage.tsx
index 98769ca9..193434c6 100644
--- a/ui/src/pages/LoginPage.tsx
+++ b/ui/src/pages/LoginPage.tsx
@@ -12,12 +12,8 @@ export function LoginPage() {
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
- const from = (
- location.state as { from?: { pathname?: string; search?: string } }
- )?.from;
- const returnPath = from
- ? (from.pathname ?? "/") + (from.search ? `?${from.search}` : "")
- : "/";
+ const returnPath =
+ (location.state as { from?: { pathname?: string } })?.from?.pathname ?? "/";
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
diff --git a/ui/src/pages/ProfilePage.tsx b/ui/src/pages/ProfilePage.tsx
index 4a72e6a7..69ac94c4 100644
--- a/ui/src/pages/ProfilePage.tsx
+++ b/ui/src/pages/ProfilePage.tsx
@@ -2,6 +2,7 @@ import { useParams, Link } from "react-router-dom";
import { useState, useMemo, useEffect } from "react";
import { useAuth } from "../contexts/AuthContext";
import { Avatar, Card, CardContent } from "../components/ui";
+import { getImageUrl } from "../lib/utils";
import { workspaceService } from "../services/workspaceService";
import { projectService } from "../services/projectService";
import { issueService } from "../services/issueService";
@@ -13,7 +14,7 @@ import type {
StateApiResponse,
WorkspaceMemberApiResponse,
} from "../api/types";
-import type { Issue } from "../types";
+// import type { Issue } from "../types"; // reserved for future use
type TabId = "summary" | "assigned" | "created" | "subscribed" | "activity";
@@ -119,7 +120,12 @@ export function ProfilePage() {
: [],
[profileUser, issues],
);
- const issuesAssigned = useMemo(() => [], []); // API does not return assignee on issue yet
+ const issuesAssigned = useMemo((): IssueApiResponse[] => {
+ if (!profileUser?.id) return [];
+ return issues.filter((i) =>
+ (i.assignee_ids ?? []).includes(profileUser.id),
+ );
+ }, [profileUser?.id, issues]);
const issuesSubscribed = issuesAssigned.length;
const issuesSubscribedList = issuesAssigned;
@@ -178,7 +184,8 @@ export function ProfilePage() {
none: 0,
};
issuesAssigned.forEach((i) => {
- counts[i.priority] = (counts[i.priority] ?? 0) + 1;
+ const p = i.priority ?? "none";
+ counts[p] = (counts[p] ?? 0) + 1;
});
return counts;
}, [issuesAssigned]);
@@ -198,12 +205,28 @@ export function ProfilePage() {
id: string;
projectId: string;
issueId: string;
- action: string;
+ type: string;
createdAt: string;
+ labelName?: string;
+ cycleName?: string;
+ assigneeName?: string;
}> => {
if (!profileUser) return [];
- return [];
- }, [profileUser]);
+ const created = issuesCreated
+ .map((i) => ({
+ id: `created-${i.id}`,
+ projectId: i.project_id,
+ issueId: i.id,
+ type: "created" as const,
+ createdAt: i.created_at ?? i.updated_at ?? new Date().toISOString(),
+ }))
+ .sort(
+ (a, b) =>
+ new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+ )
+ .slice(0, 20);
+ return created;
+ }, [profileUser, issuesCreated]);
const projectsWithProgress = useMemo(() => {
return projects
@@ -465,11 +488,15 @@ export function ProfilePage() {
);
const width = max ? (count / max) * 100 : 0;
const barColor =
- p === "urgent" || p === "high"
- ? "var(--bg-danger-subtle)"
- : p === "medium"
- ? "var(--bg-warning-subtle)"
- : "var(--bg-layer-2)";
+ p === "urgent"
+ ? "#ef4444"
+ : p === "high"
+ ? "#f87171"
+ : p === "medium"
+ ? "#f59e0b"
+ : p === "low"
+ ? "#60a5fa"
+ : "#94a3b8";
return (
@@ -607,7 +634,9 @@ export function ProfilePage() {
@@ -630,6 +659,7 @@ export function ProfilePage() {
issues={issuesAssigned}
states={states}
projects={projects}
+ members={members}
baseUrl={baseUrl}
workspaceSlug={workspace.slug}
/>
@@ -639,6 +669,7 @@ export function ProfilePage() {
issues={issuesCreated}
states={states}
projects={projects}
+ members={members}
baseUrl={baseUrl}
workspaceSlug={workspace.slug}
/>
@@ -648,6 +679,7 @@ export function ProfilePage() {
issues={issuesSubscribedList}
states={states}
projects={projects}
+ members={members}
baseUrl={baseUrl}
workspaceSlug={workspace.slug}
/>
@@ -669,7 +701,18 @@ export function ProfilePage() {
variant="outlined"
className="flex min-h-0 flex-1 flex-col overflow-hidden border border-[var(--border-subtle)] bg-white"
>
-
+