diff --git a/assets/js/uploader.js b/assets/js/uploader.js
index ee6732cc..7f87ef9a 100644
--- a/assets/js/uploader.js
+++ b/assets/js/uploader.js
@@ -9,17 +9,28 @@
const invalid = "is-invalid";
- // Modal elements
- const pouetM = document.getElementById("uploaderPouet");
+ // Poeut modal elements
+ const pouetM = document.getElementById("uploader-pouet");
if (pouetM == null) {
throw new Error("The uploader-pouet element is null.");
}
+ pouetM.addEventListener("shown.bs.modal", function () {
+ poeutInput.focus();
+ });
const pouetModal = new bootstrap.Modal(pouetM);
+ const poeutInput = document.getElementById("pouet-submission");
+ if (poeutInput == null) {
+ throw new Error("The pouet-submission element is null.");
+ }
- const demozooM = document.getElementById("uploader-demozoo");
+ // Demozoo modal elements
+ const demozooM = document.getElementById("uploader-demozoo");
if (demozooM == null) {
throw new Error("The uploader-demozoo element is null.");
}
+ demozooM.addEventListener("shown.bs.modal", function () {
+ demozooInput.focus();
+ });
const demozooModal = new bootstrap.Modal(demozooM);
const demozooInput = document.getElementById("demozoo-submission");
if (demozooInput == null) {
@@ -71,30 +82,38 @@
// Keyboard shortcuts event listener
document.addEventListener("keydown", function (event) {
+ const demozoo = "d",
+ pouet = "p",
+ intro = "i",
+ nfo = "n",
+ graphic = "g",
+ magazine = "m",
+ advanced = "a",
+ glossaryOfTerms = "t";
if (event.ctrlKey && event.altKey) {
switch (event.key) {
- case "d":
+ case demozoo:
demozooModal.show();
break;
- case "p":
+ case pouet:
pouetModal.show();
break;
- case "i":
+ case intro:
demozooModal.show();
break;
- case "n": // n for nfo
+ case nfo:
txtModal.show();
break;
- case "g": // g for gfx
+ case graphic:
imgModal.show();
break;
- case "m":
+ case magazine:
magModal.show();
break;
- case "a":
+ case advanced:
advModal.show();
break;
- case "t": // t for terms
+ case glossaryOfTerms:
glossModal.show();
break;
}
@@ -186,17 +205,6 @@
const imgFrm = document.getElementById("imageUploader");
const magFrm = document.getElementById("magUploader");
const advFrm = document.getElementById("advancedUploader");
- // Focus on the first input field when the modal is shown
- demozooM.addEventListener("shown.bs.modal", function () {
- demozooInput.focus();
- });
- // Focus on the first input field when the modal is shown
- const pouetId = document.getElementById("pouetProdsID");
- document
- .getElementById("uploaderPouet")
- .addEventListener("shown.bs.modal", function () {
- pouetId.focus();
- });
// Elements for the intro uploader
const introFile = document.getElementById("introFile");
diff --git a/handler/app/context.go b/handler/app/context.go
index 73bceb5f..6e781bf9 100644
--- a/handler/app/context.go
+++ b/handler/app/context.go
@@ -1041,7 +1041,7 @@ func ProdPouet(logr *zap.SugaredLogger, c echo.Context, id string) error {
if logr == nil {
return InternalErr(logr, c, name, ErrZap)
}
- p := pouet.Pouet{}
+ p := pouet.Production{}
i, err := strconv.Atoi(id)
if err != nil {
return c.String(http.StatusNotFound, err.Error())
diff --git a/handler/htmx/htmx.go b/handler/htmx/htmx.go
index 5d83d4d0..0697a475 100644
--- a/handler/htmx/htmx.go
+++ b/handler/htmx/htmx.go
@@ -31,6 +31,12 @@ func Routes(logr *zap.SugaredLogger, e *echo.Echo) *echo.Echo {
submit.POST("/demozoo/production/submit/:id", func(x echo.Context) error {
return DemozooSubmit(logr, x)
})
+ submit.POST("/pouet/production", func(x echo.Context) error {
+ return PouetProd(x)
+ })
+ submit.POST("/pouet/production/submit/:id", func(x echo.Context) error {
+ return PouetSubmit(logr, x)
+ })
submit.POST("/search/releaser", func(x echo.Context) error {
return SearchReleaser(logr, x)
})
diff --git a/handler/htmx/pouet.go b/handler/htmx/pouet.go
new file mode 100644
index 00000000..d781d8d4
--- /dev/null
+++ b/handler/htmx/pouet.go
@@ -0,0 +1,188 @@
+package htmx
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/Defacto2/server/internal/helper"
+ "github.com/Defacto2/server/internal/postgres"
+ "github.com/Defacto2/server/internal/pouet"
+ "github.com/Defacto2/server/model"
+ "github.com/labstack/echo/v4"
+ "go.uber.org/zap"
+)
+
+// PouetProd fetches the multiple download_links values from the
+// Pouet production API and attempts to download and save one of the
+// linked files. If multiple links are found, the first link is used as
+// they should all point to the same asset.
+//
+// Both the Pouet production ID param and the Defacto2 UUID query
+// param values are required as params to fetch the production data and
+// to save the file to the correct filename.
+func PouetProd(c echo.Context) error {
+ sid := c.FormValue("pouet-submission")
+ id, err := strconv.Atoi(sid)
+ if err != nil {
+ return c.String(http.StatusNotAcceptable,
+ "The Pouet production ID must be a numeric value, "+sid)
+ }
+
+ db, err := postgres.ConnectDB()
+ if err != nil {
+ return ErrDB
+ }
+ defer db.Close()
+ ctx := context.Background()
+
+ deleted, key, err := model.FindPouetFile(ctx, db, int64(id))
+ if err != nil {
+ return c.String(http.StatusServiceUnavailable,
+ "error, the database query failed")
+ }
+ if key != 0 && !deleted {
+ html := fmt.Sprintf("This Pouet production is already in use .", helper.ObfuscateID(key))
+ return c.HTML(http.StatusOK, html)
+ }
+ if key != 0 && deleted {
+ return c.HTML(http.StatusOK, "This Pouet production is already in use.")
+ }
+
+ resp, err := PouetValid(c, id)
+ if err != nil {
+ return err
+ } else if resp.Prod.ID == "" {
+ return nil
+ }
+ if !resp.Success {
+ return c.String(http.StatusNotFound, "error, the Pouet production ID is not found")
+ }
+
+ prod := resp.Prod
+ if pid, err := strconv.Atoi(prod.ID); err != nil {
+ return c.String(http.StatusNotFound, "error, the Pouet production ID is invalid")
+ } else if pid < 1 {
+ return nil
+ }
+
+ info := []string{prod.Title}
+ if len(prod.Groups) > 0 {
+ info = append(info, "by")
+ for _, a := range prod.Groups {
+ info = append(info, a.Name)
+ }
+ }
+ if prod.ReleaseDate != "" {
+ info = append(info, "on", prod.ReleaseDate)
+ }
+ if prod.Platfs.String() != "" {
+ info = append(info, "for", prod.Platfs.String())
+ }
+
+ html := `
`
+ html += fmt.Sprintf(`Submit ID %d `, id, id)
+ html += `
`
+ html += fmt.Sprintf(`%s
`, strings.Join(info, " "))
+ return c.HTML(http.StatusOK, html)
+}
+
+// PouetValid fetches the first usable download link from the Pouet API.
+// The production ID is validated and the production is checked to see if it
+// is suitable for Defacto2. If the production is not suitable, an empty
+// production is returned with a htmx message.
+func PouetValid(c echo.Context, id int) (pouet.Response, error) {
+ if id < 1 {
+ return pouet.Response{},
+ c.String(http.StatusNotAcceptable, fmt.Sprintf("invalid id: %d", id))
+ }
+
+ var prod pouet.Response
+ if err := prod.Get(id); err != nil {
+ return pouet.Response{}, c.String(http.StatusNotFound, err.Error())
+ }
+
+ plat := prod.Prod.Platfs
+ sect := prod.Prod.Types
+ if !plat.Valid() || !sect.Valid() {
+ return pouet.Response{}, c.HTML(http.StatusOK,
+ fmt.Sprintf("Production %d is probably not suitable for Defacto2."+
+ " A production must an intro, demo or cracktro either for MsDos or Windows.", id))
+ }
+
+ var valid string
+ if prod.Prod.Download != "" {
+ valid = prod.Prod.Download
+ }
+
+ for _, link := range prod.Prod.DownloadLinks {
+ if valid != "" {
+ break
+ }
+ if link.Link == "" {
+ continue
+ }
+ switch strings.ToLower(link.Type) {
+ case "youtube":
+ continue
+ }
+ valid = link.Link
+ break
+ }
+ if valid == "" {
+ return pouet.Response{},
+ c.String(http.StatusOK, "This Pouet production has no suitable download links.")
+ }
+ return prod, nil
+}
+
+// PouetSubmit is the handler for the /pouet/production/submit route.
+// This will attempt to insert a new file record into the database using
+// the Pouet production ID. If the Pouet production ID is already in
+// use, an error message is returned.
+func PouetSubmit(logr *zap.SugaredLogger, c echo.Context) error {
+ if logr == nil {
+ return c.String(http.StatusInternalServerError,
+ "error, pouet submit logger is nil")
+ }
+
+ sid := c.Param("id")
+ id, err := strconv.ParseUint(sid, 10, 64)
+ if err != nil {
+ return c.String(http.StatusNotAcceptable,
+ "The Pouet production ID must be a numeric value, "+sid)
+ }
+ if id < 1 || id > pouet.Sanity {
+ return c.String(http.StatusNotAcceptable,
+ "The Pouet production ID is invalid, "+sid)
+ }
+
+ db, err := postgres.ConnectDB()
+ if err != nil {
+ return ErrDB
+ }
+ defer db.Close()
+ ctx := context.Background()
+
+ if exist, err := model.ExistPouetFile(ctx, db, int64(id)); err != nil {
+ return c.String(http.StatusServiceUnavailable,
+ "error, the database query failed")
+ } else if exist {
+ return c.String(http.StatusForbidden,
+ "error, the pouet key is already in use")
+ }
+
+ key, err := model.InsertPouetFile(ctx, db, int64(id))
+ if err != nil || key == 0 {
+ logr.Error(err, id)
+ return c.String(http.StatusServiceUnavailable,
+ "error, the database insert failed")
+ }
+
+ html := fmt.Sprintf("Thanks for the submission of Pouet production: %d", id)
+ return c.HTML(http.StatusOK, html)
+}
diff --git a/internal/pouet/pouet.go b/internal/pouet/pouet.go
index af46ddf0..28064415 100644
--- a/internal/pouet/pouet.go
+++ b/internal/pouet/pouet.go
@@ -23,6 +23,8 @@ const (
Timeout = 5 * time.Second
// StarRounder is the rounding value for the stars rating.
StarRounder = 0.5
+ // Sanity is to check the maximum permitted production ID.
+ Sanity = 200000
// firstID is the first production ID on Pouet.
firstID = 1
)
@@ -33,12 +35,12 @@ var (
ErrStatus = errors.New("pouet production status is not ok")
)
-// Pouet is the production data from the Pouet API.
+// Production is the production data from the Pouet API.
// The Pouet API returns values as null or string, so this struct
// is used to normalize the data types.
-type Pouet struct {
+type Production struct {
// Platforms are the platforms the prod runs on.
- Platforms Platforms `json:"platforms"`
+ Platforms Platfs `json:"platforms"`
// Title is the prod title.
Title string `json:"title"`
// ReleaseDate is the prod release date.
@@ -83,34 +85,36 @@ type Votes struct {
// Response is the JSON response from the Pouet API with production voting data.
type Response struct {
Prod struct {
- // used by uploader and voter
- ID string `json:"id"`
- // used by voter
- Voteup string `json:"voteup"`
- Votepig string `json:"votepig"`
- Votedown string `json:"votedown"`
- Voteavg string `json:"voteavg"`
- // used by uploader
- Title string `json:"name"`
- ReleaseDate string `json:"releaseDate"`
+ ID string `json:"id"` // ID is the prod ID.
+ Voteup string `json:"voteup"` // Voteup is the number of thumbs up votes.
+ Votepig string `json:"votepig"` // Votepig is the number of meh votes.
+ Votedown string `json:"votedown"` // Votedown is the number of thumbs down votes.
+ Voteavg string `json:"voteavg"` // Voteavg is the average votes, the maximum value is 1.0.
+ Title string `json:"name"` // Title is the prod title.
+ ReleaseDate string `json:"releaseDate"` // ReleaseDate is the prod release date.
Groups []struct {
ID string `json:"id"`
Name string `json:"name"`
- } `json:"groups"`
- Platforms Platforms `json:"platforms"`
- Types Types `json:"types"`
- } `json:"prod"`
- Success bool `json:"success"`
+ } `json:"groups"` // Groups are the releasers that produced the prod.
+ Platfs Platfs `json:"platforms"` // Platforms are the platforms the prod runs on.
+ Types Types `json:"types"` // Types are the prod types.
+ Download string `json:"download"` // Download is the first download link.
+ DownloadLinks []struct {
+ Type string `json:"type"`
+ Link string `json:"link"`
+ } `json:"downloadLinks"` // DownloadLinks are the additional download links.
+ } `json:"prod"` // Prod is the production data.
+ Success bool `json:"success"` // Success is true if the prod data was found.
}
-// Platforms are the supported platforms from the Pouet API.
-type Platforms struct {
- DosGus Platform `json:"69"` // MS-Dos with GUS
- Windows Platform `json:"68"` // Windows
- MSDos Platform `json:"67"` // MS-Dos
+// Platfs are the supported platforms from the Pouet API.
+type Platfs struct {
+ DosGus Platf `json:"69"` // MS-Dos with GUS
+ Windows Platf `json:"68"` // Windows
+ MSDos Platf `json:"67"` // MS-Dos
}
-func (p Platforms) String() string {
+func (p Platfs) String() string {
s := []string{}
if p.DosGus.Name != "" {
s = append(s, p.DosGus.Name)
@@ -124,7 +128,7 @@ func (p Platforms) String() string {
return strings.Join(s, ", ")
}
-func (p Platforms) Valid() bool {
+func (p Platfs) Valid() bool {
if p.DosGus.Slug == "msdosgus" {
return true
}
@@ -137,8 +141,8 @@ func (p Platforms) Valid() bool {
return false
}
-// Platform is the production platform data from the Pouet API.
-type Platform struct {
+// Platf is the production platform data from the Pouet API.
+type Platf struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
@@ -217,7 +221,7 @@ func (r *Response) Get(id int) error {
// Uploader retrieves and parses the production data from the Pouet API.
// The id value is the Pouet production ID and must be greater than 0.
// The data is intended for the Pouet Uploader.
-func (p *Pouet) Uploader(id int) error {
+func (p *Production) Uploader(id int) error {
if id < firstID {
return fmt.Errorf("%w: %d", ErrID, id)
}
@@ -233,10 +237,10 @@ func (p *Pouet) Uploader(id int) error {
p.Title = r.Prod.Title
p.ReleaseDate = r.Prod.ReleaseDate
p.Groups = r.Prod.Groups
- p.Platforms = r.Prod.Platforms
+ p.Platforms = r.Prod.Platfs
p.Types = r.Prod.Types
- p.Platform = r.Prod.Platforms.String()
- p.Valid = r.Prod.Platforms.Valid() && r.Prod.Types.Valid()
+ p.Platform = r.Prod.Platfs.String()
+ p.Valid = r.Prod.Platfs.Valid() && r.Prod.Types.Valid()
return nil
}
diff --git a/internal/pouet/pouet_test.go b/internal/pouet/pouet_test.go
index 316ea0ec..53e5529e 100644
--- a/internal/pouet/pouet_test.go
+++ b/internal/pouet/pouet_test.go
@@ -13,8 +13,8 @@ const testRemoteServers = false
func TestPlatforms(t *testing.T) {
t.Parallel()
- p := pouet.Platforms{
- DosGus: pouet.Platform{
+ p := pouet.Platfs{
+ DosGus: pouet.Platf{
Name: "DOS/GUS",
Slug: "msdosgus",
},
@@ -46,7 +46,7 @@ func TestResponseGet(t *testing.T) {
func TestPouet(t *testing.T) {
t.Parallel()
- p := pouet.Pouet{}
+ p := pouet.Production{}
err := p.Uploader(0)
require.Error(t, err)
// this pings a remote server, so it is disabled.
diff --git a/model/file.go b/model/file.go
index d2823d4f..c8402588 100644
--- a/model/file.go
+++ b/model/file.go
@@ -91,6 +91,62 @@ func InsertDemozooFile(ctx context.Context, db *sql.DB, id int64) (int64, error)
return f.ID, nil
}
+// ExistPouetFile returns true if the file record exists in the database using a Pouet production ID.
+// This function will also return true for records that have been marked as deleted.
+func ExistPouetFile(ctx context.Context, db *sql.DB, id int64) (bool, error) {
+ if db == nil {
+ return false, ErrDB
+ }
+ return models.Files(models.FileWhere.WebIDPouet.EQ(null.Int64From(id)), qm.WithDeleted()).Exists(ctx, db)
+}
+
+// FindPouetFile retrieves the ID or key of a single file record from the database using a Pouet production ID.
+// This function will also return records that have been marked as deleted and flag those with the boolean.
+// If the record is not found then the function will return an ID of 0 but without an error.
+func FindPouetFile(ctx context.Context, db *sql.DB, id int64) (bool, int64, error) {
+ if db == nil {
+ return false, 0, ErrDB
+ }
+ f, err := models.Files(
+ qm.Select("id", "deletedat"),
+ models.FileWhere.WebIDPouet.EQ(null.Int64From(id)),
+ qm.WithDeleted()).One(ctx, db)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, 0, nil
+ }
+ if err != nil {
+ return false, 0, err
+ }
+ deleted := !f.Deletedat.IsZero()
+ return deleted, f.ID, nil
+}
+
+// InsertPouetFile inserts a new file record into the database using a Pouet production ID.
+// This will not check if the Pouet production ID already exists in the database.
+// When successful the function will return the new record ID.
+func InsertPouetFile(ctx context.Context, db *sql.DB, id int64) (int64, error) {
+ if db == nil {
+ return 0, ErrDB
+ }
+ if id < startID || id > demozoo.Sanity {
+ return 0, fmt.Errorf("%w: %d", ErrID, id)
+ }
+ uid, err := uuid.NewV7()
+ if err != nil {
+ return 0, err
+ }
+ now := time.Now()
+ f := models.File{
+ UUID: null.StringFrom(uid.String()),
+ WebIDPouet: null.Int64From(id),
+ Deletedat: null.TimeFromPtr(&now),
+ }
+ if err = f.Insert(ctx, db, boil.Infer()); err != nil {
+ return 0, err
+ }
+ return f.ID, nil
+}
+
// FindObf retrieves a single file record from the database using the obfuscated record key.
func FindObf(key string) (*models.File, error) {
return recordObf(false, key)
diff --git a/public/js/uploader.min.js b/public/js/uploader.min.js
index 519376bc..eea4929f 100644
--- a/public/js/uploader.min.js
+++ b/public/js/uploader.min.js
@@ -1,2 +1,2 @@
/* uploader.min.js © Defacto2 2024 */
-(()=>{"use strict";const t="is-invalid",U=document.getElementById("uploaderPouet");if(U==null)throw new Error("The uploader-pouet element is null.");const le=new bootstrap.Modal(U),m=document.getElementById("uploader-demozoo");if(m==null)throw new Error("The uploader-demozoo element is null.");const A=new bootstrap.Modal(m),C=document.getElementById("demozoo-submission");if(C==null)throw new Error("The demozoo-submission element is null.");const oe=document.getElementById("uploaderText"),de=document.getElementById("uploaderImg"),ie=document.getElementById("uploaderMag"),ce=document.getElementById("uploaderAdv"),me=document.getElementById("termsModal"),ue=new bootstrap.Modal(oe),re=new bootstrap.Modal(de),fe=new bootstrap.Modal(ie),ge=new bootstrap.Modal(ce),ve=new bootstrap.Modal(me),$=document.getElementById("paginationStart"),O=document.getElementById("paginationPrev"),j=document.getElementById("paginationPrev2"),J=document.getElementById("paginationNext"),q=document.getElementById("paginationNext2"),G=document.getElementById("paginationEnd"),n=document.getElementById("paginationRange");if(typeof n<"u"&&n!=null){n.addEventListener("change",function(){const s=n.value,a=new URL(window.location.href);let l=a.pathname.split("/");const ne=l[l.length-1];!isNaN(ne)&&typeof Number(ne)=="number"?l[l.length-1]=s:l.push(s),a.pathname=l.join("/"),window.location.href=a.href});const e=document.getElementById("paginationRangeLabel");n.addEventListener("input",function(){e.textContent="Jump to page "+n.value})}document.addEventListener("keydown",function(e){if(e.ctrlKey&&e.altKey)switch(e.key){case"d":A.show();break;case"p":le.show();break;case"i":A.show();break;case"n":ue.show();break;case"g":re.show();break;case"m":fe.show();break;case"a":ge.show();break;case"t":ve.show();break}const s="ArrowRight",a="ArrowLeft";if(e.ctrlKey&&e.key==a){$?.click();return}if(e.ctrlKey&&e.key==s){G?.click();return}if(e.shiftKey&&e.key==a){j?.click();return}if(e.shiftKey&&e.key==s){q?.click();return}if(e.key==a){O?.click();return}if(e.key==s){J?.click();return}});function o(e){if(`${e}`=="")return!0;const s=new Date().getFullYear();return!(e<1980||e>s)}function d(e){return`${e}`==""?!0:!(e<1||e>12)}function H(e){return`${e}`==""?!0:!(e<1||e>31)}const Q=document.getElementById("introUploader"),V=document.getElementById("textUploader"),W=document.getElementById("imageUploader"),X=document.getElementById("magUploader"),Z=document.getElementById("advancedUploader");m.addEventListener("shown.bs.modal",function(){C.focus()});const Ee=document.getElementById("pouetProdsID");document.getElementById("uploaderPouet").addEventListener("shown.bs.modal",function(){Ee.focus()});const u=document.getElementById("introFile"),r=document.getElementById("releaseTitle"),f=document.getElementById("introReleasers"),g=document.getElementById("introYear"),v=document.getElementById("introMonth");function _(){u.classList.remove(t),r.classList.remove(t),f.classList.remove(t),g.classList.remove(t),v.classList.remove(t)}document.getElementById("introSubmit").addEventListener("click",function(){let e=!0;_(),u.value==""&&(u.classList.add(t),e=!1),r.value==""&&(r.classList.add(t),e=!1),f.value==""&&(f.classList.add(t),e=!1),o(g.value)==!1&&(g.classList.add(t),e=!1),d(v.value)==!1&&(v.classList.add(t),e=!1),e==!0&&Q.submit()}),Q.addEventListener("reset",_);const E=document.getElementById("textFile"),L=document.getElementById("textTitle"),y=document.getElementById("textReleasers"),I=document.getElementById("textYear"),p=document.getElementById("textMonth");function ee(){E.classList.remove(t),L.classList.remove(t),y.classList.remove(t),I.classList.remove(t),p.classList.remove(t)}document.getElementById("textSubmit").addEventListener("click",function(){let e=!0;ee(),E.value==""&&(E.classList.add(t),e=!1),L.value==""&&(L.classList.add(t),e=!1),y.value==""&&(y.classList.add(t),e=!1),o(I.value)==!1&&(I.classList.add(t),e=!1),d(p.value)==!1&&(p.classList.add(t),e=!1),e==!0&&V.submit()}),V.addEventListener("reset",ee);const B=document.getElementById("imageFile"),h=document.getElementById("imageTitle"),M=document.getElementById("imageReleasers"),b=document.getElementById("imageYear"),w=document.getElementById("imageMonth");function te(){B.classList.remove(t),h.classList.remove(t),M.classList.remove(t),b.classList.remove(t),w.classList.remove(t)}document.getElementById("imageSubmit").addEventListener("click",function(){let e=!0;te(),B.value==""&&(B.classList.add(t),e=!1),h.value==""&&(h.classList.add(t),e=!1),M.value==""&&(M.classList.add(t),e=!1),o(b.value)==!1&&(b.classList.add(t),e=!1),d(w.value)==!1&&(w.classList.add(t),e=!1),e==!0&&W.submit()}),W.addEventListener("reset",te);const k=document.getElementById("magFile"),x=document.getElementById("magTitle"),R=document.getElementById("magIssue"),F=document.getElementById("magYear"),T=document.getElementById("magMonth"),Y=document.getElementById("magDay");function se(){k.classList.remove(t),x.classList.remove(t),R.classList.remove(t),F.classList.remove(t),T.classList.remove(t),Y.classList.remove(t)}document.getElementById("magSubmit").addEventListener("click",function(){let e=!0;se(),k.value==""&&(k.classList.add(t),e=!1),x.value==""&&(x.classList.add(t),e=!1),R.value==""&&(R.classList.add(t),e=!1),o(F.value)==!1&&(F.classList.add(t),e=!1),d(T.value)==!1&&(T.classList.add(t),e=!1),H(Y.value)==!1&&(Y.classList.add(t),e=!1),e==!0&&X.submit()}),X.addEventListener("reset",se);const S=document.getElementById("advFile"),i=document.getElementById("advSelOS"),c=document.getElementById("advSelCat"),z=document.getElementById("advTitle"),D=document.getElementById("releasersAdv"),N=document.getElementById("advYear"),P=document.getElementById("advMonth"),K=document.getElementById("advDay");function ae(){S.classList.remove(t),i.classList.remove(t),c.classList.remove(t),z.classList.remove(t),D.classList.remove(t),N.classList.remove(t),P.classList.remove(t),K.classList.remove(t)}document.getElementById("advSubmit").addEventListener("click",function(){const e="Choose...";let s=!0;ae(),S.value==""&&(S.classList.add(t),s=!1),(i.value==""||i.value==e)&&(i.classList.add(t),s=!1),(c.value==""||c.value==e)&&(c.classList.add(t),s=!1),z.value==""&&(z.classList.add(t),s=!1),D.value==""&&(D.classList.add(t),s=!1),o(N.value)==!1&&(N.classList.add(t),s=!1),d(P.value)==!1&&(P.classList.add(t),s=!1),H(K.value)==!1&&(K.classList.add(t),s=!1),s==!0&&Z.submit()}),Z.addEventListener("reset",ae)})();
+(()=>{"use strict";const t="is-invalid",r=document.getElementById("uploader-pouet");if(r==null)throw new Error("The uploader-pouet element is null.");r.addEventListener("shown.bs.modal",function(){$.focus()});const ce=new bootstrap.Modal(r),$=document.getElementById("pouet-submission");if($==null)throw new Error("The pouet-submission element is null.");const u=document.getElementById("uploader-demozoo");if(u==null)throw new Error("The uploader-demozoo element is null.");u.addEventListener("shown.bs.modal",function(){J.focus()});const j=new bootstrap.Modal(u),J=document.getElementById("demozoo-submission");if(J==null)throw new Error("The demozoo-submission element is null.");const me=document.getElementById("uploaderText"),re=document.getElementById("uploaderImg"),ue=document.getElementById("uploaderMag"),fe=document.getElementById("uploaderAdv"),ge=document.getElementById("termsModal"),ve=new bootstrap.Modal(me),Ee=new bootstrap.Modal(re),Le=new bootstrap.Modal(ue),ye=new bootstrap.Modal(fe),pe=new bootstrap.Modal(ge),q=document.getElementById("paginationStart"),G=document.getElementById("paginationPrev"),H=document.getElementById("paginationPrev2"),Q=document.getElementById("paginationNext"),V=document.getElementById("paginationNext2"),W=document.getElementById("paginationEnd"),n=document.getElementById("paginationRange");if(typeof n<"u"&&n!=null){n.addEventListener("change",function(){const s=n.value,d=new URL(window.location.href);let a=d.pathname.split("/");const m=a[a.length-1];!isNaN(m)&&typeof Number(m)=="number"?a[a.length-1]=s:a.push(s),d.pathname=a.join("/"),window.location.href=d.href});const e=document.getElementById("paginationRangeLabel");n.addEventListener("input",function(){e.textContent="Jump to page "+n.value})}document.addEventListener("keydown",function(e){const s="d",d="p",ie="i",a="n",m="g",Ie="m",Be="a",he="t";if(e.ctrlKey&&e.altKey)switch(e.key){case s:j.show();break;case d:ce.show();break;case ie:j.show();break;case a:ve.show();break;case m:Ee.show();break;case Ie:Le.show();break;case Be:ye.show();break;case he:pe.show();break}const P="ArrowRight",O="ArrowLeft";if(e.ctrlKey&&e.key==O){q?.click();return}if(e.ctrlKey&&e.key==P){W?.click();return}if(e.shiftKey&&e.key==O){H?.click();return}if(e.shiftKey&&e.key==P){V?.click();return}if(e.key==O){G?.click();return}if(e.key==P){Q?.click();return}});function l(e){if(`${e}`=="")return!0;const s=new Date().getFullYear();return!(e<1980||e>s)}function o(e){return`${e}`==""?!0:!(e<1||e>12)}function X(e){return`${e}`==""?!0:!(e<1||e>31)}const Z=document.getElementById("introUploader"),_=document.getElementById("textUploader"),ee=document.getElementById("imageUploader"),te=document.getElementById("magUploader"),se=document.getElementById("advancedUploader"),f=document.getElementById("introFile"),g=document.getElementById("releaseTitle"),v=document.getElementById("introReleasers"),E=document.getElementById("introYear"),L=document.getElementById("introMonth");function ae(){f.classList.remove(t),g.classList.remove(t),v.classList.remove(t),E.classList.remove(t),L.classList.remove(t)}document.getElementById("introSubmit").addEventListener("click",function(){let e=!0;ae(),f.value==""&&(f.classList.add(t),e=!1),g.value==""&&(g.classList.add(t),e=!1),v.value==""&&(v.classList.add(t),e=!1),l(E.value)==!1&&(E.classList.add(t),e=!1),o(L.value)==!1&&(L.classList.add(t),e=!1),e==!0&&Z.submit()}),Z.addEventListener("reset",ae);const y=document.getElementById("textFile"),p=document.getElementById("textTitle"),I=document.getElementById("textReleasers"),B=document.getElementById("textYear"),h=document.getElementById("textMonth");function ne(){y.classList.remove(t),p.classList.remove(t),I.classList.remove(t),B.classList.remove(t),h.classList.remove(t)}document.getElementById("textSubmit").addEventListener("click",function(){let e=!0;ne(),y.value==""&&(y.classList.add(t),e=!1),p.value==""&&(p.classList.add(t),e=!1),I.value==""&&(I.classList.add(t),e=!1),l(B.value)==!1&&(B.classList.add(t),e=!1),o(h.value)==!1&&(h.classList.add(t),e=!1),e==!0&&_.submit()}),_.addEventListener("reset",ne);const b=document.getElementById("imageFile"),w=document.getElementById("imageTitle"),M=document.getElementById("imageReleasers"),k=document.getElementById("imageYear"),x=document.getElementById("imageMonth");function le(){b.classList.remove(t),w.classList.remove(t),M.classList.remove(t),k.classList.remove(t),x.classList.remove(t)}document.getElementById("imageSubmit").addEventListener("click",function(){let e=!0;le(),b.value==""&&(b.classList.add(t),e=!1),w.value==""&&(w.classList.add(t),e=!1),M.value==""&&(M.classList.add(t),e=!1),l(k.value)==!1&&(k.classList.add(t),e=!1),o(x.value)==!1&&(x.classList.add(t),e=!1),e==!0&&ee.submit()}),ee.addEventListener("reset",le);const R=document.getElementById("magFile"),F=document.getElementById("magTitle"),T=document.getElementById("magIssue"),Y=document.getElementById("magYear"),S=document.getElementById("magMonth"),z=document.getElementById("magDay");function oe(){R.classList.remove(t),F.classList.remove(t),T.classList.remove(t),Y.classList.remove(t),S.classList.remove(t),z.classList.remove(t)}document.getElementById("magSubmit").addEventListener("click",function(){let e=!0;oe(),R.value==""&&(R.classList.add(t),e=!1),F.value==""&&(F.classList.add(t),e=!1),T.value==""&&(T.classList.add(t),e=!1),l(Y.value)==!1&&(Y.classList.add(t),e=!1),o(S.value)==!1&&(S.classList.add(t),e=!1),X(z.value)==!1&&(z.classList.add(t),e=!1),e==!0&&te.submit()}),te.addEventListener("reset",oe);const N=document.getElementById("advFile"),i=document.getElementById("advSelOS"),c=document.getElementById("advSelCat"),D=document.getElementById("advTitle"),K=document.getElementById("releasersAdv"),U=document.getElementById("advYear"),A=document.getElementById("advMonth"),C=document.getElementById("advDay");function de(){N.classList.remove(t),i.classList.remove(t),c.classList.remove(t),D.classList.remove(t),K.classList.remove(t),U.classList.remove(t),A.classList.remove(t),C.classList.remove(t)}document.getElementById("advSubmit").addEventListener("click",function(){const e="Choose...";let s=!0;de(),N.value==""&&(N.classList.add(t),s=!1),(i.value==""||i.value==e)&&(i.classList.add(t),s=!1),(c.value==""||c.value==e)&&(c.classList.add(t),s=!1),D.value==""&&(D.classList.add(t),s=!1),K.value==""&&(K.classList.add(t),s=!1),l(U.value)==!1&&(U.classList.add(t),s=!1),o(A.value)==!1&&(A.classList.add(t),s=!1),X(C.value)==!1&&(C.classList.add(t),s=!1),s==!0&&se.submit()}),se.addEventListener("reset",de)})();
diff --git a/view/app/layout_uploader.tmpl b/view/app/layout_uploader.tmpl
index 8ad1315f..b8643a14 100644
--- a/view/app/layout_uploader.tmpl
+++ b/view/app/layout_uploader.tmpl
@@ -7,7 +7,7 @@