feat(redesign): re-skin server-ops/operations/store/analytics views to DS (Phase D batch 2)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

18 admin views re-skinned onto design-system components + tokens: Server/Players/Plugins/ChatLog, Wipes/WipeProfiles/Maps/Schedules/Alerts, StoreConfig/StoreItems/ModuleStore, Analytics/WipeAnalytics/MapAnalytics/PlayerRetention/StoreRevenue. ECharts now read var(--accent) (token-driven, follows game skin). 14 icons added to the registry. All logic/store/router/handlers/API calls preserved; presentation-only re-skin. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-06-11 02:34:46 -04:00
parent 560d023250
commit b42a2d7ea7
18 changed files with 4826 additions and 3108 deletions

View File

@@ -2,8 +2,16 @@
import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import type { StoreCategory, StoreItem } from '@/types'
import { ShoppingBag, Plus, Trash2, RefreshCw, Edit2, DollarSign, X } from 'lucide-vue-next'
import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import IconButton from '@/components/ds/core/IconButton.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Input from '@/components/ds/forms/Input.vue'
import Select from '@/components/ds/forms/Select.vue'
import Checkbox from '@/components/ds/forms/Checkbox.vue'
const api = useApi()
@@ -42,17 +50,23 @@ const itemTypes = [
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
{ value: 'command', label: 'Custom Command', example: 'yourplugin.givereward {steam_id}' }
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' }
]
function typeBadgeClass(type: string): string {
switch (type) {
case 'kit': return 'bg-blue-500/15 text-blue-400'
case 'rank': return 'bg-purple-500/15 text-purple-400'
case 'currency': return 'bg-yellow-500/15 text-yellow-400'
case 'command': return 'bg-oxide-500/15 text-oxide-400'
default: return 'bg-neutral-700/50 text-neutral-400'
const tabItems = computed(() => [
{ value: 'categories', label: 'Categories', count: categories.value.length },
{ value: 'items', label: 'Items', count: items.value.length },
])
type ItemTypeTone = 'info' | 'accent' | 'warn' | 'neutral'
function typeTone(type: string): ItemTypeTone {
const map: Record<string, ItemTypeTone> = {
kit: 'info',
rank: 'accent',
currency: 'warn',
command: 'accent',
}
return map[type] ?? 'neutral'
}
function autoGenerateSlug(name: string): string {
@@ -188,7 +202,6 @@ function removeCommand(index: number) {
}
async function saveItem() {
// Validate
if (!itemForm.value.name.trim()) {
alert('Item name is required')
return
@@ -234,12 +247,22 @@ async function deleteItem(item: StoreItem) {
function getCategoryName(categoryId: string | null): string {
if (!categoryId) return 'Uncategorized'
const cat = categories.value.find(c => c.id === categoryId)
return cat?.name || 'Unknown'
return cat?.name ?? 'Unknown'
}
const selectedTypeExample = computed(() => {
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
return type?.example || ''
return type?.example ?? ''
})
const categorySelectOptions = computed(() => [
{ value: '', label: 'Uncategorized' },
...categories.value.map(c => ({ value: c.id, label: c.name }))
])
const categorySelectValue = computed({
get: () => itemForm.value.category_id ?? '',
set: (v: string) => { itemForm.value.category_id = v || null }
})
onMounted(() => {
@@ -249,447 +272,408 @@ onMounted(() => {
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<ShoppingBag class="w-5 h-5 text-oxide-500" />
<div>
<h1 class="text-2xl font-bold text-neutral-100">Store Items</h1>
<p class="text-sm text-neutral-500 mt-0.5">
{{ categories.length }} categories, {{ items.length }} items
</p>
</div>
<div class="si-page">
<!-- Page head -->
<div class="page__head">
<div>
<div class="t-eyebrow">Management · Store</div>
<h1 class="page__title">Store items</h1>
<p class="page__sub">{{ categories.length }} categories · {{ items.length }} items</p>
</div>
<div class="flex items-center gap-3">
<button
@click="tab === 'categories' ? fetchCategories() : fetchItems()"
:disabled="isLoading"
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
</button>
<button
v-if="tab === 'categories'"
@click="openCategoryModal()"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Plus class="w-4 h-4" />
Add Category
</button>
<button
v-else
@click="openItemModal()"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Plus class="w-4 h-4" />
Add Item
</button>
<div class="page__actions">
<IconButton icon="refresh-cw" label="Refresh" :class="{ 'si-spin': isLoading }" @click="tab === 'categories' ? fetchCategories() : fetchItems()" />
<Button v-if="tab === 'categories'" icon="plus" @click="openCategoryModal()">Add category</Button>
<Button v-else icon="plus" @click="openItemModal()">Add item</Button>
</div>
</div>
<!-- Tabs -->
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden w-fit">
<button
@click="tab = 'categories'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'categories' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Categories
</button>
<button
@click="tab = 'items'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'items' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Items
</button>
</div>
<!-- Tab bar + content panel -->
<Panel :flush-body="true">
<template #actions>
<Tabs v-model="tab" :items="tabItems" />
</template>
<!-- Categories Tab -->
<div v-if="tab === 'categories'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<table class="w-full">
<!-- Categories table -->
<table v-if="tab === 'categories'" class="si-table">
<thead>
<tr class="border-b border-neutral-800 text-left">
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Name</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Slug</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Display Order</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Visible</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
<tr>
<th>Name</th>
<th>Slug</th>
<th class="si-col-num">Order</th>
<th>Visibility</th>
<th class="si-col-actions">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tbody>
<tr v-if="categories.length === 0">
<td colspan="5" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="isLoading">Loading categories...</template>
<template v-else>No categories yet. Add one to organize your store items.</template>
<td colspan="5" class="si-empty-cell">
<EmptyState
icon="folder-open"
:title="isLoading ? 'Loading…' : 'No categories'"
:description="isLoading ? '' : 'Add a category to organize your store items.'"
/>
</td>
</tr>
<tr
v-for="category in categories"
:key="category.id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3">
<p class="text-sm font-medium text-neutral-100">{{ category.name }}</p>
<p v-if="category.description" class="text-xs text-neutral-500 truncate max-w-md">{{ category.description }}</p>
<tr v-for="category in categories" :key="category.id">
<td class="si-cell-primary">
<span class="si-name">{{ category.name }}</span>
<span v-if="category.description" class="si-sub">{{ category.description }}</span>
</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ category.slug }}</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ category.display_order }}</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="category.visible ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
>
<td><span class="si-mono">{{ category.slug }}</span></td>
<td class="si-col-num si-mono">{{ category.display_order }}</td>
<td>
<Badge :tone="category.visible ? 'online' : 'neutral'">
{{ category.visible ? 'Visible' : 'Hidden' }}
</span>
</Badge>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button
@click="openCategoryModal(category)"
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
title="Edit"
>
<Edit2 class="w-4 h-4" />
</button>
<button
@click="deleteCategory(category)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
<td class="si-col-actions">
<div class="si-row-actions">
<IconButton icon="pencil" label="Edit" size="sm" @click="openCategoryModal(category)" />
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteCategory(category)" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Items Tab -->
<div v-if="tab === 'items'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<table class="w-full">
<!-- Items table -->
<table v-if="tab === 'items'" class="si-table">
<thead>
<tr class="border-b border-neutral-800 text-left">
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Item</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Category</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Type</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Price</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Commands</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
<tr>
<th>Item</th>
<th>Category</th>
<th>Type</th>
<th class="si-col-price">Price</th>
<th>Commands</th>
<th>Status</th>
<th class="si-col-actions">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tbody>
<tr v-if="items.length === 0">
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="isLoading">Loading items...</template>
<template v-else>No items yet. Add items to start selling.</template>
<td colspan="7" class="si-empty-cell">
<EmptyState
icon="shopping-bag"
:title="isLoading ? 'Loading…' : 'No items'"
:description="isLoading ? '' : 'Add items to start selling.'"
/>
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3">
<p class="text-sm font-medium text-neutral-100">{{ item.name }}</p>
<p v-if="item.description" class="text-xs text-neutral-500 truncate max-w-xs">{{ item.description }}</p>
<tr v-for="item in items" :key="item.id">
<td class="si-cell-primary">
<span class="si-name">{{ item.name }}</span>
<span v-if="item.description" class="si-sub">{{ item.description }}</span>
</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ getCategoryName(item.category_id) }}</td>
<td class="px-4 py-3">
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="typeBadgeClass(item.item_type)">
{{ item.item_type }}
</span>
<td class="si-text-secondary">{{ getCategoryName(item.category_id) }}</td>
<td><Badge :tone="typeTone(item.item_type)">{{ item.item_type }}</Badge></td>
<td class="si-col-price">
<span class="si-price">${{ safeFixed(item.price, 2) }}</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-1 text-sm text-neutral-200">
<DollarSign class="w-3.5 h-3.5 text-neutral-500" />
{{ safeFixed(item.price, 2) }}
</div>
</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}
</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="item.enabled ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
>
<td><span class="si-mono si-text-secondary">{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}</span></td>
<td>
<Badge :tone="item.enabled ? 'online' : 'neutral'">
{{ item.enabled ? 'Enabled' : 'Disabled' }}
</span>
</Badge>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button
@click="openItemModal(item)"
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
title="Edit"
>
<Edit2 class="w-4 h-4" />
</button>
<button
@click="deleteItem(item)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
<td class="si-col-actions">
<div class="si-row-actions">
<IconButton icon="pencil" label="Edit" size="sm" @click="openItemModal(item)" />
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteItem(item)" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Panel>
<!-- Category Modal -->
<!-- ===== Category modal ===== -->
<div
v-if="showCategoryModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
class="si-modal-backdrop"
@click.self="closeCategoryModal"
>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-lg w-full">
<div class="border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-neutral-100">{{ editingCategory ? 'Edit Category' : 'Add Category' }}</h2>
<button @click="closeCategoryModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
<X class="w-5 h-5" />
</button>
<div class="si-modal">
<div class="si-modal__head">
<h2 class="si-modal__title">{{ editingCategory ? 'Edit category' : 'Add category' }}</h2>
<IconButton icon="x" label="Close" @click="closeCategoryModal" />
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Name</label>
<input
v-model="categoryForm.name"
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
type="text"
placeholder="VIP Kits"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Slug (URL-safe)</label>
<input
v-model="categoryForm.slug"
type="text"
placeholder="vip-kits"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
<div class="si-modal__body">
<Input
v-model="categoryForm.name"
label="Name"
placeholder="VIP Kits"
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
/>
<Input
v-model="categoryForm.slug"
label="Slug (URL-safe)"
placeholder="vip-kits"
:mono="true"
/>
<label class="cc-field">
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
<textarea
v-model="categoryForm.description"
class="cc-textarea"
rows="2"
placeholder="Premium kits for VIP players"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Display Order</label>
<input
v-model.number="categoryForm.display_order"
type="number"
min="0"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div class="flex items-center gap-3">
<input
v-model="categoryForm.visible"
type="checkbox"
id="category-visible"
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
/>
<label for="category-visible" class="text-sm text-neutral-300">Visible to customers</label>
</div>
</label>
<Input
v-model="categoryForm.display_order as any"
label="Display order"
type="number"
/>
<Checkbox v-model="categoryForm.visible" label="Visible to customers" />
</div>
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
<button
@click="closeCategoryModal"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
@click="saveCategory"
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
>
{{ editingCategory ? 'Save Changes' : 'Create Category' }}
</button>
<div class="si-modal__foot">
<Button variant="secondary" @click="closeCategoryModal">Cancel</Button>
<Button @click="saveCategory">{{ editingCategory ? 'Save changes' : 'Create category' }}</Button>
</div>
</div>
</div>
<!-- Item Modal -->
<!-- ===== Item modal ===== -->
<div
v-if="showItemModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
class="si-modal-backdrop"
@click.self="closeItemModal"
>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-neutral-100">{{ editingItem ? 'Edit Item' : 'Add Item' }}</h2>
<button @click="closeItemModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
<X class="w-5 h-5" />
</button>
<div class="si-modal si-modal--wide">
<div class="si-modal__head si-modal__head--sticky">
<h2 class="si-modal__title">{{ editingItem ? 'Edit item' : 'Add item' }}</h2>
<IconButton icon="x" label="Close" @click="closeItemModal" />
</div>
<div class="p-6 space-y-6">
<!-- Basic Info -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Item Name</label>
<input
v-model="itemForm.name"
type="text"
placeholder="VIP Starter Kit"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
<div class="si-modal__body">
<!-- Basic info section -->
<div class="si-section">
<div class="si-section__label">Basic information</div>
<Input v-model="itemForm.name" label="Item name" placeholder="VIP Starter Kit" />
<label class="cc-field">
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
<textarea
v-model="itemForm.description"
class="cc-textarea"
rows="2"
placeholder="Get started with essential gear and resources"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Category</label>
<select
v-model="itemForm.category_id"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
>
<option :value="null">Uncategorized</option>
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
</select>
</div>
</label>
<Select
v-model="categorySelectValue"
label="Category"
:options="categorySelectOptions"
/>
</div>
<!-- Pricing -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Pricing</h3>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Price (USD)</label>
<div class="relative">
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
<input
v-model.number="itemForm.price"
type="number"
step="0.01"
min="0.01"
placeholder="9.99"
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
</div>
<!-- Pricing section -->
<div class="si-section">
<div class="si-section__label">Pricing</div>
<Input
v-model="itemForm.price as any"
label="Price (USD)"
type="number"
prefix="$"
placeholder="9.99"
/>
</div>
<!-- Item Type -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Item Type</h3>
<div class="grid grid-cols-2 gap-2">
<!-- Item type section -->
<div class="si-section">
<div class="si-section__label">Item type</div>
<div class="si-type-grid">
<button
v-for="type in itemTypes"
:key="type.value"
type="button"
class="si-type-btn"
:class="itemForm.item_type === type.value ? 'si-type-btn--active' : ''"
@click="itemForm.item_type = type.value as any"
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors"
:class="itemForm.item_type === type.value
? 'bg-oxide-500/15 border-oxide-500 text-oxide-400'
: 'bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200'"
>
{{ type.label }}
</button>
</div>
</div>
<!-- Delivery Commands -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Delivery Commands</h3>
<button
@click="addCommand"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg transition-colors"
>
<Plus class="w-3 h-3" />
Add Command
</button>
<!-- Delivery commands section -->
<div class="si-section">
<div class="si-section__label-row">
<div class="si-section__label">Delivery commands</div>
<Button size="sm" variant="ghost" icon="plus" @click="addCommand">Add command</Button>
</div>
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-3 space-y-1.5 text-xs">
<p class="text-neutral-400">Use placeholders:</p>
<p class="text-neutral-300 font-mono">{'{steam_id}'} - Player's Steam ID</p>
<p class="text-neutral-300 font-mono">{'{player_name}'} - Player's name</p>
<p class="text-neutral-500 mt-2">Example: {{ selectedTypeExample }}</p>
<div class="si-cmd-hint">
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{steam_id}'}</span> Player's Steam ID</p>
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{player_name}'}</span> Player's name</p>
<p class="si-cmd-hint__example">Example: {{ selectedTypeExample }}</p>
</div>
<div class="space-y-2">
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="flex gap-2">
<input
<div class="si-commands">
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="si-command-row">
<Input
v-model="itemForm.delivery_commands[index]"
type="text"
:mono="true"
placeholder="inventory.giveto {steam_id} rifle.ak 1"
class="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
<button
<IconButton
v-if="itemForm.delivery_commands.length > 1"
icon="trash-2"
label="Remove"
variant="danger"
size="sm"
@click="removeCommand(index)"
class="p-2 text-neutral-500 hover:text-red-400 rounded-lg transition-colors"
>
<Trash2 class="w-4 h-4" />
</button>
/>
</div>
</div>
</div>
<!-- Additional Settings -->
<div class="space-y-4">
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Additional Settings</h3>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Image URL (optional)</label>
<input
v-model="itemForm.image_url"
type="text"
placeholder="https://example.com/image.png"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Purchase Limit Per Player (optional)</label>
<input
v-model.number="itemForm.limit_per_player"
type="number"
min="0"
placeholder="Leave empty for unlimited"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div class="flex items-center gap-3">
<input
v-model="itemForm.enabled"
type="checkbox"
id="item-enabled"
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
/>
<label for="item-enabled" class="text-sm text-neutral-300">Enabled and available for purchase</label>
</div>
<!-- Additional settings section -->
<div class="si-section">
<div class="si-section__label">Additional settings</div>
<Input
v-model="itemForm.image_url"
label="Image URL"
placeholder="https://example.com/image.png"
hint="Optional preview image for the store page."
/>
<Input
v-model="itemForm.limit_per_player as any"
label="Purchase limit per player"
type="number"
placeholder="Leave empty for unlimited"
hint="Optional. Restricts how many times a player can purchase this item."
/>
<Checkbox v-model="itemForm.enabled" label="Enabled and available for purchase" />
</div>
</div>
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
<button
@click="closeItemModal"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
@click="saveItem"
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
>
{{ editingItem ? 'Save Changes' : 'Create Item' }}
</button>
<div class="si-modal__foot">
<Button variant="secondary" @click="closeItemModal">Cancel</Button>
<Button @click="saveItem">{{ editingItem ? 'Save changes' : 'Create item' }}</Button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.si-page { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
.page__head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; flex-wrap: wrap; row-gap: 12px;
}
.page__title {
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 5px;
}
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
.page__actions { display: flex; align-items: center; gap: 8px; }
/* Table */
.si-table { width: 100%; border-collapse: collapse; }
.si-table thead th {
padding: 10px 16px; text-align: left;
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
border-bottom: 1px solid var(--border-subtle);
}
.si-table tbody tr { border-bottom: 1px solid var(--border-subtle); }
.si-table tbody tr:last-child { border-bottom: none; }
.si-table tbody tr:hover { background: var(--surface-hover); }
.si-table td { padding: 11px 16px; vertical-align: middle; }
.si-cell-primary { display: flex; flex-direction: column; gap: 2px; }
.si-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.si-sub { font-size: var(--text-xs); color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; }
.si-mono { font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.si-text-secondary { font-size: var(--text-sm); color: var(--text-secondary); }
.si-price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
.si-col-num { width: 80px; text-align: right; }
.si-col-num.si-mono { text-align: right; }
.si-col-price { width: 90px; }
.si-col-actions { width: 80px; }
.si-row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
.si-empty-cell { padding: 0 !important; }
.si-spin { animation: si-spin 0.8s linear infinite; }
@keyframes si-spin { to { transform: rotate(360deg); } }
/* Modal */
.si-modal-backdrop {
position: fixed; inset: 0; z-index: 60;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,.6); backdrop-filter: blur(4px); padding: 16px;
}
.si-modal {
background: var(--surface-base); border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl, var(--ring-default)); max-width: 520px; width: 100%;
display: flex; flex-direction: column; max-height: 90vh;
}
.si-modal--wide { max-width: 680px; }
.si-modal__head {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle);
flex: none;
}
.si-modal__head--sticky { position: sticky; top: 0; background: var(--surface-base); z-index: 1; }
.si-modal__title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
.si-modal__body {
padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px;
}
.si-modal__foot {
display: flex; align-items: center; justify-content: flex-end;
gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border-subtle);
flex: none;
}
/* Item modal sections */
.si-section { display: flex; flex-direction: column; gap: 12px; }
.si-section__label {
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
}
.si-section__label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
/* Item type selector */
.si-type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.si-type-btn {
padding: 9px 14px; font-size: var(--text-sm); font-weight: 500; font-family: var(--font-sans);
border-radius: var(--radius-md); border: none; cursor: pointer;
background: var(--surface-inset); color: var(--text-secondary);
box-shadow: var(--ring-default); transition: var(--transition-colors);
}
.si-type-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
.si-type-btn--active {
background: var(--accent-soft); color: var(--accent-text);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
/* Command hint */
.si-cmd-hint {
background: var(--surface-inset); border-radius: var(--radius-md);
box-shadow: var(--ring-default); padding: 10px 13px;
display: flex; flex-direction: column; gap: 3px;
}
.si-cmd-hint__row { font-size: var(--text-xs); color: var(--text-secondary); }
.si-cmd-hint__mono { font-family: var(--font-mono); color: var(--text-primary); margin-right: 6px; }
.si-cmd-hint__example { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-top: 4px; }
.si-commands { display: flex; flex-direction: column; gap: 8px; }
.si-command-row { display: flex; align-items: center; gap: 8px; }
.si-opt { font-weight: 400; color: var(--text-muted); }
/* Textarea token style */
.cc-textarea {
width: 100%; min-height: 68px; padding: 9px 11px;
background: var(--surface-inset); border: none; border-radius: var(--radius-md);
box-shadow: var(--ring-default); resize: vertical;
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
line-height: var(--leading-normal); outline: none;
transition: var(--transition-colors);
}
.cc-textarea::placeholder { color: var(--text-muted); }
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
</style>