feat(redesign): re-skin plugin-config editors + Loot Builder to DS (Phase D batch 3)
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
10 plugin-config views (LootBuilder, RaidableBases, Teleport, Kits, Gather, AutoDoors, FurnaceSplitter, BetterChat, TimedExecute, PluginConfigs landing) + 5 child components (loot sidebar/item-editor/group-editor/item-picker, teleport PermissionGroupEditor) re-skinned onto DS components + tokens. All config logic preserved (path-traversal get/set, apply-to-server, import-from-server, CRUD, multiplier logic, per-store status derivation). Presentation-only. Build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<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'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import DsInput from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
lootTable: Record<string, any>
|
||||
@@ -14,20 +15,21 @@ const emit = defineEmits<{
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const categoryIcons: Record<string, any> = {
|
||||
crates: Box,
|
||||
barrels: Cylinder,
|
||||
military: Shield,
|
||||
npcs: Users,
|
||||
other: HelpCircle,
|
||||
// Map container categories to DS icon names
|
||||
const categoryIcons: Record<string, string> = {
|
||||
crates: 'box',
|
||||
barrels: 'flask-conical',
|
||||
military: 'shield',
|
||||
npcs: 'users',
|
||||
other: 'info',
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
crates: 'CRATES',
|
||||
barrels: 'BARRELS',
|
||||
military: 'MILITARY',
|
||||
crates: 'Crates',
|
||||
barrels: 'Barrels',
|
||||
military: 'Military',
|
||||
npcs: 'NPCs',
|
||||
other: 'OTHER',
|
||||
other: 'Other',
|
||||
}
|
||||
|
||||
const filteredContainers = computed(() => {
|
||||
@@ -56,48 +58,136 @@ function isConfigured(prefab: string): boolean {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-64 shrink-0 bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden flex flex-col">
|
||||
<aside class="lcs-root">
|
||||
<!-- 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 class="lcs-search">
|
||||
<DsInput
|
||||
v-model="searchQuery"
|
||||
icon="search"
|
||||
placeholder="Search containers…"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Container List -->
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<!-- Container list -->
|
||||
<div class="lcs-list">
|
||||
<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>
|
||||
<!-- Category heading -->
|
||||
<div class="lcs-cat">
|
||||
<Icon
|
||||
:name="categoryIcons[category] ?? 'box'"
|
||||
:size="12"
|
||||
class="lcs-cat__icon"
|
||||
/>
|
||||
<span class="lcs-cat__label">{{ categoryLabels[category] ?? category }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Container rows -->
|
||||
<button
|
||||
v-for="c in containers"
|
||||
:key="c.prefab"
|
||||
class="lcs-item"
|
||||
:class="{ 'lcs-item--active': selected === 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"
|
||||
/>
|
||||
<span class="lcs-item__name">{{ c.name }}</span>
|
||||
<span v-if="isConfigured(c.prefab)" class="lcs-item__dot" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="Object.keys(groupedContainers).length === 0" class="px-3 py-6 text-center text-neutral-500 text-sm">
|
||||
<div v-if="Object.keys(groupedContainers).length === 0" class="lcs-empty">
|
||||
No containers match
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lcs-root {
|
||||
width: 240px;
|
||||
flex: none;
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lcs-search {
|
||||
padding: 10px 10px 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.lcs-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* Category heading */
|
||||
.lcs-cat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
.lcs-cat__icon {
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
.lcs-cat__label {
|
||||
font-size: var(--text-2xs, 10px);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Container row */
|
||||
.lcs-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
transition: var(--transition-colors);
|
||||
border-radius: 0;
|
||||
}
|
||||
.lcs-item:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lcs-item--active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.lcs-item__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lcs-item__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.lcs-empty {
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<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 Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import DsInput from '@/components/ds/forms/Input.vue'
|
||||
import type { LootGroupProfile } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -65,120 +71,260 @@ function updateGroupItemField(groupName: string, shortname: string, field: strin
|
||||
</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
|
||||
<div class="lge-root">
|
||||
<!-- Add group panel -->
|
||||
<Panel>
|
||||
<div class="lge-add">
|
||||
<DsInput
|
||||
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"
|
||||
placeholder="New group name…"
|
||||
@keydown.enter="addGroup"
|
||||
/>
|
||||
<button
|
||||
@click="addGroup"
|
||||
<Button
|
||||
icon="plus"
|
||||
: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"
|
||||
@click="addGroup"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Group
|
||||
</button>
|
||||
Add group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Empty state -->
|
||||
<Panel v-if="groupEntries.length === 0">
|
||||
<EmptyState
|
||||
icon="layers"
|
||||
title="No loot groups"
|
||||
description="Groups let you create reusable item pools that can be assigned to multiple containers."
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<!-- Group cards -->
|
||||
<div
|
||||
v-for="entry in groupEntries"
|
||||
:key="entry.name"
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden"
|
||||
class="lge-card"
|
||||
>
|
||||
<!-- Group Header -->
|
||||
<!-- Group header -->
|
||||
<button
|
||||
class="lge-card__head"
|
||||
@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
|
||||
<Icon
|
||||
:name="expandedGroup === entry.name ? 'chevron-down' : 'chevron-right'"
|
||||
:size="16"
|
||||
class="lge-card__chevron"
|
||||
/>
|
||||
<span class="lge-card__name">{{ entry.name }}</span>
|
||||
<Badge tone="neutral" mono>{{ entry.itemCount }}</Badge>
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Delete group"
|
||||
class="lge-card__del"
|
||||
@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">
|
||||
<!-- Expanded items -->
|
||||
<div v-if="expandedGroup === entry.name" class="lge-card__body">
|
||||
<table v-if="entry.itemCount > 0" class="lge-table">
|
||||
<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>
|
||||
<th class="lge-th">Item</th>
|
||||
<th class="lge-th lge-th--num">Min</th>
|
||||
<th class="lge-th lge-th--num">Max</th>
|
||||
<th class="lge-th lge-th--num">Prob %</th>
|
||||
<th class="lge-th lge-th--action"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(itemData, shortname) in entry.data.ItemList"
|
||||
:key="shortname"
|
||||
class="border-b border-neutral-800/50"
|
||||
class="lge-tr"
|
||||
>
|
||||
<td class="py-2 px-2 text-neutral-200">{{ getItemName(shortname as string) }}</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lge-td">{{ getItemName(shortname as string) }}</td>
|
||||
<td class="lge-td lge-td--num">
|
||||
<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"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lge-td lge-td--num">
|
||||
<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"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lge-td lge-td--num">
|
||||
<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"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<button
|
||||
<td class="lge-td lge-td--action">
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Remove item"
|
||||
@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">
|
||||
<p v-else class="lge-card__empty">
|
||||
No items in this group yet. Add items from the container editor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lge-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Add group row */
|
||||
.lge-add {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.lge-add > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Group card */
|
||||
.lge-card {
|
||||
background: var(--surface-base);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--ring-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
.lge-card__head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
text-align: left;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.lge-card__head:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.lge-card__chevron {
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
.lge-card__name {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lge-card__del {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Expanded body */
|
||||
.lge-card__body {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 12px;
|
||||
}
|
||||
.lge-card__empty {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
padding: 16px 0 8px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.lge-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.lge-th {
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lge-th--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lge-th--action {
|
||||
width: 40px;
|
||||
}
|
||||
.lge-tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.lge-tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.lge-tr:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.lge-td {
|
||||
padding: 7px 10px;
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.lge-td--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lge-td--action {
|
||||
width: 40px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
/* Shared number input (same as LootItemEditor) */
|
||||
.cc-num-input {
|
||||
width: 100%;
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 5px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-num-input:focus {
|
||||
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||
}
|
||||
.cc-num-input--center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
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 Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import Switch from '@/components/ds/forms/Switch.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import type { PrefabLoot } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -76,157 +81,273 @@ const ungroupedItems = computed(() => {
|
||||
...(data as any),
|
||||
}))
|
||||
})
|
||||
|
||||
// Computed boolean for the Switch v-model
|
||||
const isEnabled = computed({
|
||||
get: () => containerData.value?.Enabled ?? true,
|
||||
set: () => toggleEnabled(),
|
||||
})
|
||||
</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>
|
||||
<div class="lie-root">
|
||||
<!-- Container settings panel -->
|
||||
<Panel :title="containerName">
|
||||
<template #actions>
|
||||
<Badge tone="neutral" mono class="lie-prefab">
|
||||
{{ containerKey.split('/').pop() }}
|
||||
</Badge>
|
||||
<Switch v-model="isEnabled" label="Enabled" size="sm" />
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Item settings grid -->
|
||||
<div v-if="containerData" class="lie-settings">
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">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"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Items Max</label>
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">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"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Min Scrap</label>
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">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"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-neutral-500 mb-1">Max Scrap</label>
|
||||
<div class="lie-setting">
|
||||
<label class="lie-setting__label">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"
|
||||
class="cc-num-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="lie-unconfigured">
|
||||
Container not yet configured. Add an item to initialise its settings.
|
||||
</p>
|
||||
</Panel>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Ungrouped items panel -->
|
||||
<Panel title="Ungrouped items" :flush-body="ungroupedItems.length > 0">
|
||||
<template #actions>
|
||||
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||
Add item
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div v-if="ungroupedItems.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<div v-if="ungroupedItems.length > 0" class="lie-table-wrap">
|
||||
<table class="lie-table">
|
||||
<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>
|
||||
<th class="lie-th">Item</th>
|
||||
<th class="lie-th lie-th--num">Min</th>
|
||||
<th class="lie-th lie-th--num">Max</th>
|
||||
<th class="lie-th lie-th--num">Prob %</th>
|
||||
<th class="lie-th lie-th--action"></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"
|
||||
class="lie-tr"
|
||||
>
|
||||
<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 class="lie-td">
|
||||
<span class="lie-item-name">{{ item.name }}</span>
|
||||
<span class="lie-item-short">{{ item.shortname }}</span>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lie-td lie-td--num">
|
||||
<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"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lie-td lie-td--num">
|
||||
<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"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<td class="lie-td lie-td--num">
|
||||
<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"
|
||||
class="cc-num-input cc-num-input--center"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<button
|
||||
<td class="lie-td lie-td--action">
|
||||
<IconButton
|
||||
icon="trash-2"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
label="Remove item"
|
||||
@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>
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="package"
|
||||
title="No items configured"
|
||||
description="Add items to configure what this container can spawn."
|
||||
>
|
||||
<template #action>
|
||||
<Button size="sm" variant="outline" icon="plus" @click="emit('add-item')">
|
||||
Add item
|
||||
</Button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</Panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lie-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Badge for prefab key */
|
||||
.lie-prefab {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.lie-unconfigured {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Settings grid */
|
||||
.lie-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.lie-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.lie-setting__label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.lie-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.lie-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.lie-th {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lie-th--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lie-th--action {
|
||||
width: 40px;
|
||||
}
|
||||
.lie-tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.lie-tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.lie-tr:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.lie-td {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.lie-td--num {
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
.lie-td--action {
|
||||
width: 40px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.lie-item-name {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lie-item-short {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Shared number input */
|
||||
.cc-num-input {
|
||||
width: 100%;
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 5px 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-num-input:focus {
|
||||
box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm);
|
||||
}
|
||||
.cc-num-input--center {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { rustItems, itemCategories } from '@/data/rust-items'
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import DsInput from '@/components/ds/forms/Input.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [shortname: string]
|
||||
@@ -25,64 +27,200 @@ const filteredItems = computed(() => {
|
||||
</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>
|
||||
<Teleport to="body">
|
||||
<div class="lip-overlay" @click.self="emit('close')">
|
||||
<div class="lip-modal">
|
||||
<!-- Header -->
|
||||
<div class="lip-head">
|
||||
<span class="lip-head__title">Add item</span>
|
||||
<IconButton icon="x" size="sm" label="Close" @click="emit('close')" />
|
||||
</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
|
||||
<!-- Search + category filter -->
|
||||
<div class="lip-filters">
|
||||
<DsInput
|
||||
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
|
||||
icon="search"
|
||||
placeholder="Search items…"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="lip-cats">
|
||||
<button
|
||||
class="lip-cat"
|
||||
:class="{ 'lip-cat--active': selectedCategory === 'all' }"
|
||||
@click="selectedCategory = 'all'"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in itemCategories"
|
||||
:key="cat"
|
||||
class="lip-cat"
|
||||
:class="{ 'lip-cat--active': selectedCategory === cat }"
|
||||
@click="selectedCategory = cat"
|
||||
>
|
||||
{{ cat }}
|
||||
</button>
|
||||
</div>
|
||||
</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
|
||||
<!-- Item grid -->
|
||||
<div class="lip-grid-wrap">
|
||||
<div v-if="filteredItems.length > 0" class="lip-grid">
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.shortname"
|
||||
class="lip-item"
|
||||
@click="emit('select', item.shortname)"
|
||||
>
|
||||
<span class="lip-item__name">{{ item.name }}</span>
|
||||
<span class="lip-item__short">{{ item.shortname }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="lip-empty">
|
||||
<Icon name="search" :size="20" class="lip-empty__icon" />
|
||||
<span>No items found</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lip-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.lip-modal {
|
||||
background: var(--surface-raised);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.lip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex: none;
|
||||
}
|
||||
.lip-head__title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.lip-filters {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
.lip-cats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.lip-cat {
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 3px 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.lip-cat:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.lip-cat--active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
box-shadow: none;
|
||||
}
|
||||
.lip-cat--active:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.lip-grid-wrap {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.lip-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.lip-item {
|
||||
background: var(--surface-inset);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default);
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.lip-item:hover {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.lip-item__name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.lip-item:hover .lip-item__name {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.lip-item__short {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Empty */
|
||||
.lip-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 48px 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.lip-empty__icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Plus, Trash2 } from 'lucide-vue-next'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Icon from '@/components/ds/core/Icon.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
configData: Record<string, any>
|
||||
@@ -47,7 +49,6 @@ function ensurePaths(data: Record<string, any>) {
|
||||
function addGroup() {
|
||||
const name = newGroupName.value.trim()
|
||||
if (!name) return
|
||||
// Check if already exists
|
||||
if (groups.value.some(g => g.name === name)) return
|
||||
|
||||
const updated = { ...props.configData }
|
||||
@@ -95,96 +96,95 @@ function updateField(groupName: string, field: string, value: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-neutral-300 uppercase tracking-wider">VIP Permission Groups</h3>
|
||||
<div class="pge">
|
||||
<div class="pge__head">
|
||||
<div class="pge__section-label">VIP permission groups</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Group -->
|
||||
<div class="flex gap-2">
|
||||
<!-- Add group row -->
|
||||
<div class="pge__add-row">
|
||||
<input
|
||||
v-model="newGroupName"
|
||||
placeholder="New group name (e.g. vip, vip+, mvp)..."
|
||||
class="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-200"
|
||||
type="text"
|
||||
class="pge__name-input"
|
||||
placeholder="New group name (e.g. vip, vip+, mvp)…"
|
||||
@keydown.enter="addGroup"
|
||||
/>
|
||||
<button
|
||||
@click="addGroup"
|
||||
<Button
|
||||
size="sm"
|
||||
icon="plus"
|
||||
: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>
|
||||
@click="addGroup"
|
||||
>Add group</Button>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="groups.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-xl p-8 text-center text-neutral-500 text-sm">
|
||||
No VIP groups defined. Add groups to configure per-permission teleport limits, cooldowns, and countdowns.
|
||||
</div>
|
||||
<!-- Empty state -->
|
||||
<EmptyState
|
||||
v-if="groups.length === 0"
|
||||
icon="users"
|
||||
title="No VIP groups defined"
|
||||
description="Add groups to configure per-permission teleport limits, cooldowns, and countdowns."
|
||||
/>
|
||||
|
||||
<!-- Groups Table -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-xl overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<!-- Groups table -->
|
||||
<div v-else class="pge__table-wrap">
|
||||
<table class="pge__table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800">
|
||||
<th class="text-left py-3 px-4 text-neutral-500 font-medium">Group Name</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Homes Limit</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Cooldown (s)</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Countdown (s)</th>
|
||||
<th class="text-center py-3 px-4 text-neutral-500 font-medium w-28">Daily Limit</th>
|
||||
<th class="w-12"></th>
|
||||
<tr>
|
||||
<th class="pge__th pge__th--left">Group name</th>
|
||||
<th class="pge__th">Homes limit</th>
|
||||
<th class="pge__th">Cooldown (s)</th>
|
||||
<th class="pge__th">Countdown (s)</th>
|
||||
<th class="pge__th">Daily limit</th>
|
||||
<th class="pge__th pge__th--action" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="group in groups"
|
||||
:key="group.name"
|
||||
class="border-b border-neutral-800/50"
|
||||
class="pge__tr"
|
||||
>
|
||||
<td class="py-3 px-4 text-neutral-200 font-medium">{{ group.name }}</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--name">{{ group.name }}</td>
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.homesLimit"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'homesLimit', 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-3 px-4">
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.cooldown"
|
||||
min="0"
|
||||
@input="updateField(group.name, 'cooldown', 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-3 px-4">
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.countdown"
|
||||
@input="updateField(group.name, 'countdown', 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"
|
||||
@input="updateField(group.name, 'countdown', Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<td class="pge__td pge__td--num">
|
||||
<input
|
||||
type="number"
|
||||
class="pge__num-input"
|
||||
:value="group.dailyLimit"
|
||||
@input="updateField(group.name, 'dailyLimit', 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"
|
||||
@input="updateField(group.name, 'dailyLimit', Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<button
|
||||
@click="removeGroup(group.name)"
|
||||
class="text-neutral-600 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
<td class="pge__td pge__td--action">
|
||||
<button class="pge__del" type="button" @click="removeGroup(group.name)">
|
||||
<Icon name="trash-2" :size="15" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -193,3 +193,63 @@ function updateField(groupName: string, field: string, value: number) {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ---------- Shell ---------- */
|
||||
.pge { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
/* ---------- Head ---------- */
|
||||
.pge__head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.pge__section-label {
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
|
||||
/* ---------- Add row ---------- */
|
||||
.pge__add-row { display: flex; gap: 8px; align-items: center; }
|
||||
.pge__name-input {
|
||||
flex: 1; height: var(--control-h-sm); padding: 0 10px;
|
||||
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default); font-family: var(--font-sans);
|
||||
font-size: var(--text-sm); color: var(--text-primary);
|
||||
}
|
||||
.pge__name-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
.pge__name-input::placeholder { color: var(--text-muted); }
|
||||
|
||||
/* ---------- Table ---------- */
|
||||
.pge__table-wrap { border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--ring-default); }
|
||||
.pge__table { width: 100%; border-collapse: collapse; }
|
||||
.pge__th {
|
||||
padding: 9px 12px; font-size: var(--text-xs); font-weight: 600;
|
||||
color: var(--text-tertiary); text-align: center;
|
||||
background: var(--surface-raised); border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.pge__th--left { text-align: left; }
|
||||
.pge__th--action { width: 44px; }
|
||||
.pge__tr { border-bottom: 1px solid var(--border-subtle); }
|
||||
.pge__tr:last-child { border-bottom: 0; }
|
||||
.pge__tr:hover { background: var(--surface-hover); }
|
||||
.pge__td { padding: 8px 12px; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.pge__td--name { font-weight: 500; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.pge__td--num { text-align: center; }
|
||||
.pge__td--action { text-align: center; }
|
||||
|
||||
/* ---------- Number input (table cell) ---------- */
|
||||
.pge__num-input {
|
||||
width: 80px; height: 28px; padding: 0 8px; text-align: center;
|
||||
background: var(--surface-inset); border: 0; border-radius: var(--radius-sm);
|
||||
box-shadow: var(--ring-default); font-family: var(--font-mono);
|
||||
font-size: var(--text-sm); color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pge__num-input:focus { outline: none; box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
|
||||
/* ---------- Delete button ---------- */
|
||||
.pge__del {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; border-radius: var(--radius-sm); border: none;
|
||||
background: transparent; color: var(--text-muted); cursor: pointer;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.pge__del:hover { color: var(--danger); background: var(--status-offline-soft); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user