feat: add project icon picker component and integrate with project management

- Implemented ProjectIconPicker component for selecting project icons.
- Created ProjectIcon component to display selected icons.
- Updated calendar page to remove unused PageHeading component.
- Modified growth page to enhance UI and improve text clarity.
- Refactored index page to include project icons and improve layout.
- Added mine page for user profile management with avatar selection.
- Introduced projects-sort page for managing project order with drag-and-drop functionality.
- Enhanced projects page to allow icon selection when adding or editing projects.
- Updated record page to streamline user experience and improve form actions.
- Adjusted practice service to support project icon management.
- Added tests for project saving functionality to ensure correct handling of icons.
- Updated global type definitions to support SVG and PNG imports.
This commit is contained in:
yuxuanhui
2026-09-29 18:40:25 +08:00
parent 9644a68d07
commit b0c62d461e
71 changed files with 1305 additions and 125 deletions
+18 -3
View File
@@ -36,6 +36,7 @@ func NewAppHandler(pool *pgxpool.Pool, verifier IdentityVerifier, now func() tim
mux.HandleFunc("POST /v1/session", a.login)
mux.HandleFunc("GET /v1/projects", a.auth(a.projects))
mux.HandleFunc("POST /v1/projects", a.auth(a.saveProject))
mux.HandleFunc("PUT /v1/projects/order", a.auth(a.reorderProjects))
mux.HandleFunc("PUT /v1/projects/{id}", a.auth(a.saveProject))
mux.HandleFunc("DELETE /v1/projects/{id}", a.auth(a.removeProject))
mux.HandleFunc("GET /v1/records", a.auth(a.records))
@@ -139,14 +140,28 @@ func (a *app) projects(w http.ResponseWriter, r *http.Request, user string) erro
}
return err
}
func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error {
func (a *app) reorderProjects(w http.ResponseWriter, r *http.Request, user string) error {
var input struct {
Name string `json:"name"`
ProjectIDs []string `json:"projectIds"`
}
if err := decode(w, r, &input); err != nil {
return err
}
p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name)
if err := a.store.ReorderProjects(r.Context(), user, input.ProjectIDs); err != nil {
return err
}
respond(w, 200, map[string]string{"action": "reordered"})
return nil
}
func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error {
var input struct {
Name string `json:"name"`
IconID *string `json:"iconId"`
}
if err := decode(w, r, &input); err != nil {
return err
}
p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name, input.IconID)
if err == nil {
respond(w, 200, p)
}
+161 -2
View File
@@ -160,6 +160,158 @@ func (h *harness) project(token, name string) string {
return h.request("POST", "/v1/projects", token, string(body), 200)["id"].(string)
}
func TestProjectOrderPersistsAndRejectsStaleOrForeignLists(t *testing.T) {
h := setup(t)
alice, bob := h.login("alice"), h.login("bob")
projectIDs := func(token string) []string {
h.t.Helper()
projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any)
ids := make([]string, len(projects))
for i, raw := range projects {
ids[i] = raw.(map[string]any)["id"].(string)
}
return ids
}
setOrder := func(ids []string, status int) {
h.t.Helper()
body, _ := json.Marshal(map[string][]string{"projectIds": ids})
h.request("PUT", "/v1/projects/order", alice, string(body), status)
}
initial := projectIDs(alice)
want := append([]string(nil), initial...)
for i, j := 0, len(want)-1; i < j; i, j = i+1, j-1 {
want[i], want[j] = want[j], want[i]
}
setOrder(want, 200)
h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now })
if got := projectIDs(h.login("alice")); strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("order did not persist: %v", got)
}
newID := h.project(alice, "新项目")
want = append(want, newID)
if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("new project did not append: %v", got)
}
duplicate := append([]string(nil), want...)
duplicate[0] = duplicate[1]
setOrder(duplicate, 400)
setOrder(want[:len(want)-1], 400)
foreign := append([]string(nil), want...)
foreign[0] = projectIDs(bob)[0]
setOrder(foreign, 400)
if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("invalid order changed projects: %v", got)
}
h.request("POST", "/v1/records", alice, recordBody(want[0], "2026-09-28", 15, "", "sort-archive-0001"), 200)
h.request("DELETE", "/v1/projects/"+want[0], alice, "", 200)
setOrder(want, 400)
active := projectIDs(alice)
if strings.Join(active, ",") != strings.Join(want[1:], ",") {
t.Fatalf("archived project stayed active: %v", active)
}
for i, j := 0, len(active)-1; i < j; i, j = i+1, j-1 {
active[i], active[j] = active[j], active[i]
}
setOrder(active, 200)
if got := projectIDs(alice); strings.Join(got, ",") != strings.Join(active, ",") {
t.Fatalf("reorder after archive failed: %v", got)
}
}
func TestProjectIconsPersistClearAndRespectOwnership(t *testing.T) {
h := setup(t)
alice, bob := h.login("alice"), h.login("bob")
presets := h.request("GET", "/v1/projects", alice, "", 200)["projects"].([]any)
want := []string{"plie", "tendu", "flexibility", "foot-hip", "core-glutes", "ball-core", "swan-arms", "breathing"}
for i, raw := range presets {
if raw.(map[string]any)["iconId"] != want[i] {
t.Fatalf("preset %d: %v", i, raw)
}
}
p := h.request("POST", "/v1/projects", alice, `{"name":"自选练习","iconId":"ball-core"}`, 200)
id := p["id"].(string)
if p["iconId"] != "ball-core" {
t.Fatal(p)
}
// Older clients rename without sending iconId. This must not clear artwork.
p = h.request("PUT", "/v1/projects/"+id, alice, `{"name":"晚间练习"}`, 200)
if p["iconId"] != "ball-core" {
t.Fatal("rename cleared the icon")
}
for _, icon := range []string{"plie", "tendu", "releve", "port-de-bras", "arabesque", "attitude", "pirouette", "grand-jete", "foot-hip", "flexibility", "ball-core", "swan-arms", "breathing", "core-glutes", "soft-shoes", "pointe-shoes", "barre", "piano"} {
p = h.request("PUT", "/v1/projects/"+id, alice, `{"name":"晚间练习","iconId":"`+icon+`"}`, 200)
if p["iconId"] != icon {
t.Fatalf("icon did not round trip: %s", icon)
}
}
h.request("PUT", "/v1/projects/"+id, bob, `{"name":"偷改","iconId":"breathing"}`, 404)
h.request("PUT", "/v1/projects/"+id, alice, `{"name":"不应保存","iconId":"unknown"}`, 400)
h.request("POST", "/v1/projects", alice, `{"name":"拒绝外部图片","iconId":"https://example.com/a.svg"}`, 400)
h.request("POST", "/v1/projects", alice, `{"name":"拒绝对象","iconId":{}}`, 400)
// A fresh handler and login must read the last confirmed value from the database.
h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now })
find := func(token string) map[string]any {
for _, raw := range h.request("GET", "/v1/projects?includeArchived=true", token, "", 200)["projects"].([]any) {
project := raw.(map[string]any)
if project["id"] == id {
return project
}
}
t.Fatal("saved project missing")
return nil
}
p = find(h.login("alice"))
if p["name"] != "晚间练习" || p["iconId"] != "piano" {
t.Fatal("rejected write changed persisted project", p)
}
h.request("POST", "/v1/records", alice, recordBody(id, "2026-09-28", 15, "", "icon-archive-0001"), 200)
h.request("DELETE", "/v1/projects/"+id, alice, "", 200)
if p = find(alice); p["archived"] != true || p["iconId"] != "piano" {
t.Fatal("archiving lost artwork", p)
}
// Clearing a preset stays cleared after login and a service migration restart.
presetID := presets[0].(map[string]any)["id"].(string)
p = h.request("PUT", "/v1/projects/"+presetID, alice, `{"name":"零基础","iconId":""}`, 200)
if p["iconId"] != "" {
t.Fatal("explicit clear failed", p)
}
if err := database.Migrate(context.Background(), h.pool); err != nil {
t.Fatal(err)
}
projects := h.request("GET", "/v1/projects", h.login("alice"), "", 200)["projects"].([]any)
if projects[0].(map[string]any)["iconId"] != "" {
t.Fatal("restart restored a cleared icon")
}
}
func TestProjectIconMigrationPreservesExistingProjectsAndRecords(t *testing.T) {
h := setup(t)
token := h.login("alice")
id := h.project(token, "自定义旧项目")
h.request("POST", "/v1/records", token, recordBody(id, "2026-09-28", 30, "已有笔记", "icon-migrate-001"), 200)
// Recreate the version-1 shape only inside this test's isolated schema.
ctx := context.Background()
if _, err := h.pool.Exec(ctx, "ALTER TABLE projects DROP COLUMN icon_id; DELETE FROM schema_migrations WHERE version=2"); err != nil {
t.Fatal(err)
}
if err := database.Migrate(ctx, h.pool); err != nil {
t.Fatal(err)
}
projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any)
if len(projects) != 9 || projects[0].(map[string]any)["iconId"] != "plie" {
t.Fatal("existing presets were not migrated", projects)
}
custom := projects[8].(map[string]any)
if custom["id"] != id || custom["iconId"] != "" || custom["name"] != "自定义旧项目" {
t.Fatal("migration changed custom project", custom)
}
records := h.request("GET", "/v1/records", token, "", 200)["records"].([]any)
if len(records) != 1 || records[0].(map[string]any)["note"] != "已有笔记" {
t.Fatal("migration changed existing history", records)
}
h.summary(token, 30, 1)
}
func recordBody(project, date string, minutes int, note, key string) string {
body, _ := json.Marshal(map[string]any{"projectId": project, "date": date, "minutes": minutes, "note": note, "requestId": key})
return string(body)
@@ -187,7 +339,9 @@ func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) {
token := h.login("alice")
class := h.project(token, "晚课")
extra := h.project(token, "课后练习")
h.summary(token, 0, 0)
if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" {
t.Fatalf("empty history has first date %v", got)
}
one := h.request("POST", "/v1/records", token, recordBody(class, "2026-09-28", 90, "老师反馈:放松肩颈", "lesson-000000001"), 200)
two := h.request("POST", "/v1/records", token, recordBody(extra, "2026-09-28", 15, "", "lesson-000000002"), 200)
h.summary(token, 105, 1)
@@ -198,6 +352,9 @@ func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) {
id1, id2 := one["id"].(string), two["id"].(string)
h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-27", 90, "改到周日", ""), 200)
review := h.summary(token, 105, 2)
if got := review["allTime"].(map[string]any)["firstDate"]; got != "2026-09-27" {
t.Fatalf("earliest practice date after correction: %v", got)
}
if review["period"].(map[string]any)["totalMinutes"] != float64(15) {
t.Fatal("Sunday must belong to the previous week")
}
@@ -207,7 +364,9 @@ func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) {
h.summary(token, 60, 1)
h.request("DELETE", "/v1/records/"+id1, token, "", 200)
h.request("DELETE", "/v1/records/"+id1, token, "", 200)
h.summary(token, 0, 0)
if got := h.summary(token, 0, 0)["allTime"].(map[string]any)["firstDate"]; got != "" {
t.Fatalf("deleted history retained first date %v", got)
}
}
func TestArchiveRenameAndOwnershipAcrossAccounts(t *testing.T) {