Compare commits

...

2 Commits

8 changed files with 214 additions and 29 deletions

View File

@@ -19,8 +19,7 @@ type MessageService interface {
GetByID(userID, id int64) (*Message, error) GetByID(userID, id int64) (*Message, error)
Create(userID int64, in CreateMessageInput) (int64, error) Create(userID int64, in CreateMessageInput) (int64, error)
Archive(userID, id int64) error Archive(userID, id int64) error
//Restore()
//ToDo: implement
Unarchive(userID, id int64) error Unarchive(userID, id int64) error
//MarkRead(userID, id int64) error MarkRead(userID, id int64) error
//MarkUnread(userID, id int64) error
} }

View File

@@ -7,6 +7,7 @@ package accountMessageHandler
import ( import (
"bytes" "bytes"
"database/sql"
"net/http" "net/http"
"strconv" "strconv"
@@ -137,3 +138,36 @@ func (h *AccountMessageHandlers) ReadGet(c *gin.Context) {
} }
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes()) c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
} }
func (h *AccountMessageHandlers) MarkReadPost(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
idStr := c.PostForm("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
sm.Put(c.Request.Context(), "flash", "Invalid message id.")
c.Redirect(http.StatusSeeOther, c.Request.Referer()) // back to where they came from
return
}
if err := h.Svc.MarkRead(userID, id); err != nil {
logging.Info("❌ MarkRead error: %v", err)
if err == sql.ErrNoRows {
sm.Put(c.Request.Context(), "flash", "Message not found or not permitted.")
} else {
sm.Put(c.Request.Context(), "flash", "Could not mark message as read.")
}
c.Redirect(http.StatusSeeOther, "/account/messages")
return
}
sm.Put(c.Request.Context(), "flash", "Message marked as read.")
// Redirect back to referer when possible so UX is smooth.
if ref := c.Request.Referer(); ref != "" {
c.Redirect(http.StatusSeeOther, ref)
} else {
c.Redirect(http.StatusSeeOther, "/account/messages")
}
}

View File

@@ -72,6 +72,7 @@ func RegisterAccountRoutes(app *bootstrap.App) {
messages.GET("/archive", msgH.ArchivedList) // view archived messages messages.GET("/archive", msgH.ArchivedList) // view archived messages
messages.POST("/archive", msgH.ArchivePost) // archive a message messages.POST("/archive", msgH.ArchivePost) // archive a message
messages.POST("/restore", msgH.RestoreArchived) messages.POST("/restore", msgH.RestoreArchived)
messages.POST("/mark-read", msgH.MarkReadPost)
} }
// Notifications (auth-required) // Notifications (auth-required)

View File

@@ -277,3 +277,25 @@ func (s *Service) Unarchive(userID, id int64) error {
} }
return nil return nil
} }
func (s *Service) MarkRead(userID, id int64) error {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
UPDATE user_messages
SET is_read = 1
WHERE id = ? AND recipientId = ?
`
q = s.bind(q)
res, err := s.DB.ExecContext(ctx, q, id, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}

View File

@@ -5,16 +5,33 @@
{{ if .Messages }} {{ if .Messages }}
<ul class="list-group mb-4"> <ul class="list-group mb-4">
{{ range .Messages }} {{ range .Messages }}
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center {{ if .IsRead }}read{{ end }}" data-msg-id="{{ .ID }}">
<div> <div>
<a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a><br> <a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a><br>
<small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small> <small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small>
</div> </div>
<div class="d-flex gap-2 align-items-center">
{{/* Archive form (existing) */}}
<form method="POST" action="/account/messages/archive" class="m-0"> <form method="POST" action="/account/messages/archive" class="m-0">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="id" value="{{ .ID }}"> <input type="hidden" name="id" value="{{ .ID }}">
<button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button> <button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
</form> </form>
{{/* Mark-read: only show when unread */}}
{{ if not .IsRead }}
<!-- Non-AJAX fallback form (submit will refresh) -->
<form method="POST" action="/account/messages/mark-read" class="m-0 d-inline-block mark-read-form">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="id" value="{{ .ID }}">
<button type="submit" class="btn btn-sm btn-outline-primary mark-read-btn"
data-msg-id="{{ .ID }}"
data-csrf="{{ $.CSRFToken }}">Mark read</button>
</form>
{{ end }}
</div>
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
@@ -49,4 +66,74 @@
<a href="/account/messages/archive" class="btn btn-outline-secondary ms-2">View Archived</a> <a href="/account/messages/archive" class="btn btn-outline-secondary ms-2">View Archived</a>
</div> </div>
</div> </div>
{{/* AJAX enhancement: unobtrusive — safe fallback to regular form when JS disabled */}}
<script>
;(function(){
// Ensure browser supports fetch + FormData; otherwise we fallback to regular form submit.
if (!window.fetch || !window.FormData) return;
// Helper to decrement topbar message count badge (assumes badge element id="message-count")
function decrementMessageCount() {
var el = document.getElementById('message-count');
if (!el) return;
var current = parseInt(el.textContent || el.innerText || '0', 10) || 0;
var next = Math.max(0, current - 1);
if (next <= 0) {
// remove badge or hide it
el.remove();
} else {
el.textContent = String(next);
}
}
// Handle clicks on mark-read buttons, submit via fetch, update DOM
document.addEventListener('click', function(e){
var btn = e.target.closest('.mark-read-btn');
if (!btn) return;
// Prevent the default form POST (non-AJAX fallback)
e.preventDefault();
var msgID = btn.dataset.msgId;
var csrf = btn.dataset.csrf;
if (!msgID) {
// fallback to normal submit if something's wrong
var frm = btn.closest('form');
if (frm) frm.submit();
return;
}
// Build urlencoded body like a regular form
var body = new URLSearchParams();
body.append('id', msgID);
if (csrf) body.append('csrf_token', csrf);
fetch('/account/messages/mark-read', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString(),
credentials: 'same-origin'
}).then(function(resp){
if (resp.ok) {
// UI update: remove the mark-read button, give item a .read class, update topbar count
var li = document.querySelector('li[data-msg-id="' + msgID + '"]');
if (li) {
li.classList.add('read');
// remove any mark-read form/button inside
var form = li.querySelector('.mark-read-form');
if (form) form.remove();
}
decrementMessageCount();
} else {
// If server returned non-2xx, fall back to full reload to show flash
resp.text().then(function(){ window.location.reload(); }).catch(function(){ window.location.reload(); });
}
}).catch(function(){ window.location.reload(); });
}, false);
})();
</script>
{{ end }} {{ end }}

View File

@@ -6,17 +6,63 @@
<hr> <hr>
<p>{{ .Message.Body }}</p> <p>{{ .Message.Body }}</p>
<div class="mt-4">
<button id="mark-read-btn" data-id="{{ .Message.ID }}" class="btn btn-outline-success">Mark As Read</button>
<form method="POST" action="/account/messages/archive" class="d-inline"> <form method="POST" action="/account/messages/archive" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}"> <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="id" value="{{ .Message.ID }}"> <input type="hidden" name="id" value="{{ .Message.ID }}">
<button type="submit" class="btn btn-outline-danger mt-3">Archive</button> <button type="submit" class="btn btn-outline-danger">Archive</button>
</form> </form>
<a href="/account/messages" class="btn btn-secondary mt-3">Back to Inbox</a> <a href="/account/messages" class="btn btn-secondary">Back to Inbox</a>
</div>
{{ else }} {{ else }}
<div class="alert alert-danger text-center"> <div class="alert alert-danger text-center">
Message not found or access denied. Message not found or access denied.
</div> </div>
{{ end }} {{ end }}
</div> </div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const btn = document.getElementById("mark-read-btn");
if (!btn) return;
btn.addEventListener("click", async function () {
const id = this.dataset.id;
const res = await fetch("/account/messages/mark-read", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({
id: id,
csrf_token: "{{ $.CSRFToken }}"
})
});
if (res.ok) {
this.classList.remove("btn-outline-success");
this.classList.add("btn-success");
this.textContent = "Marked As Read ✔";
const badge = document.getElementById("message-count");
if (badge) {
let count = parseInt(badge.textContent);
if (!isNaN(count)) {
count = Math.max(count - 1, 0);
if (count === 0) {
badge.remove();
} else {
badge.textContent = count;
}
}
}
} else {
alert("Failed to mark as read.");
}
});
});
</script>
{{ end }} {{ end }}

View File

@@ -3,7 +3,7 @@
<h2>Log My Ticket</h2> <h2>Log My Ticket</h2>
<form method="POST" action="/account/tickets/add_ticket" enctype="multipart/form-data" id="ticketForm"> <form method="POST" action="/account/tickets/add_ticket" enctype="multipart/form-data" id="ticketForm">
{{ .csrfField }} <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<div class="form-section"> <div class="form-section">
<label>Game: <label>Game:
@@ -49,7 +49,7 @@
</div> </div>
<div id="ticketLinesContainer"> <div id="ticketLinesContainer">
<!-- JS will insert ticket lines here --> <!-- todo, maybe ajax so it doesnt refresh?-->
</div> </div>
<div class="form-section"> <div class="form-section">

View File

@@ -31,7 +31,8 @@
aria-expanded="false"> aria-expanded="false">
<i class="bi bi-bell fs-5 position-relative"> <i class="bi bi-bell fs-5 position-relative">
{{ if gt .NotificationCount 0 }} {{ if gt .NotificationCount 0 }}
<span class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-warning text-dark badge-small"> <span id="notification-count"
class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-warning text-dark badge-small">
{{ if gt .NotificationCount 15 }}15+{{ else }}{{ .NotificationCount }}{{ end }} {{ if gt .NotificationCount 15 }}15+{{ else }}{{ .NotificationCount }}{{ end }}
</span> </span>
{{ end }} {{ end }}
@@ -41,7 +42,6 @@
aria-labelledby="notificationDropdown"> aria-labelledby="notificationDropdown">
<li class="dropdown-header text-center fw-bold">Notifications</li> <li class="dropdown-header text-center fw-bold">Notifications</li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{{ $total := len .Notifications }} {{ $total := len .Notifications }}
{{ range $i, $n := .Notifications }} {{ range $i, $n := .Notifications }}
<li class="px-3 py-2"> <li class="px-3 py-2">
@@ -55,15 +55,11 @@
</div> </div>
</a> </a>
</li> </li>
{{ if lt (add $i 1) $total }} {{ if lt (add $i 1) $total }}<li><hr class="dropdown-divider"></li>{{ end }}
<li><hr class="dropdown-divider"></li>
{{ end }} {{ end }}
{{ end }}
{{ if not .Notifications }} {{ if not .Notifications }}
<li class="text-center text-muted py-2">No notifications</li> <li class="text-center text-muted py-2">No notifications</li>
{{ end }} {{ end }}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li class="text-center"><a href="/account/notifications" class="dropdown-item">View all notifications</a></li> <li class="text-center"><a href="/account/notifications" class="dropdown-item">View all notifications</a></li>
</ul> </ul>
@@ -75,7 +71,8 @@
aria-expanded="false"> aria-expanded="false">
<i class="bi bi-envelope fs-5 position-relative"> <i class="bi bi-envelope fs-5 position-relative">
{{ if gt .MessageCount 0 }} {{ if gt .MessageCount 0 }}
<span class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-danger text-dark badge-small"> <span id="message-count"
class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-danger text-dark badge-small">
{{ if gt .MessageCount 15 }}15+{{ else }}{{ .MessageCount }}{{ end }} {{ if gt .MessageCount 15 }}15+{{ else }}{{ .MessageCount }}{{ end }}
</span> </span>
{{ end }} {{ end }}
@@ -85,7 +82,6 @@
aria-labelledby="messageDropdown"> aria-labelledby="messageDropdown">
<li class="dropdown-header text-center fw-bold">Messages</li> <li class="dropdown-header text-center fw-bold">Messages</li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{{ if .Messages }} {{ if .Messages }}
{{ range $i, $m := .Messages }} {{ range $i, $m := .Messages }}
<li class="px-3 py-2"> <li class="px-3 py-2">
@@ -103,15 +99,15 @@
{{ else }} {{ else }}
<li class="text-center text-muted py-2">No messages</li> <li class="text-center text-muted py-2">No messages</li>
{{ end }} {{ end }}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li> <li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li>
</ul> </ul>
</div> </div>
<!-- User Greeting/Dropdown --> <!-- User Dropdown -->
<div class="dropdown"> <div class="dropdown">
<a class="nav-link dropdown-toggle text-dark" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle text-dark" href="#" id="userDropdown" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
Hello, {{ .User.Username }} Hello, {{ .User.Username }}
</a> </a>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="userDropdown"> <ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="userDropdown">