Skip to content
Open
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
6 changes: 6 additions & 0 deletions apps/server/drizzle/0036_mod_search_terms.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Admin-owned alternative search terms per mod (e.g. "wimf" for "What's in
-- my Fool") -- same "never synced from the index" shape as hidden/featured/
-- ranked_version, but editable alongside categories via the general
-- field-edit endpoint rather than its own dedicated route. See schema.ts's
-- own doc comment on mod_registry.search_terms.
ALTER TABLE "mod_registry" ADD COLUMN "search_terms" text[] DEFAULT '{}' NOT NULL;
7 changes: 7 additions & 0 deletions apps/server/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,13 @@
"when": 1791100000000,
"tag": "0035_blog_posts",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1791200000000,
"tag": "0036_mod_search_terms",
"breakpoints": true
}
]
}
15 changes: 15 additions & 0 deletions apps/server/src/features/webadmin/mods.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,15 @@ router.patch('/mods/:modId', async (req, res, next) => {
}
input.categories = body.categories as string[]
}
if (body.searchTerms !== undefined) {
if (
!Array.isArray(body.searchTerms) ||
!body.searchTerms.every((t) => typeof t === 'string')
) {
throw new AppError('searchTerms must be a string array', 400)
}
input.searchTerms = body.searchTerms as string[]
}
if (body.requiresSteamodded !== undefined) {
if (typeof body.requiresSteamodded !== 'boolean')
throw new AppError('requiresSteamodded must be a boolean', 400)
Expand Down Expand Up @@ -406,6 +415,9 @@ router.post('/mods', async (req, res, next) => {
categories: Array.isArray(body.categories)
? (body.categories as string[])
: undefined,
searchTerms: Array.isArray(body.searchTerms)
? (body.searchTerms as string[])
: undefined,
requiresSteamodded:
typeof body.requiresSteamodded === 'boolean'
? body.requiresSteamodded
Expand Down Expand Up @@ -481,6 +493,9 @@ router.put('/mods/:modId/custom', async (req, res, next) => {
categories: Array.isArray(body.categories)
? (body.categories as string[])
: undefined,
searchTerms: Array.isArray(body.searchTerms)
? (body.searchTerms as string[])
: undefined,
requiresSteamodded: bool('requiresSteamodded'),
requiresTalisman: bool('requiresTalisman'),
repoUrl: strOrNull('repoUrl'),
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/infrastructure/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,18 @@ export const modRegistry = pgTable('mod_registry', {
title: varchar('title', { length: 128 }).notNull(),
author: varchar('author', { length: 128 }).notNull(),
categories: text('categories').array().notNull().default(sql`'{}'::text[]`),
// Admin-owned aliases a mod is commonly known/searched by but that don't
// appear in its title -- e.g. "wimf" for "What's in my Fool". Unlike
// categories, this has no upstream-index counterpart at all (the base
// index carries no such concept), so it's never touched by
// upsertModFromIndex/SYNCABLE_MOD_FIELDS and never participates in
// overriddenFields -- same "permanently admin-owned" shape as featured/
// hidden/rankedVersion above, just editable through the general PATCH
// .../mods/:modId field-edit endpoint alongside categories rather than
// its own dedicated PUT (see updateModFields()'s own comment). Matched
// case-insensitively as a substring, same as title, by whatever reads
// this for search (currently /admin/ranked-mods' filter box).
searchTerms: text('search_terms').array().notNull().default(sql`'{}'::text[]`),
requiresSteamodded: boolean('requires_steamodded').notNull().default(true),
requiresTalisman: boolean('requires_talisman').notNull().default(false),
repoUrl: text('repo_url'),
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/infrastructure/gateways/mods.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export async function listPublicMods(opts?: { includeHidden?: boolean }) {
thumbnailUrl: modRegistry.thumbnailUrl,
isCustom: modRegistry.isCustom,
overriddenFields: modRegistry.overriddenFields,
// Included here (not just on the single-mod detail fetch) so
// /admin/ranked-mods' search box can filter the already-loaded list
// client-side without a second round trip per keystroke - see
// page.tsx's search filtering.
searchTerms: modRegistry.searchTerms,
})
.from(modRegistry)
.where(opts?.includeHidden ? undefined : eq(modRegistry.hidden, false))
Expand Down Expand Up @@ -510,6 +515,7 @@ export interface CustomModInput {
title: string
author: string
categories?: string[]
searchTerms?: string[]
requiresSteamodded?: boolean
requiresTalisman?: boolean
repoUrl?: string | null
Expand Down Expand Up @@ -539,6 +545,7 @@ export async function createCustomMod(
title: input.title,
author: input.author,
categories: input.categories ?? [],
searchTerms: input.searchTerms ?? [],
requiresSteamodded: input.requiresSteamodded ?? true,
requiresTalisman: input.requiresTalisman ?? false,
repoUrl: input.repoUrl ?? null,
Expand Down Expand Up @@ -572,6 +579,7 @@ export interface UpdateCustomModInput {
title?: string
author?: string
categories?: string[]
searchTerms?: string[]
requiresSteamodded?: boolean
requiresTalisman?: boolean
repoUrl?: string | null
Expand Down Expand Up @@ -651,6 +659,12 @@ export async function updateModFields(
touch('latestVersion', input.latestVersion)
touch('latestDownloadUrl', input.latestDownloadUrl)

// Deliberately bypasses touch()/overriddenFields -- searchTerms has no
// upstream value to protect from a future sync (see schema.ts's own
// doc comment on mod_registry.searchTerms), so unlike every field
// above it's just written directly, on custom and synced mods alike.
if (input.searchTerms !== undefined) set.searchTerms = input.searchTerms

if (!existing.isCustom && edited.length > 0) {
set.overriddenFields = [
...new Set([...existing.overriddenFields, ...edited]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,25 @@ export function ModFormDialog({
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='mod-search-terms'>
Alternative search terms (comma-separated)
</Label>
<Input
id='mod-search-terms'
value={form.searchTerms}
placeholder='e.g. wimf'
onChange={(e) =>
onFormChange({ ...form, searchTerms: e.target.value })
}
/>
<p className='text-muted-foreground text-xs'>
Aliases players actually search by that don't appear in the
title - e.g. "wimf" for "What's in my Fool". Matched alongside
the title/id in the catalog search box above; never synced from
or overwritten by the upstream index.
</p>
</div>
<div className='space-y-2'>
<Label htmlFor='mod-repo-url'>
Repo URL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export function ModsTable({
mods,
isAdmin,
pendingModId,
emptyMessage,
onSetRankedVersion,
onSetFeatured,
onSetHidden,
Expand All @@ -120,6 +121,12 @@ export function ModsTable({
mods: ModSummary[]
isAdmin: boolean
pendingModId: string | null
// Distinguishes "no mods synced at all" from "a search filtered every mod
// out" - both render as an empty `mods` array, but mean very different
// things to an admin looking at a blank table (see page.tsx's
// filteredMods). Defaults to the original "nothing synced" message so
// every other/future caller doesn't need to pass one.
emptyMessage?: string
onSetRankedVersion: (mod: ModSummary, version: string | null) => void
onSetFeatured: (mod: ModSummary, featured: boolean) => void
onSetHidden: (mod: ModSummary, hidden: boolean) => void
Expand Down Expand Up @@ -213,8 +220,8 @@ export function ModsTable({
colSpan={6}
className='text-center text-muted-foreground'
>
No mods synced yet — MOD_INDEX_SYNC_ENABLED may not be set, or the
hourly sync hasn't run.
{emptyMessage ??
"No mods synced yet — MOD_INDEX_SYNC_ENABLED may not be set, or the hourly sync hasn't run."}
</TableCell>
</TableRow>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export interface ModSummary {
thumbnailUrl: string | null
isCustom: boolean
overriddenFields: string[]
// Admin-set aliases (e.g. "wimf" for "What's in my Fool") matched by the
// catalog search box alongside name/id - see page.tsx's search filtering.
searchTerms: string[]
}

export interface ModVersion {
Expand All @@ -28,6 +31,7 @@ export interface ModDetail {
title: string
author: string
categories: string[]
searchTerms: string[]
requiresSteamodded: boolean
requiresTalisman: boolean
repoUrl: string | null
Expand All @@ -49,6 +53,9 @@ export interface ModForm {
title: string
author: string
categories: string
// Comma-separated, same shape as categories - see mod-form-dialog.tsx's
// field and page.tsx's modFormToFields() for the split/trim.
searchTerms: string
requiresSteamodded: boolean
requiresTalisman: boolean
repoUrl: string
Expand All @@ -74,6 +81,7 @@ export const EMPTY_MOD_FORM: ModForm = {
title: '',
author: '',
categories: '',
searchTerms: '',
requiresSteamodded: true,
requiresTalisman: false,
repoUrl: '',
Expand Down
38 changes: 36 additions & 2 deletions apps/web/src/app/(home)/admin/ranked-mods/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { ApiError, apiFetch } from '@/lib/api'
import { useAuth } from '@/lib/auth'
import { DeleteModDialog } from './components/delete-mod-dialog'
Expand Down Expand Up @@ -84,6 +85,23 @@ export default function RankedModsPage() {

const [pendingModId, setPendingModId] = useState<string | null>(null)

// Client-side filter over the already-loaded catalog (this page fetches
// every mod up front already, see the `mods` query above) - matches
// against name/id and each mod's admin-set searchTerms (e.g. "wimf" for
// "What's in my Fool"), case-insensitive substring on each. No server
// round trip per keystroke; the catalog is small enough (admin-authored +
// one hourly sync's worth of mods) that this stays instant.
const [modSearch, setModSearch] = useState('')
const filteredMods = (mods ?? []).filter((mod) => {
const q = modSearch.trim().toLowerCase()
if (!q) return true
return (
mod.name.toLowerCase().includes(q) ||
mod.id.toLowerCase().includes(q) ||
mod.searchTerms.some((term) => term.toLowerCase().includes(q))
)
})

// rankedVersion is the sole ranked-eligibility signal now -- null un-ranks
// a mod, any other value ranks it and pins it to exactly that version
// (validated server-side against sourceType, see webadmin mods.route.ts's
Expand Down Expand Up @@ -152,6 +170,10 @@ export default function RankedModsPage() {
.split(',')
.map((c) => c.trim())
.filter(Boolean),
searchTerms: form.searchTerms
.split(',')
.map((t) => t.trim())
.filter(Boolean),
requiresSteamodded: form.requiresSteamodded,
requiresTalisman: form.requiresTalisman,
repoUrl: form.repoUrl || null,
Expand Down Expand Up @@ -230,6 +252,7 @@ export default function RankedModsPage() {
title: editModDetail.title,
author: editModDetail.author,
categories: editModDetail.categories.join(', '),
searchTerms: editModDetail.searchTerms.join(', '),
requiresSteamodded: editModDetail.requiresSteamodded,
requiresTalisman: editModDetail.requiresTalisman,
repoUrl: editModDetail.repoUrl ?? '',
Expand Down Expand Up @@ -491,14 +514,25 @@ export default function RankedModsPage() {
</div>
)}
</CardHeader>
<CardContent>
<CardContent className='space-y-4'>
<Input
value={modSearch}
onChange={(e) => setModSearch(e.target.value)}
placeholder='Search by name, id, or alternative search term (e.g. wimf)…'
className='max-w-sm'
/>
{modsLoading || !mods ? (
<p className='text-muted-foreground text-sm'>Loading…</p>
) : (
<ModsTable
mods={mods}
mods={filteredMods}
isAdmin={isAdmin}
pendingModId={pendingModId}
emptyMessage={
modSearch.trim() && mods.length > 0
? `No mods match "${modSearch.trim()}"`
: undefined
}
onSetRankedVersion={(mod, version) =>
setRankedVersionMut.mutate({
modId: mod.id,
Expand Down