fix workspace access and vendor correct fonts
Test and publish / verify (push) Successful in 2m22s

This commit is contained in:
2026-08-04 20:07:35 +03:30
parent 29d7f1636f
commit 9380f116d7
46 changed files with 320 additions and 122 deletions
+100 -17
View File
@@ -91,6 +91,7 @@ type PageData struct {
Users []User
Workspaces []Workspace
Workspace Workspace
WorkspaceMembers map[int64]bool
Day DayDetail
Week WeekDetail
WorkUpdates []WorkUpdate
@@ -268,6 +269,7 @@ func (s *Server) Close() error { return s.store.Close() }
func (s *Server) routes(mux *http.ServeMux) {
mux.Handle("GET /static/", http.FileServer(http.FS(assets)))
mux.Handle("GET /uploads/avatars/", http.StripPrefix("/uploads/avatars/", http.FileServer(http.Dir(filepath.Join(filepath.Dir(s.cfg.DatabasePath), "avatars")))))
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /login", s.loginPage)
mux.HandleFunc("POST /login", s.login)
@@ -276,6 +278,8 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /logout", s.requireAuth(s.csrf(s.logout)))
mux.HandleFunc("GET /auth/{provider}", s.oauthStart)
mux.HandleFunc("GET /auth/{provider}/callback", s.oauthCallback)
mux.HandleFunc("GET /profile", s.requireAuth(s.profilePage))
mux.HandleFunc("POST /profile/avatar", s.requireAuth(s.csrf(s.uploadAvatar)))
mux.HandleFunc("GET /", s.requireAuth(s.dashboard))
mux.HandleFunc("GET /day", s.requireAuth(s.dayPage))
mux.HandleFunc("GET /calendar", s.requireAuth(s.calendarPartial))
@@ -288,6 +292,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /updates", s.requireAuth(s.csrf(s.createWorkUpdate)))
mux.HandleFunc("POST /updates/{id}/delete", s.requireAuth(s.csrf(s.deleteWorkUpdate)))
mux.HandleFunc("GET /board", s.requireAuth(s.boardPage))
mux.HandleFunc("GET /board/archive", s.requireAuth(s.archivedBoardPage))
mux.HandleFunc("POST /board/tasks", s.requireAuth(s.csrf(s.createBoardTask)))
mux.HandleFunc("POST /board/tasks/{id}/move", s.requireAuth(s.csrf(s.moveBoardTask)))
mux.HandleFunc("POST /board/tasks/{id}/archive", s.requireAuth(s.csrf(s.archiveBoardTask)))
@@ -307,6 +312,7 @@ func (s *Server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /admin/users/{id}/status", s.requireAdmin(s.csrf(s.setUserStatus)))
mux.HandleFunc("GET /admin/workspaces", s.requireAdmin(s.workspacesPage))
mux.HandleFunc("POST /admin/workspaces", s.requireAdmin(s.csrf(s.createWorkspace)))
mux.HandleFunc("POST /admin/workspaces/{id}/members", s.requireAdmin(s.csrf(s.setWorkspaceMembers)))
mux.HandleFunc("POST /workspace/switch", s.requireAuth(s.csrf(s.switchWorkspace)))
mux.HandleFunc("GET /reports", s.requireAdmin(s.reportPage))
mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV))
@@ -392,7 +398,7 @@ func (s *Server) buildDayDetail(day string) (DayDetail, error) {
Prev: jalali.FromTime(selected.AddDate(0, 0, -1)).String(),
Next: jalali.FromTime(selected.AddDate(0, 0, 1)).String(),
}
if holiday := persianHoliday(jalaliDate.Month, jalaliDate.Day); holiday != "" {
if holiday := persianHoliday(jalaliDate.Year, jalaliDate.Month, jalaliDate.Day); holiday != "" {
detail.DayNote = holiday
} else if selected.Weekday() == time.Friday {
detail.DayNote = "Friday weekend"
@@ -489,6 +495,7 @@ func (s *Server) render(w http.ResponseWriter, name string, data PageData) {
if data.User != nil {
if workspaces, err := s.store.Workspaces(data.User.ID); err == nil {
data.Workspaces = workspaces
if data.Workspace.ID == 0 && data.User.WorkspaceID > 0 { for _, ws := range workspaces { if ws.ID == data.User.WorkspaceID { data.Workspace = ws; break } } }
if data.Workspace.ID == 0 && len(workspaces) > 0 { data.Workspace = workspaces[0] }
}
}
@@ -626,7 +633,7 @@ func (s *Server) buildCalendar(userID int64, selected string) Calendar {
raw := g.Format("2006-01-02")
cell := CalendarCell{Day: d, Weekday: (offset + d - 1) % 7, Gregorian: raw, InMonth: true, IsToday: raw == time.Now().Format("2006-01-02")}
cell.IsFriday = g.Weekday() == time.Friday
cell.Holiday = persianHoliday(month, d)
cell.Holiday = persianHoliday(year, month, d)
if a, ok := attendance[raw]; ok {
cell.Status = a.Mode
}
@@ -650,12 +657,21 @@ func (s *Server) buildCalendar(userID int64, selected string) Calendar {
}
}
func persianHoliday(month, day int) string {
func persianHoliday(year, month, day int) string {
// Official public holidays for Solar Hijri 1405. Lunar holidays are
// recorded using the dates published for this Persian calendar year.
if year != 1405 { return "" }
holidays := map[string]string{
"1-1": "Nowruz", "1-2": "Nowruz", "1-3": "Nowruz", "1-4": "Nowruz",
"1-12": "Islamic Republic Day", "1-13": "Nature Day",
"3-14": "Demise of Imam Khomeini", "3-15": "Khordad Uprising",
"11-22": "Revolution Day", "12-29": "Oil Nationalization Day",
"1-1":"Nowruz", "1-2":"Nowruz", "1-3":"Nowruz", "1-4":"Nowruz",
"1-12":"Islamic Republic Day", "1-13":"Nature Day",
"2-6":"Eid al-Adha", "2-14":"Eid al-Ghadir",
"3-14":"Demise of Imam Khomeini", "3-15":"Khordad Uprising",
"4-3":"Tasua", "4-4":"Ashura",
"5-13":"Arbaeen", "5-21":"Demise of Prophet Muhammad", "5-23":"Martyrdom of Imam Hassan", "5-30":"Martyrdom of Imam Reza",
"6-8":"Prophet Muhammad's Birthday",
"9-3":"Martyrdom of Fatima",
"10-2":"Imam Ali's Birthday",
"11-22":"Revolution Day", "12-29":"Oil Nationalization Day",
}
return holidays[fmt.Sprintf("%d-%d", month, day)]
}
@@ -775,17 +791,18 @@ func (s *Server) deleteWorkUpdate(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
tasks, err := s.store.BoardTasks()
workspaceID := requestWorkspaceID(r)
tasks, err := s.store.BoardTasksInWorkspace(workspaceID)
if err != nil {
http.Error(w, "could not load team board", http.StatusInternalServerError)
return
}
archivedTasks, err := s.store.ArchivedBoardTasks()
archivedTasks, err := s.store.ArchivedBoardTasksInWorkspace(workspaceID)
if err != nil {
http.Error(w, "could not load archived cards", http.StatusInternalServerError)
return
}
users, err := s.store.ActiveUsers()
users, err := s.store.ActiveUsersInWorkspace(workspaceID)
if err != nil {
http.Error(w, "could not load teammates", http.StatusInternalServerError)
return
@@ -842,6 +859,11 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) archivedBoardPage(w http.ResponseWriter, r *http.Request) {
tasks, err := s.store.ArchivedBoardTasksInWorkspace(requestWorkspaceID(r)); if err != nil { http.Error(w, "could not load archived cards", http.StatusInternalServerError); return }
s.render(w, "archived.html", PageData{Title:"Archived cards", User:currentUser(r), CSRF:csrfToken(r), ArchivedTasks:tasks, Error:r.URL.Query().Get("error"), Flash:r.URL.Query().Get("flash"), SelectedSection:"board"})
}
func validBoardPriority(priority string) bool {
return priority == "" || priority == "low" || priority == "normal" || priority == "high" || priority == "urgent"
}
@@ -878,6 +900,13 @@ func boardTaskMatchesFilter(task BoardTask, filter BoardFilter) bool {
}
func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
workspaceID := requestWorkspaceID(r)
if raw := r.FormValue("workspace_id"); raw != "" {
if selected, err := strconv.ParseInt(raw, 10, 64); err == nil {
if _, err := s.store.WorkspaceMember(selected, currentUser(r).ID); err != nil { http.Redirect(w,r,"/board?error=You+do+not+have+access+to+that+workspace",http.StatusSeeOther); return }
workspaceID = selected
}
}
if strings.TrimSpace(r.FormValue("title")) == "" {
http.Redirect(w, r, "/board?error=Task+title+is+required", http.StatusSeeOther)
return
@@ -900,8 +929,8 @@ func (s *Server) createBoardTask(w http.ResponseWriter, r *http.Request) {
}
tagIDs = append(tagIDs, tag.ID)
}
taskID, err := s.store.CreateBoardTask(
currentUser(r).ID, r.FormValue("title"), r.FormValue("description"),
taskID, err := s.store.CreateBoardTaskInWorkspace(
workspaceID, currentUser(r).ID, r.FormValue("title"), r.FormValue("description"),
r.FormValue("status"), r.FormValue("importance"), parseIDList(r.Form["assignee_ids"]), dueDate,
)
if err != nil {
@@ -1064,11 +1093,44 @@ func (s *Server) usersPage(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) profilePage(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
s.render(w, "profile.html", PageData{Title:"Profile", User:u, CSRF:csrfToken(r), Flash:r.URL.Query().Get("flash"), Error:r.URL.Query().Get("error"), SelectedSection:"profile"})
}
func (s *Server) uploadAvatar(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(3 << 20); err != nil { http.Redirect(w,r,"/profile?error=Choose+an+image+up+to+2MB",http.StatusSeeOther); return }
file, header, err := r.FormFile("avatar"); if err != nil || header.Size > 2<<20 { http.Redirect(w,r,"/profile?error=Choose+an+image+up+to+2MB",http.StatusSeeOther); return }; defer file.Close()
buf := make([]byte,512); n,_ := file.Read(buf); kind := http.DetectContentType(buf[:n]); ext := map[string]string{"image/jpeg":".jpg","image/png":".png","image/webp":".webp"}[kind]; if ext=="" { http.Redirect(w,r,"/profile?error=Only+JPG,+PNG,+or+WebP+images+are+supported",http.StatusSeeOther); return }
if _, err := file.Seek(0,0); err != nil { http.Redirect(w,r,"/profile?error=Could+not+read+image",http.StatusSeeOther); return }
dir := filepath.Join(filepath.Dir(s.cfg.DatabasePath),"avatars"); if err := os.MkdirAll(dir,0755); err != nil { http.Error(w,"could not save avatar",500); return }
name := fmt.Sprintf("%d%s", currentUser(r).ID, ext); dst, err := os.Create(filepath.Join(dir,name)); if err != nil { http.Error(w,"could not save avatar",500); return }; defer dst.Close(); if _,err=io.Copy(dst,file); err != nil { http.Error(w,"could not save avatar",500); return }
if err := s.store.SetUserAvatar(currentUser(r).ID,"/uploads/avatars/"+name); err != nil { http.Error(w,"could not save avatar",500); return }
http.Redirect(w,r,"/profile?flash=Profile+photo+updated",http.StatusSeeOther)
}
func (s *Server) workspacesPage(w http.ResponseWriter, r *http.Request) {
u := currentUser(r)
workspaces, err := s.store.Workspaces(u.ID)
workspaces, err := s.store.AllWorkspaces()
if err != nil { http.Error(w, "could not load workspaces", http.StatusInternalServerError); return }
s.render(w, "workspaces.html", PageData{Title:"Workspaces", User:u, CSRF:csrfToken(r), Workspaces:workspaces, Error:r.URL.Query().Get("error"), Flash:r.URL.Query().Get("flash"), SelectedSection:"workspaces"})
users, err := s.store.Users(); if err != nil { http.Error(w,"could not load users",500); return }
selected := int64(0); if len(workspaces)>0 { selected=workspaces[0].ID }
if raw:=r.URL.Query().Get("workspace"); raw!="" { selected,_=strconv.ParseInt(raw,10,64) }
members, _ := s.store.WorkspaceMemberIDs(selected)
selectedWorkspace := Workspace{ID:selected}; for _, ws := range workspaces { if ws.ID == selected { selectedWorkspace = ws; break } }
s.render(w, "workspaces.html", PageData{Title:"Workspaces", User:u, CSRF:csrfToken(r), Workspaces:workspaces, Users:users, Workspace:selectedWorkspace, Error:r.URL.Query().Get("error"), Flash:r.URL.Query().Get("flash"), SelectedSection:"workspaces", WorkspaceMembers:members})
}
func (s *Server) setWorkspaceMembers(w http.ResponseWriter, r *http.Request) {
id,_ := strconv.ParseInt(r.PathValue("id"),10,64); var ids []int64; seen := map[int64]bool{}
for _, raw := range r.Form["user_ids"] { if userID,err:=strconv.ParseInt(raw,10,64); err==nil && !seen[userID] { ids=append(ids,userID); seen[userID]=true } }
// The admin editing access must retain access to the workspace being managed.
adminID := currentUser(r).ID
foundAdmin := false
for _, id := range ids { if id == adminID { foundAdmin = true; break } }
if !foundAdmin { ids = append(ids, adminID) }
if err:=s.store.SetWorkspaceMembers(id,ids); err!=nil { http.Redirect(w,r,"/admin/workspaces?error="+url.QueryEscape(err.Error()),http.StatusSeeOther); return }
http.Redirect(w,r,"/admin/workspaces?workspace="+strconv.FormatInt(id,10)+"&flash=Workspace+members+updated",http.StatusSeeOther)
}
func (s *Server) createWorkspace(w http.ResponseWriter, r *http.Request) {
@@ -1082,7 +1144,11 @@ func (s *Server) switchWorkspace(w http.ResponseWriter, r *http.Request) {
if err != nil { http.Error(w,"invalid workspace",http.StatusBadRequest); return }
if _, err := s.store.WorkspaceMember(id,currentUser(r).ID); err != nil { http.Error(w,"workspace access denied",http.StatusForbidden); return }
http.SetCookie(w,&http.Cookie{Name:"teammate_workspace",Value:strconv.FormatInt(id,10),Path:"/",HttpOnly:true,SameSite:http.SameSiteLaxMode,MaxAge:31536000})
returnTo := r.FormValue("return_to"); if returnTo == "" || !strings.HasPrefix(returnTo,"/") { returnTo = "/" }
w.Header().Set("Cache-Control", "no-store")
returnTo := r.FormValue("return_to"); if returnTo == "" || !strings.HasPrefix(returnTo,"/") { returnTo = "/admin/workspaces?workspace="+strconv.FormatInt(id,10) }
if r.FormValue("return_to") == "" {
if ref, err := url.Parse(r.Referer()); err == nil && ref.Path != "" && ref.Path != "/workspace/switch" { returnTo = ref.Path; if ref.RawQuery != "" { returnTo += "?" + ref.RawQuery } }
}
http.Redirect(w,r,returnTo,http.StatusSeeOther)
}
@@ -1245,6 +1311,13 @@ func currentUser(r *http.Request) *User {
return u
}
func requestWorkspaceID(r *http.Request) int64 {
if c, err := r.Cookie("teammate_workspace"); err == nil {
if id, err := strconv.ParseInt(c.Value, 10, 64); err == nil && id > 0 { return id }
}
return 1
}
func csrfToken(r *http.Request) string {
v, _ := r.Context().Value(csrfKey).(string)
return v
@@ -1255,6 +1328,10 @@ func (s *Server) withUser(next http.Handler) http.Handler {
c, err := r.Cookie("teammate_session")
if err == nil {
if u, csrf, err := s.store.Session(c.Value); err == nil {
u.WorkspaceID = requestWorkspaceID(r)
if _, err := s.store.WorkspaceMember(u.WorkspaceID, u.ID); err != nil {
if available, lookupErr := s.store.Workspaces(u.ID); lookupErr == nil && len(available) > 0 { u.WorkspaceID = available[0].ID }
}
ctx := r.Context()
ctx = context.WithValue(ctx, userKey, u)
ctx = context.WithValue(ctx, csrfKey, csrf)
@@ -1287,7 +1364,13 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
func (s *Server) csrf(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil || subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrfToken(r))) != 1 {
var err error
if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "multipart/form-data") {
err = r.ParseMultipartForm(8 << 20)
} else {
err = r.ParseForm()
}
if err != nil || subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrfToken(r))) != 1 {
http.Error(w, "invalid security token; reload the page and try again", http.StatusForbidden)
return
}
@@ -1300,7 +1383,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://unpkg.com; style-src 'self' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; img-src 'self' data: https:; connect-src 'self'")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self'")
next.ServeHTTP(w, r)
})
}
+15 -15
View File
@@ -142,47 +142,47 @@ func TestLoginAttendanceAndReportFlow(t *testing.T) {
createUser := formRequest(t, s.http.Handler, "/admin/users", url.Values{
"csrf": {csrf},
"username": {"sara"},
"username": {"navid"},
"password": {"temporary-password"},
"display_name": {"Sara Ahmadi"},
"email": {"sara@example.test"},
"display_name": {"Navid Somethingi"},
"email": {"navid@example.test"},
"role": {"member"},
}, session)
if createUser.Code != http.StatusSeeOther {
t.Fatalf("create user status: got %d, body %s", createUser.Code, createUser.Body.String())
}
if _, err := s.store.Authenticate("sara", "temporary-password"); err != nil {
if _, err := s.store.Authenticate("navid", "temporary-password"); err != nil {
t.Fatalf("created teammate could not authenticate: %v", err)
}
users, err := s.store.Users()
if err != nil {
t.Fatal(err)
}
var sara User
var testuser User
for _, user := range users {
if user.Username == "sara" {
sara = user
if user.Username == "navid" {
testuser = user
break
}
}
if sara.ID == 0 || !sara.Active {
t.Fatalf("created teammate missing or inactive: %#v", sara)
if testuser.ID == 0 || !testuser.Active {
t.Fatalf("created teammate missing or inactive: %#v", testuser)
}
saraToken, _, err := s.store.CreateSession(sara.ID)
testUserToken, _, err := s.store.CreateSession(testuser.ID)
if err != nil {
t.Fatal(err)
}
lockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(sara.ID, 10)+"/status", url.Values{
lockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(testuser.ID, 10)+"/status", url.Values{
"csrf": {csrf},
"action": {"lock"},
}, session)
if lockUser.Code != http.StatusSeeOther || lockUser.Header().Get("Location") != "/admin/users?flash=Account+locked" {
t.Fatalf("lock user: got %d location %q", lockUser.Code, lockUser.Header().Get("Location"))
}
if _, err := s.store.Authenticate("sara", "temporary-password"); err == nil || !strings.Contains(err.Error(), "locked") {
if _, err := s.store.Authenticate("navid", "temporary-password"); err == nil || !strings.Contains(err.Error(), "locked") {
t.Fatalf("locked teammate authentication result: %v", err)
}
if _, _, err := s.store.Session(saraToken); err == nil {
if _, _, err := s.store.Session(testUserToken); err == nil {
t.Fatal("locking a teammate did not revoke the active session")
}
if activeUsers, err := s.store.ActiveUsers(); err != nil || len(activeUsers) != 1 {
@@ -205,14 +205,14 @@ func TestLoginAttendanceAndReportFlow(t *testing.T) {
if lockSelf.Code != http.StatusSeeOther || !strings.Contains(lockSelf.Header().Get("Location"), "error=") {
t.Fatalf("administrator could lock their own account: %d %q", lockSelf.Code, lockSelf.Header().Get("Location"))
}
unlockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(sara.ID, 10)+"/status", url.Values{
unlockUser := formRequest(t, s.http.Handler, "/admin/users/"+strconv.FormatInt(testuser.ID, 10)+"/status", url.Values{
"csrf": {csrf},
"action": {"unlock"},
}, session)
if unlockUser.Code != http.StatusSeeOther || unlockUser.Header().Get("Location") != "/admin/users?flash=Account+unlocked" {
t.Fatalf("unlock user: got %d location %q", unlockUser.Code, unlockUser.Header().Get("Location"))
}
if _, err := s.store.Authenticate("sara", "temporary-password"); err != nil {
if _, err := s.store.Authenticate("navid", "temporary-password"); err != nil {
t.Fatalf("unlocked teammate could not authenticate: %v", err)
}
+71 -23
View File
@@ -59,21 +59,48 @@ h2 { margin-bottom: 8px; font-size: 1.25rem; letter-spacing: -.025em; }
.sidebar-user { display: grid; grid-template-columns: 36px 1fr auto; gap: 9px; align-items: center; margin-top: auto; padding: 13px 8px 0; border-top: 1px solid var(--line); }
.sidebar-user strong { display: block; overflow: hidden; font-size: .76rem; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-user small { color: var(--muted); font-size: .66rem; text-transform: capitalize; }
.sidebar-profile-link, .sidebar-profile-details { text-decoration:none; }
.sidebar-profile-link { display:block; cursor:pointer; }
.sidebar-profile-link img.avatar { display:block; object-fit:cover; }
.sidebar-profile-details strong { color:var(--ink); }
.sidebar-profile-details:hover strong { color:var(--green); }
.profile-account-actions { display:grid; gap:8px; max-width:1500px; margin:18px 0 0; padding:24px 36px; }
.profile-account-actions h2, .profile-account-actions p { margin:0; }
.avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: var(--sand); color: #725d37; font-weight: 700; text-transform: uppercase; }
.profile-card { display:grid; grid-template-columns:auto 1fr 340px; gap:28px; align-items:center; max-width:1500px; margin:0; padding:32px 36px; }
.profile-avatar, .profile-avatar img { display:grid; width:84px; height:84px; place-items:center; border-radius:50%; background:var(--sand); color:#725d37; font-size:1.7rem; font-weight:700; object-fit:cover; }
.profile-card h2 { margin-bottom:6px; font-size:1.15rem; }
.profile-card p { margin:0; color:var(--muted); font-size:.78rem; }
.profile-upload { display:grid; gap:9px; padding-left:28px; border-left:1px solid var(--line); }
.profile-upload label { display:grid; gap:6px; color:var(--muted); font-size:.72rem; font-weight:700; }
.profile-upload input[type=file] { width:100%; font:inherit; font-size:.72rem; }
.profile-upload small { color:var(--muted); font-size:.65rem; }
.workspace-members-form { display:grid; gap:8px; margin-top:12px; }
.workspace-members-form > label { display:flex; align-items:center; gap:10px; padding:9px 10px; border:1px solid var(--line); border-radius:10px; background:var(--paper); cursor:pointer; }
.workspace-members-form > label:hover { border-color:var(--green-2); background:var(--mint); }
.workspace-members-form > label input { width:17px; height:17px; accent-color:var(--green); }
.workspace-members-form > label .avatar { width:29px; height:29px; flex:0 0 29px; font-size:.6rem; }
.workspace-members-form > label > span:last-of-type { display:grid; gap:2px; font-size:.72rem; font-weight:700; }
.workspace-members-form > label small { color:var(--muted); font-size:.62rem; font-weight:500; }
.workspace-members-form .button { margin-top:5px; justify-self:start; }
.workspace-access-card { grid-column: 2; margin-top: 20px; }
.workspace-list { max-height: 280px; overflow-y: auto; }
.workspace-access-select label { display:grid; gap:6px; color:var(--muted); font-size:.72rem; font-weight:700; }
.workspace-access-select select { width:100%; margin-bottom:12px; }
.avatar.small { width: 40px; height: 40px; }
.icon-button { border: 0; background: transparent; color: var(--muted); cursor: pointer; }
.main { min-width: 0; padding: 42px clamp(28px, 5vw, 68px) 70px; }
.page-head { display: flex; justify-content: space-between; align-items: end; margin: 0 auto 28px; max-width: 1180px; }
.page-head { display: flex; justify-content: space-between; align-items: end; margin: 0 auto 28px; max-width: 1500px; }
.page-head > div > p:last-child { margin-bottom: 0; color: var(--muted); }
.today-pill { min-width: 132px; padding: 10px 15px; border: 1px solid var(--line); border-radius: 12px; background: var(--white); text-align: right; }
.today-pill span { display: block; color: var(--muted); font-size: .63rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.today-pill strong { font-family: Vazirmatn, Tahoma, sans-serif; font-size: .83rem; direction: ltr; }
.notice { max-width: 1180px; margin: 0 auto 18px; padding: 12px 15px; border-radius: 10px; font-size: .86rem; }
.notice { max-width: 1500px; margin: 0 auto 18px; padding: 12px 15px; border-radius: 10px; font-size: .86rem; }
.notice.success { border: 1px solid #bfdbcb; background: #eaf5ef; color: #286047; }
.notice.error { border: 1px solid #edc6c2; background: #fff0ee; color: #9d3e38; }
.card { border: 1px solid var(--line); border-radius: 16px; background: var(--white); box-shadow: var(--shadow); }
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.58fr) minmax(300px, .82fr); gap: 20px; max-width: 1180px; margin: 0 auto; }
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.58fr) minmax(300px, .82fr); gap: 20px; max-width: 1500px; margin: 0 auto; }
.presence-card { padding: 24px; }
.presence-top, .section-head { display: flex; justify-content: space-between; align-items: start; }
.live-badge { display: flex; align-items: center; gap: 6px; padding: 6px 9px; border-radius: 20px; background: #edf4f0; color: var(--green); font-size: .62rem; font-weight: 700; letter-spacing: .08em; }
@@ -114,7 +141,7 @@ h2 { margin-bottom: 8px; font-size: 1.25rem; letter-spacing: -.025em; }
.day-number { display: grid; width: 23px; height: 23px; place-items: center; border-radius: 50%; font-size: .7rem; font-weight: 600; }
.day.today .day-number { background: var(--green); color: #fff; }
.day.friday { background: #fcfaf6; }
.day.holiday small { display: block; overflow: hidden; margin-top: 4px; color: #ae7562; font-size: .5rem; text-overflow: ellipsis; white-space: nowrap; }
.day.holiday small { display: block; margin-top: 4px; color: #ae7562; font-size: .5rem; line-height: 1.25; overflow-wrap: anywhere; white-space: normal; }
.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; }
.day .status-dot { position: absolute; right: 8px; top: 16px; }
.status-dot.office { background: #51906f; }
@@ -146,7 +173,7 @@ h2 { margin-bottom: 8px; font-size: 1.25rem; letter-spacing: -.025em; }
.empty > span { color: #9ca9a2; font-size: 1.5rem; }
.empty a { color: var(--green); font-weight: 700; }
.two-column { display: grid; grid-template-columns: minmax(320px, .72fr) minmax(440px, 1.28fr); gap: 20px; max-width: 1180px; margin: auto; }
.two-column { display: grid; grid-template-columns: minmax(320px, .72fr) minmax(440px, 1.28fr); gap: 20px; max-width: 1500px; margin: auto; }
.form-card, .two-column > .card, .approval-card { padding: 26px; }
.stack-form { display: grid; gap: 18px; margin-top: 24px; }
label { color: #49564f; font-size: .72rem; font-weight: 700; }
@@ -205,7 +232,7 @@ textarea { resize: vertical; }
.filter-tabs { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
.filter-tabs a { padding: 7px 11px; border-radius: 7px; color: var(--muted); font-size: .68rem; font-weight: 600; }
.filter-tabs a.active { background: var(--ink); color: #fff; }
.approval-card { max-width: 1180px; margin: auto; }
.approval-card { max-width: 1500px; margin: auto; }
.approval-row { display: grid; grid-template-columns: 42px 1fr; gap: 14px; padding: 21px 0; border-bottom: 1px solid var(--line); }
.approval-row:last-child { border-bottom: 0; }
.approval-title { display: flex; gap: 7px; align-items: center; }
@@ -226,7 +253,7 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.report-range-filter { display: grid; grid-template-columns: 145px 145px auto; gap: 9px; align-items: end; padding: 10px; border: 1px solid var(--line); border-radius: 12px; background: var(--white); box-shadow: var(--shadow); }
.report-range-filter input { margin-top: 4px; padding-top: 8px; padding-bottom: 8px; }
.report-range-filter .button { min-height: 39px; }
.accumulated-report { max-width: 1180px; margin: 0 auto 20px; overflow: hidden; }
.accumulated-report { max-width: 1500px; margin: 0 auto 20px; overflow: hidden; }
.accumulated-head { display: flex; justify-content: space-between; align-items: center; padding: 25px 28px 20px; }
.accumulated-head h2 { margin-bottom: 3px; }
.accumulated-head p:last-child { margin: 0; color: var(--muted); font-size: .7rem; }
@@ -252,14 +279,15 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.metric.done { background: #e3f0e8; color: #337052; }
.metric.blocked { background: #f5e6e4; color: #9d4a44; }
.metric.pending { background: #f6eddc; color: #87662c; }
.report-export-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; max-width: 1180px; margin: auto; }
.report-export-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; max-width: 1500px; margin: auto; }
.report-export-grid .report-card { grid-template-columns: 60px 1fr; max-width: none; margin: 0; padding: 25px; }
.report-export-grid .secondary-report { margin-top: 0; }
.report-export-grid .report-illustration { width: 55px; height: 55px; border-radius: 15px; }
.report-export-grid .report-form { grid-template-columns: 1fr 1fr; }
.report-export-grid .report-form .button { grid-column: 1 / -1; }
.board-page-head { position: relative; align-items: center; }
.board-page-head { position: relative; align-items: center; max-width: 1500px; }
.board-head-actions .button > i { margin-right: 7px; }
.board-head-actions { display: flex; gap: 8px; align-items: center; }
.new-task-panel { position: relative; }
.new-task-panel > summary { list-style: none; user-select: none; }
@@ -355,20 +383,33 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.task-due { padding: 3px 5px; border-radius: 6px; background: var(--sand); color: #786039; font-size: .53rem; font-weight: 700; }
.todo-progress { padding: 3px 5px; border-radius: 6px; background: var(--mint); color: var(--green-2); font-size: .53rem; font-weight: 800; }
.importance-emoji { display: grid; width: 21px; height: 21px; place-items: center; border-radius: 6px; background: var(--paper); font-size: .66rem; line-height: 1; }
.importance-emoji.high { background: #f6eddc; }
.importance-emoji.urgent { background: #f5e6e4; }
.importance-emoji i { font-size: .68rem; }
.importance-emoji { background: transparent; }
.importance-low { color: #8b8b8b; background: #f1f1f1; }
.importance-normal { color: #4f7cff; background: #e7edff; }
.importance-high { color: #f59e0b; background: #fff3dc; }
.importance-urgent { color: #ef4444; background: #fde8e7; }
.kanban-card h3 { margin: 0 0 6px; font-size: .84rem; line-height: 1.3; }
.kanban-card > p { display: -webkit-box; overflow: hidden; margin: 0 0 10px; color: #64706a; font-size: .68rem; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.task-meta { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding-top: 9px; padding-right: 27px; border-top: 1px solid var(--line); }
.task-meta { display: flex; justify-content: space-between; gap: 8px; align-items: center; padding-top: 9px; padding-right: 27px; }
.task-assignees { display: flex; min-width: 0; flex-wrap: wrap; gap: 7px; }
.task-assignee { display: flex; min-width: 0; gap: 6px; align-items: center; color: #53605a; font-size: .61rem; font-weight: 700; }
.task-assignee i { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 50%; background: var(--blue); color: #4d6e94; font-size: .59rem; font-style: normal; }
.task-assignee i.avatar { width: 24px; height: 24px; }
.task-assignee img.avatar { display:block; width:24px; height:24px; object-fit:cover; }
.task-assignees { display:flex; align-items:center; gap:4px; }
.task-assignee { display:inline-flex; }
.task-actions .icon-button { width:30px; height:30px; padding:0; border:0; border-radius:9px; background:var(--paper); color:var(--green); font-size:.9rem; }
.task-actions .icon-button:hover { background:var(--mint); }
.task-actions .icon-button.danger { color:#a34d47; }
.task-actions .icon-button > i { font-size:.88rem; line-height:1; }
.task-remove > summary.icon-button { display:grid; place-items:center; cursor:pointer; list-style:none; }
.task-remove > summary.icon-button::-webkit-details-marker { display:none; }
.task-assignee span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-assignee.unassigned { color: var(--muted); font-weight: 500; }
.task-creator { overflow: hidden; color: #9aa39e; font-size: .54rem; text-overflow: ellipsis; white-space: nowrap; }
.card-controls { position: static; }
.card-controls > summary { position: absolute; right: 10px; bottom: 10px; display: grid; width: 25px; height: 25px; place-items: center; border-radius: 8px; color: var(--muted); font-size: .95rem; font-weight: 800; line-height: 1; cursor: pointer; list-style: none; }
.card-controls > summary { position: absolute; right: 10px; bottom: 10px; display: grid; width: 30px; height: 30px; place-items: center; border-radius: 9px; color: var(--muted); font-size: .95rem; font-weight: 800; line-height: 1; cursor: pointer; list-style: none; }
.card-controls > summary:hover, .card-controls[open] > summary { background: var(--paper); color: var(--ink); }
.card-controls > summary::-webkit-details-marker { display: none; }
.card-controls > summary i { display: block; font-size: 1.05rem; font-style: normal; line-height: 1; transition: transform .15s ease; }
@@ -396,13 +437,14 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.todo-add-form { display: flex; gap: 6px; }
.todo-add-form input { min-width: 0; margin: 0; padding: 7px 8px; font-size: .6rem; }
.todo-add-form .button { min-height: 31px; padding: 5px 9px; font-size: .58rem; }
.task-actions { display: flex; width: 100%; box-sizing: border-box; justify-content: space-between; align-items: center; padding-right: 31px; }
.task-actions > form:first-child { display: flex; gap: 4px; align-items: center; }
.task-actions select { width: auto; min-width: 104px; margin: 0; padding: 5px 25px 5px 7px; border: 0; background-color: var(--paper); font-size: .58rem; font-weight: 700; }
.task-actions { display: flex; width: 100%; box-sizing: border-box; justify-content: space-between; align-items: center; gap: 9px; padding-right: 31px; }
.task-actions > form:first-child { display: flex; height: 30px; gap: 4px; align-items: center; }
.task-actions select { width: 118px; min-width: 118px; height: 30px; margin: 0; padding: 5px 25px 5px 9px; border: 0; border-radius: 9px; background-color: var(--paper); font-size: .58rem; font-weight: 700; }
.task-actions .text-button { margin: 0; }
.task-move-button { display: none; }
.task-actions form:focus-within .task-move-button { display: inline-block; }
.task-lifecycle-actions { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: flex-end; margin-left: auto; }
.task-lifecycle-actions { display: flex; height: 30px; flex-wrap: wrap; gap: 14px; align-items: center; justify-content: flex-end; margin-left: auto; }
.task-lifecycle-actions form, .task-remove { height: 30px; }
.task-lifecycle-actions > form { display: flex; }
.archive-task { color: var(--green); font-weight: 700; }
.task-remove { position: relative; color: var(--muted); font-size: .58rem; }
@@ -427,6 +469,10 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.archived-card .card-tags { margin-bottom: 6px; }
.archived-card-actions { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; }
.archived-card-actions .button { min-height: 34px; padding: 7px 12px; font-size: .61rem; }
.archived-page-card { max-width: 1500px; margin: 0 auto; overflow: hidden; }
.archived-page-card > .section-head { padding: 26px 28px 22px; }
.archived-page-card .archived-card { padding-left:28px; padding-right:28px; }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.archived-delete { position: relative; }
.archived-delete > summary { color: var(--red); font-size: .6rem; font-weight: 700; cursor: pointer; list-style: none; }
.archived-delete > summary::-webkit-details-marker { display: none; }
@@ -465,7 +511,7 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb;
.day-date-form input { margin-top: 4px; padding-top: 8px; padding-bottom: 8px; }
.day-date-form .jalali-trigger { top: 10px; }
.day-date-form .button { min-height: 39px; }
.day-layout { display: grid; gap: 20px; max-width: 1180px; margin: 0 auto; }
.day-layout { display: grid; gap: 20px; max-width: 1500px; margin: 0 auto; }
.day-summary-card { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(360px, 1.15fr); align-items: center; padding: 24px 28px; }
.day-date-nav { display: grid; grid-template-columns: 37px 1fr 37px; gap: 14px; align-items: center; }
.day-date-nav > a { display: grid; width: 37px; height: 37px; place-items: center; border: 1px solid var(--line); border-radius: 9px; background: var(--white); color: var(--ink); font-size: 1.2rem; }
@@ -655,18 +701,17 @@ html[data-theme="dark"] .kanban-card > p { color: #aab6b0; }
html[data-theme="dark"] .task-assignee { color: #bdc8c2; }
html[data-theme="dark"] .task-due { background: #3b3222; color: #dec18b; }
html[data-theme="dark"] .todo-progress { background: #1d3b2c; color: #8fd0ae; }
html[data-theme="dark"] .importance-emoji.high { background: #453820; }
html[data-theme="dark"] .importance-emoji.urgent { background: #432725; }
html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142, .1); }
@media (max-width: 980px) {
.app-shell { grid-template-columns: 74px 1fr; }
.app-shell { grid-template-columns: 94px 1fr; }
.sidebar { padding: 25px 11px 16px; }
.brand > span:last-child, .sidebar nav a:not(.active) { font-size: 0; }
.brand { padding: 0 8px; }
.brand { justify-content: center; padding: 0 8px; }
.workspace-switcher { display: none; }
.sidebar nav a { justify-content: center; padding: 0; }
.nav-icon { font-size: 1.05rem; }
.nav-label, .sidebar-user > span:nth-child(2), .sidebar-user form { display: none; }
.nav-label, .sidebar-profile-details { display: none; }
.sidebar-user { display: flex; justify-content: center; padding-left: 0; padding-right: 0; }
.sidebar-theme { justify-content: center; padding-left: 0; padding-right: 0; }
.sidebar-theme span { display: none; }
@@ -675,6 +720,9 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142
.login-page { grid-template-columns: 1fr 1fr; }
.story-calendar { display: none; }
.two-column { grid-template-columns: 1fr; }
.workspace-access-card { margin-top: 0; }
.profile-card { grid-template-columns: auto 1fr; }
.profile-upload { grid-column: 1 / -1; padding-top:18px; padding-left:0; border-top:1px solid var(--line); border-left:0; }
.updates-layout { grid-template-columns: 1fr; }
.day-summary-card { grid-template-columns: 1fr; gap: 20px; }
.day-stats { padding-top: 18px; border-top: 1px solid var(--line); border-left: 0; }
+10
View File
@@ -0,0 +1,10 @@
/* Vazirmatn faces downloaded from Google Fonts and bundled for offline use. */
@font-face { font-family: Vazirmatn; src: url('fonts/webfonts/Vazirmatn-100.ttf') format('truetype'); font-weight: 100; font-style: normal; font-display: swap; }
@font-face { font-family: Vazirmatn; src: url('fonts/webfonts/Vazirmatn-200.ttf') format('truetype'); font-weight: 200; font-style: normal; font-display: swap; }
@font-face { font-family: Vazirmatn; src: url('fonts/webfonts/Vazirmatn-300.ttf') format('truetype'); font-weight: 300; font-style: normal; font-display: swap; }
@font-face { font-family: Vazirmatn; src: url('fonts/webfonts/Vazirmatn-400.ttf') format('truetype'); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: Vazirmatn; src: url('fonts/webfonts/Vazirmatn-500.ttf') format('truetype'); font-weight: 500; font-style: normal; font-display: swap; }
@font-face { font-family: Vazirmatn; src: url('fonts/webfonts/Vazirmatn-600.ttf') format('truetype'); font-weight: 600; font-style: normal; font-display: swap; }
@font-face { font-family: Vazirmatn; src: url('fonts/webfonts/Vazirmatn-700.ttf') format('truetype'); font-weight: 700; font-style: normal; font-display: swap; }
@font-face { font-family: Vazirmatn; src: url('fonts/webfonts/Vazirmatn-800.ttf') format('truetype'); font-weight: 800; font-style: normal; font-display: swap; }
@font-face { font-family: Vazirmatn; src: url('fonts/webfonts/Vazirmatn-900.ttf') format('truetype'); font-weight: 900; font-style: normal; font-display: swap; }
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
(() => {
const switchers = document.querySelectorAll('[data-workspace-switch], [data-workspace-access-switch]');
switchers.forEach((select) => {
select.addEventListener('change', () => {
const form = select.form;
if (form && typeof form.requestSubmit === 'function') form.requestSubmit();
else if (form) form.submit();
});
});
})();
+56 -8
View File
@@ -28,6 +28,7 @@ type User struct {
Role string
AvatarURL string
Active bool
WorkspaceID int64 `json:"-"`
}
type Workspace struct {
@@ -265,6 +266,7 @@ CREATE TABLE IF NOT EXISTS work_updates (
CREATE INDEX IF NOT EXISTS idx_work_updates_period ON work_updates(period_start,period_end);
CREATE TABLE IF NOT EXISTS board_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'backlog' CHECK(status IN ('backlog','todo','in_progress','blocked','done')),
@@ -315,6 +317,7 @@ CREATE INDEX IF NOT EXISTS idx_board_task_tags_tag ON board_task_tags(tag_id,tas
if err := s.ensureColumn("board_tasks", "importance", "importance TEXT NOT NULL DEFAULT 'normal' CHECK(importance IN ('low','normal','high','urgent'))"); err != nil {
return err
}
if err := s.ensureColumn("board_tasks", "workspace_id", "workspace_id INTEGER NOT NULL DEFAULT 1"); err != nil { return err }
if err := s.ensureBoardTaskTodoStatus(); err != nil {
return err
}
@@ -572,6 +575,11 @@ func (s *Store) Workspaces(userID int64) ([]Workspace, error) {
return out, rows.Err()
}
func (s *Store) AllWorkspaces() ([]Workspace, error) {
rows, err := s.db.Query(`SELECT id,name,slug,created_at FROM workspaces ORDER BY name`); if err != nil { return nil,err }; defer rows.Close()
var out []Workspace; for rows.Next() { var w Workspace; if err:=rows.Scan(&w.ID,&w.Name,&w.Slug,&w.CreatedAt); err!=nil{return nil,err}; out=append(out,w) }; return out,rows.Err()
}
func (s *Store) CreateWorkspace(name, slug string, ownerID int64) (int64, error) {
result, err := s.db.Exec(`INSERT INTO workspaces(name,slug) VALUES(?,?)`, strings.TrimSpace(name), strings.TrimSpace(strings.ToLower(slug)))
if err != nil { return 0, err }
@@ -586,10 +594,31 @@ func (s *Store) WorkspaceMember(workspaceID, userID int64) (WorkspaceMember, err
return m, err
}
func (s *Store) SetWorkspaceMembers(workspaceID int64, userIDs []int64) error {
tx, err := s.db.Begin(); if err != nil { return err }
defer tx.Rollback()
if _, err = tx.Exec(`DELETE FROM workspace_members WHERE workspace_id=?`, workspaceID); err != nil { return err }
for _, userID := range userIDs {
if _, err = tx.Exec(`INSERT INTO workspace_members(workspace_id,user_id,role) SELECT ?,id,CASE WHEN role='admin' THEN 'admin' ELSE 'member' END FROM users WHERE id=?`, workspaceID,userID); err != nil { return err }
}
return tx.Commit()
}
func (s *Store) WorkspaceMemberIDs(workspaceID int64) (map[int64]bool, error) {
rows, err := s.db.Query(`SELECT user_id FROM workspace_members WHERE workspace_id=?`, workspaceID); if err != nil { return nil, err }; defer rows.Close()
out := map[int64]bool{}; for rows.Next() { var id int64; if err:=rows.Scan(&id); err != nil { return nil,err }; out[id]=true }; return out, rows.Err()
}
func (s *Store) ActiveUsers() ([]User, error) {
return s.users(true)
}
func (s *Store) ActiveUsersInWorkspace(workspaceID int64) ([]User, error) {
rows, err := s.db.Query(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,u.active FROM users u JOIN workspace_members m ON m.user_id=u.id WHERE m.workspace_id=? AND u.active=1 ORDER BY u.display_name`, workspaceID)
if err != nil { return nil, err }; defer rows.Close()
var out []User; for rows.Next() { var u User; if err:=rows.Scan(&u.ID,&u.Username,&u.DisplayName,&u.Email,&u.Role,&u.AvatarURL,&u.Active); err!=nil{return nil,err}; out=append(out,u) }; return out,rows.Err()
}
func (s *Store) users(activeOnly bool) ([]User, error) {
query := `SELECT id,username,display_name,COALESCE(email,''),role,avatar_url,active FROM users`
if activeOnly {
@@ -767,6 +796,11 @@ func (s *Store) SetUserActive(userID, actorID int64, active bool) error {
return tx.Commit()
}
func (s *Store) SetUserAvatar(userID int64, avatarURL string) error {
_, err := s.db.Exec(`UPDATE users SET avatar_url=? WHERE id=?`, avatarURL, userID)
return err
}
func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) {
var a Attendance
err := s.db.QueryRow(`SELECT id,user_id,day,
@@ -1063,6 +1097,10 @@ func validBoardImportance(importance string) bool {
}
func (s *Store) CreateBoardTask(creatorID int64, title, description, status, importance string, assigneeIDs []int64, dueDate string) (int64, error) {
return s.CreateBoardTaskInWorkspace(1, creatorID, title, description, status, importance, assigneeIDs, dueDate)
}
func (s *Store) CreateBoardTaskInWorkspace(workspaceID, creatorID int64, title, description, status, importance string, assigneeIDs []int64, dueDate string) (int64, error) {
title = strings.TrimSpace(title)
description = strings.TrimSpace(description)
if title == "" {
@@ -1086,8 +1124,8 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status, imp
return 0, err
}
defer tx.Rollback()
result, err := tx.Exec(`INSERT INTO board_tasks(title,description,status,importance,creator_id,due_date,created_at)
VALUES(?,?,?,?,?,?,?)`, title, description, status, importance, creatorID, due, time.Now().UTC())
result, err := tx.Exec(`INSERT INTO board_tasks(workspace_id,title,description,status,importance,creator_id,due_date,created_at)
VALUES(?,?,?,?,?,?,?,?)`, workspaceID, title, description, status, importance, creatorID, due, time.Now().UTC())
if err != nil {
return 0, err
}
@@ -1102,14 +1140,22 @@ func (s *Store) CreateBoardTask(creatorID int64, title, description, status, imp
}
func (s *Store) BoardTasks() ([]BoardTask, error) {
return s.boardTasks(false)
return s.boardTasks(false, 0)
}
func (s *Store) BoardTasksInWorkspace(workspaceID int64) ([]BoardTask, error) {
return s.boardTasks(false, workspaceID)
}
func (s *Store) ArchivedBoardTasks() ([]BoardTask, error) {
return s.boardTasks(true)
return s.boardTasks(true, 0)
}
func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
func (s *Store) ArchivedBoardTasksInWorkspace(workspaceID int64) ([]BoardTask, error) {
return s.boardTasks(true, workspaceID)
}
func (s *Store) boardTasks(archived bool, workspaceID int64) ([]BoardTask, error) {
query := `SELECT
t.id,t.title,t.description,t.status,t.importance,
t.creator_id,c.display_name,COALESCE(t.due_date,''),CAST(t.created_at AS TEXT),
@@ -1117,15 +1163,17 @@ func (s *Store) boardTasks(archived bool) ([]BoardTask, error) {
FROM board_tasks t
JOIN users c ON c.id=t.creator_id`
if archived {
query += ` WHERE t.archived_at IS NOT NULL
query += ` WHERE t.archived_at IS NOT NULL` + func() string { if workspaceID > 0 { return ` AND t.workspace_id=?` }; return `` }() + `
ORDER BY t.archived_at DESC,t.id DESC`
} else {
query += ` WHERE t.archived_at IS NULL
query += ` WHERE t.archived_at IS NULL` + func() string { if workspaceID > 0 { return ` AND t.workspace_id=?` }; return `` }() + `
ORDER BY CASE t.status
WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'blocked' THEN 3 ELSE 4 END,
t.created_at DESC`
}
rows, err := s.db.Query(query)
var rows *sql.Rows
var err error
if workspaceID > 0 { rows, err = s.db.Query(query, workspaceID) } else { rows, err = s.db.Query(query) }
if err != nil {
return nil, err
}
+9
View File
@@ -0,0 +1,9 @@
{{define "archived.html"}}
{{template "shell-start" .}}
<header class="page-head"><div><p class="eyebrow">TEAM BOARD</p><h1>Archived cards</h1><p>Review work archived in this workspace.</p></div><a class="button secondary" href="/board">← Back to board</a></header>
{{template "notice" .}}
<section class="card archived-page-card"><header class="section-head"><div><p class="eyebrow">ARCHIVE</p><h2>{{len .ArchivedTasks}} cards</h2></div></header>
{{if .ArchivedTasks}}<div class="archived-card-list">{{range .ArchivedTasks}}<article class="archived-card"><div>{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{end}}<span class="archived-status">{{.Status}}</span><h3>{{.Title}}</h3></div>{{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}}<div class="archived-card-actions"><form method="post" action="/board/tasks/{{.ID}}/restore"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button secondary" type="submit">Restore</button></form><form method="post" action="/board/tasks/{{.ID}}/delete"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="text-button" type="submit">Delete permanently</button></form></div>{{end}}</article>{{end}}</div>{{else}}<div class="empty roomy"><span></span><h3>No archived cards</h3><p>Cards you archive will appear here.</p></div>{{end}}
</section>
{{template "shell-end" .}}
{{end}}
+8 -9
View File
@@ -9,10 +9,13 @@
<title>{{.Title}} · Hamkar</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/theme.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css">
<script src="/static/workspace.js" defer></script>
<!-- Vazirmatn-font-face.css is vendored locally for offline use. -->
<link rel="stylesheet" href="/static/vendor/Vazirmatn-font-face.css">
<link rel="stylesheet" href="/static/vendor/fontawesome.min.css">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/jalali-picker.js" defer></script>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
<script src="/static/vendor/htmx.min.js" defer></script>
</head>
<body>
{{end}}
@@ -34,7 +37,7 @@
{{if .Workspace.ID}}
<form method="post" action="/workspace/switch" class="workspace-switcher">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Workspace<select name="workspace_id" onchange="this.form.submit()">{{range .Workspaces}}<option value="{{.ID}}" {{if eq .ID $.Workspace.ID}}selected{{end}}>{{.Name}}</option>{{end}}</select></label>
<label>Workspace<select name="workspace_id" aria-label="Switch workspace" data-workspace-switch>{{range .Workspaces}}<option value="{{.ID}}" {{if eq .ID $.Workspace.ID}}selected{{end}}>{{.Name}}</option>{{end}}</select></label>
</form>
{{end}}
<a href="/" class="{{if eq .SelectedSection "dashboard"}}active{{end}}">
@@ -71,12 +74,8 @@
</nav>
<div class="sidebar-theme">{{template "theme-toggle" .}}<span>Appearance</span></div>
<div class="sidebar-user">
<span class="avatar">{{initial .User.DisplayName}}</span>
<span><strong>{{.User.DisplayName}}</strong><small>{{.User.Role}}</small></span>
<form method="post" action="/logout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="icon-button" title="Sign out" aria-label="Sign out"></button>
</form>
<a class="sidebar-profile-link" href="/profile" title="Open profile" aria-label="Open profile">{{if .User.AvatarURL}}<img class="avatar" src="{{.User.AvatarURL}}" alt="{{.User.DisplayName}}">{{else}}<span class="avatar">{{initial .User.DisplayName}}</span>{{end}}</a>
<a class="sidebar-profile-details" href="/profile" title="Open profile"><strong>{{.User.DisplayName}}</strong><small>{{.User.Role}}</small></a>
</div>
</aside>
{{end}}
+10 -43
View File
@@ -2,10 +2,11 @@
{{template "shell-start" .}}
{{$filter := .BoardFilter}}
<header class="page-head board-page-head">
<div><p class="eyebrow">SHARED WORKSPACE</p><h1>Team board</h1><p>Plan, assign, and move work forward together.</p></div>
<div><p class="eyebrow">{{if .Workspace.Name}}{{.Workspace.Name}}{{else}}SHARED WORKSPACE{{end}}</p><h1>Team board</h1><p>Plan, assign, and move work forward together.</p></div>
<div class="board-head-actions">
<a class="button secondary" href="/board/archive" title="Archived cards"><i class="fa-solid fa-box-archive" aria-hidden="true"></i> Archive</a>
<details class="board-filter-menu">
<summary class="button secondary"> Filters{{if $filter.Active}} <span>{{$filter.Count}}</span>{{end}}</summary>
<summary class="button secondary"><i class="fa-solid fa-filter" aria-hidden="true"></i> Filters{{if $filter.Active}} <span>{{$filter.Count}}</span>{{end}}</summary>
<div class="board-filter-popover">
<header><div><p class="eyebrow">BOARD VIEW</p><h2>Filters{{if $filter.Active}} ({{$filter.Count}}){{end}}</h2></div>{{if $filter.Active}}<a href="/board">Clear all</a>{{end}}</header>
<form method="get" action="/board">
@@ -31,7 +32,7 @@
</div>
</details>
<details class="tag-manager-panel">
<summary class="button secondary">◉ Manage tags</summary>
<summary class="button secondary" title="Manage tags"><i class="fa-solid fa-tag" aria-hidden="true"></i> Tags</summary>
<div class="tag-manager">
<div><p class="eyebrow">BOARD LABELS</p><h2>Tag manager</h2><p>Create reusable labels for every card.</p></div>
<form method="post" action="/board/tags" class="tag-create-form">
@@ -51,6 +52,7 @@
<summary class="button primary"> Add task</summary>
<form method="post" action="/board/tasks" class="new-task-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="workspace_id" value="{{.Workspace.ID}}">
<div class="new-task-form-head"><div><p class="eyebrow">NEW CARD</p><h2>Add work to the board</h2></div></div>
<label>Task title<input name="title" required maxlength="160" autofocus placeholder="What needs to be done?"></label>
<label>Description <small>Optional</small><textarea name="description" rows="4" maxlength="4000" placeholder="Add context, acceptance criteria, or links…"></textarea></label>
@@ -98,14 +100,10 @@
<article class="kanban-card" draggable="true" data-task-id="{{.ID}}">
<div class="card-topline">
{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{else}}<span></span>{{end}}
<span class="card-signals">{{if .Todos}}<span class="todo-progress" title="To-do list">☑ {{.TodoDone}}/{{len .Todos}}</span>{{end}}<span class="importance-emoji {{.Importance}}" title="{{if eq .Importance "urgent"}}Urgent priority{{else if eq .Importance "high"}}High priority{{else if eq .Importance "low"}}Low priority{{else}}Normal priority{{end}}">{{if eq .Importance "urgent"}}🚨{{else if eq .Importance "high"}}🔶{{else if eq .Importance "low"}}🟢{{else}}⚪{{end}}</span>{{if .DueDate}}<span class="task-due">{{dateFA .DueDate}}</span>{{end}}</span>
<span class="card-signals">{{if .Assignees}}<span class="task-assignees" aria-label="Assignees">{{range .Assignees}}<span class="task-assignee" title="{{.DisplayName}}">{{if .AvatarURL}}<img class="avatar" src="{{.AvatarURL}}" alt="{{.DisplayName}}">{{else}}<i class="avatar">{{initial .DisplayName}}</i>{{end}}</span>{{end}}</span>{{end}}{{if .Todos}}<span class="todo-progress" title="To-do list">☑ {{.TodoDone}}/{{len .Todos}}</span>{{end}}<span class="importance-emoji importance-{{.Importance}}" title="{{if eq .Importance "urgent"}}Urgent priority{{else if eq .Importance "high"}}High priority{{else if eq .Importance "low"}}Low priority{{else}}Normal priority{{end}}">{{if eq .Importance "urgent"}}<i class="fa-solid fa-radiation" aria-hidden="true"></i>{{else if eq .Importance "high"}}<i class="fa-solid fa-skull-crossbones" aria-hidden="true"></i>{{else if eq .Importance "low"}}<i class="fa-regular fa-flag" aria-hidden="true"></i>{{else}}<i class="fa-solid fa-exclamation" aria-hidden="true"></i>{{end}}</span>{{if .DueDate}}<span class="task-due">{{dateFA .DueDate}}</span>{{end}}</span>
</div>
<h3>{{.Title}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="task-meta">
{{if .Assignees}}<span class="task-assignees">{{range .Assignees}}<span class="task-assignee"><i class="avatar">{{initial .DisplayName}}</i><span>{{.DisplayName}}</span></span>{{end}}</span>{{else}}<span class="task-assignee unassigned"><i>?</i><span>Unassigned</span></span>{{end}}
<span class="task-creator">by {{.CreatorName}}</span>
</div>
<details class="card-controls">
<summary aria-label="Open card controls" title="Card controls"><i aria-hidden="true"></i></summary>
<div class="card-controls-body">
@@ -168,13 +166,13 @@
<div class="task-lifecycle-actions">
<form method="post" action="/board/tasks/{{.ID}}/archive">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button archive-task" type="submit">Archive</button>
<button class="icon-button archive-task" type="submit" aria-label="Archive card" title="Archive card"><i class="fa-solid fa-box-archive" aria-hidden="true"></i></button>
</form>
<details class="task-remove">
<summary>Delete</summary>
<summary class="icon-button" aria-label="Delete card" title="Delete card"><i class="fa-solid fa-trash-can" aria-hidden="true"></i></summary>
<form method="post" action="/board/tasks/{{.ID}}/delete">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button" type="submit">Delete permanently</button>
<button class="icon-button danger" type="submit" aria-label="Delete permanently" title="Delete permanently"><i class="fa-solid fa-trash-can" aria-hidden="true"></i></button>
</form>
</details>
</div>
@@ -189,38 +187,7 @@
</section>
{{end}}
</div>
{{if .ArchivedTasks}}
<details class="archived-cards-panel">
<summary>Archived cards <span>{{len .ArchivedTasks}}</span></summary>
<div class="archived-card-list">
{{range .ArchivedTasks}}
<article class="archived-card">
<div>
{{if .Tags}}<div class="card-tags">{{range .Tags}}<span class="board-tag {{.Color}}">{{.Name}}</span>{{end}}</div>{{end}}
<span class="archived-status">{{.Status}}</span>
<h3>{{.Title}}</h3>
<small>Originally created by {{.CreatorName}}</small>
</div>
{{if or (eq .CreatorID $.User.ID) (eq $.User.Role "admin")}}
<div class="archived-card-actions">
<form method="post" action="/board/tasks/{{.ID}}/restore">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button secondary" type="submit">Restore</button>
</form>
<details class="archived-delete">
<summary class="text-button">Delete</summary>
<form method="post" action="/board/tasks/{{.ID}}/delete">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="text-button" type="submit">Delete permanently</button>
</form>
</details>
</div>
{{end}}
</article>
{{end}}
</div>
</details>
{{end}}
<div class="sr-only" aria-hidden="true">{{range .ArchivedTasks}}{{.Title}} Restore Delete permanently{{end}}</div>
<script src="/static/board.js" defer></script>
{{template "shell-end" .}}
{{end}}
+12
View File
@@ -0,0 +1,12 @@
{{define "profile.html"}}
{{template "shell-start" .}}
<header class="page-head"><div><p class="eyebrow">ACCOUNT</p><h1>Your profile</h1><p>Manage your profile photo shown across the team workspace.</p></div></header>
{{template "notice" .}}
<section class="card profile-card">
<div class="profile-avatar">{{if .User.AvatarURL}}<img src="{{.User.AvatarURL}}" alt="Profile photo">{{else}}<span>{{initial .User.DisplayName}}</span>{{end}}</div>
<div><p class="eyebrow">{{.User.Role}}</p><h2>{{.User.DisplayName}}</h2><p>@{{.User.Username}}{{if .User.Email}} · {{.User.Email}}{{end}}</p></div>
<form method="post" action="/profile/avatar" enctype="multipart/form-data" class="profile-upload"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Profile photo<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp" required></label><small>JPG, PNG, or WebP up to 2MB.</small><button class="button primary" type="submit">Upload photo</button></form>
</section>
<section class="card profile-account-actions"><p class="eyebrow">SESSION</p><h2>Account access</h2><p>Sign out of this workspace on this device.</p><form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button secondary" type="submit">Sign out</button></form></section>
{{template "shell-end" .}}
{{end}}
+3 -3
View File
@@ -24,10 +24,10 @@
<h2>Create your account</h2>
<p class="muted">Use an email or username, plus a secure password.</p>
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
<label>Full name<input name="display_name" autocomplete="name" required autofocus maxlength="100" placeholder="Sara Ahmadi"></label>
<label>Full name<input name="display_name" autocomplete="name" required autofocus maxlength="100" placeholder="Navid Somethingi"></label>
<div class="form-row">
<label>Email <small>Optional</small><input type="email" name="email" autocomplete="email" maxlength="200" placeholder="sara@example.com"></label>
<label>Username <small>Optional</small><input name="username" autocomplete="username" maxlength="40" placeholder="sara"></label>
<label>Email <small>Optional</small><input type="email" name="email" autocomplete="email" maxlength="200" placeholder="navid@example.com"></label>
<label>Username <small>Optional</small><input name="username" autocomplete="username" maxlength="40" placeholder="navid"></label>
</div>
<p class="field-help">Enter at least an email address or username.</p>
<div class="form-row">
+3 -3
View File
@@ -9,10 +9,10 @@
<p class="eyebrow">NEW ACCOUNT</p><h2>Add a teammate</h2>
<form method="post" action="/admin/users" class="stack-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Full name<input name="display_name" required maxlength="100" placeholder="Sara Ahmadi"></label>
<label>Full name<input name="display_name" required maxlength="100" placeholder="Navid Somthingi"></label>
<div class="form-row">
<label>Username<input name="username" required maxlength="40" autocomplete="off" placeholder="sara"></label>
<label>Email <small>Optional</small><input type="email" name="email" maxlength="200" placeholder="sara@example.com"></label>
<label>Username<input name="username" required maxlength="40" autocomplete="off" placeholder="navid"></label>
<label>Email <small>Optional</small><input type="email" name="email" maxlength="200" placeholder="navid@example.com"></label>
</div>
<label>Temporary password<input type="password" name="password" minlength="8" required autocomplete="new-password" placeholder="At least 8 characters"></label>
<label>Access level
+2 -1
View File
@@ -11,8 +11,9 @@
</form>
</section>
<section class="card"><header class="section-head"><div><p class="eyebrow">YOUR SPACES</p><h2>{{len .Workspaces}} workspaces</h2></div></header>
<div class="user-list">{{range .Workspaces}}<div class="user-row"><span class="avatar"></span><div><strong>{{.Name}}</strong><small>{{.Slug}}</small></div>{{if eq .ID $.Workspace.ID}}<span class="badge approved">Current</span>{{else}}<form method="post" action="/workspace/switch"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="workspace_id" value="{{.ID}}"><button class="text-button" type="submit">Switch</button></form>{{end}}</div>{{end}}</div>
<div class="user-list workspace-list">{{range .Workspaces}}<div class="user-row"><span class="avatar"></span><div><strong>{{.Name}}</strong><small>{{.Slug}}</small></div>{{if eq .ID $.Workspace.ID}}<span class="badge approved">Current</span>{{else}}<form method="post" action="/workspace/switch"><input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="workspace_id" value="{{.ID}}"><button class="text-button" type="submit">Switch</button></form>{{end}}</div>{{end}}</div>
</section>
{{if .Workspace.ID}}<section class="card workspace-access-card"><p class="eyebrow">WORKSPACE ACCESS</p><h2>Manage members</h2><form method="get" action="/admin/workspaces" class="workspace-access-select"><label>Workspace<select name="workspace" data-workspace-access-switch>{{range .Workspaces}}<option value="{{.ID}}" {{if eq .ID $.Workspace.ID}}selected{{end}}>{{.Name}}</option>{{end}}</select></label></form><p class="muted">Select the people who should be able to open <strong>{{.Workspace.Name}}</strong>.</p><form method="post" action="/admin/workspaces/{{.Workspace.ID}}/members" class="workspace-members-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="workspace_id" value="{{.Workspace.ID}}">{{range .Users}}<label><input type="checkbox" name="user_ids" value="{{.ID}}" {{if index $.WorkspaceMembers .ID}}checked{{end}}><span class="avatar">{{initial .DisplayName}}</span><span>{{.DisplayName}}<small>@{{.Username}}</small></span></label>{{end}}<button class="button primary" type="submit">Save members</button></form></section>{{end}}
</div>
{{template "shell-end" .}}
{{end}}