diff options
| -rw-r--r-- | internal/mailer/mailer.go | 76 | ||||
| -rw-r--r-- | internal/router/api-v1-general-page-body.go | 83 | ||||
| -rw-r--r-- | internal/router/api-v1-subs.go | 67 | ||||
| -rw-r--r-- | locale/localization.en.yaml | 10 | ||||
| -rw-r--r-- | locale/localization.go | 10 | ||||
| -rw-r--r-- | locale/localization.ru.yaml | 10 | ||||
| -rw-r--r-- | main.go | 1 | ||||
| -rw-r--r-- | static/input.css | 4 | ||||
| -rw-r--r-- | views/pages/user-page.html | 117 |
9 files changed, 344 insertions, 34 deletions
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index 450b818..cd5eda8 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -43,6 +43,14 @@ type Mailer struct { l map[string]*locale.LocaleConfig } +type SubscriptionType int + +const ( + All SubscriptionType = iota + None + Specific +) + func NewMailer(db *sql.DB, clientHost string, mailHost string, publicName string, mailAddress string, username string, password string, salt []byte, localization map[string]*locale.LocaleConfig) (*Mailer, error) { verificationCodes, err := ristretto.NewCache(&ristretto.Config[uint64, string]{ NumCounters: 10000, @@ -279,3 +287,71 @@ func (m *Mailer) Verify(verificationCodeEncoded string, lang string) error { delete(m.lostMailMap, verificationSegments[0]) return nil } + +func (m *Mailer) GetSubscriptions(userId string) (subscriptionType SubscriptionType, tags []string, err error) { + tx, err := m.db.Begin() + if err != nil { + return None, nil, fmt.Errorf("failed to initialize transaction with db: %s", err) + } + + hash := m.GetHash(userId) + + var rows *sql.Rows + if rows, err = tx.Query(`SELECT tags FROM subscription_user_to_tags_table WHERE user_id=? LIMIT 1;`, hash); err != nil { + tx.Rollback() + return None, nil, fmt.Errorf("failed to query user-to-tags table in db for the user: %s", err) + } + defer tx.Commit() + defer rows.Close() + + if !rows.Next() { + return None, nil, nil + } + + tagsString := "" + if err = rows.Scan(&tagsString); err != nil { + return None, nil, fmt.Errorf("failed to scan the result from user-to-tags query: %s", err) + } + + switch tagsString { + case "": + return None, nil, nil + case "_all": + return All, nil, nil + default: + return Specific, strings.Split(tagsString, ","), nil + } +} + +func (m *Mailer) Subscribe(userId string, subscriptionType SubscriptionType, tags ...string) error { + tx, err := m.db.Begin() + if err != nil { + return fmt.Errorf("failed to initialize transaction with db: %s", err) + } + + tagsOutput := "" + switch subscriptionType { + case All: + tagsOutput = "_all" + case None: + tagsOutput = "" + case Specific: + tagsOutput = strings.Join(tags, ",") + } + + hash := m.GetHash(userId) + + if _, err = tx.Exec(`INSERT INTO subscription_user_to_tags_table(user_id, tags) VALUES(?, ?) + ON CONFLICT(user_id) DO UPDATE SET + tags=excluded.tags;`, hash, tagsOutput); err != nil { + tx.Rollback() + return fmt.Errorf("failed to configure user-to-tags table in db for the user: %s", err) + } + + if err = tx.Commit(); err != nil { + tx.Rollback() + return fmt.Errorf("failed to commit transaction to db: %s", err) + } + + return nil +} diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go index 6f856d3..66f1234 100644 --- a/internal/router/api-v1-general-page-body.go +++ b/internal/router/api-v1-general-page-body.go @@ -95,33 +95,12 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.A queryTags = append(queryTags, string(match[1])) } - pages, err := b2Client.Scan(lang + "/") + tagsArray, err := getTags(b2Client, lang) if err != nil { - slog.Warn("failed to scan pages via b2", slog.String("error", err.Error())) - return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages via b2: %s", slog.String("error", err.Error()))) + slog.Warn("failed to get the available tags", slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags") } - tagsMap := make(map[string]int) - for _, page := range pages { - for _, tag := range page.Metadata.Tags { - tagsMap[tag]++ - } - } - slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path())) - - type Tag struct { - Name string `json:"Name" yaml:"name"` - Count int `json:"Count" yaml:"count"` - } - - tagsArray := make([]Tag, 0, len(tagsMap)) - for tag, count := range tagsMap { - tagsArray = append(tagsArray, Tag{tag, count}) - } - slices.SortFunc(tagsArray, func(a Tag, b Tag) int { - return strings.Compare(a.Name, b.Name) - }) - values["Tags"] = tagsArray values["QuerySort"] = querySort values["QueryTags"] = strings.Join(queryTags, ",") @@ -135,8 +114,33 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []config.A if err != nil { slog.Error("get info from mailer about a client", slog.String("error", err.Error())) } + + tagsArray, err := getTags(b2Client, lang) + if err != nil { + slog.Warn("failed to get the available tags", slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to gather available tags") + } + + subscriptionType, tags, err := Mailer.GetSubscriptions(c.IP()) + if err != nil { + slog.Warn("failed to get the user subscriptions", slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to get the user subscriptions") + } + + switch subscriptionType { + case mailer.None: + values["TagsPicked"] = "none" + case mailer.All: + values["TagsPicked"] = "all" + case mailer.Specific: + values["TagsPicked"] = "specific" + } + + values["TagsPickedList"] = tags + values["Email"] = email values["EmailCode"] = c.Query("email_code") + values["ExistingTags"] = tagsArray additionalTemplates = append(additionalTemplates, "views/pages/user-page.html") } else if len(pathParts) == 3 && pathParts[1] == "blog" { @@ -204,3 +208,34 @@ func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string return metadata, buf.String(), nil } + +type Tag struct { + Name string `json:"Name" yaml:"name"` + Count int `json:"Count" yaml:"count"` +} + +func getTags(b2Client *b2.B2Client, lang string) (tags []Tag, err error) { + pages, err := b2Client.Scan(lang + "/") + if err != nil { + slog.Warn("failed to scan pages via b2", slog.String("error", err.Error())) + return nil, fmt.Errorf("failed to scan pages via b2: %w", err) + } + + tagsMap := make(map[string]int) + for _, page := range pages { + for _, tag := range page.Metadata.Tags { + tagsMap[tag]++ + } + } + slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("lang", lang)) + + tagsArray := make([]Tag, 0, len(tagsMap)) + for tag, count := range tagsMap { + tagsArray = append(tagsArray, Tag{tag, count}) + } + slices.SortFunc(tagsArray, func(a Tag, b Tag) int { + return strings.Compare(a.Name, b.Name) + }) + + return tagsArray, nil +} diff --git a/internal/router/api-v1-subs.go b/internal/router/api-v1-subs.go new file mode 100644 index 0000000..8995ebe --- /dev/null +++ b/internal/router/api-v1-subs.go @@ -0,0 +1,67 @@ +package router + +import ( + "net/url" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/mailer" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + assert(0, tm.Add("personal-page-status", "views/partials/personal-page-status.html")) +} + +func Api_V1_Subs_Put(l map[string]*locale.LocaleConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + id := c.IP() + lang := "en" + + var referer, path string + var pathParts []string + var urlStruct *url.URL + var err error + + referer = c.Get("Referer", "") + if referer == "" { + goto skipFetchingLang + } + + urlStruct, err = url.ParseRequestURI(referer) + if err != nil { + goto skipFetchingLang + } + + path = urlStruct.EscapedPath() + pathParts = strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) == 0 { + goto skipFetchingLang + } + + lang = pathParts[0] + + skipFetchingLang: + subscriptionType := c.FormValue("tags") + var subscriptionTypeEnum mailer.SubscriptionType + switch subscriptionType { + case "all": + subscriptionTypeEnum = mailer.All + case "none": + subscriptionTypeEnum = mailer.None + case "specific": + subscriptionTypeEnum = mailer.Specific + default: + return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.SubscribeInvalidType, map[string]string{}) + } + + specificTags := c.FormValue("tags_picked") + if err = Mailer.Subscribe(id, subscriptionTypeEnum, specificTags); err != nil { + return api_v1_email_sendStatusHtml(c, "subs-message", l, "Failed", fiber.StatusUnprocessableEntity, lang, l[lang].UserProfile.FailedToSubscribe, map[string]string{}) + } + + return api_v1_email_sendStatusHtml(c, "subs-message", l, "OK", fiber.StatusOK, lang, l[lang].UserProfile.SubscribedSuccessfully, map[string]string{}) + } +} diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml index 49d21ee..8816f2e 100644 --- a/locale/localization.en.yaml +++ b/locale/localization.en.yaml @@ -52,3 +52,13 @@ UserProfile: VerificationSuccess: 'Success! You are now able to subscribe with your new e-mail.' VerificationFailed: 'Verification code is invalid, please ask administrator for a more detailed cause.' VerificationEmpty: 'Verification code is unset or empty.' + SubscriptionHeader: 'Subscribe to...' + TagsNone: 'None' + TagsAll: 'All' + TagsSpecific: 'Specific' + TagDoesNotExist: 'The tag "{}" does not exist on the site.' + TagAlreadyAdded: 'The tag "{}" is already in your subscribed list.' + SaveButton: 'Save' + SubscribeInvalidType: 'Invalid subscription type.' + FailedToSubscribe: 'Failed to subscribe by new settings, please ask administrator for a more detailed cause.' + SubscribedSuccessfully: 'Successfully subscribed you by new settings!' diff --git a/locale/localization.go b/locale/localization.go index 9022b50..cd83aa1 100644 --- a/locale/localization.go +++ b/locale/localization.go @@ -86,6 +86,16 @@ type UserProfileConfig struct { VerificationSuccess string `yaml:"VerificationSuccess" json:"VerificationSuccess"` VerificationFailed string `yaml:"VerificationFailed" json:"VerificationFailed"` VerificationEmpty string `yaml:"VerificationEmpty" json:"VerificationEmpty"` + SubscriptionHeader string `yaml:"SubscriptionHeader" json:"SubscriptionHeader"` + TagsNone string `yaml:"TagsNone" json:"TagsNone"` + TagsAll string `yaml:"TagsAll" json:"TagsAll"` + TagsSpecific string `yaml:"TagsSpecific" json:"TagsSpecific"` + TagDoesNotExist string `yaml:"TagDoesNotExist" json:"TagDoesNotExist"` + TagAlreadyAdded string `yaml:"TagAlreadyAdded" json:"TagAlreadyAdded"` + SaveButton string `yaml:"SaveButton" json:"SaveButton"` + SubscribeInvalidType string `yaml:"SubscribeInvalidType" json:"SubscribeInvalidType"` + FailedToSubscribe string `yaml:"FailedToSubscribe" json:"FailedToSubscribe"` + SubscribedSuccessfully string `yaml:"SubscribedSuccessfully" json:"SubscribedSuccessfully"` } func LoadConfig(path string, config *LocaleConfig) error { diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml index fe6d55f..8a0ec5e 100644 --- a/locale/localization.ru.yaml +++ b/locale/localization.ru.yaml @@ -52,3 +52,13 @@ UserProfile: VerificationSuccess: 'Поздравляем! Теперь по этой почте можно подписаться на блог.' VerificationFailed: 'Код для верификации невалиден, при вопросах, пожалуйста, обращайтесь к администратору.' VerificationEmpty: 'Код для верификации не введён.' + SubscriptionHeader: 'Подписаться на...' + TagsNone: 'Ничего' + TagsAll: 'Всё' + TagsSpecific: 'Определённое' + TagDoesNotExist: 'Тэга "{}" нет на сайте.' + TagAlreadyAdded: 'Тэг "{}" уже у вас добавлен.' + SaveButton: 'Сохранить' + SubscribeInvalidType: 'Неизвестный тип подписки.' + FailedToSubscribe: 'Неудачная попытка записать новую подписку, более подробно спрашивайте у администратора.' + SubscribedSuccessfully: 'Вы успешно подписались по новым настройкам!' @@ -171,6 +171,7 @@ func main() { app.Post("/api/v1/email/send-verification-code", router.Api_V1_Email_SendVerificationCode(localization)) app.Post("/api/v1/email/verify", router.Api_V1_Email_Verify(localization)) app.Get("/api/v1/email/is-in-verification", router.Api_V1_Email_IsInVerification(localization)) + app.Put("/api/v1/subs", router.Api_V1_Subs_Put(localization)) app.Static("/", "./static") diff --git a/static/input.css b/static/input.css index cc88959..c3c10fd 100644 --- a/static/input.css +++ b/static/input.css @@ -180,8 +180,8 @@ a:hover, .linklike:hover { text-decoration: underline; } -form.disabled::before, -form.htmx-request::before { +.disabled::before, +.htmx-request::before { content: ''; position: absolute; top: 0; diff --git a/views/pages/user-page.html b/views/pages/user-page.html index 1632670..6fab0ad 100644 --- a/views/pages/user-page.html +++ b/views/pages/user-page.html @@ -1,9 +1,9 @@ {{ define "body" }} -<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4 overflow-y-auto"> +<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4"> <form id="email-form" class="w-full mb-2 flex flex-col relative" hx-get="/api/v1/email/is-in-verification" hx-target="#email-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this"> <div class="flex flex-row w-full mb-4 z-21"> - <div class="flex flex-col flex-3/5 grow-0"> + <div class="flex flex-col w-full sm:flex-3/5 grow-0"> <label class="block text-main-medium font-bold mb-1 z-22"> {{ .L.UserProfile.EmailHeader }} </label> @@ -12,30 +12,78 @@ data-validated-email="{{ .Email }}" onchange="checkIfValidated(this);"> </div> </div> - <button class="w-1/3 mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" + <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" hx-post="/api/v1/email/send-verification-code" hx-target="#email-message" hx-swap="outerHTML" hx-indicator="#email-form"> {{ .L.UserProfile.SendCodeButton }} </button> </form> - <div id="email-message"></div> + <div id="email-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div> + <hr class="my-2 border-t-4 border-dotted border-main-hard"> + <form id="email-verification-form" class="w-full mb-2 flex flex-col relative hidden" {{ if .EmailCode }}hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-trigger="load" hx-indicator="this"{{ end }}> <div class="flex flex-row w-full mb-4 z-21"> - <div class="flex flex-col flex-2/5 grow-0"> + <div class="flex flex-col w-full sm:flex-2/5 grow-0"> <label class="block text-main-medium font-bold ml-1 z-22"> {{ .L.UserProfile.VerificationCodeHeader }} </label> <input class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22" - name="email_code" type="text" value="{{ .EmailCode }}" placeholder="0123456789ABCDEF" maxlength="16"> + name="email_code" type="text" value="{{ .EmailCode }}" placeholder="0123456789ABCDEF" maxlength="16" autocapitalize="characters"> </div> </div> - <button class="w-1/3 mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" + <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" hx-post="/api/v1/email/verify" hx-target="#verification-message" hx-swap="outerHTML" hx-indicator="#email-verification-form"> {{ .L.UserProfile.VerifyButton }} </button> </form> - <div id="verification-message"></div> + <div id="verification-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div> +</div> + +<hr class="my-4 border-t-4 border-dotted border-main-hard"> + +<div class="bg-background-light flex flex-col inset-shadow-[0_0_0.4rem_black] px-8 py-4"> + <form id="subs-form" class="w-full mb-2 flex flex-col relative"> + <div class="flex flex-col w-full mb-4 z-21"> + <label class="block text-main-medium font-bold mb-1 z-22"> + {{ .L.UserProfile.SubscriptionHeader }} + </label> + <div class="flex flex-col sm:flex-row w-full"> + <div class="m-1 w-1/3"> + <label><input class="mr-1" type="radio" id="tagsNone" name="tags" value="none" {{ if eq .TagsPicked "none" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsNone }}</label> + </div> + <div class="m-1 w-1/3"> + <label><input class="mr-1" type="radio" id="tagsAll" name="tags" value="all" {{ if eq .TagsPicked "all" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsAll }}</label> + </div> + <div class="m-1 w-1/3"> + <label><input class="mr-1" type="radio" id="tagsSpecific" name="tags" value="specific" {{ if eq .TagsPicked "specific" }}checked{{ end }} onclick="toggleTagsPick(this);">{{ .L.UserProfile.TagsSpecific }}</label> + </div> + </div> + <div id="pick-tags-form" class="flex flex-col w-full relative"> + <input type="text" class="bg-(--background-medium-color) appearance-none border-(--background-medium-color) rounded ml-6 mr-4 py-2 px-4 text-main-medium leading-tight inset-shadow-[0_0_0.4rem_black] focus:outline-none focus:bg-(--background-light-color) focus:text-main-hard z-22" + list="existing-tags" onchange="addTag(this.value);" onkeydown="if (event.keyCode === 13) {addTag(this.value); this.value=''; return false;}"> + <datalist id="existing-tags"> + {{- range .ExistingTags }} + <option value="{{ .Name }}"> + {{- end }} + </datalist> + <div id="tags-picked-list" class="bg-background-dark flex flex-wrap inset-shadow-[0_0_0.4rem_black] p-2 m-4 w-full"> + {{- range .TagsPickedList }} + <div id="tagPicked{{ . }}" class="bg-main-hard text-background-light m-2 flex flex-row"> + <p class="py-1 px-2">{{ . }}</p> + <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked{{ . }}'));">✘</p> + <input type="hidden" name="tags_picked" value="{{ . }}"> + </div> + {{- end }} + </div> + </div> + <button class="w-max mx-auto bg-(--main-hard-color) hover:bg-(--main-medium-color) active:bg-(--main-soft-color) active:inset-shadow-[0.2em_0.2em_0.2rem_black] transition-colors transition-300 text-(--background-light-color) font-bold py-2 px-4 rounded z-22" + hx-put="/api/v1/subs" hx-target="#subs-message" hx-swap="outerHTML" hx-indicator="#subs-form"> + {{ .L.UserProfile.SaveButton }} + </button> + </div> + </form> + <div id="subs-message" class="bg-background-dark text-main-soft z-26 w-full inset-shadow-[0_0_0.4rem_black] py-2 px-4 text-center"></div> </div> <script> @@ -92,5 +140,58 @@ function checkIfValidated(elem) { } checkIfValidated(htmx.find("#email-input")); + +function toggleTagsPick(elem) { + const pickTagsForm = htmx.find("#pick-tags-form"); + if (elem.value === "specific" && elem.checked) { + pickTagsForm.classList.remove("disabled"); + } else { + pickTagsForm.classList.add("disabled"); + } +} + +toggleTagsPick(htmx.find("#tagsSpecific")); + +function addTag(name) { + name = name.trim(); + const subsMessage = htmx.find("#subs-message"); + const existingTags = htmx.findAll(htmx.find("#existing-tags"), "option"); + found = [...existingTags].find(opt => opt.value===name); + if (!found) { + subsMessage.classList.remove("hidden"); + subsMessage.innerHTML = "{{ .L.UserProfile.TagDoesNotExist }}".replace("{}", name); + return; + } + + const tagsPickedList = htmx.find("#tags-picked-list"); + const subscribedTags = htmx.findAll(tagsPickedList, "div input"); + found = [...subscribedTags].find(opt => opt.value===name); + if (found) { + subsMessage.classList.remove("hidden"); + subsMessage.innerHTML = "{{ .L.UserProfile.TagAlreadyAdded }}".replace("{}", name); + return; + } + + tagsPickedList.innerHTML += ` + <div id="tagPicked${name}" class="bg-main-hard text-background-light m-2 flex flex-row"> + <p class="py-1 px-2">${name}</p> + <p class="py-1 px-2 bg-main-soft cursor-pointer" onclick="htmx.remove(htmx.find('#tagPicked${name}'));">✘</p> + <input type="hidden" name="tags_picked" value="${name}"> + </div> + `; +} + +htmx.on("htmx:configRequest", (e) => { + const params = e.detail.parameters; + + if (Array.isArray(params.tags_picked)) { + tags_join = params.tags_picked[0] || ""; + for (i = 1; i < params.tags_picked.length; i++) { + tags_join += ","; + tags_join += params.tags_picked[i]; + } + params.tags_picked = tags_join; + } +}) </script> {{ end }}
\ No newline at end of file |