initial commit
This commit is contained in:
91
src/App.vue
Normal file
91
src/App.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-950 text-gray-100">
|
||||
<AppHeader :total-recipes="ALL_RECIPES.length" />
|
||||
|
||||
<!-- Page tabs -->
|
||||
<div class="flex gap-0 px-6 border-b border-gray-800 bg-gray-900/80">
|
||||
<button v-for="page in PAGES" :key="page.id"
|
||||
class="px-5 py-3 text-sm font-medium transition-colors border-b-2 -mb-px" :class="currentPage === page.id
|
||||
? 'text-amber-400 border-amber-400'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-300'" @click="currentPage = page.id">
|
||||
{{ page.label }}
|
||||
<span v-if="page.id === 'production' && orderCount > 0"
|
||||
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-600 text-amber-100 text-[10px] font-bold">{{
|
||||
orderCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Calculator page -->
|
||||
<div v-if="currentPage === 'calculator'" class="p-6 space-y-4">
|
||||
<FilterBar :filters="filters" :result-count="profitResults.length" @set-name-filter="setNameFilter"
|
||||
@set-city="setCity" @toggle-tier="toggleTier" @set-selected-item-types="setSelectedItemTypes"
|
||||
@toggle-enchantment="toggleEnchantment" @reset-enchantments="resetEnchantments" @set-rrr="setRrr" />
|
||||
|
||||
<ProfitTable :results="profitResults" :sort-state="sortState" @sort="handleSort" />
|
||||
</div>
|
||||
|
||||
<!-- Bill of Production page -->
|
||||
<ProductionPage v-else-if="currentPage === 'production'" />
|
||||
|
||||
<!-- Prices page -->
|
||||
<PricesPage v-else-if="currentPage === 'prices'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AppHeader from './components/layout/AppHeader.vue'
|
||||
import FilterBar from './components/filters/FilterBar.vue'
|
||||
import ProfitTable from './components/table/ProfitTable.vue'
|
||||
import PricesPage from './pages/PricesPage.vue'
|
||||
import ProductionPage from './pages/ProductionPage.vue'
|
||||
|
||||
import { useFilters } from './composables/useFilters'
|
||||
import { useCraftingProfit } from './composables/useCraftingProfit'
|
||||
import { useProductionOrder } from './composables/useProductionOrder'
|
||||
|
||||
import { ALL_RECIPES } from './data/recipes'
|
||||
import type { SortField, SortState } from './types/crafting'
|
||||
|
||||
// Page navigation
|
||||
const { orderCount } = useProductionOrder()
|
||||
|
||||
const PAGES = [
|
||||
{ id: 'calculator', label: 'Calculator' },
|
||||
{ id: 'production', label: 'Bill of Production' },
|
||||
{ id: 'prices', label: 'Prices' },
|
||||
] as const
|
||||
type PageId = typeof PAGES[number]['id']
|
||||
const currentPage = ref<PageId>('calculator')
|
||||
|
||||
// Sort state
|
||||
const sortState = ref<SortState>({ field: 'materialCost', direction: 'asc' })
|
||||
|
||||
// Filters
|
||||
const {
|
||||
filters,
|
||||
setCity,
|
||||
toggleTier,
|
||||
setSelectedItemTypes,
|
||||
setRrr,
|
||||
setNameFilter,
|
||||
toggleEnchantment,
|
||||
resetEnchantments,
|
||||
} = useFilters()
|
||||
|
||||
// Profit calculation
|
||||
const { profitResults } = useCraftingProfit(ALL_RECIPES, filters, sortState)
|
||||
|
||||
// Sort handler
|
||||
function handleSort(field: SortField) {
|
||||
if (sortState.value.field === field) {
|
||||
sortState.value = {
|
||||
field,
|
||||
direction: sortState.value.direction === 'asc' ? 'desc' : 'asc',
|
||||
}
|
||||
} else {
|
||||
sortState.value = { field, direction: 'asc' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
41
src/components/HelloWorld.vue
Normal file
41
src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
111
src/components/PriceCell.vue
Normal file
111
src/components/PriceCell.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="font-mono text-xs min-w-[72px]">
|
||||
<!-- Editing -->
|
||||
<div v-if="editing" class="flex items-center gap-1 justify-end" @click.stop>
|
||||
<input v-focus type="number" min="1" :placeholder="currentPrice > 0 ? 'empty=clear' : 'silver'"
|
||||
class="w-24 bg-gray-900 border border-amber-500 rounded px-1.5 py-0.5 text-right text-xs font-mono text-gray-200 focus:outline-none placeholder-gray-600"
|
||||
v-model="inputValue" @keydown.enter="save" @keydown.escape="cancel" />
|
||||
<button class="text-amber-500 hover:text-amber-300 leading-none" title="Save" @click="save">✓</button>
|
||||
<button class="text-gray-500 hover:text-gray-300 leading-none" title="Cancel" @click="cancel">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Display -->
|
||||
<button
|
||||
v-else
|
||||
ref="displayBtn"
|
||||
class="price-cell-btn group flex items-center gap-0.5 w-full justify-end"
|
||||
:class="cellClass"
|
||||
:title="tooltip"
|
||||
@click="startEdit()"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span>{{ displayText }}</span>
|
||||
<span class="opacity-0 group-hover:opacity-40 text-[9px] ml-0.5">✎</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { useAlbionPrices } from '../composables/useAlbionPrices'
|
||||
import { useFilters } from '../composables/useFilters'
|
||||
import { formatSilver, formatLastUpdated } from '../utils/formatting'
|
||||
|
||||
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
|
||||
|
||||
const props = defineProps<{ itemId: string }>()
|
||||
|
||||
const { getPrice, isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
||||
const { filters } = useFilters()
|
||||
|
||||
const editing = ref(false)
|
||||
const inputValue = ref<number | string>('')
|
||||
const displayBtn = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const city = computed(() => filters.value.city)
|
||||
|
||||
const entry = computed(() => getPrice(props.itemId, city.value))
|
||||
const currentPrice = computed(() => entry.value?.sell_price_min ?? 0)
|
||||
const isManual = computed(() => isManualPrice(props.itemId, city.value))
|
||||
|
||||
const displayText = computed(() =>
|
||||
currentPrice.value > 0 ? formatSilver(currentPrice.value) : '—'
|
||||
)
|
||||
|
||||
const cellClass = computed(() => {
|
||||
if (isManual.value) return 'text-amber-400 hover:text-amber-200'
|
||||
if (currentPrice.value === 0) return 'text-gray-600 hover:text-amber-400'
|
||||
return 'text-gray-300 hover:text-gray-100'
|
||||
})
|
||||
|
||||
const tooltip = computed(() => {
|
||||
const exact = currentPrice.value > 0 ? currentPrice.value.toLocaleString() : null
|
||||
const e = getManualEntry(props.itemId, city.value)
|
||||
if (e && isManual.value) {
|
||||
return exact ? `${exact} — set ${formatLastUpdated(new Date(e.editedAt))} — click to edit` : `Set ${formatLastUpdated(new Date(e.editedAt))} — click to edit`
|
||||
}
|
||||
return exact ? `${exact} — click to edit` : 'Click to set price'
|
||||
})
|
||||
|
||||
function startEdit(initial?: number) {
|
||||
inputValue.value = initial !== undefined ? initial : (currentPrice.value > 0 ? currentPrice.value : '')
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function save() {
|
||||
const v = Math.round(Number(inputValue.value))
|
||||
if (!inputValue.value && inputValue.value !== 0) {
|
||||
clearManualPrice(props.itemId, city.value)
|
||||
} else if (v > 0) {
|
||||
setManualPrice(props.itemId, city.value, v)
|
||||
}
|
||||
editing.value = false
|
||||
inputValue.value = ''
|
||||
focusNext()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
editing.value = false
|
||||
inputValue.value = ''
|
||||
nextTick(() => displayBtn.value?.focus())
|
||||
}
|
||||
|
||||
function focusNext() {
|
||||
nextTick(() => {
|
||||
const all = Array.from(document.querySelectorAll<HTMLButtonElement>('.price-cell-btn'))
|
||||
const idx = displayBtn.value ? all.indexOf(displayBtn.value) : -1
|
||||
const next = all[idx + 1]
|
||||
if (next) next.focus()
|
||||
})
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
startEdit(Number(e.key))
|
||||
e.preventDefault()
|
||||
} else if (e.key === 'Enter') {
|
||||
startEdit()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
31
src/components/filters/CategoryFilter.vue
Normal file
31
src/components/filters/CategoryFilter.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-2">Categories</label>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat"
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium border transition-colors"
|
||||
:class="categories.has(cat)
|
||||
? 'bg-amber-500 border-amber-400 text-gray-900'
|
||||
: 'bg-gray-700 border-gray-600 text-gray-400 hover:border-gray-500'"
|
||||
@click="$emit('toggle', cat)"
|
||||
>
|
||||
{{ cat }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ItemCategory } from '../../types/crafting'
|
||||
import { CATEGORIES } from '../../data/constants'
|
||||
|
||||
defineProps<{
|
||||
categories: Set<ItemCategory>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggle: [category: ItemCategory]
|
||||
}>()
|
||||
</script>
|
||||
25
src/components/filters/CityFilter.vue
Normal file
25
src/components/filters/CityFilter.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-1">City</label>
|
||||
<select
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-amber-500"
|
||||
:value="city"
|
||||
@change="$emit('change', ($event.target as HTMLSelectElement).value as AlbionCity)"
|
||||
>
|
||||
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AlbionCity } from '../../types/api'
|
||||
import { CITIES } from '../../data/constants'
|
||||
|
||||
defineProps<{
|
||||
city: AlbionCity
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
change: [city: AlbionCity]
|
||||
}>()
|
||||
</script>
|
||||
328
src/components/filters/FilterBar.vue
Normal file
328
src/components/filters/FilterBar.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div class="bg-gray-800/50 border border-gray-700 rounded-xl p-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
||||
<!-- Item name search -->
|
||||
<div class="relative">
|
||||
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||
</svg>
|
||||
<input type="text" placeholder="Search items..."
|
||||
class="bg-gray-900 border border-gray-700 rounded-lg pl-8 pr-3 py-1.5 text-sm text-gray-200 w-44 focus:outline-none focus:border-amber-500 placeholder-gray-600"
|
||||
:value="filters.nameFilter" @input="$emit('set-name-filter', ($event.target as HTMLInputElement).value)" />
|
||||
</div>
|
||||
|
||||
<div class="h-5 w-px bg-gray-600" />
|
||||
|
||||
<!-- Category tree dropdown -->
|
||||
<div class="relative" ref="categoryRef">
|
||||
<button
|
||||
class="flex items-center gap-1.5 bg-gray-900 border border-gray-700 rounded-lg px-2.5 py-1.5 text-sm text-gray-200 hover:border-gray-500 focus:outline-none focus:border-amber-500 transition-colors"
|
||||
:class="categoryOpen ? 'border-amber-500' : ''" @click="categoryOpen = !categoryOpen">
|
||||
<span class="text-gray-400 text-xs">Category</span>
|
||||
<span class="font-medium">{{ categoryLabel }}</span>
|
||||
<svg class="w-3 h-3 text-gray-500 transition-transform" :class="categoryOpen ? 'rotate-180' : ''" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="categoryOpen"
|
||||
class="absolute top-full left-0 mt-1 z-50 bg-gray-800 border border-gray-600 rounded-lg shadow-2xl min-w-[240px] max-h-[480px] overflow-y-auto py-1">
|
||||
<!-- All Items row -->
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 hover:bg-gray-700/50 cursor-pointer select-none">
|
||||
<span class="w-4 h-4 flex-shrink-0" />
|
||||
<input type="checkbox" class="w-3.5 h-3.5 flex-shrink-0 rounded accent-amber-500" :checked="isAllChecked"
|
||||
:indeterminate="isAllIndet" @change="toggleAll" />
|
||||
<span class="text-sm text-gray-200 font-medium">All Items</span>
|
||||
</label>
|
||||
<div class="my-1 border-t border-gray-700/60" />
|
||||
|
||||
<!-- Tree rows (depth-first, collapsed branches hidden) -->
|
||||
<div v-for="row in visibleRows" :key="row.path"
|
||||
class="flex items-center gap-1.5 py-1 hover:bg-gray-700/50 select-none"
|
||||
:style="{ paddingLeft: `${8 + row.depth * 16}px`, paddingRight: '12px' }">
|
||||
<!-- Branch: chevron toggle -->
|
||||
<button v-if="row.node.children"
|
||||
class="w-4 h-4 flex-shrink-0 flex items-center justify-center text-gray-500 hover:text-gray-300"
|
||||
@click="toggleExpand(row.path)">
|
||||
<svg class="w-3 h-3 transition-transform duration-100"
|
||||
:class="expandedPaths.has(row.path) ? 'rotate-90' : ''" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Leaf: spacer -->
|
||||
<span v-else class="w-4 h-4 flex-shrink-0" />
|
||||
|
||||
<!-- Checkbox -->
|
||||
<input type="checkbox" class="w-3.5 h-3.5 flex-shrink-0 rounded accent-amber-500 cursor-pointer"
|
||||
:checked="isNodeChecked(row.node)" :indeterminate="isNodeIndet(row.node)"
|
||||
@change="toggleNode(row.node)" />
|
||||
|
||||
<!-- Label -->
|
||||
<span class="cursor-pointer" :class="row.node.children
|
||||
? 'text-xs text-gray-400 font-semibold uppercase tracking-wider'
|
||||
: 'text-sm text-gray-300'" @click="row.node.children ? toggleExpand(row.path) : toggleNode(row.node)">
|
||||
{{ row.node.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier multi-select dropdown -->
|
||||
<div class="relative" ref="tierRef">
|
||||
<button
|
||||
class="flex items-center gap-1.5 bg-gray-900 border border-gray-700 rounded-lg px-2.5 py-1.5 text-sm text-gray-200 hover:border-gray-500 focus:outline-none focus:border-amber-500 transition-colors"
|
||||
:class="tierOpen ? 'border-amber-500' : ''" @click="tierOpen = !tierOpen">
|
||||
<span class="text-gray-400 text-xs">Tier</span>
|
||||
<span class="font-medium">{{ tierLabel }}</span>
|
||||
<svg class="w-3 h-3 text-gray-500 transition-transform" :class="tierOpen ? 'rotate-180' : ''" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="tierOpen"
|
||||
class="absolute top-full left-0 mt-1 z-50 bg-gray-800 border border-gray-600 rounded-lg shadow-2xl min-w-[130px] py-1">
|
||||
<!-- All -->
|
||||
<label class="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-700/50 cursor-pointer select-none">
|
||||
<input type="checkbox" class="w-3.5 h-3.5 rounded accent-amber-500" :checked="allTiersSelected"
|
||||
:indeterminate="someTiersSelected" @change="toggleAllTiers" />
|
||||
<span class="text-sm text-gray-200 font-medium">All Tiers</span>
|
||||
</label>
|
||||
<div class="my-1 border-t border-gray-700/60" />
|
||||
<label v-for="tier in TIERS" :key="tier"
|
||||
class="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-700/50 cursor-pointer select-none">
|
||||
<input type="checkbox" class="w-3.5 h-3.5 rounded accent-amber-500" :checked="filters.tiers.has(tier)"
|
||||
@change="$emit('toggle-tier', tier)" />
|
||||
<span class="inline-flex items-center justify-center w-7 h-5 rounded text-xs font-bold"
|
||||
:style="tierStyle(tier)">T{{ tier }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enchantment toggles -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-400">Enchant</span>
|
||||
<div class="flex gap-0.5">
|
||||
<button class="px-2 py-1 rounded text-xs font-semibold transition-colors" :class="filters.enchantments === null
|
||||
? 'bg-amber-600 text-amber-100'
|
||||
: 'bg-gray-800 text-gray-500 hover:bg-gray-700'" @click="$emit('reset-enchantments')">All</button>
|
||||
<button v-for="enc in ENCHANTMENTS" :key="enc" class="px-2 py-1 rounded text-xs font-bold transition-colors"
|
||||
:style="isEnchantActive(enc) ? enchantStyle(enc) : {}"
|
||||
:class="isEnchantActive(enc) ? '' : 'bg-gray-800 text-gray-600 hover:bg-gray-700'"
|
||||
@click="$emit('toggle-enchantment', enc as Enchantment)">.{{ enc }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- City select -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-xs text-gray-400">City</span>
|
||||
<select
|
||||
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-amber-500 cursor-pointer"
|
||||
:value="filters.city" @change="$emit('set-city', ($event.target as HTMLSelectElement).value as AlbionCity)">
|
||||
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="h-5 w-px bg-gray-600" />
|
||||
|
||||
<!-- RRR -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">RRR</span>
|
||||
<input type="number"
|
||||
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 w-16 focus:outline-none focus:border-amber-500 font-mono"
|
||||
:value="filters.rrr" @input="$emit('set-rrr', Number(($event.target as HTMLInputElement).value))" min="0"
|
||||
max="100" step="0.5" />
|
||||
<span class="text-xs text-gray-500">%</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Result count -->
|
||||
<span class="ml-auto text-xs text-gray-500 whitespace-nowrap">{{ resultCount }} results</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { tierStyle, enchantStyle } from '../../utils/formatting'
|
||||
import type { FilterState } from '../../types/filters'
|
||||
import type { AlbionCity } from '../../types/api'
|
||||
import type { Tier, Enchantment } from '../../types/crafting'
|
||||
import { TIERS, CITIES, ENCHANTMENTS } from '../../data/constants'
|
||||
import { ITEM_TREE, ALL_ITEM_NAMES, getLeaves } from '../../data/itemTree'
|
||||
import type { TreeNode } from '../../data/itemTree'
|
||||
|
||||
const props = defineProps<{
|
||||
filters: FilterState
|
||||
resultCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'set-name-filter': [value: string]
|
||||
'set-city': [city: AlbionCity]
|
||||
'toggle-tier': [tier: Tier]
|
||||
'set-selected-item-types': [v: Set<string> | null]
|
||||
'toggle-enchantment': [enc: Enchantment]
|
||||
'reset-enchantments': []
|
||||
'set-rrr': [rate: number]
|
||||
}>()
|
||||
|
||||
// ─── Category tree ────────────────────────────────────────────────────────────
|
||||
|
||||
interface FlatRow { node: TreeNode; depth: number; path: string }
|
||||
|
||||
function flattenVisible(nodes: TreeNode[], expanded: Set<string>, depth = 0, parentPath = ''): FlatRow[] {
|
||||
const rows: FlatRow[] = []
|
||||
for (const node of nodes) {
|
||||
const path = parentPath ? `${parentPath}/${node.label}` : node.label
|
||||
rows.push({ node, depth, path })
|
||||
if (node.children && expanded.has(path)) {
|
||||
rows.push(...flattenVisible(node.children, expanded, depth + 1, path))
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// Top-level nodes expanded by default
|
||||
const expandedPaths = ref<Set<string>>(new Set(ITEM_TREE.map(n => n.label)))
|
||||
|
||||
function toggleExpand(path: string) {
|
||||
const next = new Set(expandedPaths.value)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
} else {
|
||||
next.add(path)
|
||||
}
|
||||
expandedPaths.value = next
|
||||
}
|
||||
|
||||
const visibleRows = computed<FlatRow[]>(() => flattenVisible(ITEM_TREE, expandedPaths.value))
|
||||
|
||||
// Per-node checked / indeterminate state
|
||||
function isNodeChecked(node: TreeNode): boolean {
|
||||
const s = props.filters.selectedItemTypes
|
||||
if (s === null) return true
|
||||
const leaves = getLeaves(node)
|
||||
return leaves.length > 0 && leaves.every(l => s.has(l))
|
||||
}
|
||||
|
||||
function isNodeIndet(node: TreeNode): boolean {
|
||||
const s = props.filters.selectedItemTypes
|
||||
if (s === null) return false
|
||||
const leaves = getLeaves(node)
|
||||
const some = leaves.some(l => s.has(l))
|
||||
const all = leaves.every(l => s.has(l))
|
||||
return some && !all
|
||||
}
|
||||
|
||||
function toggleNode(node: TreeNode) {
|
||||
const leaves = getLeaves(node)
|
||||
const current = props.filters.selectedItemTypes
|
||||
|
||||
if (isNodeChecked(node)) {
|
||||
// Deselect: remove these leaves
|
||||
const base = current === null ? new Set(ALL_ITEM_NAMES) : new Set(current)
|
||||
for (const l of leaves) base.delete(l)
|
||||
emit('set-selected-item-types', base)
|
||||
} else {
|
||||
// Select: add these leaves
|
||||
const base = current === null ? new Set(ALL_ITEM_NAMES) : new Set(current)
|
||||
for (const l of leaves) base.add(l)
|
||||
emit('set-selected-item-types', base.size >= ALL_ITEM_NAMES.size ? null : base)
|
||||
}
|
||||
}
|
||||
|
||||
// "All Items" row
|
||||
const isAllChecked = computed(() => {
|
||||
const s = props.filters.selectedItemTypes
|
||||
return s === null || s.size === ALL_ITEM_NAMES.size
|
||||
})
|
||||
|
||||
const isAllIndet = computed(() => {
|
||||
const s = props.filters.selectedItemTypes
|
||||
return s !== null && s.size > 0 && s.size < ALL_ITEM_NAMES.size
|
||||
})
|
||||
|
||||
function toggleAll() {
|
||||
const s = props.filters.selectedItemTypes
|
||||
if (s === null || s.size === ALL_ITEM_NAMES.size) {
|
||||
emit('set-selected-item-types', new Set<string>())
|
||||
} else {
|
||||
emit('set-selected-item-types', null)
|
||||
}
|
||||
}
|
||||
|
||||
// Button label
|
||||
const categoryLabel = computed(() => {
|
||||
const s = props.filters.selectedItemTypes
|
||||
if (s === null) return 'All'
|
||||
if (s.size === 0) return 'None'
|
||||
for (const node of ITEM_TREE) {
|
||||
const leaves = getLeaves(node)
|
||||
if (leaves.length === s.size && leaves.every(l => s.has(l))) {
|
||||
return node.label
|
||||
}
|
||||
}
|
||||
return `${s.size} items`
|
||||
})
|
||||
|
||||
// ─── Enchantment helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function isEnchantActive(enc: Enchantment): boolean {
|
||||
const s = props.filters.enchantments
|
||||
return s === null || s.has(enc)
|
||||
}
|
||||
|
||||
|
||||
// ─── Dropdown open states ─────────────────────────────────────────────────────
|
||||
|
||||
const categoryOpen = ref(false)
|
||||
const tierOpen = ref(false)
|
||||
|
||||
const categoryRef = ref<HTMLElement | null>(null)
|
||||
const tierRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function onDocumentClick(e: MouseEvent) {
|
||||
if (categoryRef.value && !categoryRef.value.contains(e.target as Node)) {
|
||||
categoryOpen.value = false
|
||||
}
|
||||
if (tierRef.value && !tierRef.value.contains(e.target as Node)) {
|
||||
tierOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', onDocumentClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', onDocumentClick))
|
||||
|
||||
// ─── Tier helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const allTiersSelected = computed(() => TIERS.every(t => props.filters.tiers.has(t)))
|
||||
const someTiersSelected = computed(() =>
|
||||
!allTiersSelected.value && TIERS.some(t => props.filters.tiers.has(t))
|
||||
)
|
||||
|
||||
const tierLabel = computed(() => {
|
||||
const selected = TIERS.filter(t => props.filters.tiers.has(t))
|
||||
if (selected.length === 0) return 'None'
|
||||
if (selected.length === TIERS.length) return 'All'
|
||||
return selected.map(t => `T${t}`).join(', ')
|
||||
})
|
||||
|
||||
function toggleAllTiers() {
|
||||
if (allTiersSelected.value) {
|
||||
TIERS.slice(1).forEach(t => {
|
||||
if (props.filters.tiers.has(t)) emit('toggle-tier', t)
|
||||
})
|
||||
} else {
|
||||
TIERS.forEach(t => {
|
||||
if (!props.filters.tiers.has(t)) emit('toggle-tier', t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
90
src/components/filters/FilterPanel.vue
Normal file
90
src/components/filters/FilterPanel.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<aside class="w-64 flex-shrink-0">
|
||||
<div class="sticky top-20 bg-gray-800 rounded-xl border border-gray-700 p-4 space-y-5">
|
||||
<h2 class="text-sm font-semibold text-gray-300 uppercase tracking-wider">Filters</h2>
|
||||
|
||||
<CityFilter :city="filters.city" @change="$emit('set-city', $event)" />
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-1">Quality</label>
|
||||
<select
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-amber-500"
|
||||
:value="filters.quality"
|
||||
@change="$emit('set-quality', Number(($event.target as HTMLSelectElement).value) as AlbionQuality)"
|
||||
>
|
||||
<option v-for="q in QUALITIES" :key="q.value" :value="q.value">{{ q.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<CategoryFilter :categories="filters.categories" @toggle="$emit('toggle-category', $event)" />
|
||||
|
||||
<TierFilter :tiers="filters.tiers" @toggle="$emit('toggle-tier', $event)" />
|
||||
|
||||
<TaxInput :tax-rate="filters.taxRate" @change="$emit('set-tax-rate', $event)" />
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-1">Min Profit (silver)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-amber-500"
|
||||
:value="filters.minProfit"
|
||||
@input="$emit('set-min-profit', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
step="1000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded accent-amber-500"
|
||||
:checked="filters.hideStale"
|
||||
@change="$emit('set-hide-stale', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="text-xs text-gray-300">Hide stale prices</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded accent-amber-500"
|
||||
:checked="filters.hideMissing"
|
||||
@change="$emit('set-hide-missing', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="text-xs text-gray-300">Hide missing prices</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="pt-1 border-t border-gray-700 text-xs text-gray-500">
|
||||
{{ resultCount }} results shown
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import CityFilter from './CityFilter.vue'
|
||||
import CategoryFilter from './CategoryFilter.vue'
|
||||
import TierFilter from './TierFilter.vue'
|
||||
import TaxInput from './TaxInput.vue'
|
||||
import type { FilterState } from '../../types/filters'
|
||||
import type { AlbionCity, AlbionQuality } from '../../types/api'
|
||||
import type { Tier, ItemCategory } from '../../types/crafting'
|
||||
import { QUALITIES } from '../../data/constants'
|
||||
|
||||
defineProps<{
|
||||
filters: FilterState
|
||||
resultCount: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'set-city': [city: AlbionCity]
|
||||
'set-quality': [quality: AlbionQuality]
|
||||
'toggle-tier': [tier: Tier]
|
||||
'toggle-category': [category: ItemCategory]
|
||||
'set-tax-rate': [rate: number]
|
||||
'set-min-profit': [value: number]
|
||||
'set-hide-stale': [value: boolean]
|
||||
'set-hide-missing': [value: boolean]
|
||||
}>()
|
||||
</script>
|
||||
27
src/components/filters/TaxInput.vue
Normal file
27
src/components/filters/TaxInput.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-1">
|
||||
Tax Rate (%)
|
||||
<span class="text-gray-500 font-normal ml-1">crafting fee</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-amber-500"
|
||||
:value="taxRate"
|
||||
@input="$emit('change', Number(($event.target as HTMLInputElement).value))"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
taxRate: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
change: [rate: number]
|
||||
}>()
|
||||
</script>
|
||||
40
src/components/filters/TierFilter.vue
Normal file
40
src/components/filters/TierFilter.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-400 mb-2">Tiers</label>
|
||||
<div class="flex gap-1.5">
|
||||
<button
|
||||
v-for="tier in TIERS"
|
||||
:key="tier"
|
||||
class="flex-1 py-1.5 rounded-lg text-xs font-bold border transition-colors"
|
||||
:class="tiers.has(tier) ? tierActiveClass(tier) : 'bg-gray-700 border-gray-600 text-gray-400 hover:border-gray-500'"
|
||||
@click="$emit('toggle', tier)"
|
||||
>
|
||||
T{{ tier }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Tier } from '../../types/crafting'
|
||||
import { TIERS } from '../../data/constants'
|
||||
|
||||
defineProps<{
|
||||
tiers: Set<Tier>
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggle: [tier: Tier]
|
||||
}>()
|
||||
|
||||
function tierActiveClass(tier: Tier): string {
|
||||
const classes: Record<Tier, string> = {
|
||||
4: 'bg-blue-600 border-blue-500 text-white',
|
||||
5: 'bg-green-600 border-green-500 text-white',
|
||||
6: 'bg-yellow-600 border-yellow-500 text-white',
|
||||
7: 'bg-orange-600 border-orange-500 text-white',
|
||||
8: 'bg-red-600 border-red-500 text-white',
|
||||
}
|
||||
return classes[tier]
|
||||
}
|
||||
</script>
|
||||
20
src/components/layout/AppHeader.vue
Normal file
20
src/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<header class="sticky top-0 z-50 bg-gray-900 border-b border-gray-700 shadow-lg">
|
||||
<div class="flex items-center justify-between px-6 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-2xl font-bold text-amber-400 tracking-tight">
|
||||
Albion Crafting Calculator
|
||||
</div>
|
||||
<span class="hidden sm:inline text-xs text-gray-500 font-mono">
|
||||
{{ totalRecipes }} recipes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
totalRecipes: number
|
||||
}>()
|
||||
</script>
|
||||
294
src/components/table/ProfitRow.vue
Normal file
294
src/components/table/ProfitRow.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-b border-gray-700/50 hover:bg-gray-700/30 transition-colors cursor-pointer select-none"
|
||||
:class="result.missingPrices ? 'opacity-60' : ''"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<!-- Item name -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500 text-xs transition-transform duration-150" :class="expanded ? 'rotate-90' : ''">▶</span>
|
||||
<img
|
||||
:src="itemImageUrl(result.recipe.outputItemId)"
|
||||
:alt="result.recipe.displayName"
|
||||
class="w-12 h-12 -my-2 rounded flex-shrink-0"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-200">{{ result.recipe.displayName }}</span>
|
||||
<span class="text-xs text-gray-500">{{ result.recipe.category }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Tier badge -->
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-bold"
|
||||
:style="tierEnchantStyle(result.recipe.tier, result.recipe.enchantment)"
|
||||
>
|
||||
T{{ result.recipe.tier }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Effective material cost (after RRR) -->
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-300"
|
||||
:title="result.effectiveMaterialCost > 0 ? result.effectiveMaterialCost.toLocaleString() : undefined">
|
||||
{{ formatSilver(result.effectiveMaterialCost) }}
|
||||
</td>
|
||||
|
||||
<!-- Price age -->
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="inline-block w-2.5 h-2.5 rounded-full"
|
||||
:class="ageDotClass(result)"
|
||||
:title="ageDotTitle(result)"
|
||||
/>
|
||||
</td>
|
||||
|
||||
<!-- Add to bill -->
|
||||
<td class="px-3 py-3 text-right whitespace-nowrap" @click.stop>
|
||||
<!-- Inline qty form -->
|
||||
<div v-if="addingToBill" class="inline-flex items-center gap-1">
|
||||
<input
|
||||
v-focus
|
||||
type="number"
|
||||
min="1"
|
||||
v-model.number="billQty"
|
||||
class="w-12 bg-gray-900 border border-amber-500 rounded px-1.5 py-0.5 text-center text-xs font-mono text-gray-200 focus:outline-none"
|
||||
@keydown.enter="confirmBill"
|
||||
@keydown.escape="cancelBill"
|
||||
/>
|
||||
<button class="text-amber-500 hover:text-amber-300 text-xs leading-none" @click="confirmBill">✓</button>
|
||||
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none" @click="cancelBill">✕</button>
|
||||
</div>
|
||||
<!-- In-order badge + remove -->
|
||||
<div v-else-if="isInOrder" class="inline-flex items-center gap-1">
|
||||
<button
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-600/20 border border-amber-600/40 text-amber-400 text-xs font-mono hover:border-amber-500 transition-colors"
|
||||
@click="startBill"
|
||||
>×{{ currentQty }}</button>
|
||||
<button
|
||||
class="text-gray-600 hover:text-red-400 transition-colors text-xs leading-none"
|
||||
title="Remove from order"
|
||||
@click="remove(result.recipe.outputItemId)"
|
||||
>✕</button>
|
||||
</div>
|
||||
<!-- Add button -->
|
||||
<button
|
||||
v-else
|
||||
class="w-6 h-6 rounded-full bg-gray-700 hover:bg-amber-600/30 hover:text-amber-400 text-gray-400 text-sm leading-none transition-colors inline-flex items-center justify-center"
|
||||
title="Add to Bill of Production"
|
||||
@click="startBill"
|
||||
>+</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded detail row -->
|
||||
<tr v-if="expanded" class="border-b border-gray-700/50 bg-gray-900/60">
|
||||
<td colspan="5" class="px-6 py-4">
|
||||
<div class="flex gap-8">
|
||||
|
||||
<!-- Ingredients breakdown -->
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Ingredients</p>
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500">
|
||||
<th class="text-left font-normal pb-1">Material</th>
|
||||
<th class="text-right font-normal pb-1">Qty</th>
|
||||
<th class="text-right font-normal pb-1">Unit Price</th>
|
||||
<th class="text-right font-normal pb-1">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="ing in result.ingredientBreakdown"
|
||||
:key="ing.itemId"
|
||||
class="border-t border-gray-700/30"
|
||||
>
|
||||
<td class="py-1 text-gray-300">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<img :src="itemImageUrl(ing.itemId)" :alt="ing.displayName" class="w-6 h-6 rounded flex-shrink-0" />
|
||||
{{ ing.displayName }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-1 text-right font-mono text-gray-400">× {{ ing.quantity }}</td>
|
||||
|
||||
<td class="py-1 text-right font-mono">
|
||||
<div
|
||||
v-if="editingItemId === ing.itemId"
|
||||
class="flex items-center justify-end gap-1"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-focus
|
||||
type="number"
|
||||
min="1"
|
||||
:placeholder="ing.unitPrice > 0 ? 'empty to clear' : 'silver'"
|
||||
class="w-28 bg-gray-900 border border-amber-500 rounded px-1.5 py-0.5 text-right text-xs font-mono text-gray-200 focus:outline-none placeholder-gray-600"
|
||||
v-model="inputValue"
|
||||
@keydown.enter="saveEdit(ing.itemId)"
|
||||
@keydown.escape="cancelEdit"
|
||||
/>
|
||||
<button class="text-amber-500 hover:text-amber-300 text-xs leading-none px-0.5" title="Save" @click="saveEdit(ing.itemId)">✓</button>
|
||||
<button class="text-gray-500 hover:text-gray-300 text-xs leading-none px-0.5" title="Cancel" @click="cancelEdit">✕</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="group flex items-center gap-0.5 ml-auto"
|
||||
:class="priceButtonClass(ing.itemId, ing.unitPrice)"
|
||||
:title="priceTitle(ing.itemId, ing.unitPrice)"
|
||||
@click.stop="startEdit(ing.itemId, ing.unitPrice)"
|
||||
>
|
||||
{{ ing.unitPrice > 0 ? formatSilver(ing.unitPrice) : '—' }}
|
||||
<span class="opacity-0 group-hover:opacity-40 text-[10px] ml-0.5">✎</span>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<td class="py-1 text-right font-mono text-gray-200"
|
||||
:title="ing.totalCost > 0 ? ing.totalCost.toLocaleString() : undefined">
|
||||
{{ ing.totalCost > 0 ? formatSilver(ing.totalCost) : '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Summary breakdown -->
|
||||
<div class="min-w-[180px] border-l border-gray-700/50 pl-6">
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Summary</p>
|
||||
<div class="space-y-1.5 text-xs font-mono">
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="text-gray-400">Raw Materials</span>
|
||||
<span class="text-gray-200">{{ formatSilver(result.materialCost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="text-gray-400">RRR ({{ filters.rrr }}%)</span>
|
||||
<span class="text-green-400">−{{ formatSilver(result.materialCost - result.effectiveMaterialCost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-1.5">
|
||||
<span class="text-gray-300 font-semibold">Craft Cost</span>
|
||||
<span class="font-semibold text-gray-100">{{ formatSilver(result.effectiveMaterialCost) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ProfitResult, Tier } from '../../types/crafting'
|
||||
import { formatSilver, formatLastUpdated, tierEnchantStyle, itemImageUrl } from '../../utils/formatting'
|
||||
import { useAlbionPrices } from '../../composables/useAlbionPrices'
|
||||
import { useFilters } from '../../composables/useFilters'
|
||||
import { useProductionOrder } from '../../composables/useProductionOrder'
|
||||
|
||||
const vFocus = { mounted: (el: HTMLElement) => el.focus() }
|
||||
|
||||
const props = defineProps<{
|
||||
result: ProfitResult
|
||||
}>()
|
||||
|
||||
const { isManualPrice, getManualEntry, setManualPrice, clearManualPrice } = useAlbionPrices()
|
||||
const { filters } = useFilters()
|
||||
const { upsert, remove, getQty, inOrder } = useProductionOrder()
|
||||
|
||||
const expanded = ref(false)
|
||||
const editingItemId = ref<string | null>(null)
|
||||
const inputValue = ref('')
|
||||
|
||||
// ─── Bill of Production ───────────────────────────────────────────────────────
|
||||
|
||||
const addingToBill = ref(false)
|
||||
const billQty = ref(1)
|
||||
|
||||
const isInOrder = computed(() => inOrder(props.result.recipe.outputItemId))
|
||||
const currentQty = computed(() => getQty(props.result.recipe.outputItemId))
|
||||
|
||||
function startBill() {
|
||||
billQty.value = isInOrder.value ? currentQty.value : 1
|
||||
addingToBill.value = true
|
||||
}
|
||||
|
||||
function confirmBill() {
|
||||
if (billQty.value > 0) upsert(props.result.recipe, billQty.value)
|
||||
addingToBill.value = false
|
||||
}
|
||||
|
||||
function cancelBill() {
|
||||
addingToBill.value = false
|
||||
}
|
||||
|
||||
// ─── Price age display ────────────────────────────────────────────────────────
|
||||
|
||||
function formatAge(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60_000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
function ageDotClass(result: ProfitResult): string {
|
||||
if (result.missingPrices) return 'bg-red-500'
|
||||
if (result.priceAgeMs === null) return 'bg-gray-600'
|
||||
const hours = result.priceAgeMs / 3_600_000
|
||||
if (hours < 1) return 'bg-green-400'
|
||||
if (hours < 4) return 'bg-yellow-400'
|
||||
if (hours < 8) return 'bg-orange-400'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
function ageDotTitle(result: ProfitResult): string {
|
||||
if (result.missingPrices) return 'Missing prices'
|
||||
if (result.priceAgeMs === null) return 'No price data'
|
||||
return `Prices last set ${formatAge(result.priceAgeMs)}`
|
||||
}
|
||||
|
||||
// ─── Price cell helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function priceButtonClass(itemId: string, currentPrice: number): string {
|
||||
if (isManualPrice(itemId, filters.value.city)) {
|
||||
return 'text-amber-400 hover:text-amber-200'
|
||||
}
|
||||
if (currentPrice === 0) {
|
||||
return 'text-gray-500 hover:text-amber-400'
|
||||
}
|
||||
return 'text-gray-300 hover:text-gray-100'
|
||||
}
|
||||
|
||||
function priceTitle(itemId: string, currentPrice: number): string {
|
||||
const exact = currentPrice > 0 ? currentPrice.toLocaleString() : null
|
||||
const entry = getManualEntry(itemId, filters.value.city)
|
||||
if (entry && isManualPrice(itemId, filters.value.city)) {
|
||||
return exact ? `${exact} — set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit` : `Set ${formatLastUpdated(new Date(entry.editedAt))} — click to edit`
|
||||
}
|
||||
return exact ? `${exact} — click to set price` : 'Click to set price'
|
||||
}
|
||||
|
||||
// ─── Edit state ───────────────────────────────────────────────────────────────
|
||||
|
||||
function startEdit(itemId: string, current: number) {
|
||||
editingItemId.value = itemId
|
||||
inputValue.value = current > 0 ? String(current) : ''
|
||||
}
|
||||
|
||||
function saveEdit(itemId: string) {
|
||||
const v = Math.round(Number(inputValue.value))
|
||||
if (!inputValue.value && inputValue.value !== 0) {
|
||||
clearManualPrice(itemId, filters.value.city)
|
||||
} else if (v > 0) {
|
||||
setManualPrice(itemId, filters.value.city, v)
|
||||
}
|
||||
editingItemId.value = null
|
||||
inputValue.value = ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingItemId.value = null
|
||||
inputValue.value = ''
|
||||
}
|
||||
|
||||
</script>
|
||||
47
src/components/table/ProfitTable.vue
Normal file
47
src/components/table/ProfitTable.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="results.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 text-gray-500"
|
||||
>
|
||||
<div class="text-4xl mb-4">📊</div>
|
||||
<p class="text-lg font-medium text-gray-400">No results</p>
|
||||
<p class="text-sm mt-1">Try adjusting your filters or refreshing prices</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="overflow-x-auto rounded-xl border border-gray-700 bg-gray-800/50">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<TableHeader :sort-state="sortState" @sort="$emit('sort', $event)" />
|
||||
<tbody>
|
||||
<ProfitRow
|
||||
v-for="(result, i) in results"
|
||||
:key="`${result.recipe.outputItemId}-${i}`"
|
||||
:result="result"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Result count -->
|
||||
<div v-if="results.length > 0" class="mt-2 text-xs text-gray-500 text-right">
|
||||
Showing {{ results.length }} items
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TableHeader from './TableHeader.vue'
|
||||
import ProfitRow from './ProfitRow.vue'
|
||||
import type { ProfitResult, SortField, SortState } from '../../types/crafting'
|
||||
|
||||
defineProps<{
|
||||
results: ProfitResult[]
|
||||
sortState: SortState
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
sort: [field: SortField]
|
||||
}>()
|
||||
</script>
|
||||
42
src/components/table/TableHeader.vue
Normal file
42
src/components/table/TableHeader.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr class="bg-gray-800 border-b border-gray-700">
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.field"
|
||||
class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider whitespace-nowrap select-none"
|
||||
:class="col.sortable ? 'cursor-pointer hover:text-gray-200 transition-colors' : ''"
|
||||
@click="col.sortable && col.field !== 'status' ? $emit('sort', col.field as SortField) : undefined"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
{{ col.label }}
|
||||
<span v-if="col.sortable && sortState.field === col.field" class="text-amber-400">
|
||||
{{ sortState.direction === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
<span v-else-if="col.sortable" class="text-gray-600">↕</span>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SortField, SortState } from '../../types/crafting'
|
||||
|
||||
const columns: { field: SortField | 'status' | 'bill'; label: string; sortable: boolean }[] = [
|
||||
{ field: 'displayName', label: 'Item', sortable: true },
|
||||
{ field: 'tier', label: 'Tier', sortable: true },
|
||||
{ field: 'variantType', label: 'Variant', sortable: true },
|
||||
{ field: 'materialCost', label: 'Craft Cost', sortable: true },
|
||||
{ field: 'status', label: 'Price Age', sortable: false },
|
||||
{ field: 'bill', label: '', sortable: false },
|
||||
]
|
||||
|
||||
defineProps<{
|
||||
sortState: SortState
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
sort: [field: SortField]
|
||||
}>()
|
||||
</script>
|
||||
28
src/components/ui/ErrorBanner.vue
Normal file
28
src/components/ui/ErrorBanner.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="message"
|
||||
class="flex items-start gap-3 p-4 rounded-lg bg-red-900/40 border border-red-700 text-red-300 mb-4"
|
||||
>
|
||||
<span class="text-red-400 mt-0.5 text-lg leading-none">!</span>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-sm">API Error</p>
|
||||
<p class="text-xs text-red-400 mt-0.5">{{ message }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-red-400 hover:text-red-200 transition-colors text-sm"
|
||||
@click="$emit('dismiss')"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
</script>
|
||||
32
src/components/ui/LastUpdated.vue
Normal file
32
src/components/ui/LastUpdated.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="text-xs text-gray-400 font-mono">
|
||||
<span class="text-gray-500">Updated:</span>
|
||||
{{ formatted }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { formatLastUpdated } from '../../utils/formatting'
|
||||
|
||||
const props = defineProps<{
|
||||
date: Date | null
|
||||
}>()
|
||||
|
||||
const now = ref(new Date())
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => { now.value = new Date() }, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
const formatted = computed(() => {
|
||||
// Access now.value to make it reactive
|
||||
void now.value
|
||||
return formatLastUpdated(props.date)
|
||||
})
|
||||
</script>
|
||||
14
src/components/ui/LoadingSpinner.vue
Normal file
14
src/components/ui/LoadingSpinner.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<div class="text-center">
|
||||
<div class="inline-block w-10 h-10 border-4 border-amber-400 border-t-transparent rounded-full animate-spin mb-4" />
|
||||
<p class="text-gray-400 text-sm">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>()
|
||||
</script>
|
||||
48
src/components/ui/RefreshButton.vue
Normal file
48
src/components/ui/RefreshButton.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
|
||||
:class="isLoading
|
||||
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-amber-500 hover:bg-amber-400 text-gray-900 cursor-pointer'"
|
||||
:disabled="isLoading"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full"
|
||||
:class="isLoading ? 'animate-spin' : ''"
|
||||
v-if="isLoading"
|
||||
/>
|
||||
<span v-if="!isLoading">Refresh</span>
|
||||
<span v-else>Loading...</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-mono transition-colors border"
|
||||
:class="autoRefresh
|
||||
? 'bg-green-900/50 border-green-700 text-green-400 hover:bg-green-900'
|
||||
: 'bg-gray-800 border-gray-600 text-gray-400 hover:bg-gray-700'"
|
||||
:title="autoRefresh ? 'Auto-refresh ON — click to disable' : 'Auto-refresh OFF — click to enable'"
|
||||
@click="$emit('toggle-auto-refresh')"
|
||||
>
|
||||
<span v-if="autoRefresh">
|
||||
Auto {{ formatCountdown(countdown) }}
|
||||
</span>
|
||||
<span v-else>Auto OFF</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatCountdown } from '../../utils/formatting'
|
||||
|
||||
defineProps<{
|
||||
isLoading: boolean
|
||||
countdown: number
|
||||
autoRefresh: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
refresh: []
|
||||
'toggle-auto-refresh': []
|
||||
}>()
|
||||
</script>
|
||||
137
src/composables/useAlbionPrices.ts
Normal file
137
src/composables/useAlbionPrices.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ref, readonly } from 'vue'
|
||||
import type { AlbionPriceEntry, AlbionCity, ManualPriceEntry, ManualPriceCache } from '../types/api'
|
||||
|
||||
// ─── Override duration ────────────────────────────────────────────────────────
|
||||
|
||||
export const OVERRIDE_DURATION_OPTIONS: { label: string; ms: number }[] = [
|
||||
{ label: '30 min', ms: 30 * 60 * 1000 },
|
||||
{ label: '1 hour', ms: 60 * 60 * 1000 },
|
||||
{ label: '2 hours', ms: 2 * 60 * 60 * 1000 },
|
||||
{ label: '4 hours', ms: 4 * 60 * 60 * 1000 },
|
||||
{ label: '8 hours', ms: 8 * 60 * 60 * 1000 },
|
||||
{ label: '24 hours',ms: 24 * 60 * 60 * 1000 },
|
||||
]
|
||||
|
||||
const DURATION_STORAGE_KEY = 'albion-override-duration'
|
||||
const MANUAL_STORAGE_KEY = 'albion-manual-prices'
|
||||
|
||||
function loadOverrideDuration(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(DURATION_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const ms = Number(raw)
|
||||
if (OVERRIDE_DURATION_OPTIONS.some(o => o.ms === ms)) return ms
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return 24 * 60 * 60 * 1000 // default 24 hours
|
||||
}
|
||||
|
||||
function loadManualPricesFromStorage(): ManualPriceCache {
|
||||
try {
|
||||
const raw = localStorage.getItem(MANUAL_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const obj = JSON.parse(raw) as Record<string, ManualPriceEntry>
|
||||
return new Map(Object.entries(obj))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return new Map()
|
||||
}
|
||||
|
||||
function saveManualPricesToStorage(map: ManualPriceCache): void {
|
||||
try {
|
||||
const obj: Record<string, ManualPriceEntry> = {}
|
||||
for (const [key, val] of map) obj[key] = val
|
||||
localStorage.setItem(MANUAL_STORAGE_KEY, JSON.stringify(obj))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Module-level singleton state ────────────────────────────────────────────
|
||||
|
||||
const manualPrices = ref<ManualPriceCache>(loadManualPricesFromStorage())
|
||||
const overrideDurationMs = ref<number>(loadOverrideDuration())
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function cacheKey(itemId: string, city: string): string {
|
||||
return `${itemId}::${city}`
|
||||
}
|
||||
|
||||
function isManualExpired(entry: ManualPriceEntry): boolean {
|
||||
return Date.now() - new Date(entry.editedAt).getTime() > overrideDurationMs.value
|
||||
}
|
||||
|
||||
function synthesizeManualEntry(
|
||||
entry: ManualPriceEntry,
|
||||
itemId: string,
|
||||
city: AlbionCity,
|
||||
): AlbionPriceEntry {
|
||||
return {
|
||||
item_id: itemId,
|
||||
city,
|
||||
quality: 1,
|
||||
sell_price_min: entry.sell_price_min,
|
||||
sell_price_min_date: entry.editedAt,
|
||||
sell_price_max: entry.sell_price_min,
|
||||
buy_price_min: 0,
|
||||
buy_price_max: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Price accessors ──────────────────────────────────────────────────────────
|
||||
|
||||
function getPrice(itemId: string, city: AlbionCity): AlbionPriceEntry | null {
|
||||
const key = cacheKey(itemId, city)
|
||||
const manual = manualPrices.value.get(key)
|
||||
if (manual && !isManualExpired(manual)) {
|
||||
return synthesizeManualEntry(manual, itemId, city)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Manual price management ──────────────────────────────────────────────────
|
||||
|
||||
function setManualPrice(itemId: string, city: AlbionCity, price: number): void {
|
||||
const key = cacheKey(itemId, city)
|
||||
const next = new Map(manualPrices.value)
|
||||
next.set(key, { sell_price_min: price, editedAt: new Date().toISOString() })
|
||||
manualPrices.value = next
|
||||
saveManualPricesToStorage(next)
|
||||
}
|
||||
|
||||
function clearManualPrice(itemId: string, city: AlbionCity): void {
|
||||
const key = cacheKey(itemId, city)
|
||||
if (!manualPrices.value.has(key)) return
|
||||
const next = new Map(manualPrices.value)
|
||||
next.delete(key)
|
||||
manualPrices.value = next
|
||||
saveManualPricesToStorage(next)
|
||||
}
|
||||
|
||||
function isManualPrice(itemId: string, city: AlbionCity): boolean {
|
||||
const entry = manualPrices.value.get(cacheKey(itemId, city))
|
||||
return !!entry && !isManualExpired(entry)
|
||||
}
|
||||
|
||||
function getManualEntry(itemId: string, city: AlbionCity): ManualPriceEntry | undefined {
|
||||
const entry = manualPrices.value.get(cacheKey(itemId, city))
|
||||
return entry && !isManualExpired(entry) ? entry : undefined
|
||||
}
|
||||
|
||||
function setOverrideDuration(ms: number): void {
|
||||
overrideDurationMs.value = ms
|
||||
try { localStorage.setItem(DURATION_STORAGE_KEY, String(ms)) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAlbionPrices() {
|
||||
return {
|
||||
overrideDurationMs: readonly(overrideDurationMs),
|
||||
getPrice,
|
||||
setManualPrice,
|
||||
clearManualPrice,
|
||||
isManualPrice,
|
||||
getManualEntry,
|
||||
setOverrideDuration,
|
||||
}
|
||||
}
|
||||
55
src/composables/useAutoRefresh.ts
Normal file
55
src/composables/useAutoRefresh.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { AUTO_REFRESH_INTERVAL_S } from '../data/constants'
|
||||
|
||||
export function useAutoRefresh(fetchFn: () => Promise<void>, enabled: Ref<boolean>) {
|
||||
const countdown = ref(AUTO_REFRESH_INTERVAL_S)
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let tickId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startRefresh() {
|
||||
stopRefresh()
|
||||
countdown.value = AUTO_REFRESH_INTERVAL_S
|
||||
|
||||
// Countdown tick every second
|
||||
tickId = setInterval(() => {
|
||||
countdown.value = Math.max(0, countdown.value - 1)
|
||||
}, 1000)
|
||||
|
||||
// Fetch every interval
|
||||
intervalId = setInterval(async () => {
|
||||
await fetchFn()
|
||||
countdown.value = AUTO_REFRESH_INTERVAL_S
|
||||
}, AUTO_REFRESH_INTERVAL_S * 1000)
|
||||
}
|
||||
|
||||
function stopRefresh() {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
if (tickId !== null) {
|
||||
clearInterval(tickId)
|
||||
tickId = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
enabled,
|
||||
(val) => {
|
||||
if (val) {
|
||||
startRefresh()
|
||||
} else {
|
||||
stopRefresh()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopRefresh()
|
||||
})
|
||||
|
||||
return { countdown }
|
||||
}
|
||||
131
src/composables/useCraftingProfit.ts
Normal file
131
src/composables/useCraftingProfit.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { CraftingRecipe, ProfitResult, IngredientBreakdown, SortState } from '../types/crafting'
|
||||
import type { FilterState } from '../types/filters'
|
||||
import { useAlbionPrices } from './useAlbionPrices'
|
||||
import { formatItemId } from '../utils/formatting'
|
||||
|
||||
// Returns 0=basic, 1=artifact, 2=avalon, 3=crystal
|
||||
function variantRank(outputItemId: string): number {
|
||||
const id = outputItemId.replace(/@\d$/, '') // strip enchantment suffix
|
||||
if (id.endsWith('_AVALON')) return 2
|
||||
if (id.endsWith('_CRYSTAL')) return 3
|
||||
if (/_SET[123]$/.test(id)) return 0
|
||||
// Artifact suffixes: UNDEAD, HELL, MORGANA, KEEPER, and unique named artifacts
|
||||
if (/_(?:UNDEAD|HELL|MORGANA|KEEPER|ROYAL|FEY)$/.test(id)) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export function useCraftingProfit(
|
||||
recipes: CraftingRecipe[],
|
||||
filters: Ref<FilterState>,
|
||||
sortState: Ref<SortState>
|
||||
) {
|
||||
const { getPrice } = useAlbionPrices()
|
||||
|
||||
const profitResults = computed<ProfitResult[]>(() => {
|
||||
const f = filters.value
|
||||
const city = f.city
|
||||
const rrrFactor = 1 - f.rrr / 100
|
||||
|
||||
const results: ProfitResult[] = []
|
||||
const nameLower = f.nameFilter.trim().toLowerCase()
|
||||
|
||||
for (const recipe of recipes) {
|
||||
if (!f.tiers.has(recipe.tier)) continue
|
||||
if (f.enchantments !== null && !f.enchantments.has(recipe.enchantment)) continue
|
||||
const baseName = recipe.displayName.replace(/^T\d+\.\d /, '')
|
||||
if (f.selectedItemTypes !== null && !f.selectedItemTypes.has(baseName)) continue
|
||||
if (nameLower && !recipe.displayName.toLowerCase().includes(nameLower)) continue
|
||||
|
||||
// Calculate material cost from ingredients only
|
||||
let materialCost = 0
|
||||
let missingPrices = false
|
||||
|
||||
// Track oldest price date (for status column)
|
||||
let oldestDate: string | null = null
|
||||
function trackDate(date: string) {
|
||||
if (!oldestDate || date < oldestDate) oldestDate = date
|
||||
}
|
||||
|
||||
const ingredientBreakdown: IngredientBreakdown[] = []
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
const ingEntry = getPrice(ing.itemId, city)
|
||||
const unitPrice = ingEntry?.sell_price_min ?? 0
|
||||
if (ingEntry === null || unitPrice === 0) {
|
||||
missingPrices = true
|
||||
ingredientBreakdown.push({
|
||||
itemId: ing.itemId,
|
||||
displayName: formatItemId(ing.itemId),
|
||||
quantity: ing.quantity,
|
||||
unitPrice: 0,
|
||||
totalCost: 0,
|
||||
})
|
||||
} else {
|
||||
trackDate(ingEntry.sell_price_min_date)
|
||||
const totalCost = unitPrice * ing.quantity
|
||||
materialCost += totalCost
|
||||
ingredientBreakdown.push({
|
||||
itemId: ing.itemId,
|
||||
displayName: formatItemId(ing.itemId),
|
||||
quantity: ing.quantity,
|
||||
unitPrice,
|
||||
totalCost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveMaterialCost = materialCost * rrrFactor
|
||||
const priceAgeMs = missingPrices ? null : (oldestDate ? Date.now() - new Date(oldestDate).getTime() : null)
|
||||
|
||||
results.push({
|
||||
recipe,
|
||||
materialCost,
|
||||
effectiveMaterialCost,
|
||||
priceAgeMs,
|
||||
missingPrices,
|
||||
ingredientBreakdown,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort results
|
||||
const { field, direction } = sortState.value
|
||||
results.sort((a, b) => {
|
||||
let aVal: number | string
|
||||
let bVal: number | string
|
||||
|
||||
switch (field) {
|
||||
case 'materialCost':
|
||||
aVal = a.effectiveMaterialCost
|
||||
bVal = b.effectiveMaterialCost
|
||||
break
|
||||
case 'displayName':
|
||||
aVal = a.recipe.displayName
|
||||
bVal = b.recipe.displayName
|
||||
break
|
||||
case 'tier':
|
||||
aVal = a.recipe.tier
|
||||
bVal = b.recipe.tier
|
||||
break
|
||||
case 'variantType':
|
||||
aVal = variantRank(a.recipe.outputItemId)
|
||||
bVal = variantRank(b.recipe.outputItemId)
|
||||
break
|
||||
default:
|
||||
aVal = a.effectiveMaterialCost
|
||||
bVal = b.effectiveMaterialCost
|
||||
}
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return direction === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal)
|
||||
}
|
||||
const diff = (aVal as number) - (bVal as number)
|
||||
return direction === 'asc' ? diff : -diff
|
||||
})
|
||||
|
||||
return results
|
||||
})
|
||||
|
||||
return { profitResults }
|
||||
}
|
||||
79
src/composables/useFilters.ts
Normal file
79
src/composables/useFilters.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref } from 'vue'
|
||||
import type { FilterState } from '../types/filters'
|
||||
import type { Tier, Enchantment } from '../types/crafting'
|
||||
import type { AlbionCity } from '../types/api'
|
||||
import { ENCHANTMENTS } from '../data/constants'
|
||||
|
||||
function loadCity(): AlbionCity {
|
||||
return (localStorage.getItem('albion-city') as AlbionCity) ?? 'Caerleon'
|
||||
}
|
||||
|
||||
function loadRrr(): number {
|
||||
const v = Number(localStorage.getItem('albion-rrr'))
|
||||
return isNaN(v) || v < 0 || v > 100 ? 15 : v
|
||||
}
|
||||
|
||||
const filters = ref<FilterState>({
|
||||
city: loadCity(),
|
||||
tiers: new Set([4, 5, 6, 7, 8]),
|
||||
selectedItemTypes: null,
|
||||
rrr: loadRrr(),
|
||||
nameFilter: '',
|
||||
enchantments: null,
|
||||
})
|
||||
|
||||
function setCity(city: AlbionCity) {
|
||||
filters.value.city = city
|
||||
localStorage.setItem('albion-city', city)
|
||||
}
|
||||
|
||||
function toggleTier(tier: Tier) {
|
||||
const tiers = new Set(filters.value.tiers)
|
||||
if (tiers.has(tier)) {
|
||||
tiers.delete(tier)
|
||||
} else {
|
||||
tiers.add(tier)
|
||||
}
|
||||
filters.value.tiers = tiers
|
||||
}
|
||||
|
||||
function setSelectedItemTypes(value: Set<string> | null) {
|
||||
filters.value.selectedItemTypes = value
|
||||
}
|
||||
|
||||
function setRrr(rate: number) {
|
||||
filters.value.rrr = Math.max(0, Math.min(100, rate))
|
||||
localStorage.setItem('albion-rrr', String(filters.value.rrr))
|
||||
}
|
||||
|
||||
function setNameFilter(value: string) {
|
||||
filters.value.nameFilter = value
|
||||
}
|
||||
|
||||
function toggleEnchantment(enc: Enchantment) {
|
||||
const current = filters.value.enchantments
|
||||
const next = current === null ? new Set(ENCHANTMENTS as Enchantment[]) : new Set(current)
|
||||
if (next.has(enc)) {
|
||||
next.delete(enc)
|
||||
} else {
|
||||
next.add(enc)
|
||||
}
|
||||
filters.value.enchantments = next.size === ENCHANTMENTS.length ? null : next
|
||||
}
|
||||
|
||||
function resetEnchantments() {
|
||||
filters.value.enchantments = null
|
||||
}
|
||||
|
||||
export function useFilters() {
|
||||
return {
|
||||
filters,
|
||||
setCity,
|
||||
toggleTier,
|
||||
setSelectedItemTypes,
|
||||
setRrr,
|
||||
setNameFilter,
|
||||
toggleEnchantment,
|
||||
resetEnchantments,
|
||||
}
|
||||
}
|
||||
79
src/composables/useProductionOrder.ts
Normal file
79
src/composables/useProductionOrder.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import type { CraftingRecipe } from '../types/crafting'
|
||||
import { ALL_RECIPES } from '../data/recipes'
|
||||
|
||||
export interface OrderItem {
|
||||
recipe: CraftingRecipe
|
||||
qty: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'albion-production-order'
|
||||
|
||||
const recipeIndex = new Map(ALL_RECIPES.map(r => [r.outputItemId, r]))
|
||||
|
||||
function load(): Map<string, OrderItem> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const items = JSON.parse(raw) as OrderItem[]
|
||||
const result: [string, OrderItem][] = []
|
||||
for (const i of items) {
|
||||
const recipe = recipeIndex.get(i.recipe.outputItemId)
|
||||
if (recipe) result.push([recipe.outputItemId, { recipe, qty: i.qty }])
|
||||
}
|
||||
return new Map(result)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return new Map()
|
||||
}
|
||||
|
||||
function save(map: Map<string, OrderItem>): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...map.values()]))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Singleton state ──────────────────────────────────────────────────────────
|
||||
|
||||
const order = ref<Map<string, OrderItem>>(load())
|
||||
|
||||
// ─── Mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
function upsert(recipe: CraftingRecipe, qty: number): void {
|
||||
if (qty <= 0) { remove(recipe.outputItemId); return }
|
||||
const next = new Map(order.value)
|
||||
next.set(recipe.outputItemId, { recipe, qty })
|
||||
order.value = next
|
||||
save(next)
|
||||
}
|
||||
|
||||
function remove(outputItemId: string): void {
|
||||
const next = new Map(order.value)
|
||||
next.delete(outputItemId)
|
||||
order.value = next
|
||||
save(next)
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
order.value = new Map()
|
||||
save(order.value)
|
||||
}
|
||||
|
||||
// ─── Derived state ────────────────────────────────────────────────────────────
|
||||
|
||||
const orderItems = computed(() => [...order.value.values()])
|
||||
const orderCount = computed(() => order.value.size)
|
||||
|
||||
function getQty(outputItemId: string): number {
|
||||
return order.value.get(outputItemId)?.qty ?? 0
|
||||
}
|
||||
|
||||
function inOrder(outputItemId: string): boolean {
|
||||
return order.value.has(outputItemId)
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useProductionOrder() {
|
||||
return { orderItems, orderCount, upsert, remove, clear, getQty, inOrder }
|
||||
}
|
||||
456
src/data/categories.json
Normal file
456
src/data/categories.json
Normal file
@@ -0,0 +1,456 @@
|
||||
[
|
||||
{
|
||||
"label": "Weapons",
|
||||
"children": [
|
||||
{
|
||||
"label": "Bow",
|
||||
"children": [
|
||||
"Bow",
|
||||
"Longbow",
|
||||
"Warbow",
|
||||
"Whispering Bow",
|
||||
"Wailing Bow",
|
||||
"Bow of Badon",
|
||||
"Mistpiercer",
|
||||
"Skystrider Bow"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Crossbow",
|
||||
"children": [
|
||||
"Crossbow",
|
||||
"Heavy Crossbow",
|
||||
"Light Crossbow",
|
||||
"Weeping Repeater",
|
||||
"Boltcasters",
|
||||
"Siegebow",
|
||||
"Energy Shaper",
|
||||
"Arclight Blasters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Axe",
|
||||
"children": [
|
||||
"Battleaxe",
|
||||
"Greataxe",
|
||||
"Halberd",
|
||||
"Carrioncaller",
|
||||
"Infernal Scythe",
|
||||
"Bear Paws",
|
||||
"Realmbreaker",
|
||||
"Crystal Reaper"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Dagger",
|
||||
"children": [
|
||||
"Dagger",
|
||||
"Dagger Pair",
|
||||
"Claws",
|
||||
"Bloodletter",
|
||||
"Demon Fang",
|
||||
"Deathgivers",
|
||||
"Bridled Fury",
|
||||
"Twin Slayers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Hammer",
|
||||
"children": [
|
||||
"Hammer",
|
||||
"Great Hammer",
|
||||
"Polehammer",
|
||||
"Tombhammer",
|
||||
"Forge Hammers",
|
||||
"Grovekeeper",
|
||||
"Hand of Justice",
|
||||
"Truebolt Hammer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "War Gloves",
|
||||
"children": [
|
||||
"Brawler Gloves",
|
||||
"Battle Bracers",
|
||||
"Spiked Gauntlets",
|
||||
"Ursine Maulers",
|
||||
"Hellfire Hands",
|
||||
"Ravenstrike Cestus",
|
||||
"Fists of Avalon",
|
||||
"Forcepulse Bracers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Mace",
|
||||
"children": [
|
||||
"Mace",
|
||||
"Heavy Mace",
|
||||
"Morning Star",
|
||||
"Bedrock Mace",
|
||||
"Incubus Mace",
|
||||
"Camlann Mace",
|
||||
"Oathkeepers",
|
||||
"Dreadstorm Monarch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Quarterstaff",
|
||||
"children": [
|
||||
"Quarterstaff",
|
||||
"Iron-clad Staff",
|
||||
"Double Bladed Staff",
|
||||
"Black Monk Stave",
|
||||
"Staff of Balance",
|
||||
"Grailseeker",
|
||||
"Phantom Twinblade"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Spear",
|
||||
"children": [
|
||||
"Spear",
|
||||
"Pike",
|
||||
"Glaive",
|
||||
"Heron Spear",
|
||||
"Spirithunter",
|
||||
"Trinity Spear",
|
||||
"Daybreaker",
|
||||
"Rift Glaive"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Sword",
|
||||
"children": [
|
||||
"Broadsword",
|
||||
"Claymore",
|
||||
"Dual Swords",
|
||||
"Clarent Blade",
|
||||
"Carving Sword",
|
||||
"Galatine Pair",
|
||||
"Kingmaker",
|
||||
"Infinity Blade"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Arcane Staff",
|
||||
"children": [
|
||||
"Arcane Staff",
|
||||
"Great Arcane Staff",
|
||||
"Enigmatic Staff",
|
||||
"Witchwork Staff",
|
||||
"Occult Staff",
|
||||
"Malevolent Locus",
|
||||
"Evensong",
|
||||
"Astral Staff"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Cursed Staff",
|
||||
"children": [
|
||||
"Cursed Staff",
|
||||
"Great Cursed Staff",
|
||||
"Demonic Staff",
|
||||
"Lifecurse Staff",
|
||||
"Cursed Skull",
|
||||
"Damnation Staff",
|
||||
"Shadowcaller",
|
||||
"Rotcaller Staff"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Fire Staff",
|
||||
"children": [
|
||||
"Fire Staff",
|
||||
"Great Fire Staff",
|
||||
"Infernal Staff",
|
||||
"Wildfire Staff",
|
||||
"Brimstone Staff",
|
||||
"Blazing Staff",
|
||||
"Dawnsong",
|
||||
"Flamewalker Staff"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Frost Staff",
|
||||
"children": [
|
||||
"Frost Staff",
|
||||
"Great Frost Staff",
|
||||
"Glacial Staff",
|
||||
"Hoarfrost Staff",
|
||||
"Icicle Staff",
|
||||
"Permafrost Prism",
|
||||
"Chillhowl",
|
||||
"Arctic Staff"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Holy Staff",
|
||||
"children": [
|
||||
"Holy Staff",
|
||||
"Great Holy Staff",
|
||||
"Divine Staff",
|
||||
"Lifetouch Staff",
|
||||
"Fallen Staff",
|
||||
"Redemption Staff",
|
||||
"Hallowfall",
|
||||
"Exalted Staff"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Nature Staff",
|
||||
"children": [
|
||||
"Nature Staff",
|
||||
"Great Nature Staff",
|
||||
"Wild Staff",
|
||||
"Druidic Staff",
|
||||
"Blight Staff",
|
||||
"Rampant Staff",
|
||||
"Ironroot Staff",
|
||||
"Forgebark Staff"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Shapeshifter",
|
||||
"children": [
|
||||
"Prowling Staff",
|
||||
"Rootbound Staff",
|
||||
"Primal Staff",
|
||||
"Bloodmoon Staff",
|
||||
"Hellspawn Staff",
|
||||
"Earthrune Staff",
|
||||
"Lightcaller",
|
||||
"Stillgaze Staff"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Armor",
|
||||
"children": [
|
||||
{
|
||||
"label": "Head",
|
||||
"children": [
|
||||
{
|
||||
"label": "Plate",
|
||||
"children": [
|
||||
"Soldier Helmet",
|
||||
"Knight Helmet",
|
||||
"Guardian Helmet",
|
||||
"Graveguard Helmet",
|
||||
"Royal Helmet",
|
||||
"Demon Helmet",
|
||||
"Judicator Helmet",
|
||||
"Duskweaver Helmet",
|
||||
"Helmet of Valor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Leather",
|
||||
"children": [
|
||||
"Mercenary Hood",
|
||||
"Hunter Hood",
|
||||
"Assassin Hood",
|
||||
"Stalker Hood",
|
||||
"Royal Hood",
|
||||
"Hellion Hood",
|
||||
"Specter Hood",
|
||||
"Mistwalker Hood",
|
||||
"Hood of Tenacity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Cloth",
|
||||
"children": [
|
||||
"Scholar Cowl",
|
||||
"Cleric Cowl",
|
||||
"Mage Cowl",
|
||||
"Druid Cowl",
|
||||
"Royal Cowl",
|
||||
"Fiend Cowl",
|
||||
"Cultist Cowl",
|
||||
"Feyscale Cowl",
|
||||
"Cowl of Purity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Chest",
|
||||
"children": [
|
||||
{
|
||||
"label": "Plate",
|
||||
"children": [
|
||||
"Soldier Armor",
|
||||
"Knight Armor",
|
||||
"Guardian Armor",
|
||||
"Graveguard Armor",
|
||||
"Royal Armor",
|
||||
"Demon Armor",
|
||||
"Judicator Armor",
|
||||
"Duskweaver Armor",
|
||||
"Armor of Valor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Leather",
|
||||
"children": [
|
||||
"Mercenary Jacket",
|
||||
"Hunter Jacket",
|
||||
"Assassin Jacket",
|
||||
"Stalker Jacket",
|
||||
"Royal Jacket",
|
||||
"Hellion Jacket",
|
||||
"Specter Jacket",
|
||||
"Mistwalker Jacket",
|
||||
"Jacket of Tenacity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Cloth",
|
||||
"children": [
|
||||
"Scholar Robe",
|
||||
"Cleric Robe",
|
||||
"Mage Robe",
|
||||
"Druid Robe",
|
||||
"Royal Robe",
|
||||
"Fiend Robe",
|
||||
"Cultist Robe",
|
||||
"Feyscale Robe",
|
||||
"Robe of Purity"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Boots",
|
||||
"children": [
|
||||
{
|
||||
"label": "Plate",
|
||||
"children": [
|
||||
"Soldier Boots",
|
||||
"Knight Boots",
|
||||
"Guardian Boots",
|
||||
"Graveguard Boots",
|
||||
"Royal Boots",
|
||||
"Demon Boots",
|
||||
"Judicator Boots",
|
||||
"Duskweaver Boots",
|
||||
"Boots of Valor"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Leather",
|
||||
"children": [
|
||||
"Mercenary Shoes",
|
||||
"Hunter Shoes",
|
||||
"Assassin Shoes",
|
||||
"Stalker Shoes",
|
||||
"Royal Shoes",
|
||||
"Hellion Shoes",
|
||||
"Specter Shoes",
|
||||
"Mistwalker Shoes",
|
||||
"Shoes of Tenacity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Cloth",
|
||||
"children": [
|
||||
"Scholar Sandals",
|
||||
"Cleric Sandals",
|
||||
"Mage Sandals",
|
||||
"Druid Sandals",
|
||||
"Royal Sandals",
|
||||
"Fiend Sandals",
|
||||
"Cultist Sandals",
|
||||
"Feyscale Sandals",
|
||||
"Sandals of Purity"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Off-hand",
|
||||
"children": [
|
||||
{
|
||||
"label": "Mage",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"label": "Hunter",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"label": "Warrior",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Gathering Equipment",
|
||||
"children": [
|
||||
{
|
||||
"label": "Fishing",
|
||||
"children": [
|
||||
"Fishing Rods",
|
||||
"Garbs",
|
||||
"Caps",
|
||||
"Workboots",
|
||||
"Backpacks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Fiber",
|
||||
"children": [
|
||||
"Sickle",
|
||||
"Garbs",
|
||||
"Caps",
|
||||
"Workboots",
|
||||
"Backpacks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Hide",
|
||||
"children": [
|
||||
"Skinning Knife",
|
||||
"Garbs",
|
||||
"Caps",
|
||||
"Workboots",
|
||||
"Backpacks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Ore",
|
||||
"children": [
|
||||
"Pickaxe",
|
||||
"Garbs",
|
||||
"Caps",
|
||||
"Workboots",
|
||||
"Backpacks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Stone",
|
||||
"children": [
|
||||
"Hammer",
|
||||
"Garbs",
|
||||
"Caps",
|
||||
"Workboots",
|
||||
"Backpacks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Wood",
|
||||
"children": [
|
||||
"Axes",
|
||||
"Garbs",
|
||||
"Caps",
|
||||
"Workboots",
|
||||
"Backpacks"
|
||||
]
|
||||
},
|
||||
"Tracking Toolkit"
|
||||
]
|
||||
}
|
||||
]
|
||||
24
src/data/constants.ts
Normal file
24
src/data/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { AlbionCity, AlbionQuality } from '../types/api'
|
||||
import type { Tier, ItemCategory, Enchantment } from '../types/crafting'
|
||||
|
||||
export const CITIES: AlbionCity[] = [
|
||||
'Caerleon',
|
||||
'Bridgewatch',
|
||||
'Fort Sterling',
|
||||
'Lymhurst',
|
||||
'Martlock',
|
||||
'Thetford',
|
||||
'Black Market',
|
||||
]
|
||||
|
||||
export const QUALITIES: { label: string; value: AlbionQuality }[] = [
|
||||
{ label: 'Normal', value: 1 },
|
||||
{ label: 'Good', value: 2 },
|
||||
{ label: 'Outstanding', value: 3 },
|
||||
{ label: 'Excellent', value: 4 },
|
||||
{ label: 'Masterpiece', value: 5 },
|
||||
]
|
||||
|
||||
export const TIERS: Tier[] = [4, 5, 6, 7, 8]
|
||||
export const ENCHANTMENTS: Enchantment[] = [0, 1, 2, 3, 4]
|
||||
export const CATEGORIES: ItemCategory[] = ['Weapons', 'Armor', 'Gathering']
|
||||
29
src/data/itemTree.ts
Normal file
29
src/data/itemTree.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import rawCategories from './categories.json'
|
||||
|
||||
export interface TreeNode {
|
||||
label: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
type RawNode = { label: string; children?: (RawNode | string)[] }
|
||||
|
||||
function normalize(raw: RawNode): TreeNode {
|
||||
if (!raw.children) return { label: raw.label }
|
||||
return {
|
||||
label: raw.label,
|
||||
children: raw.children.map(c => typeof c === 'string' ? { label: c } : normalize(c)),
|
||||
}
|
||||
}
|
||||
|
||||
export function getLeaves(node: TreeNode): string[] {
|
||||
if (!node.children) return [node.label]
|
||||
const result: string[] = []
|
||||
for (const child of node.children) {
|
||||
result.push(...getLeaves(child))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const ITEM_TREE: TreeNode[] = (rawCategories as RawNode[]).map(normalize)
|
||||
|
||||
export const ALL_ITEM_NAMES: Set<string> = new Set(ITEM_TREE.flatMap(getLeaves))
|
||||
2287
src/data/recipes.json
Normal file
2287
src/data/recipes.json
Normal file
File diff suppressed because it is too large
Load Diff
80
src/data/recipes.ts
Normal file
80
src/data/recipes.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { CraftingRecipe, Tier, Enchantment, ItemCategory, JournalType } from '../types/crafting'
|
||||
import { ENCHANTMENTS } from './constants'
|
||||
import rawRecipes from './recipes.json'
|
||||
|
||||
// ─── Template types ───────────────────────────────────────────────────────────
|
||||
|
||||
interface IngredientTemplate {
|
||||
itemId: string
|
||||
qty: number | Record<string, number>
|
||||
noEnchant?: boolean
|
||||
}
|
||||
|
||||
interface RecipeTemplate {
|
||||
id: string
|
||||
displayName: string
|
||||
category: ItemCategory
|
||||
outputId: string
|
||||
enchanted?: boolean
|
||||
ingredients: IngredientTemplate[]
|
||||
}
|
||||
|
||||
const ALL_TIERS: Tier[] = [4, 5, 6, 7, 8]
|
||||
|
||||
// ─── Journal type inference ────────────────────────────────────────────────────
|
||||
|
||||
function inferJournalType(outputId: string): JournalType {
|
||||
if (outputId.includes('_TOOL_')) return 'toolmaker'
|
||||
if (/FIRESTAFF|INFERNOSTAFF|ARCANESTAFF|ENIGMATICSTAFF|CURSEDSTAFF|DEMONICSTAFF|FROSTSTAFF|GLACIALSTAFF|HOLYSTAFF|DIVINESTAFF|NATURESTAFF|WILDSTAFF|_CLOTH_|OFF_BOOK|ENIGMATICORB|RINGPAIR|SKULLORB|ICEGAUNTLETS|ICECRYSTAL/.test(outputId)) return 'mage'
|
||||
if (/BOW|_LEATHER_|MAIN_DAGGER|DAGGERPAIR|CLAWPAIR|OFF_TORCH|SHAPESHIFTER|RAPIER|DUALSICKLE|2H_DAGGER/.test(outputId)) return 'hunter'
|
||||
return 'warrior'
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function applyTier(template: string, tier: number): string {
|
||||
return template
|
||||
.replace(/\{t-1\}/g, String(tier - 1))
|
||||
.replace(/\{t\}/g, String(tier))
|
||||
}
|
||||
|
||||
function resolveQty(qty: number | Record<string, number>, tier: number): number {
|
||||
if (typeof qty === 'number') return qty
|
||||
const val = qty[String(tier)]
|
||||
if (val === undefined) throw new Error(`No qty defined for tier ${tier}`)
|
||||
return val
|
||||
}
|
||||
|
||||
// ─── Build all recipes ────────────────────────────────────────────────────────
|
||||
|
||||
export const ALL_RECIPES: CraftingRecipe[] = []
|
||||
|
||||
for (const entry of rawRecipes) {
|
||||
if (!('outputId' in entry)) continue // skip comment-only entries
|
||||
const template = entry as unknown as RecipeTemplate
|
||||
|
||||
const enchants: Enchantment[] = template.enchanted === false ? [0] : ENCHANTMENTS
|
||||
|
||||
for (const tier of ALL_TIERS) {
|
||||
for (const enc of enchants) {
|
||||
const baseOutputId = applyTier(template.outputId, tier)
|
||||
const outputItemId = enc === 0 ? baseOutputId : `${baseOutputId}@${enc}`
|
||||
|
||||
const ingredients = template.ingredients.map(ing => {
|
||||
const baseId = applyTier(ing.itemId, tier)
|
||||
const itemId = (ing.noEnchant === true || enc === 0) ? baseId : `${baseId}_LEVEL${enc}`
|
||||
return { itemId, quantity: resolveQty(ing.qty, tier) }
|
||||
})
|
||||
|
||||
ALL_RECIPES.push({
|
||||
outputItemId,
|
||||
displayName: `T${tier}.${enc} ${template.displayName}`,
|
||||
tier,
|
||||
enchantment: enc,
|
||||
category: template.category,
|
||||
journalType: inferJournalType(template.outputId),
|
||||
ingredients,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/env.d.ts
vendored
Normal file
1
src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
115
src/pages/PricesPage.vue
Normal file
115
src/pages/PricesPage.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
|
||||
<!-- Page header -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-lg font-semibold text-gray-200">Price Editor</h2>
|
||||
<div class="h-5 w-px bg-gray-600" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs text-gray-400">City</span>
|
||||
<select
|
||||
class="bg-gray-900 border border-gray-700 rounded-lg px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-amber-500 cursor-pointer"
|
||||
:value="filters.city"
|
||||
@change="setCity(($event.target as HTMLSelectElement).value as AlbionCity)"
|
||||
>
|
||||
<option v-for="c in CITIES" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 ml-2">
|
||||
Amber = manual override · Click any price to edit · Empty field to clear override
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Material selector -->
|
||||
<div class="bg-gray-800/50 border border-gray-700 rounded-xl p-3">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="mat in MATERIALS"
|
||||
:key="mat.name"
|
||||
class="px-3 py-1 rounded-lg text-xs font-medium transition-colors border"
|
||||
:class="activeMaterial === mat.name
|
||||
? 'bg-amber-500/15 border-amber-500/60 text-amber-300'
|
||||
: 'bg-transparent border-gray-600 text-gray-400 hover:border-gray-400 hover:text-gray-200'"
|
||||
@click="setActiveMaterial(mat.name)"
|
||||
>{{ mat.name }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier blocks for the active material -->
|
||||
<div v-if="activeMaterialDef" class="space-y-3">
|
||||
<div
|
||||
v-for="tier in activeMaterialDef.tiers"
|
||||
:key="tier"
|
||||
class="bg-gray-800/50 border border-gray-700 rounded-xl overflow-hidden"
|
||||
>
|
||||
<!-- Tier header -->
|
||||
<div class="flex items-center gap-2 px-4 py-2 bg-gray-700/30 border-b border-gray-700">
|
||||
<span
|
||||
class="inline-flex items-center justify-center w-8 h-6 rounded text-xs font-bold"
|
||||
:style="tierStyle(tier)"
|
||||
>T{{ tier }}</span>
|
||||
<span class="text-xs text-gray-500">{{ activeMaterialDef.name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Enchantment columns: .0 .1 .2 .3 .4 -->
|
||||
<div class="grid grid-cols-5 divide-x divide-gray-700/40">
|
||||
<div
|
||||
v-for="enc in ENCHANTMENTS"
|
||||
:key="enc"
|
||||
class="px-4 py-3"
|
||||
>
|
||||
<div class="text-[11px] font-semibold mb-2" :style="enchantTextStyle(enc)">
|
||||
.{{ enc }}
|
||||
</div>
|
||||
<PriceCell :item-id="enchantedId(activeMaterialDef.baseId(tier), enc)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { tierStyle, enchantTextStyle } from '../utils/formatting'
|
||||
import PriceCell from '../components/PriceCell.vue'
|
||||
import { useFilters } from '../composables/useFilters'
|
||||
import { CITIES, ENCHANTMENTS } from '../data/constants'
|
||||
import type { AlbionCity, } from '../types/api'
|
||||
import type { Enchantment } from '../types/crafting'
|
||||
|
||||
const { filters, setCity } = useFilters()
|
||||
|
||||
function enchantedId(baseId: string, enchant: Enchantment): string {
|
||||
return enchant === 0 ? baseId : `${baseId}@${enchant}`
|
||||
}
|
||||
|
||||
interface MaterialDef {
|
||||
name: string
|
||||
tiers: number[]
|
||||
baseId: (t: number) => string
|
||||
}
|
||||
|
||||
const MATERIALS: MaterialDef[] = [
|
||||
{ name: 'Metal Bar', tiers: [4, 5, 6, 7, 8], baseId: t => `T${t}_METALBAR` },
|
||||
{ name: 'Planks', tiers: [4, 5, 6, 7, 8], baseId: t => `T${t}_PLANKS` },
|
||||
{ name: 'Leather', tiers: [4, 5, 6, 7, 8], baseId: t => `T${t}_LEATHER` },
|
||||
{ name: 'Cloth', tiers: [4, 5, 6, 7, 8], baseId: t => `T${t}_CLOTH` },
|
||||
]
|
||||
|
||||
const activeMaterial = ref<string>(localStorage.getItem('albion-prices-material') ?? 'Metal Bar')
|
||||
|
||||
function setActiveMaterial(name: string) {
|
||||
activeMaterial.value = name
|
||||
localStorage.setItem('albion-prices-material', name)
|
||||
}
|
||||
|
||||
const activeMaterialDef = computed<MaterialDef | undefined>(
|
||||
() => MATERIALS.find(m => m.name === activeMaterial.value)
|
||||
)
|
||||
|
||||
// ─── Styling helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
</script>
|
||||
286
src/pages/ProductionPage.vue
Normal file
286
src/pages/ProductionPage.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="orderItems.length === 0" class="flex flex-col items-center justify-center py-24 text-gray-500">
|
||||
<p class="text-lg font-medium text-gray-400">No items in production</p>
|
||||
<p class="text-sm mt-1">Click the <span class="text-amber-400 font-mono">+</span> button on any item in the Calculator</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- ── Production Order ──────────────────────────────────────────────── -->
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider">Production Order</h2>
|
||||
<button
|
||||
class="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
@click="clear()"
|
||||
>Clear all</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-800/50 border border-gray-700 rounded-xl overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 bg-gray-700/30">
|
||||
<th class="text-left px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">Item</th>
|
||||
<th class="text-center px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider w-28">Qty</th>
|
||||
<th class="text-right px-4 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">Cost / unit</th>
|
||||
<th class="w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in computedItems"
|
||||
:key="item.recipe.outputItemId"
|
||||
class="border-t border-gray-700/40 hover:bg-gray-700/20"
|
||||
>
|
||||
<td class="px-4 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="itemImageUrl(item.recipe.outputItemId)"
|
||||
:alt="item.recipe.displayName"
|
||||
class="w-12 h-12 -my-2 rounded flex-shrink-0"
|
||||
/>
|
||||
<span class="text-gray-200 font-medium">{{ item.recipe.displayName }}</span>
|
||||
<span class="text-xs text-gray-500">{{ item.recipe.category }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
:value="item.qty"
|
||||
class="w-20 bg-gray-900 border border-gray-700 rounded px-2 py-0.5 text-center text-sm font-mono text-gray-200 focus:outline-none focus:border-amber-500"
|
||||
@change="upsert(item.recipe, Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right font-mono text-gray-300">
|
||||
<span v-if="item.missingPrices" class="text-red-400 text-xs">Missing prices</span>
|
||||
<span v-else>{{ formatSilver(item.craftCostPerUnit) }}</span>
|
||||
</td>
|
||||
<td class="px-2 py-2">
|
||||
<button
|
||||
class="text-gray-600 hover:text-red-400 transition-colors text-sm leading-none"
|
||||
title="Remove"
|
||||
@click="remove(item.recipe.outputItemId)"
|
||||
>✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Bottom section: Materials + Summary ──────────────────────────── -->
|
||||
<div class="flex gap-6 items-start">
|
||||
|
||||
<!-- Materials Needed + Journals -->
|
||||
<section class="flex-[2] min-w-0 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Materials Needed</h2>
|
||||
<div class="bg-gray-800/50 border border-gray-700 rounded-xl overflow-hidden">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 bg-gray-700/30">
|
||||
<th class="text-left px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Material</th>
|
||||
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Qty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="mat in aggregatedMaterials"
|
||||
:key="mat.itemId"
|
||||
class="border-t border-gray-700/40 hover:bg-gray-700/20"
|
||||
>
|
||||
<td class="px-4 py-1.5 text-gray-300">
|
||||
<div class="flex items-center gap-2">
|
||||
<img :src="itemImageUrl(mat.itemId)" :alt="mat.displayName" class="w-10 h-10 -my-1 rounded flex-shrink-0" />
|
||||
{{ mat.displayName }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-1.5 text-right font-mono text-gray-200 font-medium">{{ mat.qty.toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Journals -->
|
||||
<div>
|
||||
<div class="bg-gray-800/50 border border-gray-700 rounded-xl overflow-hidden">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 bg-gray-700/30">
|
||||
<th class="text-left px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Journal</th>
|
||||
<th class="text-right px-4 py-2 text-gray-400 font-semibold uppercase tracking-wider">Qty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in journalRows"
|
||||
:key="`${row.journalType}::${row.tier}`"
|
||||
class="border-t border-gray-700/40 hover:bg-gray-700/20"
|
||||
>
|
||||
<td class="px-4 py-1.5 text-gray-300">
|
||||
<div class="flex items-center gap-2">
|
||||
<img :src="row.journalImgUrl" :alt="row.journalName" class="w-10 h-10 -my-1 rounded flex-shrink-0" />
|
||||
{{ row.journalName }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-1.5 text-right font-mono text-gray-200 font-medium">{{ row.journals.toLocaleString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Summary -->
|
||||
<section class="flex-1 min-w-0">
|
||||
<h2 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Summary</h2>
|
||||
<div class="bg-gray-800/50 border border-gray-700 rounded-xl p-4 space-y-2 text-xs font-mono">
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="text-gray-400">Raw materials cost</span>
|
||||
<span class="text-gray-200">{{ formatSilver(totals.rawCost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="text-gray-400">RRR savings ({{ filters.rrr }}%)</span>
|
||||
<span class="text-green-400">−{{ formatSilver(totals.rrrSavings) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2">
|
||||
<span class="text-gray-200 font-semibold">Total craft cost</span>
|
||||
<span class="text-gray-100 font-semibold">{{ formatSilver(totals.effectiveCost) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-t border-gray-700/50 pt-2">
|
||||
<span class="text-gray-400">Total items</span>
|
||||
<span class="text-gray-200">{{ totals.totalItems }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="text-gray-400">Crafting actions</span>
|
||||
<span class="text-gray-200">{{ totals.totalCrafts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useProductionOrder } from '../composables/useProductionOrder'
|
||||
import { useAlbionPrices } from '../composables/useAlbionPrices'
|
||||
import { useFilters } from '../composables/useFilters'
|
||||
import { formatSilver, formatItemId, tierStyle, itemImageUrl } from '../utils/formatting'
|
||||
import type { Tier, JournalType } from '../types/crafting'
|
||||
|
||||
const { orderItems, upsert, remove, clear } = useProductionOrder()
|
||||
const { getPrice } = useAlbionPrices()
|
||||
const { filters } = useFilters()
|
||||
|
||||
// Laborer journal fills ≈ 13 crafts per book (ratio is constant across tiers)
|
||||
const CRAFTS_PER_JOURNAL = 13
|
||||
|
||||
const JOURNAL_DISPLAY_NAME: Record<JournalType, string> = {
|
||||
warrior: "Blacksmith's",
|
||||
hunter: "Fletcher's",
|
||||
mage: "Imbuer's",
|
||||
toolmaker: "Toolmaker's",
|
||||
}
|
||||
|
||||
const JOURNAL_ITEM_ID: Record<JournalType, string> = {
|
||||
warrior: 'JOURNAL_WARRIOR',
|
||||
hunter: 'JOURNAL_HUNTER',
|
||||
mage: 'JOURNAL_MAGE',
|
||||
toolmaker: 'JOURNAL_TOOLMAKER',
|
||||
}
|
||||
|
||||
// ─── Per-item computed values ─────────────────────────────────────────────────
|
||||
|
||||
const computedItems = computed(() => {
|
||||
const city = filters.value.city
|
||||
const rrrFactor = 1 - filters.value.rrr / 100
|
||||
|
||||
return orderItems.value.map(({ recipe, qty }) => {
|
||||
let rawCost = 0
|
||||
let missingPrices = false
|
||||
|
||||
for (const ing of recipe.ingredients) {
|
||||
const entry = getPrice(ing.itemId, city)
|
||||
if (!entry || entry.sell_price_min === 0) {
|
||||
missingPrices = true
|
||||
} else {
|
||||
rawCost += entry.sell_price_min * ing.quantity
|
||||
}
|
||||
}
|
||||
|
||||
const craftCostPerUnit = rawCost * rrrFactor
|
||||
return { recipe, qty, craftCostPerUnit, totalCraftCost: craftCostPerUnit * qty, missingPrices }
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Aggregated materials ─────────────────────────────────────────────────────
|
||||
|
||||
const aggregatedMaterials = computed(() => {
|
||||
const city = filters.value.city
|
||||
const map = new Map<string, { displayName: string; qty: number; unitPrice: number }>()
|
||||
|
||||
for (const { recipe, qty } of orderItems.value) {
|
||||
for (const ing of recipe.ingredients) {
|
||||
const entry = getPrice(ing.itemId, city)
|
||||
const unitPrice = entry?.sell_price_min ?? 0
|
||||
if (!map.has(ing.itemId)) {
|
||||
map.set(ing.itemId, { displayName: formatItemId(ing.itemId), qty: 0, unitPrice })
|
||||
}
|
||||
map.get(ing.itemId)!.qty += ing.quantity * qty
|
||||
}
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => a[1].displayName.localeCompare(b[1].displayName))
|
||||
.map(([itemId, m]) => ({ ...m, itemId, totalCost: m.qty * m.unitPrice }))
|
||||
})
|
||||
|
||||
// ─── Totals + journals ────────────────────────────────────────────────────────
|
||||
|
||||
const totals = computed(() => {
|
||||
const rrrFactor = 1 - filters.value.rrr / 100
|
||||
let rawCost = 0
|
||||
let totalCrafts = 0
|
||||
let totalItems = 0
|
||||
|
||||
for (const item of computedItems.value) {
|
||||
if (!item.missingPrices) {
|
||||
rawCost += item.craftCostPerUnit / rrrFactor * item.qty // back to raw
|
||||
}
|
||||
totalCrafts += item.qty
|
||||
totalItems += item.qty
|
||||
}
|
||||
|
||||
const effectiveCost = rawCost * rrrFactor
|
||||
const rrrSavings = rawCost - effectiveCost
|
||||
const totalJournals = Math.ceil(totalCrafts / CRAFTS_PER_JOURNAL)
|
||||
|
||||
return { rawCost, effectiveCost, rrrSavings, totalCrafts, totalItems, totalJournals }
|
||||
})
|
||||
|
||||
const journalRows = computed(() => {
|
||||
const map = new Map<string, { journalType: JournalType; tier: Tier; crafts: number }>()
|
||||
for (const { recipe, qty } of orderItems.value) {
|
||||
const key = `${recipe.journalType}::${recipe.tier}`
|
||||
const cur = map.get(key) ?? { journalType: recipe.journalType, tier: recipe.tier as Tier, crafts: 0 }
|
||||
map.set(key, { ...cur, crafts: cur.crafts + qty })
|
||||
}
|
||||
|
||||
return [...map.values()]
|
||||
.sort((a, b) => a.journalType.localeCompare(b.journalType) || a.tier - b.tier)
|
||||
.map(row => ({
|
||||
...row,
|
||||
journalName: JOURNAL_DISPLAY_NAME[row.journalType],
|
||||
journals: Math.ceil(row.crafts / CRAFTS_PER_JOURNAL),
|
||||
journalImgUrl: itemImageUrl(`T${row.tier}_${JOURNAL_ITEM_ID[row.journalType]}`),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
3
src/style.css
Normal file
3
src/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
30
src/types/api.ts
Normal file
30
src/types/api.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface AlbionPriceEntry {
|
||||
item_id: string
|
||||
city: string
|
||||
quality: number
|
||||
sell_price_min: number
|
||||
sell_price_min_date: string
|
||||
sell_price_max: number
|
||||
buy_price_min: number
|
||||
buy_price_max: number
|
||||
}
|
||||
|
||||
export type PriceCache = Map<string, AlbionPriceEntry>
|
||||
|
||||
export interface ManualPriceEntry {
|
||||
sell_price_min: number
|
||||
editedAt: string // ISO date string
|
||||
}
|
||||
|
||||
export type ManualPriceCache = Map<string, ManualPriceEntry>
|
||||
|
||||
export type AlbionCity =
|
||||
| 'Thetford'
|
||||
| 'Caerleon'
|
||||
| 'Bridgewatch'
|
||||
| 'Fort Sterling'
|
||||
| 'Lymhurst'
|
||||
| 'Martlock'
|
||||
| 'Black Market'
|
||||
|
||||
export type AlbionQuality = 1 | 2 | 3 | 4 | 5
|
||||
47
src/types/crafting.ts
Normal file
47
src/types/crafting.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type Tier = 4 | 5 | 6 | 7 | 8
|
||||
export type Enchantment = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
export type ItemCategory = 'Weapons' | 'Armor' | 'Gathering'
|
||||
|
||||
export type JournalType = 'warrior' | 'mage' | 'hunter' | 'toolmaker'
|
||||
|
||||
export type SortField = 'materialCost' | 'displayName' | 'tier' | 'variantType'
|
||||
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface SortState {
|
||||
field: SortField
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
export interface Ingredient {
|
||||
itemId: string
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface CraftingRecipe {
|
||||
outputItemId: string
|
||||
displayName: string
|
||||
tier: Tier
|
||||
enchantment: Enchantment
|
||||
category: ItemCategory
|
||||
journalType: JournalType
|
||||
ingredients: Ingredient[]
|
||||
}
|
||||
|
||||
export interface IngredientBreakdown {
|
||||
itemId: string
|
||||
displayName: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
totalCost: number
|
||||
}
|
||||
|
||||
export interface ProfitResult {
|
||||
recipe: CraftingRecipe
|
||||
materialCost: number
|
||||
effectiveMaterialCost: number
|
||||
priceAgeMs: number | null // null = missing prices; ms since oldest manual entry
|
||||
missingPrices: boolean
|
||||
ingredientBreakdown: IngredientBreakdown[]
|
||||
}
|
||||
11
src/types/filters.ts
Normal file
11
src/types/filters.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { AlbionCity } from './api'
|
||||
import type { Tier, Enchantment } from './crafting'
|
||||
|
||||
export interface FilterState {
|
||||
city: AlbionCity
|
||||
tiers: Set<Tier>
|
||||
selectedItemTypes: Set<string> | null
|
||||
rrr: number
|
||||
nameFilter: string
|
||||
enchantments: Set<Enchantment> | null
|
||||
}
|
||||
140
src/utils/formatting.ts
Normal file
140
src/utils/formatting.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
const ITEM_NAME_MAP: Record<string, string> = {
|
||||
METALBAR: 'Metal Bar',
|
||||
PLANKS: 'Planks',
|
||||
LEATHER: 'Leather',
|
||||
CLOTH: 'Cloth',
|
||||
MEAT: 'Meat',
|
||||
GRAIN: 'Grain',
|
||||
VEGETABLE: 'Vegetable',
|
||||
FISH: 'Fish',
|
||||
HIDE: 'Hide',
|
||||
PLANT: 'Plant',
|
||||
FIBER: 'Fiber',
|
||||
ROCK: 'Rock',
|
||||
ORE: 'Ore',
|
||||
ARTEFACT_2H_LONGBOW_UNDEAD: 'Ghastly Arrows',
|
||||
ARTEFACT_2H_BOW_HELL: 'Demonic Arrowheads',
|
||||
ARTEFACT_2H_BOW_KEEPER: 'Carved Bone',
|
||||
ARTEFACT_2H_BOW_AVALON: 'Immaculately Crafted Riser',
|
||||
ARTEFACT_2H_BOW_CRYSTAL: 'Windborne Crystal',
|
||||
ARTEFACT_MAIN_SCIMITAR_MORGANA: 'Bloodforged Blade',
|
||||
ARTEFACT_2H_CLEAVER_HELL: 'Demonic Blade',
|
||||
ARTEFACT_2H_DUALSCIMITAR_UNDEAD: 'Cursed Blades',
|
||||
ARTEFACT_2H_CLAYMORE_AVALON: 'Remnants of the Old King',
|
||||
ARTEFACT_MAIN_SWORD_CRYSTAL: 'Infinite Crystal',
|
||||
ARTEFACT_2H_HALBERD_MORGANA: 'Morgana Halberd Head',
|
||||
ARTEFACT_2H_SCYTHE_HELL: 'Hellish Sicklehead',
|
||||
ARTEFACT_2H_DUALAXE_KEEPER: 'Keeper Axeheads',
|
||||
ARTEFACT_2H_AXE_AVALON: 'Avalonian Battle Memoir',
|
||||
ARTEFACT_2H_SCYTHE_CRYSTAL: 'Edged Crystal',
|
||||
ARTEFACT_2H_REPEATINGCROSSBOW_UNDEAD: 'Lost Crossbow Mechanism',
|
||||
}
|
||||
|
||||
export function formatItemId(itemId: string): string {
|
||||
// Handle @N suffix (crafted items)
|
||||
let base = itemId
|
||||
let encSuffix = ''
|
||||
const atMatch = base.match(/^(.+)@(\d+)$/)
|
||||
if (atMatch) {
|
||||
base = atMatch[1]!
|
||||
encSuffix = `.${atMatch[2]}`
|
||||
}
|
||||
|
||||
// Handle _LEVELН suffix (enchanted ingredients)
|
||||
const levelMatch = base.match(/^(T\d+_.+?)_LEVEL(\d+)$/)
|
||||
if (levelMatch) {
|
||||
base = levelMatch[1]!
|
||||
encSuffix = `.${levelMatch[2]}`
|
||||
}
|
||||
|
||||
const match = base.match(/^(T\d+)_(.+)$/)
|
||||
if (!match) return itemId
|
||||
const tier = match[1]!
|
||||
const key = match[2]!
|
||||
const name = ITEM_NAME_MAP[key] ?? key
|
||||
return `${tier}${encSuffix} ${name}`
|
||||
}
|
||||
|
||||
export function itemImageUrl(itemId: string): string {
|
||||
return `https://render.albiononline.com/v1/item/${itemId}?quality=1`
|
||||
}
|
||||
|
||||
// Background + text for enchantment buttons/chips
|
||||
export function enchantStyle(enc: number): { backgroundColor: string; color: string } {
|
||||
const map: Record<number, [string, string]> = {
|
||||
0: ['#6b7280', '#f3f4f6'],
|
||||
1: ['#3E8759', '#ffffff'],
|
||||
2: ['#2F828F', '#ffffff'],
|
||||
3: ['#644F8B', '#ffffff'],
|
||||
4: ['#857D47', '#ffffff'],
|
||||
}
|
||||
const [bg, text] = map[enc] ?? ['#6b7280', '#f3f4f6']
|
||||
return { backgroundColor: bg, color: text }
|
||||
}
|
||||
|
||||
// Text color only, for labels
|
||||
export function enchantTextStyle(enc: number): { color: string } {
|
||||
const map: Record<number, string> = {
|
||||
0: '#9ca3af',
|
||||
1: '#3E8759',
|
||||
2: '#2F828F',
|
||||
3: '#644F8B',
|
||||
4: '#857D47',
|
||||
}
|
||||
return { color: map[enc] ?? '#9ca3af' }
|
||||
}
|
||||
|
||||
export function tierEnchantStyle(tier: number, enchantment: number): object {
|
||||
const base = tierStyle(tier)
|
||||
if (enchantment === 0) return base
|
||||
return {
|
||||
...base,
|
||||
outline: `2px solid ${enchantStyle(enchantment).backgroundColor}`,
|
||||
outlineOffset: '2px',
|
||||
}
|
||||
}
|
||||
|
||||
export function tierStyle(tier: number): { backgroundColor: string; color: string } {
|
||||
const map: Record<number, [string, string]> = {
|
||||
4: ['#355f78', '#ffffff'],
|
||||
5: ['#77221a', '#ffffff'],
|
||||
6: ['#c9712c', '#ffffff'],
|
||||
7: ['#d1b044', '#1a1a1a'],
|
||||
8: ['#d0d0d0', '#1a1a1a'],
|
||||
}
|
||||
const [bg, text] = map[tier] ?? ['#484047', '#ffffff']
|
||||
return { backgroundColor: bg, color: text }
|
||||
}
|
||||
|
||||
export function formatSilver(value: number): string {
|
||||
if (value === 0) return '0'
|
||||
if (Math.abs(value) >= 1_000_000) {
|
||||
return (value / 1_000_000).toFixed(2) + 'M'
|
||||
}
|
||||
if (Math.abs(value) >= 1_000) {
|
||||
return (value / 1_000).toFixed(1) + 'k'
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatRoi(roi: number): string {
|
||||
return roi.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
export function formatLastUpdated(date: Date | null): string {
|
||||
if (!date) return 'Never'
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffS = Math.floor(diffMs / 1000)
|
||||
if (diffS < 60) return `${diffS}s ago`
|
||||
const diffM = Math.floor(diffS / 60)
|
||||
if (diffM < 60) return `${diffM}m ago`
|
||||
const diffH = Math.floor(diffM / 60)
|
||||
return `${diffH}h ago`
|
||||
}
|
||||
|
||||
export function formatCountdown(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
Reference in New Issue
Block a user