feat: Add loot builder frontend — Pinia store, views, 4 components, router + nav
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
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:
@@ -27,6 +27,7 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Crosshair,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
@@ -44,6 +45,7 @@ const navItems = [
|
|||||||
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
{ name: 'Players', path: '/players', icon: Users, permission: 'players.view' },
|
||||||
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
{ name: 'Plugins', path: '/plugins', icon: Puzzle, permission: 'plugins.view' },
|
||||||
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
|
{ name: 'File Manager', path: '/files', icon: FolderOpen, permission: 'files.view' },
|
||||||
|
{ name: 'Loot Builder', path: '/loot-builder', icon: Crosshair, permission: 'loot.view' },
|
||||||
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
{ name: 'Auto-Wiper', path: '/wipes', icon: RefreshCw, permission: 'wipes.view' },
|
||||||
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
{ name: 'Maps', path: '/maps', icon: Map, permission: 'maps.view' },
|
||||||
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
{ name: 'Chat Log', path: '/chat', icon: MessageSquare, permission: 'chat.view' },
|
||||||
|
|||||||
103
frontend/src/components/loot/LootContainerSidebar.vue
Normal file
103
frontend/src/components/loot/LootContainerSidebar.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { rustContainers, containerCategories } from '@/data/rust-containers'
|
||||||
|
import { Search, Box, Cylinder, Shield, Users, HelpCircle } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lootTable: Record<string, any>
|
||||||
|
selected: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [prefab: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, any> = {
|
||||||
|
crates: Box,
|
||||||
|
barrels: Cylinder,
|
||||||
|
military: Shield,
|
||||||
|
npcs: Users,
|
||||||
|
other: HelpCircle,
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = {
|
||||||
|
crates: 'CRATES',
|
||||||
|
barrels: 'BARRELS',
|
||||||
|
military: 'MILITARY',
|
||||||
|
npcs: 'NPCs',
|
||||||
|
other: 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredContainers = computed(() => {
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
if (!q) return rustContainers
|
||||||
|
return rustContainers.filter(c => c.name.toLowerCase().includes(q) || c.prefab.toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedContainers = computed(() => {
|
||||||
|
const groups: Record<string, typeof rustContainers> = {}
|
||||||
|
for (const cat of containerCategories) {
|
||||||
|
const items = filteredContainers.value.filter(c => c.category === cat)
|
||||||
|
if (items.length > 0) groups[cat] = items
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
function isConfigured(prefab: string): boolean {
|
||||||
|
const entry = props.lootTable[prefab]
|
||||||
|
if (!entry) return false
|
||||||
|
const hasItems = entry.UngroupedItems && Object.keys(entry.UngroupedItems).length > 0
|
||||||
|
const hasGuaranteed = entry.GuaranteedItems && Object.keys(entry.GuaranteedItems).length > 0
|
||||||
|
const hasProfiles = entry.LootProfiles && entry.LootProfiles.length > 0
|
||||||
|
return hasItems || hasGuaranteed || hasProfiles
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-64 shrink-0 bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden flex flex-col">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="p-3 border-b border-neutral-800">
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search containers..."
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200 placeholder-neutral-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Container List -->
|
||||||
|
<div class="flex-1 overflow-y-auto py-2">
|
||||||
|
<template v-for="(containers, category) in groupedContainers" :key="category">
|
||||||
|
<div class="px-3 pt-3 pb-1">
|
||||||
|
<div class="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-widest text-neutral-500">
|
||||||
|
<component :is="categoryIcons[category]" class="w-3 h-3" />
|
||||||
|
{{ categoryLabels[category] || category }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="c in containers"
|
||||||
|
:key="c.prefab"
|
||||||
|
@click="emit('select', c.prefab)"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-sm flex items-center gap-2 transition-colors"
|
||||||
|
:class="selected === c.prefab
|
||||||
|
? 'bg-oxide-500/10 text-oxide-400'
|
||||||
|
: 'text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200'"
|
||||||
|
>
|
||||||
|
<span class="truncate flex-1">{{ c.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="isConfigured(c.prefab)"
|
||||||
|
class="w-1.5 h-1.5 rounded-full bg-oxide-500 shrink-0"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="Object.keys(groupedContainers).length === 0" class="px-3 py-6 text-center text-neutral-500 text-sm">
|
||||||
|
No containers match
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
184
frontend/src/components/loot/LootGroupEditor.vue
Normal file
184
frontend/src/components/loot/LootGroupEditor.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { rustItems } from '@/data/rust-items'
|
||||||
|
import { Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-vue-next'
|
||||||
|
import type { LootGroupProfile } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lootGroups: Record<string, any>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
dirty: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expandedGroup = ref<string | null>(null)
|
||||||
|
const newGroupName = ref('')
|
||||||
|
|
||||||
|
const groupEntries = computed(() => {
|
||||||
|
return Object.entries(props.lootGroups).map(([name, data]) => ({
|
||||||
|
name,
|
||||||
|
data: data as LootGroupProfile,
|
||||||
|
itemCount: data?.ItemList ? Object.keys(data.ItemList).length : 0,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleGroup(name: string) {
|
||||||
|
expandedGroup.value = expandedGroup.value === name ? null : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGroup() {
|
||||||
|
const name = newGroupName.value.trim()
|
||||||
|
if (!name || props.lootGroups[name]) return
|
||||||
|
props.lootGroups[name] = {
|
||||||
|
Enabled: true,
|
||||||
|
GuaranteedItems: {},
|
||||||
|
ItemList: {},
|
||||||
|
}
|
||||||
|
newGroupName.value = ''
|
||||||
|
expandedGroup.value = name
|
||||||
|
emit('dirty')
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGroup(name: string) {
|
||||||
|
if (!confirm(`Delete group "${name}"?`)) return
|
||||||
|
delete props.lootGroups[name]
|
||||||
|
if (expandedGroup.value === name) expandedGroup.value = null
|
||||||
|
emit('dirty')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemName(shortname: string): string {
|
||||||
|
return rustItems.find(i => i.shortname === shortname)?.name || shortname
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItemFromGroup(groupName: string, shortname: string) {
|
||||||
|
delete props.lootGroups[groupName].ItemList[shortname]
|
||||||
|
emit('dirty')
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroupItemField(groupName: string, shortname: string, field: string, value: number) {
|
||||||
|
if (props.lootGroups[groupName]?.ItemList?.[shortname]) {
|
||||||
|
props.lootGroups[groupName].ItemList[shortname][field] = value
|
||||||
|
emit('dirty')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Add Group -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="newGroupName"
|
||||||
|
placeholder="New group name..."
|
||||||
|
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||||
|
@keydown.enter="addGroup"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="addGroup"
|
||||||
|
:disabled="!newGroupName.trim()"
|
||||||
|
class="flex items-center gap-1 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
Add Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group List -->
|
||||||
|
<div v-if="groupEntries.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||||
|
No loot groups defined. Groups let you create reusable item pools that can be assigned to multiple containers.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="entry in groupEntries"
|
||||||
|
:key="entry.name"
|
||||||
|
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Group Header -->
|
||||||
|
<button
|
||||||
|
@click="toggleGroup(entry.name)"
|
||||||
|
class="w-full flex items-center justify-between px-4 py-3 hover:bg-neutral-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<component
|
||||||
|
:is="expandedGroup === entry.name ? ChevronDown : ChevronRight"
|
||||||
|
class="w-4 h-4 text-neutral-500"
|
||||||
|
/>
|
||||||
|
<span class="text-neutral-200 font-medium">{{ entry.name }}</span>
|
||||||
|
<span class="text-xs text-neutral-500">{{ entry.itemCount }} items</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click.stop="deleteGroup(entry.name)"
|
||||||
|
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Group Items -->
|
||||||
|
<div v-if="expandedGroup === entry.name" class="border-t border-neutral-800 p-4">
|
||||||
|
<table v-if="entry.itemCount > 0" class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-neutral-800">
|
||||||
|
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
||||||
|
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
||||||
|
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
||||||
|
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
||||||
|
<th class="w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(itemData, shortname) in entry.data.ItemList"
|
||||||
|
:key="shortname"
|
||||||
|
class="border-b border-neutral-800/50"
|
||||||
|
>
|
||||||
|
<td class="py-2 px-2 text-neutral-200">{{ getItemName(shortname as string) }}</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="(itemData as any).Min ?? 1"
|
||||||
|
@input="updateGroupItemField(entry.name, shortname as string, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="(itemData as any).Max ?? 1"
|
||||||
|
@input="updateGroupItemField(entry.name, shortname as string, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="(itemData as any).Probability ?? 100"
|
||||||
|
@input="updateGroupItemField(entry.name, shortname as string, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<button
|
||||||
|
@click="removeItemFromGroup(entry.name, shortname as string)"
|
||||||
|
class="text-neutral-600 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p v-else class="text-neutral-500 text-sm text-center py-4">
|
||||||
|
No items in this group yet. Add items from the container editor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
232
frontend/src/components/loot/LootItemEditor.vue
Normal file
232
frontend/src/components/loot/LootItemEditor.vue
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { rustItems } from '@/data/rust-items'
|
||||||
|
import { rustContainers } from '@/data/rust-containers'
|
||||||
|
import { Trash2, Plus, Settings2 } from 'lucide-vue-next'
|
||||||
|
import type { PrefabLoot } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
containerKey: string
|
||||||
|
lootTable: Record<string, any>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
dirty: []
|
||||||
|
'add-item': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerName = computed(() => {
|
||||||
|
const c = rustContainers.find(c => c.prefab === props.containerKey)
|
||||||
|
return c?.name || props.containerKey.split('/').pop()?.replace('.prefab', '') || 'Unknown'
|
||||||
|
})
|
||||||
|
|
||||||
|
const containerData = computed<PrefabLoot | null>(() => {
|
||||||
|
return props.lootTable[props.containerKey] || null
|
||||||
|
})
|
||||||
|
|
||||||
|
function ensureContainer() {
|
||||||
|
if (!props.lootTable[props.containerKey]) {
|
||||||
|
props.lootTable[props.containerKey] = {
|
||||||
|
Enabled: true,
|
||||||
|
LootProfiles: [],
|
||||||
|
GuaranteedItems: {},
|
||||||
|
UngroupedItems: {},
|
||||||
|
ItemSettings: { ItemsMin: 1, ItemsMax: 6, MinScrap: 0, MaxScrap: 0 },
|
||||||
|
}
|
||||||
|
emit('dirty')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemName(shortname: string): string {
|
||||||
|
return rustItems.find(i => i.shortname === shortname)?.name || shortname
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItemField(shortname: string, field: string, value: number) {
|
||||||
|
ensureContainer()
|
||||||
|
const items = props.lootTable[props.containerKey].UngroupedItems
|
||||||
|
if (items[shortname]) {
|
||||||
|
items[shortname][field] = value
|
||||||
|
emit('dirty')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSettings(field: string, value: number) {
|
||||||
|
ensureContainer()
|
||||||
|
props.lootTable[props.containerKey].ItemSettings[field] = value
|
||||||
|
emit('dirty')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEnabled() {
|
||||||
|
ensureContainer()
|
||||||
|
props.lootTable[props.containerKey].Enabled = !props.lootTable[props.containerKey].Enabled
|
||||||
|
emit('dirty')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(shortname: string) {
|
||||||
|
if (!containerData.value?.UngroupedItems) return
|
||||||
|
delete props.lootTable[props.containerKey].UngroupedItems[shortname]
|
||||||
|
emit('dirty')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ungroupedItems = computed(() => {
|
||||||
|
if (!containerData.value?.UngroupedItems) return []
|
||||||
|
return Object.entries(containerData.value.UngroupedItems).map(([shortname, data]) => ({
|
||||||
|
shortname,
|
||||||
|
name: getItemName(shortname),
|
||||||
|
...(data as any),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Container Header -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100">{{ containerName }}</h2>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="containerData?.Enabled ?? true"
|
||||||
|
@change="toggleEnabled"
|
||||||
|
class="rounded bg-neutral-800 border-neutral-600 text-oxide-500 focus:ring-oxide-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-neutral-400">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Settings2 class="w-4 h-4 text-neutral-500" />
|
||||||
|
<span class="text-xs text-neutral-500 font-mono">{{ containerKey.split('/').pop() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item Settings -->
|
||||||
|
<div class="grid grid-cols-4 gap-3" v-if="containerData">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Items Min</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="containerData.ItemSettings?.ItemsMin ?? 1"
|
||||||
|
@input="updateSettings('ItemsMin', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Items Max</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="containerData.ItemSettings?.ItemsMax ?? 6"
|
||||||
|
@input="updateSettings('ItemsMax', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Min Scrap</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="containerData.ItemSettings?.MinScrap ?? 0"
|
||||||
|
@input="updateSettings('MinScrap', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-neutral-500 mb-1">Max Scrap</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="containerData.ItemSettings?.MaxScrap ?? 0"
|
||||||
|
@input="updateSettings('MaxScrap', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ungrouped Items Table -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-sm font-semibold text-neutral-300">Ungrouped Items</h3>
|
||||||
|
<button
|
||||||
|
@click="emit('add-item')"
|
||||||
|
class="flex items-center gap-1 px-3 py-1.5 bg-oxide-500/10 text-oxide-400 rounded-lg hover:bg-oxide-500/20 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-3.5 h-3.5" />
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ungroupedItems.length > 0" class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-neutral-800">
|
||||||
|
<th class="text-left py-2 px-2 text-neutral-500 font-medium">Item</th>
|
||||||
|
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Min</th>
|
||||||
|
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-20">Max</th>
|
||||||
|
<th class="text-center py-2 px-2 text-neutral-500 font-medium w-24">Prob %</th>
|
||||||
|
<th class="w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="item in ungroupedItems"
|
||||||
|
:key="item.shortname"
|
||||||
|
class="border-b border-neutral-800/50 hover:bg-neutral-800/30"
|
||||||
|
>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<div>
|
||||||
|
<span class="text-neutral-200">{{ item.name }}</span>
|
||||||
|
<span class="text-neutral-600 text-xs ml-2">{{ item.shortname }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="item.Min"
|
||||||
|
@input="updateItemField(item.shortname, 'Min', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="item.Max"
|
||||||
|
@input="updateItemField(item.shortname, 'Max', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="item.Probability ?? 100"
|
||||||
|
@input="updateItemField(item.shortname, 'Probability', Number(($event.target as HTMLInputElement).value))"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-center text-neutral-200"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<button
|
||||||
|
@click="removeItem(item.shortname)"
|
||||||
|
class="text-neutral-600 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-center py-6 text-neutral-500 text-sm">
|
||||||
|
No items configured for this container.
|
||||||
|
<button @click="emit('add-item')" class="text-oxide-400 hover:underline ml-1">Add one</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
88
frontend/src/components/loot/LootItemPicker.vue
Normal file
88
frontend/src/components/loot/LootItemPicker.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { rustItems, itemCategories } from '@/data/rust-items'
|
||||||
|
import { Search, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [shortname: string]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedCategory = ref<string>('all')
|
||||||
|
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
let items = rustItems
|
||||||
|
if (selectedCategory.value !== 'all') {
|
||||||
|
items = items.filter(i => i.category === selectedCategory.value)
|
||||||
|
}
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(i => i.name.toLowerCase().includes(q) || i.shortname.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="emit('close')">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="p-4 border-b border-neutral-800 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100">Add Item</h2>
|
||||||
|
<button @click="emit('close')" class="text-neutral-500 hover:text-neutral-300">
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search + Filter -->
|
||||||
|
<div class="p-4 space-y-3 border-b border-neutral-800">
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Search items..."
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-9 pr-3 py-2 text-sm text-neutral-200"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
@click="selectedCategory = 'all'"
|
||||||
|
class="px-2 py-1 rounded text-xs"
|
||||||
|
:class="selectedCategory === 'all' ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="cat in itemCategories"
|
||||||
|
:key="cat"
|
||||||
|
@click="selectedCategory = cat"
|
||||||
|
class="px-2 py-1 rounded text-xs capitalize"
|
||||||
|
:class="selectedCategory === cat ? 'bg-oxide-500 text-white' : 'bg-neutral-800 text-neutral-400 hover:text-neutral-200'"
|
||||||
|
>
|
||||||
|
{{ cat }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item Grid -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="item in filteredItems"
|
||||||
|
:key="item.shortname"
|
||||||
|
@click="emit('select', item.shortname)"
|
||||||
|
class="text-left px-3 py-2 bg-neutral-800 rounded-lg hover:bg-neutral-700 transition-colors group"
|
||||||
|
>
|
||||||
|
<div class="text-sm text-neutral-200 group-hover:text-oxide-400">{{ item.name }}</div>
|
||||||
|
<div class="text-xs text-neutral-500">{{ item.shortname }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="filteredItems.length === 0" class="text-center py-8 text-neutral-500">
|
||||||
|
No items found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -110,6 +110,11 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
name: 'files',
|
name: 'files',
|
||||||
component: () => import('@/views/admin/FileManagerView.vue'),
|
component: () => import('@/views/admin/FileManagerView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'loot-builder',
|
||||||
|
name: 'loot-builder',
|
||||||
|
component: () => import('@/views/admin/LootBuilderView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'wipes',
|
path: 'wipes',
|
||||||
name: 'wipes',
|
name: 'wipes',
|
||||||
|
|||||||
179
frontend/src/stores/loot.ts
Normal file
179
frontend/src/stores/loot.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
393
frontend/src/views/admin/LootBuilderView.vue
Normal file
393
frontend/src/views/admin/LootBuilderView.vue
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useLootStore } from '@/stores/loot'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import LootContainerSidebar from '@/components/loot/LootContainerSidebar.vue'
|
||||||
|
import LootItemEditor from '@/components/loot/LootItemEditor.vue'
|
||||||
|
import LootGroupEditor from '@/components/loot/LootGroupEditor.vue'
|
||||||
|
import LootItemPicker from '@/components/loot/LootItemPicker.vue'
|
||||||
|
import { Save, Upload, Download, Play, Copy, Trash2, Plus, Layers } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const loot = useLootStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const showCreateModal = ref(false)
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const showItemPicker = ref(false)
|
||||||
|
const newProfileName = ref('')
|
||||||
|
const newProfileDesc = ref('')
|
||||||
|
const selectedMultiplier = ref(1)
|
||||||
|
const showApplyDropdown = ref(false)
|
||||||
|
const importJson = ref('')
|
||||||
|
const importName = ref('')
|
||||||
|
const activeTab = ref<'items' | 'groups'>('items')
|
||||||
|
|
||||||
|
const multipliers = [1, 2, 5, 10]
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loot.fetchProfiles()
|
||||||
|
if (loot.profiles.length > 0) {
|
||||||
|
await loot.loadProfile(loot.profiles[0].id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleCreateProfile() {
|
||||||
|
if (!newProfileName.value.trim()) return
|
||||||
|
const profile = await loot.createProfile(newProfileName.value.trim(), newProfileDesc.value.trim() || undefined)
|
||||||
|
if (profile) {
|
||||||
|
showCreateModal.value = false
|
||||||
|
newProfileName.value = ''
|
||||||
|
newProfileDesc.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteProfile() {
|
||||||
|
if (!loot.currentProfile) return
|
||||||
|
if (!confirm(`Delete "${loot.currentProfile.profile_name}"?`)) return
|
||||||
|
await loot.deleteProfile(loot.currentProfile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDuplicate() {
|
||||||
|
if (!loot.currentProfile) return
|
||||||
|
const dup = await loot.duplicateProfile(loot.currentProfile.id)
|
||||||
|
if (dup) await loot.loadProfile(dup.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApply(mult: number) {
|
||||||
|
if (!loot.currentProfile) return
|
||||||
|
showApplyDropdown.value = false
|
||||||
|
if (loot.isDirty) {
|
||||||
|
await loot.saveCurrentProfile()
|
||||||
|
}
|
||||||
|
await loot.applyToServer(loot.currentProfile.id, mult)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!importName.value.trim() || !importJson.value.trim()) return
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(importJson.value)
|
||||||
|
// Support both full export format and raw LootTables format
|
||||||
|
const lootTable = parsed.loot_table || parsed
|
||||||
|
const lootGroups = parsed.loot_groups || {}
|
||||||
|
await loot.importProfile(importName.value.trim(), lootTable, lootGroups)
|
||||||
|
showImportModal.value = false
|
||||||
|
importJson.value = ''
|
||||||
|
importName.value = ''
|
||||||
|
} catch {
|
||||||
|
toast.error('Invalid JSON')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
if (!loot.currentProfile) return
|
||||||
|
const data = await loot.exportProfile(loot.currentProfile.id, selectedMultiplier.value)
|
||||||
|
if (!data) return
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${data.profile_name}_${data.multiplier}x.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProfileChange(id: string) {
|
||||||
|
if (loot.isDirty) {
|
||||||
|
if (!confirm('Unsaved changes will be lost. Continue?')) return
|
||||||
|
}
|
||||||
|
await loot.loadProfile(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddItem(shortname: string) {
|
||||||
|
if (!loot.currentProfile || !loot.selectedContainer) return
|
||||||
|
const table = loot.currentProfile.loot_table
|
||||||
|
if (!table[loot.selectedContainer]) {
|
||||||
|
table[loot.selectedContainer] = {
|
||||||
|
Enabled: true,
|
||||||
|
LootProfiles: [],
|
||||||
|
GuaranteedItems: {},
|
||||||
|
UngroupedItems: {},
|
||||||
|
ItemSettings: { ItemsMin: 1, ItemsMax: 6, MinScrap: 0, MaxScrap: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const container = table[loot.selectedContainer]
|
||||||
|
if (!container.UngroupedItems) container.UngroupedItems = {}
|
||||||
|
if (!container.UngroupedItems[shortname]) {
|
||||||
|
container.UngroupedItems[shortname] = {
|
||||||
|
Min: 1,
|
||||||
|
Max: 1,
|
||||||
|
SkinId: 0,
|
||||||
|
DisplayName: '',
|
||||||
|
Probability: 50,
|
||||||
|
DurabilitySettings: { MinDurability: 1, MaxDurability: 1 },
|
||||||
|
ItemEntryModifications: { AmmoSettings: null, AttachmentSettings: null },
|
||||||
|
BonusItems: {},
|
||||||
|
}
|
||||||
|
loot.markDirty()
|
||||||
|
}
|
||||||
|
showItemPicker.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-neutral-100">Loot Builder</h1>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Plus class="w-4 h-4" />
|
||||||
|
New Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Bar -->
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-4">
|
||||||
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
|
<!-- Profile Selector -->
|
||||||
|
<select
|
||||||
|
v-if="loot.profiles.length > 0"
|
||||||
|
:value="loot.currentProfile?.id || ''"
|
||||||
|
@change="handleProfileChange(($event.target as HTMLSelectElement).value)"
|
||||||
|
class="bg-neutral-800 border border-neutral-700 text-neutral-200 rounded-lg px-3 py-2 text-sm min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option v-for="p in loot.profiles" :key="p.id" :value="p.id">
|
||||||
|
{{ p.profile_name }}
|
||||||
|
<template v-if="p.is_active"> (Active)</template>
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span v-else class="text-neutral-500 text-sm">No profiles yet</span>
|
||||||
|
|
||||||
|
<!-- Save -->
|
||||||
|
<button
|
||||||
|
@click="loot.saveCurrentProfile()"
|
||||||
|
:disabled="!loot.currentProfile || !loot.isDirty || loot.isSaving"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
{{ loot.isSaving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Apply Dropdown -->
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
@click="showApplyDropdown = !showApplyDropdown"
|
||||||
|
:disabled="!loot.currentProfile || loot.isApplying"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||||
|
>
|
||||||
|
<Play class="w-4 h-4" />
|
||||||
|
{{ loot.isApplying ? 'Applying...' : 'Apply to Server' }}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="showApplyDropdown"
|
||||||
|
class="absolute top-full mt-1 right-0 bg-neutral-800 border border-neutral-700 rounded-lg shadow-xl z-10 py-1 min-w-[140px]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="m in multipliers"
|
||||||
|
:key="m"
|
||||||
|
@click="handleApply(m)"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-neutral-300 hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
{{ m }}x Multiplier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duplicate -->
|
||||||
|
<button
|
||||||
|
@click="handleDuplicate"
|
||||||
|
:disabled="!loot.currentProfile"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
<Copy class="w-4 h-4" />
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Import -->
|
||||||
|
<button
|
||||||
|
@click="showImportModal = true"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<Upload class="w-4 h-4" />
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Export -->
|
||||||
|
<button
|
||||||
|
@click="handleExport"
|
||||||
|
:disabled="!loot.currentProfile"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 text-neutral-300 rounded-lg hover:bg-neutral-700 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
<Download class="w-4 h-4" />
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button
|
||||||
|
@click="handleDeleteProfile"
|
||||||
|
:disabled="!loot.currentProfile"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 disabled:opacity-50 text-sm ml-auto"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div v-if="loot.currentProfile" class="flex gap-4" style="height: calc(100vh - 250px)">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<LootContainerSidebar
|
||||||
|
:loot-table="loot.currentProfile.loot_table"
|
||||||
|
:selected="loot.selectedContainer"
|
||||||
|
@select="loot.selectedContainer = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Editor Area -->
|
||||||
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="flex border-b border-neutral-800 mb-4">
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'items'"
|
||||||
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors"
|
||||||
|
:class="activeTab === 'items' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||||
|
>
|
||||||
|
Container Items
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="activeTab = 'groups'"
|
||||||
|
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors flex items-center gap-2"
|
||||||
|
:class="activeTab === 'groups' ? 'border-oxide-500 text-oxide-400' : 'border-transparent text-neutral-500 hover:text-neutral-300'"
|
||||||
|
>
|
||||||
|
<Layers class="w-4 h-4" />
|
||||||
|
Loot Groups
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
<LootItemEditor
|
||||||
|
v-if="activeTab === 'items' && loot.selectedContainer"
|
||||||
|
:container-key="loot.selectedContainer"
|
||||||
|
:loot-table="loot.currentProfile.loot_table"
|
||||||
|
@dirty="loot.markDirty()"
|
||||||
|
@add-item="showItemPicker = true"
|
||||||
|
/>
|
||||||
|
<div v-else-if="activeTab === 'items'" class="flex items-center justify-center h-full text-neutral-500">
|
||||||
|
Select a container from the sidebar
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LootGroupEditor
|
||||||
|
v-if="activeTab === 'groups'"
|
||||||
|
:loot-groups="loot.currentProfile.loot_groups"
|
||||||
|
@dirty="loot.markDirty()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="!loot.isLoading" class="bg-neutral-900 border border-neutral-800 rounded-xl p-12 text-center">
|
||||||
|
<Layers class="w-12 h-12 text-neutral-600 mx-auto mb-4" />
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-300 mb-2">No Loot Profile Selected</h2>
|
||||||
|
<p class="text-neutral-500 mb-4">Create a new profile or select one from the dropdown above.</p>
|
||||||
|
<button
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600"
|
||||||
|
>
|
||||||
|
Create First Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loot.isLoading" class="flex items-center justify-center py-20">
|
||||||
|
<div class="animate-spin w-8 h-8 border-2 border-oxide-500 border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Modal -->
|
||||||
|
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showCreateModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">New Loot Profile</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newProfileName"
|
||||||
|
placeholder="e.g. Vanilla 2x"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
@keydown.enter="handleCreateProfile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="newProfileDesc"
|
||||||
|
rows="2"
|
||||||
|
placeholder="What is this profile for?"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showCreateModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleCreateProfile"
|
||||||
|
:disabled="!newProfileName.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Modal -->
|
||||||
|
<div v-if="showImportModal" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="showImportModal = false">
|
||||||
|
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-lg">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-100 mb-4">Import Loot Profile</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">Profile Name</label>
|
||||||
|
<input
|
||||||
|
v-model="importName"
|
||||||
|
placeholder="Name for imported profile"
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-neutral-400 mb-1">BetterLoot JSON</label>
|
||||||
|
<textarea
|
||||||
|
v-model="importJson"
|
||||||
|
rows="10"
|
||||||
|
placeholder="Paste LootTables.json content here..."
|
||||||
|
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-neutral-200 text-sm font-mono resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button @click="showImportModal = false" class="px-4 py-2 text-sm text-neutral-400 hover:text-neutral-200">Cancel</button>
|
||||||
|
<button
|
||||||
|
@click="handleImport"
|
||||||
|
:disabled="!importName.trim() || !importJson.trim()"
|
||||||
|
class="px-4 py-2 bg-oxide-500 text-white rounded-lg hover:bg-oxide-600 disabled:opacity-50 text-sm"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Item Picker Modal -->
|
||||||
|
<LootItemPicker
|
||||||
|
v-if="showItemPicker"
|
||||||
|
@select="handleAddItem"
|
||||||
|
@close="showItemPicker = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Click-away for apply dropdown -->
|
||||||
|
<div v-if="showApplyDropdown" class="fixed inset-0 z-0" @click="showApplyDropdown = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user