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 +

+ +
+ ) : ( +
+ Preview +
+ +
+
+ )} +

+ 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 +

+ +
+ ) : ( +
+ Preview +
+ +
+
+ )} +

+ 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" > -
    +
    + {/* Overlay: icon + name + identifier, inside cover, no border */} +
    + + + +
    +

    {project.name}

    -

    +

    {project.identifier ?? project.id.slice(0, 8)}

    - {/* Description */} -
    -

    - {project.description || "No description"} -

    -
    - - {/* Bottom: avatars (link to project) + settings */} -
    - - {visibleMembers.length === 0 ? ( - - No members - - ) : ( - <> - {visibleMembers.map((user) => ( - - ))} - {extraCount > 0 && ( - - +{extraCount} - - )} - - )} - - - -
    - + {/* Description */} +
    +

    + {project.description || "No description"} +

    +
    + + {/* Bottom: avatars + settings */} +
    + + {visibleMembers.length === 0 ? ( + + No members + + ) : ( + <> + {visibleMembers.map((user) => ( + + ))} + {extraCount > 0 && ( + + +{extraCount} + + )} + + )} + + + + +
    ); })} diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index da93475b..dd1a64f4 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -7,6 +7,13 @@ import { useSearchParams, } from "react-router-dom"; import { Card, CardContent, Button, Avatar, Modal } from "../components/ui"; +import { CoverImageModal } from "../components/CoverImageModal"; +import { UploadImageModal } from "../components/UploadImageModal"; +import { + ProjectIconModal, + ProjectIconDisplay, +} from "../components/ProjectIconModal"; +import { getImageUrl } from "../lib/utils"; import { useAuth } from "../contexts/AuthContext"; import { useTheme } from "../contexts/ThemeContext"; import { workspaceService } from "../services/workspaceService"; @@ -493,40 +500,9 @@ const IconMessageCircle = () => ( ); -const IconHome = () => ( - - - - -); -const IconTruck = () => ( - - - - - - -); +// Reserved for future nav/settings use: +// const IconHome = () => ( ); +// const IconTruck = () => ( ... ); const IconLayers = () => ( (null); + const [accountCoverModalOpen, setAccountCoverModalOpen] = useState(false); + const [accountAvatarModalOpen, setAccountAvatarModalOpen] = useState(false); + const [projectCoverModalOpen, setProjectCoverModalOpen] = useState(false); + const [projectIconModalOpen, setProjectIconModalOpen] = useState(false); + const [workspaceLogoModalOpen, setWorkspaceLogoModalOpen] = useState(false); const navigate = useNavigate(); const timezoneOptions = useMemo(() => getTimezoneOptions(), []); @@ -1313,8 +1294,8 @@ export function SettingsPage() {

    @@ -1389,13 +1370,6 @@ export function SettingsPage() {

    {projects.map((proj) => { const isSelected = selectedProjectId === proj.id; - const projectIcon = proj.name - .toLowerCase() - .includes("mobile") ? ( - - ) : ( - - ); return (
    -
    - +
    +
    @@ -1650,6 +1663,34 @@ export function SettingsPage() { )} + setAccountCoverModalOpen(false)} + onSelect={async (url) => { + try { + const api = await userService.updateMe({ + cover_image: url, + }); + setUserFromApi(api); + } catch { + // error could be shown in modal or toast + } + }} + title="Select cover image" + /> + setAccountAvatarModalOpen(false)} + onSave={async (url) => { + try { + const api = await userService.updateMe({ avatar: url }); + setUserFromApi(api); + } catch { + // error shown in modal + } + }} + title="Upload profile picture" + />
    )} @@ -2308,24 +2349,49 @@ export function SettingsPage() { {isProjectsTab && selectedProject && projectSection === "general" && (
    -
    -
    +
    +
    + {!getImageUrl(selectedProject?.cover_image) && ( +
    + )} +
    -
    - - - -
    -

    +

    + +
    +

    {projectName || selectedProject.name}

    -

    +

    {selectedProject.identifier} Β· Public

    @@ -2506,6 +2572,57 @@ export function SettingsPage() { )}

    )} + {workspaceSlug && selectedProjectId && ( + <> + setProjectCoverModalOpen(false)} + onSelect={async (url) => { + try { + const updated = await projectService.update( + workspaceSlug, + selectedProjectId, + { cover_image: url }, + ); + setProjects((prev) => + prev.map((p) => (p.id === updated.id ? updated : p)), + ); + } catch { + // error could be shown + } + }} + title="Select project cover" + /> + setProjectIconModalOpen(false)} + currentEmoji={selectedProject?.emoji} + currentIconProp={selectedProject?.icon_prop} + onSelect={async (selection) => { + try { + const payload = + selection.emoji != null + ? { emoji: selection.emoji, icon_prop: undefined } + : { + emoji: undefined, + icon_prop: selection.icon_prop ?? undefined, + }; + const updated = await projectService.update( + workspaceSlug, + selectedProjectId, + payload, + ); + setProjects((prev) => + prev.map((p) => (p.id === updated.id ? updated : p)), + ); + } catch { + // error could be shown + } + }} + title="Project icon" + /> + + )}
    )} @@ -3355,8 +3472,16 @@ export function SettingsPage() { {!isAccountTab && !isProjectsTab && section === "general" && (
    -
    - {workspace.name.charAt(0).toUpperCase()} +
    + {workspace?.logo && getImageUrl(workspace.logo) ? ( + + ) : ( + (workspace?.name?.charAt(0).toUpperCase() ?? "") + )}

    @@ -3367,6 +3492,7 @@ export function SettingsPage() {

    )} diff --git a/ui/src/pages/WorkspaceHomePage.tsx b/ui/src/pages/WorkspaceHomePage.tsx index d47b4f05..1999d4d1 100644 --- a/ui/src/pages/WorkspaceHomePage.tsx +++ b/ui/src/pages/WorkspaceHomePage.tsx @@ -310,7 +310,7 @@ export function WorkspaceHomePage() { const { user } = useAuth(); const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); const [workspace, setWorkspace] = useState(null); - const [projects, setProjects] = useState([]); + const [_projects, setProjects] = useState([]); const [quicklinks, setQuicklinks] = useState([]); const [stickies, setStickies] = useState([]); const [recents, setRecents] = useState([]); @@ -386,14 +386,8 @@ export function WorkspaceHomePage() { .catch(() => {}); } }; - const refetchRecents = () => { - if (workspaceSlug) { - recentsService - .list(workspaceSlug) - .then(setRecents) - .catch(() => {}); - } - }; + // Reserved for future use (e.g. refresh recents list): + // const refetchRecents = () => { if (workspaceSlug) { recentsService.list(workspaceSlug).then(setRecents).catch(() => {}); } }; const handleCloseQuicklink = () => { setAddQuicklinkOpen(false); @@ -414,15 +408,8 @@ export function WorkspaceHomePage() { setQuicklinkSubmitting(false); } }; - const handleDeleteQuicklink = async (id: string) => { - if (!workspaceSlug) return; - try { - await quickLinksService.delete(workspaceSlug, id); - refetchQuicklinks(); - } catch { - // already handled by interceptor - } - }; + // Reserved for future use (delete quicklink from UI): + // const handleDeleteQuicklink = async (id: string) => { if (!workspaceSlug) return; try { await quickLinksService.delete(workspaceSlug, id); refetchQuicklinks(); } catch {} }; const handleCloseSticky = () => { setAddStickyOpen(false); setStickyContent(""); diff --git a/ui/src/pages/WorkspaceViewsPage.tsx b/ui/src/pages/WorkspaceViewsPage.tsx index 5b1a14c3..c62dee31 100644 --- a/ui/src/pages/WorkspaceViewsPage.tsx +++ b/ui/src/pages/WorkspaceViewsPage.tsx @@ -212,7 +212,9 @@ export function WorkspaceViewsPage() { projects.find((p) => p.id === projectId); const getStateName = (stateId: string | null | undefined) => stateId ? (states.find((s) => s.id === stateId)?.name ?? stateId) : "β€”"; - const getUser = (_userId: string | null) => null; + const getUser = ( + _userId: string | null, + ): { name: string; avatarUrl?: string | null } | null => null; const getLabelNames = (_labelIds: string[] = []) => [] as string[]; const getCycleName = (_projectId: string, _cycleId: string | null) => null; const getModuleName = (_projectId: string, _moduleId: string | null) => null; diff --git a/ui/src/pages/instance-admin/InstanceAdminAIPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAIPage.tsx index 4c045416..edf4c746 100644 --- a/ui/src/pages/instance-admin/InstanceAdminAIPage.tsx +++ b/ui/src/pages/instance-admin/InstanceAdminAIPage.tsx @@ -48,7 +48,10 @@ export function InstanceAdminAIPage() { const apiKeyToSend = apiKeyLocal !== undefined ? apiKeyLocal : ai.api_key; const payload: InstanceAISection = { ...ai, api_key: apiKeyToSend ?? "" }; instanceSettingsService - .updateSection("ai", payload) + .updateSection( + "ai", + payload as import("../../api/types").InstanceSettingSectionValue, + ) .then((res) => { setApiKeyLocal(undefined); if (res.value) diff --git a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx index cf15384b..2decc8d8 100644 --- a/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx +++ b/ui/src/pages/instance-admin/InstanceAdminAuthenticationPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Button, Skeleton } from "../../components/ui"; import { instanceSettingsService } from "../../services/instanceService"; import { getApiErrorMessage } from "../../api/client"; @@ -78,7 +78,7 @@ const IconGitLab = () => ( const AUTH_MODES: Array<{ key: keyof InstanceAuthSection; - Icon: () => JSX.Element; + Icon: () => React.ReactElement; name: string; desc: string; action?: string; @@ -128,7 +128,7 @@ export function InstanceAdminAuthenticationPage() { gitlab: false, }); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); + const [_saving, setSaving] = useState(false); const [error, setError] = useState(""); useEffect(() => { diff --git a/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx b/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx index 6ebbec6f..64c89fb4 100644 --- a/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx +++ b/ui/src/pages/instance-admin/InstanceAdminEmailPage.tsx @@ -64,7 +64,10 @@ export function InstanceAdminEmailPage() { password: passwordToSend ?? "", }; instanceSettingsService - .updateSection("email", payload) + .updateSection( + "email", + payload as import("../../api/types").InstanceSettingSectionValue, + ) .then((res) => { setSmtpPasswordLocal(undefined); if (res.value) diff --git a/ui/src/pages/instance-admin/InstanceAdminImagePage.tsx b/ui/src/pages/instance-admin/InstanceAdminImagePage.tsx index 83bfd405..e7797888 100644 --- a/ui/src/pages/instance-admin/InstanceAdminImagePage.tsx +++ b/ui/src/pages/instance-admin/InstanceAdminImagePage.tsx @@ -54,7 +54,10 @@ export function InstanceAdminImagePage() { unsplash_access_key: accessKeyToSend ?? "", }; instanceSettingsService - .updateSection("image", payload) + .updateSection( + "image", + payload as import("../../api/types").InstanceSettingSectionValue, + ) .then((res) => { setAccessKeyLocal(undefined); if (res.value) diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx index 64fb8e29..b0b08566 100644 --- a/ui/src/routes/index.tsx +++ b/ui/src/routes/index.tsx @@ -133,11 +133,8 @@ const InstanceAdminCreateWorkspacePage = lazy(() => }), ), ); -const InstanceAdminLoginPage = lazy(() => - import("../pages/instance-admin").then((m) => - page({ InstanceAdminLoginPage: m.InstanceAdminLoginPage }), - ), -); +// Reserved for future instance-admin login route: +// const InstanceAdminLoginPage = lazy(() => import("../pages/instance-admin").then((m) => page({ InstanceAdminLoginPage: m.InstanceAdminLoginPage }))); const InstanceSetupWelcomePage = lazy(() => import("../pages/setup").then((m) => @@ -154,11 +151,6 @@ const InstanceSetupCompletePage = lazy(() => page({ InstanceSetupCompletePage: m.InstanceSetupCompletePage }), ), ); -const InviteAcceptPage = lazy(() => - import("../pages/InviteAcceptPage").then((m) => - page({ InviteAcceptPage: m.InviteAcceptPage }), - ), -); const PageFallback = () => (
    @@ -296,14 +288,6 @@ const router = createBrowserRouter([ ), }, - { - path: "invite", - element: ( - }> - - - ), - }, { element: , children: [ diff --git a/ui/src/services/instanceService.ts b/ui/src/services/instanceService.ts index e6efb42d..1fb98752 100644 --- a/ui/src/services/instanceService.ts +++ b/ui/src/services/instanceService.ts @@ -27,6 +27,13 @@ export const instanceService = { }, }; +/** Unsplash search result item from proxy. */ +export interface UnsplashSearchResult { + id: string; + url: string; + thumb: string; +} + /** Instance admin settings (requires auth). */ export const instanceSettingsService = { async getSettings(): Promise { @@ -46,4 +53,15 @@ export const instanceSettingsService = { }>(`/api/instance/settings/${encodeURIComponent(key)}`, { value }); return data; }, + + /** Search Unsplash (uses instance image API key). GET /api/instance/unsplash/search?q= */ + async unsplashSearch( + q: string, + ): Promise<{ results: UnsplashSearchResult[] }> { + const { data } = await apiClient.get<{ results: UnsplashSearchResult[] }>( + "/api/instance/unsplash/search", + { params: { q: q.trim() } }, + ); + return data; + }, }; diff --git a/ui/src/services/projectService.ts b/ui/src/services/projectService.ts index 3d0ba77f..d4f00343 100644 --- a/ui/src/services/projectService.ts +++ b/ui/src/services/projectService.ts @@ -59,6 +59,9 @@ export const projectService = { identifier?: string; description?: string; timezone?: string; + cover_image?: string; + emoji?: string; + icon_prop?: { name?: string; color?: string } | null; /** When present, use empty string to clear; omit to leave unchanged. */ project_lead_id?: string; /** When present, use empty string to clear; omit to leave unchanged. */ diff --git a/ui/src/services/uploadService.ts b/ui/src/services/uploadService.ts new file mode 100644 index 00000000..97ccd0c1 --- /dev/null +++ b/ui/src/services/uploadService.ts @@ -0,0 +1,21 @@ +import { apiClient } from "../api/client"; + +const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; + +export interface UploadResponse { + url: string; +} + +/** + * Upload an image file. Returns the URL path to use (e.g. /api/files/uploads/...). + * Supported: .jpeg, .jpg, .png, .webp + */ +export async function uploadImage(file: File): Promise { + if (!ALLOWED_TYPES.includes(file.type)) { + throw new Error("Invalid file type. Supported: .jpeg, .jpg, .png, .webp"); + } + const form = new FormData(); + form.append("file", file); + const { data } = await apiClient.post("/api/upload", form); + return data; +} diff --git a/ui/src/services/userService.ts b/ui/src/services/userService.ts index 809d3775..b0f30ab8 100644 --- a/ui/src/services/userService.ts +++ b/ui/src/services/userService.ts @@ -53,9 +53,7 @@ export const userService = { return data; }, - async createToken( - payload: CreateTokenRequest, - ): Promise<{ + async createToken(payload: CreateTokenRequest): Promise<{ token: string; label: string; description: string; diff --git a/ui/src/services/workspaceService.ts b/ui/src/services/workspaceService.ts index be4f4c3a..c9099835 100644 --- a/ui/src/services/workspaceService.ts +++ b/ui/src/services/workspaceService.ts @@ -78,7 +78,7 @@ export const workspaceService = { */ async update( slug: string, - payload: { name?: string; slug?: string }, + payload: { name?: string; slug?: string; logo?: string }, ): Promise { const { data } = await apiClient.patch( `/api/workspaces/${encodeURIComponent(slug)}/`, @@ -122,16 +122,4 @@ export const workspaceService = { `/api/workspaces/${encodeURIComponent(workspaceSlug)}/invitations/${encodeURIComponent(invitePk)}/`, ); }, - - /** - * Accept a workspace invitation by token (from invite email link). - * POST /api/workspaces/join/ - */ - async joinByToken(token: string): Promise { - const { data } = await apiClient.post( - "/api/workspaces/join/", - { token }, - ); - return data; - }, }; diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 08e29826..0258ba25 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -9,6 +9,7 @@ export interface User { email: string; name: string; avatarUrl?: string | null; + coverImageUrl?: string | null; } export interface Workspace { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 764235e0..401b4d48 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -12,9 +12,18 @@ export default defineConfig({ "react-vendor": ["react", "react-dom"], router: ["react-router-dom"], charts: ["recharts"], + tiptap: [ + "@tiptap/react", + "@tiptap/starter-kit", + "@tiptap/extension-placeholder", + "@tiptap/extension-underline", + "@tiptap/extension-link", + ], "ui-vendor": ["@headlessui/react", "lucide-react"], + "core-vendor": ["axios", "clsx", "tailwind-merge"], }, }, }, + chunkSizeWarningLimit: 600, }, });