diff --git a/api.go b/api.go index 96e13f2..a51a254 100644 --- a/api.go +++ b/api.go @@ -13,6 +13,7 @@ func declareAPIRoutes(router *gin.Engine) { apiRoutes.Use(authMiddleware()) declareAPIAuthRoutes(apiRoutes) + declareAPICategoriesRoutes(apiRoutes) declareAPISurveysRoutes(apiRoutes) declareAPIWorksRoutes(apiRoutes) declareAPIKeysRoutes(apiRoutes) @@ -50,6 +51,7 @@ func declareAPIRoutes(router *gin.Engine) { declareAPIAdminAuthRoutes(apiAdminRoutes) declareAPIAdminAsksRoutes(apiAdminRoutes) + declareAPIAdminCategoriesRoutes(apiRoutes) declareAPIAuthGradesRoutes(apiAdminRoutes) declareAPIAdminHelpRoutes(apiAdminRoutes) declareAPIAdminQuestionsRoutes(apiAdminRoutes) diff --git a/categories.go b/categories.go new file mode 100644 index 0000000..2888489 --- /dev/null +++ b/categories.go @@ -0,0 +1,168 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +func declareAPICategoriesRoutes(router *gin.RouterGroup) { + categoriesRoutes := router.Group("/categories/:cid") + categoriesRoutes.Use(categoryHandler) + + categoriesRoutes.GET("", func(c *gin.Context) { + c.JSON(http.StatusOK, c.MustGet("category").(*Category)) + }) +} + +func declareAPIAdminCategoriesRoutes(router *gin.RouterGroup) { + router.GET("categories", func(c *gin.Context) { + categories, err := getCategories() + if err != nil { + log.Println("Unable to getCategories:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to retrieve categories. Please try again later."}) + return + } + + c.JSON(http.StatusOK, categories) + }) + router.POST("categories", func(c *gin.Context) { + var new Category + if err := c.ShouldBindJSON(&new); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + + if new.Promo == 0 { + new.Promo = currentPromo + } + + if cat, err := NewCategory(new.Label, new.Promo, new.Expand); err != nil { + log.Println("Unable to NewCategory:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during category creation: %s", err.Error())}) + return + } else { + c.JSON(http.StatusOK, cat) + } + }) + + categoriesRoutes := router.Group("/categories/:cid") + categoriesRoutes.Use(categoryHandler) + + categoriesRoutes.PUT("", func(c *gin.Context) { + current := c.MustGet("category").(*Category) + + var new Category + if err := c.ShouldBindJSON(&new); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": err.Error()}) + return + } + new.Id = current.Id + + if _, err := new.Update(); err != nil { + log.Println("Unable to Update category:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to update the given category. Please try again later."}) + return + } else { + c.JSON(http.StatusOK, new) + } + }) + categoriesRoutes.DELETE("", func(c *gin.Context) { + current := c.MustGet("category").(*Category) + + if _, err := current.Delete(); err != nil { + log.Println("Unable to Delete category:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": "Unable to delete the given category. Please try again later."}) + return + } else { + c.JSON(http.StatusOK, nil) + } + }) +} + +func categoryHandler(c *gin.Context) { + var category *Category + + cid, err := strconv.Atoi(string(c.Param("cid"))) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"errmsg": "Bad category identifier."}) + return + } else { + category, err = getCategory(cid) + if err != nil { + c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": "Category not found."}) + return + } + } + + c.Set("category", category) + + c.Next() +} + +type Category struct { + Id int64 `json:"id"` + Label string `json:"label"` + Promo uint `json:"promo"` + Expand bool `json:"expand,omitempty"` +} + +func getCategories() (categories []Category, err error) { + if rows, errr := DBQuery("SELECT id_category, label, promo, expand FROM categories ORDER BY promo DESC, expand DESC, id_category DESC"); errr != nil { + return nil, errr + } else { + defer rows.Close() + + for rows.Next() { + var c Category + if err = rows.Scan(&c.Id, &c.Label, &c.Promo, &c.Expand); err != nil { + return + } + categories = append(categories, c) + } + if err = rows.Err(); err != nil { + return + } + + return + } +} + +func getCategory(id int) (c *Category, err error) { + c = new(Category) + err = DBQueryRow("SELECT id_category, label, promo, expand FROM categories WHERE id_category=?", id).Scan(&c.Id, &c.Label, &c.Promo, &c.Expand) + return +} + +func NewCategory(label string, promo uint, expand bool) (*Category, error) { + if res, err := DBExec("INSERT INTO categories (label, promo, expand) VALUES (?, ?, ?)", label, promo, expand); err != nil { + return nil, err + } else if cid, err := res.LastInsertId(); err != nil { + return nil, err + } else { + return &Category{cid, label, promo, expand}, nil + } +} + +func (c *Category) Update() (int64, error) { + if res, err := DBExec("UPDATE categories SET label = ?, promo = ?, expand = ? WHERE id_category = ?", c.Label, c.Promo, c.Expand, c.Id); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} + +func (c *Category) Delete() (int64, error) { + if res, err := DBExec("DELETE FROM categories WHERE id_category = ?", c.Id); err != nil { + return 0, err + } else if nb, err := res.RowsAffected(); err != nil { + return 0, err + } else { + return nb, err + } +} diff --git a/db.go b/db.go index b0d94f7..b4f6799 100644 --- a/db.go +++ b/db.go @@ -93,6 +93,7 @@ CREATE TABLE IF NOT EXISTS user_keys( if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS surveys( id_survey INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + id_category INTEGER NOT NULL, title VARCHAR(255), promo MEDIUMINT NOT NULL, grp VARCHAR(255) NOT NULL, @@ -100,7 +101,8 @@ CREATE TABLE IF NOT EXISTS surveys( direct INTEGER DEFAULT NULL, corrected BOOLEAN NOT NULL DEFAULT FALSE, start_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(id_category) REFERENCES categories(id_category) ) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin; `); err != nil { return err @@ -200,6 +202,7 @@ CREATE TABLE IF NOT EXISTS user_need_help( if _, err := db.Exec(` CREATE TABLE IF NOT EXISTS works( id_work INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + id_category INTEGER NOT NULL, title VARCHAR(255), promo MEDIUMINT NOT NULL, grp VARCHAR(255) NOT NULL, @@ -209,7 +212,8 @@ CREATE TABLE IF NOT EXISTS works( submission_URL VARCHAR(255) NULL, corrected BOOLEAN NOT NULL DEFAULT FALSE, start_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + end_availability TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(id_category) REFERENCES categories(id_category) ) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin; `); err != nil { return err @@ -241,6 +245,16 @@ CREATE TABLE IF NOT EXISTS user_work_repositories( FOREIGN KEY(id_work) REFERENCES works(id_work), UNIQUE one_repo_per_work (id_user, id_work) ) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin; +`); err != nil { + return err + } + if _, err := db.Exec(` +CREATE TABLE IF NOT EXISTS categories( + id_category INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT, + label VARCHAR(255) NOT NULL, + promo MEDIUMINT NOT NULL, + expand BOOLEAN NOT NULL DEFAULT FALSE +) DEFAULT CHARACTER SET = utf8 COLLATE = utf8_bin; `); err != nil { return err } @@ -250,7 +264,7 @@ CREATE VIEW IF NOT EXISTS student_scores AS SELECT T.id_user, T.id_survey, Q.id_ return err } if _, err := db.Exec(` -CREATE VIEW IF NOT EXISTS all_works AS SELECT "work" AS kind, id_work AS id, title, promo, grp, shown, NULL AS direct, submission_url, corrected, start_availability, end_availability FROM works UNION SELECT "survey" AS kind, id_survey AS id, title, promo, grp, shown, direct, NULL AS submission_url, corrected, start_availability, end_availability FROM surveys; +CREATE VIEW IF NOT EXISTS all_works AS SELECT "work" AS kind, id_work AS id, id_category, title, promo, grp, shown, NULL AS direct, submission_url, corrected, start_availability, end_availability FROM works UNION SELECT "survey" AS kind, id_survey AS id, id_category, title, promo, grp, shown, direct, NULL AS submission_url, corrected, start_availability, end_availability FROM surveys; `); err != nil { return err } diff --git a/static.go b/static.go index 07c69c4..8b370d9 100644 --- a/static.go +++ b/static.go @@ -53,6 +53,8 @@ func declareStaticRoutes(router *gin.Engine) { router.GET("/_app/*_", serveOrReverse("")) router.GET("/auth/", serveOrReverse("/")) router.GET("/bug-bounty", serveOrReverse("/")) + router.GET("/categories", serveOrReverse("/")) + router.GET("/categories/*_", serveOrReverse("/")) router.GET("/donnees-personnelles", serveOrReverse("/")) router.GET("/grades", serveOrReverse("/")) router.GET("/help", serveOrReverse("/")) diff --git a/surveys.go b/surveys.go index 2afe45c..950e588 100644 --- a/surveys.go +++ b/surveys.go @@ -146,7 +146,7 @@ func declareAPIAdminSurveysRoutes(router *gin.RouterGroup) { new.Promo = currentPromo } - if s, err := NewSurvey(new.Title, new.Promo, new.Group, new.Shown, new.Direct, new.StartAvailability, new.EndAvailability); err != nil { + if s, err := NewSurvey(new.IdCategory, new.Title, new.Promo, new.Group, new.Shown, new.Direct, new.StartAvailability, new.EndAvailability); err != nil { log.Println("Unable to NewSurvey:", err) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"errmsg": fmt.Sprintf("An error occurs during survey creation: %s", err.Error())}) return @@ -240,6 +240,7 @@ func surveyUserAccessHandler(c *gin.Context) { type Survey struct { Id int64 `json:"id"` + IdCategory int64 `json:"id_category"` Title string `json:"title"` Promo uint `json:"promo"` Group string `json:"group"` @@ -251,14 +252,14 @@ type Survey struct { } func getSurveys(cnd string, param ...interface{}) (surveys []*Survey, err error) { - if rows, errr := DBQuery("SELECT id_survey, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys "+cnd, param...); errr != nil { + if rows, errr := DBQuery("SELECT id_survey, id_category, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys "+cnd, param...); errr != nil { return nil, errr } else { defer rows.Close() for rows.Next() { var s Survey - if err = rows.Scan(&s.Id, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability); err != nil { + if err = rows.Scan(&s.Id, &s.IdCategory, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability); err != nil { return } surveys = append(surveys, &s) @@ -280,7 +281,7 @@ func getSurvey(id int) (s *Survey, err error) { } s = new(Survey) - err = DBQueryRow("SELECT id_survey, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys WHERE id_survey=?", id).Scan(&s.Id, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability) + err = DBQueryRow("SELECT id_survey, id_category, title, promo, grp, shown, direct, corrected, start_availability, end_availability FROM surveys WHERE id_survey=?", id).Scan(&s.Id, &s.IdCategory, &s.Title, &s.Promo, &s.Group, &s.Shown, &s.Direct, &s.Corrected, &s.StartAvailability, &s.EndAvailability) _surveys_cache_mutex.Lock() _surveys_cache[int64(id)] = s @@ -288,13 +289,13 @@ func getSurvey(id int) (s *Survey, err error) { return } -func NewSurvey(title string, promo uint, group string, shown bool, direct *int64, startAvailability time.Time, endAvailability time.Time) (*Survey, error) { - if res, err := DBExec("INSERT INTO surveys (title, promo, grp, shown, direct, start_availability, end_availability) VALUES (?, ?, ?, ?, ?, ?, ?)", title, promo, group, shown, direct, startAvailability, endAvailability); err != nil { +func NewSurvey(id_category int64, title string, promo uint, group string, shown bool, direct *int64, startAvailability time.Time, endAvailability time.Time) (*Survey, error) { + if res, err := DBExec("INSERT INTO surveys (id_category, title, promo, grp, shown, direct, start_availability, end_availability) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id_category, title, promo, group, shown, direct, startAvailability, endAvailability); err != nil { return nil, err } else if sid, err := res.LastInsertId(); err != nil { return nil, err } else { - return &Survey{sid, title, promo, group, shown, direct, false, startAvailability, endAvailability}, nil + return &Survey{sid, id_category, title, promo, group, shown, direct, false, startAvailability, endAvailability}, nil } } @@ -352,7 +353,7 @@ func (s Survey) GetScores() (scores map[int64]*float64, err error) { } func (s *Survey) Update() (*Survey, error) { - if _, err := DBExec("UPDATE surveys SET title = ?, promo = ?, grp = ?, shown = ?, direct = ?, corrected = ?, start_availability = ?, end_availability = ? WHERE id_survey = ?", s.Title, s.Promo, s.Group, s.Shown, s.Direct, s.Corrected, s.StartAvailability, s.EndAvailability, s.Id); err != nil { + if _, err := DBExec("UPDATE surveys SET id_category = ?, title = ?, promo = ?, grp = ?, shown = ?, direct = ?, corrected = ?, start_availability = ?, end_availability = ? WHERE id_survey = ?", s.IdCategory, s.Title, s.Promo, s.Group, s.Shown, s.Direct, s.Corrected, s.StartAvailability, s.EndAvailability, s.Id); err != nil { return nil, err } else { _surveys_cache_mutex.Lock() diff --git a/ui/src/lib/categories.js b/ui/src/lib/categories.js new file mode 100644 index 0000000..633fa03 --- /dev/null +++ b/ui/src/lib/categories.js @@ -0,0 +1,60 @@ +export async function getCategories() { + let url = '/api/categories'; + const res = await fetch(url, {headers: {'Accept': 'application/json'}}) + if (res.status == 200) { + return (await res.json()).map((r) => new Category(r)); + } else { + throw new Error((await res.json()).errmsg); + } +} + +export class Category { + constructor(res) { + if (res) { + this.update(res); + } + } + + update({ id, label, promo, expand }) { + this.id = id; + this.label = label; + this.promo = promo; + this.expand = expand; + } + + async save() { + const res = await fetch(this.id?`api/categories/${this.id}`:'api/categories', { + method: this.id?'PUT':'POST', + headers: {'Accept': 'application/json'}, + body: JSON.stringify(this), + }); + if (res.status == 200) { + const data = await res.json() + this.update(data); + return data; + } else { + throw new Error((await res.json()).errmsg); + } + } + + async delete() { + const res = await fetch(`api/categories/${this.id}`, { + method: 'DELETE', + headers: {'Accept': 'application/json'}, + }); + if (res.status == 200) { + return true; + } else { + throw new Error((await res.json()).errmsg); + } + } +} + +export async function getCategory(cid) { + const res = await fetch(`api/categories/${cid}`, {headers: {'Accept': 'application/json'}}) + if (res.status == 200) { + return new Category(await res.json()); + } else { + throw new Error((await res.json()).errmsg); + } +} diff --git a/ui/src/lib/components/CategoryAdmin.svelte b/ui/src/lib/components/CategoryAdmin.svelte new file mode 100644 index 0000000..0891983 --- /dev/null +++ b/ui/src/lib/components/CategoryAdmin.svelte @@ -0,0 +1,92 @@ + + +
+ + {#if category.id} +
+
+ +
+
+ +
+
+ {/if} + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ +
+
+ + {#if category.id} + + {/if} +
+
+ +
diff --git a/ui/src/lib/components/SurveyAdmin.svelte b/ui/src/lib/components/SurveyAdmin.svelte index c17fcfa..784d033 100644 --- a/ui/src/lib/components/SurveyAdmin.svelte +++ b/ui/src/lib/components/SurveyAdmin.svelte @@ -2,6 +2,7 @@ import { createEventDispatcher } from 'svelte'; import { goto } from '$app/navigation'; + import { getCategories } from '$lib/categories'; import { getQuestions } from '$lib/questions'; import DateTimeInput from './DateTimeInput.svelte'; import { ToastsStore } from '$lib/stores/toasts'; @@ -67,6 +68,21 @@ +
+
+ +
+
+ {#await getCategories() then categories} + + {/await} +
+
+
diff --git a/ui/src/lib/components/SurveyList.svelte b/ui/src/lib/components/SurveyList.svelte index 1176d9f..166e1a6 100644 --- a/ui/src/lib/components/SurveyList.svelte +++ b/ui/src/lib/components/SurveyList.svelte @@ -5,6 +5,7 @@ import DateFormat from '$lib/components/DateFormat.svelte'; import SurveyBadge from '$lib/components/SurveyBadge.svelte'; import SubmissionStatus from '$lib/components/SubmissionStatus.svelte'; + import { getCategories } from '$lib/categories'; import { getSurveys } from '$lib/surveys'; import { getScore } from '$lib/users'; @@ -21,6 +22,13 @@ } }); + let categories = {}; + getCategories().then((cs) => { + for (const c of cs) { + categories[c.id] = c; + } + }); + function gotoSurvey(survey) { if (survey.kind === "w") { goto(`works/${survey.id}`); @@ -60,12 +68,30 @@ {#each surveys as survey, sid (survey.kind + survey.id)} {#if (survey.shown || survey.direct == null || ($user && $user.is_admin)) && (!$user || (!$user.was_admin || $user.promo == survey.promo) || $user.is_admin)} {#if $user && $user.is_admin && (sid == 0 || surveys[sid-1].promo != survey.promo)} - + {survey.promo} {/if} + {#if $user && (sid == 0 || surveys[sid-1].id_category != survey.id_category) && categories[survey.id_category]} + + categories[survey.id_category].expand = !categories[survey.id_category].expand}> + {#if categories[survey.id_category].expand} + + {:else} + + {/if} + {categories[survey.id_category].label} + {#if $user && $user.is_admin} + + categories[survey.id_category].expand = !categories[survey.id_category].expand}> + + {/if} + + + {/if} + {#if categories[survey.id_category] && categories[survey.id_category].expand} gotoSurvey(survey)}> {#if !survey.shown}{/if} @@ -127,6 +153,7 @@ {/if} {/if} + {/if} {/if} {/each} diff --git a/ui/src/lib/components/WorkAdmin.svelte b/ui/src/lib/components/WorkAdmin.svelte index 7e4eeae..ca77bd3 100644 --- a/ui/src/lib/components/WorkAdmin.svelte +++ b/ui/src/lib/components/WorkAdmin.svelte @@ -2,6 +2,7 @@ import { createEventDispatcher } from 'svelte'; import { goto } from '$app/navigation'; + import { getCategories } from '$lib/categories'; import DateTimeInput from './DateTimeInput.svelte'; import { ToastsStore } from '$lib/stores/toasts'; @@ -62,6 +63,21 @@
+
+
+ +
+
+ {#await getCategories() then categories} + + {/await} +
+
+
diff --git a/ui/src/lib/surveys.js b/ui/src/lib/surveys.js index 9e1e69f..4009bfa 100644 --- a/ui/src/lib/surveys.js +++ b/ui/src/lib/surveys.js @@ -11,8 +11,9 @@ export class Survey { } } - update({ id, title, promo, group, shown, direct, corrected, start_availability, end_availability }) { + update({ id, id_category, title, promo, group, shown, direct, corrected, start_availability, end_availability }) { this.id = id; + this.id_category = id_category; this.title = title; this.promo = promo; this.group = group; diff --git a/ui/src/lib/works.js b/ui/src/lib/works.js index ba642e9..c926927 100644 --- a/ui/src/lib/works.js +++ b/ui/src/lib/works.js @@ -6,8 +6,9 @@ export class Work { } } - update({ id, title, promo, group, shown, tag, description, descr_raw, submission_url, corrected, start_availability, end_availability }) { + update({ id, id_category, title, promo, group, shown, tag, description, descr_raw, submission_url, corrected, start_availability, end_availability }) { this.id = id; + this.id_category = id_category; this.title = title; this.promo = promo; this.group = group; diff --git a/ui/src/routes/categories/[cid]/index.svelte b/ui/src/routes/categories/[cid]/index.svelte new file mode 100644 index 0000000..8962b1b --- /dev/null +++ b/ui/src/routes/categories/[cid]/index.svelte @@ -0,0 +1,39 @@ + + + + +{#await categoryP then category} +
+

+ < + {category.label} +

+
+ + {#if $user && $user.is_admin} + { goto(`categories/`)}} /> + {/if} +{/await} diff --git a/ui/src/routes/categories/index.svelte b/ui/src/routes/categories/index.svelte new file mode 100644 index 0000000..afde413 --- /dev/null +++ b/ui/src/routes/categories/index.svelte @@ -0,0 +1,70 @@ + + +{#if $user && $user.is_admin} + + + + {#await getPromos() then promos} +
+ +
+ {/await} +{/if} +

+ Catégories // cours +

+ +{#await getCategories()} +
+
+ Chargement des catégories … +
+{:then categories} + + + + + + + + + + + {#each categories.filter((c) => (filterPromo === "" || filterPromo === c.promo)) as c (c.id)} + + + + + + + {/each} + +
IDNomPromoÉtendre
{c.id} + {c.label} + {c.promo} + + {c.expand?"Oui":"Non"} + +
+{/await} diff --git a/ui/src/routes/categories/new.svelte b/ui/src/routes/categories/new.svelte new file mode 100644 index 0000000..a35231f --- /dev/null +++ b/ui/src/routes/categories/new.svelte @@ -0,0 +1,20 @@ + + +
+

+ < + Nouvelle catégorie +

+
+ +{#if $user && $user.is_admin} + { goto(`categories/${e.detail.id}`)}} /> +{/if} diff --git a/ui/src/routes/works/index.svelte b/ui/src/routes/works/index.svelte index 1c37109..93651d5 100644 --- a/ui/src/routes/works/index.svelte +++ b/ui/src/routes/works/index.svelte @@ -5,17 +5,28 @@ import DateFormat from '$lib/components/DateFormat.svelte'; import SurveyBadge from '$lib/components/SurveyBadge.svelte'; import SubmissionStatus from '$lib/components/SubmissionStatus.svelte'; + import { getCategories } from '$lib/categories'; import { getWorks } from '$lib/works'; import { getPromos } from '$lib/users'; import { getScore } from '$lib/users'; let filterPromo = ""; + + let categories = {}; + getCategories().then((cs) => { + for (const c of cs) { + categories[c.id] = c; + } + }); {#if $user && $user.is_admin} + + + {#await getPromos() then promos}