Initial commit

This commit is contained in:
Mann Patel
2025-08-26 14:13:09 -06:00
commit 23f6b359ca
39 changed files with 4606 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

201
LICENSE Normal file
View 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.

2
README.MD Normal file
View File

@@ -0,0 +1,2 @@
# Poll-system

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

9
app/.env Normal file
View 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
View 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
View 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

Binary file not shown.

View 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
}

View 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
}

View 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",
})
}

View 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
}

View 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
//

View 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)
}
}

View 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)
}

View 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
View 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")
}

View 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
}

View 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
}

View 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 }}

View 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 }}

View 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 }}

View 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>&copy; 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 }}

View 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 }}

View 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 }}

View 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 }}

View 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}}

View 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 }}

View 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 }}

View 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
View 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
View 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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB