added admin page
authorPhiTux <redacted>
Tue, 9 Sep 2025 12:24:39 +0000 (14:24 +0200)
committerPhiTux <redacted>
Tue, 9 Sep 2025 12:24:39 +0000 (14:24 +0200)
backend/handlers/admin.go [new file with mode: 0644]
backend/handlers/users.go
backend/main.go
frontend/src/lib/settings/Admin.svelte
frontend/src/lib/settings/Statistics.svelte
frontend/src/routes/(authed)/read/+page.js
frontend/src/routes/(authed)/write/+page.js
frontend/src/routes/login/+page.svelte

diff --git a/backend/handlers/admin.go b/backend/handlers/admin.go
new file mode 100644 (file)
index 0000000..0049149
--- /dev/null
@@ -0,0 +1,174 @@
+package handlers
+
+import (
+       "encoding/json"
+       "log"
+       "net/http"
+       "os"
+       "path/filepath"
+       "strconv"
+       "strings"
+
+       "github.com/phitux/dailytxt/backend/utils"
+)
+
+type AdminRequest struct {
+       AdminPassword string `json:"admin_password"`
+}
+
+type AdminUserResponse struct {
+       ID        int    `json:"id"`
+       Username  string `json:"username"`
+       DiskUsage int64  `json:"disk_usage"`
+}
+
+// ValidateAdminPassword validates the admin password
+func ValidateAdminPassword(w http.ResponseWriter, r *http.Request) {
+       adminPassword := os.Getenv("ADMIN_PASSWORD")
+       if adminPassword == "" {
+               json.NewEncoder(w).Encode(map[string]bool{
+                       "valid": false,
+               })
+               return
+       }
+
+       var req struct {
+               Password string `json:"password"`
+       }
+
+       if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+               http.Error(w, "Invalid request", http.StatusBadRequest)
+               return
+       }
+
+       w.Header().Set("Content-Type", "application/json")
+       json.NewEncoder(w).Encode(map[string]bool{
+               "valid": req.Password == adminPassword,
+       })
+}
+
+// validateAdminPasswordInRequest checks if the admin password in request is valid
+func validateAdminPasswordInRequest(r *http.Request) bool {
+       adminPassword := os.Getenv("ADMIN_PASSWORD")
+       if adminPassword == "" {
+               return false
+       }
+
+       var req AdminRequest
+       if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+               return false
+       }
+
+       return req.AdminPassword == adminPassword
+}
+
+// GetAllUsers returns all users with their disk usage
+func GetAllUsers(w http.ResponseWriter, r *http.Request) {
+       if !validateAdminPasswordInRequest(r) {
+               http.Error(w, "Invalid admin password", http.StatusUnauthorized)
+               return
+       }
+
+       // Read users.json
+       users, err := utils.GetUsers()
+       if err != nil {
+               http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+               return
+       }
+
+       // get users
+       usersList, ok := users["users"].([]any)
+       if !ok || len(usersList) == 0 {
+               utils.Logger.Printf("No users found.")
+       }
+
+       // Calculate disk usage for each user
+       var adminUsers []AdminUserResponse
+       for _, u := range usersList {
+               user, ok := u.(map[string]any)
+               if !ok {
+                       continue
+               }
+
+               userID, ok := user["user_id"].(float64)
+               if !ok {
+                       continue
+               }
+
+               username, _ := user["username"].(string)
+
+               // Calculate disk usage for this user
+               diskUsage := calculateUserDiskUsage(int(userID))
+
+               adminUsers = append(adminUsers, AdminUserResponse{
+                       ID:        int(userID),
+                       Username:  username,
+                       DiskUsage: diskUsage,
+               })
+       }
+
+       w.Header().Set("Content-Type", "application/json")
+       json.NewEncoder(w).Encode(map[string]any{
+               "users": adminUsers,
+       })
+}
+
+// calculateUserDiskUsage calculates the total disk usage for a user
+func calculateUserDiskUsage(userID int) int64 {
+       userDataDir := filepath.Join(utils.Settings.DataPath, strconv.Itoa(userID))
+       var totalSize int64
+
+       // Calculate size recursively
+       err := filepath.Walk(userDataDir, func(path string, info os.FileInfo, err error) error {
+               if err != nil {
+                       return nil // Continue on errors
+               }
+               if !info.IsDir() {
+                       totalSize += info.Size()
+               }
+               return nil
+       })
+
+       if err != nil {
+               log.Printf("Error calculating disk usage for user %d: %v", userID, err)
+       }
+
+       return totalSize
+}
+
+// DeleteUser deletes a user and all their data
+func DeleteUser(w http.ResponseWriter, r *http.Request) {
+       var req struct {
+               AdminPassword string `json:"admin_password"`
+               UserID        int    `json:"user_id"`
+       }
+
+       if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+               http.Error(w, "Invalid request", http.StatusBadRequest)
+               return
+       }
+
+       // Validate admin password
+       adminPassword := os.Getenv("ADMIN_PASSWORD")
+       if adminPassword == "" || req.AdminPassword != adminPassword {
+               http.Error(w, "Invalid admin password", http.StatusUnauthorized)
+               return
+       }
+
+       // Use the shared delete function from users.go
+       if err := deleteUserByID(req.UserID); err != nil {
+               log.Printf("Error deleting user %d: %v", req.UserID, err)
+               errMsg := err.Error()
+               if strings.Contains(errMsg, "not found") {
+                       http.Error(w, "User not found", http.StatusNotFound)
+               } else {
+                       http.Error(w, "Error deleting user", http.StatusInternalServerError)
+               }
+               return
+       }
+
+       w.Header().Set("Content-Type", "application/json")
+       json.NewEncoder(w).Encode(map[string]bool{
+               "success": true,
+       })
+}
index 4127b1365504b7e36648e314f8bd3eb68246ab4f..09bf0fcc57365c0f5438259a5a0e060174573871 100644 (file)
@@ -861,60 +861,25 @@ type DeleteAccountRequest struct {
        Password string `json:"password"`
 }
 
-func DeleteAccount(w http.ResponseWriter, r *http.Request) {
-       // Get user ID from context
-       userID, ok := r.Context().Value(utils.UserIDKey).(int)
-       if !ok {
-               utils.JSONResponse(w, http.StatusUnauthorized, map[string]any{
-                       "success": false,
-                       "message": "User not authenticated",
-               })
-               return
-       }
-
-       // Parse request body
-       var req DeleteAccountRequest
-       if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
-               utils.JSONResponse(w, http.StatusBadRequest, map[string]any{
-                       "success": false,
-                       "message": "Invalid request body",
-               })
-               return
-       }
-
-       derived_key, _, err := utils.CheckPasswordForUser(userID, req.Password)
-       if err != nil || len(derived_key) == 0 {
-               utils.JSONResponse(w, http.StatusOK, map[string]any{
-                       "success":            false,
-                       "message":            "Error checking password",
-                       "password_incorrect": true,
-               })
-               return
-       }
-
+// deleteUserByID deletes a user by their user ID from the system
+// This is a shared function used by both DeleteAccount and admin DeleteUser
+func deleteUserByID(userID int) error {
        utils.UsersFileMutex.Lock()
        defer utils.UsersFileMutex.Unlock()
 
        // Get User data
        users, err := utils.GetUsers()
        if err != nil {
-               utils.JSONResponse(w, http.StatusOK, map[string]any{
-                       "success": false,
-                       "message": fmt.Sprintf("Error retrieving users: %v", err),
-               })
-               return
+               return fmt.Errorf("error retrieving users: %v", err)
        }
        usersList, ok := users["users"].([]any)
        if !ok {
-               utils.JSONResponse(w, http.StatusOK, map[string]any{
-                       "success": false,
-                       "message": "Users data is not in the correct format",
-               })
-               return
+               return fmt.Errorf("users data is not in the correct format")
        }
 
        // Remove user from users list
        var newUsersList []any
+       userFound := false
        for _, u := range usersList {
                uMap, ok := u.(map[string]any)
                if !ok {
@@ -924,27 +889,67 @@ func DeleteAccount(w http.ResponseWriter, r *http.Request) {
                if id, ok := uMap["user_id"].(float64); !ok || int(id) != userID {
                        utils.Logger.Printf("Keeping user with ID %f (%d)", id, userID)
                        newUsersList = append(newUsersList, u)
+               } else {
+                       userFound = true
                }
        }
+
+       if !userFound {
+               return fmt.Errorf("user with ID %d not found", userID)
+       }
+
        users["users"] = newUsersList
 
        if err := utils.WriteUsers(users); err != nil {
-               utils.JSONResponse(w, http.StatusOK, map[string]any{
+               return fmt.Errorf("error writing users data: %v", err)
+       }
+
+       // Delete directory of the user with all his data
+       if err := utils.DeleteUserData(userID); err != nil {
+               return fmt.Errorf("error deleting user data of ID %d: %v", userID, err)
+       }
+
+       return nil
+}
+
+func DeleteAccount(w http.ResponseWriter, r *http.Request) {
+       // Get user ID from context
+       userID, ok := r.Context().Value(utils.UserIDKey).(int)
+       if !ok {
+               utils.JSONResponse(w, http.StatusUnauthorized, map[string]any{
                        "success": false,
-                       "message": fmt.Sprintf("Error writing users data: %v", err),
+                       "message": "User not authenticated",
+               })
+               return
+       }
+
+       // Parse request body
+       var req DeleteAccountRequest
+       if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+               utils.JSONResponse(w, http.StatusBadRequest, map[string]any{
+                       "success": false,
+                       "message": "Invalid request body",
                })
                return
        }
 
-       utils.UsersFileMutex.Unlock()
+       derived_key, _, err := utils.CheckPasswordForUser(userID, req.Password)
+       if err != nil || len(derived_key) == 0 {
+               utils.JSONResponse(w, http.StatusOK, map[string]any{
+                       "success":            false,
+                       "message":            "Error checking password",
+                       "password_incorrect": true,
+               })
+               return
+       }
 
-       // Delete directory of the user with all his data
-       if err := utils.DeleteUserData(userID); err != nil {
+       // Use the shared delete function
+       if err := deleteUserByID(userID); err != nil {
                utils.JSONResponse(w, http.StatusOK, map[string]any{
                        "success": false,
-                       "message": fmt.Sprintf("Error deleting user data of ID %d (account already deleted): %v", userID, err),
+                       "message": err.Error(),
                })
-               utils.Logger.Printf("Error deleting user data of ID %d (You can savely delete the directory with the same id): %v", userID, err)
+               utils.Logger.Printf("Error deleting user account ID %d: %v", userID, err)
                return
        }
 
index ea344c52a8964aeac4b48c36ef06405ea8014043..c040b863b610100df3ee84e1de7b604cd85dff96 100644 (file)
@@ -90,6 +90,11 @@ func main() {
        mux.HandleFunc("GET /logs/deleteDay", middleware.RequireAuth(handlers.DeleteDay))
        mux.HandleFunc("GET /logs/exportData", middleware.RequireAuth(handlers.ExportData))
 
+       // Admin routes
+       mux.HandleFunc("POST /admin/validate-password", handlers.ValidateAdminPassword)
+       mux.HandleFunc("POST /admin/users", handlers.GetAllUsers)
+       mux.HandleFunc("POST /admin/delete-user", handlers.DeleteUser)
+
        // Create a handler chain with Timeout, Logger and CORS middleware
        // Timeout middleware will be executed first, then Logger, then CORS
        handler := timeoutMiddleware(middleware.Logger(middleware.CORS(mux)))
index 2576bf78c8ee965a6ae1b8887a1ccd7c1791611f..6bae5612ac4c35cba961643e95ee5766eb2af7d9 100644 (file)
 <script>
        import { getTranslate } from '@tolgee/svelte';
+       import { API_URL } from '$lib/APIurl';
+       import axios from 'axios';
+       import { onDestroy, onMount } from 'svelte';
+       import { slide } from 'svelte/transition';
+       import { formatBytes } from '$lib/helpers';
+       import * as bootstrap from 'bootstrap';
+
        const { t } = getTranslate();
+
+       let adminPassword = $state('');
+       let isAdminAuthenticated = $state(false);
+
+       let adminPasswordInput = $state(null);
+       let currentUser = $state('');
+
+       // Admin authentication state
+       let isCheckingAdminAuth = $state(false);
+       let adminAuthError = $state('');
+
+       // Admin data
+       let users = $state([]);
+       let isLoadingUsers = $state(false);
+       let deleteUserId = $state(null);
+       let isDeletingUser = $state(false);
+
+       onMount(() => {
+               currentUser = localStorage.getItem('user');
+               resetAdminState();
+               adminPasswordInput.focus();
+       });
+
+       onDestroy(() => {
+               resetAdminState();
+       });
+
+       // Admin login function
+       async function loginAsAdmin() {
+               if (isCheckingAdminAuth || !adminPassword.trim()) return;
+
+               isCheckingAdminAuth = true;
+               adminAuthError = '';
+
+               try {
+                       const response = await axios.post(API_URL + '/admin/validate-password', {
+                               password: adminPassword
+                       });
+
+                       if (response.data.valid) {
+                               isAdminAuthenticated = true;
+                               loadUsers(); // Load users immediately after successful login
+                       } else {
+                               adminAuthError = $t('settings.admin.invalid_password');
+                       }
+               } catch (error) {
+                       adminAuthError = $t('settings.admin.login_error');
+               } finally {
+                       isCheckingAdminAuth = false;
+               }
+       }
+
+       // Function to make API calls with admin password
+       async function makeAdminApiCall(endpoint, data = {}) {
+               return axios.post(API_URL + endpoint, {
+                       ...data,
+                       admin_password: adminPassword
+               });
+       }
+
+       // Load all users with disk usage
+       async function loadUsers() {
+               if (isLoadingUsers || !isAdminAuthenticated) return;
+               isLoadingUsers = true;
+
+               try {
+                       const response = await makeAdminApiCall('/admin/users');
+                       users = response.data.users || [];
+               } catch (error) {
+                       console.error('Error loading users:', error);
+                       if (error.response?.status === 401) {
+                               // Admin password became invalid, reset state
+                               resetAdminState();
+                       }
+               } finally {
+                       isLoadingUsers = false;
+               }
+       }
+
+       // Delete user
+       async function deleteUser(userId, username) {
+               if (isDeletingUser) return;
+               isDeletingUser = true;
+
+               try {
+                       const response = await makeAdminApiCall('/admin/delete-user', { user_id: userId });
+                       if (response.data.success) {
+                               users = users.filter((user) => user.id !== userId);
+                               deleteUserId = null;
+
+                               // toast
+                               const toast = new bootstrap.Toast(document.getElementById('toastSuccessUserDelete'));
+                               toast.show();
+                       } else {
+                               // toast
+                               const toast = new bootstrap.Toast(document.getElementById('toastErrorUserDelete'));
+                               toast.show();
+                       }
+               } catch (error) {
+                       console.error('Error deleting user:', error);
+                       if (error.response?.status === 401) {
+                               resetAdminState();
+                       }
+               } finally {
+                       isDeletingUser = false;
+               }
+       }
+
+       // Reset admin authentication
+       function resetAdminState() {
+               isAdminAuthenticated = false;
+               adminPassword = '';
+               adminAuthError = '';
+               users = [];
+               deleteUserId = null;
+       }
+
+       function confirmDeleteUser(userId) {
+               deleteUserId = deleteUserId === userId ? null : userId;
+       }
 </script>
 
 <div class="settings-admin">
-       <h2 class="h4 mb-3">{$t('settings.admin.title')}</h2>
-       <p class="text-muted">{$t('settings.admin.placeholder')}</p>
+       {#if !isAdminAuthenticated}
+               <!-- Admin Login Form -->
+               <div
+                       class="d-flex flex-column align-items-center justify-content-center"
+                       style="min-height: 400px;"
+               >
+                       <div class="card" style="width: 100%; max-width: 400px;">
+                               <div class="card-body">
+                                       <h4 class="card-title text-center mb-4">🔒 {$t('settings.admin.login_required')}</h4>
+                                       <p class="card-text text-center text-muted mb-4">
+                                               {$t('settings.admin.login_description')}
+                                       </p>
+
+                                       <form onsubmit={loginAsAdmin}>
+                                               <div class="form-floating mb-3">
+                                                       <input
+                                                               bind:this={adminPasswordInput}
+                                                               type="password"
+                                                               class="form-control"
+                                                               id="adminPassword"
+                                                               placeholder={$t('settings.admin.password')}
+                                                               bind:value={adminPassword}
+                                                               disabled={isCheckingAdminAuth}
+                                                       />
+                                                       <label for="adminPassword">{$t('settings.admin.password')}</label>
+                                               </div>
+
+                                               {#if adminAuthError}
+                                                       <div class="alert alert-danger" transition:slide>
+                                                               {adminAuthError}
+                                                       </div>
+                                               {/if}
+
+                                               <button
+                                                       type="submit"
+                                                       class="btn btn-primary w-100"
+                                                       disabled={isCheckingAdminAuth || !adminPassword.trim()}
+                                               >
+                                                       {#if isCheckingAdminAuth}
+                                                               <span class="spinner-border spinner-border-sm me-2"></span>
+                                                       {/if}
+                                                       {$t('settings.admin.check_password')}
+                                               </button>
+                                       </form>
+                               </div>
+                       </div>
+               </div>
+       {:else}
+               <!-- Admin Panel Content -->
+               <div class="admin-authenticated" transition:slide>
+                       <h2 class="mb-4">⚙️ {$t('settings.admin.title')}</h2>
+
+                       <!-- Admin status bar -->
+                       <div
+                               class="d-flex align-items-center mb-4 p-3 bg-success bg-opacity-10 border border-success rounded"
+                       >
+                               <span class="text-success me-3">🔓 {$t('settings.admin.authenticated')}</span>
+                               <button class="btn btn-outline-secondary btn-sm ms-2" onclick={resetAdminState}>
+                                       {$t('settings.admin.logout')}
+                               </button>
+                       </div>
+
+                       <!-- User management card -->
+                       <div class="card">
+                               <div class="card-header">
+                                       <h4 class="card-title mb-0">👥 {$t('settings.admin.user_management')}</h4>
+                               </div>
+                               <div class="card-body">
+                                       {#if isLoadingUsers}
+                                               <div class="text-center p-4">
+                                                       <div class="spinner-border" role="status">
+                                                               <span class="visually-hidden">Loading...</span>
+                                                       </div>
+                                                       <p class="mt-2 text-muted">{$t('settings.admin.loading_users')}</p>
+                                               </div>
+                                       {:else if users.length === 0}
+                                               <p class="text-muted">{$t('settings.admin.no_users')}</p>
+                                       {:else}
+                                               <div class="table-responsive">
+                                                       <table class="table table-striped">
+                                                               <thead>
+                                                                       <tr>
+                                                                               <th>{$t('settings.admin.id')}</th>
+                                                                               <th>{$t('settings.admin.username')}</th>
+                                                                               <th>{$t('settings.admin.disk_usage')}</th>
+                                                                               <th>{$t('settings.admin.delete_account')}</th>
+                                                                       </tr>
+                                                               </thead>
+                                                               <tbody>
+                                                                       {#each users as user}
+                                                                               <tr>
+                                                                                       <td>{user.id}</td>
+                                                                                       <td>
+                                                                                               <strong>{user.username}</strong>
+                                                                                               {#if user.username === currentUser}
+                                                                                                       <span class="badge bg-info text-dark ms-2">
+                                                                                                               {$t('settings.admin.me')}
+                                                                                                       </span>
+                                                                                               {/if}
+                                                                                       </td>
+                                                                                       <td>{formatBytes(user.disk_usage || 0)}</td>
+                                                                                       <td>
+                                                                                               <button
+                                                                                                       class="btn btn-danger btn-sm"
+                                                                                                       onclick={() => confirmDeleteUser(user.id)}
+                                                                                               >
+                                                                                                       🗑️ {$t('settings.admin.delete')}
+                                                                                               </button>
+                                                                                               {#if user.username === currentUser}
+                                                                                                       <div class="form-text text-muted">
+                                                                                                               {$t('settings.admin.warning_delete_self')}
+                                                                                                       </div>
+                                                                                               {/if}
+
+                                                                                               {#if deleteUserId === user.id}
+                                                                                                       <div class="mt-2" transition:slide>
+                                                                                                               <div class="alert alert-danger">
+                                                                                                                       <p class="mb-2">
+                                                                                                                               <strong>{$t('settings.admin.confirm_delete')}</strong><br />
+                                                                                                                               {$t('settings.admin.delete_warning', { username: user.username })}
+                                                                                                                       </p>
+                                                                                                                       <div class="d-flex gap-2">
+                                                                                                                               <button
+                                                                                                                                       class="btn btn-secondary btn-sm"
+                                                                                                                                       onclick={() => (deleteUserId = null)}
+                                                                                                                               >
+                                                                                                                                       {$t('settings.admin.cancel')}
+                                                                                                                               </button>
+                                                                                                                               <button
+                                                                                                                                       class="btn btn-danger btn-sm"
+                                                                                                                                       onclick={() => deleteUser(user.id, user.username)}
+                                                                                                                                       disabled={isDeletingUser}
+                                                                                                                               >
+                                                                                                                                       {#if isDeletingUser}
+                                                                                                                                               <span class="spinner-border spinner-border-sm me-1"></span>
+                                                                                                                                       {/if}
+                                                                                                                                       {$t('settings.admin.delete')}
+                                                                                                                               </button>
+                                                                                                                       </div>
+                                                                                                               </div>
+                                                                                                       </div>
+                                                                                               {/if}
+                                                                                       </td>
+                                                                               </tr>
+                                                                       {/each}
+                                                               </tbody>
+                                                       </table>
+                                               </div>
+
+                                               <!-- Summary -->
+                                               <div class="mt-3">
+                                                       <div class="row">
+                                                               <div class="col-md-6">
+                                                                       <strong>{$t('settings.admin.total_users')}: </strong>
+                                                                       {users.length}
+                                                               </div>
+                                                               <div class="col-md-6">
+                                                                       <strong>{$t('settings.admin.total_disk_usage')}: </strong>
+                                                                       {formatBytes(users.reduce((sum, user) => sum + (user.disk_usage || 0), 0))}
+                                                               </div>
+                                                       </div>
+                                               </div>
+                                       {/if}
+
+                                       <div class="mt-3">
+                                               <button class="btn btn-outline-primary" onclick={loadUsers} disabled={isLoadingUsers}>
+                                                       {#if isLoadingUsers}
+                                                               <span class="spinner-border spinner-border-sm me-2"></span>
+                                                       {/if}
+                                                       {$t('settings.admin.refresh_users')}
+                                               </button>
+                                       </div>
+                               </div>
+                       </div>
+               </div>
+       {/if}
+
+       <div class="toast-container position-fixed bottom-0 end-0 p-3">
+               <div
+                       id="toastSuccessUserDelete"
+                       class="toast align-items-center text-bg-success"
+                       role="alert"
+                       aria-live="assertive"
+                       aria-atomic="true"
+               >
+                       <div class="d-flex">
+                               <div class="toast-body">
+                                       {$t('settings.statistics.toast_success_user_delete')}
+                               </div>
+                       </div>
+               </div>
+
+               <div
+                       id="toastErrorUserDelete"
+                       class="toast align-items-center text-bg-danger"
+                       role="alert"
+                       aria-live="assertive"
+                       aria-atomic="true"
+               >
+                       <div class="d-flex">
+                               <div class="toast-body">
+                                       {$t('settings.statistics.toast_error_user_delete')}
+                               </div>
+                       </div>
+               </div>
+       </div>
 </div>
 
 <style>
        .settings-admin {
                min-height: 40vh;
        }
+
+       .table th {
+               background-color: rgba(13, 110, 253, 0.1);
+       }
+
+       .admin-authenticated {
+               max-width: 100%;
+       }
+
+       .card-title {
+               color: var(--bs-primary);
+       }
 </style>
index 6136ecc2157127c9542af76337cfa1c179aaa7fd..3fbc414e91dc2810cb811ac55c14bbbdc22ea5d5 100644 (file)
        const tolgee = getTolgee(['language']);
 
        // Raw day stats from backend
-       let dayStats = [];
+       let dayStats = $state([]);
 
        // Derived years list & selected year
-       let years = [];
-       let selectedYear = new Date().getFullYear();
+       let years = $state([]);
+       let selectedYear = $state(new Date().getFullYear());
 
        // Heatmap data (weeks -> days)
-       let weeks = [];
+       let weeks = $state([]);
        let maxWordCountYear = 0;
        let minWordCountYear = 0; // smallest > 0 value
        // Filter stats for selected year
        // Iterate all days of the year
-       let legendRanges = [];
+       let legendRanges = $state([]);
        // Bootstrap tooltip support
-       let heatmapEl;
+       let heatmapEl = $state(null);
        let dayMap = new Map(); // key -> day data
 
        const buildYearData = () => {
                        day: '2-digit'
                });
 
+       let errorOnLoading = $state(false);
+       let isLoading = $state(false);
        onMount(async () => {
                try {
+                       isLoading = true;
                        const resp = await axios.get(API_URL + '/users/statistics');
+                       isLoading = false;
                        dayStats = resp.data;
                        // Normalize key names to camelCase if backend sent lower-case
                        dayStats = dayStats.map((d) => ({
                        if (!years.includes(selectedYear) && years.length) selectedYear = years[years.length - 1];
                        buildYearData();
                } catch (e) {
+                       isLoading = false;
                        console.error('Failed loading statistics', e);
+                       errorOnLoading = true;
                }
        });
 
 <div class="settings-stats">
        <h2 class=" mb-3">{$t('settings.statistics.title')}</h2>
 
-       {#if years.length === 0}
-               <div class="spinner-border" role="status">
-                       <span class="visually-hidden">Loading...</span>
+       {#if errorOnLoading}
+               <div class="text-center">
+                       <p class="text-danger">{$t('settings.statistics.error_loading_data')}</p>
                </div>
-       {:else}
+       {/if}
+
+       {#if isLoading}
+               <div class="text-center">
+                       <div class="spinner-border" role="status">
+                               <span class="visually-hidden">Loading...</span>
+                       </div>
+                       <p class="mt-2 text-muted">{$t('settings.statistics.loading_data')}</p>
+               </div>
+       {:else if years.length !== 0}
                <div class="year-selector d-flex align-items-center gap-2 mb-3 flex-wrap">
                        <button
                                class="btn btn-sm btn-outline-secondary"
-                               on:click={prevYear}
+                               onclick={prevYear}
                                disabled={years.indexOf(selectedYear) === 0}
                                aria-label="previous year">«</button
                        >
                        <select
                                class="form-select form-select-sm year-dropdown"
                                bind:value={selectedYear}
-                               on:change={(e) => selectYear(+e.target.value)}
+                               onchange={(e) => selectYear(+e.target.value)}
                        >
                                {#each years as y}
                                        <option value={y}>{y}</option>
                        </select>
                        <button
                                class="btn btn-sm btn-outline-secondary"
-                               on:click={nextYear}
+                               onclick={nextYear}
                                disabled={years.indexOf(selectedYear) === years.length - 1}
                                aria-label="next year">»</button
                        >
                                })()}
                        </li>
                </ul>
+       {:else if years.length === 0}
+               <p class="text-info">{$t('settings.statistics.no_data')}</p>
        {/if}
 </div>
 
index d48ea95838bd1203a119717162651cd7653cc601..68c6d4df025b922910d3fa08ce29ebc7dc3176f0 100644 (file)
@@ -1,7 +1,7 @@
 import {redirect} from '@sveltejs/kit'
 
 export const load = () => {
-  const user = JSON.parse(localStorage.getItem('user'));
+  const user = localStorage.getItem('user');
                if (!user) {
                        throw redirect(307, '/login');
                }
index d48ea95838bd1203a119717162651cd7653cc601..68c6d4df025b922910d3fa08ce29ebc7dc3176f0 100644 (file)
@@ -1,7 +1,7 @@
 import {redirect} from '@sveltejs/kit'
 
 export const load = () => {
-  const user = JSON.parse(localStorage.getItem('user'));
+  const user = localStorage.getItem('user');
                if (!user) {
                        throw redirect(307, '/login');
                }
index 314535a39b9618f6c26fad5007f11f48b52776d1..a2ba50e081a6d056094bb89d358d820b41c97e6b 100644 (file)
 
                                        handleMigrationProgress(response.data.username);
                                } else {
-                                       localStorage.setItem('user', JSON.stringify(response.data.username));
+                                       localStorage.setItem('user', response.data.username);
                                        goto('/write');
                                }
                        })
git clone https://git.99rst.org/PROJECT