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>
680 lines
23 KiB
Vue
680 lines
23 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useApi } from '@/composables/useApi'
|
|
import type { StoreCategory, StoreItem } from '@/types'
|
|
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()
|
|
|
|
const tab = ref<'categories' | 'items'>('categories')
|
|
const isLoading = ref(false)
|
|
const categories = ref<StoreCategory[]>([])
|
|
const items = ref<StoreItem[]>([])
|
|
|
|
// Category modal state
|
|
const showCategoryModal = ref(false)
|
|
const editingCategory = ref<StoreCategory | null>(null)
|
|
const categoryForm = ref({
|
|
name: '',
|
|
slug: '',
|
|
description: '',
|
|
display_order: 0,
|
|
visible: true
|
|
})
|
|
|
|
// Item modal state
|
|
const showItemModal = ref(false)
|
|
const editingItem = ref<StoreItem | null>(null)
|
|
const itemForm = ref({
|
|
category_id: null as string | null,
|
|
name: '',
|
|
description: '',
|
|
price: 0,
|
|
image_url: '',
|
|
item_type: 'kit' as 'kit' | 'rank' | 'currency' | 'command',
|
|
delivery_commands: [''],
|
|
limit_per_player: null as number | null,
|
|
enabled: true
|
|
})
|
|
|
|
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}' }
|
|
]
|
|
|
|
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 {
|
|
return name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.trim()
|
|
}
|
|
|
|
async function fetchCategories() {
|
|
isLoading.value = true
|
|
try {
|
|
const data = await api.get<StoreCategory[]>('/webstore/categories')
|
|
categories.value = data
|
|
} catch (error) {
|
|
console.error('Failed to fetch categories:', error)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function fetchItems() {
|
|
isLoading.value = true
|
|
try {
|
|
const data = await api.get<StoreItem[]>('/webstore/items')
|
|
items.value = data
|
|
} catch (error) {
|
|
console.error('Failed to fetch items:', error)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
function openCategoryModal(category?: StoreCategory) {
|
|
if (category) {
|
|
editingCategory.value = category
|
|
categoryForm.value = {
|
|
name: category.name,
|
|
slug: category.slug,
|
|
description: category.description || '',
|
|
display_order: category.display_order,
|
|
visible: category.visible
|
|
}
|
|
} else {
|
|
editingCategory.value = null
|
|
categoryForm.value = {
|
|
name: '',
|
|
slug: '',
|
|
description: '',
|
|
display_order: categories.value.length,
|
|
visible: true
|
|
}
|
|
}
|
|
showCategoryModal.value = true
|
|
}
|
|
|
|
function closeCategoryModal() {
|
|
showCategoryModal.value = false
|
|
editingCategory.value = null
|
|
}
|
|
|
|
async function saveCategory() {
|
|
try {
|
|
if (editingCategory.value) {
|
|
await api.put(`/webstore/categories/${editingCategory.value.id}`, categoryForm.value)
|
|
} else {
|
|
await api.post('/webstore/categories', categoryForm.value)
|
|
}
|
|
await fetchCategories()
|
|
closeCategoryModal()
|
|
} catch (error) {
|
|
console.error('Failed to save category:', error)
|
|
}
|
|
}
|
|
|
|
async function deleteCategory(category: StoreCategory) {
|
|
if (!confirm(`Delete category "${category.name}"? Items in this category will become uncategorized.`)) return
|
|
try {
|
|
await api.del(`/webstore/categories/${category.id}`)
|
|
await fetchCategories()
|
|
} catch (error) {
|
|
console.error('Failed to delete category:', error)
|
|
}
|
|
}
|
|
|
|
function openItemModal(item?: StoreItem) {
|
|
if (item) {
|
|
editingItem.value = item
|
|
itemForm.value = {
|
|
category_id: item.category_id,
|
|
name: item.name,
|
|
description: item.description || '',
|
|
price: item.price,
|
|
image_url: item.image_url || '',
|
|
item_type: item.item_type,
|
|
delivery_commands: item.delivery_commands.length > 0 ? item.delivery_commands : [''],
|
|
limit_per_player: item.limit_per_player,
|
|
enabled: item.enabled
|
|
}
|
|
} else {
|
|
editingItem.value = null
|
|
itemForm.value = {
|
|
category_id: null,
|
|
name: '',
|
|
description: '',
|
|
price: 0,
|
|
image_url: '',
|
|
item_type: 'kit',
|
|
delivery_commands: [''],
|
|
limit_per_player: null,
|
|
enabled: true
|
|
}
|
|
}
|
|
showItemModal.value = true
|
|
}
|
|
|
|
function closeItemModal() {
|
|
showItemModal.value = false
|
|
editingItem.value = null
|
|
}
|
|
|
|
function addCommand() {
|
|
itemForm.value.delivery_commands.push('')
|
|
}
|
|
|
|
function removeCommand(index: number) {
|
|
itemForm.value.delivery_commands.splice(index, 1)
|
|
if (itemForm.value.delivery_commands.length === 0) {
|
|
itemForm.value.delivery_commands.push('')
|
|
}
|
|
}
|
|
|
|
async function saveItem() {
|
|
if (!itemForm.value.name.trim()) {
|
|
alert('Item name is required')
|
|
return
|
|
}
|
|
if (itemForm.value.price <= 0) {
|
|
alert('Price must be greater than 0')
|
|
return
|
|
}
|
|
const validCommands = itemForm.value.delivery_commands.filter(c => c.trim())
|
|
if (validCommands.length === 0) {
|
|
alert('At least one delivery command is required')
|
|
return
|
|
}
|
|
|
|
try {
|
|
const payload = {
|
|
...itemForm.value,
|
|
delivery_commands: validCommands
|
|
}
|
|
|
|
if (editingItem.value) {
|
|
await api.put(`/webstore/items/${editingItem.value.id}`, payload)
|
|
} else {
|
|
await api.post('/webstore/items', payload)
|
|
}
|
|
await fetchItems()
|
|
closeItemModal()
|
|
} catch (error) {
|
|
console.error('Failed to save item:', error)
|
|
}
|
|
}
|
|
|
|
async function deleteItem(item: StoreItem) {
|
|
if (!confirm(`Delete item "${item.name}"?`)) return
|
|
try {
|
|
await api.del(`/webstore/items/${item.id}`)
|
|
await fetchItems()
|
|
} catch (error) {
|
|
console.error('Failed to delete item:', error)
|
|
}
|
|
}
|
|
|
|
function getCategoryName(categoryId: string | null): string {
|
|
if (!categoryId) return 'Uncategorized'
|
|
const cat = categories.value.find(c => c.id === categoryId)
|
|
return cat?.name ?? 'Unknown'
|
|
}
|
|
|
|
const selectedTypeExample = computed(() => {
|
|
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
|
|
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(() => {
|
|
fetchCategories()
|
|
fetchItems()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<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="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>
|
|
|
|
<!-- Tab bar + content panel -->
|
|
<Panel :flush-body="true">
|
|
<template #actions>
|
|
<Tabs v-model="tab" :items="tabItems" />
|
|
</template>
|
|
|
|
<!-- Categories table -->
|
|
<table v-if="tab === 'categories'" class="si-table">
|
|
<thead>
|
|
<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>
|
|
<tr v-if="categories.length === 0">
|
|
<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">
|
|
<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><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' }}
|
|
</Badge>
|
|
</td>
|
|
<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>
|
|
|
|
<!-- Items table -->
|
|
<table v-if="tab === 'items'" class="si-table">
|
|
<thead>
|
|
<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>
|
|
<tr v-if="items.length === 0">
|
|
<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">
|
|
<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="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><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' }}
|
|
</Badge>
|
|
</td>
|
|
<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>
|
|
</Panel>
|
|
|
|
<!-- ===== Category modal ===== -->
|
|
<div
|
|
v-if="showCategoryModal"
|
|
class="si-modal-backdrop"
|
|
@click.self="closeCategoryModal"
|
|
>
|
|
<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="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"
|
|
/>
|
|
</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="si-modal__foot">
|
|
<Button variant="secondary" @click="closeCategoryModal">Cancel</Button>
|
|
<Button @click="saveCategory">{{ editingCategory ? 'Save changes' : 'Create category' }}</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== Item modal ===== -->
|
|
<div
|
|
v-if="showItemModal"
|
|
class="si-modal-backdrop"
|
|
@click.self="closeItemModal"
|
|
>
|
|
<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="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"
|
|
/>
|
|
</label>
|
|
<Select
|
|
v-model="categorySelectValue"
|
|
label="Category"
|
|
:options="categorySelectOptions"
|
|
/>
|
|
</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 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"
|
|
>
|
|
{{ type.label }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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="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="si-commands">
|
|
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="si-command-row">
|
|
<Input
|
|
v-model="itemForm.delivery_commands[index]"
|
|
:mono="true"
|
|
placeholder="inventory.giveto {steam_id} rifle.ak 1"
|
|
/>
|
|
<IconButton
|
|
v-if="itemForm.delivery_commands.length > 1"
|
|
icon="trash-2"
|
|
label="Remove"
|
|
variant="danger"
|
|
size="sm"
|
|
@click="removeCommand(index)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</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="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>
|