From 81f0988ecf3cc590fd80e5f578d068def7acc2ce Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 4 Aug 2026 22:45:10 -0500 Subject: [PATCH] Feature set 132. Give games artwork: a full logo and a square thumbnail. Games were the one catalogue entity with no picture. They now carry two, because the full logo and the square emblem are different crops rather than two sizes of the same image -- the full lockup is wide and mostly title text, which is illegible and badly proportioned wherever a square is wanted. Both are editable and uploadable, and both are served by /api/games and /api/games/{id}. No generated slot, unlike characters and monsters. The generation stage selects those two only, so art parked at gen/games/{id}.webp would be promoted by nothing and served by nothing. Enforcing that turned the upload endpoint's flat resource and slot lists into a per-resource map: characters and monsters take original or generated, games take original or thumbnail. A shared list plus an exception for games would have rotted at the next shape. Keys follow the bucket layout already in use -- thumb/games/7.webp sits beside gen/monsters/12.webp. The dashboard's art panel was likewise hardcoded to two fixed slots in three places. Slots are now per tab, and a tab marks which one the table thumbnail should prefer -- the square one for games, the generated one for everything else, which is what it already did. Co-Authored-By: Claude Opus 5 --- README.md | 4 + src/MoogleAPI.Web/Dashboard/index.html | 42 +- .../Dashboard/Browse/GamesEndpoint.cs | 3 +- .../Features/Dashboard/Browse/Models.cs | 10 +- .../Features/Dashboard/Create/Endpoints.cs | 3 +- .../Features/Dashboard/Update/GameEndpoint.cs | 6 +- .../Features/Dashboard/Update/Models.cs | 2 + .../Dashboard/UploadImage/Endpoint.cs | 52 ++- .../Features/Games/Get/Endpoint.cs | 2 +- .../Features/Games/Get/Models.cs | 4 + .../Features/Games/GetAll/Endpoint.cs | 2 +- .../Features/Games/GetAll/Models.cs | 7 +- .../Infrastructure/Models/Game.cs | 22 + .../20260805033624_AddGameImages.Designer.cs | 406 ++++++++++++++++++ .../20260805033624_AddGameImages.cs | 48 +++ .../Migrations/AppDbContextModelSnapshot.cs | 9 + 16 files changed, 597 insertions(+), 25 deletions(-) create mode 100644 src/MoogleAPI.Web/Migrations/20260805033624_AddGameImages.Designer.cs create mode 100644 src/MoogleAPI.Web/Migrations/20260805033624_AddGameImages.cs diff --git a/README.md b/README.md index 55dd3bc..61d9ca5 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ GET /api/monsters?gameId=4 # every Final Fantasy IV monster | `GET` | `/api/games` | List all games (`page`, `pageSize`) | | `GET` | `/api/games/{id}` | Get a game by ID (includes character + monster counts) | +Both return two pictures: `imageUrl` is the full logo — the wide lockup with the title text — +and `thumbnailUrl` is the square emblem, the artwork alone. They are separate crops rather than +two sizes of one image, so pick by shape, not by resolution. + ### Arena Powers [Battle Square](https://moogleapi.com/battle-square) — one character against eight consecutive waves of their own game's monsters. diff --git a/src/MoogleAPI.Web/Dashboard/index.html b/src/MoogleAPI.Web/Dashboard/index.html index 0d994a6..e4d9fd1 100644 --- a/src/MoogleAPI.Web/Dashboard/index.html +++ b/src/MoogleAPI.Web/Dashboard/index.html @@ -700,8 +700,16 @@ endpoint: '/api/dashboard/games', singular: 'game', paged: false, - hasArt: false, + hasArt: true, + // A square emblem and the wide logo — two crops, not two sizes. No generated slot: + // generation never selects games, so it would upload into a column nothing promotes + // and nothing serves. The API refuses that combination too. + artSlots: [ + { key: 'thumbnailUrl', label: 'Thumbnail', slot: 'thumbnail', preview: true }, + { key: 'imageUrl', label: 'Full logo', slot: 'original' }, + ], columns: [ + { label: '', cls: 'thumb', art: true, px: 64, prio: 1 }, { label: 'ID', cls: 'id', get: r => r.id, px: 54, prio: 2 }, { label: 'Name', cls: 'name', get: r => r.fields.name, flex: 2, prio: 1 }, { label: 'Year', cls: 'num', get: r => r.fields.releaseYear, px: 58, prio: 1 }, @@ -720,12 +728,24 @@ }, }; - // Both artwork columns, and which R2 slot an upload to each one targets. + // Both artwork columns, and which R2 slot an upload to each one targets. A tab can narrow + // this with its own `artSlots` — games carry a logo and nothing else. const ART_SLOTS = [ { key: 'imageUrl', label: 'Original', slot: 'original' }, - { key: 'generatedImageUrl', label: 'Generated', slot: 'generated' }, + // `preview` picks which picture the table cell shows when a row has more than one. + { key: 'generatedImageUrl', label: 'Generated', slot: 'generated', preview: true }, ]; + const artSlotsFor = cfg => cfg.artSlots ?? ART_SLOTS; + + // The marked slot if it has a picture, otherwise whichever slot does. + function previewUrl(row, cfg) { + const slots = artSlotsFor(cfg); + const chosen = slots.find(s => s.preview && row.fields[s.key]) + ?? slots.find(s => row.fields[s.key]); + return chosen ? row.fields[chosen.key] : null; + } + // ── State ─────────────────────────────────────────────────────────────────── const state = { tab: 'characters', search: '', gameId: '', page: 1, pageSize: 50, total: 0 }; const editing = { tab: null, row: null, mode: 'edit', inputs: new Map(), art: new Map() }; @@ -832,7 +852,7 @@ function artCell(row) { const td = document.createElement('td'); td.className = 'thumb'; - const src = row.fields.generatedImageUrl || row.fields.imageUrl; + const src = previewUrl(row, TABS[state.tab]); if (!src) { const none = document.createElement('div'); @@ -934,7 +954,7 @@ // A row can carry two pictures — the original scraped art and its generated replacement — so // the modal offers both rather than picking one: comparing them is usually the reason to look. function openZoom(row) { - const sources = ART_SLOTS + const sources = artSlotsFor(TABS[state.tab]) .filter(s => row.fields[s.key]) .map(s => ({ label: s.label, url: row.fields[s.key] })) .reverse(); @@ -1140,6 +1160,10 @@ } else { fields.releaseYear = new Date().getFullYear(); fields.platform = ''; + // Games carry two pictures and no generated slot. + fields.imageUrl = null; + fields.thumbnailUrl = null; + fields.imageSourceUrl = null; } return { id: null, gameName: '', releaseYear: 0, fields }; } @@ -1168,7 +1192,7 @@ $('editor-art-wrap').hidden = !cfg.hasArt || creating; if (cfg.hasArt && !creating) { - $('editor-art').replaceChildren(...ART_SLOTS.map(slot => buildArtSlot(slot, row))); + $('editor-art').replaceChildren(...artSlotsFor(cfg).map(slot => buildArtSlot(slot, row))); // Provenance is not something to hand-type, but it decides whether the image tool will // overwrite this row's art, so it is shown and editable rather than hidden. @@ -1178,7 +1202,9 @@ } else if (cfg.hasArt && creating) { // The draft may already carry a URL the import found; keep it rather than dropping it on // the floor just because the upload panel is not shown yet. - for (const key of ['imageUrl', 'generatedImageUrl', 'imageSourceUrl']) { + // Only the keys this tab actually has — posting a generatedImageUrl back for a game + // would be a field its edit record does not carry. + for (const key of [...artSlotsFor(cfg).map(s => s.key), 'imageSourceUrl']) { const hidden = document.createElement('input'); hidden.type = 'hidden'; hidden.value = row.fields[key] ?? ''; @@ -1402,7 +1428,7 @@ name.textContent = row.fields.name; body.append(name, ` (#${row.id})? This cannot be undone.`); - if (cfg.hasArt && (row.fields.imageUrl || row.fields.generatedImageUrl)) { + if (cfg.hasArt && artSlotsFor(cfg).some(s => row.fields[s.key])) { body.append(document.createElement('br')); const note = document.createElement('small'); note.style.color = 'var(--muted)'; diff --git a/src/MoogleAPI.Web/Features/Dashboard/Browse/GamesEndpoint.cs b/src/MoogleAPI.Web/Features/Dashboard/Browse/GamesEndpoint.cs index 4d1068f..88cca5f 100644 --- a/src/MoogleAPI.Web/Features/Dashboard/Browse/GamesEndpoint.cs +++ b/src/MoogleAPI.Web/Features/Dashboard/Browse/GamesEndpoint.cs @@ -32,7 +32,8 @@ public override async Task HandleAsync(BrowseRequest req, CancellationToken ct) .OrderBy(g => g.ReleaseYear).ThenBy(g => g.Name) .Select(g => new GameRow( g.Id, g.Characters.Count, g.Monsters.Count, g.Cards.Count, - new GameEdit(g.Name, g.ReleaseYear, g.Platform, g.Description))) + new GameEdit(g.Name, g.ReleaseYear, g.Platform, g.Description, + g.ImageUrl, g.ThumbnailUrl, g.ImageSourceUrl))) .ToListAsync(ct); await Send.OkAsync(new BrowseResponse(items, items.Count, 1, items.Count), ct); diff --git a/src/MoogleAPI.Web/Features/Dashboard/Browse/Models.cs b/src/MoogleAPI.Web/Features/Dashboard/Browse/Models.cs index 0dcb2b5..60f3117 100644 --- a/src/MoogleAPI.Web/Features/Dashboard/Browse/Models.cs +++ b/src/MoogleAPI.Web/Features/Dashboard/Browse/Models.cs @@ -79,11 +79,19 @@ public record MonsterEdit( int GameId ); +/// +/// Two images, and no GeneratedImageUrl. The full logo and the square emblem are separate +/// crops rather than two sizes of one picture, so both are stored. Generation, meanwhile, selects +/// monsters and characters only — a generated slot here would be one nothing can ever fill. +/// public record GameEdit( string Name, int ReleaseYear, string Platform, - string? Description + string? Description, + string? ImageUrl, + string? ThumbnailUrl, + string? ImageSourceUrl ); // ── Rows ────────────────────────────────────────────────────────────────────── diff --git a/src/MoogleAPI.Web/Features/Dashboard/Create/Endpoints.cs b/src/MoogleAPI.Web/Features/Dashboard/Create/Endpoints.cs index 87250e2..220b77a 100644 --- a/src/MoogleAPI.Web/Features/Dashboard/Create/Endpoints.cs +++ b/src/MoogleAPI.Web/Features/Dashboard/Create/Endpoints.cs @@ -208,7 +208,8 @@ public override async Task HandleAsync(CreateGameRequest req, CancellationToken await Send.OkAsync(new CreateResponse( new GameRow(game.Id, 0, 0, 0, - new GameEdit(game.Name, game.ReleaseYear, game.Platform, game.Description)), + new GameEdit(game.Name, game.ReleaseYear, game.Platform, game.Description, + game.ImageUrl, game.ThumbnailUrl, game.ImageSourceUrl)), duplicate ? $"There was already a game called {game.Name}." : null), ct); } } diff --git a/src/MoogleAPI.Web/Features/Dashboard/Update/GameEndpoint.cs b/src/MoogleAPI.Web/Features/Dashboard/Update/GameEndpoint.cs index aeb89bb..4debc7c 100644 --- a/src/MoogleAPI.Web/Features/Dashboard/Update/GameEndpoint.cs +++ b/src/MoogleAPI.Web/Features/Dashboard/Update/GameEndpoint.cs @@ -35,6 +35,9 @@ public override async Task HandleAsync(UpdateGameRequest req, CancellationToken game.ReleaseYear = f.ReleaseYear; game.Platform = f.Platform.Trim(); game.Description = EditText.Clean(f.Description); + game.ImageUrl = EditText.Clean(f.ImageUrl); + game.ThumbnailUrl = EditText.Clean(f.ThumbnailUrl); + game.ImageSourceUrl = EditText.Clean(f.ImageSourceUrl); await db.SaveChangesAsync(ct); await CatalogCache.InvalidateAsync(cache, ct); @@ -46,6 +49,7 @@ public override async Task HandleAsync(UpdateGameRequest req, CancellationToken await Send.OkAsync(new UpdateResponse( new GameRow(game.Id, counts.Characters, counts.Monsters, counts.Cards, - new GameEdit(game.Name, game.ReleaseYear, game.Platform, game.Description))), ct); + new GameEdit(game.Name, game.ReleaseYear, game.Platform, game.Description, + game.ImageUrl, game.ThumbnailUrl, game.ImageSourceUrl))), ct); } } diff --git a/src/MoogleAPI.Web/Features/Dashboard/Update/Models.cs b/src/MoogleAPI.Web/Features/Dashboard/Update/Models.cs index 67251a4..97dcdd6 100644 --- a/src/MoogleAPI.Web/Features/Dashboard/Update/Models.cs +++ b/src/MoogleAPI.Web/Features/Dashboard/Update/Models.cs @@ -130,5 +130,7 @@ public UpdateGameValidator() .WithMessage("Release year looks wrong."); RuleFor(x => x.Fields.Description).MaximumLength(EditRules.MaxLongText); + RuleFor(x => x.Fields.ImageUrl).OptionalUrl(); + RuleFor(x => x.Fields.ThumbnailUrl).OptionalUrl(); } } diff --git a/src/MoogleAPI.Web/Features/Dashboard/UploadImage/Endpoint.cs b/src/MoogleAPI.Web/Features/Dashboard/UploadImage/Endpoint.cs index 79e190e..4b2ec91 100644 --- a/src/MoogleAPI.Web/Features/Dashboard/UploadImage/Endpoint.cs +++ b/src/MoogleAPI.Web/Features/Dashboard/UploadImage/Endpoint.cs @@ -8,7 +8,7 @@ namespace MoogleAPI.Web.Features.Dashboard.UploadImage; public class UploadImageRequest { - /// "characters" or "monsters" — the same words the bucket uses for its folders. + /// "characters", "monsters" or "games" — the same words the bucket uses for its folders. public string Resource { get; set; } = string.Empty; public int Id { get; set; } @@ -53,8 +53,17 @@ public class Endpoint(AppDbContext db, HybridCache cache, ImageUploadStore store /// Comfortably above any source art, far below anything that would stall the server. private const long MaxBytes = 15 * 1024 * 1024; - private static readonly string[] Resources = ["characters", "monsters"]; - private static readonly string[] Slots = ["original", "generated"]; + /// + /// Which slots each resource actually has. A flat list would let a caller upload a game's + /// "generated" art — generation never selects games, so that file would be promoted by + /// nothing and served by nothing. + /// + private static readonly Dictionary SlotsByResource = new() + { + ["characters"] = ["original", "generated"], + ["monsters"] = ["original", "generated"], + ["games"] = ["original", "thumbnail"], + }; public override void Configure() { @@ -78,9 +87,16 @@ public override async Task HandleAsync(UploadImageRequest req, CancellationToken var resource = req.Resource.ToLowerInvariant(); var slot = req.Slot.ToLowerInvariant(); - if (!Resources.Contains(resource) || !Slots.Contains(slot)) + if (!SlotsByResource.TryGetValue(resource, out var allowedSlots)) { - AddError($"Resource must be one of {string.Join(", ", Resources)} and slot one of {string.Join(", ", Slots)}."); + AddError($"Resource must be one of {string.Join(", ", SlotsByResource.Keys)}."); + await Send.ErrorsAsync(cancellation: ct); + return; + } + + if (!allowedSlots.Contains(slot)) + { + AddError($"{resource} take slot {string.Join(" or ", allowedSlots)}, not \"{slot}\"."); await Send.ErrorsAsync(cancellation: ct); return; } @@ -92,9 +108,12 @@ public override async Task HandleAsync(UploadImageRequest req, CancellationToken return; } - var exists = resource == "characters" - ? await db.Characters.AnyAsync(c => c.Id == req.Id, ct) - : await db.Monsters.AnyAsync(m => m.Id == req.Id, ct); + var exists = resource switch + { + "characters" => await db.Characters.AnyAsync(c => c.Id == req.Id, ct), + "monsters" => await db.Monsters.AnyAsync(m => m.Id == req.Id, ct), + _ => await db.Games.AnyAsync(g => g.Id == req.Id, ct), + }; if (!exists) { @@ -102,7 +121,14 @@ public override async Task HandleAsync(UploadImageRequest req, CancellationToken return; } - var key = slot == "generated" ? $"gen/{resource}/{req.Id}.webp" : $"{resource}/{req.Id}.webp"; + // Prefix per slot, mirroring the bucket layout the image tool already writes: + // gen/monsters/12.webp, thumb/games/7.webp, monsters/12.webp. + var key = slot switch + { + "generated" => $"gen/{resource}/{req.Id}.webp", + "thumbnail" => $"thumb/{resource}/{req.Id}.webp", + _ => $"{resource}/{req.Id}.webp", + }; string url; try @@ -125,12 +151,18 @@ public override async Task HandleAsync(UploadImageRequest req, CancellationToken if (slot == "generated") row.GeneratedImageUrl = url; else { row.ImageUrl = url; row.ImageSourceUrl = provenance; } } - else + else if (resource == "monsters") { var row = await db.Monsters.FirstAsync(m => m.Id == req.Id, ct); if (slot == "generated") row.GeneratedImageUrl = url; else { row.ImageUrl = url; row.ImageSourceUrl = provenance; } } + else + { + var row = await db.Games.FirstAsync(g => g.Id == req.Id, ct); + if (slot == "thumbnail") row.ThumbnailUrl = url; + else { row.ImageUrl = url; row.ImageSourceUrl = provenance; } + } await db.SaveChangesAsync(ct); await CatalogCache.InvalidateAsync(cache, ct); diff --git a/src/MoogleAPI.Web/Features/Games/Get/Endpoint.cs b/src/MoogleAPI.Web/Features/Games/Get/Endpoint.cs index 004752c..e6ca061 100644 --- a/src/MoogleAPI.Web/Features/Games/Get/Endpoint.cs +++ b/src/MoogleAPI.Web/Features/Games/Get/Endpoint.cs @@ -24,7 +24,7 @@ public override async Task HandleAsync(GetGameRequest req, CancellationToken ct) async token => await db.Games .Where(g => g.Id == req.Id) .Select(g => new GetGameResponse( - g.Id, g.Name, g.ReleaseYear, g.Platform, g.Description, + g.Id, g.Name, g.ReleaseYear, g.Platform, g.Description, g.ImageUrl, g.ThumbnailUrl, g.Characters.Count, g.Monsters.Count)) .FirstOrDefaultAsync(token), tags: CatalogCache.Tags, diff --git a/src/MoogleAPI.Web/Features/Games/Get/Models.cs b/src/MoogleAPI.Web/Features/Games/Get/Models.cs index c926fbf..73b3eba 100644 --- a/src/MoogleAPI.Web/Features/Games/Get/Models.cs +++ b/src/MoogleAPI.Web/Features/Games/Get/Models.cs @@ -8,6 +8,10 @@ public record GetGameResponse( int ReleaseYear, string Platform, string? Description, + /// The full logo — the wide lockup with the title text. + string? ImageUrl, + /// The square emblem — artwork only, no title text. + string? ThumbnailUrl, int CharacterCount, int MonsterCount ); diff --git a/src/MoogleAPI.Web/Features/Games/GetAll/Endpoint.cs b/src/MoogleAPI.Web/Features/Games/GetAll/Endpoint.cs index 4843b09..0cbaea9 100644 --- a/src/MoogleAPI.Web/Features/Games/GetAll/Endpoint.cs +++ b/src/MoogleAPI.Web/Features/Games/GetAll/Endpoint.cs @@ -30,7 +30,7 @@ public override async Task HandleAsync(GetAllGamesRequest req, CancellationToken .OrderBy(g => g.ReleaseYear) .Skip((req.Page - 1) * req.PageSize) .Take(req.PageSize) - .Select(g => new GameSummary(g.Id, g.Name, g.ReleaseYear, g.Platform)) + .Select(g => new GameSummary(g.Id, g.Name, g.ReleaseYear, g.Platform, g.ImageUrl, g.ThumbnailUrl)) .ToListAsync(token); return new GetAllGamesResponse(items, total, req.Page, req.PageSize); diff --git a/src/MoogleAPI.Web/Features/Games/GetAll/Models.cs b/src/MoogleAPI.Web/Features/Games/GetAll/Models.cs index df8b2b5..327f424 100644 --- a/src/MoogleAPI.Web/Features/Games/GetAll/Models.cs +++ b/src/MoogleAPI.Web/Features/Games/GetAll/Models.cs @@ -2,6 +2,11 @@ namespace MoogleAPI.Web.Features.Games.GetAll; public record GetAllGamesRequest(int Page = 1, int PageSize = 20); -public record GameSummary(int Id, string Name, int ReleaseYear, string Platform); +public record GameSummary( + int Id, string Name, int ReleaseYear, string Platform, + /// The full logo — the wide lockup with the title text. + string? ImageUrl, + /// The square emblem — artwork only, no title text. + string? ThumbnailUrl); public record GetAllGamesResponse(IReadOnlyList Items, int TotalCount, int Page, int PageSize); diff --git a/src/MoogleAPI.Web/Infrastructure/Models/Game.cs b/src/MoogleAPI.Web/Infrastructure/Models/Game.cs index a2974f2..f51ee48 100644 --- a/src/MoogleAPI.Web/Infrastructure/Models/Game.cs +++ b/src/MoogleAPI.Web/Infrastructure/Models/Game.cs @@ -8,6 +8,28 @@ public class Game public string Platform { get; set; } = string.Empty; public string? Description { get; set; } + /// + /// The game's full logo — the wide lockup with the title text. Hand-uploaded through the + /// dashboard rather than scraped: there is no per-game article the image pipeline reads, and + /// a logo is a fixed piece of brand art, not something to search for. + /// + public string? ImageUrl { get; set; } + + /// + /// The square emblem — the artwork alone, without the title text. A separate column rather + /// than a resize of because it is a different crop, not a smaller one: + /// the full logo is wide and mostly text, which is illegible and badly proportioned wherever + /// a square is wanted. + /// + public string? ThumbnailUrl { get; set; } + + /// + /// Where came from, matching Monster.ImageSourceUrl. Games are + /// not in the copy or generation stages, so nothing reads this to decide what to re-fetch; + /// it is kept so a logo's provenance is recorded the same way as every other image. + /// + public string? ImageSourceUrl { get; set; } + public ICollection Characters { get; set; } = []; public ICollection Monsters { get; set; } = []; public ICollection Cards { get; set; } = []; diff --git a/src/MoogleAPI.Web/Migrations/20260805033624_AddGameImages.Designer.cs b/src/MoogleAPI.Web/Migrations/20260805033624_AddGameImages.Designer.cs new file mode 100644 index 0000000..05289fe --- /dev/null +++ b/src/MoogleAPI.Web/Migrations/20260805033624_AddGameImages.Designer.cs @@ -0,0 +1,406 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MoogleAPI.Web.Infrastructure.Data; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MoogleAPI.Web.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260805033624_AddGameImages")] + partial class AddGameImages + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MoogleAPI.Web.Infrastructure.Models.Card", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bottom") + .HasColumnType("integer"); + + b.Property("CardClass") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Element") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("ImageSourceUrl") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("Left") + .HasColumnType("integer"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Right") + .HasColumnType("integer"); + + b.Property("Top") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("Name", "GameId") + .IsUnique(); + + b.ToTable("Cards"); + }); + + modelBuilder.Entity("MoogleAPI.Web.Infrastructure.Models.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abilities") + .HasColumnType("text"); + + b.Property("Affiliation") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("GeneratedImageUrl") + .HasColumnType("text"); + + b.Property("Hometown") + .HasColumnType("text"); + + b.Property("ImageKind") + .HasColumnType("text"); + + b.Property("ImageSourceUrl") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsPlayable") + .HasColumnType("boolean"); + + b.Property("Job") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Popularity") + .HasColumnType("integer"); + + b.Property("Race") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("text"); + + b.Property("Weapon") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WikiBacklinks") + .HasColumnType("integer"); + + b.Property("WikiPageLength") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("Popularity"); + + b.HasIndex("GameId", "IsPlayable") + .HasFilter("\"IsPlayable\" = true"); + + b.HasIndex("Name", "GameId") + .IsUnique(); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("MoogleAPI.Web.Infrastructure.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImageSourceUrl") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Platform") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("ThumbnailUrl") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("MoogleAPI.Web.Infrastructure.Models.Monster", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abilities") + .HasColumnType("text"); + + b.Property("Absorbs") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Attack") + .HasColumnType("integer"); + + b.Property("Category") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Defense") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Drops") + .HasColumnType("text"); + + b.Property("Evasion") + .HasColumnType("integer"); + + b.Property("Experience") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("GeneratedImageUrl") + .HasColumnType("text"); + + b.Property("Gil") + .HasColumnType("integer"); + + b.Property("HitPoints") + .HasColumnType("integer"); + + b.Property("ImageKind") + .HasColumnType("text"); + + b.Property("ImageSourceUrl") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("Location") + .HasColumnType("text"); + + b.Property("MagicAttack") + .HasColumnType("integer"); + + b.Property("MagicDefense") + .HasColumnType("integer"); + + b.Property("MagicPoints") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Popularity") + .HasColumnType("integer"); + + b.Property("Speed") + .HasColumnType("integer"); + + b.Property("Steals") + .HasColumnType("text"); + + b.Property("Weaknesses") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("WikiBacklinks") + .HasColumnType("integer"); + + b.Property("WikiPageLength") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Category"); + + b.HasIndex("GameId"); + + b.HasIndex("Popularity"); + + b.HasIndex("Name", "GameId") + .IsUnique(); + + b.ToTable("Monsters"); + }); + + modelBuilder.Entity("MoogleAPI.Web.Infrastructure.Models.RequestLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DurationMs") + .HasColumnType("integer"); + + b.Property("IpHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IsPremium") + .HasColumnType("boolean"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ResourceType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SearchTerm") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Timestamp"); + + b.ToTable("RequestLogs"); + }); + + modelBuilder.Entity("MoogleAPI.Web.Infrastructure.Models.Card", b => + { + b.HasOne("MoogleAPI.Web.Infrastructure.Models.Game", "Game") + .WithMany("Cards") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("MoogleAPI.Web.Infrastructure.Models.Character", b => + { + b.HasOne("MoogleAPI.Web.Infrastructure.Models.Game", "Game") + .WithMany("Characters") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("MoogleAPI.Web.Infrastructure.Models.Monster", b => + { + b.HasOne("MoogleAPI.Web.Infrastructure.Models.Game", "Game") + .WithMany("Monsters") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("MoogleAPI.Web.Infrastructure.Models.Game", b => + { + b.Navigation("Cards"); + + b.Navigation("Characters"); + + b.Navigation("Monsters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MoogleAPI.Web/Migrations/20260805033624_AddGameImages.cs b/src/MoogleAPI.Web/Migrations/20260805033624_AddGameImages.cs new file mode 100644 index 0000000..eae2fce --- /dev/null +++ b/src/MoogleAPI.Web/Migrations/20260805033624_AddGameImages.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MoogleAPI.Web.Migrations +{ + /// + public partial class AddGameImages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ImageSourceUrl", + table: "Games", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ImageUrl", + table: "Games", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ThumbnailUrl", + table: "Games", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ImageSourceUrl", + table: "Games"); + + migrationBuilder.DropColumn( + name: "ImageUrl", + table: "Games"); + + migrationBuilder.DropColumn( + name: "ThumbnailUrl", + table: "Games"); + } + } +} diff --git a/src/MoogleAPI.Web/Migrations/AppDbContextModelSnapshot.cs b/src/MoogleAPI.Web/Migrations/AppDbContextModelSnapshot.cs index ca9e30f..a032cdd 100644 --- a/src/MoogleAPI.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/MoogleAPI.Web/Migrations/AppDbContextModelSnapshot.cs @@ -169,6 +169,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Description") .HasColumnType("text"); + b.Property("ImageSourceUrl") + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + b.Property("Name") .IsRequired() .HasMaxLength(200) @@ -182,6 +188,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ReleaseYear") .HasColumnType("integer"); + b.Property("ThumbnailUrl") + .HasColumnType("text"); + b.HasKey("Id"); b.ToTable("Games");