Initial commit
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
BIN
app/.DS_Store
vendored
Normal file
BIN
app/.DS_Store
vendored
Normal file
Binary file not shown.
9
app/.env
Normal file
9
app/.env
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
DB_USER=mannpatel
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_NAME=poll_database
|
||||||
|
DB_PASSWORD=admin
|
||||||
|
DB_PORT=5432
|
||||||
|
PORT=3000
|
||||||
|
JWT_SECRET=r683gi77ft92fg923keyfasdfas2r123
|
||||||
|
ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
NODE_ENV=development
|
||||||
9
app/go.mod
Normal file
9
app/go.mod
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module github.com/patel-mann/poll-system/app
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
golang.org/x/crypto v0.41.0
|
||||||
|
)
|
||||||
6
app/go.sum
Normal file
6
app/go.sum
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
BIN
app/internal/.DS_Store
vendored
Normal file
BIN
app/internal/.DS_Store
vendored
Normal file
Binary file not shown.
402
app/internal/handlers/admin.go
Normal file
402
app/internal/handlers/admin.go
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// View model for listing/assigning schedules
|
||||||
|
type AssignmentVM struct {
|
||||||
|
ID int
|
||||||
|
VolunteerID int
|
||||||
|
VolunteerName string
|
||||||
|
AddressID int
|
||||||
|
Address string
|
||||||
|
Date string // YYYY-MM-DD (for input[type=date])
|
||||||
|
AppointmentTime string // HH:MM
|
||||||
|
VisitedValidated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET + POST in one handler:
|
||||||
|
// - GET: show assignments + form to assign
|
||||||
|
// - POST: create a new assignment
|
||||||
|
func AdminAssignmentsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
if err := createAssignmentFromForm(r); err != nil {
|
||||||
|
log.Println("create assignment error:", err)
|
||||||
|
volunteers, _ := fetchVolunteers()
|
||||||
|
addresses, _ := fetchAddresses()
|
||||||
|
assignments, _ := fetchAssignments()
|
||||||
|
|
||||||
|
utils.Render(w, "schedual/assignments.html", map[string]interface{}{
|
||||||
|
"Title": "Admin — Assign Addresses",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"ActiveSection": "admin_assignments",
|
||||||
|
"Volunteers": volunteers,
|
||||||
|
"Addresses": addresses,
|
||||||
|
"Assignments": assignments,
|
||||||
|
"Error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/assignments", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: fetch volunteers, addresses, and existing assignments
|
||||||
|
volunteers, err := fetchVolunteers()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("fetch volunteers error:", err)
|
||||||
|
http.Error(w, "Failed to load volunteers", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addresses, err := fetchAddresses()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("fetch addresses error:", err)
|
||||||
|
http.Error(w, "Failed to load addresses", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assignments, err := fetchAssignments()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("fetch assignments error:", err)
|
||||||
|
http.Error(w, "Failed to load assignments", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Render(w, "assignments.html", map[string]interface{}{
|
||||||
|
"Title": "Admin — Assign Addresses",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"ActiveSection": "admin_assignments",
|
||||||
|
"Volunteers": volunteers,
|
||||||
|
"Addresses": addresses,
|
||||||
|
"Assignments": assignments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET (edit form) + POST (update/delete)
|
||||||
|
func AdminAssignmentEditHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.URL.Query().Get("id")
|
||||||
|
id, _ := strconv.Atoi(idStr)
|
||||||
|
if id <= 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
action := r.FormValue("action")
|
||||||
|
switch action {
|
||||||
|
case "delete":
|
||||||
|
if err := deleteAssignment(id); err != nil {
|
||||||
|
log.Println("delete assignment error:", err)
|
||||||
|
http.Error(w, "Failed to delete assignment", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/assignments", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
case "update":
|
||||||
|
if err := updateAssignmentFromForm(id, r); err != nil {
|
||||||
|
log.Println("update assignment error:", err)
|
||||||
|
vm, _ := fetchAssignmentByID(id)
|
||||||
|
volunteers, _ := fetchVolunteers()
|
||||||
|
addresses, _ := fetchAddresses()
|
||||||
|
|
||||||
|
utils.Render(w, "assignment_edit.html", map[string]interface{}{
|
||||||
|
"Title": "Edit Assignment",
|
||||||
|
"Assignment": vm,
|
||||||
|
"Volunteers": volunteers,
|
||||||
|
"Addresses": addresses,
|
||||||
|
"Error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/admin/assignments", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
http.Error(w, "Unknown action", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET edit
|
||||||
|
vm, err := fetchAssignmentByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("fetch assignment by ID error:", err)
|
||||||
|
http.Error(w, "Failed to load assignment", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
volunteers, err := fetchVolunteers()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("fetch volunteers error:", err)
|
||||||
|
http.Error(w, "Failed to load volunteers", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addresses, err := fetchAddresses()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("fetch addresses error:", err)
|
||||||
|
http.Error(w, "Failed to load addresses", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Render(w, "assignment_edit.html", map[string]interface{}{
|
||||||
|
"Title": "Edit Assignment",
|
||||||
|
"Assignment": vm,
|
||||||
|
"Volunteers": volunteers,
|
||||||
|
"Addresses": addresses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Helpers -----
|
||||||
|
|
||||||
|
func createAssignmentFromForm(r *http.Request) error {
|
||||||
|
volID, _ := strconv.Atoi(r.FormValue("volunteer_id"))
|
||||||
|
addrID, _ := strconv.Atoi(r.FormValue("address_id"))
|
||||||
|
dateStr := r.FormValue("date")
|
||||||
|
timeStr := r.FormValue("appointment_time")
|
||||||
|
|
||||||
|
if volID <= 0 || addrID <= 0 || dateStr == "" || timeStr == "" {
|
||||||
|
return errors.New("please fill all required fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
|
||||||
|
return errors.New("invalid date format")
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("15:04", timeStr); err != nil {
|
||||||
|
return errors.New("invalid time format")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := models.DB.Exec(`
|
||||||
|
INSERT INTO schedual (user_id, address_id, appointment_date, appointment_time, created_at, updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,NOW(),NOW())
|
||||||
|
`, volID, addrID, dateStr, timeStr)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println("database insert error:", err)
|
||||||
|
return errors.New("failed to create assignment")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAssignmentFromForm(id int, r *http.Request) error {
|
||||||
|
volID, _ := strconv.Atoi(r.FormValue("volunteer_id"))
|
||||||
|
addrID, _ := strconv.Atoi(r.FormValue("address_id"))
|
||||||
|
dateStr := r.FormValue("date")
|
||||||
|
timeStr := r.FormValue("appointment_time")
|
||||||
|
|
||||||
|
if volID <= 0 || addrID <= 0 || dateStr == "" || timeStr == "" {
|
||||||
|
return errors.New("please fill all required fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
|
||||||
|
return errors.New("invalid date format")
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("15:04", timeStr); err != nil {
|
||||||
|
return errors.New("invalid time format")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := models.DB.Exec(`
|
||||||
|
UPDATE schedual
|
||||||
|
SET user_id=$1, address_id=$2, appointment_date=$3, appointment_time=$4, updated_at=NOW()
|
||||||
|
WHERE schedual_id=$5
|
||||||
|
`, volID, addrID, dateStr, timeStr, id)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println("database update error:", err)
|
||||||
|
return errors.New("failed to update assignment")
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return errors.New("assignment not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAssignment(id int) error {
|
||||||
|
result, err := models.DB.Exec(`DELETE FROM schedual WHERE schedual_id=$1`, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("database delete error:", err)
|
||||||
|
return errors.New("failed to delete assignment")
|
||||||
|
}
|
||||||
|
rowsAffected, _ := result.RowsAffected()
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return errors.New("assignment not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch volunteers
|
||||||
|
type VolunteerPick struct {
|
||||||
|
ID int
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchVolunteers() ([]VolunteerPick, error) {
|
||||||
|
rows, err := models.DB.Query(`
|
||||||
|
SELECT users_id, first_name, last_name, email
|
||||||
|
FROM "user"
|
||||||
|
WHERE role='volunteer'
|
||||||
|
ORDER BY first_name, last_name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []VolunteerPick
|
||||||
|
for rows.Next() {
|
||||||
|
var v VolunteerPick
|
||||||
|
if err := rows.Scan(&v.ID, &v.FirstName, &v.LastName, &v.Email); err != nil {
|
||||||
|
log.Println("fetchVolunteers scan:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch addresses
|
||||||
|
type AddressPick struct {
|
||||||
|
ID int
|
||||||
|
Label string
|
||||||
|
VisitedValidated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAddresses() ([]AddressPick, error) {
|
||||||
|
rows, err := models.DB.Query(`
|
||||||
|
SELECT
|
||||||
|
address_id,
|
||||||
|
address,
|
||||||
|
street_name,
|
||||||
|
street_type,
|
||||||
|
street_quadrant,
|
||||||
|
house_number,
|
||||||
|
house_alpha,
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
visited_validated
|
||||||
|
FROM address_database
|
||||||
|
ORDER BY address_id DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []AddressPick
|
||||||
|
for rows.Next() {
|
||||||
|
var addr models.AddressDatabase
|
||||||
|
if err := rows.Scan(
|
||||||
|
&addr.AddressID,
|
||||||
|
&addr.Address,
|
||||||
|
&addr.StreetName,
|
||||||
|
&addr.StreetType,
|
||||||
|
&addr.StreetQuadrant,
|
||||||
|
&addr.HouseNumber,
|
||||||
|
&addr.HouseAlpha,
|
||||||
|
&addr.Longitude,
|
||||||
|
&addr.Latitude,
|
||||||
|
&addr.VisitedValidated,
|
||||||
|
); err != nil {
|
||||||
|
log.Println("fetchAddresses scan:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
label := addr.Address
|
||||||
|
if label == "" {
|
||||||
|
label = addr.HouseNumber
|
||||||
|
if addr.StreetName != "" {
|
||||||
|
if label != "" {
|
||||||
|
label += " "
|
||||||
|
}
|
||||||
|
label += addr.StreetName
|
||||||
|
}
|
||||||
|
if addr.StreetType != "" {
|
||||||
|
label += " " + addr.StreetType
|
||||||
|
}
|
||||||
|
if addr.StreetQuadrant != "" {
|
||||||
|
label += " " + addr.StreetQuadrant
|
||||||
|
}
|
||||||
|
if addr.HouseAlpha != nil {
|
||||||
|
label += " " + *addr.HouseAlpha
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append(out, AddressPick{
|
||||||
|
ID: addr.AddressID,
|
||||||
|
Label: label,
|
||||||
|
VisitedValidated: addr.VisitedValidated,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this missing function
|
||||||
|
func fetchAssignments() ([]AssignmentVM, error) {
|
||||||
|
rows, err := models.DB.Query(`
|
||||||
|
SELECT
|
||||||
|
s.schedual_id,
|
||||||
|
u.users_id,
|
||||||
|
COALESCE(u.first_name,'') || ' ' || COALESCE(u.last_name,'') AS volunteer_name,
|
||||||
|
a.address_id,
|
||||||
|
COALESCE(a.address,'') AS address,
|
||||||
|
s.appointment_date,
|
||||||
|
s.appointment_time
|
||||||
|
FROM schedual s
|
||||||
|
JOIN "user" u ON u.users_id = s.user_id
|
||||||
|
JOIN address_database a ON a.address_id = s.address_id
|
||||||
|
ORDER BY s.appointment_date DESC, s.appointment_time DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var assignments []AssignmentVM
|
||||||
|
for rows.Next() {
|
||||||
|
var vm AssignmentVM
|
||||||
|
if err := rows.Scan(&vm.ID, &vm.VolunteerID, &vm.VolunteerName, &vm.AddressID, &vm.Address,
|
||||||
|
&vm.Date, &vm.AppointmentTime); err != nil {
|
||||||
|
log.Println("fetchAssignments scan:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assignments = append(assignments, vm)
|
||||||
|
}
|
||||||
|
return assignments, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAssignmentByID(id int) (AssignmentVM, error) {
|
||||||
|
var vm AssignmentVM
|
||||||
|
err := models.DB.QueryRow(`
|
||||||
|
SELECT
|
||||||
|
s.schedual_id,
|
||||||
|
u.users_id,
|
||||||
|
COALESCE(u.first_name,'') || ' ' || COALESCE(u.last_name,'') AS volunteer_name,
|
||||||
|
a.address_id,
|
||||||
|
COALESCE(a.address,'') AS address,
|
||||||
|
s.appointment_date,
|
||||||
|
s.appointment_time
|
||||||
|
FROM schedual s
|
||||||
|
JOIN "user" u ON u.users_id = s.user_id
|
||||||
|
JOIN address_database a ON a.address_id = s.address_id
|
||||||
|
WHERE s.schedual_id = $1
|
||||||
|
`, id).Scan(&vm.ID, &vm.VolunteerID, &vm.VolunteerName, &vm.AddressID, &vm.Address,
|
||||||
|
&vm.Date, &vm.AppointmentTime)
|
||||||
|
|
||||||
|
return vm, err
|
||||||
|
}
|
||||||
184
app/internal/handlers/admin_addresses.go
Normal file
184
app/internal/handlers/admin_addresses.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaginationInfo holds pagination metadata
|
||||||
|
type PaginationInfo struct {
|
||||||
|
CurrentPage int
|
||||||
|
TotalPages int
|
||||||
|
TotalRecords int
|
||||||
|
PageSize int
|
||||||
|
HasPrevious bool
|
||||||
|
HasNext bool
|
||||||
|
StartRecord int
|
||||||
|
EndRecord int
|
||||||
|
PreviousPage int
|
||||||
|
NextPage int
|
||||||
|
FirstPage int
|
||||||
|
LastPage int
|
||||||
|
PageNumbers []PageNumber
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageNumber struct {
|
||||||
|
Number int
|
||||||
|
IsCurrent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get pagination parameters from query string
|
||||||
|
pageStr := r.URL.Query().Get("page")
|
||||||
|
pageSizeStr := r.URL.Query().Get("pageSize")
|
||||||
|
|
||||||
|
// Default values
|
||||||
|
page := 1
|
||||||
|
pageSize := 20 // Default page size
|
||||||
|
|
||||||
|
// Parse page number
|
||||||
|
if pageStr != "" {
|
||||||
|
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||||
|
page = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse page size
|
||||||
|
if pageSizeStr != "" {
|
||||||
|
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 && ps <= 100 {
|
||||||
|
pageSize = ps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate offset
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
// Get total count first
|
||||||
|
var totalRecords int
|
||||||
|
err := models.DB.QueryRow(`SELECT COUNT(*) FROM "address_database"`).Scan(&totalRecords)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Count query error:", err)
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate pagination info
|
||||||
|
totalPages := (totalRecords + pageSize - 1) / pageSize
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure current page is within bounds
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
offset = (page - 1) * pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
rows, err := models.DB.Query(`
|
||||||
|
SELECT address_id, address, street_name, street_type,
|
||||||
|
street_quadrant, house_number, house_alpha, longitude,
|
||||||
|
latitude, visited_validated
|
||||||
|
FROM "address_database"
|
||||||
|
WHERE street_quadrant = 'ne'
|
||||||
|
ORDER BY address_id
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
`, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Query error:", err)
|
||||||
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var addresses []models.AddressDatabase
|
||||||
|
for rows.Next() {
|
||||||
|
var a models.AddressDatabase
|
||||||
|
err := rows.Scan(
|
||||||
|
&a.AddressID,
|
||||||
|
&a.Address,
|
||||||
|
&a.StreetName,
|
||||||
|
&a.StreetType,
|
||||||
|
&a.StreetQuadrant,
|
||||||
|
&a.HouseNumber,
|
||||||
|
&a.HouseAlpha,
|
||||||
|
&a.Longitude,
|
||||||
|
&a.Latitude,
|
||||||
|
&a.VisitedValidated,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Scan error:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addresses = append(addresses, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate start and end record numbers for display
|
||||||
|
startRecord := offset + 1
|
||||||
|
endRecord := offset + len(addresses)
|
||||||
|
if totalRecords == 0 {
|
||||||
|
startRecord = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate page numbers for pagination controls
|
||||||
|
pageNumbers := generatePageNumbers(page, totalPages)
|
||||||
|
|
||||||
|
pagination := PaginationInfo{
|
||||||
|
CurrentPage: page,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
TotalRecords: totalRecords,
|
||||||
|
PageSize: pageSize,
|
||||||
|
HasPrevious: page > 1,
|
||||||
|
HasNext: page < totalPages,
|
||||||
|
StartRecord: startRecord,
|
||||||
|
EndRecord: endRecord,
|
||||||
|
PreviousPage: page - 1,
|
||||||
|
NextPage: page + 1,
|
||||||
|
FirstPage: 1,
|
||||||
|
LastPage: totalPages,
|
||||||
|
PageNumbers: pageNumbers,
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Render(w, "address/address.html", map[string]interface{}{
|
||||||
|
"Title": "Addresses",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"ShowAdminNav": true,
|
||||||
|
"ActiveSection": "address", // Add this line
|
||||||
|
"Addresses": addresses,
|
||||||
|
"Role": "admin",
|
||||||
|
"Pagination": pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePageNumbers(currentPage, totalPages int) []PageNumber {
|
||||||
|
var pageNumbers []PageNumber
|
||||||
|
|
||||||
|
// Generate page numbers to show (max 7 pages)
|
||||||
|
start := currentPage - 3
|
||||||
|
end := currentPage + 3
|
||||||
|
|
||||||
|
if start < 1 {
|
||||||
|
end += 1 - start
|
||||||
|
start = 1
|
||||||
|
}
|
||||||
|
if end > totalPages {
|
||||||
|
start -= end - totalPages
|
||||||
|
end = totalPages
|
||||||
|
}
|
||||||
|
if start < 1 {
|
||||||
|
start = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
pageNumbers = append(pageNumbers, PageNumber{
|
||||||
|
Number: i,
|
||||||
|
IsCurrent: i == currentPage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageNumbers
|
||||||
|
}
|
||||||
81
app/internal/handlers/admin_dashboard.go
Normal file
81
app/internal/handlers/admin_dashboard.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
currentAdminID := r.Context().Value("user_id").(int)
|
||||||
|
|
||||||
|
role, _ := r.Context().Value("uesr_role").(int)
|
||||||
|
|
||||||
|
var volunteerCount int
|
||||||
|
var totalDonations float64
|
||||||
|
var validatedCount int
|
||||||
|
var housesLeftPercent float64
|
||||||
|
|
||||||
|
// 1. Count volunteers assigned to this admin
|
||||||
|
err := models.DB.QueryRow(`
|
||||||
|
SELECT COUNT(av.volunteer_id)
|
||||||
|
FROM admin_volunteers av
|
||||||
|
WHERE av.admin_id = $1 AND av.is_active = TRUE;
|
||||||
|
`, currentAdminID).Scan(&volunteerCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Volunteer query error:", err)
|
||||||
|
volunteerCount = 0 // Set default value on error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Total donations from polls
|
||||||
|
err = models.DB.QueryRow(`
|
||||||
|
SELECT COALESCE(SUM(amount_donated), 0)
|
||||||
|
FROM poll;
|
||||||
|
`).Scan(&totalDonations)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Donation query error:", err)
|
||||||
|
totalDonations = 0 // Set default value on error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Count validated addresses
|
||||||
|
err = models.DB.QueryRow(`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM address_database
|
||||||
|
WHERE visited_validated = TRUE;
|
||||||
|
`).Scan(&validatedCount)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Validated addresses query error:", err)
|
||||||
|
validatedCount = 0 // Set default value on error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Calculate percentage of houses left to visit
|
||||||
|
err = models.DB.QueryRow(`
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(*) = 0 THEN 0
|
||||||
|
ELSE ROUND(
|
||||||
|
(COUNT(*) FILTER (WHERE visited_validated = FALSE)::numeric / COUNT(*)::numeric) * 100, 2
|
||||||
|
)
|
||||||
|
END
|
||||||
|
FROM address_database;
|
||||||
|
`).Scan(&housesLeftPercent)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Houses left query error:", err)
|
||||||
|
housesLeftPercent = 0 // Set default value on error
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Render(w, "dashboard/dashboard.html", map[string]interface{}{
|
||||||
|
"Title": "Admin Dashboard",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"VolunteerCount": volunteerCount,
|
||||||
|
"TotalDonations": totalDonations,
|
||||||
|
"ValidatedCount": validatedCount,
|
||||||
|
"HousesLeftPercent": housesLeftPercent,
|
||||||
|
"ShowAdminNav": true,
|
||||||
|
"Role": role,
|
||||||
|
"ActiveSection": "dashboard",
|
||||||
|
})
|
||||||
|
}
|
||||||
160
app/internal/handlers/admin_post.go
Normal file
160
app/internal/handlers/admin_post.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// Updated admin_post.go with better image handling
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PostsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := r.Context().Value("user_id").(int)
|
||||||
|
role := r.Context().Value("user_role").(int)
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
// Parse multipart form
|
||||||
|
err := r.ParseMultipartForm(10 << 20) // 10MB max
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error parsing form: %v\n", err)
|
||||||
|
http.Error(w, "Invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content := r.FormValue("content")
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
http.Error(w, "Content cannot be empty", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var imagePath string
|
||||||
|
file, handler, err := r.FormFile("image")
|
||||||
|
if err == nil && file != nil {
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
allowedTypes := map[string]bool{
|
||||||
|
".jpg": true,
|
||||||
|
".jpeg": true,
|
||||||
|
".png": true,
|
||||||
|
".gif": true,
|
||||||
|
".webp": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(handler.Filename))
|
||||||
|
if !allowedTypes[ext] {
|
||||||
|
http.Error(w, "Invalid file type. Only images allowed.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure uploads folder exists
|
||||||
|
uploadDir := "uploads"
|
||||||
|
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||||
|
fmt.Printf("Error creating upload directory: %v\n", err)
|
||||||
|
http.Error(w, "Unable to create upload directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unique filename
|
||||||
|
filename := fmt.Sprintf("%d_%d%s", userID, time.Now().UnixNano(), ext)
|
||||||
|
savePath := filepath.Join(uploadDir, filename)
|
||||||
|
|
||||||
|
out, err := os.Create(savePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error creating file: %v\n", err)
|
||||||
|
http.Error(w, "Unable to save file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(out, file)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error copying file: %v\n", err)
|
||||||
|
http.Error(w, "Failed to save file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save path relative to the static route
|
||||||
|
imagePath = "/uploads/" + filename
|
||||||
|
fmt.Printf("Image saved at: %s\n", imagePath)
|
||||||
|
} else if err != http.ErrMissingFile {
|
||||||
|
fmt.Printf("Error getting file: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert post
|
||||||
|
_, err = models.DB.Exec(`INSERT INTO post (author_id, content, image_url) VALUES ($1, $2, $3)`,
|
||||||
|
userID, content, imagePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Database error: %v\n", err)
|
||||||
|
http.Error(w, "Failed to create post", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Post created successfully with image: %s\n", imagePath)
|
||||||
|
http.Redirect(w, r, "/posts", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET request: fetch posts
|
||||||
|
rows, err := models.DB.Query(`
|
||||||
|
SELECT p.post_id, p.author_id, u.first_name || ' ' || u.last_name AS author_name,
|
||||||
|
p.content, COALESCE(p.image_url, '') as image_url, p.created_at
|
||||||
|
FROM post p
|
||||||
|
JOIN users u ON p.author_id = u.user_id
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Database query error: %v\n", err)
|
||||||
|
http.Error(w, "Failed to fetch posts", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var posts []models.Post
|
||||||
|
for rows.Next() {
|
||||||
|
var p models.Post
|
||||||
|
err := rows.Scan(&p.PostID, &p.AuthorID, &p.AuthorName, &p.Content, &p.ImageURL, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Row scan error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
posts = append(posts, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cache busting parameter to image URLs
|
||||||
|
for i := range posts {
|
||||||
|
if posts[i].ImageURL != "" {
|
||||||
|
posts[i].ImageURL += "?t=" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
fmt.Printf("Post %d image URL: %s\n", posts[i].PostID, posts[i].ImageURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get navigation flags
|
||||||
|
showAdminNav, showVolunteerNav := getNavFlags(role)
|
||||||
|
|
||||||
|
fmt.Printf("Rendering %d posts\n", len(posts))
|
||||||
|
|
||||||
|
utils.Render(w, "posts.html", map[string]interface{}{
|
||||||
|
"Title": "Posts",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"ShowAdminNav": showAdminNav,
|
||||||
|
"ShowVolunteerNav": showVolunteerNav,
|
||||||
|
"Posts": posts,
|
||||||
|
"ActiveSection": "posts",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function (add this to your main.go if not already there)
|
||||||
|
func getNavFlags(role int) (bool, bool) {
|
||||||
|
showAdminNav := role == 1 // Admin role
|
||||||
|
showVolunteerNav := role == 3 // Volunteer role
|
||||||
|
return showAdminNav, showVolunteerNav
|
||||||
|
}
|
||||||
219
app/internal/handlers/admin_voluteers.go
Normal file
219
app/internal/handlers/admin_voluteers.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func VolunteerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO: Replace this with actual session/jwt extraction
|
||||||
|
currentAdminID := r.Context().Value("user_id").(int)
|
||||||
|
|
||||||
|
rows, err := models.DB.Query(`
|
||||||
|
SELECT u.user_id, u.email, u.role_id, u.first_name, u.last_name, u.phone
|
||||||
|
FROM "users" u
|
||||||
|
JOIN admin_volunteers av ON u.user_id = av.volunteer_id
|
||||||
|
WHERE av.admin_id = $1 AND ( u.role_id = 3 OR u.role_id = 2 )
|
||||||
|
`, currentAdminID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Query error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var user []models.User
|
||||||
|
for rows.Next() {
|
||||||
|
var b models.User
|
||||||
|
err := rows.Scan(&b.UserID, &b.Email, &b.RoleID, &b.FirstName, &b.LastName, &b.Phone)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Scan error:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user = append(user, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Render(w, "volunteer/volunteer.html", map[string]interface{}{
|
||||||
|
"Title": "Assigned Volunteers",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"ShowAdminNav": true,
|
||||||
|
"Users": user,
|
||||||
|
"ActiveSection": "volunteer",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func EditVolunteerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
volunteerID := r.URL.Query().Get("id")
|
||||||
|
var user models.User
|
||||||
|
err := models.DB.QueryRow(`
|
||||||
|
SELECT user_id, email, role_id, first_name, last_name, phone
|
||||||
|
FROM "users"
|
||||||
|
WHERE user_id = $1 AND (role_id = 3 OR role_id = 2)
|
||||||
|
`, volunteerID).Scan(&user.UserID, &user.Email, &user.RoleID, &user.FirstName, &user.LastName, &user.Phone)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Volunteer not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Render(w, "volunteer/edit_volunteer.html", map[string]interface{}{
|
||||||
|
"Title": "Edit Volunteer",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"ShowAdminNav": true,
|
||||||
|
"Volunteer": user,
|
||||||
|
"ActiveSection": "volunteer",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
volunteerID := r.FormValue("user_id")
|
||||||
|
firstName := r.FormValue("first_name")
|
||||||
|
lastName := r.FormValue("last_name")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
phone := r.FormValue("phone")
|
||||||
|
roleID := r.FormValue("role_id")
|
||||||
|
|
||||||
|
rid, err := strconv.Atoi(roleID)
|
||||||
|
if err != nil || (rid != 2 && rid != 3) {
|
||||||
|
http.Error(w, "Invalid role selection", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = models.DB.Exec(`
|
||||||
|
UPDATE "users"
|
||||||
|
SET first_name = $1, last_name = $2, email = $3, phone = $4, role_id = $5
|
||||||
|
WHERE user_id = $6
|
||||||
|
`, firstName, lastName, email, phone, rid, volunteerID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Print(err)
|
||||||
|
http.Error(w, "Update failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/volunteers", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamLead struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Volunteers []User
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamBuilderData struct {
|
||||||
|
TeamLeads []TeamLead
|
||||||
|
UnassignedVolunteers []User
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func TeamBuilderHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// GET request: show team leads and unassigned volunteers
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
var teamLeads []TeamLead
|
||||||
|
var unassignedVolunteers []User
|
||||||
|
|
||||||
|
// Get all team leads (role_id = 2)
|
||||||
|
tlRows, err := models.DB.Query(`SELECT user_id, first_name || ' ' || last_name AS name FROM users WHERE role_id = 2`)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error fetching team leads", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tlRows.Close()
|
||||||
|
for tlRows.Next() {
|
||||||
|
var tl TeamLead
|
||||||
|
tlRows.Scan(&tl.ID, &tl.Name)
|
||||||
|
|
||||||
|
// Get assigned volunteers for this team lead
|
||||||
|
vRows, _ := models.DB.Query(`SELECT u.user_id, u.first_name || ' ' || u.last_name AS name
|
||||||
|
FROM users u
|
||||||
|
JOIN team t ON u.user_id = t.volunteer_id
|
||||||
|
WHERE t.team_lead_id = $1`, tl.ID)
|
||||||
|
|
||||||
|
for vRows.Next() {
|
||||||
|
var vol User
|
||||||
|
vRows.Scan(&vol.ID, &vol.Name)
|
||||||
|
tl.Volunteers = append(tl.Volunteers, vol)
|
||||||
|
}
|
||||||
|
|
||||||
|
teamLeads = append(teamLeads, tl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unassigned volunteers (role_id = 3)
|
||||||
|
vRows, _ := models.DB.Query(`SELECT user_id, first_name || ' ' || last_name AS name
|
||||||
|
FROM users
|
||||||
|
WHERE role_id = 3
|
||||||
|
AND user_id NOT IN (SELECT volunteer_id FROM team)`)
|
||||||
|
for vRows.Next() {
|
||||||
|
var vol User
|
||||||
|
vRows.Scan(&vol.ID, &vol.Name)
|
||||||
|
unassignedVolunteers = append(unassignedVolunteers, vol)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Render(w, "volunteer/team_builder.html", map[string]interface{}{
|
||||||
|
"Title": "Team Builder",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"ShowAdminNav": true,
|
||||||
|
"TeamLeads": teamLeads,
|
||||||
|
"UnassignedVolunteers": unassignedVolunteers,
|
||||||
|
"ActiveSection": "team_builder",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST request: assign volunteer to a team lead
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
volunteerID, err := strconv.Atoi(r.FormValue("volunteer_id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid volunteer ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
teamLeadID, err := strconv.Atoi(r.FormValue("team_lead_id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid team lead ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = models.DB.Exec(`INSERT INTO team (volunteer_id, team_lead_id) VALUES ($1, $2)`, volunteerID, teamLeadID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
http.Error(w, "Failed to assign volunteer", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/team_builder", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//assign volunterr the title of team_leader
|
||||||
|
//Team View
|
||||||
|
//edit volnteer data
|
||||||
|
//
|
||||||
367
app/internal/handlers/login.go
Normal file
367
app/internal/handlers/login.go
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jwtKey = []byte("your-secret-key") //TODO: Move to env/config
|
||||||
|
|
||||||
|
// Helper function to get redirect URL based on role
|
||||||
|
func getDefaultRedirectURL(role int) string {
|
||||||
|
switch role {
|
||||||
|
case 1: // Admin
|
||||||
|
return "/dashboard"
|
||||||
|
case 2: // Volunteer
|
||||||
|
return "/volunteer/dashboard"
|
||||||
|
case 3: // Volunteer
|
||||||
|
return "/volunteer/dashboard"
|
||||||
|
default:
|
||||||
|
return "/" // Fallback to login page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to render error pages with consistent data
|
||||||
|
func renderLoginError(w http.ResponseWriter, errorMsg string) {
|
||||||
|
utils.Render(w, "login.html", map[string]interface{}{
|
||||||
|
"Error": errorMsg,
|
||||||
|
"Title": "Login",
|
||||||
|
"IsAuthenticated": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderRegisterError(w http.ResponseWriter, errorMsg string) {
|
||||||
|
utils.Render(w, "register.html", map[string]interface{}{
|
||||||
|
"Error": errorMsg,
|
||||||
|
"Title": "Register",
|
||||||
|
"IsAuthenticated": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create and sign JWT token
|
||||||
|
func createJWTToken(userID, role int) (string, time.Time, error) {
|
||||||
|
expirationTime := time.Now().Add(12 * time.Hour)
|
||||||
|
claims := &models.Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString(jwtKey)
|
||||||
|
return tokenString, expirationTime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to set session cookie
|
||||||
|
func setSessionCookie(w http.ResponseWriter, tokenString string, expirationTime time.Time) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: tokenString,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false, // Set to true in production with HTTPS
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
Expires: expirationTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to clear session cookie
|
||||||
|
func clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "session",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: false, // Set to true in production with HTTPS
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// func LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// utils.Render(w, "login.html", map[string]interface{}{
|
||||||
|
// "Title": "Login",
|
||||||
|
// "IsAuthenticated": false,
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
|
func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := r.FormValue("email")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
// Input validation
|
||||||
|
if email == "" || password == "" {
|
||||||
|
renderLoginError(w, "Email and password are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from database
|
||||||
|
var storedHash string
|
||||||
|
var userID int
|
||||||
|
var role int
|
||||||
|
|
||||||
|
err := models.DB.QueryRow(`
|
||||||
|
SELECT user_id, password, role_id
|
||||||
|
FROM "users"
|
||||||
|
WHERE email = $1
|
||||||
|
`, email).Scan(&userID, &storedHash, &role)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Login failed for email %s: %v", email, err)
|
||||||
|
renderLoginError(w, "Invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Password verification failed for user ID %d", userID)
|
||||||
|
renderLoginError(w, "Invalid email or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JWT token
|
||||||
|
tokenString, expirationTime, err := createJWTToken(userID, role)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("JWT token creation failed for user ID %d: %v", userID, err)
|
||||||
|
http.Error(w, "Could not log in", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set session cookie
|
||||||
|
setSessionCookie(w, tokenString, expirationTime)
|
||||||
|
|
||||||
|
// Redirect based on user role
|
||||||
|
redirectURL := getDefaultRedirectURL(role)
|
||||||
|
log.Printf("User %d (role %d) logged in successfully, redirecting to %s", userID, role, redirectURL)
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
utils.Render(w, "register.html", map[string]interface{}{
|
||||||
|
"Title": "Register",
|
||||||
|
"IsAuthenticated": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
firstName := r.FormValue("first_name")
|
||||||
|
lastName := r.FormValue("last_name")
|
||||||
|
email := r.FormValue("email")
|
||||||
|
phone := r.FormValue("phone")
|
||||||
|
role := r.FormValue("role")
|
||||||
|
password := r.FormValue("password")
|
||||||
|
|
||||||
|
// Input validation
|
||||||
|
if firstName == "" || lastName == "" || email == "" || password == "" || role == "" {
|
||||||
|
renderRegisterError(w, "All fields are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Password hashing failed: %v", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert user into database
|
||||||
|
_, err = models.DB.Exec(`
|
||||||
|
INSERT INTO "users" (first_name, last_name, email, phone, password, role_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
`, firstName, lastName, email, phone, string(hashedPassword), role)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("User registration failed for email %s: %v", email, err)
|
||||||
|
renderRegisterError(w, "Could not create account. Email might already be in use.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("User registered successfully: %s %s (%s)", firstName, lastName, email)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clearSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Admin Dashboard Handler
|
||||||
|
// func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// role := r.Context().Value("user_role").(int)
|
||||||
|
// userID := r.Context().Value("user_id").(int)
|
||||||
|
|
||||||
|
// // TODO: Fetch real data from database
|
||||||
|
// dashboardData := map[string]interface{}{
|
||||||
|
// "UserID": userID,
|
||||||
|
// "TotalUsers": 100, // Example: get from database
|
||||||
|
// "TotalVolunteers": 50, // Example: get from database
|
||||||
|
// "TotalAddresses": 200, // Example: get from database
|
||||||
|
// "RecentActivity": []string{"User logged in", "New volunteer registered"}, // Example
|
||||||
|
// }
|
||||||
|
|
||||||
|
// data := createTemplateData("Admin Dashboard", "dashboard", role, true, dashboardData)
|
||||||
|
// utils.Render(w, "dashboard/dashboard.html", data)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Volunteer Management Handler
|
||||||
|
// func VolunteerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// role := r.Context().Value("user_role").(int)
|
||||||
|
|
||||||
|
// // TODO: Fetch real volunteer data from database
|
||||||
|
// volunteerData := map[string]interface{}{
|
||||||
|
// "Volunteers": []map[string]interface{}{
|
||||||
|
// {"ID": 1, "Name": "John Doe", "Email": "john@example.com", "Status": "Active"},
|
||||||
|
// {"ID": 2, "Name": "Jane Smith", "Email": "jane@example.com", "Status": "Active"},
|
||||||
|
// }, // Example: get from database
|
||||||
|
// }
|
||||||
|
|
||||||
|
// data := createTemplateData("Volunteers", "volunteer", role, true, volunteerData)
|
||||||
|
// utils.Render(w, "volunteers/volunteers.html", data)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Address Management Handler
|
||||||
|
// func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// role := r.Context().Value("user_role").(int)
|
||||||
|
|
||||||
|
// // TODO: Fetch real address data from database
|
||||||
|
// addressData := map[string]interface{}{
|
||||||
|
// "Addresses": []map[string]interface{}{
|
||||||
|
// {"ID": 1, "Street": "123 Main St", "City": "Calgary", "Status": "Validated"},
|
||||||
|
// {"ID": 2, "Street": "456 Oak Ave", "City": "Calgary", "Status": "Pending"},
|
||||||
|
// }, // Example: get from database
|
||||||
|
// }
|
||||||
|
|
||||||
|
// data := createTemplateData("Addresses", "address", role, true, addressData)
|
||||||
|
// utils.Render(w, "addresses/addresses.html", data)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Reports Handler
|
||||||
|
// func ReportHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// role := r.Context().Value("user_role").(int)
|
||||||
|
|
||||||
|
// // TODO: Fetch real report data from database
|
||||||
|
// reportData := map[string]interface{}{
|
||||||
|
// "Reports": []map[string]interface{}{
|
||||||
|
// {"ID": 1, "Name": "Weekly Summary", "Date": "2025-08-25", "Status": "Complete"},
|
||||||
|
// {"ID": 2, "Name": "Monthly Analytics", "Date": "2025-08-01", "Status": "Pending"},
|
||||||
|
// }, // Example: get from database
|
||||||
|
// }
|
||||||
|
|
||||||
|
// data := createTemplateData("Reports", "report", role, true, reportData)
|
||||||
|
// utils.Render(w, "reports/reports.html", data)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Profile Handler (works for both admin and volunteer)
|
||||||
|
// func ProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// role := r.Context().Value("user_role").(int)
|
||||||
|
// userID := r.Context().Value("user_id").(int)
|
||||||
|
|
||||||
|
// // Fetch real user data from database
|
||||||
|
// var firstName, lastName, email, phone string
|
||||||
|
// err := models.DB.QueryRow(`
|
||||||
|
// SELECT first_name, last_name, email, phone
|
||||||
|
// FROM "users"
|
||||||
|
// WHERE user_id = $1
|
||||||
|
// `, userID).Scan(&firstName, &lastName, &email, &phone)
|
||||||
|
|
||||||
|
// profileData := map[string]interface{}{
|
||||||
|
// "UserID": userID,
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if err != nil {
|
||||||
|
// log.Printf("Error fetching user profile for ID %d: %v", userID, err)
|
||||||
|
// profileData["Error"] = "Could not load profile data"
|
||||||
|
// } else {
|
||||||
|
// profileData["FirstName"] = firstName
|
||||||
|
// profileData["LastName"] = lastName
|
||||||
|
// profileData["Email"] = email
|
||||||
|
// profileData["Phone"] = phone
|
||||||
|
// }
|
||||||
|
|
||||||
|
// data := createTemplateData("Profile", "profile", role, true, profileData)
|
||||||
|
// utils.Render(w, "profile/profile.html", data)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Volunteer Dashboard Handler
|
||||||
|
// func VolunteerDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// role := r.Context().Value("user_role").(int)
|
||||||
|
// userID := r.Context().Value("user_id").(int)
|
||||||
|
|
||||||
|
// // TODO: Fetch volunteer-specific data from database
|
||||||
|
// dashboardData := map[string]interface{}{
|
||||||
|
// "UserID": userID,
|
||||||
|
// "AssignedTasks": 5, // Example: get from database
|
||||||
|
// "CompletedTasks": 12, // Example: get from database
|
||||||
|
// "UpcomingEvents": []string{"Community Meeting - Aug 30", "Training Session - Sep 5"}, // Example
|
||||||
|
// }
|
||||||
|
|
||||||
|
// data := createTemplateData("Volunteer Dashboard", "dashboard", role, true, dashboardData)
|
||||||
|
// utils.Render(w, "volunteer/dashboard.html", data)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Schedule Handler for Volunteers
|
||||||
|
// func ScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// role := r.Context().Value("user_role").(int)
|
||||||
|
// userID := r.Context().Value("user_id").(int)
|
||||||
|
|
||||||
|
// // TODO: Fetch schedule data from database
|
||||||
|
// scheduleData := map[string]interface{}{
|
||||||
|
// "UserID": userID,
|
||||||
|
// "Schedule": []map[string]interface{}{
|
||||||
|
// {"Date": "2025-08-26", "Time": "10:00 AM", "Task": "Door-to-door survey", "Location": "Downtown"},
|
||||||
|
// {"Date": "2025-08-28", "Time": "2:00 PM", "Task": "Data entry", "Location": "Office"},
|
||||||
|
// }, // Example: get from database
|
||||||
|
// }
|
||||||
|
|
||||||
|
// data := createTemplateData("My Schedule", "schedual", role, true, scheduleData)
|
||||||
|
// utils.Render(w, "volunteer/schedule.html", data)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Enhanced middleware to check JWT auth and add user context
|
||||||
|
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &models.Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return jwtKey, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
log.Printf("Invalid token: %v", err)
|
||||||
|
clearSessionCookie(w) // Clear invalid cookie
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user info to context
|
||||||
|
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
|
||||||
|
ctx = context.WithValue(ctx, "user_role", claims.Role)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/internal/handlers/profile.go
Normal file
98
app/internal/handlers/profile.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Extract current user ID from session/jwt
|
||||||
|
currentUserID := r.Context().Value("user_id").(int)
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
err := models.DB.QueryRow(`
|
||||||
|
SELECT user_id, first_name, last_name, email, phone, role_id, created_at, updated_at
|
||||||
|
FROM "users"
|
||||||
|
WHERE user_id = $1
|
||||||
|
`, currentUserID).Scan(
|
||||||
|
&user.UserID,
|
||||||
|
&user.FirstName,
|
||||||
|
&user.LastName,
|
||||||
|
&user.Email,
|
||||||
|
&user.Phone,
|
||||||
|
&user.RoleID,
|
||||||
|
&user.CreatedAt,
|
||||||
|
&user.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Profile query error:", err)
|
||||||
|
http.Error(w, "Could not load profile", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role := r.Context().Value("user_role").(int)
|
||||||
|
adminnav := false
|
||||||
|
volunteernav := false
|
||||||
|
|
||||||
|
if role == 1{
|
||||||
|
adminnav = true
|
||||||
|
volunteernav = false
|
||||||
|
}else{
|
||||||
|
volunteernav = true
|
||||||
|
adminnav = false
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Render(w, "profile/profile.html", map[string]interface{}{
|
||||||
|
"Title": "My Profile",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"ShowAdminNav": adminnav,
|
||||||
|
"ShowVolunteerNav": volunteernav,
|
||||||
|
"User": user,
|
||||||
|
"ActiveSection": "profile",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileUpdateHandler handles profile form submissions
|
||||||
|
func ProfileUpdateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract current user ID from session/jwt
|
||||||
|
currentUserID := r.Context().Value("user_id").(int)
|
||||||
|
|
||||||
|
// Parse form values
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Form parse error:", err)
|
||||||
|
http.Error(w, "Invalid form submission", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
firstName := r.FormValue("first_name")
|
||||||
|
lastName := r.FormValue("last_name")
|
||||||
|
phone := r.FormValue("phone")
|
||||||
|
|
||||||
|
// Update in DB
|
||||||
|
_, err = models.DB.Exec(`
|
||||||
|
UPDATE "users"
|
||||||
|
SET first_name = $1,
|
||||||
|
last_name = $2,
|
||||||
|
phone = $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = $4
|
||||||
|
`, firstName, lastName, phone, currentUserID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Profile update error:", err)
|
||||||
|
http.Error(w, "Could not update profile", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect back to profile with success
|
||||||
|
http.Redirect(w, r, "/profile?success=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
74
app/internal/handlers/volunteer_posts.go
Normal file
74
app/internal/handlers/volunteer_posts.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Add this to your handlers package (create volunteer_posts.go or add to existing file)
|
||||||
|
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VolunteerPostsHandler - Read-only posts view for volunteers
|
||||||
|
func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only allow GET requests for volunteers
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info from context
|
||||||
|
role := r.Context().Value("user_role").(int)
|
||||||
|
|
||||||
|
// Fetch posts from database
|
||||||
|
rows, err := models.DB.Query(`
|
||||||
|
SELECT p.post_id, p.author_id, u.first_name || ' ' || u.last_name AS author_name,
|
||||||
|
p.content, COALESCE(p.image_url, '') as image_url, p.created_at
|
||||||
|
FROM post p
|
||||||
|
JOIN users u ON p.author_id = u.user_id
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Database query error: %v\n", err)
|
||||||
|
http.Error(w, "Failed to fetch posts", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var posts []models.Post
|
||||||
|
for rows.Next() {
|
||||||
|
var p models.Post
|
||||||
|
err := rows.Scan(&p.PostID, &p.AuthorID, &p.AuthorName, &p.Content, &p.ImageURL, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Row scan error: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
posts = append(posts, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cache busting parameter to image URLs
|
||||||
|
for i := range posts {
|
||||||
|
if posts[i].ImageURL != "" {
|
||||||
|
posts[i].ImageURL += "?t=" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get navigation flags
|
||||||
|
showAdminNav, showVolunteerNav := getNavFlags(role)
|
||||||
|
|
||||||
|
fmt.Printf("Volunteer viewing %d posts\n", len(posts))
|
||||||
|
|
||||||
|
utils.Render(w, "dashboard/volunteer_dashboard.html", map[string]interface{}{
|
||||||
|
"Title": "Community Posts",
|
||||||
|
"IsAuthenticated": true,
|
||||||
|
"ShowAdminNav": showAdminNav,
|
||||||
|
"ShowVolunteerNav": showVolunteerNav,
|
||||||
|
"Posts": posts,
|
||||||
|
"ActiveSection": "posts",
|
||||||
|
"IsVolunteer": true, // Flag to indicate this is volunteer view
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
31
app/internal/models/db.go
Normal file
31
app/internal/models/db.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *sql.DB
|
||||||
|
|
||||||
|
func InitDB() {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Example DSN format for PostgreSQL:
|
||||||
|
// "postgres://username:password@host:port/dbname?sslmode=disable"
|
||||||
|
dsn := "postgres://mannpatel:Admin@localhost:5432/poll_database?sslmode=disable"
|
||||||
|
|
||||||
|
DB, err = sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = DB.Ping()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to ping DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Database connection successful")
|
||||||
|
}
|
||||||
176
app/internal/models/structs.go
Normal file
176
app/internal/models/structs.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID int
|
||||||
|
Role int
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenResponse struct {
|
||||||
|
Token string
|
||||||
|
User User
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string
|
||||||
|
Details []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Role struct {
|
||||||
|
RoleID int
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
UserID int
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
Email string
|
||||||
|
Phone string
|
||||||
|
Password string
|
||||||
|
RoleID int
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserAddress struct {
|
||||||
|
UserID int
|
||||||
|
AddressLine1 string
|
||||||
|
AddressLine2 string
|
||||||
|
City string
|
||||||
|
Province string
|
||||||
|
Country string
|
||||||
|
PostalCode string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Address Database
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
type AddressDatabase struct {
|
||||||
|
AddressID int
|
||||||
|
Address string
|
||||||
|
StreetName string
|
||||||
|
StreetType string
|
||||||
|
StreetQuadrant string
|
||||||
|
HouseNumber string
|
||||||
|
HouseAlpha *string
|
||||||
|
Longitude float64
|
||||||
|
Latitude float64
|
||||||
|
VisitedValidated bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Teams & Assignments
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
|
||||||
|
type Team struct {
|
||||||
|
TeamID int
|
||||||
|
TeamLeadID int
|
||||||
|
VolunteerID int
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminVolunteer struct {
|
||||||
|
AdminID int
|
||||||
|
VolunteerID int
|
||||||
|
IsActive bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Appointment struct {
|
||||||
|
SchedID int
|
||||||
|
UserID int
|
||||||
|
AddressID int
|
||||||
|
AppointmentDate time.Time
|
||||||
|
AppointmentTime time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Polls & Responses
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
type Poll struct {
|
||||||
|
PollID int
|
||||||
|
AddressID int
|
||||||
|
UserID int
|
||||||
|
ResponseURL string
|
||||||
|
AmountDonated float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type PollResponse struct {
|
||||||
|
ResponseID int
|
||||||
|
PollID int
|
||||||
|
Signage bool
|
||||||
|
VotingChoice string
|
||||||
|
DonationAmount float64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Updates & Reactions
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
PostID int
|
||||||
|
AuthorID int
|
||||||
|
AuthorName string // for display
|
||||||
|
Content string
|
||||||
|
ImageURL string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Reaction struct {
|
||||||
|
ReactionID int
|
||||||
|
PostID int
|
||||||
|
UserID int
|
||||||
|
ReactionType string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Volunteer Availability
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
type Availability struct {
|
||||||
|
AvailabilityID int
|
||||||
|
UserID int
|
||||||
|
DayOfWeek string
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================
|
||||||
|
// Chat Links
|
||||||
|
// =====================
|
||||||
|
|
||||||
|
type ChatLink struct {
|
||||||
|
ChatID int
|
||||||
|
Platform string
|
||||||
|
URL string
|
||||||
|
UserID *int
|
||||||
|
TeamID *int
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
28
app/internal/models/token.go
Normal file
28
app/internal/models/token.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jwtKey = []byte("your-secret-key") //TODO: Move to env/config
|
||||||
|
|
||||||
|
|
||||||
|
func ExtractClaims(tokenStr string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return jwtKey, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Valid {
|
||||||
|
return nil, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
201
app/internal/templates/address/address.html
Normal file
201
app/internal/templates/address/address.html
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<!-- Top Navigation -->
|
||||||
|
<div class="bg-white border-b border-gray-200 px-6 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
class="{{if .PageIcon}}{{.PageIcon}}{{else}}fas fa-map-marker-alt{{end}} text-green-600"
|
||||||
|
></i>
|
||||||
|
<span class="text-sm font-medium"> Address Database </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Records Info -->
|
||||||
|
{{if .Pagination}}
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
Showing {{.Pagination.StartRecord}}-{{.Pagination.EndRecord}} of
|
||||||
|
{{.Pagination.TotalRecords}} addresses
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="relative">
|
||||||
|
<i
|
||||||
|
class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"
|
||||||
|
></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search Addresses"
|
||||||
|
class="w-full pl-8 pr-3 py-2 text-sm border border-gray-200 rounded bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
class="px-6 py-2 border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 transition-colors rounded"
|
||||||
|
>
|
||||||
|
<i class="fas fa-upload mr-2"></i>Import Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Controls -->
|
||||||
|
{{if .Pagination}}
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
<!-- Page Size Selector -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="pageSize" class="text-gray-600">Per page:</label>
|
||||||
|
<select
|
||||||
|
id="pageSize"
|
||||||
|
onchange="changePageSize(this.value)"
|
||||||
|
class="px-3 py-1 text-sm border border-gray-200 rounded bg-white"
|
||||||
|
>
|
||||||
|
<option value="20" {{if eq .Pagination.PageSize 20}}selected{{end}}>
|
||||||
|
20
|
||||||
|
</option>
|
||||||
|
<option value="50" {{if eq .Pagination.PageSize 50}}selected{{end}}>
|
||||||
|
50
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="100"
|
||||||
|
{{if
|
||||||
|
eq
|
||||||
|
.Pagination.PageSize
|
||||||
|
100}}selected{{end}}
|
||||||
|
>
|
||||||
|
100
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page Navigation -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Previous Button -->
|
||||||
|
<button
|
||||||
|
onclick="goToPage({{.Pagination.PreviousPage}})"
|
||||||
|
{{if
|
||||||
|
not
|
||||||
|
.Pagination.HasPrevious}}disabled{{end}}
|
||||||
|
class="px-3 py-1 text-sm border border-gray-200 rounded {{if .Pagination.HasPrevious}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}}"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Page Info -->
|
||||||
|
<span class="px-2 text-gray-600">
|
||||||
|
{{.Pagination.CurrentPage}} / {{.Pagination.TotalPages}}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Next Button -->
|
||||||
|
<button
|
||||||
|
onclick="goToPage({{.Pagination.NextPage}})"
|
||||||
|
{{if
|
||||||
|
not
|
||||||
|
.Pagination.HasNext}}disabled{{end}}
|
||||||
|
class="px-3 py-1 text-sm border border-gray-200 rounded {{if .Pagination.HasNext}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}}"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Wrapper -->
|
||||||
|
<div
|
||||||
|
class="flex-1 overflow-x-auto overflow-y-auto bg-white border border-gray-100"
|
||||||
|
>
|
||||||
|
<table class="w-full divide-gray-200 text-sm table-auto">
|
||||||
|
<!-- Table Head -->
|
||||||
|
<thead class="bg-gray-50 divide-gray-200 sticky top-0">
|
||||||
|
<tr
|
||||||
|
class="text-left text-gray-700 font-medium border-b border-gray-200"
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 whitespace-nowrap">ID</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">Address</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">Street</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">House #</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">Longitude</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">Latitude</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">Validated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<!-- Table Body -->
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
{{ range .Addresses }}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">{{ .AddressID }}</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">{{ .Address }}</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">
|
||||||
|
{{ .StreetName }} {{ .StreetType }} {{ .StreetQuadrant }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">{{ .HouseNumber }}</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">{{ .Longitude }}</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">{{ .Latitude }}</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">
|
||||||
|
{{ if .VisitedValidated }}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check mr-1"></i> Valid
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times mr-1"></i> Invalid
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="px-6 py-8 text-center text-gray-500">
|
||||||
|
No addresses found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Controls -->
|
||||||
|
{{if .Pagination}}
|
||||||
|
<div class="bg-white border-t border-gray-200 px-6 py-3">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<!-- Records Info -->
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
Showing {{.Pagination.StartRecord}}-{{.Pagination.EndRecord}} of
|
||||||
|
{{.Pagination.TotalRecords}} addresses
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function goToPage(page) {
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.set("page", page);
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePageSize(pageSize) {
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.set("pageSize", pageSize);
|
||||||
|
urlParams.set("page", 1);
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
230
app/internal/templates/dashboard/dashboard.html
Normal file
230
app/internal/templates/dashboard/dashboard.html
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{{.Title}}</title>
|
||||||
|
<link
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="https://www.gstatic.com/charts/loader.js"
|
||||||
|
></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50">
|
||||||
|
<!-- Full Width Container -->
|
||||||
|
<div class="min-h-screen w-full flex flex-col">
|
||||||
|
<!-- Top Navigation Bar -->
|
||||||
|
<div class="bg-white border-b border-gray-200 w-full">
|
||||||
|
<div class="px-8 py-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 bg-blue-600 flex items-center justify-center">
|
||||||
|
<i class="fas fa-chart-bar text-white text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-xl font-semibold text-gray-900">
|
||||||
|
Dashboard Overview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
class="px-6 py-2.5 bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-download mr-2"></i>Export Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-6 py-2.5 border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-filter mr-2"></i>Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Dashboard Content -->
|
||||||
|
<div class="w-full">
|
||||||
|
<!-- Stats Grid - Full Width -->
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 bg-white border-b border-gray-200"
|
||||||
|
>
|
||||||
|
<!-- Active Volunteers -->
|
||||||
|
<div
|
||||||
|
class="border-r border-gray-200 p-8 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
onclick="focusMap()"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 bg-blue-50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-users text-blue-600 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-600 mb-1">
|
||||||
|
Active Volunteers
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
{{.VolunteerCount}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Addresses Visited -->
|
||||||
|
<div
|
||||||
|
class="border-r border-gray-200 p-8 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
onclick="updateChart('visitors')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 bg-blue-50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-map-marker-alt text-blue-600 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-600 mb-1">
|
||||||
|
Addresses Visited
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
{{.ValidatedCount}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total Donations -->
|
||||||
|
<div
|
||||||
|
class="border-r border-gray-200 p-8 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
onclick="updateChart('revenue')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 bg-blue-50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-dollar-sign text-blue-600 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-600 mb-1">Donation</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
${{.TotalDonations}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Houses Left -->
|
||||||
|
<div
|
||||||
|
class="p-8 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||||
|
onclick="updateChart('conversion')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 bg-blue-50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-percentage text-blue-600 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-600 mb-1">
|
||||||
|
Houses Left
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
{{.HousesLeftPercent}}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Section - Full Width -->
|
||||||
|
<div class="bg-white w-full">
|
||||||
|
<div class="px-8 py-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">
|
||||||
|
Location Analytics
|
||||||
|
</h3>
|
||||||
|
<div id="map" class="w-full h-[850px] border border-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let map;
|
||||||
|
|
||||||
|
function focusMap() {
|
||||||
|
// Center map example
|
||||||
|
map.setCenter({ lat: 43.0896, lng: -79.0849 }); // Niagara Falls
|
||||||
|
map.setZoom(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
const niagaraFalls = { lat: 43.0896, lng: -79.0849 };
|
||||||
|
|
||||||
|
map = new google.maps.Map(document.getElementById("map"), {
|
||||||
|
zoom: 12,
|
||||||
|
center: niagaraFalls,
|
||||||
|
});
|
||||||
|
|
||||||
|
new google.maps.Marker({
|
||||||
|
position: niagaraFalls,
|
||||||
|
map,
|
||||||
|
title: "Niagara Falls",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Charts
|
||||||
|
google.charts.load("current", { packages: ["corechart", "line"] });
|
||||||
|
google.charts.setOnLoadCallback(drawAnalyticsChart);
|
||||||
|
|
||||||
|
function drawAnalyticsChart() {
|
||||||
|
var data = new google.visualization.DataTable();
|
||||||
|
data.addColumn("string", "Time");
|
||||||
|
data.addColumn("number", "Visitors");
|
||||||
|
data.addColumn("number", "Revenue");
|
||||||
|
|
||||||
|
data.addRows([
|
||||||
|
["Jan", 4200, 32000],
|
||||||
|
["Feb", 4800, 38000],
|
||||||
|
["Mar", 5200, 42000],
|
||||||
|
["Apr", 4900, 39000],
|
||||||
|
["May", 5800, 45000],
|
||||||
|
["Jun", 6200, 48000],
|
||||||
|
]);
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
title: "Performance Over Time",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
hAxis: { title: "Month" },
|
||||||
|
vAxis: { title: "Value" },
|
||||||
|
colors: ["#3B82F6", "#10B981"],
|
||||||
|
chartArea: {
|
||||||
|
left: 60,
|
||||||
|
top: 40,
|
||||||
|
width: "90%",
|
||||||
|
height: "70%",
|
||||||
|
},
|
||||||
|
legend: { position: "top", alignment: "center" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var chart = new google.visualization.LineChart(
|
||||||
|
document.getElementById("analytics_chart")
|
||||||
|
);
|
||||||
|
chart.draw(data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(type) {
|
||||||
|
drawAnalyticsChart();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
src="https://maps.googleapis.com/maps/api/js?key=YOUR_KEY_HERE&callback=initMap"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
122
app/internal/templates/dashboard/volunteer_dashboard.html
Normal file
122
app/internal/templates/dashboard/volunteer_dashboard.html
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="flex flex-col min-h-screen bg-gray-100">
|
||||||
|
<!-- Optional Header -->
|
||||||
|
<header class="bg-white shadow p-4">
|
||||||
|
<h1 class="text-xl font-bold">Community</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Scrollable Posts -->
|
||||||
|
<main class="flex-1 overflow-y-auto px-2 py-4 max-w-2xl mx-auto space-y-4">
|
||||||
|
<!-- Posts Feed -->
|
||||||
|
{{range .Posts}}
|
||||||
|
<article class="bg-white border-b border-gray-200">
|
||||||
|
<!-- Post Header -->
|
||||||
|
<div class="flex items-center px-6 py-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-semibold"
|
||||||
|
>
|
||||||
|
{{slice .AuthorName 0 1}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-900">{{.AuthorName}}</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{.CreatedAt.Format "Jan 2, 2006"}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Image -->
|
||||||
|
{{if .ImageURL}}
|
||||||
|
<div class="w-full">
|
||||||
|
<img
|
||||||
|
src="{{.ImageURL}}"
|
||||||
|
alt="Post image"
|
||||||
|
class="w-full max-h-96 object-cover"
|
||||||
|
onerror="this.parentElement.style.display='none'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Post Actions -->
|
||||||
|
<div class="px-6 py-3">
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<button
|
||||||
|
class="reaction-btn flex items-center space-x-2 text-gray-600 hover:text-blue-500 transition-colors"
|
||||||
|
data-post-id="{{.PostID}}"
|
||||||
|
data-reaction="like"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V18m-7-8a2 2 0 01-2-2V7a2 2 0 012-2h3.764a2 2 0 011.789 1.106L14 8v2m-7-8V5a2 2 0 012-2h1m-5 10h3m4 3H8"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium like-count">0</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="reaction-btn flex items-center space-x-2 text-gray-600 hover:text-red-500 transition-colors"
|
||||||
|
data-post-id="{{.PostID}}"
|
||||||
|
data-reaction="dislike"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018c.163 0 .326.02.485.06L17 4m-7 10v-8m7 8a2 2 0 002 2v1a2 2 0 01-2 2h-3.764a2 2 0 01-1.789-1.106L10 16v-2m7 8V19a2 2 0 00-2-2h-1m5-10H12m-4-3h4"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium dislike-count">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Content -->
|
||||||
|
{{if .Content}}
|
||||||
|
<div class="px-6 pb-4">
|
||||||
|
<p class="text-gray-900 leading-relaxed">
|
||||||
|
<span class="font-semibold">{{.AuthorName}}</span> {{.Content}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{else}}
|
||||||
|
<div class="bg-white p-12 text-center">
|
||||||
|
<div class="max-w-sm mx-auto">
|
||||||
|
<svg
|
||||||
|
class="w-16 h-16 mx-auto text-gray-300 mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts yet</h3>
|
||||||
|
<p class="text-gray-500">
|
||||||
|
Be the first to share something with the community!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
490
app/internal/templates/layout.html
Normal file
490
app/internal/templates/layout.html
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
{{ define "layout" }}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{{if .Title}}{{.Title}}{{else}}Poll System{{end}}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="//unpkg.com/alpinejs" defer></script>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body class="bg-white font-sans">
|
||||||
|
{{ if .IsAuthenticated }}
|
||||||
|
<!-- Authenticated User Interface -->
|
||||||
|
<div class="w-full h-screen bg-white overflow-hidden">
|
||||||
|
<!-- Title Bar -->
|
||||||
|
<div class="bg-gray-100 px-4 py-3 flex items-center justify-between border-b border-gray-200">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-5 h-5 bg-orange-500 rounded text-white text-xs flex items-center justify-center font-bold">
|
||||||
|
L
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium">Poll System</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/logout" class="p-2 hover:bg-gray-100 rounded inline-block">
|
||||||
|
<i class="fas fa-external-link-alt text-gray-500"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex h-full">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="w-64 bg-gray-50 border-r border-gray-200 flex-shrink-0">
|
||||||
|
<div class="p-3 space-y-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
{{ if .ShowAdminNav }}
|
||||||
|
<a href="/dashboard" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "dashboard"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-tachometer-alt text-gray-400 mr-2"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="/volunteers" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "volunteer"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-hands-helping text-gray-400 mr-2"></i>
|
||||||
|
<span>Volunteers</span>
|
||||||
|
</a>
|
||||||
|
<a href="/team_builder" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "team_builder"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-hands-helping text-gray-400 mr-2"></i>
|
||||||
|
<span>Team Builder</span>
|
||||||
|
</a>
|
||||||
|
<a href="/addresses" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "address"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-map-marker-alt text-gray-400 mr-2"></i>
|
||||||
|
<span>Addresses</span>
|
||||||
|
</a>
|
||||||
|
<a href="/posts" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "post"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-chart-bar text-gray-400 mr-2"></i>
|
||||||
|
<span>Posts</span>
|
||||||
|
</a>
|
||||||
|
<a href="/reports" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "report"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-chart-bar text-gray-400 mr-2"></i>
|
||||||
|
<span>Reports</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if .ShowVolunteerNav }}
|
||||||
|
<a href="/volunteer/dashboard" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "dashboard"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-tachometer-alt text-gray-400 mr-2"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="/volunteer/schedual" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "schedual"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-calendar text-gray-400 mr-2"></i>
|
||||||
|
<span>My Schedule</span>
|
||||||
|
</a>
|
||||||
|
<a href="/volunteer/Addresses" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "schedual"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-calendar text-gray-400 mr-2"></i>
|
||||||
|
<span>Assigned Address</span>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<a href="/profile" class="flex items-center text-sm text-gray-600 hover:bg-gray-100 rounded px-2 py-1 {{if eq .ActiveSection "profile"}}bg-gray-100{{end}}">
|
||||||
|
<i class="fas fa-user text-gray-400 mr-2"></i>
|
||||||
|
<span>Profile</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden min-h-screen">
|
||||||
|
<div class="bg-white flex-1 overflow-auto pb-[60px]">
|
||||||
|
{{ template "content" . }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<!-- Landing Page -->
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-slate-50 to-gray-100">
|
||||||
|
<!-- Fixed Navigation -->
|
||||||
|
<nav class="fixed top-0 w-full bg-white/90 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-16">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 bg-blue-600 text-white text-sm flex items-center justify-center font-bold">
|
||||||
|
L
|
||||||
|
</div>
|
||||||
|
<span class="text-xl font-semibold text-gray-900">Poll System</span>
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:flex items-center gap-6">
|
||||||
|
<a href="#home" class="text-gray-600 hover:text-gray-900 font-medium transition-colors">Home</a>
|
||||||
|
<a href="#features" class="text-gray-600 hover:text-gray-900 font-medium transition-colors">Features</a>
|
||||||
|
<a href="#about" class="text-gray-600 hover:text-gray-900 font-medium transition-colors">About</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="openLoginModal()" class="px-4 py-2 text-gray-600 hover:text-gray-900 font-medium transition-colors">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
<button onclick="openRegisterModal()" class="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 font-medium transition-colors">
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section id="home" class="max-w-4xl mx-auto px-4 pt-32 pb-32 text-center">
|
||||||
|
<h1 class="text-5xl font-bold text-gray-900 mb-6 leading-tight">
|
||||||
|
Streamline Your<br>
|
||||||
|
<span class="text-blue-600">Polling Operations</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
Manage volunteers, organize addresses, and track progress with our comprehensive polling system.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
|
<button onclick="openRegisterModal()" class="px-8 py-3 bg-blue-600 text-white hover:bg-blue-700 font-semibold transition-colors">
|
||||||
|
Start Now
|
||||||
|
</button>
|
||||||
|
<button onclick="openLoginModal()" class="px-8 py-3 border border-gray-300 text-gray-700 hover:bg-gray-50 font-semibold transition-colors">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
<section id="features" class="max-w-6xl mx-auto px-4 py-20">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<h2 class="text-4xl font-bold text-gray-900 mb-4">Powerful Features</h2>
|
||||||
|
<p class="text-xl text-gray-600 max-w-3xl mx-auto">Everything you need to manage your polling operations efficiently and effectively.</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid md:grid-cols-3 gap-8">
|
||||||
|
<div class="bg-white p-8 shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
|
||||||
|
<div class="w-12 h-12 bg-blue-100 flex items-center justify-center mb-4">
|
||||||
|
<i class="fas fa-users text-blue-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Volunteer Management</h3>
|
||||||
|
<p class="text-gray-600">Organize and coordinate your volunteer teams efficiently with role-based access and scheduling.</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white p-8 shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
|
||||||
|
<div class="w-12 h-12 bg-green-100 flex items-center justify-center mb-4">
|
||||||
|
<i class="fas fa-map-marker-alt text-green-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Address Tracking</h3>
|
||||||
|
<p class="text-gray-600">Keep track of all polling locations and assignments with real-time updates and mapping.</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white p-8 shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
|
||||||
|
<div class="w-12 h-12 bg-purple-100 flex items-center justify-center mb-4">
|
||||||
|
<i class="fas fa-chart-bar text-purple-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-3">Real-time Reports</h3>
|
||||||
|
<p class="text-gray-600">Monitor progress with comprehensive analytics and detailed reporting dashboards.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- About Section -->
|
||||||
|
<section id="about" class="bg-white py-20">
|
||||||
|
<div class="max-w-6xl mx-auto px-4">
|
||||||
|
<div class="grid md:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-4xl font-bold text-gray-900 mb-6">About Poll System</h2>
|
||||||
|
<p class="text-lg text-gray-600 mb-6">
|
||||||
|
Poll System was created to simplify and streamline the complex process of managing polling operations.
|
||||||
|
Our platform brings together volunteers, administrators, and team leaders in one unified system.
|
||||||
|
</p>
|
||||||
|
<p class="text-lg text-gray-600 mb-8">
|
||||||
|
With years of experience in civic technology, we understand the challenges faced by polling organizations.
|
||||||
|
Our solution provides the tools needed to coordinate effectively and ensure smooth operations.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-6 h-6 bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-check text-green-600 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-700">Streamlined volunteer coordination</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-6 h-6 bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-check text-green-600 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-700">Real-time progress tracking</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-6 h-6 bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-check text-green-600 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-700">Comprehensive reporting tools</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="bg-gradient-to-br from-blue-500 to-blue-700 p-8 text-white">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="fas fa-users text-6xl mb-6 opacity-20"></i>
|
||||||
|
<h3 class="text-2xl font-bold mb-4">Trusted by Organizations</h3>
|
||||||
|
<p class="text-lg opacity-90 mb-6">
|
||||||
|
Join hundreds of organizations already using Poll System to manage their operations efficiently.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold">500+</div>
|
||||||
|
<div class="text-sm opacity-80">Volunteers</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold">50+</div>
|
||||||
|
<div class="text-sm opacity-80">Organizations</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold">1000+</div>
|
||||||
|
<div class="text-sm opacity-80">Addresses</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-gray-900 text-white py-12">
|
||||||
|
<div class="max-w-6xl mx-auto px-4">
|
||||||
|
<div class="grid md:grid-cols-4 gap-8">
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<div class="w-8 h-8 bg-blue-600 text-white text-sm flex items-center justify-center font-bold">
|
||||||
|
L
|
||||||
|
</div>
|
||||||
|
<span class="text-xl font-semibold">Poll System</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-400 mb-4 max-w-md">
|
||||||
|
Streamlining polling operations with comprehensive volunteer management,
|
||||||
|
address tracking, and real-time reporting capabilities.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<a href="#" class="w-10 h-10 bg-gray-800 flex items-center justify-center hover:bg-blue-600 transition-colors">
|
||||||
|
<i class="fab fa-twitter"></i>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="w-10 h-10 bg-gray-800 flex items-center justify-center hover:bg-blue-600 transition-colors">
|
||||||
|
<i class="fab fa-linkedin"></i>
|
||||||
|
</a>
|
||||||
|
<a href="#" class="w-10 h-10 bg-gray-800 flex items-center justify-center hover:bg-blue-600 transition-colors">
|
||||||
|
<i class="fab fa-github"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-4">Platform</h4>
|
||||||
|
<ul class="space-y-2 text-gray-400">
|
||||||
|
<li><a href="#" class="hover:text-white transition-colors">Dashboard</a></li>
|
||||||
|
<li><a href="#" class="hover:text-white transition-colors">Volunteers</a></li>
|
||||||
|
<li><a href="#" class="hover:text-white transition-colors">Addresses</a></li>
|
||||||
|
<li><a href="#" class="hover:text-white transition-colors">Reports</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold mb-4">Support</h4>
|
||||||
|
<ul class="space-y-2 text-gray-400">
|
||||||
|
<li><a href="#" class="hover:text-white transition-colors">Help Center</a></li>
|
||||||
|
<li><a href="#" class="hover:text-white transition-colors">Contact Us</a></li>
|
||||||
|
<li><a href="#" class="hover:text-white transition-colors">Privacy Policy</a></li>
|
||||||
|
<li><a href="#" class="hover:text-white transition-colors">Terms of Service</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
||||||
|
<p>© 2025 Poll System. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Modal -->
|
||||||
|
<div id="loginModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white shadow-2xl max-w-4xl w-full mx-4 overflow-hidden">
|
||||||
|
<div class="flex min-h-[500px]">
|
||||||
|
<!-- Left Side - Image -->
|
||||||
|
<div class="flex-1 bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center p-8">
|
||||||
|
<div class="text-center text-white">
|
||||||
|
<i class="fas fa-chart-line text-6xl mb-6"></i>
|
||||||
|
<h2 class="text-3xl font-bold mb-4">Welcome Back</h2>
|
||||||
|
<p class="text-lg opacity-90">Continue managing your polling operations</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right Side - Form -->
|
||||||
|
<div class="flex-1 p-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900">Sign In</h3>
|
||||||
|
<button onclick="closeLoginModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/login" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="login_email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||||
|
<input type="email" name="email" id="login_email" required
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="login_password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||||
|
<input type="password" name="password" id="login_password" required
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="w-full bg-blue-600 text-white py-3 hover:bg-blue-700 font-medium transition-colors">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-center text-sm text-gray-600 mt-6">
|
||||||
|
Don't have an account?
|
||||||
|
<button onclick="switchToRegister()" class="text-blue-600 hover:text-blue-700 font-medium">Sign up</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Register Modal -->
|
||||||
|
<div id="registerModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white shadow-2xl max-w-4xl w-full mx-4 overflow-hidden">
|
||||||
|
<div class="flex min-h-[600px]">
|
||||||
|
<!-- Left Side - Image -->
|
||||||
|
<div class="flex-1 bg-gradient-to-br from-blue-600 to-blue-800 flex items-center justify-center p-8">
|
||||||
|
<div class="text-center text-white">
|
||||||
|
<i class="fas fa-rocket text-6xl mb-6"></i>
|
||||||
|
<h2 class="text-3xl font-bold mb-4">Get Started</h2>
|
||||||
|
<p class="text-lg opacity-90">Join our platform and streamline your operations</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Right Side - Form -->
|
||||||
|
<div class="flex-1 p-8 overflow-y-auto">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900">Create Account</h3>
|
||||||
|
<button onclick="closeRegisterModal()" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/register" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
||||||
|
<input type="text" name="first_name" id="first_name" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
||||||
|
<input type="text" name="last_name" id="last_name" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="register_email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input type="email" name="email" id="register_email" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
||||||
|
<input type="tel" name="phone" id="phone"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="role" class="block text-sm font-medium text-gray-700 mb-1">Role</label>
|
||||||
|
<select name="role" id="role" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||||
|
<option value="">Select role</option>
|
||||||
|
<option value="1">Admin</option>
|
||||||
|
<option value="2">Team Leader</option>
|
||||||
|
<option value="3">Volunteer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="register_password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<input type="password" name="password" id="register_password" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="w-full bg-blue-600 text-white py-3 hover:bg-blue-700 font-medium transition-colors mt-6">
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-center text-sm text-gray-600 mt-4">
|
||||||
|
Already have an account?
|
||||||
|
<button onclick="switchToLogin()" class="text-blue-600 hover:text-blue-700 font-medium">Sign in</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Smooth scrolling for navigation links
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const links = document.querySelectorAll('a[href^="#"]');
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
link.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const targetId = this.getAttribute('href').substring(1);
|
||||||
|
const targetElement = document.getElementById(targetId);
|
||||||
|
|
||||||
|
if (targetElement) {
|
||||||
|
const offsetTop = targetElement.offsetTop - 80; // Account for fixed navbar
|
||||||
|
window.scrollTo({
|
||||||
|
top: offsetTop,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openLoginModal() {
|
||||||
|
document.getElementById('loginModal').classList.remove('hidden');
|
||||||
|
document.getElementById('loginModal').classList.add('flex');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLoginModal() {
|
||||||
|
document.getElementById('loginModal').classList.add('hidden');
|
||||||
|
document.getElementById('loginModal').classList.remove('flex');
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRegisterModal() {
|
||||||
|
document.getElementById('registerModal').classList.remove('hidden');
|
||||||
|
document.getElementById('registerModal').classList.add('flex');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRegisterModal() {
|
||||||
|
document.getElementById('registerModal').classList.add('hidden');
|
||||||
|
document.getElementById('registerModal').classList.remove('flex');
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToRegister() {
|
||||||
|
closeLoginModal();
|
||||||
|
setTimeout(() => openRegisterModal(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToLogin() {
|
||||||
|
closeRegisterModal();
|
||||||
|
setTimeout(() => openLoginModal(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const loginModal = document.getElementById('loginModal');
|
||||||
|
const registerModal = document.getElementById('registerModal');
|
||||||
|
|
||||||
|
if (event.target === loginModal) {
|
||||||
|
closeLoginModal();
|
||||||
|
}
|
||||||
|
if (event.target === registerModal) {
|
||||||
|
closeRegisterModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle escape key
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeLoginModal();
|
||||||
|
closeRegisterModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
369
app/internal/templates/posts.html
Normal file
369
app/internal/templates/posts.html
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="min-h-screen bg-gray-100">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white border-b border-gray-200 sticky top-0 z-10">
|
||||||
|
<div class="max-w-2xl mx-auto px-4 py-4">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Posts</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<!-- Create Post Form -->
|
||||||
|
<div class="bg-white border-b border-gray-200 p-6">
|
||||||
|
<form
|
||||||
|
action="/posts"
|
||||||
|
method="POST"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-semibold"
|
||||||
|
>
|
||||||
|
U
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
name="content"
|
||||||
|
placeholder="What's on your mind?"
|
||||||
|
class="w-full px-0 py-2 text-gray-900 placeholder-gray-500 border-0 resize-none focus:outline-none focus:ring-0"
|
||||||
|
rows="3"
|
||||||
|
required
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between pt-4 border-t border-gray-100"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<label
|
||||||
|
for="image"
|
||||||
|
class="cursor-pointer flex items-center space-x-2 text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">Photo</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="image"
|
||||||
|
type="file"
|
||||||
|
name="image"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-6 py-2 text-sm font-semibold transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image preview -->
|
||||||
|
<div id="imagePreview" class="hidden">
|
||||||
|
<img
|
||||||
|
id="previewImg"
|
||||||
|
class="w-full h-64 object-cover border"
|
||||||
|
alt="Preview"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="removeImage"
|
||||||
|
class="mt-2 text-red-500 text-sm hover:text-red-600"
|
||||||
|
>
|
||||||
|
Remove image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posts Feed -->
|
||||||
|
<div class="space-y-0">
|
||||||
|
{{range .Posts}}
|
||||||
|
<article class="bg-white border-b border-gray-200">
|
||||||
|
<!-- Post Header -->
|
||||||
|
<div class="flex items-center px-6 py-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-semibold"
|
||||||
|
>
|
||||||
|
{{slice .AuthorName 0 1}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-900">{{.AuthorName}}</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{{.CreatedAt.Format "Jan 2, 2006"}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Image -->
|
||||||
|
{{if .ImageURL}}
|
||||||
|
<div class="w-full">
|
||||||
|
<img
|
||||||
|
src="{{.ImageURL}}"
|
||||||
|
alt="Post image"
|
||||||
|
class="w-full max-h-96 object-cover"
|
||||||
|
onerror="this.parentElement.style.display='none'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Post Actions -->
|
||||||
|
<div class="px-6 py-3">
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<!-- Like Button -->
|
||||||
|
<button
|
||||||
|
class="reaction-btn flex items-center space-x-2 text-gray-600 hover:text-blue-500 transition-colors"
|
||||||
|
data-post-id="{{.PostID}}"
|
||||||
|
data-reaction="like"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-thumbs-up text-lg"></i>
|
||||||
|
<span class="text-sm font-medium like-count">0</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Dislike Button -->
|
||||||
|
<button
|
||||||
|
class="reaction-btn flex items-center space-x-2 text-gray-600 hover:text-red-500 transition-colors"
|
||||||
|
data-post-id="{{.PostID}}"
|
||||||
|
data-reaction="dislike"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-thumbs-down text-lg"></i>
|
||||||
|
<span class="text-sm font-medium dislike-count">0</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Post Content -->
|
||||||
|
{{if .Content}}
|
||||||
|
<div class="px-6 pb-4">
|
||||||
|
<p class="text-gray-900 leading-relaxed">
|
||||||
|
<span class="font-semibold">{{.AuthorName}}</span> {{.Content}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
{{else}}
|
||||||
|
<div class="bg-white p-12 text-center">
|
||||||
|
<div class="max-w-sm mx-auto">
|
||||||
|
<svg
|
||||||
|
class="w-16 h-16 mx-auto text-gray-300 mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts yet</h3>
|
||||||
|
<p class="text-gray-500">
|
||||||
|
Be the first to share something with the community!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Custom styles for Instagram-like feel */
|
||||||
|
.reaction-btn.active {
|
||||||
|
color: #3b82f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn.active svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn.dislike-active {
|
||||||
|
color: #ef4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions */
|
||||||
|
.reaction-btn {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles */
|
||||||
|
button:focus {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const fileInput = document.getElementById("image");
|
||||||
|
const imagePreview = document.getElementById("imagePreview");
|
||||||
|
const previewImg = document.getElementById("previewImg");
|
||||||
|
const removeImageBtn = document.getElementById("removeImage");
|
||||||
|
const form = document.querySelector("form");
|
||||||
|
|
||||||
|
// Image upload preview
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener("change", function (e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
console.log(
|
||||||
|
"Selected file:",
|
||||||
|
file.name,
|
||||||
|
"Size:",
|
||||||
|
file.size,
|
||||||
|
"Type:",
|
||||||
|
file.type
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate file size (10MB max)
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
alert("File is too large. Maximum size is 10MB.");
|
||||||
|
this.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
];
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
alert("Invalid file type. Please select a valid image file.");
|
||||||
|
this.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show preview
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function (e) {
|
||||||
|
previewImg.src = e.target.result;
|
||||||
|
imagePreview.classList.remove("hidden");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove image preview
|
||||||
|
if (removeImageBtn) {
|
||||||
|
removeImageBtn.addEventListener("click", function () {
|
||||||
|
fileInput.value = "";
|
||||||
|
imagePreview.classList.add("hidden");
|
||||||
|
previewImg.src = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaction buttons
|
||||||
|
const reactionBtns = document.querySelectorAll(".reaction-btn");
|
||||||
|
reactionBtns.forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const postId = this.dataset.postId;
|
||||||
|
const reaction = this.dataset.reaction;
|
||||||
|
|
||||||
|
// Toggle active state
|
||||||
|
if (reaction === "like") {
|
||||||
|
this.classList.toggle("active");
|
||||||
|
// Remove dislike active state from sibling
|
||||||
|
const dislikeBtn = this.parentElement.querySelector(
|
||||||
|
'[data-reaction="dislike"]'
|
||||||
|
);
|
||||||
|
dislikeBtn.classList.remove("dislike-active");
|
||||||
|
} else {
|
||||||
|
this.classList.toggle("dislike-active");
|
||||||
|
// Remove like active state from sibling
|
||||||
|
const likeBtn = this.parentElement.querySelector(
|
||||||
|
'[data-reaction="like"]'
|
||||||
|
);
|
||||||
|
likeBtn.classList.remove("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update count (mock implementation)
|
||||||
|
const countSpan = this.querySelector("span");
|
||||||
|
const currentCount = parseInt(countSpan.textContent);
|
||||||
|
const isActive =
|
||||||
|
this.classList.contains("active") ||
|
||||||
|
this.classList.contains("dislike-active");
|
||||||
|
countSpan.textContent = isActive
|
||||||
|
? currentCount + 1
|
||||||
|
: Math.max(0, currentCount - 1);
|
||||||
|
|
||||||
|
console.log(`${reaction} clicked for post ${postId}`);
|
||||||
|
|
||||||
|
// Here you would typically send an AJAX request to update the backend
|
||||||
|
// fetch(`/posts/${postId}/react`, {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: { 'Content-Type': 'application/json' },
|
||||||
|
// body: JSON.stringify({ reaction: reaction })
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
const content = document.getElementById("content").value.trim();
|
||||||
|
const hasImage = fileInput.files.length > 0;
|
||||||
|
|
||||||
|
if (!content && !hasImage) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert("Please add some content or an image to your post.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Form being submitted...");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
const textarea = document.getElementById("content");
|
||||||
|
if (textarea) {
|
||||||
|
textarea.addEventListener("input", function () {
|
||||||
|
this.style.height = "auto";
|
||||||
|
this.style.height = this.scrollHeight + "px";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth scroll to top when clicking header
|
||||||
|
const header = document.querySelector("h1");
|
||||||
|
if (header) {
|
||||||
|
header.addEventListener("click", function () {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
274
app/internal/templates/profile/profile.html
Normal file
274
app/internal/templates/profile/profile.html
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Header Bar -->
|
||||||
|
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<i class="fas fa-user-circle text-blue-600 text-xl"></i>
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">User Profile</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 text-sm text-gray-600">
|
||||||
|
<i class="fas fa-shield-check text-blue-500"></i>
|
||||||
|
<span>Secure Profile Management</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Profile Overview Tile -->
|
||||||
|
<div class="bg-white border border-gray-200 mb-6">
|
||||||
|
<div class="bg-blue-50 border-b border-gray-200 px-6 py-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||||
|
<i class="fas fa-id-card text-blue-600 mr-3"></i>
|
||||||
|
Profile Overview
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
{{ .User.FirstName }} {{ .User.LastName }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600">{{ .User.Email }}</p>
|
||||||
|
<div class="flex items-center mt-2 space-x-4">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 border border-blue-200"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user-check mr-1"></i>
|
||||||
|
Active User
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500"
|
||||||
|
>ID: {{ .User.UserID }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="bg-gray-50 border border-gray-200 p-4">
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700 mb-3">
|
||||||
|
Account Information
|
||||||
|
</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-600">User ID:</span>
|
||||||
|
<span class="font-mono text-gray-900">{{ .User.UserID }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-600">Role:</span>
|
||||||
|
<span class="text-gray-900">{{ if eq .User.RoleID 1 }}Admin
|
||||||
|
{{ else if eq .User.RoleID 2 }}Team Leader
|
||||||
|
{{ else }}Volunteer
|
||||||
|
{{ end }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-600">Status:</span>
|
||||||
|
<span class="text-green-600 font-medium">Active</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Profile Form Tile -->
|
||||||
|
<div class="bg-white border border-gray-200 mt-0 m-6">
|
||||||
|
<div class="bg-blue-50 border-b border-gray-200 px-6 py-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||||
|
<i class="fas fa-edit text-blue-600 mr-3"></i>
|
||||||
|
Edit Profile Information
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<form method="post" action="/profile/update">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- First Name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
First Name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="first_name"
|
||||||
|
value="{{ .User.FirstName }}"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
|
||||||
|
placeholder="Enter first name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Last Name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="last_name"
|
||||||
|
value="{{ .User.LastName }}"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
|
||||||
|
placeholder="Enter last name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email (Read-only) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Email Address
|
||||||
|
<span class="ml-2 text-xs bg-gray-200 text-gray-600 px-2 py-1"
|
||||||
|
>Read Only</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value="{{ .User.Email }}"
|
||||||
|
disabled
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 bg-gray-100 text-gray-600 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||||
|
<i class="fas fa-lock text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Contact system administrator to change email
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-gray-700 mb-2">
|
||||||
|
Phone Number
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
value="{{ .User.Phone }}"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
|
||||||
|
placeholder="Enter phone number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form Actions -->
|
||||||
|
<div
|
||||||
|
class="mt-8 pt-6 border-t border-gray-200 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
|
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
|
||||||
|
Changes will be applied immediately after saving
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="window.history.back()"
|
||||||
|
class="px-6 py-2 border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times mr-2"></i>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-6 py-2 bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
||||||
|
>
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Professional square corner design */
|
||||||
|
* {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clean transitions */
|
||||||
|
input,
|
||||||
|
button,
|
||||||
|
.transition-colors {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus states with blue accent */
|
||||||
|
input:focus {
|
||||||
|
box-shadow: 0 0 0 1px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
box-shadow: 0 0 0 2px #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover effects for tiles */
|
||||||
|
.hover\:bg-blue-50:hover {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:border-blue-500:hover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Professional table-like layout */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure full width usage */
|
||||||
|
.min-h-screen {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Professional button styling */
|
||||||
|
button {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clean form inputs */
|
||||||
|
input[disabled] {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.bg-blue-100 {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-blue-800 {
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bars */
|
||||||
|
.bg-blue-600 {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.lg\:grid-cols-2 {
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg\:grid-cols-3 {
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{ end }}
|
||||||
239
app/internal/templates/schedual/schedual.html
Normal file
239
app/internal/templates/schedual/schedual.html
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Interactive Dashboard</title>
|
||||||
|
<link
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script
|
||||||
|
type="text/javascript"
|
||||||
|
src="https://www.gstatic.com/charts/loader.js"
|
||||||
|
></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100">
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<!-- Top Navigation -->
|
||||||
|
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="fas fa-chart-bar text-blue-600"></i>
|
||||||
|
<span class="text-xl font-semibold text-gray-800">
|
||||||
|
Schedual Overview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-download mr-2"></i>Export Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 border border-gray-300 text-gray-700 text-sm hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-filter mr-2"></i>Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Content -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<!-- Top Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- Active Locations Card -->
|
||||||
|
<div
|
||||||
|
class="bg-white border-r border-b border-gray-200 p-8 hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onclick="focusMap()"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 bg-blue-100 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-map-marker-alt text-blue-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-600">
|
||||||
|
Active Locations
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">24</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total Visitors Card -->
|
||||||
|
<div
|
||||||
|
class="bg-white border-r border-b border-gray-200 p-8 hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onclick="updateChart('visitors')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 bg-green-100 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-users text-green-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-600">Total Visitors</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">12,847</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revenue Card -->
|
||||||
|
<div
|
||||||
|
class="bg-white border-r border-b border-gray-200 p-8 hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onclick="updateChart('revenue')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 bg-purple-100 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-dollar-sign text-purple-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-600">Revenue</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">$47,392</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversion Rate Card -->
|
||||||
|
<div
|
||||||
|
class="bg-white border-b border-gray-200 p-8 hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onclick="updateChart('conversion')"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="w-14 h-14 bg-orange-100 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<i class="fas fa-percentage text-orange-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-600">Conversion Rate</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">3.2%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full Width Google Map -->
|
||||||
|
<div class="bg-white border-b border-gray-200 p-8">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">
|
||||||
|
Location Analytics
|
||||||
|
</h3>
|
||||||
|
<div id="map" class="w-full h-[600px] border border-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Performance Metrics Chart - Full Width Bottom -->
|
||||||
|
<div class="bg-white border-gray-200 p-8">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
Performance Metrics
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick="updateChart('daily')"
|
||||||
|
class="px-3 py-1 text-sm border border-gray-300 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Daily
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick="updateChart('weekly')"
|
||||||
|
class="px-3 py-1 text-sm bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Weekly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick="updateChart('monthly')"
|
||||||
|
class="px-3 py-1 text-sm border border-gray-300 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="analytics_chart" class="w-full h-[400px]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let map;
|
||||||
|
|
||||||
|
function focusMap() {
|
||||||
|
// Center map example
|
||||||
|
map.setCenter({ lat: 43.0896, lng: -79.0849 }); // Niagara Falls
|
||||||
|
map.setZoom(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMap() {
|
||||||
|
const niagaraFalls = { lat: 43.0896, lng: -79.0849 };
|
||||||
|
|
||||||
|
map = new google.maps.Map(document.getElementById("map"), {
|
||||||
|
zoom: 12,
|
||||||
|
center: niagaraFalls,
|
||||||
|
});
|
||||||
|
|
||||||
|
new google.maps.Marker({
|
||||||
|
position: niagaraFalls,
|
||||||
|
map,
|
||||||
|
title: "Niagara Falls",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Charts
|
||||||
|
google.charts.load("current", { packages: ["corechart", "line"] });
|
||||||
|
google.charts.setOnLoadCallback(drawAnalyticsChart);
|
||||||
|
|
||||||
|
function drawAnalyticsChart() {
|
||||||
|
var data = new google.visualization.DataTable();
|
||||||
|
data.addColumn("string", "Time");
|
||||||
|
data.addColumn("number", "Visitors");
|
||||||
|
data.addColumn("number", "Revenue");
|
||||||
|
|
||||||
|
data.addRows([
|
||||||
|
["Jan", 4200, 32000],
|
||||||
|
["Feb", 4800, 38000],
|
||||||
|
["Mar", 5200, 42000],
|
||||||
|
["Apr", 4900, 39000],
|
||||||
|
["May", 5800, 45000],
|
||||||
|
["Jun", 6200, 48000],
|
||||||
|
]);
|
||||||
|
|
||||||
|
var options = {
|
||||||
|
title: "Performance Over Time",
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
hAxis: { title: "Month" },
|
||||||
|
vAxis: { title: "Value" },
|
||||||
|
colors: ["#3B82F6", "#10B981"],
|
||||||
|
chartArea: {
|
||||||
|
left: 60,
|
||||||
|
top: 40,
|
||||||
|
width: "90%",
|
||||||
|
height: "70%",
|
||||||
|
},
|
||||||
|
legend: { position: "top", alignment: "center" },
|
||||||
|
};
|
||||||
|
|
||||||
|
var chart = new google.visualization.LineChart(
|
||||||
|
document.getElementById("analytics_chart")
|
||||||
|
);
|
||||||
|
chart.draw(data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(type) {
|
||||||
|
drawAnalyticsChart();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
src="https://maps.googleapis.com/maps/api/js?key=YOUR_KEY_HERE&callback=initMap"
|
||||||
|
></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
45
app/internal/templates/volunteer/edit_volunteer.html
Normal file
45
app/internal/templates/volunteer/edit_volunteer.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<h2>Edit Volunteer</h2>
|
||||||
|
<form method="POST" action="/volunteer/edit">
|
||||||
|
<input type="hidden" name="user_id" value="{{.Volunteer.UserID}}" />
|
||||||
|
|
||||||
|
<label>First Name:</label>
|
||||||
|
<input type="text" name="first_name" value="{{.Volunteer.FirstName}}" /><br />
|
||||||
|
|
||||||
|
<label>Last Name:</label>
|
||||||
|
<input type="text" name="last_name" value="{{.Volunteer.LastName}}" /><br />
|
||||||
|
|
||||||
|
<label>Email:</label>
|
||||||
|
<input type="email" name="email" value="{{.Volunteer.Email}}" /><br />
|
||||||
|
|
||||||
|
<label>Phone:</label>
|
||||||
|
<input type="text" name="phone" value="{{.Volunteer.Phone}}" /><br />
|
||||||
|
|
||||||
|
<label for="role_id">Role</label><br />
|
||||||
|
<select name="role_id" id="role_id" required>
|
||||||
|
<option value="">--Select Role--</option>
|
||||||
|
<option
|
||||||
|
type="number"
|
||||||
|
value="3"
|
||||||
|
{{if
|
||||||
|
eq
|
||||||
|
.Volunteer.RoleID
|
||||||
|
3}}selected{{end}}
|
||||||
|
>
|
||||||
|
Volunteer
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
type="number"
|
||||||
|
value="2"
|
||||||
|
{{if
|
||||||
|
eq
|
||||||
|
.Volunteer.RoleID
|
||||||
|
2}}selected{{end}}
|
||||||
|
>
|
||||||
|
Team Leader
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
36
app/internal/templates/volunteer/team_builder.html
Normal file
36
app/internal/templates/volunteer/team_builder.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="p-6 space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Team Builder</h1>
|
||||||
|
|
||||||
|
{{range .TeamLeads}}
|
||||||
|
<div class="mb-4 p-4 bg-white rounded shadow">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="font-bold">{{.Name}}</span>
|
||||||
|
<form action="/team_builderx" method="POST" class="flex space-x-2">
|
||||||
|
<input type="hidden" name="team_lead_id" value="{{.ID}}" />
|
||||||
|
<select name="volunteer_id" class="border px-2 py-1 rounded">
|
||||||
|
<option value="">--Select Volunteer--</option>
|
||||||
|
{{range $.UnassignedVolunteers}}
|
||||||
|
<option value="{{.ID}}">{{.Name}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="bg-blue-500 text-white px-3 py-1 rounded">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List of already assigned volunteers -->
|
||||||
|
{{if .Volunteers}}
|
||||||
|
<ul class="mt-2 list-disc list-inside">
|
||||||
|
{{range .Volunteers}}
|
||||||
|
<li>{{.Name}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-gray-500 mt-1">No volunteers assigned yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
299
app/internal/templates/volunteer/volunteer.html
Normal file
299
app/internal/templates/volunteer/volunteer.html
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden" x-data="volunteerTable()">
|
||||||
|
<!-- Top Navigation -->
|
||||||
|
<div class="bg-white border-b border-gray-200 px-6 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i
|
||||||
|
class="{{if .PageIcon}}{{.PageIcon}}{{else}}fas fa-users{{end}} text-blue-600"
|
||||||
|
></i>
|
||||||
|
<span class="text-sm font-medium">Volunteers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
|
||||||
|
<div class="flex items-center gap-4 text-sm">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="relative">
|
||||||
|
<i
|
||||||
|
class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"
|
||||||
|
></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="searchTerm"
|
||||||
|
placeholder="Search volunteers..."
|
||||||
|
class="w-64 pl-8 pr-3 py-2 text-sm border border-gray-200 bg-white focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Role Filter -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="roleFilter" class="text-gray-600 font-medium">Role:</label>
|
||||||
|
<select
|
||||||
|
x-model="roleFilter"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">All Roles</option>
|
||||||
|
<option value="1">Admin</option>
|
||||||
|
<option value="2">Team Leader</option>
|
||||||
|
<option value="3">Volunteer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear Filters -->
|
||||||
|
<button
|
||||||
|
@click="clearFilters()"
|
||||||
|
class="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times mr-1"></i>Clear
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Results Count -->
|
||||||
|
<div class="ml-auto">
|
||||||
|
<span class="text-gray-600 text-sm">
|
||||||
|
Showing <span x-text="filteredVolunteers.length"></span> of
|
||||||
|
<span x-text="volunteers.length"></span> volunteers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Wrapper -->
|
||||||
|
<div
|
||||||
|
class="flex-1 overflow-x-auto overflow-y-auto bg-white border border-gray-100"
|
||||||
|
>
|
||||||
|
<table class="w-full divide-gray-200 text-sm table-auto">
|
||||||
|
<!-- Table Head -->
|
||||||
|
<thead class="bg-gray-50 divide-gray-200">
|
||||||
|
<tr
|
||||||
|
class="text-left text-gray-700 font-medium border-b border-gray-200"
|
||||||
|
>
|
||||||
|
<th class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
|
@click="sortBy('UserID')"
|
||||||
|
>
|
||||||
|
ID <i class="fas" :class="getSortIcon('UserID')"></i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
|
@click="sortBy('FirstName')"
|
||||||
|
>
|
||||||
|
First Name <i class="fas" :class="getSortIcon('FirstName')"></i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
|
@click="sortBy('LastName')"
|
||||||
|
>
|
||||||
|
Last Name <i class="fas" :class="getSortIcon('LastName')"></i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
|
@click="sortBy('Email')"
|
||||||
|
>
|
||||||
|
Email <i class="fas" :class="getSortIcon('Email')"></i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
|
@click="sortBy('Phone')"
|
||||||
|
>
|
||||||
|
Phone <i class="fas" :class="getSortIcon('Phone')"></i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 cursor-pointer"
|
||||||
|
@click="sortBy('RoleID')"
|
||||||
|
>
|
||||||
|
Role <i class="fas" :class="getSortIcon('RoleID')"></i>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 whitespace-nowrap">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<!-- Table Body -->
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
<template
|
||||||
|
x-for="volunteer in filteredVolunteers"
|
||||||
|
:key="volunteer.UserID"
|
||||||
|
>
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td
|
||||||
|
class="px-6 py-3 whitespace-nowrap"
|
||||||
|
x-text="volunteer.UserID"
|
||||||
|
></td>
|
||||||
|
<td
|
||||||
|
class="px-6 py-3 whitespace-nowrap"
|
||||||
|
x-text="volunteer.FirstName"
|
||||||
|
></td>
|
||||||
|
<td
|
||||||
|
class="px-6 py-3 whitespace-nowrap"
|
||||||
|
x-text="volunteer.LastName"
|
||||||
|
></td>
|
||||||
|
<td
|
||||||
|
class="px-6 py-3 whitespace-nowrap"
|
||||||
|
x-text="volunteer.Email"
|
||||||
|
></td>
|
||||||
|
<td
|
||||||
|
class="px-6 py-3 whitespace-nowrap"
|
||||||
|
x-text="volunteer.Phone"
|
||||||
|
></td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800"
|
||||||
|
x-text="getRoleName(volunteer.RoleID)"
|
||||||
|
></span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-3 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
:href="`/volunteer/edit?id=${volunteer.UserID}`"
|
||||||
|
class="text-blue-600 hover:text-blue-800 font-medium text-xs px-2 py-1 hover:bg-blue-50 transition-colors"
|
||||||
|
>Edit</a
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
action="/volunteer/delete"
|
||||||
|
method="POST"
|
||||||
|
class="inline-block"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" :value="volunteer.UserID" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="text-red-600 hover:text-red-800 font-medium text-xs px-2 py-1 hover:bg-red-50 transition-colors"
|
||||||
|
@click="return confirm('Are you sure you want to delete this volunteer?')"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- No Results Message -->
|
||||||
|
<div x-show="filteredVolunteers.length === 0" class="text-center py-12">
|
||||||
|
<i class="fas fa-search text-gray-400 text-3xl mb-4"></i>
|
||||||
|
<p class="text-gray-600 text-lg mb-2">No volunteers found</p>
|
||||||
|
<p class="text-gray-500 text-sm">
|
||||||
|
Try adjusting your search or filter criteria
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
|
||||||
|
defer
|
||||||
|
></script>
|
||||||
|
<script>
|
||||||
|
function volunteerTable() {
|
||||||
|
return {
|
||||||
|
searchTerm: '',
|
||||||
|
roleFilter: '',
|
||||||
|
sortColumn: '',
|
||||||
|
sortDirection: 'asc',
|
||||||
|
volunteers: [
|
||||||
|
{{ range .Users }}
|
||||||
|
{
|
||||||
|
UserID: {{ .UserID }},
|
||||||
|
FirstName: "{{ .FirstName }}",
|
||||||
|
LastName: "{{ .LastName }}",
|
||||||
|
Email: "{{ .Email }}",
|
||||||
|
Phone: "{{ .Phone }}",
|
||||||
|
RoleID: {{ .RoleID }}
|
||||||
|
},
|
||||||
|
{{ end }}
|
||||||
|
],
|
||||||
|
|
||||||
|
get filteredVolunteers() {
|
||||||
|
let filtered = this.volunteers.filter(volunteer => {
|
||||||
|
// Search filter
|
||||||
|
const searchMatch = !this.searchTerm ||
|
||||||
|
volunteer.FirstName.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
||||||
|
volunteer.LastName.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
||||||
|
volunteer.Email.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
|
||||||
|
volunteer.Phone.includes(this.searchTerm);
|
||||||
|
|
||||||
|
// Role filter
|
||||||
|
const roleMatch = !this.roleFilter || volunteer.RoleID.toString() === this.roleFilter;
|
||||||
|
|
||||||
|
return searchMatch && roleMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort filtered results
|
||||||
|
if (this.sortColumn) {
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let aValue = a[this.sortColumn];
|
||||||
|
let bValue = b[this.sortColumn];
|
||||||
|
|
||||||
|
// Handle string comparison
|
||||||
|
if (typeof aValue === 'string') {
|
||||||
|
aValue = aValue.toLowerCase();
|
||||||
|
bValue = bValue.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
let comparison = 0;
|
||||||
|
if (aValue > bValue) comparison = 1;
|
||||||
|
if (aValue < bValue) comparison = -1;
|
||||||
|
|
||||||
|
return this.sortDirection === 'asc' ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
|
|
||||||
|
sortBy(column) {
|
||||||
|
if (this.sortColumn === column) {
|
||||||
|
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
this.sortColumn = column;
|
||||||
|
this.sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSortIcon(column) {
|
||||||
|
if (this.sortColumn !== column) {
|
||||||
|
return 'fa-sort text-gray-400';
|
||||||
|
}
|
||||||
|
return this.sortDirection === 'asc' ? 'fa-sort-up text-blue-600' : 'fa-sort-down text-blue-600';
|
||||||
|
},
|
||||||
|
|
||||||
|
getRoleName(roleId) {
|
||||||
|
switch (roleId) {
|
||||||
|
case 1: return 'Admin';
|
||||||
|
case 2: return 'Team Leader';
|
||||||
|
case 3: return 'Volunteer';
|
||||||
|
default: return 'Unknown';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFilters() {
|
||||||
|
this.searchTerm = '';
|
||||||
|
this.roleFilter = '';
|
||||||
|
this.sortColumn = '';
|
||||||
|
this.sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
69
app/internal/utils/render.go
Normal file
69
app/internal/utils/render.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper functions for templates
|
||||||
|
var templateFuncs = template.FuncMap{
|
||||||
|
"add": func(a, b int) int {
|
||||||
|
return a + b
|
||||||
|
},
|
||||||
|
"sub": func(a, b int) int {
|
||||||
|
return a - b
|
||||||
|
},
|
||||||
|
"eq": func(a, b interface{}) bool {
|
||||||
|
return a == b
|
||||||
|
},
|
||||||
|
"pageRange": func(currentPage, totalPages int) []int {
|
||||||
|
// Generate page numbers to show (max 7 pages)
|
||||||
|
start := currentPage - 3
|
||||||
|
end := currentPage + 3
|
||||||
|
|
||||||
|
if start < 1 {
|
||||||
|
end += 1 - start
|
||||||
|
start = 1
|
||||||
|
}
|
||||||
|
if end > totalPages {
|
||||||
|
start -= end - totalPages
|
||||||
|
end = totalPages
|
||||||
|
}
|
||||||
|
if start < 1 {
|
||||||
|
start = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var pages []int
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
pages = append(pages, i)
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Render(w http.ResponseWriter, tmpl string, data interface{}) {
|
||||||
|
// Paths for layout + page templates
|
||||||
|
layout := filepath.Join("/Users/mannpatel/Desktop/Poll-system/app/internal/templates/", "layout.html")
|
||||||
|
page := filepath.Join("/Users/mannpatel/Desktop/Poll-system/app/internal/templates/", tmpl)
|
||||||
|
|
||||||
|
// Parse files with helper functions
|
||||||
|
tmpls, err := template.New("").Funcs(templateFuncs).ParseFiles(layout, page)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Template parsing error: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render to buffer first (catch errors before writing response)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = tmpls.ExecuteTemplate(&buf, "layout", data)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Template execution error: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write final HTML to response
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
buf.WriteTo(w)
|
||||||
|
}
|
||||||
182
app/main.go
Normal file
182
app/main.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// Add this debugging code to your main.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/handlers"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/models"
|
||||||
|
"github.com/patel-mann/poll-system/app/internal/utils"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq" // use PostgreSQL
|
||||||
|
)
|
||||||
|
|
||||||
|
var jwtSecret = []byte("your-secret-key")
|
||||||
|
|
||||||
|
// Custom file server with logging
|
||||||
|
func loggingFileServer(dir string) http.Handler {
|
||||||
|
fs := http.FileServer(http.Dir(dir))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Log the request
|
||||||
|
log.Printf("File request: %s", r.URL.Path)
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
filePath := filepath.Join(dir, r.URL.Path)
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
log.Printf("File not found: %s", filePath)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Serving file: %s", filePath)
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to determine navigation visibility based on role
|
||||||
|
func getNavFlags(role int) (bool, bool, bool) {
|
||||||
|
showAdminNav := role == 1 // Admin role
|
||||||
|
showLeaderNav := role == 2 // Volunteer role
|
||||||
|
showVolunteerNav := role == 3 // Volunteer role
|
||||||
|
return showAdminNav, showVolunteerNav, showLeaderNav
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to create template data with proper nav flags
|
||||||
|
func createTemplateData(title, activeSection string, role int, isAuthenticated bool, additionalData map[string]interface{}) map[string]interface{} {
|
||||||
|
showAdminNav, showVolunteerNav, _ := getNavFlags(role)
|
||||||
|
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"Title": title,
|
||||||
|
"IsAuthenticated": isAuthenticated,
|
||||||
|
"Role": role,
|
||||||
|
"ShowAdminNav": showAdminNav,
|
||||||
|
"ShowVolunteerNav": showVolunteerNav,
|
||||||
|
"ActiveSection": activeSection,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any additional data
|
||||||
|
for key, value := range additionalData {
|
||||||
|
data[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie("session")
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &models.Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return jwtSecret, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user info to context
|
||||||
|
ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
|
||||||
|
ctx = context.WithValue(ctx, "user_role", claims.Role)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin middleware to check if user has admin role
|
||||||
|
func adminMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
role, ok := r.Context().Value("user_role").(int)
|
||||||
|
fmt.Print(role)
|
||||||
|
if !ok || role != 1 {
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volunteer middleware to check if user has volunteer role
|
||||||
|
func volunteerMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
role, ok := r.Context().Value("user_role").(int)
|
||||||
|
if !ok || (role != 3 && role != 2) {
|
||||||
|
fmt.Printf("Access denied: role %d not allowed\n", role) // Debug log
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Updated handler functions using the helper
|
||||||
|
func schedualHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
role := r.Context().Value("user_role").(int)
|
||||||
|
|
||||||
|
data := createTemplateData("My Schedule", "schedual", role, true, nil)
|
||||||
|
utils.Render(w, "Schedual/schedual.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HomeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
utils.Render(w, "dashboard/dashboard.html", map[string]interface{}{
|
||||||
|
"Title": "Admin Dashboard",
|
||||||
|
"IsAuthenticated": false,
|
||||||
|
"ActiveSection": "dashboard",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
models.InitDB()
|
||||||
|
|
||||||
|
// Static file servers with logging
|
||||||
|
fs := http.FileServer(http.Dir("static"))
|
||||||
|
http.Handle("/static/", http.StripPrefix("/static/", fs))
|
||||||
|
|
||||||
|
// Use logging file server for uploads
|
||||||
|
http.Handle("/uploads/", http.StripPrefix("/uploads/", loggingFileServer("uploads")))
|
||||||
|
|
||||||
|
// Public HTML Routes
|
||||||
|
http.HandleFunc("/", HomeHandler)
|
||||||
|
http.HandleFunc("/login", handlers.LoginHandler)
|
||||||
|
http.HandleFunc("/register", handlers.RegisterHandler)
|
||||||
|
|
||||||
|
//--- Protected HTML Routes
|
||||||
|
http.HandleFunc("/logout", authMiddleware(handlers.LogoutHandler))
|
||||||
|
|
||||||
|
// Common routes (both admin and volunteer can access)
|
||||||
|
http.HandleFunc("/profile", authMiddleware(handlers.ProfileHandler))
|
||||||
|
http.HandleFunc("/profile/update", authMiddleware(handlers.ProfileUpdateHandler))
|
||||||
|
|
||||||
|
//--- Admin-only routes
|
||||||
|
http.HandleFunc("/dashboard", adminMiddleware(handlers.AdminDashboardHandler))
|
||||||
|
http.HandleFunc("/volunteers", adminMiddleware(handlers.VolunteerHandler))
|
||||||
|
http.HandleFunc("/volunteer/edit", adminMiddleware(handlers.EditVolunteerHandler))
|
||||||
|
|
||||||
|
http.HandleFunc("/team_builder", adminMiddleware(handlers.TeamBuilderHandler))
|
||||||
|
|
||||||
|
http.HandleFunc("/addresses", adminMiddleware(handlers.AddressHandler))
|
||||||
|
http.HandleFunc("/posts", adminMiddleware(handlers.PostsHandler))
|
||||||
|
|
||||||
|
//--- Volunteer-only routes
|
||||||
|
http.HandleFunc("/volunteer/dashboard", volunteerMiddleware(handlers.VolunteerPostsHandler))
|
||||||
|
http.HandleFunc("/schedual", volunteerMiddleware(schedualHandler))
|
||||||
|
|
||||||
|
log.Println("Server started on localhost:8080")
|
||||||
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
|
}
|
||||||
1
app/tmp/build-errors.log
Normal file
1
app/tmp/build-errors.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1
|
||||||
BIN
app/tmp/main
Executable file
BIN
app/tmp/main
Executable file
Binary file not shown.
BIN
app/uploads/3_1756233257286246000.jpg
Normal file
BIN
app/uploads/3_1756233257286246000.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 MiB |
BIN
app/uploads/3_1756234910125847000.jpeg
Normal file
BIN
app/uploads/3_1756234910125847000.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
app/uploads/3_1756234944747970000.jpg
Normal file
BIN
app/uploads/3_1756234944747970000.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
BIN
app/uploads/3_1756235257999355000.jpg
Normal file
BIN
app/uploads/3_1756235257999355000.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 282 KiB |
BIN
app/uploads/3_1756236773454920000.jpeg
Normal file
BIN
app/uploads/3_1756236773454920000.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
Reference in New Issue
Block a user