From 24c8c4108d5e8577a3789f38f3be01bcb7c1615f Mon Sep 17 00:00:00 2001 From: Marcel Schramm Date: Thu, 5 Feb 2026 23:48:43 +0100 Subject: [PATCH 1/5] Rewrite old gallery branch, attempt 2 --- internal/api/http.go | 1 + internal/api/v1.go | 20 ++++ internal/frontend/gallery.go | 41 ++++++++ internal/frontend/gallery.js | 120 +++++++++++++++++++++++ internal/frontend/http.go | 2 + internal/frontend/index.go | 32 ++++-- internal/frontend/lobby.go | 27 +++++ internal/frontend/resources/gallery.css | 41 ++++++++ internal/frontend/templates/gallery.html | 51 ++++++++++ internal/game/data.go | 12 +++ internal/game/lobby.go | 10 ++ 11 files changed, 349 insertions(+), 8 deletions(-) create mode 100644 internal/frontend/gallery.go create mode 100644 internal/frontend/gallery.js create mode 100644 internal/frontend/resources/gallery.css create mode 100644 internal/frontend/templates/gallery.html diff --git a/internal/api/http.go b/internal/api/http.go index 315c12b5..54e84876 100644 --- a/internal/api/http.go +++ b/internal/api/http.go @@ -22,6 +22,7 @@ func (handler *V1Handler) SetupRoutes(rootPath string, register func(string, str register("GET", path.Join(v1, "lobby"), handler.getLobbies) register("POST", path.Join(v1, "lobby"), handler.postLobby) + register("GET", path.Join(v1, "lobby", "{lobby_id}", "gallery"), handler.getGallery) register("PATCH", path.Join(v1, "lobby", "{lobby_id}"), handler.patchLobby) // We support both path parameter and cookie. register("PATCH", path.Join(v1, "lobby"), handler.patchLobby) diff --git a/internal/api/v1.go b/internal/api/v1.go index 4203a647..c56f8ab0 100644 --- a/internal/api/v1.go +++ b/internal/api/v1.go @@ -200,6 +200,26 @@ func (handler *V1Handler) postLobby(writer http.ResponseWriter, request *http.Re state.AddLobby(lobby) } +type Gallery []game.GalleryDrawing + +func (handler *V1Handler) getGallery(writer http.ResponseWriter, request *http.Request) { + lobby := state.GetLobby(GetLobbyId(request)) + if lobby == nil { + http.Error(writer, ErrLobbyNotExistent.Error(), http.StatusNotFound) + return + } + + // FIXME Synchronise access to lobby.Drawings. + // The drawings should also be available in an unstarted game. + + if started, err := marshalToHTTPWriter(Gallery(lobby.Drawings), writer); err != nil { + if !started { + http.Error(writer, err.Error(), http.StatusInternalServerError) + } + return + } +} + func (handler *V1Handler) postPlayer(writer http.ResponseWriter, request *http.Request) { lobby := state.GetLobby(request.PathValue("lobby_id")) if lobby == nil { diff --git a/internal/frontend/gallery.go b/internal/frontend/gallery.go new file mode 100644 index 00000000..229df600 --- /dev/null +++ b/internal/frontend/gallery.go @@ -0,0 +1,41 @@ +package frontend + +import ( + "log" + "net/http" + "strings" + + "github.com/scribble-rs/scribble.rs/internal/translations" +) + +type galleryPageData struct { + *BasePageConfig + + LobbyID string + Translation *translations.Translation + Locale string +} + +func (handler *SSRHandler) ssrGallery(writer http.ResponseWriter, request *http.Request) { + userAgent := strings.ToLower(request.UserAgent()) + if !isHumanAgent(userAgent) { + // FIXME Handle robots + return + } + + lobbyId := request.PathValue("lobby_id") + translation, locale := determineTranslation(request) + pageData := &galleryPageData{ + BasePageConfig: handler.basePageConfig, + LobbyID: lobbyId, + Translation: translation, + Locale: locale, + } + + // If the pagedata isn't initialized, it means the synchronized block has exited. + // In this case we don't want to template the lobby, since an error has occurred + // and probably already has been handled. + if err := pageTemplates.ExecuteTemplate(writer, "gallery-page", pageData); err != nil { + log.Printf("Error templating lobby: %s\n", err) + } +} diff --git a/internal/frontend/gallery.js b/internal/frontend/gallery.js new file mode 100644 index 00000000..583473ad --- /dev/null +++ b/internal/frontend/gallery.js @@ -0,0 +1,120 @@ +document.getElementById("prev").addEventListener("click", () => { + prevDrawing(); +}); + +document.getElementById("next").addEventListener("click", () => { + nextDrawing(); +}); + +const getGallery = () => { + return new Promise((resolve, reject) => { + const cachedGallery = sessionStorage.getItem("cached_gallery"); + if (cachedGallery) { + resolve(JSON.parse(cachedGallery)); + return; + } + + fetch("{{.RootPath}}/v1/lobby/{{.LobbyID}}/gallery") + .then((response) => { + response + .json() + .then((json) => { + sessionStorage.setItem( + "cached_gallery", + JSON.stringify(json), + ); + return json; + }) + .then(resolve); + }) + .catch(reject); + }); +}; + +const word = document.getElementById("word"); + +const drawingBoard = document.getElementById("drawing-board"); +const context = drawingBoard.getContext("2d", { alpha: false }); +let imageData; + +function clear(context) { + context.fillStyle = "#FFFFFF"; + context.fillRect(0, 0, drawingBoard.width, drawingBoard.height); + // Refetch, as we don't manually fill here. + imageData = context.getImageData( + 0, + 0, + context.canvas.width, + context.canvas.height, + ); +} +clear(context); + +function setDrawing(drawing) { + clear(context); + + word.innerText = drawing.word; + + drawing.events.forEach((drawElement) => { + const drawData = drawElement.data; + if (drawElement.type === "fill") { + floodfillUint8ClampedArray( + imageData.data, + drawData.x, + drawData.y, + indexToRgbColor(drawData.color), + imageData.width, + imageData.height, + ); + } else if (drawElement.type === "line") { + drawLineNoPut( + context, + imageData, + drawData.x, + drawData.y, + drawData.x2, + drawData.y2, + indexToRgbColor(drawData.color), + drawData.width, + ); + } else { + console.log("Unknown draw element type: " + drawData.type); + } + }); + + context.putImageData(imageData, 0, 0); +} + +let currentIndex = 0; +let galleryData; + +getGallery().then((data) => { + setDrawing(data[0]); + galleryData = data; +}); + +function prevDrawing() { + if (!galleryData) { + return; + } + + if (currentIndex <= 0) { + return; + } + + currentIndex = currentIndex - 1; + setDrawing(galleryData[currentIndex]); +} + +function nextDrawing() { + if (!galleryData) { + return; + } + + if (currentIndex >= galleryData.length - 1) { + return; + } + + currentIndex = currentIndex + 1; + setDrawing(galleryData[currentIndex]); +} diff --git a/internal/frontend/http.go b/internal/frontend/http.go index 93a997f6..4e0040bd 100644 --- a/internal/frontend/http.go +++ b/internal/frontend/http.go @@ -152,8 +152,10 @@ func (handler *SSRHandler) SetupRoutes(register func(string, string, http.Handle ).ServeHTTP, ) registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby.js"), handler.lobbyJs) + registerWithCsp("GET", path.Join(handler.cfg.RootPath, "gallery.js"), handler.galleryJs) registerWithCsp("GET", path.Join(handler.cfg.RootPath, "index.js"), handler.indexJs) registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby", "{lobby_id}"), handler.ssrEnterLobby) + registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby", "{lobby_id}", "gallery"), handler.ssrGallery) registerWithCsp("POST", path.Join(handler.cfg.RootPath, "lobby"), handler.ssrCreateLobby) } diff --git a/internal/frontend/index.go b/internal/frontend/index.go index a30b71ac..0304edc3 100644 --- a/internal/frontend/index.go +++ b/internal/frontend/index.go @@ -24,6 +24,9 @@ import ( //go:embed lobby.js var lobbyJsRaw string +//go:embed gallery.js +var galleryJsRaw string + //go:embed index.js var indexJsRaw string @@ -37,10 +40,11 @@ type indexJsData struct { // This file contains the API for the official web client. type SSRHandler struct { - cfg *config.Config - basePageConfig *BasePageConfig - lobbyJsRawTemplate *txtTemplate.Template - indexJsRawTemplate *txtTemplate.Template + cfg *config.Config + basePageConfig *BasePageConfig + lobbyJsRawTemplate *txtTemplate.Template + galleryJsRawTemplate *txtTemplate.Template + indexJsRawTemplate *txtTemplate.Template } func NewHandler(cfg *config.Config) (*SSRHandler, error) { @@ -74,7 +78,15 @@ func NewHandler(cfg *config.Config) (*SSRHandler, error) { return nil, fmt.Errorf("error parsing lobby js template: %w", err) } + galleryJsRawTemplate, err := txtTemplate. + New("gallery-js"). + Parse(galleryJsRaw) + if err != nil { + return nil, fmt.Errorf("error parsing gallery js template: %w", err) + } + lobbyJsRawTemplate.AddParseTree("footer", pageTemplates.Tree) + galleryJsRawTemplate.AddParseTree("footer", pageTemplates.Tree) entries, err := frontendResourcesFS.ReadDir("resources") if err != nil { @@ -95,15 +107,19 @@ func NewHandler(cfg *config.Config) (*SSRHandler, error) { if err := basePageConfig.Hash("index.js", []byte(indexJsRaw)); err != nil { return nil, fmt.Errorf("error hashing: %w", err) } + if err := basePageConfig.Hash("gallery.js", []byte(galleryJsRaw)); err != nil { + return nil, fmt.Errorf("error hashing: %w", err) + } if err := basePageConfig.Hash("lobby.js", []byte(lobbyJsRaw)); err != nil { return nil, fmt.Errorf("error hashing: %w", err) } handler := &SSRHandler{ - cfg: cfg, - basePageConfig: basePageConfig, - lobbyJsRawTemplate: lobbyJsRawTemplate, - indexJsRawTemplate: indexJsRawTemplate, + cfg: cfg, + basePageConfig: basePageConfig, + lobbyJsRawTemplate: lobbyJsRawTemplate, + galleryJsRawTemplate: galleryJsRawTemplate, + indexJsRawTemplate: indexJsRawTemplate, } return handler, nil } diff --git a/internal/frontend/lobby.go b/internal/frontend/lobby.go index 43458e78..94265fe6 100644 --- a/internal/frontend/lobby.go +++ b/internal/frontend/lobby.go @@ -44,6 +44,33 @@ func (handler *SSRHandler) lobbyJs(writer http.ResponseWriter, request *http.Req if err := handler.lobbyJsRawTemplate.ExecuteTemplate(writer, "lobby-js", pageData); err != nil { log.Printf("error templating JS: %s\n", err) } + +} + +type galleryJsData struct { + *BasePageConfig + + LobbyID string + Translation *translations.Translation + Locale string +} + +func (handler *SSRHandler) galleryJs(writer http.ResponseWriter, request *http.Request) { + translation, locale := determineTranslation(request) + pageData := &galleryJsData{ + LobbyID: api.GetLobbyId(request), + BasePageConfig: handler.basePageConfig, + Translation: translation, + Locale: locale, + } + + writer.Header().Set("Content-Type", "text/javascript") + // Duration of 1 year, since we use cachebusting anyway. + writer.Header().Set("Cache-Control", "public, max-age=31536000") + writer.WriteHeader(http.StatusOK) + if err := handler.galleryJsRawTemplate.ExecuteTemplate(writer, "gallery-js", pageData); err != nil { + log.Printf("error templating JS: %s\n", err) + } } // ssrEnterLobby opens a lobby, either opening it directly or asking for a lobby. diff --git a/internal/frontend/resources/gallery.css b/internal/frontend/resources/gallery.css new file mode 100644 index 00000000..5984d67f --- /dev/null +++ b/internal/frontend/resources/gallery.css @@ -0,0 +1,41 @@ +.app { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + box-sizing: border-box; + height: 100%; +} + +#drawing-board { + border-radius: var(--component-border-radius); + min-height: 0; + max-width: 100%; + margin-left: auto; + margin-right: auto; +} + +.top-bar { + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 0.5rem; + background-color: var(--component-base-color); + border-radius: var(--component-border-radius); +} + +#word { + font-size: 2rem; +} + +.prev, +.next { + font-size: 2rem; + border: 1px solid black; +} + +.prev { +} + +.next { +} diff --git a/internal/frontend/templates/gallery.html b/internal/frontend/templates/gallery.html new file mode 100644 index 00000000..dd36b035 --- /dev/null +++ b/internal/frontend/templates/gallery.html @@ -0,0 +1,51 @@ +{{define "gallery-page"}} + + + + Scribble.rs - Gallery + + + {{template "non-static-css-decl" .}} + + + {{template "favicon-decl" .}} + + + +
+ +
+ + + +
+ + +
+ + + + + +{{end}} diff --git a/internal/game/data.go b/internal/game/data.go index 8dbf8c93..d4ce1700 100644 --- a/internal/game/data.go +++ b/internal/game/data.go @@ -81,6 +81,10 @@ type Lobby struct { // lobby object. currentDrawing []any + // Drawings contains the history of all drawings in the current lobby + // accross all rounds and games played. + Drawings []GalleryDrawing + // These variables are used to define the ranges of connected drawing events. // For example a line that has been drawn or a fill that has been executed. // Since we can't trust the client to tell us this, we use the time passed @@ -105,6 +109,14 @@ type Lobby struct { WritePreparedMessage func(*Player, *gws.Broadcaster) error } +// GalleryDrawing is a historic entry of a drawing from a past turn. +type GalleryDrawing struct { + // Word is the word that was drawn. + Word string `json:"word"` + // Events are the events required for recreation of the drawing. + Events []any `json:"events"` +} + // MaxPlayerNameLength defines how long a string can be at max when used // as the playername. const MaxPlayerNameLength int = 30 diff --git a/internal/game/lobby.go b/internal/game/lobby.go index b7059eb0..e091ac45 100644 --- a/internal/game/lobby.go +++ b/internal/game/lobby.go @@ -612,6 +612,16 @@ func advanceLobbyPredefineDrawer(lobby *Lobby, roundOver bool, newDrawer *Player drawer.Score += newDrawerScore } + // Prevents that the initial advance, used to start the game, applies + // an empty drawing. + if lobby.State == Ongoing && len(lobby.currentDrawing) > 0 && lobby.CurrentWord != "" { + // Append drawing to history. Since we reallocate on clear, this will be safe. + lobby.Drawings = append(lobby.Drawings, GalleryDrawing{ + Word: lobby.CurrentWord, + Events: lobby.currentDrawing, + }) + } + // We need this for the next-turn / game-over event, in order to allow the // client to know which word was previously supposed to be guessed. previousWord := lobby.CurrentWord From a8360c3f317fdb255d6fbf1806304c8d0e1e1a17 Mon Sep 17 00:00:00 2001 From: Marcel Schramm Date: Sat, 7 Feb 2026 17:13:04 +0100 Subject: [PATCH 2/5] solve fixmes --- internal/api/v1.go | 30 ++++++++++++++++++++---------- internal/frontend/gallery.go | 4 +++- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/internal/api/v1.go b/internal/api/v1.go index c56f8ab0..da9f334a 100644 --- a/internal/api/v1.go +++ b/internal/api/v1.go @@ -31,16 +31,20 @@ func NewHandler(cfg *config.Config) *V1Handler { } } +func writeJson(writer http.ResponseWriter, bytes []byte) error { + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("Content-Length", strconv.Itoa(len(bytes))) + _, err := writer.Write(bytes) + return err +} + func marshalToHTTPWriter(data any, writer http.ResponseWriter) (bool, error) { bytes, err := json.Marshal(data) if err != nil { return false, err } - writer.Header().Set("Content-Type", "application/json") - writer.Header().Set("Content-Length", strconv.Itoa(len(bytes))) - _, err = writer.Write(bytes) - return true, err + return true, writeJson(writer, bytes) } type LobbyEntries []*LobbyEntry @@ -209,13 +213,19 @@ func (handler *V1Handler) getGallery(writer http.ResponseWriter, request *http.R return } - // FIXME Synchronise access to lobby.Drawings. - // The drawings should also be available in an unstarted game. + var encodedJson []byte + var err error + lobby.Synchronized(func() { + encodedJson, err = json.Marshal(Gallery(lobby.Drawings)) + }) - if started, err := marshalToHTTPWriter(Gallery(lobby.Drawings), writer); err != nil { - if !started { - http.Error(writer, err.Error(), http.StatusInternalServerError) - } + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + + if err := writeJson(writer, encodedJson); err != nil { + log.Println("Error responding to gallery request:", err) return } } diff --git a/internal/frontend/gallery.go b/internal/frontend/gallery.go index 229df600..e2f233fd 100644 --- a/internal/frontend/gallery.go +++ b/internal/frontend/gallery.go @@ -19,7 +19,9 @@ type galleryPageData struct { func (handler *SSRHandler) ssrGallery(writer http.ResponseWriter, request *http.Request) { userAgent := strings.ToLower(request.UserAgent()) if !isHumanAgent(userAgent) { - // FIXME Handle robots + translation, _ := determineTranslation(request) + writer.WriteHeader(http.StatusForbidden) + handler.userFacingError(writer, translation.Get("forbidden"), translation) return } From d876b1056cf409827cae45858f7ab1e6b119a21d Mon Sep 17 00:00:00 2001 From: Marcel Schramm Date: Sat, 7 Feb 2026 20:46:04 +0100 Subject: [PATCH 3/5] drawings now stored per lobby and refresh when new drawings get in --- internal/api/v1.go | 68 ++++++++++++++++---- internal/frontend/gallery.go | 3 + internal/frontend/gallery.js | 121 +++++++++++++++++++++++++++++------ 3 files changed, 161 insertions(+), 31 deletions(-) diff --git a/internal/api/v1.go b/internal/api/v1.go index da9f334a..1e2c52d3 100644 --- a/internal/api/v1.go +++ b/internal/api/v1.go @@ -19,7 +19,10 @@ import ( "golang.org/x/text/language" ) -var ErrLobbyNotExistent = errors.New("the requested lobby doesn't exist") +var ( + ErrLobbyNotExistent = errors.New("the requested lobby doesn't exist") + ErrSessionNotMatching = errors.New("session doesn't match any player") +) type V1Handler struct { cfg *config.Config @@ -207,27 +210,68 @@ func (handler *V1Handler) postLobby(writer http.ResponseWriter, request *http.Re type Gallery []game.GalleryDrawing func (handler *V1Handler) getGallery(writer http.ResponseWriter, request *http.Request) { - lobby := state.GetLobby(GetLobbyId(request)) - if lobby == nil { - http.Error(writer, ErrLobbyNotExistent.Error(), http.StatusNotFound) + // Cached lobbies should simply run into an error if they try to update. + userSession, err := GetUserSession(request) + if err != nil { + log.Printf("error getting user session: %v", err) + http.Error(writer, "no valid usersession supplied", http.StatusBadRequest) return } - var encodedJson []byte - var err error - lobby.Synchronized(func() { - encodedJson, err = json.Marshal(Gallery(lobby.Drawings)) - }) + if userSession == uuid.Nil { + http.Error(writer, "no usersession supplied", http.StatusBadRequest) + return + } + + if err := request.ParseForm(); err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + rawLocalCacheCount := request.FormValue("local_cache_count") + localCacheCount, err := strconv.Atoi(rawLocalCacheCount) if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) + http.Error(writer, err.Error(), http.StatusBadRequest) return } - if err := writeJson(writer, encodedJson); err != nil { - log.Println("Error responding to gallery request:", err) + lobby := state.GetLobby(GetLobbyId(request)) + if lobby == nil { + http.Error(writer, ErrLobbyNotExistent.Error(), http.StatusNotFound) return } + + var after func() + lobby.Synchronized(func() { + // FIXME Improve these. + if localCacheCount == len(lobby.Drawings) { + after = func() { + http.Error(writer, "drawings unchanged", http.StatusNoContent) + } + return + } + + if lobby.GetPlayerBySession(userSession) == nil { + after = func() { + http.Error(writer, ErrSessionNotMatching.Error(), http.StatusForbidden) + } + return + } + + encodedJson, err := json.Marshal(Gallery(lobby.Drawings)) + after = func() { + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + + if err := writeJson(writer, encodedJson); err != nil { + log.Println("Error responding to gallery request:", err) + return + } + } + }) + after() } func (handler *V1Handler) postPlayer(writer http.ResponseWriter, request *http.Request) { diff --git a/internal/frontend/gallery.go b/internal/frontend/gallery.go index e2f233fd..8c490cae 100644 --- a/internal/frontend/gallery.go +++ b/internal/frontend/gallery.go @@ -26,6 +26,9 @@ func (handler *SSRHandler) ssrGallery(writer http.ResponseWriter, request *http. } lobbyId := request.PathValue("lobby_id") + // Note that this lobby doesn't have to exist necessarily, as the user can still have + // the data cached locally. + translation, locale := determineTranslation(request) pageData := &galleryPageData{ BasePageConfig: handler.basePageConfig, diff --git a/internal/frontend/gallery.js b/internal/frontend/gallery.js index 583473ad..0a4b7dc9 100644 --- a/internal/frontend/gallery.js +++ b/internal/frontend/gallery.js @@ -6,28 +6,96 @@ document.getElementById("next").addEventListener("click", () => { nextDrawing(); }); -const getGallery = () => { +/** + * @returns {Promise} + */ +const openDB = () => { + const db = indexedDB.open("scribblers", 1); + + db.onupgradeneeded = (event) => { + const db = event.target.result; + const objectStore = db.createObjectStore("gallery", { keyPath: "id" }); + // No index, as we store an array. + }; + return new Promise((resolve, reject) => { - const cachedGallery = sessionStorage.getItem("cached_gallery"); - if (cachedGallery) { - resolve(JSON.parse(cachedGallery)); - return; - } + db.onsuccess = () => { + resolve(db.result); + }; + db.onerror = () => { + reject(db.error); + }; + }); +}; + +const dbPromise = openDB(); - fetch("{{.RootPath}}/v1/lobby/{{.LobbyID}}/gallery") +const getGalleryEntry = async (store, id) => { + return new Promise((resolve, reject) => { + const gallery = store.get(id); + gallery.onsuccess = (event) => { + const galleryData = event.target.result; + resolve(galleryData); + }; + gallery.onerror = () => { + reject(gallery.error); + }; + }); +}; + +const getGallery = () => { + return new Promise(async (resolve, reject) => { + const db = await dbPromise; + const store = db.transaction("gallery").objectStore("gallery"); + const cachedGallery = await getGalleryEntry(store, "{{.LobbyID}}"); + + fetch( + "{{.RootPath}}/v1/lobby/{{.LobbyID}}/gallery?" + + new URLSearchParams({ + local_cache_count: cachedGallery + ? cachedGallery.data.length + : 0, + }).toString(), + ) .then((response) => { - response - .json() - .then((json) => { - sessionStorage.setItem( - "cached_gallery", - JSON.stringify(json), - ); - return json; - }) - .then(resolve); + if (response.status === 204) { + console.log( + "No new gallery data for lobby {{.LobbyID}} available.", + ); + resolve(cachedGallery ? cachedGallery.data : []); + return; + } + + if (response.status === 200) { + response + .json() + .then((json) => { + const store = db + .transaction("gallery", "readwrite") + .objectStore("gallery"); + store.put({ + id: "{{.LobbyID}}", + data: json, + }); + console.log( + "Latest gallery for lobby {{.LobbyID}} stored.", + ); + return json; + }) + .then(resolve); + return; + } + + console.log("Unknown error, falling back to cached value"); + resolve(cachedGallery.data); }) - .catch(reject); + .catch((err) => { + if (cachedGallery && cachedGallery.data.length > 0) { + resolve(cachedGallery.data); + } else { + reject(err); + } + }); }); }; @@ -89,7 +157,9 @@ let currentIndex = 0; let galleryData; getGallery().then((data) => { - setDrawing(data[0]); + if (data.length > 0) { + setDrawing(data[0]); + } galleryData = data; }); @@ -118,3 +188,16 @@ function nextDrawing() { currentIndex = currentIndex + 1; setDrawing(galleryData[currentIndex]); } + +dbPromise.then((db) => { + const galleryStore = db.transaction("gallery").objectStore("gallery"); + const galleryCursor = galleryStore.openCursor(); + galleryCursor.onsuccess = async (event) => { + const cursor = event.target.result; + if (cursor) { + const entry = await cursor.value; + console.log(entry.id); + cursor.continue(); + } + }; +}); From 3f2177e40eafd81685a2a5576711866190246d58 Mon Sep 17 00:00:00 2001 From: Marcel Schramm Date: Mon, 9 Feb 2026 23:02:54 +0100 Subject: [PATCH 4/5] remove templating for gallery JS --- internal/api/http.go | 26 +++ internal/api/v1.go | 45 +---- internal/frontend/gallery.go | 6 + internal/frontend/gallery.js | 203 ---------------------- internal/frontend/http.go | 1 - internal/frontend/index.go | 33 +--- internal/frontend/lobby.go | 26 --- internal/frontend/resources/gallery.js | 209 +++++++++++++++++++++++ internal/frontend/templates/gallery.html | 2 +- 9 files changed, 256 insertions(+), 295 deletions(-) delete mode 100644 internal/frontend/gallery.js create mode 100644 internal/frontend/resources/gallery.js diff --git a/internal/api/http.go b/internal/api/http.go index 54e84876..2eff2b18 100644 --- a/internal/api/http.go +++ b/internal/api/http.go @@ -84,3 +84,29 @@ func GetIPAddressFromRequest(request *http.Request) string { return remoteAddressToSimpleIP(request.RemoteAddr) } + +func SetDiscordCookie( + w http.ResponseWriter, + key, value string, +) { + http.SetCookie(w, &http.Cookie{ + Name: key, + Value: value, + Domain: discordDomain, + Path: "/", + SameSite: http.SameSiteNoneMode, + Partitioned: true, + Secure: true, + }) +} +func SetNormalCookie( + w http.ResponseWriter, + key, value string, +) { + http.SetCookie(w, &http.Cookie{ + Name: key, + Value: value, + Path: "/", + SameSite: http.SameSiteStrictMode, + }) +} diff --git a/internal/api/v1.go b/internal/api/v1.go index 1e2c52d3..c5e48d32 100644 --- a/internal/api/v1.go +++ b/internal/api/v1.go @@ -337,15 +337,7 @@ const discordDomain = "1320396325925163070.discordsays.com" func SetDiscordCookies(w http.ResponseWriter, request *http.Request) { discordInstanceId := GetDiscordInstanceId(request) if discordInstanceId != "" { - http.SetCookie(w, &http.Cookie{ - Name: "discord-instance-id", - Value: discordInstanceId, - Domain: discordDomain, - Path: "/", - SameSite: http.SameSiteNoneMode, - Partitioned: true, - Secure: true, - }) + SetDiscordCookie(w, "discord-instance-id", discordInstanceId) } } @@ -359,39 +351,14 @@ func SetGameplayCookies( ) { discordInstanceId := GetDiscordInstanceId(request) if discordInstanceId != "" { - http.SetCookie(w, &http.Cookie{ - Name: "usersession", - Value: player.GetUserSession().String(), - Domain: discordDomain, - Path: "/", - SameSite: http.SameSiteNoneMode, - Partitioned: true, - Secure: true, - }) - http.SetCookie(w, &http.Cookie{ - Name: "lobby-id", - Value: lobby.LobbyID, - Domain: discordDomain, - Path: "/", - SameSite: http.SameSiteNoneMode, - Partitioned: true, - Secure: true, - }) + SetDiscordCookie(w, "usersession", player.GetUserSession().String()) + SetDiscordCookie(w, "lobby-id", lobby.LobbyID) } else { + // FIXME This comment seems nonsensical, am i not getting something? // For the discord case, we need both, as the discord specific cookies // aren't available during the readirect from ssrCreate to ssrEnter. - http.SetCookie(w, &http.Cookie{ - Name: "usersession", - Value: player.GetUserSession().String(), - Path: "/", - SameSite: http.SameSiteStrictMode, - }) - http.SetCookie(w, &http.Cookie{ - Name: "lobby-id", - Value: lobby.LobbyID, - Path: "/", - SameSite: http.SameSiteStrictMode, - }) + SetNormalCookie(w, "usersession", player.GetUserSession().String()) + SetNormalCookie(w, "lobby-id", lobby.LobbyID) } } diff --git a/internal/frontend/gallery.go b/internal/frontend/gallery.go index 8c490cae..c45512f9 100644 --- a/internal/frontend/gallery.go +++ b/internal/frontend/gallery.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/scribble-rs/scribble.rs/internal/api" "github.com/scribble-rs/scribble.rs/internal/translations" ) @@ -26,6 +27,11 @@ func (handler *SSRHandler) ssrGallery(writer http.ResponseWriter, request *http. } lobbyId := request.PathValue("lobby_id") + + // FIXME Do we care about discord anymore? + api.SetNormalCookie(writer, "lobby-id", lobbyId) + api.SetNormalCookie(writer, "root-path", handler.basePageConfig.RootPath) + // Note that this lobby doesn't have to exist necessarily, as the user can still have // the data cached locally. diff --git a/internal/frontend/gallery.js b/internal/frontend/gallery.js deleted file mode 100644 index 0a4b7dc9..00000000 --- a/internal/frontend/gallery.js +++ /dev/null @@ -1,203 +0,0 @@ -document.getElementById("prev").addEventListener("click", () => { - prevDrawing(); -}); - -document.getElementById("next").addEventListener("click", () => { - nextDrawing(); -}); - -/** - * @returns {Promise} - */ -const openDB = () => { - const db = indexedDB.open("scribblers", 1); - - db.onupgradeneeded = (event) => { - const db = event.target.result; - const objectStore = db.createObjectStore("gallery", { keyPath: "id" }); - // No index, as we store an array. - }; - - return new Promise((resolve, reject) => { - db.onsuccess = () => { - resolve(db.result); - }; - db.onerror = () => { - reject(db.error); - }; - }); -}; - -const dbPromise = openDB(); - -const getGalleryEntry = async (store, id) => { - return new Promise((resolve, reject) => { - const gallery = store.get(id); - gallery.onsuccess = (event) => { - const galleryData = event.target.result; - resolve(galleryData); - }; - gallery.onerror = () => { - reject(gallery.error); - }; - }); -}; - -const getGallery = () => { - return new Promise(async (resolve, reject) => { - const db = await dbPromise; - const store = db.transaction("gallery").objectStore("gallery"); - const cachedGallery = await getGalleryEntry(store, "{{.LobbyID}}"); - - fetch( - "{{.RootPath}}/v1/lobby/{{.LobbyID}}/gallery?" + - new URLSearchParams({ - local_cache_count: cachedGallery - ? cachedGallery.data.length - : 0, - }).toString(), - ) - .then((response) => { - if (response.status === 204) { - console.log( - "No new gallery data for lobby {{.LobbyID}} available.", - ); - resolve(cachedGallery ? cachedGallery.data : []); - return; - } - - if (response.status === 200) { - response - .json() - .then((json) => { - const store = db - .transaction("gallery", "readwrite") - .objectStore("gallery"); - store.put({ - id: "{{.LobbyID}}", - data: json, - }); - console.log( - "Latest gallery for lobby {{.LobbyID}} stored.", - ); - return json; - }) - .then(resolve); - return; - } - - console.log("Unknown error, falling back to cached value"); - resolve(cachedGallery.data); - }) - .catch((err) => { - if (cachedGallery && cachedGallery.data.length > 0) { - resolve(cachedGallery.data); - } else { - reject(err); - } - }); - }); -}; - -const word = document.getElementById("word"); - -const drawingBoard = document.getElementById("drawing-board"); -const context = drawingBoard.getContext("2d", { alpha: false }); -let imageData; - -function clear(context) { - context.fillStyle = "#FFFFFF"; - context.fillRect(0, 0, drawingBoard.width, drawingBoard.height); - // Refetch, as we don't manually fill here. - imageData = context.getImageData( - 0, - 0, - context.canvas.width, - context.canvas.height, - ); -} -clear(context); - -function setDrawing(drawing) { - clear(context); - - word.innerText = drawing.word; - - drawing.events.forEach((drawElement) => { - const drawData = drawElement.data; - if (drawElement.type === "fill") { - floodfillUint8ClampedArray( - imageData.data, - drawData.x, - drawData.y, - indexToRgbColor(drawData.color), - imageData.width, - imageData.height, - ); - } else if (drawElement.type === "line") { - drawLineNoPut( - context, - imageData, - drawData.x, - drawData.y, - drawData.x2, - drawData.y2, - indexToRgbColor(drawData.color), - drawData.width, - ); - } else { - console.log("Unknown draw element type: " + drawData.type); - } - }); - - context.putImageData(imageData, 0, 0); -} - -let currentIndex = 0; -let galleryData; - -getGallery().then((data) => { - if (data.length > 0) { - setDrawing(data[0]); - } - galleryData = data; -}); - -function prevDrawing() { - if (!galleryData) { - return; - } - - if (currentIndex <= 0) { - return; - } - - currentIndex = currentIndex - 1; - setDrawing(galleryData[currentIndex]); -} - -function nextDrawing() { - if (!galleryData) { - return; - } - - if (currentIndex >= galleryData.length - 1) { - return; - } - - currentIndex = currentIndex + 1; - setDrawing(galleryData[currentIndex]); -} - -dbPromise.then((db) => { - const galleryStore = db.transaction("gallery").objectStore("gallery"); - const galleryCursor = galleryStore.openCursor(); - galleryCursor.onsuccess = async (event) => { - const cursor = event.target.result; - if (cursor) { - const entry = await cursor.value; - console.log(entry.id); - cursor.continue(); - } - }; -}); diff --git a/internal/frontend/http.go b/internal/frontend/http.go index 4e0040bd..93ba3994 100644 --- a/internal/frontend/http.go +++ b/internal/frontend/http.go @@ -152,7 +152,6 @@ func (handler *SSRHandler) SetupRoutes(register func(string, string, http.Handle ).ServeHTTP, ) registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby.js"), handler.lobbyJs) - registerWithCsp("GET", path.Join(handler.cfg.RootPath, "gallery.js"), handler.galleryJs) registerWithCsp("GET", path.Join(handler.cfg.RootPath, "index.js"), handler.indexJs) registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby", "{lobby_id}"), handler.ssrEnterLobby) registerWithCsp("GET", path.Join(handler.cfg.RootPath, "lobby", "{lobby_id}", "gallery"), handler.ssrGallery) diff --git a/internal/frontend/index.go b/internal/frontend/index.go index 0304edc3..892455b1 100644 --- a/internal/frontend/index.go +++ b/internal/frontend/index.go @@ -24,9 +24,6 @@ import ( //go:embed lobby.js var lobbyJsRaw string -//go:embed gallery.js -var galleryJsRaw string - //go:embed index.js var indexJsRaw string @@ -40,11 +37,10 @@ type indexJsData struct { // This file contains the API for the official web client. type SSRHandler struct { - cfg *config.Config - basePageConfig *BasePageConfig - lobbyJsRawTemplate *txtTemplate.Template - galleryJsRawTemplate *txtTemplate.Template - indexJsRawTemplate *txtTemplate.Template + cfg *config.Config + basePageConfig *BasePageConfig + lobbyJsRawTemplate *txtTemplate.Template + indexJsRawTemplate *txtTemplate.Template } func NewHandler(cfg *config.Config) (*SSRHandler, error) { @@ -77,16 +73,7 @@ func NewHandler(cfg *config.Config) (*SSRHandler, error) { if err != nil { return nil, fmt.Errorf("error parsing lobby js template: %w", err) } - - galleryJsRawTemplate, err := txtTemplate. - New("gallery-js"). - Parse(galleryJsRaw) - if err != nil { - return nil, fmt.Errorf("error parsing gallery js template: %w", err) - } - lobbyJsRawTemplate.AddParseTree("footer", pageTemplates.Tree) - galleryJsRawTemplate.AddParseTree("footer", pageTemplates.Tree) entries, err := frontendResourcesFS.ReadDir("resources") if err != nil { @@ -107,19 +94,15 @@ func NewHandler(cfg *config.Config) (*SSRHandler, error) { if err := basePageConfig.Hash("index.js", []byte(indexJsRaw)); err != nil { return nil, fmt.Errorf("error hashing: %w", err) } - if err := basePageConfig.Hash("gallery.js", []byte(galleryJsRaw)); err != nil { - return nil, fmt.Errorf("error hashing: %w", err) - } if err := basePageConfig.Hash("lobby.js", []byte(lobbyJsRaw)); err != nil { return nil, fmt.Errorf("error hashing: %w", err) } handler := &SSRHandler{ - cfg: cfg, - basePageConfig: basePageConfig, - lobbyJsRawTemplate: lobbyJsRawTemplate, - galleryJsRawTemplate: galleryJsRawTemplate, - indexJsRawTemplate: indexJsRawTemplate, + cfg: cfg, + basePageConfig: basePageConfig, + lobbyJsRawTemplate: lobbyJsRawTemplate, + indexJsRawTemplate: indexJsRawTemplate, } return handler, nil } diff --git a/internal/frontend/lobby.go b/internal/frontend/lobby.go index 94265fe6..13ba0c6c 100644 --- a/internal/frontend/lobby.go +++ b/internal/frontend/lobby.go @@ -47,32 +47,6 @@ func (handler *SSRHandler) lobbyJs(writer http.ResponseWriter, request *http.Req } -type galleryJsData struct { - *BasePageConfig - - LobbyID string - Translation *translations.Translation - Locale string -} - -func (handler *SSRHandler) galleryJs(writer http.ResponseWriter, request *http.Request) { - translation, locale := determineTranslation(request) - pageData := &galleryJsData{ - LobbyID: api.GetLobbyId(request), - BasePageConfig: handler.basePageConfig, - Translation: translation, - Locale: locale, - } - - writer.Header().Set("Content-Type", "text/javascript") - // Duration of 1 year, since we use cachebusting anyway. - writer.Header().Set("Cache-Control", "public, max-age=31536000") - writer.WriteHeader(http.StatusOK) - if err := handler.galleryJsRawTemplate.ExecuteTemplate(writer, "gallery-js", pageData); err != nil { - log.Printf("error templating JS: %s\n", err) - } -} - // ssrEnterLobby opens a lobby, either opening it directly or asking for a lobby. func (handler *SSRHandler) ssrEnterLobby(writer http.ResponseWriter, request *http.Request) { translation, _ := determineTranslation(request) diff --git a/internal/frontend/resources/gallery.js b/internal/frontend/resources/gallery.js new file mode 100644 index 00000000..dccfc73d --- /dev/null +++ b/internal/frontend/resources/gallery.js @@ -0,0 +1,209 @@ +function getCookie(name) { + let cookie = {}; + document.cookie.split(";").forEach(function (el) { + let split = el.split("="); + cookie[split[0].trim()] = split.slice(1).join("="); + }); + return cookie[name]; +} + +const rootPath = getCookie("root-path"); +const lobbyId = getCookie("lobby-id"); + +document.getElementById("prev").addEventListener("click", () => { + prevDrawing(); +}); + +document.getElementById("next").addEventListener("click", () => { + nextDrawing(); +}); + +/** + * @returns {Promise} + */ +const openDB = () => { + const db = indexedDB.open("scribblers", 1); + + db.onupgradeneeded = (event) => { + const db = event.target.result; + const objectStore = db.createObjectStore("gallery", { keyPath: "id" }); + // No index, as we store an array. + }; + + return new Promise((resolve, reject) => { + db.onsuccess = () => { + resolve(db.result); + }; + db.onerror = () => { + reject(db.error); + }; + }); +}; + +const dbPromise = openDB(); + +const getGalleryEntry = async (store, id) => { + return new Promise((resolve, reject) => { + const gallery = store.get(id); + gallery.onsuccess = (event) => { + const galleryData = event.target.result; + resolve(galleryData); + }; + gallery.onerror = () => { + reject(gallery.error); + }; + }); +}; + +const getGallery = () => { + return new Promise(async (resolve, reject) => { + const db = await dbPromise; + const store = db.transaction("gallery").objectStore("gallery"); + const cachedGallery = await getGalleryEntry(store, lobbyId); + + fetch( + `${rootPath}/v1/lobby/${lobbyId}/gallery?` + + new URLSearchParams({ + local_cache_count: cachedGallery ? cachedGallery.data.length : 0, + }).toString(), + ) + .then((response) => { + if (response.status === 204) { + console.log(`No new gallery data for lobby ${lobbyId} available.`); + resolve(cachedGallery ? cachedGallery.data : []); + return; + } + + if (response.status === 200) { + response + .json() + .then((json) => { + const store = db + .transaction("gallery", "readwrite") + .objectStore("gallery"); + store.put({ + id: lobbyId, + data: json, + }); + console.log(`Latest gallery for lobby ${lobbyId} stored.`); + return json; + }) + .then(resolve); + return; + } + + console.log("Unknown error, falling back to cached value"); + resolve(cachedGallery.data); + }) + .catch((err) => { + if (cachedGallery && cachedGallery.data.length > 0) { + resolve(cachedGallery.data); + } else { + reject(err); + } + }); + }); +}; + +const word = document.getElementById("word"); + +const drawingBoard = document.getElementById("drawing-board"); +const context = drawingBoard.getContext("2d", { alpha: false }); +let imageData; + +function clear(context) { + context.fillStyle = "#FFFFFF"; + context.fillRect(0, 0, drawingBoard.width, drawingBoard.height); + // Refetch, as we don't manually fill here. + imageData = context.getImageData( + 0, + 0, + context.canvas.width, + context.canvas.height, + ); +} +clear(context); + +function setDrawing(drawing) { + clear(context); + + word.innerText = drawing.word; + + drawing.events.forEach((drawElement) => { + const drawData = drawElement.data; + if (drawElement.type === "fill") { + floodfillUint8ClampedArray( + imageData.data, + drawData.x, + drawData.y, + indexToRgbColor(drawData.color), + imageData.width, + imageData.height, + ); + } else if (drawElement.type === "line") { + drawLineNoPut( + context, + imageData, + drawData.x, + drawData.y, + drawData.x2, + drawData.y2, + indexToRgbColor(drawData.color), + drawData.width, + ); + } else { + console.log("Unknown draw element type: " + drawData.type); + } + }); + + context.putImageData(imageData, 0, 0); +} + +let currentIndex = 0; +let galleryData; + +getGallery().then((data) => { + if (data.length > 0) { + setDrawing(data[0]); + } + galleryData = data; +}); + +function prevDrawing() { + if (!galleryData) { + return; + } + + if (currentIndex <= 0) { + return; + } + + currentIndex = currentIndex - 1; + setDrawing(galleryData[currentIndex]); +} + +function nextDrawing() { + if (!galleryData) { + return; + } + + if (currentIndex >= galleryData.length - 1) { + return; + } + + currentIndex = currentIndex + 1; + setDrawing(galleryData[currentIndex]); +} + +dbPromise.then((db) => { + const galleryStore = db.transaction("gallery").objectStore("gallery"); + const galleryCursor = galleryStore.openCursor(); + galleryCursor.onsuccess = async (event) => { + const cursor = event.target.result; + if (cursor) { + const entry = await cursor.value; + console.log(entry.id); + cursor.continue(); + } + }; +}); diff --git a/internal/frontend/templates/gallery.html b/internal/frontend/templates/gallery.html index dd36b035..92676cfe 100644 --- a/internal/frontend/templates/gallery.html +++ b/internal/frontend/templates/gallery.html @@ -44,7 +44,7 @@ > From 5e27d5ccc8fd3ebb1b43ca851d4431f5baf9b828 Mon Sep 17 00:00:00 2001 From: Marcel Schramm Date: Sat, 14 Feb 2026 18:07:41 +0100 Subject: [PATCH 5/5] Improve gallery * add drawer name to data and frontend * make layout more compact * add menu entry to load gallery --- README.md | 5 ++++- internal/frontend/lobby.js | 7 +++++++ internal/frontend/resources/gallery.css | 9 ++++----- internal/frontend/resources/gallery.js | 6 ++++++ internal/frontend/resources/gallery.svg | 7 +++++++ internal/frontend/templates/gallery.html | 12 +++++++----- internal/frontend/templates/lobby.html | 7 +++++++ internal/game/data.go | 3 +++ internal/game/lobby.go | 11 ++++++++--- internal/translations/de_DE.go | 1 + internal/translations/en_us.go | 1 + 11 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 internal/frontend/resources/gallery.svg diff --git a/README.md b/README.md index fd216f23..d39cea87 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ Some of these were slightly altered if the license allowed it. Treat each of the files in this repository with the same license terms as the original file. +**It seems iconfinder was renamed and all the URLs aren't correct anymore. If you are the author of any of the icons and want me to attribute you differently, create an issue please.** + * Logo - All rights reserved, excluded from BSD-3 licensing * Background - All rights reserved, excluded from BSD-3 licensing * Favicon - All rights reserved, excluded from BSD-3 licensing @@ -204,4 +206,5 @@ original file. * [Trash Icon](https://www.iconfinder.com/icons/315225/trash_can_icon) - Made by [Yannick Lung](https://yannicklung.com) * [Undo Icon](https://www.iconfinder.com/icons/308948/arrow_undo_icon) - Made by [Ivan Boyko](https://www.iconfinder.com/visualpharm) * [Alarmclock Icon](https://www.iconfinder.com/icons/4280508/alarm_outlined_alert_clock_icon) - Made by [Kit of Parts](https://www.iconfinder.com/kitofparts) -* https://www.iconfinder.com/icons/808399/load_turn_turnaround_icon TODO +* https://www.iconfinder.com/icons/808399/load_turn_turnaround_icon - Made by Pixel Bazaar +*[Gallery Icon](https://www.svgrepo.com/svg/528992/gallery-wide) - Made by [Solar Icons](https://www.svgrepo.com/author/Solar%20Icons/) diff --git a/internal/frontend/lobby.js b/internal/frontend/lobby.js index 1b5ab7eb..c78e74cc 100644 --- a/internal/frontend/lobby.js +++ b/internal/frontend/lobby.js @@ -3,6 +3,7 @@ String.prototype.format = function() { }; const discordInstanceId = getCookie("discord-instance-id") +const lobbyId = getCookie("lobby-id"); const rootPath = `${discordInstanceId ? ".proxy/" : ""}{{.RootPath}}` let socketIsConnecting = false; @@ -346,6 +347,12 @@ function toggleFullscreen() { } document.getElementById("toggle-fullscreen-button").addEventListener("click", toggleFullscreen); +function showGallery() { + window.open(`${rootPath}/lobby/${lobbyId}/gallery`,"_blank" ); +} + +document.getElementById("show-gallery-button").addEventListener("click", showGallery); + function showLobbySettingsDialog() { hideMenu(); lobbySettingsDialog.style.visibility = "visible"; diff --git a/internal/frontend/resources/gallery.css b/internal/frontend/resources/gallery.css index 5984d67f..33da6a2c 100644 --- a/internal/frontend/resources/gallery.css +++ b/internal/frontend/resources/gallery.css @@ -2,7 +2,7 @@ display: flex; flex-direction: column; gap: 0.5rem; - padding: 1rem; + padding: 0.5rem; box-sizing: border-box; height: 100%; } @@ -22,15 +22,14 @@ padding: 0.5rem; background-color: var(--component-base-color); border-radius: var(--component-border-radius); -} -#word { - font-size: 2rem; + * { + font-size: 1.5rem; + } } .prev, .next { - font-size: 2rem; border: 1px solid black; } diff --git a/internal/frontend/resources/gallery.js b/internal/frontend/resources/gallery.js index dccfc73d..c55cfb3e 100644 --- a/internal/frontend/resources/gallery.js +++ b/internal/frontend/resources/gallery.js @@ -106,6 +106,7 @@ const getGallery = () => { }; const word = document.getElementById("word"); +const drawer = document.getElementById("drawer"); const drawingBoard = document.getElementById("drawing-board"); const context = drawingBoard.getContext("2d", { alpha: false }); @@ -128,6 +129,11 @@ function setDrawing(drawing) { clear(context); word.innerText = drawing.word; + if (drawing.drawer) { + drawer.innerText = `by ${drawing.drawer}`; + } else { + drawer.innerText = ""; + } drawing.events.forEach((drawElement) => { const drawData = drawElement.data; diff --git a/internal/frontend/resources/gallery.svg b/internal/frontend/resources/gallery.svg new file mode 100644 index 00000000..b99f00f3 --- /dev/null +++ b/internal/frontend/resources/gallery.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/internal/frontend/templates/gallery.html b/internal/frontend/templates/gallery.html index 92676cfe..ab38ad4c 100644 --- a/internal/frontend/templates/gallery.html +++ b/internal/frontend/templates/gallery.html @@ -6,7 +6,7 @@ {{template "non-static-css-decl" .}}
- - - + +
+ + +
+
- diff --git a/internal/frontend/templates/lobby.html b/internal/frontend/templates/lobby.html index 908ca104..94be135c 100644 --- a/internal/frontend/templates/lobby.html +++ b/internal/frontend/templates/lobby.html @@ -71,6 +71,13 @@ class="header-button-image" /> {{.Translation.Get "toggle-fullscreen"}} +