Files
Starlight_Lancher/apps/app-frontend/src/pages/LabRecipeGenerator.vue
T

2590 lines
71 KiB
Vue

<script setup lang="ts">
import {
ClipboardCopyIcon,
DownloadIcon,
PencilIcon,
PlusIcon,
SaveIcon,
TrashIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
defineMessages,
DropdownSelect,
injectNotificationManager,
type MessageDescriptor,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { computed, onBeforeUnmount, reactive, ref, shallowRef, useTemplateRef, watch } from 'vue'
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
import ItemPalette, { type PaletteEntry } from '@/components/lab/recipe-generator/ItemPalette.vue'
import RecipeGeneratorCopyrightModal from '@/components/lab/recipe-generator/RecipeGeneratorCopyrightModal.vue'
import RecipeItemIcon from '@/components/lab/recipe-generator/RecipeItemIcon.vue'
import RecipeSlotGrid from '@/components/lab/recipe-generator/RecipeSlotGrid.vue'
import TagPalette from '@/components/lab/recipe-generator/TagPalette.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { useResultCountWheel } from '@/composables/lab/useResultCountWheel'
import { drawCountOnCanvas } from '@/lab/recipe-generator/count-display'
import {
createDatapackDescription,
createDatapackFileName,
createDatapackFiles,
type DatapackRecipe,
type PackFile,
saveDatapackAs,
saveJsonFile,
} from '@/lab/recipe-generator/datapack'
import { getSlotDisplay, type SlotDisplay } from '@/lab/recipe-generator/display'
import { parseIdentifier } from '@/lab/recipe-generator/identifier'
import { exportDatapackToWorld } from '@/lab/recipe-generator/instance-export'
import { resolveRecipeItemName } from '@/lab/recipe-generator/item-names'
import { categoryMessages, recipeTypeMessages } from '@/lab/recipe-generator/messages'
import { getCurrentRecipeName } from '@/lab/recipe-generator/naming'
import { CRAFTING_GRID_SLOTS, generateJavaRecipe } from '@/lab/recipe-generator/recipe-engine'
import {
getRecipeLayout,
RECIPE_IMAGE_HEIGHT,
RECIPE_IMAGE_WIDTH,
type RecipeLayoutSlotBox,
} from '@/lab/recipe-generator/recipe-layouts'
import {
buildSlotContext,
type LoadedVersionResources,
loadVersionResources,
TEXTURE_ATLAS,
} from '@/lab/recipe-generator/resources'
import type {
CustomItem,
CustomTag,
JavaVersionId,
RecipeGeneratorStore,
RecipeSlot,
RecipeSlotContext,
RecipeState,
RecipeType,
SlotValue,
} from '@/lab/recipe-generator/types'
import { type RecipeIssueCode, validateRecipe } from '@/lab/recipe-generator/validation'
import {
coerceRecipeTypeForVersion,
DEFAULT_COOKING_TIME,
getRecipeCategoryOptions,
getSupportedRecipeTypes,
JAVA_VERSIONS,
LATEST_JAVA_VERSION,
supportsCustomTags,
supportsRecipeCategory,
supportsShowNotification,
supportsSmithingTrimPattern,
supportsVanillaTagList,
} from '@/lab/recipe-generator/versions'
const { addNotification, handleError } = injectNotificationManager()
const { formatMessage, locale } = useVIntl()
function createDefaultRecipeState(type: RecipeState['recipeType'] = 'crafting'): RecipeState {
return {
id: crypto.randomUUID(),
recipeType: type,
group: '',
category: '',
showNotification: true,
nameMode: 'auto',
name: '',
slots: {},
crafting: {
shapeless: false,
keepWhitespace: false,
twoByTwo: false,
},
cooking: {
time: null,
experience: 0,
},
smithing: {
trimPattern: '',
},
}
}
function createDefaultRecipeGeneratorStore(): RecipeGeneratorStore {
const recipe = createDefaultRecipeState()
return {
version: 1,
selectedVersion: LATEST_JAVA_VERSION,
recipes: [recipe],
selectedRecipeId: recipe.id,
customItems: [],
customTags: [],
}
}
function ensureRecipeTypeForVersion(recipes: RecipeState[], version: JavaVersionId): RecipeState[] {
const supported = new Set(getSupportedRecipeTypes(version))
return recipes.map((recipe) =>
supported.has(recipe.recipeType)
? recipe
: { ...recipe, recipeType: coerceRecipeTypeForVersion(recipe.recipeType, version) },
)
}
const store = reactive(createDefaultRecipeGeneratorStore())
const resources = shallowRef<LoadedVersionResources | null>(null)
const loadingResources = ref(false)
const resourceError = ref('')
const rightTab = ref<'items' | 'tags'>('items')
const pendingDatapack = ref<{ files: PackFile[]; fileName: string } | null>(null)
const customItemDraft = reactive({ uid: '', id: '', name: '', texture: '' })
const customItemModal = useTemplateRef<InstanceType<typeof ModalWrapper>>('customItemModal')
const copyrightModal =
useTemplateRef<InstanceType<typeof RecipeGeneratorCopyrightModal>>('copyrightModal')
const instanceExportModal =
useTemplateRef<InstanceType<typeof InstanceExportModal>>('instanceExportModal')
const messages = defineMessages({
title: { id: 'app.lab.recipe-generator.title', defaultMessage: 'Recipe generator' },
version: { id: 'app.lab.recipe-generator.version', defaultMessage: 'Version' },
versionLabel: {
id: 'app.lab.recipe-generator.version-label',
defaultMessage: 'Java {version}',
},
copyright: {
id: 'app.lab.recipe-generator.copyright.open',
defaultMessage: 'Copyright and attribution',
},
loadingResources: {
id: 'app.lab.recipe-generator.loading-resources',
defaultMessage: 'Loading version data',
},
resourceError: {
id: 'app.lab.recipe-generator.resource-error',
defaultMessage: 'Unable to load version data.',
},
recipesTitle: {
id: 'app.lab.recipe-generator.recipes.title',
defaultMessage: 'Recipes',
},
recipeName: {
id: 'app.lab.recipe-generator.recipes.name',
defaultMessage: 'Name: {name}',
},
newRecipe: { id: 'app.lab.recipe-generator.recipes.new', defaultMessage: 'New recipe' },
cloneRecipe: { id: 'app.lab.recipe-generator.recipes.clone', defaultMessage: 'Clone recipe' },
deleteRecipe: { id: 'app.lab.recipe-generator.recipes.delete', defaultMessage: 'Delete recipe' },
exportDatapack: {
id: 'app.lab.recipe-generator.recipes.export-datapack',
defaultMessage: 'Export datapack',
},
exportDatapackDone: {
id: 'app.lab.recipe-generator.recipes.export-datapack-done',
defaultMessage: 'Datapack exported',
},
exportDatapackInvalid: {
id: 'app.lab.recipe-generator.recipes.export-datapack-invalid',
defaultMessage: 'Fix recipe errors before exporting.',
},
recipeType: { id: 'app.lab.recipe-generator.recipe-type', defaultMessage: 'Recipe type' },
optionsTitle: {
id: 'app.lab.recipe-generator.options.title',
defaultMessage: 'Options',
},
previewTitle: { id: 'app.lab.recipe-generator.preview.title', defaultMessage: 'Preview' },
outputTitle: { id: 'app.lab.recipe-generator.output.title', defaultMessage: 'JSON output' },
saveJson: {
id: 'app.lab.recipe-generator.output.save-json',
defaultMessage: 'Save JSON',
},
jsonSaved: {
id: 'app.lab.recipe-generator.output.saved',
defaultMessage: 'JSON saved',
},
copyPreviewImage: {
id: 'app.lab.recipe-generator.preview.copy-image',
defaultMessage: 'Copy image',
},
previewImageCopied: {
id: 'app.lab.recipe-generator.preview.image-copied',
defaultMessage: 'Preview image copied',
},
datapackSaved: {
id: 'app.lab.recipe-generator.save-as-done',
defaultMessage: 'Datapack saved',
},
shapeless: {
id: 'app.lab.recipe-generator.options.shapeless',
defaultMessage: 'Shapeless crafting',
},
twoByTwo: {
id: 'app.lab.recipe-generator.options.two-by-two',
defaultMessage: '2x2 grid',
},
keepWhitespace: {
id: 'app.lab.recipe-generator.options.keep-whitespace',
defaultMessage: 'Keep exact slot positions',
},
experience: { id: 'app.lab.recipe-generator.options.experience', defaultMessage: 'Experience' },
cookingTime: {
id: 'app.lab.recipe-generator.options.cooking-time',
defaultMessage: 'Cooking time',
},
defaultTime: {
id: 'app.lab.recipe-generator.options.default-time',
defaultMessage: 'Default time',
},
group: { id: 'app.lab.recipe-generator.options.group', defaultMessage: 'Group' },
category: { id: 'app.lab.recipe-generator.options.category', defaultMessage: 'Category' },
categoryNone: {
id: 'app.lab.recipe-generator.options.category-none',
defaultMessage: 'None',
},
showNotification: {
id: 'app.lab.recipe-generator.options.show-notification',
defaultMessage: 'Show recipe notification',
},
fileName: { id: 'app.lab.recipe-generator.options.file-name', defaultMessage: 'File name' },
nameModeAuto: {
id: 'app.lab.recipe-generator.options.name-mode-auto',
defaultMessage: 'Automatic',
},
nameModeManual: {
id: 'app.lab.recipe-generator.options.name-mode-manual',
defaultMessage: 'Manual',
},
manualNamePlaceholder: {
id: 'app.lab.recipe-generator.options.manual-name-placeholder',
defaultMessage: 'recipe_name',
},
autoName: {
id: 'app.lab.recipe-generator.options.auto-name',
defaultMessage: 'Auto name: {name}',
},
clearSlots: { id: 'app.lab.recipe-generator.slots.clear', defaultMessage: 'Clear slots' },
itemsTab: { id: 'app.lab.recipe-generator.panel.items', defaultMessage: 'Items' },
tagsTab: { id: 'app.lab.recipe-generator.panel.tags', defaultMessage: 'Tags' },
customItemsTitle: {
id: 'app.lab.recipe-generator.items.custom-title',
defaultMessage: 'Custom items',
},
addCustomItem: {
id: 'app.lab.recipe-generator.items.add-custom',
defaultMessage: 'Add custom item',
},
editCustomItem: {
id: 'app.lab.recipe-generator.items.edit-custom',
defaultMessage: 'Edit custom item',
},
customItemId: {
id: 'app.lab.recipe-generator.items.custom-id',
defaultMessage: 'Item ID',
},
customItemIdPlaceholder: {
id: 'app.lab.recipe-generator.items.custom-id-placeholder',
defaultMessage: 'minecraft:item_id',
},
customItemName: {
id: 'app.lab.recipe-generator.items.custom-name',
defaultMessage: 'Display name',
},
customItemTexture: {
id: 'app.lab.recipe-generator.items.custom-texture',
defaultMessage: 'Texture URL',
},
customTexturePlaceholder: {
id: 'app.lab.recipe-generator.items.custom-texture-placeholder',
defaultMessage: 'https://example.com/texture.png',
},
save: { id: 'app.lab.recipe-generator.save', defaultMessage: 'Save' },
cancel: { id: 'app.lab.recipe-generator.cancel', defaultMessage: 'Cancel' },
invalidCustomItem: {
id: 'app.lab.recipe-generator.items.invalid-custom',
defaultMessage: 'Enter an item ID.',
},
customItemSaved: {
id: 'app.lab.recipe-generator.items.custom-saved',
defaultMessage: 'Custom item saved',
},
customItemDeleted: {
id: 'app.lab.recipe-generator.items.custom-deleted',
defaultMessage: 'Custom item deleted',
},
deleteCustomItem: {
id: 'app.lab.recipe-generator.items.delete-custom',
defaultMessage: 'Delete custom item',
},
editCustomItemAction: {
id: 'app.lab.recipe-generator.items.edit-custom-action',
defaultMessage: 'Edit custom item',
},
gridFull: {
id: 'app.lab.recipe-generator.slots.grid-full',
defaultMessage: 'The grid is full. Clear a slot first.',
},
recipeErrors: {
id: 'app.lab.recipe-generator.validation.summary',
defaultMessage: '{count} issue',
},
recipeErrorsPlural: {
id: 'app.lab.recipe-generator.validation.summary-plural',
defaultMessage: '{count} issues',
},
issueUnsupportedType: {
id: 'app.lab.recipe-generator.validation.unsupported-type',
defaultMessage: 'This recipe type is not available in the selected version.',
},
issueMissingIngredient: {
id: 'app.lab.recipe-generator.validation.missing-ingredient',
defaultMessage: 'Add an ingredient.',
},
issueMissingResult: {
id: 'app.lab.recipe-generator.validation.missing-result',
defaultMessage: 'Add a result item.',
},
issueMissingTemplate: {
id: 'app.lab.recipe-generator.validation.missing-template',
defaultMessage: 'Add a template item.',
},
issueMissingBase: {
id: 'app.lab.recipe-generator.validation.missing-base',
defaultMessage: 'Add a base item.',
},
issueMissingAddition: {
id: 'app.lab.recipe-generator.validation.missing-addition',
defaultMessage: 'Add an addition item.',
},
issueMissingTrimPattern: {
id: 'app.lab.recipe-generator.validation.missing-trim-pattern',
defaultMessage: 'Add a trim pattern.',
},
issueTagInResult: {
id: 'app.lab.recipe-generator.validation.tag-in-result',
defaultMessage: 'Result slots cannot contain tags.',
},
issueMissingCustomItem: {
id: 'app.lab.recipe-generator.validation.missing-custom-item',
defaultMessage: 'The recipe references a deleted custom item.',
},
issueMissingCustomTag: {
id: 'app.lab.recipe-generator.validation.missing-custom-tag',
defaultMessage: 'The recipe references a deleted custom tag.',
},
issueInvalidIdentifier: {
id: 'app.lab.recipe-generator.validation.invalid-identifier',
defaultMessage: 'An item identifier is invalid.',
},
issueTagsNotSupported: {
id: 'app.lab.recipe-generator.validation.tags-not-supported',
defaultMessage: 'Item tags are not supported in this version.',
},
trimPatternLabel: {
id: 'app.lab.recipe-generator.options.trim-pattern',
defaultMessage: 'Trim pattern',
},
jsonPlaceholder: {
id: 'app.lab.recipe-generator.output.placeholder',
defaultMessage: 'Recipe JSON appears here',
},
})
const issueMessages: Record<RecipeIssueCode, MessageDescriptor> = {
'unsupported-type': messages.issueUnsupportedType,
'missing-ingredient': messages.issueMissingIngredient,
'missing-result': messages.issueMissingResult,
'missing-template': messages.issueMissingTemplate,
'missing-base': messages.issueMissingBase,
'missing-addition': messages.issueMissingAddition,
'missing-trim-pattern': messages.issueMissingTrimPattern,
'tag-in-result': messages.issueTagInResult,
'missing-custom-item': messages.issueMissingCustomItem,
'missing-custom-tag': messages.issueMissingCustomTag,
'invalid-identifier': messages.issueInvalidIdentifier,
'tags-not-supported': messages.issueTagsNotSupported,
}
const currentRecipe = computed(
() => store.recipes.find((recipe) => recipe.id === store.selectedRecipeId) ?? store.recipes[0],
)
const slotContext = computed<RecipeSlotContext | null>(() => {
if (!resources.value) return null
const context = buildSlotContext(store.customItems, store.customTags, resources.value)
const itemsById: RecipeSlotContext['itemsById'] = {}
for (const item of Object.values(context.itemsById)) {
itemsById[item.id] = {
...item,
name: resolveRecipeItemName(item.id, locale.value, item.name),
}
}
return { ...context, itemsById }
})
const generatedJson = computed(() => {
if (!currentRecipe.value || !slotContext.value) return null
try {
return generateJavaRecipe(currentRecipe.value, store.selectedVersion, slotContext.value)
} catch {
return null
}
})
const jsonText = computed(() =>
generatedJson.value ? JSON.stringify(generatedJson.value, null, 2) : '',
)
const naming = computed(() =>
currentRecipe.value && slotContext.value
? getCurrentRecipeName(currentRecipe.value, store.recipes, slotContext.value)
: null,
)
const issues = computed(() =>
currentRecipe.value && slotContext.value
? validateRecipe(currentRecipe.value, store.selectedVersion, slotContext.value)
: [],
)
const availableTypes = computed(() => getSupportedRecipeTypes(store.selectedVersion))
const categoryOptions = computed(
() => getRecipeCategoryOptions(currentRecipe.value?.recipeType ?? 'crafting') ?? [],
)
const showCategory = computed(() =>
currentRecipe.value
? supportsRecipeCategory(store.selectedVersion, currentRecipe.value.recipeType)
: false,
)
const showNotificationOption = computed(() => {
const recipe = currentRecipe.value
return recipe
? supportsShowNotification(store.selectedVersion, recipe.recipeType, recipe.crafting.shapeless)
: false
})
const showTrimPattern = computed(
() =>
currentRecipe.value?.recipeType === 'smithing_trim' &&
supportsSmithingTrimPattern(store.selectedVersion),
)
const showVanillaTags = computed(() => supportsVanillaTagList(store.selectedVersion))
const showCustomTags = computed(() => supportsCustomTags(store.selectedVersion))
const paletteEntries = computed<PaletteEntry[]>(() => {
if (!resources.value) return []
const entries: PaletteEntry[] = resources.value.items.map((item) => {
const name = resolveRecipeItemName(item.id, locale.value, item.name)
return {
key: `item:${item.id}`,
name,
id: item.id,
display: { label: name, texture: item.texture, isTag: false },
value: { kind: 'item', id: item.id },
}
})
for (const item of store.customItems) {
entries.push({
key: `custom:${item.uid}`,
name: item.name,
id: item.id,
display: { label: item.name, texture: item.texture || null, isTag: false },
value: { kind: 'custom_item', uid: item.uid },
})
}
return entries
})
const autoPlaceSlots = computed<RecipeSlot[]>(() => {
switch (currentRecipe.value?.recipeType) {
case 'crafting': {
const recipe = currentRecipe.value
if (recipe?.crafting.twoByTwo) {
return CRAFTING_GRID_SLOTS.filter((slot) => !TWO_BY_TWO_DISABLED_SLOTS.has(slot))
}
return [...CRAFTING_GRID_SLOTS]
}
case 'smelting':
case 'blasting':
case 'smoking':
case 'campfire_cooking':
return ['cooking.ingredient']
case 'stonecutter':
return ['stonecutter.ingredient']
case 'smithing':
case 'smithing_trim':
case 'smithing_transform':
return ['smithing.template', 'smithing.base', 'smithing.addition']
default:
return []
}
})
const RECIPE_SLOT_MIME_TYPE = 'application/x-axolotl-recipe-slot'
const TWO_BY_TWO_DISABLED_SLOTS = new Set<RecipeSlot>([
'crafting.3',
'crafting.6',
'crafting.7',
'crafting.8',
'crafting.9',
])
const PREVIEW_ICON_SCALE = 0.9
const TRIM_PATTERNS = [
'coast',
'sentry',
'dune',
'wild',
'ward',
'eye',
'vex',
'tide',
'snout',
'rib',
'spire',
'wayfinder',
'shaper',
'silence',
'raiser',
'host',
'flow',
'bolt',
] as const
type LayoutSlotRender = {
layoutSlot: RecipeSlot
targetSlot: RecipeSlot
box: RecipeLayoutSlotBox
disabled: boolean
}
type TrimPatternOption = {
pattern: string
id: string
display: SlotDisplay
}
const recipeLayout = computed(() =>
currentRecipe.value ? getRecipeLayout(currentRecipe.value.recipeType) : null,
)
const layoutStageRef = useTemplateRef<HTMLDivElement>('layoutStage')
const layoutStageWidth = ref(0)
let layoutStageObserver: ResizeObserver | null = null
const recipeEditorRef = useTemplateRef<HTMLElement>('recipeEditor')
const recipePaletteMaxHeight = ref('')
let recipeEditorObserver: ResizeObserver | null = null
const layoutSlots = computed<LayoutSlotRender[]>(() => {
const layout = recipeLayout.value
if (!layout) return []
const twoByTwoCrafting =
currentRecipe.value?.recipeType === 'crafting' && currentRecipe.value.crafting.twoByTwo
return (Object.entries(layout.slots) as [RecipeSlot, RecipeLayoutSlotBox][]).map(
([layoutSlot, box]) => ({
layoutSlot,
targetSlot: layoutTargetSlot(layoutSlot),
box,
disabled: twoByTwoCrafting && TWO_BY_TWO_DISABLED_SLOTS.has(layoutSlot),
}),
)
})
const barrierDisplay = computed<SlotDisplay | null>(() =>
slotContext.value
? getSlotDisplay({ kind: 'item', id: 'minecraft:barrier' }, slotContext.value)
: null,
)
const resultCountSlot = computed<RecipeSlot | null>(() => {
switch (currentRecipe.value?.recipeType) {
case 'crafting':
return 'crafting.result'
case 'stonecutter':
return 'stonecutter.result'
default:
return null
}
})
const { hint: resultWheelHintRef, onWheel: onResultSlotWheel } = useResultCountWheel({
getSlot: () => resultCountSlot.value,
getValue: () => {
const slot = resultCountSlot.value
return slot ? currentRecipe.value?.slots[slot] : undefined
},
getCount: () => {
const slot = resultCountSlot.value
const value = slot ? currentRecipe.value?.slots[slot] : undefined
return value && (value.kind === 'item' || value.kind === 'custom_item') && value.count
? value.count
: 1
},
setCount: (count) => {
const slot = resultCountSlot.value
if (slot) setSlotCount(slot, count)
},
})
const trimPatternOptions = computed<TrimPatternOption[]>(() => {
const ctx = slotContext.value
if (!ctx) return []
return TRIM_PATTERNS.map((pattern) => {
const id = `minecraft:${pattern}_armor_trim_smithing_template`
const item = ctx.itemsById[id]
const label = item ? resolveRecipeItemName(item.id, locale.value, item.name) : pattern
return {
pattern,
id,
display: item
? { label, texture: item.texture, isTag: false }
: { label, texture: null, isTag: false },
}
})
})
watch(
layoutStageRef,
(element) => {
layoutStageObserver?.disconnect()
layoutStageObserver = null
layoutStageWidth.value = 0
if (!element || typeof ResizeObserver === 'undefined') return
layoutStageObserver = new ResizeObserver(([entry]) => {
layoutStageWidth.value = entry.contentRect.width
})
layoutStageObserver.observe(element)
layoutStageWidth.value = element.getBoundingClientRect().width
},
{ flush: 'post' },
)
watch(
recipeEditorRef,
(element) => {
recipeEditorObserver?.disconnect()
recipeEditorObserver = null
recipePaletteMaxHeight.value = ''
if (!element) return
const updatePaletteMaxHeight = () => {
const height = element.getBoundingClientRect().height
recipePaletteMaxHeight.value = height ? `${height}px` : ''
}
updatePaletteMaxHeight()
if (typeof ResizeObserver === 'undefined') return
recipeEditorObserver = new ResizeObserver(updatePaletteMaxHeight)
recipeEditorObserver.observe(element)
},
{ flush: 'post' },
)
onBeforeUnmount(() => {
layoutStageObserver?.disconnect()
recipeEditorObserver?.disconnect()
})
watch(
() => store.selectedVersion,
(version, previous) => {
if (previous === version) return
const nextRecipes = ensureRecipeTypeForVersion(store.recipes, version)
store.recipes = nextRecipes
if (!nextRecipes.some((recipe) => recipe.id === store.selectedRecipeId)) {
store.selectedRecipeId = nextRecipes[0]?.id ?? ''
}
loadResources(version)
},
{ immediate: true },
)
watch(
[() => currentRecipe.value?.id, () => currentRecipe.value?.crafting.twoByTwo],
() => {
const recipe = currentRecipe.value
if (!recipe || recipe.recipeType !== 'crafting' || !recipe.crafting.twoByTwo) return
for (const slot of TWO_BY_TWO_DISABLED_SLOTS) {
Reflect.deleteProperty(recipe.slots, slot)
}
},
{ immediate: true },
)
async function loadResources(version: JavaVersionId) {
loadingResources.value = true
resourceError.value = ''
try {
resources.value = await loadVersionResources(version)
} catch (error) {
resources.value = null
resourceError.value = error instanceof Error ? error.message : String(error)
} finally {
loadingResources.value = false
}
}
function versionLabel(version: JavaVersionId) {
return formatMessage(messages.versionLabel, { version })
}
function recipeTypeLabel(type: RecipeType) {
return formatMessage(recipeTypeMessages[type])
}
function categoryLabel(category: string) {
return category
? formatMessage(categoryMessages[category as keyof typeof categoryMessages])
: formatMessage(messages.categoryNone)
}
function nameModeLabel(mode: 'auto' | 'manual') {
return mode === 'auto'
? formatMessage(messages.nameModeAuto)
: formatMessage(messages.nameModeManual)
}
function setRecipeType(type: RecipeType) {
const recipe = currentRecipe.value
if (!recipe || recipe.recipeType === type) return
recipe.recipeType = type
recipe.slots = {}
recipe.category = ''
}
function setTwoByTwo(value: boolean) {
const recipe = currentRecipe.value
if (!recipe || recipe.recipeType !== 'crafting') return
recipe.crafting.twoByTwo = value
if (value) {
for (const slot of TWO_BY_TWO_DISABLED_SLOTS) {
Reflect.deleteProperty(recipe.slots, slot)
}
}
}
function newRecipe() {
const type = availableTypes.value[0] ?? 'crafting'
const recipe = createDefaultRecipeState(type)
store.recipes.push(recipe)
store.selectedRecipeId = recipe.id
}
function cloneRecipe() {
const recipe = currentRecipe.value
if (!recipe) return
const copy: RecipeState = JSON.parse(JSON.stringify(recipe))
copy.id = crypto.randomUUID()
copy.nameMode = 'auto'
copy.name = ''
store.recipes.push(copy)
store.selectedRecipeId = copy.id
}
function deleteRecipe() {
if (store.recipes.length <= 1) {
const recipe = createDefaultRecipeState(availableTypes.value[0] ?? 'crafting')
store.recipes = [recipe]
store.selectedRecipeId = recipe.id
return
}
const index = store.recipes.findIndex((recipe) => recipe.id === store.selectedRecipeId)
store.recipes.splice(index < 0 ? 0 : index, 1)
store.selectedRecipeId = store.recipes[Math.max(0, index - 1)]?.id ?? store.recipes[0].id
}
function setSlot(slot: RecipeSlot, value: SlotValue | undefined) {
const recipe = currentRecipe.value
if (!recipe) return
if (
recipe.recipeType === 'crafting' &&
recipe.crafting.twoByTwo &&
TWO_BY_TWO_DISABLED_SLOTS.has(slot)
) {
return
}
if (value) recipe.slots[slot] = { ...value }
else Reflect.deleteProperty(recipe.slots, slot)
}
function layoutTargetSlot(layoutSlot: RecipeSlot): RecipeSlot {
return currentRecipe.value?.recipeType === 'smithing_trim' && layoutSlot === 'smithing.result'
? 'smithing.base'
: layoutSlot
}
function slotBoxStyle(box: RecipeLayoutSlotBox) {
return {
left: `${(box.x1 / RECIPE_IMAGE_WIDTH) * 100}%`,
top: `${(box.y1 / RECIPE_IMAGE_HEIGHT) * 100}%`,
width: `${((box.x2 - box.x1) / RECIPE_IMAGE_WIDTH) * 100}%`,
height: `${((box.y2 - box.y1) / RECIPE_IMAGE_HEIGHT) * 100}%`,
}
}
function slotIconSize(box: RecipeLayoutSlotBox) {
if (!layoutStageWidth.value) return 32
const backgroundSize = Math.min(box.x2 - box.x1, box.y2 - box.y1)
return Math.max(1, Math.round((backgroundSize / RECIPE_IMAGE_WIDTH) * layoutStageWidth.value))
}
function scaledSlotIconSize(box: RecipeLayoutSlotBox) {
return Math.max(1, Math.round(slotIconSize(box) * PREVIEW_ICON_SCALE))
}
function slotDisplayFor(slot: RecipeSlot): SlotDisplay | null {
const recipe = currentRecipe.value
const ctx = slotContext.value
return recipe && ctx ? getSlotDisplay(recipe.slots[slot], ctx) : null
}
function isSlotValue(value: unknown): value is SlotValue {
if (typeof value !== 'object' || value === null) return false
const candidate = value as { kind?: unknown; id?: unknown; uid?: unknown }
switch (candidate.kind) {
case 'item':
case 'vanilla_tag':
return typeof candidate.id === 'string'
case 'custom_item':
case 'custom_tag':
return typeof candidate.uid === 'string'
default:
return false
}
}
function parseSlotValue(raw: string): SlotValue | null {
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
return isSlotValue(parsed) ? parsed : null
} catch {
return null
}
}
function hasRecipePayload(event: DragEvent) {
const types = event.dataTransfer?.types
if (!types) return false
const typeList = Array.from(types)
return (
!typeList.includes('Files') &&
(typeList.includes(RECIPE_SLOT_MIME_TYPE) || typeList.includes('text/plain'))
)
}
function onHotspotClick(slot: RecipeSlot) {
const recipe = currentRecipe.value
if (!recipe || !recipe.slots[slot]) return
setSlot(slot, undefined)
}
function onSlotDropEvent(event: Event, slot: RecipeSlot) {
const detail = (event as CustomEvent<{ value?: unknown }>).detail
if (!detail || !isSlotValue(detail.value)) return
setSlot(slot, detail.value)
}
function onHotspotDragOver(event: DragEvent) {
const dataTransfer = event.dataTransfer
if (!dataTransfer) return
if (!hasRecipePayload(event)) {
dataTransfer.dropEffect = 'none'
return
}
event.preventDefault()
dataTransfer.dropEffect = 'copy'
}
function onHotspotDrop(event: DragEvent, slot: RecipeSlot) {
const dataTransfer = event.dataTransfer
if (!dataTransfer || !hasRecipePayload(event)) return
const raw = dataTransfer.getData(RECIPE_SLOT_MIME_TYPE) || dataTransfer.getData('text/plain')
const value = parseSlotValue(raw)
if (!value) return
event.preventDefault()
setSlot(slot, value)
}
function selectTrimPattern(id: string) {
const recipe = currentRecipe.value
if (recipe) recipe.smithing.trimPattern = id
}
function setSlotCount(slot: RecipeSlot, count: number) {
const value = currentRecipe.value?.slots[slot]
if (value && (value.kind === 'item' || value.kind === 'custom_item')) {
value.count = count
}
}
function resultWheelHint(slot: RecipeSlot) {
return slot === resultCountSlot.value ? resultWheelHintRef.value : undefined
}
function clearSlots() {
const recipe = currentRecipe.value
if (recipe) recipe.slots = {}
}
function placeFromPalette(value: SlotValue) {
const recipe = currentRecipe.value
if (!recipe) return
const firstEmpty = autoPlaceSlots.value.find((slot) => !recipe.slots[slot])
if (firstEmpty) {
recipe.slots[firstEmpty] = { ...value }
} else {
addNotification({ type: 'info', title: formatMessage(messages.gridFull) })
}
}
const cookingTimeIsDefault = computed(() => {
const recipe = currentRecipe.value
return !recipe || recipe.cooking.time === null
})
function toggleDefaultTime(value: boolean) {
const recipe = currentRecipe.value
if (!recipe) return
if (value) recipe.cooking.time = null
else if (recipe.recipeType in DEFAULT_COOKING_TIME) {
recipe.cooking.time =
DEFAULT_COOKING_TIME[recipe.recipeType as keyof typeof DEFAULT_COOKING_TIME]
}
}
function openCustomItemModal(item?: CustomItem) {
customItemDraft.uid = item?.uid ?? ''
customItemDraft.id = item?.id ?? ''
customItemDraft.name = item?.name ?? ''
customItemDraft.texture = item?.texture ?? ''
customItemModal.value?.show()
}
function saveCustomItem() {
const id = customItemDraft.id.trim()
if (!id) {
addNotification({ type: 'error', title: formatMessage(messages.invalidCustomItem) })
return
}
if (customItemDraft.uid) {
const index = store.customItems.findIndex((item) => item.uid === customItemDraft.uid)
if (index >= 0) {
store.customItems[index] = {
...store.customItems[index],
id,
name: customItemDraft.name.trim() || id,
texture: customItemDraft.texture.trim(),
}
}
} else {
store.customItems.push({
uid: crypto.randomUUID(),
id,
name: customItemDraft.name.trim() || id,
texture: customItemDraft.texture.trim(),
createdAt: new Date().toISOString(),
})
}
customItemModal.value?.hide()
addNotification({ type: 'success', title: formatMessage(messages.customItemSaved) })
}
function deleteCustomItem(uid: string) {
store.customItems = store.customItems.filter((item) => item.uid !== uid)
removeCustomReferences(uid)
addNotification({ type: 'success', title: formatMessage(messages.customItemDeleted) })
}
function addCustomTag(tag: CustomTag) {
store.customTags.push(tag)
}
function updateCustomTag(tag: CustomTag) {
const index = store.customTags.findIndex((entry) => entry.uid === tag.uid)
if (index >= 0) store.customTags[index] = tag
}
function deleteCustomTag(uid: string) {
store.customTags = store.customTags.filter((tag) => tag.uid !== uid)
removeCustomReferences(uid)
}
function removeCustomReferences(uid: string) {
for (const recipe of store.recipes) {
for (const [slot, value] of Object.entries(recipe.slots) as [RecipeSlot, SlotValue][]) {
if ((value.kind === 'custom_item' || value.kind === 'custom_tag') && value.uid === uid) {
Reflect.deleteProperty(recipe.slots, slot)
}
}
}
}
async function saveCurrentJson() {
if (!generatedJson.value || !naming.value) return
try {
const path = await saveJsonFile(generatedJson.value, `${naming.value.resolvedName}.json`)
if (path) addNotification({ type: 'success', title: formatMessage(messages.jsonSaved) })
} catch (error) {
handleError(error)
}
}
const previewImageCache = new Map<string, Promise<HTMLImageElement>>()
const PREVIEW_EXPORT_SCALE = 4
function loadPreviewImage(url: string): Promise<HTMLImageElement> {
const cached = previewImageCache.get(url)
if (cached) return cached
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error(`Unable to load preview image: ${url}`))
image.src = url
})
previewImageCache.set(url, promise)
return promise
}
async function drawPreviewIcon(
context: CanvasRenderingContext2D,
atlasImage: HTMLImageElement,
customImages: Map<string, HTMLImageElement>,
display: SlotDisplay,
box: RecipeLayoutSlotBox,
scale: number,
): Promise<{ x: number; y: number; size: number } | null> {
const size = Math.round(Math.min(box.x2 - box.x1, box.y2 - box.y1) * scale)
const x = Math.round(((box.x1 + box.x2) / 2) * scale - size / 2)
const y = Math.round(((box.y1 + box.y2) / 2) * scale - size / 2)
const iconCanvas = document.createElement('canvas')
iconCanvas.width = size
iconCanvas.height = size
const iconContext = iconCanvas.getContext('2d')
if (!iconContext) return null
iconContext.imageSmoothingEnabled = false
const region = display.texture ? TEXTURE_ATLAS.layout[display.texture] : undefined
if (region) {
const [ux, uy, uw, uh] = region
iconContext.drawImage(atlasImage, ux, uy, uw, uh, 0, 0, size, size)
} else if (display.texture) {
try {
let customImage = customImages.get(display.texture)
if (!customImage) {
customImage = await loadPreviewImage(display.texture)
customImages.set(display.texture, customImage)
}
iconContext.drawImage(customImage, 0, 0, size, size)
} catch {
// Unreachable custom textures are skipped.
return null
}
} else {
return null
}
context.drawImage(iconCanvas, x, y)
return { x, y, size }
}
async function createLayoutPreviewPngBlob(): Promise<Blob> {
const layout = recipeLayout.value
if (!layout) throw new Error('No recipe layout available')
const canvas = document.createElement('canvas')
canvas.width = RECIPE_IMAGE_WIDTH * PREVIEW_EXPORT_SCALE
canvas.height = RECIPE_IMAGE_HEIGHT * PREVIEW_EXPORT_SCALE
const context = canvas.getContext('2d')
if (!context) throw new Error('Unable to create a canvas context')
context.imageSmoothingEnabled = false
const background = await loadPreviewImage(layout.image)
context.drawImage(
background,
0,
0,
RECIPE_IMAGE_WIDTH * PREVIEW_EXPORT_SCALE,
RECIPE_IMAGE_HEIGHT * PREVIEW_EXPORT_SCALE,
)
const atlasImage = await loadPreviewImage(TEXTURE_ATLAS.url)
const customImages = new Map<string, HTMLImageElement>()
for (const entry of layoutSlots.value) {
const { box, targetSlot } = entry
if (entry.disabled) {
context.fillStyle = 'rgba(0, 0, 0, 0.58)'
context.fillRect(
box.x1 * PREVIEW_EXPORT_SCALE,
box.y1 * PREVIEW_EXPORT_SCALE,
(box.x2 - box.x1) * PREVIEW_EXPORT_SCALE,
(box.y2 - box.y1) * PREVIEW_EXPORT_SCALE,
)
const barrier = barrierDisplay.value
if (barrier?.texture) {
await drawPreviewIcon(context, atlasImage, customImages, barrier, box, PREVIEW_EXPORT_SCALE)
}
continue
}
const display = slotDisplayFor(targetSlot)
const drawnIcon = display?.texture
? await drawPreviewIcon(context, atlasImage, customImages, display, box, PREVIEW_EXPORT_SCALE)
: null
if (display?.count && display.count > 1 && drawnIcon) {
drawCountOnCanvas(context, display.count, drawnIcon.size, drawnIcon.x, drawnIcon.y)
}
}
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((result) => {
if (result) resolve(result)
else reject(new Error('Unable to encode preview PNG'))
}, 'image/png')
})
}
async function copyPreviewImage() {
if (!currentRecipe.value || !slotContext.value) return
try {
const blob = await createLayoutPreviewPngBlob()
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
addNotification({ type: 'success', title: formatMessage(messages.previewImageCopied) })
} catch (error) {
handleError(error)
}
}
async function saveAs() {
if (!slotContext.value) return
try {
const files = buildDatapackFiles()
if (!files) return
const path = await saveDatapackAs(
files,
pendingDatapack.value?.fileName ?? createDatapackFileName(store.selectedVersion),
)
if (path) addNotification({ type: 'success', title: formatMessage(messages.datapackSaved) })
} catch (error) {
handleError(error)
}
}
async function exportDatapack() {
if (!buildDatapackFiles()) return
instanceExportModal.value?.show()
}
function buildDatapackFiles(): PackFile[] | null {
if (!slotContext.value) return null
const invalidCount = store.recipes.filter(
(recipe) => validateRecipe(recipe, store.selectedVersion, slotContext.value!).length > 0,
).length
if (invalidCount > 0) {
addNotification({ type: 'error', title: formatMessage(messages.exportDatapackInvalid) })
return null
}
const namingMap = new Map(
store.recipes.map((recipe) => [
recipe.id,
getCurrentRecipeName(recipe, store.recipes, slotContext.value!).resolvedName,
]),
)
const recipes: DatapackRecipe[] = store.recipes.map((recipe) => ({
name: namingMap.get(recipe.id) ?? 'recipe',
json: generateJavaRecipe(recipe, store.selectedVersion, slotContext.value!),
}))
const tags = store.customTags.map((tag) => {
const ref = parseIdentifier(tag.id)
return {
namespace: ref.namespace,
id: ref.id,
values: tag.values.map((value) => (value.type === 'tag' ? `#${value.id}` : value.id)),
}
})
try {
const productNames = store.recipes.map((recipe) => sidebarTitle(recipe))
const description = createDatapackDescription(productNames)
const files = createDatapackFiles(store.selectedVersion, recipes, tags, description)
const fileName = createDatapackFileName(store.selectedVersion)
pendingDatapack.value = { files, fileName }
return files
} catch (error) {
handleError(error)
return null
}
}
async function installDatapackToWorld(target: { instanceId: string; worldPath: string }) {
const pending = pendingDatapack.value
if (!pending) return
try {
await exportDatapackToWorld(
target.instanceId,
target.worldPath,
pending.files,
pending.fileName,
)
addNotification({ type: 'success', title: formatMessage(messages.exportDatapackDone) })
} catch (error) {
handleError(error)
}
}
function sidebarTitle(recipe: RecipeState): string {
const resolved = slotContext.value
? getCurrentRecipeName(recipe, store.recipes, slotContext.value).sidebarTitle
: ''
return resolved || formatMessage(recipeTypeMessages[recipe.recipeType])
}
function issueLabel(code: RecipeIssueCode) {
return formatMessage(issueMessages[code])
}
function slotEditorSlots(type: RecipeType): RecipeSlot[] {
switch (type) {
case 'crafting':
return [...CRAFTING_GRID_SLOTS, 'crafting.result']
case 'smelting':
case 'blasting':
case 'smoking':
case 'campfire_cooking':
return ['cooking.ingredient', 'cooking.result']
case 'stonecutter':
return ['stonecutter.ingredient', 'stonecutter.result']
case 'smithing':
case 'smithing_transform':
return ['smithing.template', 'smithing.base', 'smithing.addition', 'smithing.result']
case 'smithing_trim':
return ['smithing.template', 'smithing.base', 'smithing.addition']
}
}
</script>
<template>
<main class="recipe-generator-page" data-onboarding-id="recipe-generator-workspace">
<header class="recipe-generator-header">
<h1 class="recipe-generator-title">{{ formatMessage(messages.title) }}</h1>
<div class="recipe-header-actions">
<div class="recipe-version-control">
<span class="recipe-field-label">{{ formatMessage(messages.version) }}</span>
<DropdownSelect
v-model="store.selectedVersion"
:options="JAVA_VERSIONS.map((version) => version.id)"
:display-name="versionLabel"
name="Minecraft version"
:max-visible-options="9"
class="recipe-version-dropdown"
/>
</div>
<ButtonStyled size="small" type="outlined">
<button @click="copyrightModal?.show()">
{{ formatMessage(messages.copyright) }}
</button>
</ButtonStyled>
</div>
</header>
<div v-if="loadingResources" class="recipe-resource-state">
<span class="recipe-resource-spinner" aria-hidden="true"></span>
{{ formatMessage(messages.loadingResources) }}
</div>
<div v-else-if="resourceError" class="recipe-resource-state recipe-resource-error text-red">
{{ formatMessage(messages.resourceError) }}
<span class="font-mono text-xs">{{ resourceError }}</span>
</div>
<div v-else-if="resources && slotContext" class="recipe-workbench">
<aside class="lab-panel recipe-sidebar">
<div class="recipe-sidebar-heading">
<h2>{{ formatMessage(messages.recipesTitle) }}</h2>
<ButtonStyled size="small" color="brand">
<button @click="newRecipe"><PlusIcon />{{ formatMessage(messages.newRecipe) }}</button>
</ButtonStyled>
</div>
<div class="recipe-sidebar-list">
<div
v-for="recipe in store.recipes"
:key="recipe.id"
class="recipe-sidebar-row focus-within:border-brand"
:class="{ active: recipe.id === store.selectedRecipeId }"
>
<button
type="button"
class="recipe-sidebar-select"
@click="store.selectedRecipeId = recipe.id"
>
<strong>{{ sidebarTitle(recipe) }}</strong>
<small>{{
formatMessage(messages.recipeName, {
name: getCurrentRecipeName(recipe, store.recipes, slotContext).resolvedName,
})
}}</small>
</button>
<span class="recipe-row-actions">
<button
type="button"
:title="formatMessage(messages.cloneRecipe)"
:aria-label="formatMessage(messages.cloneRecipe)"
@click.stop="cloneRecipe"
>
<PlusIcon />
</button>
<button
type="button"
:title="formatMessage(messages.deleteRecipe)"
:aria-label="formatMessage(messages.deleteRecipe)"
@click.stop="deleteRecipe"
>
<TrashIcon />
</button>
</span>
</div>
</div>
<div class="recipe-sidebar-footer">
<ButtonStyled color="brand">
<button class="recipe-export-datapack w-full" @click="exportDatapack">
<DownloadIcon />{{ formatMessage(messages.exportDatapack) }}
</button>
</ButtonStyled>
</div>
</aside>
<section ref="recipeEditor" class="lab-panel recipe-editor overflow-visible">
<div class="lab-panel-section p-4 recipe-options-section">
<div class="recipe-section-heading">
<h2>{{ formatMessage(messages.optionsTitle) }}</h2>
</div>
<div class="recipe-option-grid">
<div class="recipe-field">
<span class="recipe-field-label">{{ formatMessage(messages.recipeType) }}</span>
<DropdownSelect
:model-value="currentRecipe?.recipeType"
:options="availableTypes"
:display-name="recipeTypeLabel"
name="Recipe type"
class="w-full"
@update:model-value="setRecipeType(String($event) as RecipeType)"
/>
</div>
<div class="recipe-field">
<span class="recipe-field-label">{{ formatMessage(messages.group) }}</span>
<StyledInput
v-model="currentRecipe.group"
:placeholder="formatMessage(messages.group)"
size="small"
/>
</div>
<div v-if="showCategory" class="recipe-field">
<span class="recipe-field-label">{{ formatMessage(messages.category) }}</span>
<DropdownSelect
v-model="currentRecipe.category"
:options="categoryOptions"
:display-name="categoryLabel"
name="Recipe category"
:default-value="''"
class="w-full"
/>
</div>
<div class="recipe-field">
<span class="recipe-field-label">{{ formatMessage(messages.fileName) }}</span>
<DropdownSelect
v-model="currentRecipe.nameMode"
:options="['auto', 'manual']"
:display-name="nameModeLabel"
name="File name mode"
class="w-full"
/>
</div>
<div v-if="currentRecipe.nameMode === 'manual'" class="recipe-field">
<span class="recipe-field-label">{{ formatMessage(messages.fileName) }}</span>
<StyledInput
v-model="currentRecipe.name"
:placeholder="formatMessage(messages.manualNamePlaceholder)"
size="small"
/>
</div>
</div>
<div v-if="currentRecipe?.recipeType === 'crafting'" class="recipe-option-grid">
<Checkbox
:model-value="currentRecipe.crafting.shapeless"
:label="formatMessage(messages.shapeless)"
@update:model-value="currentRecipe.crafting.shapeless = Boolean($event)"
/>
<Checkbox
:model-value="currentRecipe.crafting.twoByTwo"
:label="formatMessage(messages.twoByTwo)"
@update:model-value="setTwoByTwo(Boolean($event))"
/>
<Checkbox
:model-value="currentRecipe.crafting.keepWhitespace"
:disabled="currentRecipe.crafting.shapeless"
:label="formatMessage(messages.keepWhitespace)"
@update:model-value="currentRecipe.crafting.keepWhitespace = Boolean($event)"
/>
</div>
<div
v-if="
currentRecipe?.recipeType === 'smelting' ||
currentRecipe?.recipeType === 'blasting' ||
currentRecipe?.recipeType === 'smoking' ||
currentRecipe?.recipeType === 'campfire_cooking'
"
class="recipe-option-grid"
>
<div class="recipe-field">
<span class="recipe-field-label">{{ formatMessage(messages.experience) }}</span>
<StyledInput
:model-value="String(currentRecipe.cooking.experience)"
:input-attrs="{ type: 'number', min: 0, step: 0.05 }"
size="small"
@update:model-value="
currentRecipe.cooking.experience = Math.max(0, Number($event) || 0)
"
/>
</div>
<div class="recipe-field">
<span class="recipe-field-label">{{ formatMessage(messages.cookingTime) }}</span>
<StyledInput
:model-value="
currentRecipe.cooking.time === null
? String(
DEFAULT_COOKING_TIME[
currentRecipe.recipeType as keyof typeof DEFAULT_COOKING_TIME
],
)
: String(currentRecipe.cooking.time)
"
:disabled="cookingTimeIsDefault"
:input-attrs="{ type: 'number', min: 1 }"
size="small"
@update:model-value="
currentRecipe.cooking.time = Math.max(1, Math.round(Number($event) || 1))
"
/>
</div>
<Checkbox
:model-value="cookingTimeIsDefault"
:label="formatMessage(messages.defaultTime)"
@update:model-value="toggleDefaultTime(Boolean($event))"
/>
</div>
<label v-if="showNotificationOption" class="recipe-toggle-row">
<span>{{ formatMessage(messages.showNotification) }}</span>
<Toggle v-model="currentRecipe.showNotification" small />
</label>
<span v-if="naming" class="recipe-auto-name">
{{ formatMessage(messages.autoName, { name: naming.resolvedName }) }}
</span>
</div>
<div class="lab-panel-section p-4">
<div class="recipe-section-heading">
<h2>{{ formatMessage(messages.previewTitle) }}</h2>
<div class="recipe-preview-actions">
<ButtonStyled size="small" type="transparent">
<button class="recipe-clear-slots" @click="clearSlots">
{{ formatMessage(messages.clearSlots) }}
</button>
</ButtonStyled>
<ButtonStyled size="small" color="brand">
<button
class="recipe-copy-preview"
:disabled="!currentRecipe || !slotContext"
@click="copyPreviewImage"
>
<ClipboardCopyIcon />{{ formatMessage(messages.copyPreviewImage) }}
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="recipeLayout"
ref="layoutStage"
class="recipe-layout-stage"
:style="{ backgroundImage: `url('${recipeLayout.image}')` }"
>
<template v-for="entry in layoutSlots" :key="entry.layoutSlot">
<div
v-if="entry.disabled"
class="recipe-layout-barrier"
:style="slotBoxStyle(entry.box)"
aria-hidden="true"
>
<span v-if="barrierDisplay" class="recipe-layout-barrier-icon flex">
<RecipeItemIcon
:display="barrierDisplay"
:atlas="TEXTURE_ATLAS"
:size="slotIconSize(entry.box) + 2"
:show-count="false"
/>
</span>
</div>
<div
v-else
v-tooltip="resultWheelHint(entry.targetSlot)"
class="recipe-layout-hotspot"
:data-recipe-slot="entry.targetSlot"
:style="slotBoxStyle(entry.box)"
@click="onHotspotClick(entry.targetSlot)"
@axolotl-recipe-slot-drop="onSlotDropEvent($event, entry.targetSlot)"
@dragover="onHotspotDragOver"
@drop="onHotspotDrop($event, entry.targetSlot)"
@wheel="onResultSlotWheel(entry.targetSlot, $event)"
>
<span v-if="slotDisplayFor(entry.targetSlot)" class="recipe-layout-icon">
<RecipeItemIcon
:display="slotDisplayFor(entry.targetSlot)"
:atlas="TEXTURE_ATLAS"
:size="scaledSlotIconSize(entry.box) + 2"
/>
</span>
</div>
</template>
</div>
<RecipeSlotGrid
v-if="!recipeLayout && currentRecipe.recipeType === 'crafting'"
:slots="[...CRAFTING_GRID_SLOTS, 'crafting.result']"
:values="currentRecipe.slots"
:ctx="slotContext"
:atlas="TEXTURE_ATLAS"
variant="crafting"
:two-by-two="currentRecipe.crafting.twoByTwo"
@update-slot="setSlot"
@update-count="setSlotCount"
/>
<RecipeSlotGrid
v-else-if="!recipeLayout"
:slots="slotEditorSlots(currentRecipe.recipeType)"
:values="currentRecipe.slots"
:ctx="slotContext"
:atlas="TEXTURE_ATLAS"
variant="row"
@update-slot="setSlot"
@update-count="setSlotCount"
/>
<div v-if="showTrimPattern" class="recipe-trim-pattern-selector">
<span class="recipe-field-label">{{ formatMessage(messages.trimPatternLabel) }}</span>
<div class="recipe-trim-pattern-grid">
<button
v-for="option in trimPatternOptions"
:key="option.pattern"
type="button"
class="recipe-trim-pattern-option"
:class="{ active: currentRecipe.smithing.trimPattern === option.id }"
:title="option.display.label"
:aria-label="option.display.label"
@click="selectTrimPattern(option.id)"
>
<RecipeItemIcon
:display="option.display"
:atlas="TEXTURE_ATLAS"
:size="30"
:show-count="false"
/>
<span>{{ option.display.label }}</span>
</button>
</div>
</div>
<div v-if="issues.length" class="recipe-issues">
<strong>
{{
formatMessage(
issues.length === 1 ? messages.recipeErrors : messages.recipeErrorsPlural,
{ count: issues.length },
)
}}
</strong>
<ul>
<li v-for="(issue, index) in issues" :key="index">
{{ issueLabel(issue.code) }}
</li>
</ul>
</div>
</div>
<div class="lab-panel-section p-4">
<div class="recipe-section-heading">
<h2>{{ formatMessage(messages.outputTitle) }}</h2>
<div class="recipe-output-actions">
<ButtonStyled size="small" color="brand">
<button :disabled="!jsonText" @click="saveCurrentJson">
<SaveIcon />{{ formatMessage(messages.saveJson) }}
</button>
</ButtonStyled>
</div>
</div>
<textarea
readonly
:value="jsonText"
:aria-label="formatMessage(messages.outputTitle)"
class="recipe-json-output"
:placeholder="formatMessage(messages.jsonPlaceholder)"
></textarea>
</div>
</section>
<aside
class="lab-panel recipe-palette"
:style="
recipePaletteMaxHeight
? { '--recipe-palette-max-height': recipePaletteMaxHeight }
: undefined
"
>
<div class="recipe-palette-heading">
<div class="recipe-tag-tabs" role="tablist">
<button
type="button"
role="tab"
:aria-selected="rightTab === 'items'"
:class="{ active: rightTab === 'items' }"
@click="rightTab = 'items'"
>
{{ formatMessage(messages.itemsTab) }}
</button>
<button
type="button"
role="tab"
:aria-selected="rightTab === 'tags'"
:class="{ active: rightTab === 'tags' }"
@click="rightTab = 'tags'"
>
{{ formatMessage(messages.tagsTab) }}
</button>
</div>
<ButtonStyled v-if="rightTab === 'items'" size="small" type="outlined">
<button @click="openCustomItemModal()">
<PlusIcon />{{ formatMessage(messages.addCustomItem) }}
</button>
</ButtonStyled>
</div>
<ItemPalette
v-if="rightTab === 'items'"
:entries="paletteEntries"
:atlas="TEXTURE_ATLAS"
:loading="loadingResources"
@pick="placeFromPalette"
/>
<TagPalette
v-else
:vanilla-tags="showVanillaTags ? resources.vanillaTags : {}"
:custom-tags="showCustomTags ? store.customTags : []"
:ctx="slotContext"
:atlas="TEXTURE_ATLAS"
@pick="placeFromPalette"
@add-custom-tag="addCustomTag"
@update-custom-tag="updateCustomTag"
@delete-custom-tag="deleteCustomTag"
/>
<div
v-if="rightTab === 'items' && store.customItems.length"
class="recipe-custom-item-list"
>
<h3>{{ formatMessage(messages.customItemsTitle) }}</h3>
<div v-for="item in store.customItems" :key="item.uid" class="recipe-custom-item-row">
<RecipeItemIcon
:display="{ label: item.name, texture: item.texture || null, isTag: false }"
:atlas="TEXTURE_ATLAS"
:size="24"
:show-count="false"
/>
<span class="min-w-0 flex-1 truncate text-xs">{{ item.name }}</span>
<button
type="button"
class="recipe-delete-custom"
:title="formatMessage(messages.editCustomItemAction)"
:aria-label="formatMessage(messages.editCustomItemAction)"
@click="openCustomItemModal(item)"
>
<PencilIcon />
</button>
<button
type="button"
class="recipe-delete-custom"
:title="formatMessage(messages.deleteCustomItem)"
:aria-label="formatMessage(messages.deleteCustomItem)"
@click="deleteCustomItem(item.uid)"
>
<TrashIcon />
</button>
</div>
</div>
</aside>
</div>
<ModalWrapper
ref="customItemModal"
:header="
formatMessage(customItemDraft.uid ? messages.editCustomItem : messages.addCustomItem)
"
>
<div class="custom-item-form">
<label class="recipe-field">
<span>{{ formatMessage(messages.customItemId) }}</span>
<StyledInput
v-model="customItemDraft.id"
:placeholder="formatMessage(messages.customItemIdPlaceholder)"
/>
</label>
<label class="recipe-field">
<span>{{ formatMessage(messages.customItemName) }}</span>
<StyledInput v-model="customItemDraft.name" />
</label>
<label class="recipe-field">
<span>{{ formatMessage(messages.customItemTexture) }}</span>
<StyledInput
v-model="customItemDraft.texture"
:placeholder="formatMessage(messages.customTexturePlaceholder)"
/>
</label>
<div class="flex justify-end gap-2">
<ButtonStyled size="small" type="outlined">
<button @click="customItemModal?.hide()">{{ formatMessage(messages.cancel) }}</button>
</ButtonStyled>
<ButtonStyled size="small" color="brand">
<button @click="saveCustomItem">{{ formatMessage(messages.save) }}</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
<RecipeGeneratorCopyrightModal ref="copyrightModal" />
<InstanceExportModal
ref="instanceExportModal"
@select="installDatapackToWorld"
@save-as="saveAs"
/>
</main>
</template>
<style scoped>
.recipe-generator-page {
--recipe-title-size: 1.5rem;
--recipe-panel-title-size: 1rem;
--recipe-label-size: 0.7rem;
--recipe-body-size: 0.875rem;
--recipe-mono-size: 0.75rem;
--color-surface-1: var(--surface-1);
--color-surface-2: var(--surface-2);
--color-surface-3: var(--surface-3);
--color-surface-4: var(--surface-4);
--color-surface-5: var(--surface-5);
display: flex;
width: 100%;
max-width: 90rem;
min-height: 0;
flex-direction: column;
gap: 1.25rem;
margin-inline: auto;
padding: 1.5rem;
container-type: inline-size;
container-name: recipe-generator-page;
}
:global(.recipe-slot-drag-ghost) {
--color-surface-2: var(--surface-2);
}
.recipe-generator-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.recipe-generator-title {
margin: 0;
min-width: 0;
color: var(--color-contrast);
font-size: var(--recipe-title-size);
font-weight: 700;
line-height: 1.25;
}
.recipe-header-actions {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.5rem;
}
.recipe-version-control {
position: relative;
z-index: 0;
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.35rem;
color: var(--color-secondary);
font-size: var(--recipe-label-size);
font-weight: 700;
}
.recipe-version-control:focus-within {
z-index: 2;
}
.recipe-version-control :deep(.animated-dropdown) {
width: min(20rem, 30vw);
}
.recipe-version-control :deep(.options-wrapper) {
overflow: hidden;
}
.recipe-version-control :deep(.options) {
z-index: 30;
}
.recipe-generator-page :deep(.options-wrapper.down) {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.recipe-generator-page :deep(.options) {
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.recipe-resource-state {
display: flex;
min-height: 14rem;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.65rem;
border: 1px solid var(--color-surface-5);
border-radius: var(--radius-lg);
background: var(--color-surface-2);
color: var(--color-secondary);
font-size: var(--recipe-body-size);
text-align: center;
}
.recipe-resource-spinner {
width: 1.5rem;
height: 1.5rem;
border: 2px solid var(--color-surface-5);
border-top-color: var(--color-brand);
border-radius: 50%;
animation: recipe-spin 0.8s linear infinite;
}
@keyframes recipe-spin {
to {
transform: rotate(360deg);
}
}
.recipe-workbench {
display: grid;
grid-template-columns: minmax(14rem, 0.55fr) minmax(26rem, 1.45fr) minmax(18rem, 0.9fr);
align-items: start;
gap: 1.25rem;
}
.lab-panel {
min-width: 0;
overflow: hidden;
border: 1px solid var(--surface-5);
border-radius: var(--radius-lg);
background: var(--surface-2);
box-shadow: var(--shadow-card);
}
.lab-panel-section + .lab-panel-section {
border-top: 1px solid var(--color-surface-5);
}
.recipe-sidebar {
display: flex;
min-width: 0;
flex-direction: column;
}
.recipe-sidebar-heading {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid var(--color-surface-5);
padding: 0.85rem 1rem;
}
.recipe-sidebar-heading h2,
.recipe-section-heading h2 {
margin: 0;
color: var(--color-contrast);
font-size: var(--recipe-panel-title-size);
font-weight: 700;
}
.recipe-sidebar-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem;
}
.recipe-sidebar-row {
display: flex;
min-width: 0;
align-items: stretch;
gap: 0.25rem;
overflow: hidden;
border: 1px solid var(--color-surface-5);
border-radius: var(--radius-sm);
background: var(--color-surface-4);
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.recipe-sidebar-row.active {
border-color: var(--color-brand);
background: var(--color-brand-highlight);
}
.recipe-sidebar-select {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.15rem;
border: 0;
background: transparent;
padding: 0.5rem 0.55rem;
color: var(--color-contrast);
cursor: pointer;
text-align: left;
}
.recipe-sidebar-select:focus-visible {
outline: none;
}
.recipe-sidebar-select strong {
overflow: hidden;
font-size: 0.8rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipe-sidebar-select small {
overflow: hidden;
color: var(--color-secondary);
font-family: monospace;
font-size: 0.7rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipe-row-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.15rem;
padding: 0.3rem 0.35rem;
}
.recipe-row-actions button,
.recipe-delete-custom {
display: inline-flex;
width: 1.75rem;
height: 1.75rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
padding: 0;
color: var(--color-secondary);
cursor: pointer;
}
.recipe-row-actions button:hover,
.recipe-delete-custom:hover {
background: var(--color-red-highlight);
color: var(--color-red);
}
.recipe-row-actions svg,
.recipe-delete-custom svg {
width: 0.9rem;
height: 0.9rem;
}
.recipe-sidebar-footer {
border-top: 1px solid var(--color-surface-5);
padding: 0.85rem 1rem;
}
.recipe-editor :deep(.recipe-slot-row .recipe-slot-cell),
.recipe-editor :deep(.recipe-crafting-editor .recipe-result-column .recipe-slot-cell) {
min-height: 7rem;
}
.recipe-editor :deep(.recipe-slot-row .recipe-slot-cell[data-recipe-slot='cooking.result']),
.recipe-editor :deep(.recipe-slot-row .recipe-slot-cell[data-recipe-slot='stonecutter.result']),
.recipe-editor :deep(.recipe-slot-row .recipe-slot-cell[data-recipe-slot='smithing.result']) {
position: relative;
}
.recipe-editor :deep(.recipe-slot-row .recipe-slot-cell[data-recipe-slot='cooking.result'])::before,
.recipe-editor
:deep(.recipe-slot-row .recipe-slot-cell[data-recipe-slot='stonecutter.result'])::before,
.recipe-editor
:deep(.recipe-slot-row .recipe-slot-cell[data-recipe-slot='smithing.result'])::before {
content: '→';
position: absolute;
left: -0.9rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-secondary);
font-size: 1.1rem;
line-height: 1;
pointer-events: none;
}
.recipe-editor :deep(.recipe-crafting-editor .recipe-result-column) {
position: relative;
padding-left: 2rem;
}
.recipe-editor :deep(.recipe-crafting-editor .recipe-result-column)::before {
content: '→';
position: absolute;
left: 0.5rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-secondary);
font-size: 1.1rem;
line-height: 1;
pointer-events: none;
}
.recipe-options-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.recipe-section-heading {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.recipe-preview-actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
}
.recipe-option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10.5rem), 1fr));
align-items: end;
gap: 0.75rem 1rem;
}
.recipe-field {
position: relative;
z-index: 0;
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.3rem;
color: var(--color-secondary);
}
.recipe-field-label {
font-size: var(--recipe-label-size);
font-weight: 700;
line-height: 1.2;
}
.recipe-field:focus-within {
z-index: 2;
}
.recipe-field :deep(.animated-dropdown),
.recipe-field :deep(.relative) {
width: 100%;
}
.recipe-field :deep(.options-wrapper) {
overflow: hidden;
}
.recipe-field :deep(.options) {
z-index: 30;
}
.recipe-generator-page :deep(.options-enter-active),
.recipe-generator-page :deep(.options-leave-active) {
transition: opacity 0.15s ease !important;
}
.recipe-generator-page :deep(.options-enter-from),
.recipe-generator-page :deep(.options-leave-to) {
opacity: 0 !important;
transform: none !important;
}
.recipe-generator-page :deep(.options-enter-to),
.recipe-generator-page :deep(.options-leave-from) {
opacity: 1 !important;
transform: none !important;
}
.recipe-toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-contrast);
font-size: var(--recipe-body-size);
}
.recipe-layout-stage {
position: relative;
width: 100%;
margin-top: 0.75rem;
aspect-ratio: 696 / 292;
background-repeat: no-repeat;
background-position: center;
background-size: 100% 100%;
}
.recipe-layout-hotspot {
position: absolute;
margin: 0;
border: 0;
background: transparent;
padding: 0;
cursor: pointer;
}
.recipe-layout-barrier {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: rgb(0 0 0 / 0.58);
pointer-events: none;
}
.recipe-layout-barrier-icon :deep(.recipe-item-icon) {
border: 1px solid transparent !important;
background: transparent !important;
}
.recipe-layout-icon {
position: absolute;
left: 50%;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%);
pointer-events: none;
}
.recipe-layout-icon :deep(.recipe-item-icon) {
border: 1px solid transparent !important;
background: transparent !important;
}
.recipe-trim-pattern-selector {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-top: 0.85rem;
}
.recipe-trim-pattern-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 4.5rem), 1fr));
gap: 0.4rem;
}
.recipe-trim-pattern-option {
display: flex;
min-width: 0;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.2rem;
overflow: hidden;
border: 1px solid var(--color-surface-5);
border-radius: var(--radius-sm);
background: var(--color-surface-4);
padding: 0.3rem 0.2rem;
color: var(--color-secondary);
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.recipe-trim-pattern-option:hover,
.recipe-trim-pattern-option:focus-visible {
border-color: var(--color-brand);
background: var(--color-surface-3);
outline: none;
}
.recipe-trim-pattern-option.active {
border-color: var(--color-brand);
background: var(--color-brand-highlight);
color: var(--color-contrast);
}
.recipe-trim-pattern-option > span {
max-width: 100%;
overflow: hidden;
font-size: 0.58rem;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipe-trim-pattern-option :deep(.recipe-item-icon) {
border: 1px solid transparent !important;
background: transparent !important;
}
.recipe-auto-name {
align-self: flex-start;
color: var(--color-secondary);
font-family: monospace;
font-size: var(--recipe-mono-size);
}
.recipe-crafting-editor {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding-top: 0.25rem;
}
.recipe-crafting-grid {
display: grid;
grid-template-columns: repeat(3, 3.75rem);
grid-auto-rows: 3.75rem;
gap: 0.45rem;
border: 1px solid var(--color-surface-5);
border-radius: var(--radius-md);
background: var(--color-surface-1);
padding: 0.6rem;
}
.recipe-crafting-grid.is-two-by-two {
grid-template-columns: repeat(2, 3.75rem);
grid-auto-rows: 3.75rem;
}
.recipe-slot-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding-top: 0.25rem;
}
.recipe-result-column {
display: flex;
align-items: center;
padding-left: 1.5rem;
border-left: 1px solid var(--color-surface-5);
}
.recipe-slot-cell {
display: flex;
min-width: 0;
flex-direction: column;
align-items: center;
gap: 0.35rem;
}
.recipe-slot-button {
display: flex;
width: 3.25rem;
height: 3.25rem;
align-items: center;
justify-content: center;
border: 2px solid var(--color-surface-5);
border-radius: var(--radius-sm);
background: var(--color-surface-2);
padding: 2px;
box-shadow:
inset 1px 1px 0 rgb(0 0 0 / 20%),
inset -1px -1px 0 rgb(255 255 255 / 10%);
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease;
}
.recipe-slot-button:hover {
border-color: var(--color-brand);
background: var(--color-surface-3);
}
.recipe-slot-button:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
.recipe-result-button {
border-color: color-mix(in srgb, var(--color-brand) 55%, var(--color-surface-5));
}
.recipe-issues {
margin-top: 0.85rem;
border: 1px solid var(--color-red);
border-radius: var(--radius-md);
background: var(--color-red-highlight);
padding: 0.6rem 0.75rem;
color: var(--color-red);
font-size: 0.8rem;
}
.recipe-issues ul {
margin: 0.35rem 0 0;
padding-left: 1.1rem;
}
.recipe-output-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
.recipe-json-output {
width: 100%;
min-height: 12rem;
margin-top: 0.75rem;
resize: vertical;
border: 1px solid var(--color-surface-5);
border-radius: var(--radius-sm);
background: var(--color-surface-1);
padding: 0.7rem;
color: var(--color-contrast);
font-family: monospace;
font-size: var(--recipe-mono-size);
line-height: 1.5;
outline: none;
}
.recipe-palette {
display: flex;
min-width: 0;
min-height: 0;
height: auto;
max-height: var(--recipe-palette-max-height, 61rem);
flex-direction: column;
container-type: inline-size;
container-name: recipe-palette;
}
.recipe-palette-heading {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-bottom: 1px solid var(--color-surface-5);
padding: 0.75rem;
}
:deep(.recipe-palette-grid) {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 4.25rem;
gap: 0.5rem;
align-content: stretch;
}
:deep(.recipe-palette-item) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.2rem;
border: 0 !important;
box-shadow: var(--shadow-card);
padding: 0.2rem 0.1rem;
}
:deep(.recipe-palette-name) {
width: 100%;
min-width: 0;
color: var(--color-contrast);
font-size: 0.7rem;
line-height: 1.1;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.recipe-palette-item .recipe-item-icon) {
border: 1px solid transparent !important;
}
.recipe-tag-tabs {
display: flex;
min-width: 0;
gap: 0.25rem;
border: 1px solid var(--color-surface-5);
border-radius: var(--radius-sm);
background: var(--color-surface-3);
padding: 0.2rem;
}
.recipe-tag-tabs button {
flex: 1;
border: 0;
border-radius: calc(var(--radius-sm) - 1px);
background: transparent;
padding: 0.4rem 0.55rem;
color: var(--color-secondary);
cursor: pointer;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
}
.recipe-tag-tabs button.active {
background: var(--color-brand);
color: var(--color-accent-contrast);
}
.recipe-custom-item-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
border-top: 1px solid var(--color-surface-5);
padding: 0.75rem;
}
.recipe-custom-item-list h3 {
margin: 0;
color: var(--color-secondary);
font-size: var(--recipe-label-size);
text-transform: uppercase;
}
.recipe-custom-item-row {
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--color-surface-5);
border-radius: var(--radius-sm);
background: var(--color-surface-4);
padding: 0.3rem 0.4rem;
}
.custom-item-form {
display: flex;
width: min(26rem, calc(100vw - 3rem));
flex-direction: column;
gap: 0.75rem;
}
@media (max-width: 68rem) {
.recipe-workbench {
grid-template-columns: minmax(0, 1fr);
}
.recipe-sidebar {
position: static;
height: auto;
min-height: 0;
max-height: none;
}
.recipe-palette {
position: static;
height: auto;
min-height: 0;
max-height: min(36rem, calc(100dvh - 5rem));
}
.recipe-generator-header {
flex-direction: column;
}
.recipe-header-actions {
width: 100%;
}
.recipe-version-control,
.recipe-version-control :deep(.animated-dropdown),
.recipe-version-control :deep(.options-wrapper) {
width: 100%;
}
.recipe-header-actions > :deep(.btn-wrapper) {
width: 100%;
}
.recipe-header-actions > :deep(.btn-wrapper button) {
width: 100%;
justify-content: center;
}
}
@container recipe-generator-page (max-width: 62rem) {
.recipe-workbench {
grid-template-columns: minmax(0, 1fr);
}
.recipe-sidebar {
position: static;
height: auto;
min-height: 0;
max-height: none;
}
.recipe-palette {
position: static;
height: auto;
min-height: 0;
max-height: min(36rem, calc(100dvh - 5rem));
}
}
@container recipe-palette (max-width: 16rem) {
:deep(.recipe-palette-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 40rem) {
:deep(.recipe-palette-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 32rem) {
.recipe-crafting-editor {
flex-direction: column;
}
.recipe-result-column {
padding-left: 0;
border-left: 0;
}
.recipe-slot-row {
gap: 0.5rem;
}
.recipe-slot-button {
width: 3rem;
height: 3rem;
}
}
</style>