diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go
index df1c4d5a..c5c926c9 100644
--- a/api/cmd/api/main.go
+++ b/api/cmd/api/main.go
@@ -45,7 +45,7 @@ func main() {
sqlDB, _ := db.DB()
defer sqlDB.Close()
- // Redis
+ // Redis
var rdb *redis.Client
if client, err := redis.New(cfg, log); err != nil {
log.Warn("redis", "error", err)
@@ -54,7 +54,7 @@ func main() {
defer rdb.Close()
}
- // RabbitMQ
+ // RabbitMQ
var queuePublisher *queue.Publisher
var rmq *rabbitmq.Client
if client, err := rabbitmq.New(cfg, log); err != nil {
@@ -69,20 +69,21 @@ 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{
- Log: log,
- DB: db,
- Redis: rdb,
- Queue: queuePublisher,
- CORSAllowOrigin: cfg.CORSAllowOrigin,
+ Log: log,
+ DB: db,
+ Redis: rdb,
+ Queue: queuePublisher,
+ Minio: mc,
+ CORSAllowOrigin: cfg.CORSAllowOrigin,
})
// Start task consumer when RabbitMQ is available
diff --git a/api/internal/auth/service.go b/api/internal/auth/service.go
index c3c081b5..390a66ff 100644
--- a/api/internal/auth/service.go
+++ b/api/internal/auth/service.go
@@ -32,8 +32,8 @@ func NewService(userStore *store.UserStore, sessionStore *store.SessionStore) *S
}
type SignUpRequest struct {
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required,min=8"`
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required,min=8"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
@@ -62,13 +62,13 @@ func (s *Service) SignUp(ctx context.Context, req SignUpRequest) (sessionKey str
return "", nil, err
}
u := &model.User{
- Username: username,
- Email: &email,
- Password: string(hash),
- FirstName: req.FirstName,
- LastName: req.LastName,
+ Username: username,
+ Email: &email,
+ Password: string(hash),
+ FirstName: req.FirstName,
+ LastName: req.LastName,
DisplayName: strings.TrimSpace(req.FirstName + " " + req.LastName),
- IsActive: true,
+ IsActive: true,
}
if err := s.userStore.Create(ctx, u); err != nil {
return "", nil, err
diff --git a/api/internal/config/config.go b/api/internal/config/config.go
index eba6a26c..fdbaf9f2 100644
--- a/api/internal/config/config.go
+++ b/api/internal/config/config.go
@@ -65,18 +65,18 @@ func Load() (*Config, error) {
}
cfg := &Config{
- Env: getEnv("ENV", "development"),
- ServerPort: getEnv("SERVER_PORT", "8080"),
- DBHost: getEnv("DB_HOST", "localhost"),
- DBPort: getEnv("DB_PORT", "5432"),
- DBUser: getEnv("DB_USER", "postgres"),
- DBPassword: getEnv("DB_PASSWORD", "postgres"),
- DBName: getEnv("DB_NAME", "devlane"),
- DBSSLMode: getEnv("DB_SSLMODE", "disable"),
- RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
- RedisPassword: getEnv("REDIS_PASSWORD", ""),
- RedisDB: redisDB,
- RabbitMQURL: getEnv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"),
+ Env: getEnv("ENV", "development"),
+ ServerPort: getEnv("SERVER_PORT", "8080"),
+ DBHost: getEnv("DB_HOST", "localhost"),
+ DBPort: getEnv("DB_PORT", "5432"),
+ DBUser: getEnv("DB_USER", "postgres"),
+ DBPassword: getEnv("DB_PASSWORD", "postgres"),
+ DBName: getEnv("DB_NAME", "devlane"),
+ DBSSLMode: getEnv("DB_SSLMODE", "disable"),
+ RedisAddr: getEnv("REDIS_ADDR", "localhost:6379"),
+ RedisPassword: getEnv("REDIS_PASSWORD", ""),
+ RedisDB: redisDB,
+ RabbitMQURL: getEnv("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/"),
MinIOEndpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"),
MinIOAccessKeyID: getEnv("MINIO_ACCESS_KEY_ID", "minioadmin"),
MinIOSecretAccessKey: getEnv("MINIO_SECRET_ACCESS_KEY", "minioadmin"),
diff --git a/api/internal/handler/auth.go b/api/internal/handler/auth.go
index 277898db..08a50639 100644
--- a/api/internal/handler/auth.go
+++ b/api/internal/handler/auth.go
@@ -17,12 +17,12 @@ import (
)
type AuthHandler struct {
- Auth *auth.Service
- Settings *store.InstanceSettingStore
- Winv *store.WorkspaceInviteStore
+ Auth *auth.Service
+ Settings *store.InstanceSettingStore
+ Winv *store.WorkspaceInviteStore
Ws *store.WorkspaceStore
- NotifPrefs *store.UserNotificationPreferenceStore
- ApiTokens *store.ApiTokenStore
+ NotifPrefs *store.UserNotificationPreferenceStore
+ ApiTokens *store.ApiTokenStore
}
type SignInRequest struct {
@@ -31,10 +31,10 @@ type SignInRequest struct {
}
type SignUpRequest struct {
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required,min=8"`
- FirstName string `json:"first_name"`
- LastName string `json:"last_name"`
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required,min=8"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
InviteToken string `json:"invite_token"`
}
@@ -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
@@ -255,11 +263,11 @@ func (h *AuthHandler) GetNotificationPreferences(c *gin.Context) {
}
if h.NotifPrefs == nil {
c.JSON(http.StatusOK, gin.H{
- "property_change": true,
- "state_change": true,
- "comment": true,
- "mention": true,
- "issue_completed": true,
+ "property_change": true,
+ "state_change": true,
+ "comment": true,
+ "mention": true,
+ "issue_completed": true,
})
return
}
@@ -270,20 +278,20 @@ func (h *AuthHandler) GetNotificationPreferences(c *gin.Context) {
}
if p == nil {
c.JSON(http.StatusOK, gin.H{
- "property_change": true,
- "state_change": true,
- "comment": true,
- "mention": true,
- "issue_completed": true,
+ "property_change": true,
+ "state_change": true,
+ "comment": true,
+ "mention": true,
+ "issue_completed": true,
})
return
}
c.JSON(http.StatusOK, gin.H{
- "property_change": p.PropertyChange,
- "state_change": p.StateChange,
- "comment": p.Comment,
- "mention": p.Mention,
- "issue_completed": p.IssueCompleted,
+ "property_change": p.PropertyChange,
+ "state_change": p.StateChange,
+ "comment": p.Comment,
+ "mention": p.Mention,
+ "issue_completed": p.IssueCompleted,
})
}
@@ -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,
@@ -530,4 +539,3 @@ func userResponse(u *model.User) gin.H {
"user_timezone": u.UserTimezone,
}
}
-
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 9bf373e9..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"
@@ -99,10 +102,10 @@ func (h *InstanceHandler) InstanceSetup(c *gin.Context) {
instanceName = user.DisplayName
}
_ = h.Settings.Upsert(c.Request.Context(), "general", model.JSONMap{
- "instance_id": instanceID,
- "admin_email": req.Email,
- "instance_name": instanceName,
- "only_admin_can_create_workspace": false,
+ "instance_id": instanceID,
+ "admin_email": req.Email,
+ "instance_name": instanceName,
+ "only_admin_can_create_workspace": false,
})
}
@@ -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/issue_view.go b/api/internal/handler/issue_view.go
index 2b73fb1d..3957a711 100644
--- a/api/internal/handler/issue_view.go
+++ b/api/internal/handler/issue_view.go
@@ -3,8 +3,8 @@ package handler
import (
"net/http"
- "github.com/Devlaner/devlane/api/internal/model"
"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"
@@ -53,9 +53,9 @@ func (h *IssueViewHandler) Create(c *gin.Context) {
}
slug := c.Param("slug")
var body struct {
- Name string `json:"name" binding:"required"`
- Description string `json:"description"`
- ProjectID *uuid.UUID `json:"project_id"`
+ Name string `json:"name" binding:"required"`
+ Description string `json:"description"`
+ ProjectID *uuid.UUID `json:"project_id"`
Query model.JSONMap `json:"query"`
Filters model.JSONMap `json:"filters"`
DisplayFilters model.JSONMap `json:"display_filters"`
@@ -118,8 +118,8 @@ func (h *IssueViewHandler) Update(c *gin.Context) {
return
}
var body struct {
- Name string `json:"name"`
- Description string `json:"description"`
+ Name string `json:"name"`
+ Description string `json:"description"`
Query model.JSONMap `json:"query"`
Filters model.JSONMap `json:"filters"`
DisplayFilters model.JSONMap `json:"display_filters"`
diff --git a/api/internal/handler/page.go b/api/internal/handler/page.go
index 3e1621ca..5dab97dd 100644
--- a/api/internal/handler/page.go
+++ b/api/internal/handler/page.go
@@ -55,7 +55,7 @@ func (h *PageHandler) Create(c *gin.Context) {
Name string `json:"name" binding:"required"`
DescriptionHTML string `json:"description_html"`
ProjectID *uuid.UUID `json:"project_id"`
- Access int16 `json:"access"` // 0 public, 1 private
+ Access int16 `json:"access"` // 0 public, 1 private
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
diff --git a/api/internal/handler/project.go b/api/internal/handler/project.go
index 83f6a36d..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"
@@ -83,8 +84,8 @@ func (h *ProjectHandler) Create(c *gin.Context) {
}
slug := c.Param("slug")
var body struct {
- Name string `json:"name" binding:"required"`
- Identifier string `json:"identifier"`
+ Name string `json:"name" binding:"required"`
+ Identifier string `json:"identifier"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
@@ -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/recent_visit.go b/api/internal/handler/recent_visit.go
index c25b8853..39c8f378 100644
--- a/api/internal/handler/recent_visit.go
+++ b/api/internal/handler/recent_visit.go
@@ -52,9 +52,9 @@ func (h *RecentVisitHandler) Record(c *gin.Context) {
}
slug := c.Param("slug")
var body struct {
- EntityName string `json:"entity_name" binding:"required"` // "issue", "project", "page"
- EntityIdentifier *uuid.UUID `json:"entity_identifier"`
- ProjectID *uuid.UUID `json:"project_id"`
+ EntityName string `json:"entity_name" binding:"required"` // "issue", "project", "page"
+ EntityIdentifier *uuid.UUID `json:"entity_identifier"`
+ ProjectID *uuid.UUID `json:"project_id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
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 0abfc0e5..9c6489d8 100644
--- a/api/internal/handler/workspace.go
+++ b/api/internal/handler/workspace.go
@@ -14,7 +14,7 @@ import (
// WorkspaceHandler serves workspace and member/invite endpoints.
type WorkspaceHandler struct {
Workspace *service.WorkspaceService
- Settings *store.InstanceSettingStore
+ Settings *store.InstanceSettingStore
}
// List returns the current user's workspaces.
@@ -113,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
@@ -124,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"})
diff --git a/api/internal/handler/workspace_link.go b/api/internal/handler/workspace_link.go
index b3b9fa83..5500c29a 100644
--- a/api/internal/handler/workspace_link.go
+++ b/api/internal/handler/workspace_link.go
@@ -45,9 +45,9 @@ func (h *WorkspaceLinkHandler) Create(c *gin.Context) {
}
slug := c.Param("slug")
var body struct {
- Title string `json:"title"`
- URL string `json:"url" binding:"required"`
- ProjectID *uuid.UUID `json:"project_id"`
+ Title string `json:"title"`
+ URL string `json:"url" binding:"required"`
+ ProjectID *uuid.UUID `json:"project_id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
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/api_token.go b/api/internal/model/api_token.go
index c0842885..4b837aa5 100644
--- a/api/internal/model/api_token.go
+++ b/api/internal/model/api_token.go
@@ -9,19 +9,19 @@ import (
// ApiToken matches api_tokens.
type ApiToken struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- Label string `gorm:"type:varchar(255);not null" json:"label"`
- Description string `gorm:"type:text" json:"description,omitempty"`
- Token string `gorm:"type:varchar(255);not null;uniqueIndex" json:"token"`
- UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
- WorkspaceID *uuid.UUID `gorm:"type:uuid" json:"workspace_id,omitempty"`
- IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
- LastUsed *time.Time `gorm:"type:timestamptz" json:"last_used,omitempty"`
- ExpiredAt *time.Time `gorm:"type:timestamptz" json:"expired_at,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Label string `gorm:"type:varchar(255);not null" json:"label"`
+ Description string `gorm:"type:text" json:"description,omitempty"`
+ Token string `gorm:"type:varchar(255);not null;uniqueIndex" json:"token"`
+ UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
+ WorkspaceID *uuid.UUID `gorm:"type:uuid" json:"workspace_id,omitempty"`
+ IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
+ LastUsed *time.Time `gorm:"type:timestamptz" json:"last_used,omitempty"`
+ ExpiredAt *time.Time `gorm:"type:timestamptz" json:"expired_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
}
func (ApiToken) TableName() string { return "api_tokens" }
diff --git a/api/internal/model/cycle.go b/api/internal/model/cycle.go
index 0feccfcd..6290b059 100644
--- a/api/internal/model/cycle.go
+++ b/api/internal/model/cycle.go
@@ -9,25 +9,25 @@ import (
// Cycle matches cycles.
type Cycle struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- Name string `gorm:"type:varchar(255);not null" json:"name"`
- Description string `gorm:"type:text" json:"description,omitempty"`
- StartDate *time.Time `gorm:"type:timestamptz" json:"start_date,omitempty"`
- EndDate *time.Time `gorm:"type:timestamptz" json:"end_date,omitempty"`
- Status string `gorm:"type:varchar(255);default:draft" json:"status"`
- ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- IssueCount int `gorm:"-" json:"issue_count,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
- SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
- ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"`
- Timezone string `gorm:"default:UTC" json:"timezone"`
- Version int `gorm:"default:1" json:"version"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Name string `gorm:"type:varchar(255);not null" json:"name"`
+ Description string `gorm:"type:text" json:"description,omitempty"`
+ StartDate *time.Time `gorm:"type:timestamptz" json:"start_date,omitempty"`
+ EndDate *time.Time `gorm:"type:timestamptz" json:"end_date,omitempty"`
+ Status string `gorm:"type:varchar(255);default:draft" json:"status"`
+ ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ IssueCount int `gorm:"-" json:"issue_count,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
+ ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"`
+ Timezone string `gorm:"default:UTC" json:"timezone"`
+ Version int `gorm:"default:1" json:"version"`
}
func (Cycle) TableName() string { return "cycles" }
@@ -42,15 +42,15 @@ func (c *Cycle) BeforeCreate(tx *gorm.DB) error {
// CycleIssue matches cycle_issues (M2M).
type CycleIssue struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- CycleID uuid.UUID `gorm:"type:uuid;not null" json:"cycle_id"`
- IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"`
- ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ CycleID uuid.UUID `gorm:"type:uuid;not null" json:"cycle_id"`
+ IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"`
+ ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
}
func (CycleIssue) TableName() string { return "cycle_issues" }
diff --git a/api/internal/model/issue.go b/api/internal/model/issue.go
index 7e3afec7..b7aa2622 100644
--- a/api/internal/model/issue.go
+++ b/api/internal/model/issue.go
@@ -9,30 +9,30 @@ import (
// Issue matches migration table "issues".
type Issue struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- Name string `gorm:"type:varchar(255);not null" json:"name"`
- Description JSONMap `gorm:"type:jsonb;serializer:json" json:"description,omitempty"`
- DescriptionHTML string `gorm:"column:description_html;type:text" json:"description_html,omitempty"`
- Priority string `gorm:"type:varchar(30)" json:"priority,omitempty"`
- StartDate *time.Time `gorm:"type:date" json:"start_date,omitempty"`
- TargetDate *time.Time `gorm:"type:date" json:"target_date,omitempty"`
- SequenceID int `gorm:"column:sequence_id;default:1" json:"sequence_id"`
- ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- StateID *uuid.UUID `gorm:"type:uuid" json:"state_id,omitempty"`
- ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"`
- AssigneeIDs []uuid.UUID `gorm:"-" json:"assignee_ids,omitempty"`
- LabelIDs []uuid.UUID `gorm:"-" json:"label_ids,omitempty"`
- CycleIDs []uuid.UUID `gorm:"-" json:"cycle_ids,omitempty"`
- ModuleIDs []uuid.UUID `gorm:"-" json:"module_ids,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
- SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
- ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"`
- IsDraft bool `gorm:"column:is_draft;default:false" json:"is_draft"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Name string `gorm:"type:varchar(255);not null" json:"name"`
+ Description JSONMap `gorm:"type:jsonb;serializer:json" json:"description,omitempty"`
+ DescriptionHTML string `gorm:"column:description_html;type:text" json:"description_html,omitempty"`
+ Priority string `gorm:"type:varchar(30)" json:"priority,omitempty"`
+ StartDate *time.Time `gorm:"type:date" json:"start_date,omitempty"`
+ TargetDate *time.Time `gorm:"type:date" json:"target_date,omitempty"`
+ SequenceID int `gorm:"column:sequence_id;default:1" json:"sequence_id"`
+ ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ StateID *uuid.UUID `gorm:"type:uuid" json:"state_id,omitempty"`
+ ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"`
+ AssigneeIDs []uuid.UUID `gorm:"-" json:"assignee_ids,omitempty"`
+ LabelIDs []uuid.UUID `gorm:"-" json:"label_ids,omitempty"`
+ CycleIDs []uuid.UUID `gorm:"-" json:"cycle_ids,omitempty"`
+ ModuleIDs []uuid.UUID `gorm:"-" json:"module_ids,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
+ ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"`
+ IsDraft bool `gorm:"column:is_draft;default:false" json:"is_draft"`
}
func (Issue) TableName() string { return "issues" }
@@ -83,4 +83,3 @@ func (l *IssueLabel) BeforeCreate(tx *gorm.DB) error {
}
return nil
}
-
diff --git a/api/internal/model/issue_view.go b/api/internal/model/issue_view.go
index 3b7adbc4..05c9a05d 100644
--- a/api/internal/model/issue_view.go
+++ b/api/internal/model/issue_view.go
@@ -16,12 +16,12 @@ type IssueView struct {
Filters JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"filters,omitempty"`
DisplayFilters JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"display_filters,omitempty"`
DisplayProperties JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"display_properties,omitempty"`
- RichFilters JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"rich_filters,omitempty"`
+ RichFilters JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"rich_filters,omitempty"`
Access int16 `gorm:"default:1" json:"access"` // 0 private, 1 public
SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
LogoProps JSONMap `gorm:"type:jsonb;default:{};serializer:json" json:"logo_props,omitempty"`
OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"`
- IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"`
+ IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"`
WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
diff --git a/api/internal/model/module.go b/api/internal/model/module.go
index 3d530f65..4dbed79e 100644
--- a/api/internal/model/module.go
+++ b/api/internal/model/module.go
@@ -9,22 +9,22 @@ import (
// Module matches modules.
type Module struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- Name string `gorm:"type:varchar(255);not null" json:"name"`
- Description string `gorm:"type:text" json:"description,omitempty"`
- StartDate *time.Time `gorm:"type:date" json:"start_date,omitempty"`
- TargetDate *time.Time `gorm:"type:date" json:"target_date,omitempty"`
- Status string `gorm:"type:varchar(50);default:backlog" json:"status"`
- ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- IssueCount int `gorm:"-" json:"issue_count,omitempty"`
- LeadID *uuid.UUID `gorm:"type:uuid" json:"lead_id,omitempty"`
- SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Name string `gorm:"type:varchar(255);not null" json:"name"`
+ Description string `gorm:"type:text" json:"description,omitempty"`
+ StartDate *time.Time `gorm:"type:date" json:"start_date,omitempty"`
+ TargetDate *time.Time `gorm:"type:date" json:"target_date,omitempty"`
+ Status string `gorm:"type:varchar(50);default:backlog" json:"status"`
+ ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ IssueCount int `gorm:"-" json:"issue_count,omitempty"`
+ LeadID *uuid.UUID `gorm:"type:uuid" json:"lead_id,omitempty"`
+ SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
}
func (Module) TableName() string { return "modules" }
@@ -38,13 +38,13 @@ func (m *Module) BeforeCreate(tx *gorm.DB) error {
// ModuleIssue matches module_issues (M2M).
type ModuleIssue struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- ModuleID uuid.UUID `gorm:"type:uuid;not null" json:"module_id"`
- IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"`
- ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ ModuleID uuid.UUID `gorm:"type:uuid;not null" json:"module_id"`
+ IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"`
+ ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
}
diff --git a/api/internal/model/page.go b/api/internal/model/page.go
index 8ccd8e69..d8285542 100644
--- a/api/internal/model/page.go
+++ b/api/internal/model/page.go
@@ -9,21 +9,21 @@ import (
// Page matches pages.
type Page struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- Name string `gorm:"type:text" json:"name"`
- DescriptionHTML string `gorm:"column:description_html;type:text;default:
" json:"description_html,omitempty"`
- OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- Access int16 `gorm:"default:0" json:"access"`
- ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"`
- ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"`
- IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"`
- SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Name string `gorm:"type:text" json:"name"`
+ DescriptionHTML string `gorm:"column:description_html;type:text;default:" json:"description_html,omitempty"`
+ OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ Access int16 `gorm:"default:0" json:"access"`
+ ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"`
+ ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"`
+ IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"`
+ SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
}
func (Page) TableName() string { return "pages" }
diff --git a/api/internal/model/profile.go b/api/internal/model/profile.go
index 285ec29f..f4f3fe70 100644
--- a/api/internal/model/profile.go
+++ b/api/internal/model/profile.go
@@ -8,13 +8,13 @@ import (
// Profile matches profiles.
type Profile struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex" json:"user_id"`
- Role string `gorm:"type:varchar(300)" json:"role,omitempty"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ UserID uuid.UUID `gorm:"type:uuid;not null;uniqueIndex" json:"user_id"`
+ Role string `gorm:"type:varchar(300)" json:"role,omitempty"`
LastWorkspaceID *uuid.UUID `gorm:"type:uuid" json:"last_workspace_id,omitempty"`
- Language string `gorm:"type:varchar(255);default:en" json:"language"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ Language string `gorm:"type:varchar(255);default:en" json:"language"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
func (Profile) TableName() string { return "profiles" }
diff --git a/api/internal/model/project.go b/api/internal/model/project.go
index cebaa86e..3c6fe192 100644
--- a/api/internal/model/project.go
+++ b/api/internal/model/project.go
@@ -27,31 +27,32 @@ func (m *JSONMap) Scan(v interface{}) error {
// Project matches migration table "projects".
type Project struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- Name string `gorm:"type:varchar(255);not null" json:"name"`
- Description string `gorm:"type:text" json:"description,omitempty"`
- Identifier string `gorm:"type:varchar(12)" json:"identifier,omitempty"`
- Slug string `gorm:"type:varchar(100)" json:"slug,omitempty"`
- Network int16 `gorm:"default:2" json:"network"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- DefaultAssigneeID *uuid.UUID `gorm:"type:uuid" json:"default_assignee_id,omitempty"`
- ProjectLeadID *uuid.UUID `gorm:"type:uuid" json:"project_lead_id,omitempty"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
- DefaultStateID *uuid.UUID `gorm:"type:uuid" json:"default_state_id,omitempty"`
- Emoji string `gorm:"type:varchar(10)" json:"emoji,omitempty"`
- IconProp JSONMap `gorm:"column:icon_prop;type:jsonb;serializer:json" json:"icon_prop,omitempty"`
- ModuleView bool `gorm:"default:true" json:"module_view"`
- CycleView bool `gorm:"default:true" json:"cycle_view"`
- IssueViewsView bool `gorm:"column:issue_views_view;default:true" json:"issue_views_view"`
- PageView bool `gorm:"default:true" json:"page_view"`
- 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"`
- Timezone string `gorm:"default:UTC" json:"timezone"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ Name string `gorm:"type:varchar(255);not null" json:"name"`
+ Description string `gorm:"type:text" json:"description,omitempty"`
+ Identifier string `gorm:"type:varchar(12)" json:"identifier,omitempty"`
+ Slug string `gorm:"type:varchar(100)" json:"slug,omitempty"`
+ Network int16 `gorm:"default:2" json:"network"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ DefaultAssigneeID *uuid.UUID `gorm:"type:uuid" json:"default_assignee_id,omitempty"`
+ ProjectLeadID *uuid.UUID `gorm:"type:uuid" json:"project_lead_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ DefaultStateID *uuid.UUID `gorm:"type:uuid" json:"default_state_id,omitempty"`
+ Emoji string `gorm:"type:varchar(10)" json:"emoji,omitempty"`
+ IconProp JSONMap `gorm:"column:icon_prop;type:jsonb;serializer:json" json:"icon_prop,omitempty"`
+ ModuleView bool `gorm:"default:true" json:"module_view"`
+ CycleView bool `gorm:"default:true" json:"cycle_view"`
+ IssueViewsView bool `gorm:"column:issue_views_view;default:true" json:"issue_views_view"`
+ PageView bool `gorm:"default:true" json:"page_view"`
+ 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"`
}
func (Project) TableName() string { return "projects" }
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 d8abe82f..47a2867c 100644
--- a/api/internal/model/user_favorite.go
+++ b/api/internal/model/user_favorite.go
@@ -9,18 +9,18 @@ import (
// UserFavorite matches user_favorites.
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"`
- 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"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ 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;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;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"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
}
func (UserFavorite) TableName() string { return "user_favorites" }
diff --git a/api/internal/model/webhook.go b/api/internal/model/webhook.go
index 74deb591..061ede43 100644
--- a/api/internal/model/webhook.go
+++ b/api/internal/model/webhook.go
@@ -9,16 +9,16 @@ import (
// Webhook matches webhooks.
type Webhook struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- URL string `gorm:"type:text;not null" json:"url"`
- SecretKey string `gorm:"type:varchar(255)" json:"secret_key,omitempty"`
- IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ URL string `gorm:"type:text;not null" json:"url"`
+ SecretKey string `gorm:"type:varchar(255)" json:"secret_key,omitempty"`
+ IsActive bool `gorm:"column:is_active;default:true" json:"is_active"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
}
func (Webhook) TableName() string { return "webhooks" }
diff --git a/api/internal/model/workspace.go b/api/internal/model/workspace.go
index f0af2086..3654afd7 100644
--- a/api/internal/model/workspace.go
+++ b/api/internal/model/workspace.go
@@ -9,19 +9,19 @@ import (
// Workspace matches migration table "workspaces".
type Workspace struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- Name string `gorm:"type:varchar(255);not null" json:"name"`
- Logo string `gorm:"type:text" json:"logo,omitempty"`
- Slug string `gorm:"type:varchar(100);uniqueIndex;not null" json:"slug"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- OwnerID uuid.UUID `gorm:"type:uuid;not null" json:"owner_id"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
- OrganizationSize string `gorm:"column:organization_size;type:varchar(50)" json:"organization_size,omitempty"`
- Timezone string `gorm:"default:UTC" json:"timezone"`
- BackgroundColor string `gorm:"column:background_color;type:varchar(255)" json:"background_color,omitempty"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Name string `gorm:"type:varchar(255);not null" json:"name"`
+ Logo string `gorm:"type:text" json:"logo,omitempty"`
+ Slug string `gorm:"type:varchar(100);uniqueIndex;not null" json:"slug"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ OwnerID uuid.UUID `gorm:"type:uuid;not null" json:"owner_id"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ OrganizationSize string `gorm:"column:organization_size;type:varchar(50)" json:"organization_size,omitempty"`
+ Timezone string `gorm:"default:UTC" json:"timezone"`
+ BackgroundColor string `gorm:"column:background_color;type:varchar(255)" json:"background_color,omitempty"`
}
func (Workspace) TableName() string { return "workspaces" }
@@ -35,16 +35,16 @@ func (w *Workspace) BeforeCreate(tx *gorm.DB) error {
// WorkspaceMember matches migration table "workspace_members".
type WorkspaceMember struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- MemberID uuid.UUID `gorm:"type:uuid;not null" json:"member_id"`
- Role int16 `gorm:"not null;default:10" json:"role"`
- MemberDisplayName string `gorm:"column:member_display_name;->" json:"member_display_name,omitempty"`
- MemberEmail *string `gorm:"column:member_email;->" json:"member_email,omitempty"`
- MemberAvatar string `gorm:"column:member_avatar;->" json:"member_avatar,omitempty"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ MemberID uuid.UUID `gorm:"type:uuid;not null" json:"member_id"`
+ Role int16 `gorm:"not null;default:10" json:"role"`
+ MemberDisplayName string `gorm:"column:member_display_name;->" json:"member_display_name,omitempty"`
+ MemberEmail *string `gorm:"column:member_email;->" json:"member_email,omitempty"`
+ MemberAvatar string `gorm:"column:member_avatar;->" json:"member_avatar,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (WorkspaceMember) TableName() string { return "workspace_members" }
diff --git a/api/internal/queue/consumer.go b/api/internal/queue/consumer.go
index 4710a97d..8cfc7c2c 100644
--- a/api/internal/queue/consumer.go
+++ b/api/internal/queue/consumer.go
@@ -13,8 +13,8 @@ type TaskHandler func(ctx context.Context, queue string, body []byte) error
// Consumer consumes from RabbitMQ queues and dispatches to handlers.
type Consumer struct {
- ch *amqp.Channel
- log *slog.Logger
+ ch *amqp.Channel
+ log *slog.Logger
handlers map[string]TaskHandler
}
@@ -99,8 +99,8 @@ func HandleSendEmail(sender func(ctx context.Context, to, subject, body string)
func HandleWebhook(deliverer func(ctx context.Context, url, secret, event string, payload map[string]interface{}) error) TaskHandler {
return func(ctx context.Context, queue string, body []byte) error {
var msg struct {
- Type string `json:"type"`
- Payload WebhookPayload `json:"payload"`
+ Type string `json:"type"`
+ Payload WebhookPayload `json:"payload"`
}
if err := json.Unmarshal(body, &msg); err != nil {
return err
diff --git a/api/internal/queue/queue.go b/api/internal/queue/queue.go
index 0afa5f03..3acbe0bb 100644
--- a/api/internal/queue/queue.go
+++ b/api/internal/queue/queue.go
@@ -19,7 +19,7 @@ const (
// Task types for routing or payload identification.
const (
- TaskSendEmail = "send_email"
+ TaskSendEmail = "send_email"
TaskWebhookDeliver = "webhook_deliver"
)
@@ -42,8 +42,8 @@ type WebhookPayload struct {
// Publisher publishes tasks to RabbitMQ.
type Publisher struct {
- ch *amqp.Channel
- log *slog.Logger
+ ch *amqp.Channel
+ log *slog.Logger
queues map[string]bool
}
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index c089f974..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"
@@ -16,11 +17,12 @@ import (
// Config holds dependencies for the router.
type Config struct {
- Log *slog.Logger
- DB *gorm.DB
- Redis *redis.Client // optional: cache, locks, magic-link
- Queue *queue.Publisher // optional: enqueue emails, webhooks
- CORSAllowOrigin string // optional: e.g. "http://localhost:5173" for UI dev
+ Log *slog.Logger
+ 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
}
// New builds and returns the Gin engine with /api/ and /auth/ routes.
@@ -64,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)
@@ -94,6 +97,7 @@ func New(cfg Config) *gin.Engine {
// Handlers
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}
@@ -121,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)
@@ -149,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/issue.go b/api/internal/service/issue.go
index 3df8e61d..ff3eefc0 100644
--- a/api/internal/service/issue.go
+++ b/api/internal/service/issue.go
@@ -12,7 +12,7 @@ import (
)
var (
- ErrIssueNotFound = errors.New("issue not found")
+ ErrIssueNotFound = errors.New("issue not found")
)
// IssueService handles issue business logic.
diff --git a/api/internal/service/issue_view.go b/api/internal/service/issue_view.go
index e68bb782..68e9b476 100644
--- a/api/internal/service/issue_view.go
+++ b/api/internal/service/issue_view.go
@@ -93,7 +93,7 @@ func (s *IssueViewService) Create(ctx context.Context, workspaceSlug string, pro
Filters: filters,
DisplayFilters: displayFilters,
DisplayProperties: displayProperties,
- RichFilters: model.JSONMap{},
+ RichFilters: model.JSONMap{},
Access: 1,
OwnedByID: userID,
WorkspaceID: workspaceID,
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 c071827c..bf587854 100644
--- a/api/internal/service/workspace.go
+++ b/api/internal/service/workspace.go
@@ -14,12 +14,12 @@ import (
)
var (
- ErrWorkspaceNotFound = errors.New("workspace not found")
- ErrWorkspaceForbidden = errors.New("not a member of this workspace")
- ErrSlugInvalid = errors.New("invalid slug")
- ErrSlugTaken = errors.New("slug already in use")
- ErrInviteNotFound = errors.New("invite not found")
- ErrMemberNotFound = errors.New("member not found")
+ ErrWorkspaceNotFound = errors.New("workspace not found")
+ ErrWorkspaceForbidden = errors.New("not a member of this workspace")
+ ErrSlugInvalid = errors.New("invalid slug")
+ ErrSlugTaken = errors.New("slug already in use")
+ ErrInviteNotFound = errors.New("invite not found")
+ ErrMemberNotFound = errors.New("member not found")
)
var (
@@ -70,9 +70,9 @@ func (s *WorkspaceService) Create(ctx context.Context, name, slug string, ownerI
return nil, ErrSlugTaken
}
w := &model.Workspace{
- Name: name,
- Slug: slug,
- OwnerID: ownerID,
+ Name: name,
+ Slug: slug,
+ OwnerID: ownerID,
CreatedByID: &ownerID,
}
if err := s.ws.Create(ctx, w); err != nil {
@@ -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/session.go b/api/internal/store/session.go
index 0ace0e37..33b0673a 100644
--- a/api/internal/store/session.go
+++ b/api/internal/store/session.go
@@ -27,8 +27,8 @@ func (s *SessionStore) Create(ctx context.Context, sessionKey string, userID uui
expire := time.Now().UTC().AddDate(0, 0, sessionExpireDays)
rec := &model.Session{
SessionKey: sessionKey,
- SessionData: string(data),
- ExpireDate: expire,
+ SessionData: string(data),
+ ExpireDate: expire,
}
return s.db.WithContext(ctx).Create(rec).Error
}
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/api/internal/store/user_recent_visit.go b/api/internal/store/user_recent_visit.go
index f5d7f321..c9cdf4e7 100644
--- a/api/internal/store/user_recent_visit.go
+++ b/api/internal/store/user_recent_visit.go
@@ -52,12 +52,12 @@ func (s *UserRecentVisitStore) Upsert(ctx context.Context, workspaceID, userID u
}
if err == gorm.ErrRecordNotFound {
v := &model.UserRecentVisit{
- WorkspaceID: workspaceID,
- UserID: userID,
- EntityName: entityName,
- EntityIdentifier: entityIdentifier,
- ProjectID: projectID,
- LastVisitedAt: now,
+ WorkspaceID: workspaceID,
+ UserID: userID,
+ EntityName: entityName,
+ EntityIdentifier: entityIdentifier,
+ ProjectID: projectID,
+ LastVisitedAt: now,
}
return s.db.WithContext(ctx).Create(v).Error
}
diff --git a/ui/README.md b/ui/README.md
index f2948e47..8ba6cb99 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -17,9 +17,9 @@ If you are developing a production application, we recommend updating the config
```js
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(["dist"]),
{
- files: ['**/*.{ts,tsx}'],
+ files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
@@ -34,45 +34,44 @@ export default defineConfig([
],
languageOptions: {
parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
-])
+]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
-import reactX from 'eslint-plugin-react-x'
-import reactDom from 'eslint-plugin-react-dom'
+import reactX from "eslint-plugin-react-x";
+import reactDom from "eslint-plugin-react-dom";
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(["dist"]),
{
- files: ['**/*.{ts,tsx}'],
+ files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Enable lint rules for React
- reactX.configs['recommended-typescript'],
+ reactX.configs["recommended-typescript"],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
-])
+]);
```
-
---
## Ufazien Deployment
@@ -82,6 +81,7 @@ This project is configured for deployment to Ufazien Hosting.
### Build and Deploy
1. Build your project (this will create a `dist` or `build` folder):
+
```bash
npm run build
# or
@@ -91,6 +91,7 @@ pnpm build
```
2. Deploy to Ufazien:
+
```bash
ufazien deploy
```
diff --git a/ui/eslint.config.js b/ui/eslint.config.js
index 5e6b472f..75d3c46f 100644
--- a/ui/eslint.config.js
+++ b/ui/eslint.config.js
@@ -1,14 +1,14 @@
-import js from '@eslint/js'
-import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
-import tseslint from 'typescript-eslint'
-import { defineConfig, globalIgnores } from 'eslint/config'
+import js from "@eslint/js";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
+import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(["dist"]),
{
- files: ['**/*.{ts,tsx}'],
+ files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
@@ -20,4 +20,4 @@ export default defineConfig([
globals: globals.browser,
},
},
-])
+]);
diff --git a/ui/index.html b/ui/index.html
index 4441604e..2ec4dd9c 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -7,7 +7,10 @@
Devlane
-
+
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 0963fcde..23d10e39 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "Devlane UI",
- "version": "0.2.1",
+ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Devlane UI",
- "version": "0.2.1",
+ "version": "0.3.0",
"dependencies": {
"@headlessui/react": "^2.2.9",
"@tailwindcss/vite": "^4.1.18",
@@ -35,6 +35,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
@@ -4776,6 +4777,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/prosemirror-changeset": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index 5141f08e..7a02aa89 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -1,12 +1,15 @@
{
"name": "Devlane UI",
"private": true,
- "version": "0.2.1",
+ "version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
+ "lint:fix": "eslint --fix .",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
"preview": "vite preview"
},
"dependencies": {
@@ -37,6 +40,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
+ "prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 064dea54..089c692c 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,13 +1,16 @@
-import { RouterProvider } from 'react-router-dom';
-import { AuthProvider } from './contexts/AuthContext';
-import { ThemeProvider } from './contexts/ThemeContext';
-import { router } from './routes';
+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";
export default function App() {
return (
-
+
+
+
);
diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts
index 805d5581..e0a64946 100644
--- a/ui/src/api/client.ts
+++ b/ui/src/api/client.ts
@@ -1,5 +1,5 @@
-import axios, { type AxiosError } from 'axios';
-import { config } from '../config/env';
+import axios, { type AxiosError } from "axios";
+import { config } from "../config/env";
/**
* Shared Axios instance for all API requests.
@@ -11,7 +11,7 @@ export const apiClient = axios.create({
baseURL: config.apiBaseUrl,
withCredentials: true,
headers: {
- 'Content-Type': 'application/json',
+ "Content-Type": "application/json",
},
});
@@ -35,7 +35,7 @@ export function getApiErrorMessage(err: unknown): string {
if (ax.response?.status) return `Request failed (${ax.response.status}).`;
}
if (err instanceof Error) return err.message;
- return 'An unexpected error occurred.';
+ return "An unexpected error occurred.";
}
apiClient.interceptors.response.use(
@@ -43,5 +43,5 @@ apiClient.interceptors.response.use(
(error: AxiosError) => {
const message = getApiErrorMessage(error);
return Promise.reject(new Error(message));
- }
+ },
);
diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts
index 85a8a41b..c7ef7d2a 100644
--- a/ui/src/api/index.ts
+++ b/ui/src/api/index.ts
@@ -1,3 +1,3 @@
-export { apiClient, getApiErrorMessage } from './client';
-export type { ApiErrorResponse } from './client';
-export type { CreateWorkspaceRequest, WorkspaceApiResponse } from './types';
+export { apiClient, getApiErrorMessage } from "./client";
+export type { ApiErrorResponse } from "./client";
+export type { CreateWorkspaceRequest, WorkspaceApiResponse } from "./types";
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/CreateProjectModal.tsx b/ui/src/components/CreateProjectModal.tsx
index e5460aa4..6d2728ef 100644
--- a/ui/src/components/CreateProjectModal.tsx
+++ b/ui/src/components/CreateProjectModal.tsx
@@ -1,8 +1,8 @@
-import { useEffect, useState } from 'react';
-import { createPortal } from 'react-dom';
-import { Button, Input } from './ui';
-import { projectService } from '../services/projectService';
-import type { ProjectApiResponse } from '../api/types';
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { Button, Input } from "./ui";
+import { projectService } from "../services/projectService";
+import type { ProjectApiResponse } from "../api/types";
export interface CreateProjectModalProps {
open: boolean;
@@ -12,15 +12,25 @@ export interface CreateProjectModalProps {
}
const COVER_GRADIENTS = [
- 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
- 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 50%, #7dd3fc 100%)',
- 'linear-gradient(135deg, #10b981 0%, #34d399 50%, #6ee7b7 100%)',
- 'linear-gradient(135deg, #f59e0b 0%, #fbbf24 50%, #fcd34d 100%)',
- 'linear-gradient(135deg, #ec4899 0%, #f472b6 50%, #f9a8d4 100%)',
+ "linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)",
+ "linear-gradient(135deg, #0ea5e9 0%, #38bdf8 50%, #7dd3fc 100%)",
+ "linear-gradient(135deg, #10b981 0%, #34d399 50%, #6ee7b7 100%)",
+ "linear-gradient(135deg, #f59e0b 0%, #fbbf24 50%, #fcd34d 100%)",
+ "linear-gradient(135deg, #ec4899 0%, #f472b6 50%, #f9a8d4 100%)",
];
const IconGlobe = () => (
-