@enderfall/ui 0.1.0 → 0.1.4

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 (45) hide show
  1. package/assets/brand/enderfall-lockup.png +0 -0
  2. package/assets/brand/enderfall-lockup.svg +8 -0
  3. package/assets/brand/enderfall-mark.png +0 -0
  4. package/assets/brand/enderfall-mark.svg +8 -0
  5. package/dist/components/Button.d.ts +2 -1
  6. package/dist/components/Button.d.ts.map +1 -1
  7. package/dist/components/Button.js +8 -1
  8. package/dist/components/Dropdown.d.ts.map +1 -1
  9. package/dist/components/Dropdown.js +2 -2
  10. package/package.json +5 -2
  11. package/src/base.css +160 -0
  12. package/src/components/AccessGate.css +24 -0
  13. package/src/components/AccessGate.tsx +61 -0
  14. package/src/components/BookmarkDropdown.css +220 -0
  15. package/src/components/Button.css +183 -0
  16. package/src/components/Button.tsx +20 -0
  17. package/src/components/Dropdown.tsx +570 -0
  18. package/src/components/FloatingFooter.css +49 -0
  19. package/src/components/FloatingFooter.tsx +27 -0
  20. package/src/components/FormField.tsx +29 -0
  21. package/src/components/HeaderMenu.css +280 -0
  22. package/src/components/Input.css +68 -0
  23. package/src/components/Input.tsx +23 -0
  24. package/src/components/MainHeader.css +167 -0
  25. package/src/components/MainHeader.tsx +51 -0
  26. package/src/components/Modal.css +282 -0
  27. package/src/components/Modal.tsx +142 -0
  28. package/src/components/Panel.css +71 -0
  29. package/src/components/Panel.tsx +31 -0
  30. package/src/components/PreferencesModal.tsx +67 -0
  31. package/src/components/SideMenu.tsx +239 -0
  32. package/src/components/Slider.css +114 -0
  33. package/src/components/Slider.tsx +33 -0
  34. package/src/components/StackedCard.css +180 -0
  35. package/src/components/StackedCard.tsx +125 -0
  36. package/src/components/StatDots.css +122 -0
  37. package/src/components/StatDots.tsx +53 -0
  38. package/src/components/Tabs.css +108 -0
  39. package/src/components/Tabs.tsx +68 -0
  40. package/src/components/Toggle.css +161 -0
  41. package/src/components/Toggle.tsx +38 -0
  42. package/src/components/UserMenu.css +273 -0
  43. package/src/index.ts +45 -0
  44. package/src/theme.css +353 -0
  45. package/styles.css +1 -0
@@ -0,0 +1,239 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type ReactNode,
10
+ type MouseEvent,
11
+ } from "react";
12
+
13
+ type SideMenuContextValue = {
14
+ openId: string | null;
15
+ setOpenId: (id: string | null) => void;
16
+ scheduleOpen: (id: string) => void;
17
+ clearHover: () => void;
18
+ toggleOpen: (id: string) => void;
19
+ openedAt: number;
20
+ initialDelayMs: number;
21
+ };
22
+
23
+ const SideMenuContext = createContext<SideMenuContextValue | null>(null);
24
+
25
+ type SideMenuProps = {
26
+ children: ReactNode;
27
+ resetKey?: string | number | boolean | null;
28
+ hoverDelayMs?: number;
29
+ initialDelayMs?: number;
30
+ onOpenChange?: (openId: string | null) => void;
31
+ };
32
+
33
+ export const SideMenu = ({
34
+ children,
35
+ resetKey,
36
+ hoverDelayMs = 220,
37
+ initialDelayMs = 180,
38
+ onOpenChange,
39
+ }: SideMenuProps) => {
40
+ const [openId, setOpenId] = useState<string | null>(null);
41
+ const hoverTimerRef = useRef<number | null>(null);
42
+ const openedAtRef = useRef<number>(Date.now());
43
+
44
+ useEffect(() => {
45
+ if (hoverTimerRef.current !== null) {
46
+ window.clearTimeout(hoverTimerRef.current);
47
+ hoverTimerRef.current = null;
48
+ }
49
+ setOpenId(null);
50
+ openedAtRef.current = Date.now();
51
+ }, [resetKey]);
52
+
53
+ useEffect(() => {
54
+ onOpenChange?.(openId);
55
+ }, [openId, onOpenChange]);
56
+
57
+ useEffect(() => {
58
+ return () => {
59
+ if (hoverTimerRef.current !== null) {
60
+ window.clearTimeout(hoverTimerRef.current);
61
+ }
62
+ };
63
+ }, []);
64
+
65
+ const scheduleOpen = (id: string) => {
66
+ if (hoverTimerRef.current !== null) {
67
+ window.clearTimeout(hoverTimerRef.current);
68
+ }
69
+ const elapsed = Date.now() - openedAtRef.current;
70
+ const settleDelay = elapsed < initialDelayMs ? initialDelayMs - elapsed : 0;
71
+ hoverTimerRef.current = window.setTimeout(() => {
72
+ setOpenId(id);
73
+ }, hoverDelayMs + settleDelay);
74
+ };
75
+
76
+ const clearHover = () => {
77
+ if (hoverTimerRef.current !== null) {
78
+ window.clearTimeout(hoverTimerRef.current);
79
+ hoverTimerRef.current = null;
80
+ }
81
+ };
82
+
83
+ const toggleOpen = (id: string) => {
84
+ setOpenId((prev) => (prev === id ? null : id));
85
+ };
86
+
87
+ const value = useMemo(
88
+ () => ({
89
+ openId,
90
+ setOpenId,
91
+ scheduleOpen,
92
+ clearHover,
93
+ toggleOpen,
94
+ openedAt: openedAtRef.current,
95
+ initialDelayMs,
96
+ }),
97
+ [openId, initialDelayMs]
98
+ );
99
+
100
+ return <SideMenuContext.Provider value={value}>{children}</SideMenuContext.Provider>;
101
+ };
102
+
103
+ type SideMenuTriggerProps = {
104
+ onClick: (event: MouseEvent) => void;
105
+ disabled?: boolean;
106
+ "aria-expanded": boolean;
107
+ };
108
+
109
+ type SideMenuSubmenuProps = {
110
+ id: string;
111
+ trigger: (props: SideMenuTriggerProps) => ReactNode;
112
+ children: ReactNode;
113
+ className?: string;
114
+ panelClassName?: string;
115
+ disabled?: boolean;
116
+ enableViewportFlip?: boolean;
117
+ onOpenChange?: (open: boolean) => void;
118
+ variant?: "default" | "header";
119
+ };
120
+
121
+ export const SideMenuSubmenu = ({
122
+ id,
123
+ trigger,
124
+ children,
125
+ className,
126
+ panelClassName,
127
+ disabled,
128
+ enableViewportFlip = false,
129
+ onOpenChange,
130
+ variant = "default",
131
+ }: SideMenuSubmenuProps) => {
132
+ const context = useContext(SideMenuContext);
133
+ if (!context) {
134
+ throw new Error("SideMenuSubmenu must be used within a SideMenu provider.");
135
+ }
136
+
137
+ const { openId, setOpenId, scheduleOpen, clearHover, toggleOpen, openedAt, initialDelayMs } =
138
+ context;
139
+ const isOpen = openId === id;
140
+ const panelRef = useRef<HTMLDivElement | null>(null);
141
+ const containerRef = useRef<HTMLDivElement | null>(null);
142
+ const [panelStyle, setPanelStyle] = useState<CSSProperties>({});
143
+
144
+ useEffect(() => {
145
+ onOpenChange?.(isOpen);
146
+ }, [isOpen, onOpenChange]);
147
+
148
+ useEffect(() => {
149
+ if (!isOpen || !enableViewportFlip) {
150
+ setPanelStyle({});
151
+ return;
152
+ }
153
+ const updatePosition = () => {
154
+ const panel = panelRef.current;
155
+ const container = containerRef.current;
156
+ if (!panel || !container) return;
157
+ const panelRect = panel.getBoundingClientRect();
158
+ const containerRect = container.getBoundingClientRect();
159
+ const margin = 12;
160
+ const defaultLeft = containerRect.right + 6;
161
+ const flippedLeft = containerRect.left - panelRect.width - 6;
162
+ let left = defaultLeft;
163
+ if (defaultLeft + panelRect.width > window.innerWidth - margin && flippedLeft >= margin) {
164
+ left = flippedLeft;
165
+ }
166
+ left = Math.min(Math.max(left, margin), window.innerWidth - margin - panelRect.width);
167
+ let top = containerRect.top;
168
+ top = Math.min(Math.max(top, margin), window.innerHeight - margin - panelRect.height);
169
+ setPanelStyle({
170
+ left: left - containerRect.left,
171
+ top: top - containerRect.top,
172
+ right: "auto",
173
+ });
174
+ };
175
+ const raf = requestAnimationFrame(updatePosition);
176
+ window.addEventListener("resize", updatePosition);
177
+ window.addEventListener("scroll", updatePosition, true);
178
+ return () => {
179
+ cancelAnimationFrame(raf);
180
+ window.removeEventListener("resize", updatePosition);
181
+ window.removeEventListener("scroll", updatePosition, true);
182
+ };
183
+ }, [isOpen, enableViewportFlip]);
184
+
185
+ const handlePointerEnter = () => {
186
+ if (disabled || isOpen) return;
187
+ if (Date.now() - openedAt < initialDelayMs) return;
188
+ setOpenId(null);
189
+ scheduleOpen(id);
190
+ };
191
+
192
+ const handlePointerLeave = () => {
193
+ clearHover();
194
+ };
195
+
196
+ const handlePointerMove = () => {
197
+ if (disabled || isOpen) return;
198
+ if (Date.now() - openedAt < initialDelayMs) return;
199
+ scheduleOpen(id);
200
+ };
201
+
202
+ const handleClick = (event: MouseEvent) => {
203
+ if (disabled) return;
204
+ event.preventDefault();
205
+ if (isOpen) return;
206
+ setOpenId(id);
207
+ };
208
+
209
+ return (
210
+ <div
211
+ className={[
212
+ className,
213
+ isOpen ? "is-open" : "",
214
+ variant === "header" ? "side-menu--header" : "",
215
+ ]
216
+ .filter(Boolean)
217
+ .join(" ")}
218
+ data-submenu-id={id}
219
+ ref={containerRef}
220
+ onPointerEnter={handlePointerEnter}
221
+ onPointerLeave={handlePointerLeave}
222
+ onPointerMove={handlePointerMove}
223
+ >
224
+ {trigger({ onClick: handleClick, disabled, "aria-expanded": isOpen })}
225
+ <div
226
+ ref={panelRef}
227
+ className={[
228
+ panelClassName,
229
+ isOpen ? "is-open" : "",
230
+ ]
231
+ .filter(Boolean)
232
+ .join(" ")}
233
+ style={panelStyle}
234
+ >
235
+ {children}
236
+ </div>
237
+ </div>
238
+ );
239
+ };
@@ -0,0 +1,114 @@
1
+ .ef-slider {
2
+ width: 100%;
3
+ height: 26px;
4
+ border-radius: var(--ef-slider-track-radius, 999px);
5
+ background: var(--ef-slider-surface, var(--ef-surface));
6
+ border: 2px solid var(--ef-slider-border-color, var(--line));
7
+ padding: 0 2px;
8
+ outline: none;
9
+ appearance: none;
10
+ }
11
+
12
+ .ef-slider::-webkit-slider-runnable-track {
13
+ height: 26px;
14
+ border-radius: 999px;
15
+ background: transparent;
16
+ }
17
+
18
+ .ef-slider::-webkit-slider-thumb {
19
+ appearance: none;
20
+ width: 20px;
21
+ height: 20px;
22
+ border-radius: var(--ef-slider-thumb-radius, 999px);
23
+ clip-path: var(--ef-slider-thumb-clip, circle(50%));
24
+ background: var(--ef-slider-surface, var(--ef-surface));
25
+ border: 2px solid var(--ef-slider-border-color, var(--line));
26
+ -webkit-mask-image: var(--ef-slider-thumb-mask, radial-gradient(circle, #fff 99%, transparent 100%));
27
+ mask-image: var(--ef-slider-thumb-mask, radial-gradient(circle, #fff 99%, transparent 100%));
28
+ margin-top: 3px;
29
+ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.35);
30
+ }
31
+
32
+ .ef-slider::-moz-range-track {
33
+ height: 26px;
34
+ border-radius: 999px;
35
+ background: transparent;
36
+ }
37
+
38
+ .ef-slider::-moz-range-thumb {
39
+ width: 20px;
40
+ height: 20px;
41
+ border-radius: var(--ef-slider-thumb-radius, 999px);
42
+ clip-path: var(--ef-slider-thumb-clip, circle(50%));
43
+ background: var(--ef-slider-surface, var(--ef-surface));
44
+ border: 2px solid var(--ef-slider-border-color, var(--line));
45
+ -webkit-mask-image: var(--ef-slider-thumb-mask, radial-gradient(circle, #fff 99%, transparent 100%));
46
+ mask-image: var(--ef-slider-thumb-mask, radial-gradient(circle, #fff 99%, transparent 100%));
47
+ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.35);
48
+ }
49
+
50
+ :root[data-theme="galaxy"] .ef-slider,
51
+ :root[data-theme="plain-dark"] .ef-slider,
52
+ :root[data-theme="light"] .ef-slider,
53
+ :root[data-theme="plain-light"] .ef-slider,
54
+ [data-theme="galaxy"] .ef-slider,
55
+ [data-theme="plain-dark"] .ef-slider,
56
+ [data-theme="light"] .ef-slider,
57
+ [data-theme="plain-light"] .ef-slider,
58
+ .ef-slider[data-ef-theme="galaxy"],
59
+ .ef-slider[data-ef-theme="plain-dark"],
60
+ .ef-slider[data-ef-theme="light"],
61
+ .ef-slider[data-ef-theme="plain-light"] {
62
+ background:
63
+ linear-gradient(var(--ef-surface), var(--ef-surface)) padding-box,
64
+ var(--ef-border-gradient) border-box;
65
+ border-color: transparent;
66
+ background-clip: padding-box, border-box;
67
+ background-origin: border-box;
68
+ }
69
+
70
+ :root[data-theme="galaxy"] .ef-slider::-webkit-slider-thumb,
71
+ :root[data-theme="plain-dark"] .ef-slider::-webkit-slider-thumb,
72
+ :root[data-theme="light"] .ef-slider::-webkit-slider-thumb,
73
+ :root[data-theme="plain-light"] .ef-slider::-webkit-slider-thumb,
74
+ [data-theme="galaxy"] .ef-slider::-webkit-slider-thumb,
75
+ [data-theme="plain-dark"] .ef-slider::-webkit-slider-thumb,
76
+ [data-theme="light"] .ef-slider::-webkit-slider-thumb,
77
+ [data-theme="plain-light"] .ef-slider::-webkit-slider-thumb,
78
+ .ef-slider[data-ef-theme="galaxy"]::-webkit-slider-thumb,
79
+ .ef-slider[data-ef-theme="plain-dark"]::-webkit-slider-thumb,
80
+ .ef-slider[data-ef-theme="light"]::-webkit-slider-thumb,
81
+ .ef-slider[data-ef-theme="plain-light"]::-webkit-slider-thumb {
82
+ background:
83
+ linear-gradient(var(--ef-surface), var(--ef-surface)) padding-box,
84
+ var(--ef-border-gradient) border-box;
85
+ border-color: transparent;
86
+ background-clip: padding-box, border-box;
87
+ background-origin: border-box;
88
+ }
89
+
90
+ :root[data-theme="galaxy"] .ef-slider::-moz-range-thumb,
91
+ :root[data-theme="plain-dark"] .ef-slider::-moz-range-thumb,
92
+ :root[data-theme="light"] .ef-slider::-moz-range-thumb,
93
+ :root[data-theme="plain-light"] .ef-slider::-moz-range-thumb,
94
+ [data-theme="galaxy"] .ef-slider::-moz-range-thumb,
95
+ [data-theme="plain-dark"] .ef-slider::-moz-range-thumb,
96
+ [data-theme="light"] .ef-slider::-moz-range-thumb,
97
+ [data-theme="plain-light"] .ef-slider::-moz-range-thumb,
98
+ .ef-slider[data-ef-theme="galaxy"]::-moz-range-thumb,
99
+ .ef-slider[data-ef-theme="plain-dark"]::-moz-range-thumb,
100
+ .ef-slider[data-ef-theme="light"]::-moz-range-thumb,
101
+ .ef-slider[data-ef-theme="plain-light"]::-moz-range-thumb {
102
+ background:
103
+ linear-gradient(var(--ef-surface), var(--ef-surface)) padding-box,
104
+ var(--ef-border-gradient) border-box;
105
+ border-color: transparent;
106
+ background-clip: padding-box, border-box;
107
+ background-origin: border-box;
108
+ }
109
+
110
+ :root[data-theme="atelier"] .ef-slider,
111
+ [data-theme="atelier"] .ef-slider,
112
+ .ef-slider[data-ef-theme="atelier"] {
113
+ border-radius: var(--ef-slider-track-radius, 0px);
114
+ }
@@ -0,0 +1,33 @@
1
+ import { useEffect, useState, type InputHTMLAttributes } from "react";
2
+
3
+ type SliderProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type">;
4
+
5
+ const getTheme = () =>
6
+ document.documentElement.dataset.theme || document.body.dataset.theme || "";
7
+
8
+ export const Slider = ({ className, ...props }: SliderProps) => {
9
+ const [theme, setTheme] = useState("");
10
+
11
+ useEffect(() => {
12
+ setTheme(getTheme());
13
+ const observer = new MutationObserver(() => setTheme(getTheme()));
14
+ observer.observe(document.documentElement, {
15
+ attributes: true,
16
+ attributeFilter: ["data-theme"],
17
+ });
18
+ observer.observe(document.body, {
19
+ attributes: true,
20
+ attributeFilter: ["data-theme"],
21
+ });
22
+ return () => observer.disconnect();
23
+ }, []);
24
+
25
+ return (
26
+ <input
27
+ type="range"
28
+ className={["ef-slider", className].filter(Boolean).join(" ")}
29
+ data-ef-theme={theme || undefined}
30
+ {...props}
31
+ />
32
+ );
33
+ };
@@ -0,0 +1,180 @@
1
+ .ef-stacked-frame {
2
+ position: relative;
3
+ padding: 2px 2px 15px;
4
+ border-radius: 24px;
5
+ background: var(--ef-border-gradient);
6
+ box-shadow: var(--ef-stacked-frame-shadow);
7
+ }
8
+
9
+ .ef-stacked-frame::after {
10
+ content: "";
11
+ position: absolute;
12
+ left: 6px;
13
+ right: 6px;
14
+ bottom: -6px;
15
+ height: 10px;
16
+ border-radius: 999px;
17
+ background: var(--ef-border-gradient);
18
+ filter: blur(12px);
19
+ opacity: 0.7;
20
+ }
21
+
22
+ .ef-stacked-card {
23
+ position: relative;
24
+ padding: 20px;
25
+ border-radius: 22px;
26
+ background: var(--ef-stacked-card-bg);
27
+ overflow: hidden;
28
+ box-shadow: var(--ef-stacked-card-shadow);
29
+ min-height: 320px;
30
+ display: flex;
31
+ flex-direction: column;
32
+ justify-content: flex-end;
33
+ }
34
+
35
+ .ef-stacked-frame:hover .ef-stacked-card {
36
+ box-shadow: var(--ef-stacked-card-shadow-hover);
37
+ }
38
+
39
+ .ef-stacked-image {
40
+ position: absolute;
41
+ inset: 0;
42
+ border-radius: 18px;
43
+ background:
44
+ radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.16), transparent 55%),
45
+ var(--ef-stacked-image-gradient);
46
+ box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.35);
47
+ z-index: 0;
48
+ }
49
+
50
+ .ef-stacked-image-photo {
51
+ background-repeat: no-repeat;
52
+ }
53
+
54
+ .ef-stacked-image::after {
55
+ content: "";
56
+ position: absolute;
57
+ inset: 0;
58
+ border-radius: 18px;
59
+ background: radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.2), transparent 55%);
60
+ mix-blend-mode: screen;
61
+ }
62
+
63
+ .ef-stacked-body {
64
+ position: relative;
65
+ padding: 20px 16px 12px;
66
+ min-height: 100%;
67
+ text-align: center;
68
+ z-index: 2;
69
+ display: grid;
70
+ gap: 12px;
71
+ justify-items: center;
72
+ justify-content: center;
73
+ color: var(--text-strong);
74
+ }
75
+
76
+ .ef-stacked-body::before {
77
+ content: "";
78
+ position: absolute;
79
+ inset: -40px -20px -20px;
80
+ background: linear-gradient(
81
+ 180deg,
82
+ rgba(9, 12, 18, 0) 0%,
83
+ rgba(9, 12, 18, 0.78) 55%,
84
+ rgba(9, 12, 18, 0.9) 100%
85
+ );
86
+ z-index: -1;
87
+ }
88
+
89
+ .ef-stacked-body-plain {
90
+ position: relative;
91
+ padding: 20px 16px 12px;
92
+ text-align: center;
93
+ z-index: 2;
94
+ display: grid;
95
+ gap: 12px;
96
+ justify-items: center;
97
+ color: var(--text-strong);
98
+ }
99
+
100
+ .ef-stacked-body-plain::before {
101
+ content: none;
102
+ }
103
+
104
+ .ef-stacked-body-left {
105
+ text-align: left;
106
+ justify-items: start;
107
+ justify-content: start;
108
+ align-content: start;
109
+ }
110
+
111
+ .ef-stacked-body h3 {
112
+ margin: 0 0 8px;
113
+ font-size: 1.4rem;
114
+ }
115
+
116
+ .ef-stacked-body p {
117
+ margin: 0;
118
+ color: var(--ef-stacked-body-muted);
119
+ }
120
+
121
+ .ef-stacked-tags {
122
+ display: flex;
123
+ gap: 8px;
124
+ flex-wrap: wrap;
125
+ justify-content: center;
126
+ }
127
+
128
+ .ef-stacked-tags-left {
129
+ justify-content: flex-start;
130
+ }
131
+
132
+ .ef-stacked-tag {
133
+ padding: 8px 18px;
134
+ border-radius: 8px;
135
+ background: var(--ef-stacked-tag-bg);
136
+ border: 2px solid transparent;
137
+ font-size: 0.7rem;
138
+ letter-spacing: 0.08em;
139
+ text-transform: uppercase;
140
+ color: var(--ef-stacked-tag-text);
141
+ }
142
+
143
+ .ef-stacked-action {
144
+ margin-top: 4px;
145
+ padding: 12px 32px;
146
+ border-radius: 8px;
147
+ font-weight: 600;
148
+ font-family: inherit;
149
+ text-transform: uppercase;
150
+ letter-spacing: 0.06em;
151
+ cursor: pointer;
152
+ border: 2px solid transparent;
153
+ min-width: 190px;
154
+ text-decoration: none;
155
+ display: inline-flex;
156
+ align-items: center;
157
+ justify-content: center;
158
+ align-self: center;
159
+ background:
160
+ linear-gradient(var(--ef-surface), var(--ef-surface)) padding-box,
161
+ var(--ef-border-gradient) border-box;
162
+ color: var(--text-strong);
163
+ box-shadow: var(--ef-stacked-action-shadow);
164
+ }
165
+
166
+ .ef-stacked-variant-games .ef-stacked-image {
167
+ background: linear-gradient(160deg, rgba(255, 152, 0, 0.6), rgba(255, 87, 34, 0.25));
168
+ }
169
+
170
+ .ef-stacked-variant-mods .ef-stacked-image {
171
+ background: linear-gradient(160deg, rgba(76, 175, 80, 0.6), rgba(0, 188, 212, 0.25));
172
+ }
173
+
174
+ .ef-stacked-variant-servers .ef-stacked-image {
175
+ background: linear-gradient(160deg, rgba(33, 150, 243, 0.6), rgba(103, 58, 183, 0.25));
176
+ }
177
+
178
+ .ef-stacked-variant-apps {
179
+ --ef-stacked-image-gradient: linear-gradient(160deg, rgba(255, 179, 0, 0.6), rgba(255, 87, 34, 0.25));
180
+ }
@@ -0,0 +1,125 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+
3
+ type Variant = "games" | "mods" | "servers" | "apps";
4
+ type Align = "center" | "left";
5
+ type Tone = "default" | "plain";
6
+
7
+ type Action = {
8
+ label: string;
9
+ href?: string;
10
+ onClick?: () => void;
11
+ };
12
+
13
+ type StackedCardProps = {
14
+ title?: string;
15
+ description?: string;
16
+ tags?: string[];
17
+ imageUrl?: string | null;
18
+ variant?: Variant;
19
+ action?: Action;
20
+ align?: Align;
21
+ tone?: Tone;
22
+ showImage?: boolean;
23
+ children?: ReactNode;
24
+ frameClassName?: string;
25
+ cardClassName?: string;
26
+ bodyClassName?: string;
27
+ id?: string;
28
+ style?: CSSProperties;
29
+ };
30
+
31
+ const variantClass = (variant?: Variant) => {
32
+ switch (variant) {
33
+ case "games":
34
+ return "ef-stacked-variant-games";
35
+ case "mods":
36
+ return "ef-stacked-variant-mods";
37
+ case "servers":
38
+ return "ef-stacked-variant-servers";
39
+ case "apps":
40
+ return "ef-stacked-variant-apps";
41
+ default:
42
+ return "";
43
+ }
44
+ };
45
+
46
+ const alignClass = (align?: Align) => (align === "left" ? "ef-stacked-body-left" : "");
47
+ const toneClass = (tone?: Tone) => (tone === "plain" ? "ef-stacked-body-plain" : "ef-stacked-body");
48
+
49
+ export const StackedCard = ({
50
+ title = "",
51
+ description = "",
52
+ tags,
53
+ imageUrl,
54
+ variant,
55
+ action,
56
+ align = "center",
57
+ tone = "default",
58
+ showImage = true,
59
+ children,
60
+ frameClassName,
61
+ cardClassName,
62
+ bodyClassName,
63
+ id,
64
+ style,
65
+ }: StackedCardProps) => {
66
+ const bodyClasses = [toneClass(tone), alignClass(align), bodyClassName]
67
+ .filter(Boolean)
68
+ .join(" ");
69
+ return (
70
+ <article
71
+ className={["ef-stacked-frame", variantClass(variant), frameClassName].filter(Boolean).join(" ")}
72
+ id={id}
73
+ style={style}
74
+ >
75
+ <div className={["ef-stacked-card", cardClassName].filter(Boolean).join(" ")}>
76
+ {showImage ? (
77
+ <div
78
+ className={["ef-stacked-image", imageUrl ? "ef-stacked-image-photo" : ""]
79
+ .filter(Boolean)
80
+ .join(" ")}
81
+ style={
82
+ imageUrl
83
+ ? {
84
+ backgroundImage: `linear-gradient(180deg, rgba(10, 12, 22, 0.1), rgba(10, 12, 22, 0.85)), url(${imageUrl})`,
85
+ backgroundSize: "cover",
86
+ backgroundPosition: "center",
87
+ }
88
+ : undefined
89
+ }
90
+ />
91
+ ) : null}
92
+ <div className={bodyClasses}>
93
+ {children ? (
94
+ children
95
+ ) : (
96
+ <>
97
+ <h3>{title}</h3>
98
+ <p>{description}</p>
99
+ {tags?.length ? (
100
+ <div className={`ef-stacked-tags ${align === "left" ? "ef-stacked-tags-left" : ""}`}>
101
+ {tags.map((tag) => (
102
+ <span key={tag} className="ef-stacked-tag">
103
+ {tag}
104
+ </span>
105
+ ))}
106
+ </div>
107
+ ) : null}
108
+ </>
109
+ )}
110
+ {action ? (
111
+ action.href ? (
112
+ <a className="ef-stacked-action" href={action.href}>
113
+ {action.label}
114
+ </a>
115
+ ) : (
116
+ <button className="ef-stacked-action" type="button" onClick={action.onClick}>
117
+ {action.label}
118
+ </button>
119
+ )
120
+ ) : null}
121
+ </div>
122
+ </div>
123
+ </article>
124
+ );
125
+ };