From 0d261d2f7d080b610f50102576073e334a89e8cb Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Thu, 23 Oct 2025 01:15:26 +0700 Subject: feat: email linking --- static/input.css | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) (limited to 'static/input.css') diff --git a/static/input.css b/static/input.css index 4fcadd1..cc88959 100644 --- a/static/input.css +++ b/static/input.css @@ -9,6 +9,7 @@ html { font-size: 12px; + --crossed-out: url("data:image/svg+xml,"); --noise-image: url(data:image/png;base64,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); --split-left-background: unset; --split-left-repeat: no-repeat; @@ -179,6 +180,27 @@ a:hover, .linklike:hover { text-decoration: underline; } +form.disabled::before, +form.htmx-request::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--main-hard-color); + background-size: 0.5rem 0.5rem; + mask-image: var(--crossed-out); + opacity: 1; + transition: opacity 0.3s; + z-index: 25; +} + +form.disabled input, +form.htmx-request input { + filter: grayscale(1); +} + .bg-interlocked-hexagons { background-image: var(--interlocked-hexagons-background); background-repeat: var(--interlocked-hexagons-background-repeat); -- cgit v1.3.1+13 From 4b2da2abbc7376ce0de71a00b18b7091b00dbcd1 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Thu, 23 Oct 2025 13:37:50 +0700 Subject: feat: subscription settings --- internal/mailer/mailer.go | 76 ++++++++++++++++++ internal/router/api-v1-general-page-body.go | 83 ++++++++++++++------ internal/router/api-v1-subs.go | 67 ++++++++++++++++ locale/localization.en.yaml | 10 +++ locale/localization.go | 10 +++ locale/localization.ru.yaml | 10 +++ main.go | 1 + static/input.css | 4 +- views/pages/user-page.html | 117 ++++++++++++++++++++++++++-- 9 files changed, 344 insertions(+), 34 deletions(-) create mode 100644 internal/router/api-v1-subs.go (limited to 'static/input.css') 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: 'Вы успешно подписались по новым настройкам!' diff --git a/main.go b/main.go index 2d9dafd..5a14bc6 100644 --- a/main.go +++ b/main.go @@ -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" }} -
+
-
+
@@ -12,30 +12,78 @@ data-validated-email="{{ .Email }}" onchange="checkIfValidated(this);">
- -
+
+
+ + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + + {{- range .ExistingTags }} + +
+ {{- range .TagsPickedList }} +
+

{{ . }}

+

+ +
+ {{- end }} +
+
+ +
+
+
{{ end }} \ No newline at end of file -- cgit v1.3.1+13 From 84c851eeb4c13042a928b6b6334675d126931a38 Mon Sep 17 00:00:00 2001 From: SayaAndy Date: Thu, 23 Oct 2025 15:13:40 +0700 Subject: feat: cross out only if explicitly set --- static/input.css | 8 ++++---- views/pages/user-page.html | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) (limited to 'static/input.css') diff --git a/static/input.css b/static/input.css index c3c10fd..739caa3 100644 --- a/static/input.css +++ b/static/input.css @@ -180,8 +180,8 @@ a:hover, .linklike:hover { text-decoration: underline; } -.disabled::before, -.htmx-request::before { +.crossable.disabled::before, +.crossable.htmx-request::before { content: ''; position: absolute; top: 0; @@ -196,8 +196,8 @@ a:hover, .linklike:hover { z-index: 25; } -form.disabled input, -form.htmx-request input { +form.crossable.disabled input, +form.crossable.htmx-request input { filter: grayscale(1); } diff --git a/views/pages/user-page.html b/views/pages/user-page.html index 6fab0ad..13eaa0b 100644 --- a/views/pages/user-page.html +++ b/views/pages/user-page.html @@ -1,6 +1,6 @@ {{ define "body" }}
-
@@ -21,7 +21,7 @@
-
@@ -43,7 +43,7 @@
- +
-
+
-- cgit v1.3.1+13