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;
+}