diff options
| author | Raymaekers Luca <luca@spacehb.net> | 2025-11-07 18:26:36 +0100 |
|---|---|---|
| committer | Raymaekers Luca <luca@spacehb.net> | 2025-11-07 18:26:36 +0100 |
| commit | 18d189f5c79ed30a96410c9443eddb80c7a89d1e (patch) | |
| tree | 6778b725b8a13ea3d71d2f192c452b8ce07a4b3b /code | |
| parent | bef456df253a057c536b6fda209c45c8807eee4b (diff) | |
Organized so it is easier to share code across modules
Diffstat (limited to 'code')
| -rw-r--r-- | code/index.tmpl.html | 321 | ||||
| -rw-r--r-- | code/noelan.go | 548 |
2 files changed, 869 insertions, 0 deletions
diff --git a/code/index.tmpl.html b/code/index.tmpl.html new file mode 100644 index 0000000..cedd378 --- /dev/null +++ b/code/index.tmpl.html @@ -0,0 +1,321 @@ +<!DOCTYPE html> +<html lang="fr" data-theme="dark"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + + <meta property="og:title" content="Cadeaux de noël 2025"/> + <meta property="og:description" content="À qui offrir le cadeau de noël?"/> + + <meta property="og:image" content="/static/favicon.ico" /> + + <link rel="icon" type="image/x-icon" href="/static/favicon.ico"> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> + <style> +h1 { + display: flex; + justify-content: center; +} + +button { + font-size: 1rem; +} + +.buttons { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: 50vh; +} + +span.name { + color: #87bfcf; +} + +textarea { + display: block !important; +} + +.fade { + animation: fadeOut 1s forwards; /* Duration and direction of the animation */ +} + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + + </style> + <script> +"use strict"; + +//- Globals +let people = +[{{ range .People }} + {"name": "{{.Name}}", "has_picked": {{.HasPicked}},}, {{ end }} +]; + +let local_storage_key = "{{.Key}}"; + +let global_all_data = { + initialized: false, + token: null, + thisName: null, + thisWishlist: null, + otherName: null, + otherWishlist: null, +}; + +//- Helpers +function findPersonByName(picking_person_name) { + let result = null; + + for(let peopleIndex = 0; peopleIndex < people.length; peopleIndex += 1) + { + if(people[peopleIndex].name === picking_person_name) + { + result = people[peopleIndex]; + break; + } + } + + return result; +} + +//- Pages +function setPageChoose() { + document.body.innerHTML = ` + <div> + <h1>Qui es-tu?</h1> + <div class="buttons"> + </div> + </div> + `; + + let buttons = document.querySelector("div.buttons"); + for(let index = 0; index < people.length; index += 1) + { + let person = people[index]; + if(!person.has_picked) + { + let button = document.createElement('button'); + button.textContent = people[index].name; + buttons.appendChild(button); + button.addEventListener("click", function(event) { + event.preventDefault(); + + let name = person.name; + let thisPerson = findPersonByName(name); + + document.body.innerHTML = ` + <h1>Tu es bien ${thisPerson.name}?</h1> + <div class="buttons"> + <button id="yes">Oui</button> + <button id="no">Non</button> + </div>`; + + let yes_button = document.getElementById("yes"); + let no_button = document.getElementById("no"); + + yes_button.addEventListener("click", async function(event) { + event.preventDefault(); + await fetch(`/choose/?name=${thisPerson.name}`) + .then(function(response) { + if (!response.ok) { + console.error('Network response was not ok'); + } + return response.json(); + }) + .then(function(response) { + global_all_data.token = response.Token; + global_all_data.thisName = thisPerson.name; + global_all_data.thisWishlist = response.ThisWishlist; + global_all_data.otherName = response.OtherName; + global_all_data.otherWishlist = response.OtherWishlist; + global_all_data.initialized = true; + + localStorage.setItem(local_storage_key, JSON.stringify(global_all_data)); + }) + .catch(function(error) { + console.error('There was a problem with the fetch operation:', error); + }); + + setPageThisPerson(); + }); + + no_button.addEventListener("click", function(event) { + event.preventDefault(); + setPageChoose(); + }); + + }); // button click even listener + } // has_picked + } // for loop +} + +function setPageOtherPerson() { + let wishlistID = "wishlist"; + document.body.innerHTML = ` + <div> + <h1>Tu as <span class="name">${global_all_data.otherName}</span>.</h1> + <h4>Sa liste des souhaits:</h4> + <textarea readonly rows="5" cols="40" id="${wishlistID}" placeholder="vide..."></textarea> + </div> + `; + let wishlist = document.getElementById(wishlistID); + + wishlist.textContent = global_all_data.otherWishlist; + + fetch(`/list/?name=${global_all_data.thisName}&token=${global_all_data.token}`) + .then(function(response) { + if (!response.ok) { + console.error('Network response was not ok'); + } + return response.text(); + }) + .then(function(response) { + if(wishlist.textContent != response) + { + global_all_data.otherWishlist = response; + wishlist.textContent = global_all_data.otherWishlist; + localStorage.setItem(local_storage_key, JSON.stringify(global_all_data)); + } + }) + .catch(function(error) { + console.error('There was a problem with the fetch operation:', error); + }); + +} + +function setPageThisPerson() { + let showPersonButtonID = "show-person"; + let writeLetterButtonID = "write-letter"; + + document.body.innerHTML = ` + <main class="container"> + <h1>Noël-An de <span class="name">${global_all_data.thisName}</span></h1> + <div class="buttons"> + <button id="${showPersonButtonID}">Voir qui j'ai choisi</button> + <button id="${writeLetterButtonID}">Ma liste des souhaits</button> + </div> + <h4><b>Infos:</b></h4> + <ul> + <li>Ne dévoilez à persone qui vous avez tiré au sort!</li> + <li>Vous pouvez écrire une liste de souhaits, la personne ayant tiré votre nom y a accès.</li> + <li>Il n'est pas obligé de respecter la liste ou d'en écrire une. Elle sert pour inspirer des idées de cadeaux.</li> + <li>Si vous avez un soucis n'hésitez-pas à me contacter !</li> + </ul> + </main> + `; + + let showPersonButton = document.getElementById(showPersonButtonID); + showPersonButton.addEventListener("click", function(event) { + event.preventDefault(); + + setPageOtherPerson(); + }); + + let writeLetterButton = document.getElementById(writeLetterButtonID); + writeLetterButton.addEventListener("click", function(event) { + event.preventDefault(); + + let sendLetterButtonID = "send-letter"; + let letterTextAreaID = "letter-text"; + + document.body.innerHTML = ` + <div> + <h1>Liste des souhaits</h1> + <textarea id="${letterTextAreaID}" rows="5" cols="40" placeholder="..."></textarea> + <div class="buttons"> + <button id="${sendLetterButtonID}">sauvegarder</button> + </div> + </div> + `; + + let letterTextArea = document.getElementById(letterTextAreaID); + letterTextArea.textContent = global_all_data.thisWishlist; + + let sendLetterButton = document.getElementById(sendLetterButtonID); + sendLetterButton.addEventListener("click", function(event) { + event.preventDefault(); + + console.log("Sending list of", global_all_data.thisName); + + global_all_data.thisWishlist = letterTextArea.value + localStorage.setItem(local_storage_key, JSON.stringify(global_all_data)); + + let messageBody = { + name: global_all_data.thisName, + token: global_all_data.token, + text: letterTextArea.value, + }; + const postBody = new URLSearchParams(messageBody).toString(); + + fetch('/list/', { + method: 'POST', + headers: + { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: postBody, + }) + .then(function(response) { + response.text(). + then(function(text_response) { + const textElement = document.createElement('div'); + textElement.textContent = 'Sauvegardé'; + textElement.classList.add('fade'); + let buttons = document.querySelector("div.buttons"); + buttons.appendChild(textElement); + setTimeout(function() { + textElement.remove(); + }, 1000); + }) + }) + .catch(function(error) { console.error('Error:', error); }); + + }); // add event listener send button + }); // add event listener write letter +} + +//- Main +window.onload = function() { + // NOTE(luca): Users will expect going back to go to the home page so we do this manually. + var stateObj = { home: "home" }; + history.pushState(stateObj, "home", "/"); + window.addEventListener('popstate', function(event) { + window.location.reload(); + }); + + let all_data = localStorage.getItem(local_storage_key); + if(!all_data) + { + setPageChoose(); + } + else + { + let data_parsed = JSON.parse(all_data); + global_all_data = data_parsed; + console.log(global_all_data); + + setPageThisPerson(); + } +}; + </script> + </head> + <body> + </body> + +</html> diff --git a/code/noelan.go b/code/noelan.go new file mode 100644 index 0000000..e5bbef9 --- /dev/null +++ b/code/noelan.go @@ -0,0 +1,548 @@ +// Documentation +// +// Secret santa app that's random so people don't have to worry about the picking process. +// No emails or login, just use the domain. +// +// Run it with `go run .` +// +// +// TODOs +// +// TODO: Command line flags +// - serve to serve it +// - unpick to unpick all +// - editor (to have one where i can edit the gob file manually) +// +// TODO: Make it safer by doing this. +// 1. Have no ID +// 2. Choose name +// 3. Get (and store in local storage) +// - other person's name & list +// - this person's name & list +// - token to make requests +// 4. Set this ID picked on server +// +// -> This data is used to know if the person has picked or not. +// +// 1. Already have an ID (in local storage) +// 2. Get +// - Wishlists (for sync) + +package noelan + +//- Libraries +import ( + "encoding/gob" + "encoding/json" + "errors" + "flag" + "fmt" + "html/template" + "math/rand" + "net/http" + "os/signal" + "strconv" + "strings" + + _ "embed" + "io" + "log" + "os" + "time" +) + +// - Types +type Person struct { + Name string + Other int + Wishlist string + HasPicked bool + Token int64 +} + +//- Globals + +var nil_person = Person{Name: "nil"} +var global_local_storage_key int64 +var global_local_storage_key_initialized = false + +//go:embed index.tmpl.html +var global_page_html string + +// - Globals +var global_people = []Person{ + {Name: "Nawel"}, + {Name: "Tobias"}, + {Name: "Luca"}, + {Name: "Lola"}, + {Name: "Aeris"}, + {Name: "Lionel"}, + {Name: "Aurélie"}, + {Name: "Sean"}, + {Name: "Émilie"}, + {Name: "Yves"}, + {Name: "Marthe"}, +} +var global_people_initialized = false + +var global_version int = 2 +var global_data_file_name string = "people.gob" +var global_data_directory_name string = "gobs" + +func EncodeWrapper(encoder *gob.Encoder, value any, logger *log.Logger) { + if err := encoder.Encode(value); err != nil { + logger.Fatalln(err) + } +} + +// - Serializing +func EncodeData(logger *log.Logger, file_name string) { + + file, err := os.Create(file_name) + if err != nil { + logger.Fatalln(err) + } + defer file.Close() + + encoder := gob.NewEncoder(file) + + EncodeWrapper(encoder, global_version, logger) + EncodeWrapper(encoder, global_local_storage_key, logger) + EncodeWrapper(encoder, global_people, logger) + + logger.Printf("saved %d people.\n", len(global_people)) +} + +func DecodeWrapper(decoder *gob.Decoder, value any, logger *log.Logger) { + if err := decoder.Decode(value); err != nil { + logger.Fatalln(err) + } +} + +func DecodeData(logger *log.Logger, file_name string) { + file, err := os.Open(file_name) + if errors.Is(err, os.ErrNotExist) { + logger.Println("No data imported.") + } else if err != nil { + logger.Fatalln(err) + } else { + decoder := gob.NewDecoder(file) + + var version int + DecodeWrapper(decoder, &version, logger) + logger.Printf("datafile@%d program@%d\n", version, global_version) + + // NOTE(luca): this will automatically migrate v1 to v2 + if version == 2 { + DecodeWrapper(decoder, &global_local_storage_key, logger) + global_local_storage_key_initialized = true + } + + DecodeWrapper(decoder, &global_people, logger) + + logger.Printf("Imported %d people.\n", len(global_people)) + + global_people_initialized = true + + if err := file.Close(); err != nil { + logger.Fatalln("Error closing file", err) + } + } +} + +// - Person +func (person Person) String() string { + var digits string + if person.Token > 99999 { + digits = fmt.Sprintf("%d", person.Token)[:6] + } else { + digits = fmt.Sprintf("%d", person.Token) + } + + return fmt.Sprintf("%s_%s(%t)", person.Name, digits, person.HasPicked) +} + +func TemplateToString(template_contents string, people []Person, internal bool) string { + var buf strings.Builder + template_response, err := template.New("tirage").Parse(template_contents) + if err != nil { + fmt.Println(err) + } + + type PageData struct { + People []Person + Key int64 + Internal bool + } + err = template_response.ExecuteTemplate(&buf, "tirage", PageData{People: people, Key: global_local_storage_key, Internal: internal}) + if err != nil { + fmt.Println(err) + } + response := buf.String() + + return response +} + +func FindPersonByName(people []Person, name string) (bool, *Person) { + var found_person *Person + var found bool + + for index, value := range people { + if name == value.Name { + found_person = &people[index] + found = true + break + } + } + + return found, found_person +} + +func FindPersonByOtherName(people []Person, name string) (bool, *Person) { + var found_person *Person + var found bool + + for index, person := range people { + if name == people[person.Other].Name { + found = true + found_person = &people[index] + break + } + } + + return found, found_person +} + +func ShufflePeople(rand *rand.Rand, people []Person, logger *log.Logger) { + // Get a shuffled list that has following constaints + // 1. One person cannot choose itself + // 2. Every person has picked another person + var list []int + correct := false + for !correct { + list = rand.Perm(len(people)) + + correct = true + for i, version := range list { + if version == i { + logger.Println("incorrect, need to reshuffle") + correct = false + break + } + } + } + + // Initialize people + for index, value := range list { + people[index].Other = value + } +} + +func HttpError(logger *log.Logger, message string, person *Person, writer http.ResponseWriter, request *http.Request) { + logger.Printf("Error for %s: %s | %s %s %s %s\n", + person.Name, message, + request.RemoteAddr, request.Method, request.URL, request.Form) + http.Error(writer, message, http.StatusNotFound) +} + +// - Main +func Run() { + var did_work bool + var internal, slow, save bool + var serve, shuffle, unpickall, show_people, reset_tokens bool + var add_person, remove_person, unpick string + var set_local_storage_key int64 + + flag.BoolVar(&internal, "internal", false, "run commands in internal mode") + flag.BoolVar(&slow, "slow", false, "run commands in slow mode") + flag.BoolVar(&save, "save", false, "force saving to data file") + flag.BoolVar(&shuffle, "shuffle", false, "shuffle people again") + flag.BoolVar(&reset_tokens, "reset_tokens", false, "reset tokens of people") + flag.BoolVar(&unpickall, "unpickall", false, "unpick all people") + flag.BoolVar(&show_people, "show_people", false, "show people") + flag.StringVar(&add_person, "add_person", "", "add person by name") + flag.StringVar(&remove_person, "remove_person", "", "remove person by name") + flag.StringVar(&unpick, "unpick", "", "unpick person by name") + flag.Int64Var(&set_local_storage_key, "set_local_storage_key", 0, "Set the local storage key") + flag.BoolVar(&serve, "serve", false, "run http server") + + flag.Parse() + + logger := log.New(os.Stdout, "[noel] ", log.Ldate|log.Ltime) + var seed int64 + if internal { + seed = rand.Int63() + } else { + seed = 7967946373046491984 + } + logger.Println("seed:", seed) + source := rand.NewSource(seed) + seeded_rand := rand.New(source) + rand.Seed(seed) + + // backup + { + file, err := os.Open(global_data_file_name) + defer file.Close() + if errors.Is(err, os.ErrNotExist) { + logger.Printf("Cannot create backup: file '%s' not found.\n", global_data_file_name) + } else if err != nil { + logger.Fatalln(err) + } else { + _, err := os.Stat(global_data_directory_name) + if os.IsNotExist(err) { + err = os.MkdirAll(global_data_directory_name, 0755) + if err != nil { + logger.Printf("Error creating directory: %v\n", err) + } + logger.Println("Directory created successfully") + } else if err != nil { + logger.Printf("Error checking directory: %v\n", err) + } else { + // Directory already exists + } + + now := time.Now() + formatted_now := now.Format("060102_03_04_05") + + destination_file_name := fmt.Sprintf("./%s/%s__%s", global_data_directory_name, formatted_now, global_data_file_name) + destination, err := os.Create(destination_file_name) + if err != nil { + logger.Fatalln("Error creating copy:", err) + } + defer destination.Close() + + _, err = io.Copy(destination, file) + if err == nil { + logger.Println("Created backup.") + } else { + logger.Fatalln("Error creating backup:", err) + } + } + } + + // Init people + { + DecodeData(logger, global_data_file_name) + if !global_people_initialized { + logger.Println("Initialize people.") + ShufflePeople(seeded_rand, global_people, logger) + + for index := range global_people { + // NOTE(luca): since javascript cannot handle big numbers we crop them + global_people[index].Token = seeded_rand.Int63() / 10000 + } + + global_people_initialized = true + } + + } + + if save { + did_work = true + } + + if len(add_person) > 0 { + global_people = append(global_people, Person{Name: add_person}) + fmt.Println(global_people) + did_work = true + } + + if len(remove_person) > 0 { + for index, person := range global_people { + if person.Name == remove_person { + global_people = append(global_people[:index], global_people[index+1:]...) + break + } + } + logger.Println("remove:", remove_person) + did_work = true + } + + if reset_tokens { + did_work = true + // NOTE(luca): Users will need to pick again + global_local_storage_key_initialized = false + + for index := range global_people { + // NOTE(luca): since javascript cannot handle big numbers we crop them + global_people[index].Token = seeded_rand.Int63() / 10000 + } + + logger.Println("reset tokens.") + } + + if unpickall { + did_work = true + for index := range global_people { + global_people[index].HasPicked = false + } + logger.Println("Unpicked all.") + } + + if shuffle { + did_work = true + ShufflePeople(seeded_rand, global_people, logger) + logger.Println("Shuffled people.") + } + + if show_people { + for _, person := range global_people { + fmt.Printf("%12s [%15d] %t\n", person.Name, person.Token, person.HasPicked) + } + } + + if len(unpick) > 0 { + did_work = true + logger.Println("unpick:", unpick) + found, person := FindPersonByName(global_people, unpick) + if found { + person.HasPicked = false + logger.Println(person) + } else { + logger.Fatalln("No such person") + } + } + + if set_local_storage_key != 0 { + did_work = true + global_local_storage_key = set_local_storage_key + global_local_storage_key_initialized = true + } + + if !global_local_storage_key_initialized { + logger.Println("Initialize local storage key.") + global_local_storage_key = rand.Int63() + global_local_storage_key_initialized = true + } + + if serve { + did_work = true + logger.Println("local storage key:", global_local_storage_key) + logger.Println(global_people) + + for index := range global_people { + fmt.Print(global_people[index].Other) + } + fmt.Print("\n") + + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + <-c + + if did_work { + EncodeData(logger, global_data_file_name) + } + + logger.Println("data saved.") + os.Exit(0) + }() + + http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./assets")))) + + http.HandleFunc("/list/", func(writer http.ResponseWriter, request *http.Request) { + if request.Method == http.MethodPost { + name := request.FormValue("name") + text := request.FormValue("text") + token := request.FormValue("token") + + logger.Println("Edit wishlist of", name) + found, person := FindPersonByName(global_people, name) + + if found { + tokenString := strconv.FormatInt(person.Token, 10) + if token == tokenString { + person.Wishlist = text + person.HasPicked = true + + fmt.Fprintln(writer, "ok") + } else { + HttpError(logger, "invalid token", person, writer, request) + } + } else { + HttpError(logger, "no such person", &nil_person, writer, request) + } + } else if request.Method == http.MethodGet { + params := request.URL.Query() + name := params.Get("name") + token := params.Get("token") + + found, person := FindPersonByName(global_people, name) + + if found { + tokenString := strconv.FormatInt(person.Token, 10) + + if token == tokenString { + fmt.Fprint(writer, global_people[person.Other].Wishlist) + } else { + HttpError(logger, "invalid token", person, writer, request) + } + } else { + HttpError(logger, "no such person", &nil_person, writer, request) + } + } + }) + + http.HandleFunc("/choose/", func(writer http.ResponseWriter, request *http.Request) { + params := request.URL.Query() + name := params.Get("name") + found, person := FindPersonByName(global_people, name) + if found { + if !person.HasPicked { + person.HasPicked = true + + type Response struct { + Token int64 + ThisWishlist string + OtherName string + OtherWishlist string + } + other_person := global_people[person.Other] + + response := Response{person.Token, person.Wishlist, other_person.Name, other_person.Wishlist} + + json.NewEncoder(writer).Encode(response) + } else { + HttpError(logger, "person already picked", person, writer, request) + } + } else { + HttpError(logger, "no such person", &nil_person, writer, request) + } + }) + + // Execute the template before-hand since the contents won't change. + response := TemplateToString(global_page_html, global_people, internal) + http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { + if slow { + buffer, err := os.ReadFile("index.tmpl.html") + if err == nil { + file_contents := string(buffer) + response = TemplateToString(file_contents, global_people, internal) + } else { + logger.Println(err) + } + } + + fmt.Fprint(writer, response) + }) + + var address string + if internal { + address = "0.0.0.0:15118" + } else { + address = "localhost:15118" + } + logger.Printf("Listening on http://%s\n", address) + if err := http.ListenAndServe(address, nil); err != nil { + panic(err) + } + + } + + if did_work { + EncodeData(logger, global_data_file_name) + } + + return +} |
