Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 34 additions & 8 deletions src/MoogleAPI.Web/Dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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() };
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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.
Expand All @@ -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] ?? '';
Expand Down Expand Up @@ -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)';
Expand Down
3 changes: 2 additions & 1 deletion src/MoogleAPI.Web/Features/Dashboard/Browse/GamesEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameRow>(items, items.Count, 1, items.Count), ct);
Expand Down
10 changes: 9 additions & 1 deletion src/MoogleAPI.Web/Features/Dashboard/Browse/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,19 @@ public record MonsterEdit(
int GameId
);

/// <remarks>
/// Two images, and no <c>GeneratedImageUrl</c>. 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.
/// </remarks>
public record GameEdit(
string Name,
int ReleaseYear,
string Platform,
string? Description
string? Description,
string? ImageUrl,
string? ThumbnailUrl,
string? ImageSourceUrl
);

// ── Rows ──────────────────────────────────────────────────────────────────────
Expand Down
3 changes: 2 additions & 1 deletion src/MoogleAPI.Web/Features/Dashboard/Create/Endpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ public override async Task HandleAsync(CreateGameRequest req, CancellationToken

await Send.OkAsync(new CreateResponse<GameRow>(
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);
}
}
6 changes: 5 additions & 1 deletion src/MoogleAPI.Web/Features/Dashboard/Update/GameEndpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -46,6 +49,7 @@ public override async Task HandleAsync(UpdateGameRequest req, CancellationToken

await Send.OkAsync(new UpdateResponse<GameRow>(
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);
}
}
2 changes: 2 additions & 0 deletions src/MoogleAPI.Web/Features/Dashboard/Update/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
52 changes: 42 additions & 10 deletions src/MoogleAPI.Web/Features/Dashboard/UploadImage/Endpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace MoogleAPI.Web.Features.Dashboard.UploadImage;

public class UploadImageRequest
{
/// <summary>"characters" or "monsters" — the same words the bucket uses for its folders.</summary>
/// <summary>"characters", "monsters" or "games" — the same words the bucket uses for its folders.</summary>
public string Resource { get; set; } = string.Empty;

public int Id { get; set; }
Expand Down Expand Up @@ -53,8 +53,17 @@ public class Endpoint(AppDbContext db, HybridCache cache, ImageUploadStore store
/// <summary>Comfortably above any source art, far below anything that would stall the server.</summary>
private const long MaxBytes = 15 * 1024 * 1024;

private static readonly string[] Resources = ["characters", "monsters"];
private static readonly string[] Slots = ["original", "generated"];
/// <summary>
/// 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.
/// </summary>
private static readonly Dictionary<string, string[]> SlotsByResource = new()
{
["characters"] = ["original", "generated"],
["monsters"] = ["original", "generated"],
["games"] = ["original", "thumbnail"],
};

public override void Configure()
{
Expand All @@ -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;
}
Expand All @@ -92,17 +108,27 @@ 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)
{
await Send.NotFoundAsync(ct);
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
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/MoogleAPI.Web/Features/Games/Get/Endpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/MoogleAPI.Web/Features/Games/Get/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ public record GetGameResponse(
int ReleaseYear,
string Platform,
string? Description,
/// <summary>The full logo — the wide lockup with the title text.</summary>
string? ImageUrl,
/// <summary>The square emblem — artwork only, no title text.</summary>
string? ThumbnailUrl,
int CharacterCount,
int MonsterCount
);
2 changes: 1 addition & 1 deletion src/MoogleAPI.Web/Features/Games/GetAll/Endpoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion src/MoogleAPI.Web/Features/Games/GetAll/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/// <summary>The full logo — the wide lockup with the title text.</summary>
string? ImageUrl,
/// <summary>The square emblem — artwork only, no title text.</summary>
string? ThumbnailUrl);

public record GetAllGamesResponse(IReadOnlyList<GameSummary> Items, int TotalCount, int Page, int PageSize);
22 changes: 22 additions & 0 deletions src/MoogleAPI.Web/Infrastructure/Models/Game.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ public class Game
public string Platform { get; set; } = string.Empty;
public string? Description { get; set; }

/// <summary>
/// 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.
/// </summary>
public string? ImageUrl { get; set; }

/// <summary>
/// The square emblem — the artwork alone, without the title text. A separate column rather
/// than a resize of <see cref="ImageUrl"/> 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.
/// </summary>
public string? ThumbnailUrl { get; set; }

/// <summary>
/// Where <see cref="ImageUrl"/> came from, matching <c>Monster.ImageSourceUrl</c>. 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.
/// </summary>
public string? ImageSourceUrl { get; set; }

public ICollection<Character> Characters { get; set; } = [];
public ICollection<Monster> Monsters { get; set; } = [];
public ICollection<Card> Cards { get; set; } = [];
Expand Down
Loading