@terrafirmagreg-team/recipe 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/package.json +62 -3
  2. package/src/components/button-copy.tsx +43 -0
  3. package/src/components/cards/card/index.tsx +214 -0
  4. package/src/components/cards/card/scene.tsx +107 -0
  5. package/src/components/cards/card/strip.tsx +198 -0
  6. package/src/components/cards/card/text.tsx +46 -0
  7. package/src/components/cards/card/tips.tsx +63 -0
  8. package/src/components/cards/default-item.tsx +47 -0
  9. package/src/components/cards/glare-filter.tsx +29 -0
  10. package/src/components/cards/paged-card.tsx +222 -0
  11. package/src/components/cards/slot-hitbox.tsx +213 -0
  12. package/src/components/cards/strip-card/buttons.tsx +106 -0
  13. package/src/components/cards/strip-card/index.tsx +214 -0
  14. package/src/components/cards/strip-card/member-cell.tsx +167 -0
  15. package/src/components/cards/strip-card/sprite.tsx +36 -0
  16. package/src/components/embed-card.tsx +44 -0
  17. package/src/components/mdx/recipe.tsx +129 -0
  18. package/src/components/tool-list.tsx +49 -0
  19. package/src/components/tree.tsx +98 -0
  20. package/src/components/views/alloy/index.tsx +112 -0
  21. package/src/components/views/alloy/list.tsx +85 -0
  22. package/src/components/views/alloy/mix/index.tsx +35 -0
  23. package/src/components/views/alloy/mix/ratio.tsx +94 -0
  24. package/src/components/views/alloy/mix/rows.tsx +145 -0
  25. package/src/components/views/alloy/mix/status.tsx +122 -0
  26. package/src/components/views/alloy/mix/total.tsx +132 -0
  27. package/src/components/views/alloy/range-bar.tsx +65 -0
  28. package/src/components/views/alloy/stack-bar.tsx +88 -0
  29. package/src/components/views/browser/axis-switcher.tsx +54 -0
  30. package/src/components/views/browser/card-grid.tsx +275 -0
  31. package/src/components/views/browser/favorite-button.tsx +46 -0
  32. package/src/components/views/browser/frame.tsx +112 -0
  33. package/src/components/views/browser/index.tsx +114 -0
  34. package/src/components/views/browser/item-panel/index.tsx +229 -0
  35. package/src/components/views/browser/item-panel/skeleton.tsx +26 -0
  36. package/src/components/views/browser/item-panel-grid/cell.tsx +178 -0
  37. package/src/components/views/browser/item-panel-grid/favorite-cell.tsx +129 -0
  38. package/src/components/views/browser/item-panel-grid/index.tsx +105 -0
  39. package/src/components/views/browser/item-view/index.tsx +186 -0
  40. package/src/components/views/browser/item-view/tags.tsx +85 -0
  41. package/src/components/views/browser/item-view-header.tsx +131 -0
  42. package/src/components/views/browser/pane-tabs.tsx +61 -0
  43. package/src/components/views/browser/place-header.tsx +72 -0
  44. package/src/components/views/browser/query-controls.tsx +105 -0
  45. package/src/components/views/browser/recipe-item-header.tsx +60 -0
  46. package/src/components/views/browser/settings.tsx +52 -0
  47. package/src/components/views/browser/toolbar.tsx +54 -0
  48. package/src/components/views/browser/type-tabs.tsx +71 -0
  49. package/src/components/views/browser/type-view.tsx +184 -0
  50. package/src/components/views/browser/workstation-rail.tsx +43 -0
  51. package/src/components/views/calc/calculator/actions.tsx +47 -0
  52. package/src/components/views/calc/calculator/help.tsx +189 -0
  53. package/src/components/views/calc/calculator/index.tsx +172 -0
  54. package/src/components/views/calc/dot-overlay.tsx +80 -0
  55. package/src/components/views/calc/edge.tsx +152 -0
  56. package/src/components/views/calc/flow/index.tsx +305 -0
  57. package/src/components/views/calc/flow/picker-dialogs.tsx +146 -0
  58. package/src/components/views/calc/flow/toolbar.tsx +60 -0
  59. package/src/components/views/calc/flow/view-menu.tsx +117 -0
  60. package/src/components/views/calc/member-picker.tsx +120 -0
  61. package/src/components/views/calc/node-card/actions.tsx +90 -0
  62. package/src/components/views/calc/node-card/badges.tsx +59 -0
  63. package/src/components/views/calc/node-card/chips.tsx +187 -0
  64. package/src/components/views/calc/node-card/far.tsx +123 -0
  65. package/src/components/views/calc/node-card/full.tsx +332 -0
  66. package/src/components/views/calc/node-card/index.tsx +27 -0
  67. package/src/components/views/calc/node-card/menu.tsx +282 -0
  68. package/src/components/views/calc/node-card/port-cells.tsx +186 -0
  69. package/src/components/views/calc/node-card/port-rails.tsx +251 -0
  70. package/src/components/views/calc/node-card/ports.tsx +86 -0
  71. package/src/components/views/calc/recipe-picker/body.tsx +207 -0
  72. package/src/components/views/calc/recipe-picker/footer.tsx +131 -0
  73. package/src/components/views/calc/recipe-picker/index.tsx +52 -0
  74. package/src/components/views/calc/sidebar.tsx +171 -0
  75. package/src/components/views/calc/trees/icon-picker.tsx +97 -0
  76. package/src/components/views/calc/trees/index.tsx +134 -0
  77. package/src/components/views/calc/trees/label.tsx +67 -0
  78. package/src/components/views/calc/trees/rows.tsx +140 -0
  79. package/src/components/views/forge/gui.tsx +131 -0
  80. package/src/components/views/forge/index.tsx +112 -0
  81. package/src/components/views/forge/list.tsx +113 -0
  82. package/src/components/views/forge/workbench/controls.tsx +103 -0
  83. package/src/components/views/forge/workbench/index.tsx +85 -0
  84. package/src/components/views/forge/workbench/panel.tsx +228 -0
  85. package/src/components/views/forge/workbench/ruler.tsx +133 -0
  86. package/src/components/views/forge/workbench/status.tsx +55 -0
  87. package/src/components/views/forge/workbench/tape.tsx +70 -0
  88. package/src/hooks/alloy/use-fluid-colors.ts +125 -0
  89. package/src/hooks/alloy/use-mix-state.ts +240 -0
  90. package/src/hooks/calc/flow/use-engage.ts +129 -0
  91. package/src/hooks/calc/flow/use-handlers.ts +192 -0
  92. package/src/hooks/calc/flow/use-layout.ts +105 -0
  93. package/src/hooks/calc/flow/use-mount.ts +97 -0
  94. package/src/hooks/calc/flow/use-nodes.ts +260 -0
  95. package/src/hooks/calc/flow/use-pickers.ts +57 -0
  96. package/src/hooks/calc/flow/use-viewport.ts +141 -0
  97. package/src/hooks/calc/flow/use-window.ts +87 -0
  98. package/src/hooks/calc/use-actions.ts +355 -0
  99. package/src/hooks/calc/use-amounts.ts +64 -0
  100. package/src/hooks/calc/use-data.ts +203 -0
  101. package/src/hooks/calc/use-defaults.ts +40 -0
  102. package/src/hooks/calc/use-far-mode.ts +37 -0
  103. package/src/hooks/calc/use-focus.ts +31 -0
  104. package/src/hooks/calc/use-maps.ts +76 -0
  105. package/src/hooks/calc/use-reset.ts +15 -0
  106. package/src/hooks/calc/use-resolver.ts +70 -0
  107. package/src/hooks/calc/use-session.ts +13 -0
  108. package/src/hooks/calc/use-share.ts +64 -0
  109. package/src/hooks/calc/use-storage.ts +126 -0
  110. package/src/hooks/calc/use-tier.ts +13 -0
  111. package/src/hooks/calc/use-view-mode.ts +24 -0
  112. package/src/hooks/calc/use-worker.ts +204 -0
  113. package/src/hooks/forge/use-anvil-gui.ts +16 -0
  114. package/src/hooks/forge/use-forge-state.ts +135 -0
  115. package/src/hooks/forge/use-ruler.ts +100 -0
  116. package/src/hooks/use-add-to-calc.ts +51 -0
  117. package/src/hooks/use-categories-by-id.ts +18 -0
  118. package/src/hooks/use-favorite-hotkey.ts +39 -0
  119. package/src/hooks/use-favorites.ts +135 -0
  120. package/src/hooks/use-group-outlines.ts +65 -0
  121. package/src/hooks/use-hide-unused.ts +12 -0
  122. package/src/hooks/use-index.ts +72 -0
  123. package/src/hooks/use-item-feeds.ts +46 -0
  124. package/src/hooks/use-item-pane.ts +106 -0
  125. package/src/hooks/use-item-press.ts +89 -0
  126. package/src/hooks/use-item-tag-groups.ts +54 -0
  127. package/src/hooks/use-item-tooltips.ts +18 -0
  128. package/src/hooks/use-left-sidebar-tree.ts +34 -0
  129. package/src/hooks/use-mod-names.ts +17 -0
  130. package/src/hooks/use-mount-limit.ts +22 -0
  131. package/src/hooks/use-panel-cells.ts +117 -0
  132. package/src/hooks/use-panel-page.ts +54 -0
  133. package/src/hooks/use-panel-tabs.ts +43 -0
  134. package/src/hooks/use-query.ts +186 -0
  135. package/src/hooks/use-session.ts +88 -0
  136. package/src/hooks/use-sidebar-group.ts +16 -0
  137. package/src/hooks/use-tag-members.ts +16 -0
  138. package/src/hooks/use-tool-recipes.ts +26 -0
  139. package/src/hooks/use-type-filter.ts +73 -0
  140. package/src/lib/alloy/mix.ts +210 -0
  141. package/src/lib/alloy/parse.ts +37 -0
  142. package/src/lib/calc/defaults.ts +47 -0
  143. package/src/lib/calc/flow.ts +315 -0
  144. package/src/lib/calc/focus.ts +15 -0
  145. package/src/lib/calc/meta.ts +17 -0
  146. package/src/lib/calc/pack.ts +77 -0
  147. package/src/lib/calc/ports.ts +43 -0
  148. package/src/lib/calc/resolve.ts +198 -0
  149. package/src/lib/calc/route/buckets.ts +49 -0
  150. package/src/lib/calc/route/geometry.ts +51 -0
  151. package/src/lib/calc/route/index.ts +3 -0
  152. package/src/lib/calc/route/polyline.ts +103 -0
  153. package/src/lib/calc/route/router.ts +394 -0
  154. package/src/lib/calc/scheme.ts +226 -0
  155. package/src/lib/calc/storage/entry.ts +97 -0
  156. package/src/lib/calc/storage/index.ts +20 -0
  157. package/src/lib/calc/storage/keys.ts +38 -0
  158. package/src/lib/calc/storage/store.ts +198 -0
  159. package/src/lib/calc/tree/index.ts +4 -0
  160. package/src/lib/calc/tree/materials.ts +29 -0
  161. package/src/lib/calc/tree/recipe.ts +104 -0
  162. package/src/lib/calc/tree/share.ts +135 -0
  163. package/src/lib/calc/worker/api.ts +25 -0
  164. package/src/lib/calc/worker/fetch.ts +66 -0
  165. package/src/lib/calc/worker/flush.ts +39 -0
  166. package/src/lib/calc/worker/index.ts +8 -0
  167. package/src/lib/calc/worker/protocol.ts +35 -0
  168. package/src/lib/calc/worker/resolve.ts +49 -0
  169. package/src/lib/calc/worker/state.ts +48 -0
  170. package/src/lib/card-model.ts +65 -0
  171. package/src/lib/client/recipe-categories.ts +14 -0
  172. package/src/lib/client/recipe.ts +85 -0
  173. package/src/lib/client-index/catalog.ts +91 -0
  174. package/src/lib/client-index/chrome.ts +180 -0
  175. package/src/lib/client-index/index.ts +20 -0
  176. package/src/lib/client-index/registry.ts +121 -0
  177. package/src/lib/client-index/rows.ts +260 -0
  178. package/src/lib/client-index/tooltips.ts +74 -0
  179. package/src/lib/config.ts +18 -0
  180. package/src/lib/data/category/dto.ts +25 -0
  181. package/src/lib/data/category/load.ts +47 -0
  182. package/src/lib/data/category/parse.ts +71 -0
  183. package/src/lib/data/dto.ts +152 -0
  184. package/src/lib/data/grid.ts +40 -0
  185. package/src/lib/data/parse/entry.ts +85 -0
  186. package/src/lib/data/parse/index.ts +3 -0
  187. package/src/lib/data/parse/layout.ts +138 -0
  188. package/src/lib/data/parse/slots.ts +85 -0
  189. package/src/lib/data/parse/widgets.ts +81 -0
  190. package/src/lib/data/scene.ts +103 -0
  191. package/src/lib/data/slot-io.ts +44 -0
  192. package/src/lib/dump.ts +6 -0
  193. package/src/lib/embed.ts +22 -0
  194. package/src/lib/favorite-hover.ts +13 -0
  195. package/src/lib/fluid-outputs.ts +70 -0
  196. package/src/lib/forge/actions.ts +59 -0
  197. package/src/lib/forge/data.ts +51 -0
  198. package/src/lib/forge/solve.ts +80 -0
  199. package/src/lib/group.ts +23 -0
  200. package/src/lib/index-store.ts +70 -0
  201. package/src/lib/links.ts +131 -0
  202. package/src/lib/lookup.ts +119 -0
  203. package/src/lib/panel-outline.ts +86 -0
  204. package/src/lib/panel.ts +105 -0
  205. package/src/lib/preview.ts +32 -0
  206. package/src/lib/recipe-slots.ts +8 -0
  207. package/src/lib/search.ts +139 -0
  208. package/src/lib/sidebar.ts +47 -0
  209. package/src/lib/used-items.ts +25 -0
  210. package/src/locales/en_us.json +172 -0
  211. package/src/locales/ru_ru.json +172 -0
  212. package/src/locales.ts +6 -0
  213. package/src/types/alloy.ts +20 -0
  214. package/src/types/calc-flow-props.ts +44 -0
  215. package/src/types/calc.ts +92 -0
  216. package/src/types/forge.ts +21 -0
  217. package/src/types/index.ts +136 -0
  218. package/styles/calc.css +308 -0
  219. package/styles/strip.css +7 -0
  220. package/styles.css +2 -0
  221. package/README.md +0 -3
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import { McText } from '@terrafirmagreg-team/game/components/entry/mc-text';
4
+ import { sceneEntityHit } from '@terrafirmagreg-team/recipe/components/cards/card/scene';
5
+ import {
6
+ Tooltip,
7
+ TooltipContent,
8
+ TooltipTrigger,
9
+ } from '@terrafirmagreg-team/ui/components/ui/tooltip';
10
+ import type {
11
+ RecipeDecorWidget,
12
+ RecipeWidget,
13
+ } from '@terrafirmagreg-team/recipe/lib/data/dto';
14
+ import type { RecipeSceneEntity } from '@terrafirmagreg-team/recipe/lib/data/scene';
15
+
16
+ export function CardWidgetTips({
17
+ widgets,
18
+ entities,
19
+ margin,
20
+ scale,
21
+ }: {
22
+ widgets: RecipeWidget[];
23
+ entities: RecipeSceneEntity[];
24
+ margin: number;
25
+ scale: number;
26
+ }) {
27
+ return widgets
28
+ .filter(
29
+ (widget): widget is RecipeDecorWidget =>
30
+ widget.type === 'widget' && (widget.tooltip?.length ?? 0) > 0,
31
+ )
32
+ .map((widget, index) => {
33
+ const panel = sceneEntityHit(entities, widget);
34
+ const hit = panel
35
+ ? {
36
+ left: (widget.x + widget.w - 10 + margin) * scale,
37
+ top: (widget.y + 1 + margin) * scale,
38
+ width: 9 * scale,
39
+ height: 9 * scale,
40
+ }
41
+ : {
42
+ left: (widget.x + margin) * scale,
43
+ top: (widget.y + margin) * scale,
44
+ width: widget.w * scale,
45
+ height: widget.h * scale,
46
+ };
47
+ return (
48
+ <Tooltip key={`tip:${index}`}>
49
+ <TooltipTrigger
50
+ render={<span className="absolute block" style={hit} />}
51
+ />
52
+ <TooltipContent className="flex-col items-start gap-0.5 p-3 text-sm whitespace-pre-wrap">
53
+ {(widget.tooltip ?? []).map((line, lineIndex) => (
54
+ <p key={lineIndex}>
55
+ <McText text={line} />
56
+ </p>
57
+ ))}
58
+ </TooltipContent>
59
+ </Tooltip>
60
+ );
61
+ });
62
+ }
63
+
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
3
+
4
+ import { Pin, PinOff } from 'lucide-react';
5
+ import { useEffect, useState } from 'react';
6
+ import { ContextMenuItem } from '@terrafirmagreg-team/ui/components/ui/context-menu';
7
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
8
+ import {
9
+ readRecipeDefaults,
10
+ subscribeRecipeDefaults,
11
+ writeRecipeDefault,
12
+ } from '@terrafirmagreg-team/recipe/lib/calc/defaults';
13
+ export function RecipeDefaultMenuItem({
14
+ dataset,
15
+ materialId,
16
+ recipeId,
17
+ }: {
18
+ dataset: DatasetRef;
19
+ materialId?: string;
20
+ recipeId: string;
21
+ }) {
22
+ const t = useTranslations();
23
+ const [pinned, setPinned] = useState(
24
+ () => materialId != null && readRecipeDefaults(dataset)[materialId] === recipeId,
25
+ );
26
+ useEffect(
27
+ () =>
28
+ subscribeRecipeDefaults(() => {
29
+ if (materialId != null) {
30
+ setPinned(readRecipeDefaults(dataset)[materialId] === recipeId);
31
+ }
32
+ }),
33
+ [dataset, materialId, recipeId],
34
+ );
35
+ if (!materialId) return null;
36
+ return (
37
+ <ContextMenuItem
38
+ onClick={() => writeRecipeDefault(dataset, materialId, pinned ? null : recipeId)}
39
+ >
40
+ {pinned ? <PinOff /> : <Pin />}
41
+ {pinned
42
+ ? t('Unpin default recipe', { note: 'recipe.calc.clear.default' })
43
+ : t('Pin as default recipe', { note: 'recipe.calc.set_default' })}
44
+ </ContextMenuItem>
45
+ );
46
+ }
47
+
@@ -0,0 +1,29 @@
1
+ import type { CSSProperties } from 'react';
2
+
3
+ export const RECIPE_GLARE_FILTER_ID = 'recipe-glare-cut';
4
+
5
+ export const recipeGlareFilter: CSSProperties = {
6
+ filter: `url(#${RECIPE_GLARE_FILTER_ID})`,
7
+ };
8
+
9
+ export function RecipeGlareFilter() {
10
+ return (
11
+ <svg aria-hidden className="absolute size-0 overflow-hidden">
12
+ <defs>
13
+ <filter
14
+ id={RECIPE_GLARE_FILTER_ID}
15
+ colorInterpolationFilters="sRGB"
16
+ >
17
+ <feColorMatrix
18
+ type="matrix"
19
+ values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 -2.946 -9.912 -1.001 13.44 0"
20
+ />
21
+ <feComponentTransfer>
22
+ <feFuncA type="table" tableValues="0.03 1" />
23
+ </feComponentTransfer>
24
+ </filter>
25
+ </defs>
26
+ </svg>
27
+ );
28
+ }
29
+
@@ -0,0 +1,222 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useRef, useState, type ComponentProps } from 'react';
4
+ import { RecipeCard } from './card';
5
+ import { SlotHitbox } from './slot-hitbox';
6
+ import { StripCard } from './strip-card';
7
+ import { recipeGlareFilter } from './glare-filter';
8
+ import { recipeGrid } from '@terrafirmagreg-team/recipe/lib/data/grid';
9
+ import { recipeSlots } from '@terrafirmagreg-team/recipe/lib/recipe-slots';
10
+ import { PagerArrow } from '@terrafirmagreg-team/ui/components/blocks/page/pager';
11
+ import { useInView } from '@terrafirmagreg-team/ui/hooks/use-in-view';
12
+ import { useSwipe } from '@terrafirmagreg-team/ui/hooks/use-swipe';
13
+ import { cn } from '@terrafirmagreg-team/ui/lib/utils/cn';
14
+
15
+ const FALLBACK_PITCH = 18;
16
+
17
+ type RecipeCardProps = ComponentProps<typeof RecipeCard>;
18
+
19
+ export function PagedCard({
20
+ maxH,
21
+ ...props
22
+ }: RecipeCardProps & { maxH?: number }) {
23
+ const { recipe, naturalH, naturalW, src, title, onFail } = props;
24
+ const scale = recipe.scale ?? 2;
25
+ const margin = recipe.margin ?? 0;
26
+ const natural = naturalH ?? 0;
27
+ const rootRef = useRef<HTMLDivElement>(null);
28
+ const observedInView = useInView(rootRef);
29
+ const inView = props.eager || observedInView;
30
+ const [page, setPage] = useState(0);
31
+
32
+ const [imgReady, setImgReady] = useState(false);
33
+ const [prevSrc, setPrevSrc] = useState(src);
34
+ if (prevSrc !== src) {
35
+ setPrevSrc(src);
36
+ setImgReady(false);
37
+ }
38
+
39
+ const slots = useMemo(() => recipeSlots(recipe), [recipe]);
40
+ const visibleH = maxH ? Math.floor(maxH) : 0;
41
+ const overflow = visibleH > 0 && natural > visibleH;
42
+
43
+ const pinTop = useMemo(() => {
44
+ if (!overflow) return 0;
45
+ const tops = slots
46
+ .filter((slot) => slot.role === 'output')
47
+ .map((slot) => ((slot.y ?? 0) + margin) * scale);
48
+ const min = tops.length ? Math.min(...tops) : 0;
49
+ return min > 0 && min < visibleH ? min : 0;
50
+ }, [slots, scale, margin, overflow, visibleH]);
51
+
52
+ const rows = useMemo(
53
+ () =>
54
+ [
55
+ ...new Set(
56
+ slots
57
+ .map((slot) => ((slot.y ?? 0) + margin) * scale)
58
+ .filter((y) => y >= pinTop),
59
+ ),
60
+ ].sort((a, b) => a - b),
61
+ [slots, scale, margin, pinTop],
62
+ );
63
+
64
+ const pitchPx = useMemo(() => {
65
+ let min = 0;
66
+ for (let i = 1; i < rows.length; i += 1) {
67
+ const delta = rows[i] - rows[i - 1];
68
+ if (delta > 0 && (min === 0 || delta < min)) min = delta;
69
+ }
70
+ return min || FALLBACK_PITCH * scale;
71
+ }, [rows, scale]);
72
+
73
+ const capH = Math.max(margin, 1) * scale;
74
+ const memberH = overflow
75
+ ? Math.max(
76
+ pitchPx,
77
+ Math.floor((visibleH - pinTop - capH) / pitchPx) * pitchPx,
78
+ )
79
+ : 0;
80
+
81
+ const starts = useMemo(() => {
82
+ if (!overflow) return [0];
83
+ const maxStart = Math.max(pinTop, natural - memberH - capH);
84
+ const stride = Math.max(pitchPx, Math.floor(memberH / pitchPx) * pitchPx);
85
+ const out = [pinTop];
86
+ let start = pinTop;
87
+ while (start < maxStart) {
88
+ const row = rows.find((y) => y > start && y + pitchPx > start + memberH);
89
+ const next = Math.min(row ?? start + stride, maxStart);
90
+ if (next <= start) break;
91
+ out.push(next);
92
+ start = next;
93
+ }
94
+ return out;
95
+ }, [overflow, natural, memberH, capH, pinTop, rows, pitchPx]);
96
+
97
+ const pages = starts.length;
98
+ const clamped = Math.min(page, pages - 1);
99
+ const swipe = useSwipe((dir) =>
100
+ setPage((p) => Math.max(0, Math.min(pages - 1, p + dir))),
101
+ );
102
+
103
+ if (recipeGrid(recipe.properties)) {
104
+ return <StripCard {...props} />;
105
+ }
106
+ if (!overflow) return <RecipeCard {...props} />;
107
+
108
+ return (
109
+ <div
110
+ ref={rootRef}
111
+ className={cn(
112
+ 'relative w-fit overflow-hidden rounded-sm',
113
+ imgReady && 'bg-mc-panel',
114
+ )}
115
+ style={{ height: pinTop + memberH + capH }}
116
+ {...swipe}
117
+ onWheel={(event) => {
118
+ if (event.deltaY === 0) return;
119
+ const next = clamped + (event.deltaY > 0 ? 1 : -1);
120
+ if (next < 0 || next >= pages) return;
121
+ event.stopPropagation();
122
+ setPage(next);
123
+ }}
124
+ >
125
+ {pinTop > 0 && (
126
+ <div
127
+ className="relative w-fit overflow-hidden"
128
+ style={{ height: pinTop }}
129
+ >
130
+ {/* eslint-disable-next-line @next/next/no-img-element -- dump asset */}
131
+ <img
132
+ src={src}
133
+ alt={title}
134
+ className="block"
135
+ width={naturalW}
136
+ height={natural}
137
+ loading="lazy"
138
+ decoding="async"
139
+ onError={onFail}
140
+ style={recipeGlareFilter}
141
+ />
142
+ {inView && !props.frameOnly
143
+ ? slots
144
+ .filter(
145
+ (widget) => ((widget.y ?? 0) + margin) * scale < pinTop,
146
+ )
147
+ .map((widget, index) => (
148
+ <SlotHitbox
149
+ key={`${widget.id}:${index}`}
150
+ widget={widget}
151
+ dataset={props.dataset}
152
+ lang={props.lang}
153
+ langMap={props.langMap}
154
+ nav={props.nav}
155
+ scale={scale}
156
+ margin={margin}
157
+ interactive={props.interactive}
158
+ tooltipLevel={props.slotTooltip}
159
+ />
160
+ ))
161
+ : null}
162
+ </div>
163
+ )}
164
+ <div className="overflow-hidden" style={{ height: memberH }}>
165
+ <div style={{ transform: `translateY(${-starts[clamped]}px)` }}>
166
+ <RecipeCard {...props} />
167
+ </div>
168
+ </div>
169
+ <div className="overflow-hidden" style={{ height: capH }}>
170
+ {/* eslint-disable-next-line @next/next/no-img-element -- dump asset */}
171
+ <img
172
+ src={src}
173
+ alt=""
174
+ aria-hidden
175
+ className="block"
176
+ width={naturalW}
177
+ height={natural}
178
+ style={{
179
+ transform: `translateY(${-(natural - capH)}px)`,
180
+ ...recipeGlareFilter,
181
+ }}
182
+ loading="lazy"
183
+ decoding="async"
184
+ onLoad={() => setImgReady(true)}
185
+ />
186
+ </div>
187
+ {pages > 1 && (
188
+ <div
189
+ className="absolute right-1.5 top-1.5 flex items-center gap-0.5 rounded-lg bg-background/80 px-0.5 py-0.5"
190
+ onClick={(event) => event.stopPropagation()}
191
+ >
192
+ <PagerArrow
193
+ dir={-1}
194
+ variant="ghost"
195
+ size="icon-xs"
196
+ disabled={clamped <= 0}
197
+ onClick={() =>
198
+ setPage((p) => Math.max(0, Math.min(p, pages - 1) - 1))
199
+ }
200
+ />
201
+ <span
202
+ className={cn(
203
+ 'px-0.5 text-2xs tabular-nums text-muted-foreground',
204
+ )}
205
+ >
206
+ {clamped + 1}/{pages}
207
+ </span>
208
+ <PagerArrow
209
+ dir={1}
210
+ variant="ghost"
211
+ size="icon-xs"
212
+ disabled={clamped + 1 >= pages}
213
+ onClick={() =>
214
+ setPage((p) => Math.min(pages - 1, Math.min(p, pages - 1) + 1))
215
+ }
216
+ />
217
+ </div>
218
+ )}
219
+ </div>
220
+ );
221
+ }
222
+
@@ -0,0 +1,213 @@
1
+ 'use client';
2
+ import type { DatasetRef } from '@terrafirmagreg-team/sites/location';
3
+
4
+ import Link from 'next/link';
5
+ import { ENTRY_CYCLE_MS, EntryIcon } from '@terrafirmagreg-team/game/components/entry/icon';
6
+ import {
7
+ EntryTooltip,
8
+ type EntryTooltipLevel,
9
+ } from '@terrafirmagreg-team/game/components/entry/tooltip';
10
+ import { useCycleIndex } from '@terrafirmagreg-team/ui/hooks/use-cycle-index';
11
+ import { stackDisplayName } from '@terrafirmagreg-team/game/lib/entry/display';
12
+ import { entryHref } from '@terrafirmagreg-team/game/lib/entry/href';
13
+ import { loadStackDisplay } from '@terrafirmagreg-team/game/lib/entry/tooltip';
14
+ import { favoriteHover } from '@terrafirmagreg-team/recipe/lib/favorite-hover';
15
+ import { useAsyncResult } from '@terrafirmagreg-team/ui/hooks/use-async-value';
16
+ import type { ReferenceKind } from '@terrafirmagreg-team/dump/lib/dump/contract/ref';
17
+ import type { RecipeSlotWidget } from '@terrafirmagreg-team/recipe/lib/data/dto';
18
+ import type { RecipeHrefOpts } from '@terrafirmagreg-team/recipe/types';
19
+
20
+ const SCALE = 2;
21
+
22
+ const TAG_REF_KIND: Record<string, ReferenceKind> = {
23
+ block: 'blockTag',
24
+ fluid: 'fluidTag',
25
+ entity: 'entityTag',
26
+ };
27
+
28
+ function widgetRefKind(widget: RecipeSlotWidget): ReferenceKind {
29
+ if (widget.kind === 'tag') {
30
+ return TAG_REF_KIND[widget.tagKind ?? 'item'] ?? 'itemTag';
31
+ }
32
+ return widget.kind === 'fluid' ? 'fluid' : 'item';
33
+ }
34
+
35
+ function stackFavoriteProps(widget: RecipeSlotWidget) {
36
+ const refKind = widgetRefKind(widget);
37
+ const stackable =
38
+ (refKind === 'item' || refKind === 'fluid') &&
39
+ !widget.id.startsWith('#');
40
+ return {
41
+ onMouseEnter: () => {
42
+ favoriteHover.slot = stackable
43
+ ? {
44
+ kind: 'stack',
45
+ id: widget.id,
46
+ ...(refKind === 'fluid' ? { ref: 'fluid' as const } : {}),
47
+ }
48
+ : undefined;
49
+ },
50
+ onMouseLeave: () => {
51
+ favoriteHover.slot = undefined;
52
+ },
53
+ };
54
+ }
55
+
56
+ export function SlotHitbox({
57
+ widget,
58
+ dataset,
59
+ lang,
60
+ langMap,
61
+ nav,
62
+ scale = SCALE,
63
+ margin = 0,
64
+ interactive = true,
65
+ tooltipLevel,
66
+ }: {
67
+ widget: RecipeSlotWidget;
68
+ dataset: DatasetRef;
69
+ lang: string;
70
+ langMap?: Record<string, string>;
71
+ nav?: Pick<RecipeHrefOpts, 'tab' | 'search'>;
72
+ scale?: number;
73
+ margin?: number;
74
+ interactive?: boolean;
75
+
76
+ tooltipLevel?: EntryTooltipLevel;
77
+ }) {
78
+ const name = stackDisplayName(langMap, widget.id);
79
+ const kind =
80
+ widget.kind === 'fluid' || widget.kind === 'tag' ? widget.kind : 'item';
81
+ const checkKind = kind === 'tag' || !interactive ? undefined : kind;
82
+ const stackPeek =
83
+ dataset && checkKind && widget.id
84
+ ? loadStackDisplay.peek(dataset, widget.id, checkKind)
85
+ : undefined;
86
+ const stackRes = useAsyncResult(
87
+ dataset && checkKind && widget.id && stackPeek === undefined
88
+ ? async () => ({
89
+ key: widget.id,
90
+ display: await loadStackDisplay(dataset, widget.id, checkKind),
91
+ })
92
+ : false,
93
+ [dataset, widget.id, checkKind, stackPeek],
94
+ );
95
+ const missingItem =
96
+ checkKind !== undefined &&
97
+ stackPeek === undefined &&
98
+ !stackRes.pending &&
99
+ stackRes.value?.key === widget.id &&
100
+ stackRes.value.display === undefined;
101
+ const iconSize = Math.max(8, Math.min(widget.w - 2, 16) * scale);
102
+ const hover = stackFavoriteProps(widget);
103
+ const alternatives = widget.alternatives;
104
+ const [altIndex] = useCycleIndex(alternatives?.length ?? 0, {
105
+ enabled: interactive !== false && (alternatives?.length ?? 0) > 1,
106
+ intervalMs: ENTRY_CYCLE_MS,
107
+ });
108
+ const iconId =
109
+ alternatives && alternatives.length > 0
110
+ ? (alternatives[altIndex] ?? alternatives[0]!)
111
+ : widget.id;
112
+ const iconKind = alternatives?.length
113
+ ? widget.tagKind === 'fluid' || widget.tagKind === 'block'
114
+ ? widget.tagKind
115
+ : undefined
116
+ : widgetRefKind(widget);
117
+ const icon = (
118
+ <span className="pointer-events-none flex size-full items-center justify-center">
119
+ {alternatives && alternatives.length > 1 ? (
120
+ <span
121
+ aria-hidden
122
+ className="absolute bg-mc-inset"
123
+ style={{ inset: scale }}
124
+ />
125
+ ) : null}
126
+ <EntryIcon
127
+ id={iconId}
128
+ kind={iconKind}
129
+ dataset={dataset}
130
+ size={iconSize}
131
+ missing="placeholder"
132
+ />
133
+ </span>
134
+ );
135
+ const pos = {
136
+ left: (widget.x + margin) * scale,
137
+ top: (widget.y + margin) * scale,
138
+ width: widget.w * scale,
139
+ height: widget.h * scale,
140
+ };
141
+ if (!interactive) {
142
+
143
+ if (tooltipLevel === 'none') {
144
+ return (
145
+ <span
146
+ aria-label={name}
147
+ className="absolute block"
148
+ style={pos}
149
+ >
150
+ {icon}
151
+ </span>
152
+ );
153
+ }
154
+ return (
155
+ <EntryTooltip
156
+ dataset={dataset}
157
+ id={widget.id}
158
+ name={name}
159
+ kind={kind === 'tag' ? 'item' : kind}
160
+ lang={lang}
161
+ langMap={langMap}
162
+ level={tooltipLevel}
163
+ >
164
+ <span
165
+ {...hover}
166
+ aria-label={name}
167
+ className="absolute block"
168
+ style={pos}
169
+ >
170
+ {icon}
171
+ </span>
172
+ </EntryTooltip>
173
+ );
174
+ }
175
+ return (
176
+ <EntryTooltip
177
+ dataset={dataset}
178
+ id={widget.id}
179
+ name={name}
180
+ kind={kind === 'tag' ? 'item' : kind}
181
+ lang={lang}
182
+ langMap={langMap}
183
+ level={tooltipLevel}
184
+ >
185
+ <span
186
+ {...hover}
187
+ className="absolute block"
188
+ style={pos}
189
+ aria-label={name}
190
+ >
191
+ {!missingItem ? (
192
+ <Link
193
+ href={entryHref(
194
+ dataset,
195
+ lang,
196
+ {
197
+ kind: widgetRefKind(widget),
198
+ id: widget.id.startsWith('#') ? widget.id.slice(1) : widget.id,
199
+ },
200
+ nav,
201
+ )}
202
+ className="absolute inset-0 block"
203
+ >
204
+ {icon}
205
+ </Link>
206
+ ) : (
207
+ <span className="absolute inset-0 block">{icon}</span>
208
+ )}
209
+ </span>
210
+ </EntryTooltip>
211
+ );
212
+ }
213
+
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Check, Copy } from 'lucide-react';
5
+ import { useCopyButton } from '@terrafirmagreg-team/ui/hooks/use-copy-button';
6
+ import { useTranslations } from '@terrafirmagreg-team/translate/hooks/use-translations';
7
+
8
+ export function StripCardTagPager({
9
+ left,
10
+ top,
11
+ dir,
12
+ scale,
13
+ disabled,
14
+ onClick,
15
+ }: {
16
+ left: number;
17
+ top: number;
18
+ dir: -1 | 1;
19
+ scale: number;
20
+ disabled: boolean;
21
+ onClick: () => void;
22
+ }) {
23
+ const t = useTranslations();
24
+ const [hover, setHover] = useState(false);
25
+ const arrow = `${3.5 * scale}px solid ${disabled ? 'var(--color-mc-inset)' : hover ? 'var(--color-mc-arrow-dark)' : 'var(--color-mc-arrow)'}`;
26
+ return (
27
+ <button
28
+ type="button"
29
+ aria-label={
30
+ dir < 0
31
+ ? t('Previous Page', { note: 'ui.prev_page' })
32
+ : t('Next Page', { note: 'ui.next_page' })
33
+ }
34
+ disabled={disabled}
35
+ onClick={onClick}
36
+ onMouseEnter={() => setHover(true)}
37
+ onMouseLeave={() => setHover(false)}
38
+ onFocus={() => setHover(true)}
39
+ onBlur={() => setHover(false)}
40
+ className="absolute flex cursor-pointer items-center justify-center border-0 p-0 outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
41
+ style={{
42
+ left,
43
+ top,
44
+ width: 12 * scale,
45
+ height: 12 * scale,
46
+ background: disabled ? 'var(--color-mc-dim)' : hover ? 'var(--color-mc-bright)' : 'var(--color-mc-panel)',
47
+ boxShadow: `inset ${scale}px ${scale}px 0 rgba(255,255,255,0.65), inset -${scale}px -${scale}px 0 rgba(0,0,0,0.45)`,
48
+ }}
49
+ >
50
+ <span
51
+ aria-hidden
52
+ className="block"
53
+ style={{
54
+ width: 0,
55
+ height: 0,
56
+ borderTop: `${2.5 * scale}px solid transparent`,
57
+ borderBottom: `${2.5 * scale}px solid transparent`,
58
+ ...(dir < 0 ? { borderRight: arrow } : { borderLeft: arrow }),
59
+ }}
60
+ />
61
+ </button>
62
+ );
63
+ }
64
+
65
+ export function StripCardTagCopy({
66
+ left,
67
+ top,
68
+ scale,
69
+ value,
70
+ }: {
71
+ left: number;
72
+ top: number;
73
+ scale: number;
74
+ value: string;
75
+ }) {
76
+ const t = useTranslations();
77
+ const [hover, setHover] = useState(false);
78
+ const { checked: done, onClick } = useCopyButton(() =>
79
+ navigator.clipboard.writeText(value),
80
+ );
81
+ const Icon = done ? Check : Copy;
82
+ return (
83
+ <button
84
+ type="button"
85
+ aria-label={t('Copy recipe id', { note: 'recipe.copy_recipe_id' })}
86
+ onClick={onClick}
87
+ onMouseEnter={() => setHover(true)}
88
+ onMouseLeave={() => setHover(false)}
89
+ onFocus={() => setHover(true)}
90
+ onBlur={() => setHover(false)}
91
+ className="absolute flex cursor-pointer items-center justify-center border-0 p-0 outline-none focus-visible:ring-1 focus-visible:ring-ring"
92
+ style={{
93
+ left,
94
+ top,
95
+ width: 12 * scale,
96
+ height: 12 * scale,
97
+ background: done ? 'var(--color-mc-bright)' : hover ? 'var(--color-mc-bright)' : 'var(--color-mc-panel)',
98
+ boxShadow: `inset ${scale}px ${scale}px 0 rgba(255,255,255,0.65), inset -${scale}px -${scale}px 0 rgba(0,0,0,0.45)`,
99
+ color: done ? 'var(--color-mc-done)' : hover ? 'var(--color-mc-arrow-dark)' : 'var(--color-mc-arrow)',
100
+ }}
101
+ >
102
+ <Icon width={7 * scale} height={7 * scale} strokeWidth={2.5} />
103
+ </button>
104
+ );
105
+ }
106
+