initial commit
This commit is contained in:
25
.claude/settings.local.json
Normal file
25
.claude/settings.local.json
Normal file
File diff suppressed because one or more lines are too long
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Node modules
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
env
|
||||||
|
env.local
|
||||||
|
env.*.local
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
README.md
Normal file
5
README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Albion Crafting Calculator</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2376
package-lock.json
generated
Normal file
2376
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "albion-crafting-calc",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.25"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vue-tsc": "^3.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.ts
Normal file
6
postcss.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
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')}`
|
||||||
|
}
|
||||||
13
tailwind.config.ts
Normal file
13
tailwind.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: [
|
||||||
|
'./index.html',
|
||||||
|
'./src/**/*.{vue,ts,tsx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config
|
||||||
16
tsconfig.app.json
Normal file
16
tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user