Adding not just the api but also some initial code for posting members
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,6 +22,3 @@
|
|||||||
go.work
|
go.work
|
||||||
go.work.sum
|
go.work.sum
|
||||||
|
|
||||||
# env file
|
|
||||||
.env
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
DBPath string `json:"db_path"`
|
DBPath string `json:"db_path"`
|
||||||
RedisHost string `json:"redis_host"`
|
RedisHost string `json:"redis_host"`
|
||||||
RedisPassword string `json:"redis_password"`
|
RedisPassword string `json:"redis_password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +17,8 @@ var conf *Config
|
|||||||
var conf_path string = ".env"
|
var conf_path string = ".env"
|
||||||
|
|
||||||
func GetConfig() *Config {
|
func GetConfig() *Config {
|
||||||
|
_ = GenerateEnvFileIfNotExists("./sjles-pta-vote.db")
|
||||||
|
|
||||||
if conf != nil {
|
if conf != nil {
|
||||||
return conf
|
return conf
|
||||||
}
|
}
|
||||||
@@ -25,9 +27,8 @@ func GetConfig() *Config {
|
|||||||
|
|
||||||
// TODO: Make this into a ini or toml file
|
// TODO: Make this into a ini or toml file
|
||||||
configContent, err := os.ReadFile(conf_path)
|
configContent, err := os.ReadFile(conf_path)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error reading .env file: ", err)
|
log.Printf("Error reading .env file: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,11 +43,6 @@ func GetConfig() *Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(configContent), conf); err != nil {
|
|
||||||
fmt.Println("Error unmarshalling config file: ", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Better mapping of key to json values
|
// TODO: Better mapping of key to json values
|
||||||
// TODO: Better error checking if values are missing
|
// TODO: Better error checking if values are missing
|
||||||
// TODO: Default values
|
// TODO: Default values
|
||||||
@@ -58,7 +54,7 @@ func GetConfig() *Config {
|
|||||||
} else if strings.Contains(key, "redis_password") {
|
} else if strings.Contains(key, "redis_password") {
|
||||||
conf.RedisPassword = value
|
conf.RedisPassword = value
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Error, Unknown key value pair: ", key, " = ", value)
|
log.Printf("Error, Unknown key value pair: %s = %s", key, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,3 +64,12 @@ func GetConfig() *Config {
|
|||||||
func SetConfig(init_conf *Config) {
|
func SetConfig(init_conf *Config) {
|
||||||
conf = init_conf
|
conf = init_conf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GenerateEnvFileIfNotExists(dbPath string) error {
|
||||||
|
_, err := os.Stat(".env")
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
envContent := fmt.Sprintf("db_path=\"%s\"\n", dbPath)
|
||||||
|
return os.WriteFile(".env", []byte(envContent), 0644)
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go-sjles-pta-vote/server/config"
|
"go-sjles-pta-vote/server/config"
|
||||||
|
|
||||||
@@ -32,28 +36,39 @@ CREATE TABLE IF NOT EXISTS members (
|
|||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
member_name TEXT,
|
member_name TEXT,
|
||||||
school_year UNSIGNED INT NOT NULL,
|
school_year UNSIGNED INT NOT NULL,
|
||||||
PRIMARY KEY (email)
|
PRIMARY KEY (email, school_year)
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
var db *sql.DB
|
var db *sql.DB
|
||||||
|
|
||||||
func Connect() (*sql.DB, error) {
|
func Connect() (*sql.DB, error) {
|
||||||
|
log.Printf("Connecting to database")
|
||||||
|
|
||||||
db_config := config.GetConfig()
|
db_config := config.GetConfig()
|
||||||
|
|
||||||
|
log.Printf("Database path: %s", db_config.DBPath)
|
||||||
db, err := sql.Open("sqlite", db_config.DBPath)
|
db, err := sql.Open("sqlite", db_config.DBPath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("Error opening database: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.Exec(build_db_query)
|
_, err = db.Exec(build_db_query)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error updating schema: %v", err)
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return db, err
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Close() {
|
func Close() {
|
||||||
if db != nil {
|
if db != nil {
|
||||||
_ = db.Close()
|
err := db.Close()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error closing database: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
server/icons/android-chrome-192x192.png
Normal file
BIN
server/icons/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
server/icons/android-chrome-512x512.png
Normal file
BIN
server/icons/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
BIN
server/icons/apple-touch-icon.png
Normal file
BIN
server/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
server/icons/favicon-16x16.png
Normal file
BIN
server/icons/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 845 B |
BIN
server/icons/favicon-32x32.png
Normal file
BIN
server/icons/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
server/icons/favicon.ico
Normal file
BIN
server/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
server/icons/site.webmanifest
Normal file
1
server/icons/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@@ -5,13 +5,16 @@ import (
|
|||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"go-sjles-pta-vote/server/models"
|
||||||
"go-sjles-pta-vote/server/services"
|
"go-sjles-pta-vote/server/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func voteHandler(w http.ResponseWriter, r *http.Request) {
|
func voteHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var vote services.Vote
|
var vote models.Vote
|
||||||
if err := json.NewDecoder(r.Body).Decode(&vote); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&vote); err != nil {
|
||||||
http.Error(w, "Invalid request payload", http.StatusBadRequest)
|
http.Error(w, "Invalid request payload", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -28,15 +31,20 @@ func voteHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func voteIDHandler(w http.ResponseWriter, r *http.Request) {
|
func voteIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
id := vars["id"]
|
idStr := vars["id"]
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid poll ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
vote := services.Vote{
|
vote := models.Vote{
|
||||||
PollId: id,
|
PollId: id,
|
||||||
Email: "example@example.com", // Replace with actual email retrieval logic
|
Email: "example@example.com", // Replace with actual email retrieval logic
|
||||||
Vote: true, // Replace with actual vote retrieval logic
|
Vote: true, // Replace with actual vote retrieval logic
|
||||||
}
|
}
|
||||||
|
|
||||||
err := services.SetVote(&vote)
|
err = services.SetVote(&vote)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -46,16 +54,24 @@ func voteIDHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func statsHandler(w http.ResponseWriter, r *http.Request) {
|
func statsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
if r.Method == "GET" {
|
||||||
id := vars["id"]
|
filePath := "./server/templates/stats.html"
|
||||||
|
log.Printf("Serving stats.html from %s", filePath)
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
} else if r.Method == "POST" {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
poll, err := services.GetPollByQuestion(id)
|
poll, err := services.GetPollByQuestion(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(poll)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(poll)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func statsIDHandler(w http.ResponseWriter, r *http.Request) {
|
func statsIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -77,16 +93,31 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func adminIDHandler(w http.ResponseWriter, r *http.Request) {
|
func adminIDHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
//vars := mux.Vars(r)
|
||||||
id := vars["id"]
|
//id := vars["id"]
|
||||||
|
|
||||||
// Add admin functionality here
|
// Add admin functionality here
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func membersHandler(w http.ResponseWriter, r *http.Request) {
|
func adminMembersHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == "POST" {
|
if r.Method == "GET" {
|
||||||
file, handler, err := r.FormFile("members.csv")
|
filePath := "./server/templates/members.html"
|
||||||
|
log.Printf("Serving members.html from %s", filePath)
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
} else if r.Method == "POST" {
|
||||||
|
var year int
|
||||||
|
var err error
|
||||||
|
r.ParseForm()
|
||||||
|
if y := r.FormValue("year"); y != "" {
|
||||||
|
year, err = strconv.Atoi(y)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid year", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file, _, err := r.FormFile("members.csv")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to upload file", http.StatusBadRequest)
|
http.Error(w, "Failed to upload file", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -99,28 +130,37 @@ func membersHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
members, err := services.ParseMembersFromBytes(2023, fileBytes) // Assuming year 2023 for demonstration purposes
|
err = services.ParseMembersFromBytes(year, fileBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(members)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
||||||
} else {
|
} else {
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
r := mux.NewRouter()
|
log.SetOutput(os.Stdout)
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
|
||||||
r.HandleFunc("/vote", voteHandler).Methods("POST")
|
log.Printf("Starting server on :8080")
|
||||||
r.HandleFunc("/vote/{id}", voteIDHandler).Methods("POST")
|
|
||||||
r.HandleFunc("/stats", statsHandler).Methods("POST")
|
|
||||||
r.HandleFunc("/stats/{id}", statsIDHandler).Methods("POST")
|
|
||||||
r.HandleFunc("/admin", adminHandler).Methods("GET")
|
|
||||||
r.HandleFunc("/admin/{id}", adminIDHandler).Methods("GET")
|
|
||||||
r.HandleFunc("/admin/members", membersHandler).Methods("POST")
|
|
||||||
|
|
||||||
log.Fatal(http.ListenAndServe(":8080", r))
|
http.HandleFunc("/vote", voteHandler)
|
||||||
|
http.HandleFunc("/vote/{id}", voteIDHandler)
|
||||||
|
http.HandleFunc("/stats", statsHandler)
|
||||||
|
http.HandleFunc("/stats/{id}", statsIDHandler)
|
||||||
|
http.HandleFunc("/admin", adminHandler)
|
||||||
|
http.HandleFunc("/admin/{id}", adminIDHandler)
|
||||||
|
http.HandleFunc("/admin/members", adminMembersHandler)
|
||||||
|
http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
filePath := "./server/icons/favicon.ico"
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package services
|
|||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"go-sjles-pta-vote/server/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Member struct {
|
type Member struct {
|
||||||
@@ -13,14 +16,18 @@ type Member struct {
|
|||||||
Email string
|
Email string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseMembersFromBytes(year int, fileBytes []byte) ([]Member, error) {
|
BATCH_SIZE := 100
|
||||||
|
|
||||||
|
func ParseMembersFromBytes(year int, fileBytes []byte) error {
|
||||||
reader := csv.NewReader(strings.NewReader(string(fileBytes)))
|
reader := csv.NewReader(strings.NewReader(string(fileBytes)))
|
||||||
|
reader.FieldsPerRecord = -1 // Allow variable number of fields per record
|
||||||
records, err := reader.ReadAll()
|
records, err := reader.ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "failed to read CSV from bytes")
|
return errors.Wrap(err, "failed to read CSV from bytes")
|
||||||
}
|
}
|
||||||
|
|
||||||
var members []Member
|
var members []Member
|
||||||
|
|
||||||
for i, record := range records {
|
for i, record := range records {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
continue // Skip the first line (column headers)
|
continue // Skip the first line (column headers)
|
||||||
@@ -28,18 +35,86 @@ func ParseMembersFromBytes(year int, fileBytes []byte) ([]Member, error) {
|
|||||||
if len(record) < 4 {
|
if len(record) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
firstName := record[1]
|
|
||||||
lastName := record[2]
|
firstName := strings.TrimSpace(record[1])
|
||||||
email := record[3]
|
lastName := strings.TrimSpace(record[2])
|
||||||
|
email := strings.TrimSpace(record[3])
|
||||||
|
|
||||||
members = append(members, Member{
|
members = append(members, Member{
|
||||||
Name: fmt.Sprintf("%s %s", firstName, lastName),
|
Name: fmt.Sprintf("%s %s", firstName, lastName),
|
||||||
Email: email,
|
Email: email,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if len(record) < 30 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
email2 := strings.TrimSpace(record[27])
|
||||||
|
if email2 != "" {
|
||||||
|
firstName2 := strings.TrimSpace(record[29])
|
||||||
|
lastName2 := strings.TrimSpace(record[28])
|
||||||
|
|
||||||
|
members = append(members, Member{
|
||||||
|
Name: fmt.Sprintf("%s %s", firstName2, lastName2),
|
||||||
|
Email: email2,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(members) == 0 {
|
return saveMember(year, members)
|
||||||
members = []Member{}
|
}
|
||||||
}
|
|
||||||
|
func saveMember(year int, members []Member) error {
|
||||||
return members, nil
|
insertMembersQuery := `
|
||||||
|
INSERT OR REPLACE INTO members (email, member_name, school_year)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`
|
||||||
|
log.Printf("Starting to save %d members for year %d", len(members), year)
|
||||||
|
|
||||||
|
db_conn, err := db.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to connect to database")
|
||||||
|
}
|
||||||
|
defer db_conn.Close()
|
||||||
|
|
||||||
|
tx, err := db_conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to begin transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(insertMembersQuery)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return errors.Wrap(err, "failed to prepare statement")
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for index, member := range members {
|
||||||
|
_, err = stmt.Exec(member.Email, member.Name, year)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return errors.Wrap(err, "failed to execute insert")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index+1) % BATCH_SIZE == 0 {
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return errors.Wrap(err, "failed to commit transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err = db_conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to begin new transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt, err = tx.Prepare(insertMembersQuery)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return errors.Wrap(err, "failed to prepare new statement")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
18
server/templates/members.html
Normal file
18
server/templates/members.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Upload Members</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Upload Members CSV</h1>
|
||||||
|
<form action="/admin/members" method="post" enctype="multipart/form-data">
|
||||||
|
<label for="year">Year:</label>
|
||||||
|
<input type="number" id="year" name="year" required><br><br>
|
||||||
|
<label for="members.csv">CSV File:</label>
|
||||||
|
<input type="file" id="members.csv" name="members.csv" accept=".csv" required><br><br>
|
||||||
|
<button type="submit">Upload</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
server/templates/stats.html
Normal file
10
server/templates/stats.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Stats</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Hello World!</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user