diff --git a/main.go b/main.go index 3f39f9e..4ff8ef8 100644 --- a/main.go +++ b/main.go @@ -5,22 +5,43 @@ import ( "html/template" "log" "net/http" - "time" ) type handler = func(http.ResponseWriter, *http.Request) -func generateHandler(file string, handler func(), data func() any) handler { - tmpl := template.Must(template.ParseFiles(fmt.Sprintf("templates/%s", file))) +func generateHandler( + file string, + handler func(http.ResponseWriter, *http.Request), + data func(http.ResponseWriter, *http.Request) any, + methods []string, +) handler { + var tmpl *template.Template + if file != "" { + tmpl = template.Must(template.ParseFiles(fmt.Sprintf("templates/%s", file))) + } return func(w http.ResponseWriter, r *http.Request) { - handler() - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tmpl.Execute(w, data()) + for _, method := range methods { + if method == r.Method { + goto ok + } + } + w.WriteHeader(http.StatusMethodNotAllowed) + return + ok: + handler(w, r) + if tmpl != nil { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl.Execute(w, data(w, r)) + } } } -func generateSseHandler(handler func(http.ResponseWriter, *http.Request)) handler { +func generateSseHandler(handler handler) handler { return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") @@ -29,25 +50,116 @@ func generateSseHandler(handler func(http.ResponseWriter, *http.Request)) handle } } -func generateCounter() handler { +type client = chan []event + +type clientSet struct { + clients map[client]bool +} + +func newClientSet() clientSet { + return clientSet{make(map[client]bool)} +} + +func (clients clientSet) broadcast(events ...event) { + for client := range clients.clients { + client <- events + } +} + +func (clients clientSet) playerCountEvent(offset int) event { + playerCount := len(clients.clients) + offset + var data string + if playerCount == 0 { + data = "No players online." + } else { + data = fmt.Sprintf("%d player%s online.", playerCount, map[bool]string{true: "", false: "s"}[playerCount == 1]) + } + return event{eventPlayerCount, data} +} + +func (clients clientSet) broadcastPlayerCount(offset int) { + clients.broadcast(clients.playerCountEvent(offset)) +} + +func (clients clientSet) broadcastWord(word string) { + clients.broadcast( + event{eventWord, fmt.Sprintf("
%s
", word)}, + event{eventNavigate, fmt.Sprintf("https://jisho.org/search/%s", word)}, + ) +} + +func (clients clientSet) connect(client client) { + // Channels are blocking. + // Client initialization is waiting for connect() to complete. + // If we try to braodcast to it before connect() is done, + // the channel handling logic wouldn't have started yet, + // and broadcast() would be freeze. + // Therefore, we must broadcast an offset player count first, + // then actually add the client. + clients.broadcastPlayerCount(1) + clients.clients[client] = true +} + +func (clients clientSet) disconnect(client client) { + delete(clients.clients, client) + clients.broadcastPlayerCount(0) +} + +var clients = newClientSet() + +type event struct { + eventName string + data string +} + +func (event event) encode() string { + return fmt.Sprintf("event: %s\ndata: %s\n\n", event.eventName, event.data) +} + +const eventWord = "word" +const eventPlayerCount = "players" +const eventNavigate = "navigate" + +func generateWordBroadcaster() handler { return generateSseHandler(func(w http.ResponseWriter, r *http.Request) { - tick := time.Tick(500 * time.Millisecond) + sendEvent := func(events ...event) { + for _, event := range events { + fmt.Fprint(w, event.encode()) + } + w.(http.Flusher).Flush() + } + sendEvent(clients.playerCountEvent(1)) ctx := r.Context() + channel := make(client) + clients.connect(channel) outer: - for i := 0; ; i++ { + for { select { case <-ctx.Done(): + clients.disconnect(channel) break outer - case <-tick: - fmt.Fprintf(w, "event: count\ndata:
%d
\n\n", i) - w.(http.Flusher).Flush() + case events := <-channel: + sendEvent(events...) } } }) } +func generateWordHandler() handler { + return generateHandler( + "", + func(w http.ResponseWriter, r *http.Request) { + r.ParseMultipartForm(0) + clients.broadcastWord(r.FormValue("word")) + }, + nil, + []string{http.MethodPost}, + ) +} + func main() { http.Handle("/", http.FileServer(http.Dir("static"))) - http.HandleFunc("/api/counter", generateCounter()) + http.HandleFunc("/api/events", generateWordBroadcaster()) + http.HandleFunc("/api/submit", generateWordHandler()) log.Fatal(http.ListenAndServe(":3333", nil)) } diff --git a/static/index.html b/static/index.html index d7952c6..e7f1d32 100644 --- a/static/index.html +++ b/static/index.html @@ -4,9 +4,39 @@ Shiritori + - -
+ +
+
+
+
+
+
+ +
+
+ +
+

+
+ +
+ Change room: +
+
\ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..efd85c7 --- /dev/null +++ b/static/style.css @@ -0,0 +1,92 @@ +* { + font-family: sans-serif; + color: ghostwhite; +} +body { + background: slategrey; + font-size: 1.75em; + display: flex; + height: 100vh; + align-items: center; + justify-content: center; + text-align: center; +} +div#content { + width: 32em; + max-width: 100%; +} +div#shiritori p { + margin: 0; + animation-duration: 1s; + animation-name: slidein; + animation-fill-mode: forwards; + opacity: 0; +} +@keyframes slidein { + from { + transform: translateX(-32px); + opacity: 0; + } + + to { + transform: none; + opacity: 1; + } +} +div#shiritori { + padding: 2px; + height: 24em; + overflow-y: scroll; +} +div#shiritori, input#shiritori-input, #split > :last-child { + background: rgba(0, 0, 0, 0.25); + border: 1px solid; + border-radius: 4px; + box-sizing: border-box; +} +div#shiritori, input#shiritori-input { + padding: 0.25em; +} +input { + font-size: 1em; + background: rgba(0, 0, 0, 0.25); + border: none; + padding: 0.25em; +} +input#shiritori-input { + width: 100%; + + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +input#shiritori-input:focus { + outline: none; + background: rgba(0, 0, 0, 0.125); +} +div#shiritori { + border-bottom: none; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} +#split { + display: flex; + gap: 0.5em; + flex-direction: row; +} +#split > * { + width: 16em; +} +a { + text-decoration: none; +} +a:hover { + border-bottom: 2px solid; +} +button { + background: rgba(0, 0, 0, 0.25); + font-size: inherit; + cursor: pointer; + padding: 1em; + border: 0; +}