Browse Source

Feature: Category deletion; UI changes

master
parent
commit
7aa3e52ddb
  1. 29
      pages/base.html
  2. 44
      pages/category.html
  3. 74
      pages/index.html
  4. 2
      pages/login.html
  5. 8
      scripts/api.js
  6. 14
      scripts/auth.js
  7. 90
      src/server/endpoints.go
  8. 1
      src/server/server.go
  9. 3
      static/images/arrows-fullscreen.svg

29
pages/base.html

@ -45,24 +45,23 @@
<script src="/scripts/api.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async function() {
if (document.cookie.indexOf("auth=") == -1) {
if (window.location.pathname != "/about" && window.location.pathname != "/login" && window.location.pathname != "/register" && window.location.pathname != "/error") {
window.location.replace("/about");
// Check if auth info is valid
try {
let response = await getUser();
if (response.ok) {
let barAuth = document.getElementById("bar-auth");
barAuth.innerHTML = '<button id="log-out-btn" class="btn btn-outline-light me-2"><img src="/static/images/person-dash-fill.svg"></button>';
document.getElementById("log-out-btn").addEventListener("click", (event) => {
// Log out
forgetAuthInfo();
window.location.replace("/about");
});
}
} catch(error) {
forgetAuthInfo();
return;
}
// Check if auth info is indeed valid
let response = await getUser();
if (response.ok) {
let barAuth = document.getElementById("bar-auth");
// barAuth.innerHTML = "<b>" + login + "</b>" + " | ";
barAuth.innerHTML = '<button id="log-out-btn" class="btn btn-outline-light me-2"><img src="/static/images/person-dash-fill.svg"></button>';
document.getElementById("log-out-btn").addEventListener("click", (event) => {
// Log out
window.location.replace("/about");
});
}
}, false)
</script>

44
pages/category.html

@ -9,23 +9,27 @@
<!-- Sidebar -->
<div id="sidebar" class="flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary" style="width: 380px;">
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom">
<svg class="bi pe-none me-2" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom">
<img class="bi pe-none me-2" width="30" height="24" src="/static/images/arrows-fullscreen.svg">
<span class="fs-5 fw-semibold">Categories</span>
</a>
{{ range .Groups }}
<div class="list-group list-group-flush border-bottom scrollarea">
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">{{ .Name }}</strong>
<small>{{ .TimeCreated }}</small>
</div>
<div class="col-10 mb-1 small">Is removable: {{ .Removable }}</div>
</a>
</div>
{{ end }}
</a>
<div class="list-group list-group-flush border-bottom scrollarea">
{{ range .Groups }}
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action py-3 lh-sm {{if eq .ID $.CurrentGroupId}} active {{end}}" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">{{ .Name }}</strong>
<small>{{ .TimeCreated }}</small>
</div>
{{ if not .Removable }}
<div class="col-10 mb-1 small">Not removable</div>
{{ end }}
</a>
{{ end }}
</div>
</div>
<div class="p-2 flex-grow-1">
<form action="javascript:void(0);">
@ -35,7 +39,8 @@
<input type="text" class="form-control" id="new-todo-text" placeholder="TODO text">
</div>
<div class="col">
<input type="datetime-local" name="new-todo-due" id="new-todo-due" placeholder="Due">
<label for="new-todo-due">Due</label>
<input aria-placeholder="Due" type="date" name="new-todo-due" id="new-todo-due" placeholder="Due">
</div>
<div class="col">
<button id="new-todo-submit" class="btn btn-primary">Add</button>
@ -136,7 +141,7 @@ document.addEventListener('DOMContentLoaded', async function() {
// "Add" button
document.getElementById("new-todo-submit").addEventListener("click", async (event) => {
let newTodoTextInput = document.getElementById("new-todo-text");
let newTodoTextInput = document.getElementById("new-todo-text");
let newTodoText = newTodoTextInput.value;
if (newTodoText.length < 1) {
newTodoTextInput.setCustomValidity("At least one character is needed!");
@ -146,10 +151,15 @@ document.addEventListener('DOMContentLoaded', async function() {
}
newTodoTextInput.value = "";
let newTodoDueInput = document.getElementById("new-todo-due");
let dueTimeStamp = Date.parse(newTodoDueInput.value) / 1000;
let groupId = document.getElementById("categoryId").innerText;
// Make a request
let response = await postNewTodo({text: newTodoText, groupId: Number(groupId)});
let response = await postNewTodo(
{text: newTodoText, groupId: Number(groupId), dueUnix: Number(dueTimeStamp)}
);
if (response.ok) {
location.reload();
}

74
pages/index.html

@ -3,49 +3,70 @@
{{ define "content" }}
<!-- Main -->
<main class="d-flex flex-wrap">
<main>
<!-- Sidebar -->
<div id="sidebar" class="flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary" style="width: 380px;">
<div class="d-flex flex-wrap">
<!-- Sidebar -->
<div id="sidebar" class="flex-shrink-1 p-2 d-flex flex-column align-items-stretch bg-body-tertiary" style="width: 380px;">
<a href="/" class="d-flex align-items-center flex-shrink-0 p-3 link-body-emphasis text-decoration-none border-bottom">
<svg class="bi pe-none me-2" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
<img class="bi pe-none me-2" width="30" height="24" src="/static/images/arrows-fullscreen.svg">
<span class="fs-5 fw-semibold">Categories</span>
</a>
<div class="list-group list-group-flush border-bottom scrollarea">
{{ range .Groups }}
<div class="list-group list-group-flush border-bottom scrollarea">
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action active py-3 lh-sm" aria-current="true">
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action py-3 lh-sm" aria-current="true">
<div class="d-flex w-100 align-items-center justify-content-between">
<strong class="mb-1">{{ .Name }}</strong>
<small>{{ .TimeCreated }}</small>
</div>
<div class="col-10 mb-1 small">Is removable: {{ .Removable }}</div>
{{ if not .Removable }}
<div class="col-10 mb-1 small">Not removable</div>
{{ end }}
</a>
</div>
{{ end }}
</div>
</div>
<div class="d-flex flex-column flex-md-row p-4 gap-4 py-md-5 align-items-center justify-content-center">
<div class="list-group">
<div class="d-flex flex-column p-4 gap-4 py-md-5 align-items-start justify-content-center">
<form onsubmit="return false;">
<!-- <label for="new-category-input" class="form-label">New category</label> -->
<input type="text" name="newCategory" class="form-control" id="new-category-input" placeholder="Category Name">
<input type="submit" value="Create" onclick="createNewCategory();" class="btn btn-primary">
</form>
</div>
</div>
<!-- <div class="d-flex flex-column flex-md-row p-4 gap-4 py-md-5 align-items-center justify-content-center flex-grow-1"> -->
<div class="d-flex flex-column flex-grow-1 flex-md-row p-4 gap-4 py-md-5">
<div class="list-group flex-grow-1">
{{ range .Groups }}
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
<img src="/static/images/box-arrow-up-right.svg" alt="Go to this category" width="32" height="32">
<div class="d-flex gap-2 w-100 justify-content-between">
<div>
<h6 class="mb-0">{{ .Name }}</h6>
<p class="mb-0 opacity-75">Jump here</p>
<div class="list-group-item list-group-item-action d-flex gap-3 py-3" aria-current="true">
<a href="/group/{{.ID}}" class="list-group-item list-group-item-action d-flex gap-3 py-3">
<img src="/static/images/box-arrow-up-right.svg" alt="Go to this category" width="32" height="32">
<div class="d-flex gap-2 w-100 justify-content-between">
<div>
<h6 class="mb-0">{{ .Name }}</h6>
<p class="mb-0 opacity-75">Jump here</p>
</div>
<small class="opacity-50 text-nowrap">{{ .TimeCreated }}</small>
</div>
</a>
{{ if .Removable }}
<div class="small">
<button class="btn btn-danger" onclick="deleteCategoryRefresh({{.ID}})">
<img src="/static/images/trash3-fill.svg" alt="Remove category">
</button>
</div>
<small class="opacity-50 text-nowrap">{{ .TimeCreatedUnix }}</small>
{{ end }}
</div>
</a>
{{ end }}
</div>
<div>
<label for="newCategory">New category</label>
<input type="text" name="newCategory" id="new-category-input" placeholder="Category Name">
<input type="submit" value="Create" onclick="createNewCategory();">
</div>
</div>
<!-- d-flex flex-column align-items-center justify-content-center -->
</main>
<script>
@ -67,6 +88,11 @@ async function createNewCategory() {
window.location.reload();
}
async function deleteCategoryRefresh(id) {
await deleteCategory(id);
window.location.reload();
}
</script>
{{ end }}

2
pages/login.html

@ -44,7 +44,7 @@ async function logIn() {
password = sha256(password);
// Check if auth info is indeed valid
let response = await getUser();
let response = await doLogin({login: login, password: password});
if (response.ok) {
window.location.replace("/");
} else {

8
scripts/api.js

@ -26,6 +26,10 @@ async function postNewUser(newUser) {
return post("/api/user/create", newUser)
}
async function doLogin(userInformation) {
return post("/api/user/login", userInformation)
}
async function get(url) {
return fetch(url, {
method: "GET",
@ -66,6 +70,10 @@ async function deleteTodo(id) {
return del("/api/todo/delete/"+id);
}
async function deleteCategory(id) {
return del("/api/group/delete/"+id);
}
async function update(url, json) {
return post(url, json);
}

14
scripts/auth.js

@ -1,7 +1,19 @@
/*
2024 Kasyanov Nikolay Alexeyevich (Unbewohnte)
2024 Kasyanov Nikolay Alexeevich (Unbewohnte)
*/
function getCookie(name){
return document.cookie.split(';').some(c => {
return c.trim().startsWith(name + '=');
});
}
function forgetAuthInfo() {
if(getCookie("auth")) {
document.cookie = "auth" + "=" + ";expires=Thu, 01 Jan 1970 00:00:01 GMT";
}
}
/**
* [js-sha256]{@link https://github.com/emn178/js-sha256}
*

90
src/server/endpoints.go

@ -97,6 +97,55 @@ func (s *Server) EndpointUserCreate(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointUserLogin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Retrieve user data
defer req.Body.Close()
contents, err := io.ReadAll(req.Body)
if err != nil {
logger.Error("[Server][EndpointUserLogin] Failed to read request body: %s", err)
http.Error(w, "Failed to read request body", http.StatusInternalServerError)
return
}
var user db.User
err = json.Unmarshal(contents, &user)
if err != nil {
logger.Error("[Server][EndpointUserLogin] Failed to unmarshal user data: %s", err)
http.Error(w, "User JSON unmarshal error", http.StatusInternalServerError)
return
}
// Check auth data
userDB, err := s.db.GetUser(user.Login)
if err != nil {
logger.Error("[Server][EndpointUserLogin] Failed to fetch user information from DB: %s", err)
http.Error(w, "Failed to fetch user information", http.StatusInternalServerError)
return
}
if user.Password != userDB.Password {
http.Error(w, "Failed auth", http.StatusForbidden)
return
}
// Send cookie
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: fmt.Sprintf("%s:%s", user.Login, user.Password),
SameSite: http.SameSiteStrictMode,
HttpOnly: false,
Path: "/",
Secure: true,
})
w.WriteHeader(http.StatusOK)
}
func (s *Server) EndpointUserUpdate(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@ -436,43 +485,47 @@ func (s *Server) EndpointTodoGroupDelete(w http.ResponseWriter, req *http.Reques
// Delete an existing group
defer req.Body.Close()
// Read body
body, err := io.ReadAll(req.Body)
if err != nil {
logger.Warning("[Server] Failed to read request body to possibly delete a TODO group: %s", err)
http.Error(w, "Failed to read body", http.StatusInternalServerError)
// Check if given user actually owns this group
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
return
}
// Unmarshal JSON
var group db.TodoGroup
err = json.Unmarshal(body, &group)
// Get group ID
groupId, err := strconv.ParseUint(path.Base(req.URL.Path), 10, 64)
if err != nil {
logger.Warning("[Server] Received invalid TODO group JSON for deletion: %s", err)
http.Error(w, "Invalid TODO group JSON", http.StatusBadRequest)
http.Error(w, "Bad Category ID", http.StatusBadRequest)
return
}
// Check if given user actually owns this group
if !IsUserAuthorizedReq(req, s.db) {
http.Error(w, "Invalid user auth data", http.StatusForbidden)
if !s.db.DoesUserOwnGroup(groupId, GetLoginFromReq(req)) {
http.Error(w, "You don't own this group", http.StatusForbidden)
return
}
if !s.db.DoesUserOwnGroup(group.ID, GetLoginFromReq(req)) {
http.Error(w, "You don't own this group", http.StatusForbidden)
groupDB, err := s.db.GetTodoGroup(groupId)
if err != nil {
logger.Error("[Server][EndpointGroupDelete] Failed to fetch TODO group with Id %d: %s", groupId, err)
http.Error(w, "Failed to retrieve TODO group", http.StatusInternalServerError)
return
}
// Now delete
err = s.db.DeleteTodoGroup(group.ID)
if !groupDB.Removable {
// Not removable
http.Error(w, "Not removable", http.StatusBadRequest)
return
}
// Delete
err = s.db.DeleteTodoGroup(groupId)
if err != nil {
logger.Error("[Server] Failed to delete %s's TODO group: %s", GetLoginFromReq(req), err)
logger.Error("[Server][EndpointGroupDelete] Failed to delete %s's TODO group: %s", GetLoginFromReq(req), err)
http.Error(w, "Failed to delete TODO group", http.StatusInternalServerError)
return
}
// Success!
logger.Info("[Server][EndpointGroupDelete] Deleted group ID: %d for %s", groupId, GetLoginFromReq(req))
w.WriteHeader(http.StatusOK)
}
@ -505,6 +558,7 @@ func (s *Server) EndpointTodoGroupCreate(w http.ResponseWriter, req *http.Reques
// Add group to the database
newGroup.OwnerLogin = GetLoginFromReq(req)
newGroup.TimeCreatedUnix = uint64(time.Now().Unix())
newGroup.Removable = true
err = s.db.CreateTodoGroup(newGroup)
if err != nil {
http.Error(w, "Failed to create TODO group", http.StatusInternalServerError)

1
src/server/server.go

@ -192,6 +192,7 @@ func New(config conf.Conf) (*Server, error) {
mux.HandleFunc("/api/user/delete", server.EndpointUserDelete) // Non specific
mux.HandleFunc("/api/user/update", server.EndpointUserUpdate) // Non specific
mux.HandleFunc("/api/user/create", server.EndpointUserCreate) // Non specific
mux.HandleFunc("/api/user/login", server.EndpointUserLogin) // Non specific
mux.HandleFunc("/api/todo/create", server.EndpointTodoCreate) // Non specific
mux.HandleFunc("/api/todo/get", server.EndpointUserTodosGet) // Non specific
mux.HandleFunc("/api/todo/delete/", server.EndpointTodoDelete) // Specific

3
static/images/arrows-fullscreen.svg

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrows-fullscreen" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5.828 10.172a.5.5 0 0 0-.707 0l-4.096 4.096V11.5a.5.5 0 0 0-1 0v3.975a.5.5 0 0 0 .5.5H4.5a.5.5 0 0 0 0-1H1.732l4.096-4.096a.5.5 0 0 0 0-.707m4.344 0a.5.5 0 0 1 .707 0l4.096 4.096V11.5a.5.5 0 1 1 1 0v3.975a.5.5 0 0 1-.5.5H11.5a.5.5 0 0 1 0-1h2.768l-4.096-4.096a.5.5 0 0 1 0-.707m0-4.344a.5.5 0 0 0 .707 0l4.096-4.096V4.5a.5.5 0 1 0 1 0V.525a.5.5 0 0 0-.5-.5H11.5a.5.5 0 0 0 0 1h2.768l-4.096 4.096a.5.5 0 0 0 0 .707m-4.344 0a.5.5 0 0 1-.707 0L1.025 1.732V4.5a.5.5 0 0 1-1 0V.525a.5.5 0 0 1 .5-.5H4.5a.5.5 0 0 1 0 1H1.732l4.096 4.096a.5.5 0 0 1 0 .707"/>
</svg>

After

Width:  |  Height:  |  Size: 726 B

Loading…
Cancel
Save