feat: Add loot builder frontend — Pinia store, views, 4 components, router + nav
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

Implements the complete frontend for BetterLoot profile management:
- Pinia store (loot.ts) with CRUD, import/export, apply-to-server actions
- LootBuilderView orchestrator with profile bar, modals, two-column layout
- LootContainerSidebar with categorized container list, search, config indicators
- LootItemEditor for per-container item settings and ungrouped item table
- LootItemPicker modal with searchable/filterable Rust item grid
- LootGroupEditor for reusable loot group management
- Router integration at /loot-builder
- Sidebar nav item with Crosshair icon and loot.view permission

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-22 00:27:46 -05:00
parent eb57c51a24
commit 9d28fdfb65
8 changed files with 1186 additions and 0 deletions

179
frontend/src/stores/loot.ts Normal file
View File

@@ -0,0 +1,179 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApi } from '@/composables/useApi'
import { useToastStore } from '@/stores/toast'
import type { LootProfileSummary, LootProfileFull, LootApplyResult } from '@/types'
export const useLootStore = defineStore('loot', () => {
const profiles = ref<LootProfileSummary[]>([])
const currentProfile = ref<LootProfileFull | null>(null)
const selectedContainer = ref<string | null>(null)
const isLoading = ref(false)
const isSaving = ref(false)
const isApplying = ref(false)
const isDirty = ref(false)
const api = useApi()
const toast = useToastStore()
const activeProfile = computed(() => profiles.value.find(p => p.is_active) || null)
async function fetchProfiles() {
isLoading.value = true
try {
const res = await api.get<{ profiles: LootProfileSummary[] }>('/loot/profiles')
profiles.value = res.profiles
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function loadProfile(id: string) {
isLoading.value = true
try {
const res = await api.get<{ profile: LootProfileFull }>(`/loot/profiles/${id}`)
currentProfile.value = res.profile
isDirty.value = false
// Select first container if none selected
if (!selectedContainer.value && currentProfile.value.loot_table) {
const keys = Object.keys(currentProfile.value.loot_table)
if (keys.length > 0) selectedContainer.value = keys[0]
}
} catch (err) {
toast.error((err as Error).message)
} finally {
isLoading.value = false
}
}
async function createProfile(name: string, description?: string) {
try {
const res = await api.post<{ profile: LootProfileFull }>('/loot/profiles', {
profile_name: name,
description,
})
await fetchProfiles()
currentProfile.value = res.profile
isDirty.value = false
toast.success(`Profile "${name}" created`)
return res.profile
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function saveCurrentProfile() {
if (!currentProfile.value) return
isSaving.value = true
try {
await api.put(`/loot/profiles/${currentProfile.value.id}`, {
profile_name: currentProfile.value.profile_name,
description: currentProfile.value.description,
loot_table: currentProfile.value.loot_table,
loot_groups: currentProfile.value.loot_groups,
})
isDirty.value = false
await fetchProfiles()
toast.success('Profile saved')
} catch (err) {
toast.error((err as Error).message)
} finally {
isSaving.value = false
}
}
async function deleteProfile(id: string) {
try {
await api.del(`/loot/profiles/${id}`)
if (currentProfile.value?.id === id) {
currentProfile.value = null
selectedContainer.value = null
}
await fetchProfiles()
toast.success('Profile deleted')
} catch (err) {
toast.error((err as Error).message)
}
}
async function duplicateProfile(id: string) {
try {
const res = await api.post<{ profile: LootProfileFull }>(`/loot/profiles/${id}/duplicate`)
await fetchProfiles()
toast.success(`Profile duplicated as "${res.profile.profile_name}"`)
return res.profile
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function applyToServer(id: string, multiplier: number) {
isApplying.value = true
try {
const res = await api.post<LootApplyResult>(`/loot/profiles/${id}/apply`, { multiplier })
await fetchProfiles()
toast.success(res.message)
return res
} catch (err) {
toast.error((err as Error).message)
return null
} finally {
isApplying.value = false
}
}
async function importProfile(name: string, lootTable: Record<string, any>, lootGroups?: Record<string, any>) {
try {
const res = await api.post<{ profile: LootProfileFull }>('/loot/import', {
profile_name: name,
loot_table: lootTable,
loot_groups: lootGroups || {},
})
await fetchProfiles()
toast.success(`Profile "${name}" imported`)
return res.profile
} catch (err) {
toast.error((err as Error).message)
return null
}
}
async function exportProfile(id: string, multiplier: number) {
try {
return await api.get<{ profile_name: string; multiplier: number; loot_table: any; loot_groups: any }>(
`/loot/export/${id}?multiplier=${multiplier}`,
)
} catch (err) {
toast.error((err as Error).message)
return null
}
}
function markDirty() {
isDirty.value = true
}
return {
profiles,
currentProfile,
selectedContainer,
isLoading,
isSaving,
isApplying,
isDirty,
activeProfile,
fetchProfiles,
loadProfile,
createProfile,
saveCurrentProfile,
deleteProfile,
duplicateProfile,
applyToServer,
importProfile,
exportProfile,
markDirty,
}
})