diff --git a/app/notification-generator/page.tsx b/app/notification-generator/page.tsx index d1245b4b..7c07dab0 100644 --- a/app/notification-generator/page.tsx +++ b/app/notification-generator/page.tsx @@ -1,10 +1,8 @@ "use client"; -import { Play } from "lucide-react"; import dynamic from "next/dynamic"; import { useCallback, useEffect, useState } from "react"; import type { NotificationConfig } from "@/components/notification-generator/types"; -import { Button } from "@/components/ui/button"; import { FadeIn, MotionSection, SlideIn } from "@/components/ui/motion/motion-components"; const NotificationGeneratedCode = dynamic( @@ -21,14 +19,6 @@ const NotificationGeneratorForm = dynamic( ), { ssr: false } ); -const MinecraftPreview = dynamic( - () => - import("@/components/notification-generator/preview/minecraft-preview").then( - (mod) => mod.MinecraftPreview - ), - { ssr: false } -); - export default function NotificationGeneratorPage() { const [notification, setNotification] = useState({ chat: "", @@ -46,74 +36,66 @@ export default function NotificationGeneratorPage() { }); const [yamlCode, setYamlCode] = useState(""); - const [previewKey, setPreviewKey] = useState(0); - const generateYaml = useCallback(() => generateYamlString(notification), [notification]); useEffect(() => { setYamlCode(generateYaml()); }, [generateYaml]); - const handlePlayPreview = useCallback(() => { - setPreviewKey((prev) => prev + 1); - }, []); - return ( - -
- -

- Notification Generator -

-
- -

- Design and preview your EternalCode notifications in real-time. -

-
+ +
+
+
+
-
- - - - - -
-

- Generated Configuration -

- -
-
-
+
+
+ +

+ Minecraft Notification Studio +

+
+ +

+ Notification Generator +

+
+ +

+ Build, preview, and ship polished in-game notifications with modern formatting and + legacy-friendly color support. +

+
+
- -
-
-
-

Live Preview

-

- See how your notification looks in-game. -

+
+ + + + + +
+
+

+ Output +

+

+ Generated Configuration +

+

+ Copy the YAML and paste it into the EternalCore notification config. +

+
+
- -
- -
- -
+
- +
); } diff --git a/components/notification-generator/form/color-constants.ts b/components/notification-generator/form/color-constants.ts index 504971af..bbcb0fb3 100644 --- a/components/notification-generator/form/color-constants.ts +++ b/components/notification-generator/form/color-constants.ts @@ -1,22 +1,22 @@ import type { MinecraftColor, TagCategory } from "./types"; export const minecraftColors: MinecraftColor[] = [ - { name: "Black", hex: "#000000" }, - { name: "Dark Blue", hex: "#0000AA" }, - { name: "Dark Green", hex: "#00AA00" }, - { name: "Dark Aqua", hex: "#00AAAA" }, - { name: "Dark Red", hex: "#AA0000" }, - { name: "Dark Purple", hex: "#AA00AA" }, - { name: "Gold", hex: "#FFAA00" }, - { name: "Gray", hex: "#AAAAAA" }, - { name: "Dark Gray", hex: "#555555" }, - { name: "Blue", hex: "#5555FF" }, - { name: "Green", hex: "#55FF55" }, - { name: "Aqua", hex: "#55FFFF" }, - { name: "Red", hex: "#FF5555" }, - { name: "Light Purple", hex: "#FF55FF" }, - { name: "Yellow", hex: "#FFFF55" }, - { name: "White", hex: "#FFFFFF" }, + { name: "Black", hex: "#000000", legacyCode: "&0" }, + { name: "Dark Blue", hex: "#0000AA", legacyCode: "&1" }, + { name: "Dark Green", hex: "#00AA00", legacyCode: "&2" }, + { name: "Dark Aqua", hex: "#00AAAA", legacyCode: "&3" }, + { name: "Dark Red", hex: "#AA0000", legacyCode: "&4" }, + { name: "Dark Purple", hex: "#AA00AA", legacyCode: "&5" }, + { name: "Gold", hex: "#FFAA00", legacyCode: "&6" }, + { name: "Gray", hex: "#AAAAAA", legacyCode: "&7" }, + { name: "Dark Gray", hex: "#555555", legacyCode: "&8" }, + { name: "Blue", hex: "#5555FF", legacyCode: "&9" }, + { name: "Green", hex: "#55FF55", legacyCode: "&a" }, + { name: "Aqua", hex: "#55FFFF", legacyCode: "&b" }, + { name: "Red", hex: "#FF5555", legacyCode: "&c" }, + { name: "Light Purple", hex: "#FF55FF", legacyCode: "&d" }, + { name: "Yellow", hex: "#FFFF55", legacyCode: "&e" }, + { name: "White", hex: "#FFFFFF", legacyCode: "&f" }, ]; export const gradientPresets = [ @@ -48,6 +48,10 @@ export const allowedTags: Record = { pattern: /<[a-z_]+>/, tags: minecraftColors.map((color) => `<${color.name.toLowerCase().replace(/ /g, "_")}>`), }, + legacy: { + pattern: /(?:§|&)[0-9a-fk-or]/i, + tags: [...minecraftColors.map((color) => color.legacyCode), "&k", "&l", "&m", "&n", "&o", "&r"], + }, clickUrl: { pattern: /<\/click>/, tags: [""], diff --git a/components/notification-generator/form/color-picker/color-picker.tsx b/components/notification-generator/form/color-picker/color-picker.tsx index 9380f566..a85b4ce0 100644 --- a/components/notification-generator/form/color-picker/color-picker.tsx +++ b/components/notification-generator/form/color-picker/color-picker.tsx @@ -9,9 +9,10 @@ import { gradientPresets, minecraftColors } from "../color-constants"; import type { ColorPickerProps, MinecraftColor } from "../types"; export const ColorPicker = ({ onApplyAction, onCloseAction }: ColorPickerProps) => { - const [activeTab, setActiveTab] = useState<"solid" | "gradient" | "presets">("solid"); + const [activeTab, setActiveTab] = useState<"solid" | "gradient" | "presets" | "legacy">("solid"); const [solidColor, setSolidColor] = useState("#FFFFFF"); const [gradientColors, setGradientColors] = useState(["#55FFFF", "#FF55FF"]); + const [legacyColor, setLegacyColor] = useState(minecraftColors[0]); const [_copied, _setCopied] = useState(false); // Sync solid color with first gradient color for smoother transitions @@ -25,10 +26,16 @@ export const ColorPicker = ({ onApplyAction, onCloseAction }: ColorPickerProps) if (activeTab === "gradient") { const result = ``; onApplyAction(result, true, gradientColors); - } else { - const result = ``; - onApplyAction(result, false, [solidColor]); + return; } + + if (activeTab === "legacy") { + onApplyAction(legacyColor.legacyCode, false, [legacyColor.hex]); + return; + } + + const result = ``; + onApplyAction(result, false, [solidColor]); }; const handlePresetClick = (color: MinecraftColor) => { @@ -89,7 +96,7 @@ export const ColorPicker = ({ onApplyAction, onCloseAction }: ColorPickerProps) {/* Tabs */}
- {(["solid", "gradient", "presets"] as const).map((tab) => ( + {(["solid", "gradient", "presets", "legacy"] as const).map((tab) => ( + ))} +
+
+ +
+ Legacy codes are classic Minecraft formatting codes that work in older configs. +
+ + )} {activeTab !== "presets" && ( @@ -265,7 +314,15 @@ export const ColorPicker = ({ onApplyAction, onCloseAction }: ColorPickerProps) onClick={handleApply} type="button" > - Apply {activeTab === "gradient" ? "Gradient" : "Color"} + {(() => { + if (activeTab === "gradient") { + return "Apply Gradient"; + } + if (activeTab === "legacy") { + return "Insert Legacy Code"; + } + return "Apply Color"; + })()} )}
diff --git a/components/notification-generator/form/formatting/tag-utils.ts b/components/notification-generator/form/formatting/tag-utils.ts index 3a0bf301..0d37523d 100644 --- a/components/notification-generator/form/formatting/tag-utils.ts +++ b/components/notification-generator/form/formatting/tag-utils.ts @@ -1,5 +1,7 @@ import { allowedTags } from "../color-constants"; +const LEGACY_CODE_PATTERN = /^(?:&|§)[0-9a-fk-or]$/i; + export const isValidTag = (tag: string): boolean => Object.values(allowedTags).some( (category) => category.pattern.test(tag) || category.tags.includes(tag) @@ -46,6 +48,23 @@ export const insertTag = ( console.warn(`Invalid tag format: ${tag}`); return { newValue: text, newCursorPosition: end }; } + + if (LEGACY_CODE_PATTERN.test(tag)) { + if (start !== end) { + const selectedText = text.substring(start, end); + const resetCode = tag.startsWith("§") ? "§r" : "&r"; + const wrappedText = `${tag}${selectedText}${resetCode}`; + return { + newValue: text.substring(0, start) + wrappedText + text.substring(end), + newCursorPosition: start + wrappedText.length, + }; + } + return { + newValue: text.substring(0, start) + tag + text.substring(end), + newCursorPosition: start + tag.length, + }; + } + if (start !== end) { const selectedText = text.substring(start, end); const tagWithContent = tag.replace(">${selectedText}("chat"); const [errors, setErrors] = useState>({}); + const [previewSeed, setPreviewSeed] = useState(0); const soundTabRef = useRef(null); const handleChange = useCallback( @@ -70,6 +72,10 @@ export function NotificationGenerator({ notification, setNotification }: Notific setErrors({}); }, [setNotification]); + const handleReplayPreview = useCallback(() => { + setPreviewSeed((prev) => prev + 1); + }, []); + const tabContent = useMemo(() => { switch (activeTab) { case "chat": @@ -94,6 +100,28 @@ export function NotificationGenerator({ notification, setNotification }: Notific } }, [activeTab, notification, handleChange, errors]); + const previewTitle = useMemo(() => { + const labels: Record = { + chat: "Chat Preview", + actionbar: "Action Bar Preview", + title: "Title Preview", + sound: "Sound Preview", + advanced: "Full Notification Preview", + }; + return labels[activeTab]; + }, [activeTab]); + + const previewDescription = useMemo(() => { + const descriptions: Record = { + chat: "Your message sits directly on the game view without the old chat box.", + actionbar: "Action bar text stays centered and clean for quick attention.", + title: "Titles and subtitles use the timing you set below.", + sound: "Trigger a sound cue and preview the overlay indicator.", + advanced: "Preview how all notification layers work together.", + }; + return descriptions[activeTab]; + }, [activeTab]); + const tabs = useMemo(() => { const tabItems: TabType[] = ["chat", "actionbar", "title", "sound", "advanced"]; return tabItems.map((tabName) => ( @@ -108,48 +136,90 @@ export function NotificationGenerator({ notification, setNotification }: Notific }, [activeTab]); return ( -
+
+
-
+
+
+
+

+ Notification Builder +

+

+ Craft the perfect message +

+

+ Switch between tabs to fine-tune every delivery channel. +

+
+ +
+
{tabs}
-
- + + + {tabContent} + + + +
+
+
+

+ {previewTitle} +

+

{previewDescription}

+
+ + Live + +
+ +
+ - {tabContent} + -
- - - - +
); diff --git a/components/notification-generator/preview/minecraft-preview.tsx b/components/notification-generator/preview/minecraft-preview.tsx index 8ddf6cd5..dcaad14b 100644 --- a/components/notification-generator/preview/minecraft-preview.tsx +++ b/components/notification-generator/preview/minecraft-preview.tsx @@ -1,18 +1,25 @@ "use client"; -import { AnimatePresence } from "framer-motion"; -import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import ActionBar from "@/components/notification-generator/tabs/actionbar/action-bar"; -import ChatMessage from "@/components/notification-generator/tabs/chat/chat-message"; import SoundIndicator from "@/components/notification-generator/tabs/sound/sound-indicator"; import Title from "@/components/notification-generator/tabs/title/title"; import { useSoundEffect, useTitleAnimation } from "../hooks"; import type { MinecraftPreviewProps } from "../types"; import BackgroundImage from "./background-image"; +import { MinecraftText } from "./minecraft-text-parser"; -export function MinecraftPreview({ notification }: MinecraftPreviewProps) { +const previewMotion = { + initial: { opacity: 0, y: 8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -8 }, + transition: { duration: 0.25 }, +}; + +export function MinecraftPreview({ notification, activeTab }: MinecraftPreviewProps) { const { showTitle, titleOpacity } = useTitleAnimation(notification); - const { playSound } = useSoundEffect(notification.sound); + const { playSound, setPlaySound } = useSoundEffect(notification.sound); const rootRef = useRef(null); const [scaleVars, setScaleVars] = useState>({}); @@ -23,44 +30,69 @@ export function MinecraftPreview({ notification }: MinecraftPreviewProps) { return; } - const rect = el.getBoundingClientRect(); - const baseHeight = 540; - const scale = Math.max(0.5, rect.height / baseHeight); - - const cssVars: Record = { - "--mc-scale": scale.toString(), - "--mc-font-size": "calc(8px * var(--mc-scale))", - "--mc-line-height": "calc(9px * var(--mc-scale))", - "--mc-shadow": "calc(1px * var(--mc-scale))", - - "--mc-chat-left": "calc(4px * var(--mc-scale))", - "--mc-chat-bottom": "calc(48px * var(--mc-scale))", - "--mc-chat-width": "calc(320px * var(--mc-scale))", + const updateScale = () => { + const rect = el.getBoundingClientRect(); + const baseHeight = 540; + const scale = Math.max(0.55, rect.height / baseHeight); - "--mc-actionbar-bottom": "calc(67px * var(--mc-scale))", + const cssVars: Record = { + "--mc-scale": scale.toString(), + "--mc-font-size": "calc(8px * var(--mc-scale))", + "--mc-line-height": "calc(9px * var(--mc-scale))", + "--mc-shadow": "calc(1px * var(--mc-scale))", + "--mc-actionbar-bottom": "calc(72px * var(--mc-scale))", + "--mc-title-font-size": "calc(32px * var(--mc-scale))", + "--mc-subtitle-font-size": "calc(16px * var(--mc-scale))", + }; - "--mc-title-font-size": "calc(32px * var(--mc-scale))", - "--mc-subtitle-font-size": "calc(16px * var(--mc-scale))", + setScaleVars(cssVars); }; - setScaleVars(cssVars); + updateScale(); + const observer = new ResizeObserver(updateScale); + observer.observe(el); + + return () => observer.disconnect(); }, []); - const chatComponent = useMemo(() => { + useEffect(() => { + if ((activeTab === "sound" || activeTab === "advanced") && notification.sound) { + setPlaySound(true); + } + }, [activeTab, notification.sound, setPlaySound]); + + const chatLines = useMemo(() => { if (!notification.chat) { return null; } + + const lines = notification.chat.split("\n").filter(Boolean); + if (lines.length === 0) { + return null; + } + + const counts = new Map(); + const keyedLines = lines.map((line) => { + const count = counts.get(line) ?? 0; + counts.set(line, count + 1); + return { id: `${line}-${count}`, text: line }; + }); + return (
- + {keyedLines.map((line) => ( +
+ +
+ ))}
); }, [notification.chat]); @@ -71,13 +103,10 @@ export function MinecraftPreview({ notification }: MinecraftPreviewProps) { } return (
@@ -86,55 +115,159 @@ export function MinecraftPreview({ notification }: MinecraftPreviewProps) { }, [notification.actionbar]); const titleComponent = useMemo(() => { - if (!showTitle) { + if (!(showTitle && (activeTab === "title" || activeTab === "advanced"))) { return null; } return ( -
- - </div> + <Title + showTitle={showTitle} + subtitle={notification.subtitle} + title={notification.title} + titleOpacity={titleOpacity} + /> ); - }, [showTitle, notification.title, notification.subtitle, titleOpacity]); + }, [showTitle, notification.title, notification.subtitle, titleOpacity, activeTab]); const soundComponent = useMemo(() => { - if (!playSound) { + if (!(playSound && (activeTab === "sound" || activeTab === "advanced"))) { + return null; + } + return <SoundIndicator playSound={playSound} sound={notification.sound} />; + }, [playSound, notification.sound, activeTab]); + + const soundLabel = useMemo(() => { + if (!(notification.sound && (activeTab === "sound" || activeTab === "advanced"))) { return null; } return ( - <div> - <SoundIndicator playSound={playSound} sound={notification.sound} /> + <div + className="absolute bottom-6 left-6 z-10 rounded-lg border border-white/10 bg-black/60 px-3 py-2 text-white" + style={{ + fontSize: "var(--mc-font-size)", + lineHeight: "var(--mc-line-height)", + textShadow: + "var(--mc-shadow) 0 0 #000, calc(-1 * var(--mc-shadow)) 0 0 #000, 0 var(--mc-shadow) 0 #000, 0 calc(-1 * var(--mc-shadow)) 0 #000", + }} + > + <span className="block text-[10px] text-white/70 uppercase tracking-[0.2em]"> + Selected Sound + </span> + <MinecraftText text={notification.sound} /> </div> ); - }, [playSound, notification.sound]); + }, [notification.sound, activeTab]); + + const placeholder = useMemo(() => { + const messageMap: Record<string, string> = { + chat: "Type a chat message to preview it here", + actionbar: "Add an action bar message to preview it here", + title: "Add a title or subtitle to preview it here", + sound: "Pick a sound to preview it here", + advanced: "Configure any notification to preview it here", + }; + const message = messageMap[activeTab] || "Start building your notification"; + + return ( + <motion.div + className="absolute inset-0 flex items-center justify-center text-center text-white/70" + {...previewMotion} + > + <div + className="rounded-2xl border border-white/10 bg-black/40 px-6 py-4 text-xs uppercase tracking-wide" + style={{ + fontSize: "var(--mc-font-size)", + lineHeight: "var(--mc-line-height)", + textShadow: + "var(--mc-shadow) 0 0 #000, calc(-1 * var(--mc-shadow)) 0 0 #000, 0 var(--mc-shadow) 0 #000, 0 calc(-1 * var(--mc-shadow)) 0 #000", + }} + > + {message} + </div> + </motion.div> + ); + }, [activeTab]); + + const hasAnyContent = + activeTab === "advanced" + ? notification.chat || + notification.actionbar || + notification.title || + notification.subtitle || + notification.sound + : { + chat: notification.chat, + actionbar: notification.actionbar, + title: notification.title || notification.subtitle, + sound: notification.sound, + }[activeTab]; + + const previewContent = useMemo(() => { + if (!hasAnyContent) { + return placeholder; + } + + if (activeTab === "chat") { + return <motion.div {...previewMotion}>{chatLines}</motion.div>; + } + + if (activeTab === "actionbar") { + return <motion.div {...previewMotion}>{actionBarComponent}</motion.div>; + } + + if (activeTab === "title") { + return <motion.div {...previewMotion}>{titleComponent}</motion.div>; + } + + if (activeTab === "sound") { + return ( + <motion.div {...previewMotion}> + {soundLabel} + {soundComponent} + </motion.div> + ); + } + + return ( + <motion.div {...previewMotion}> + {chatLines} + {actionBarComponent} + {titleComponent} + {soundLabel} + {soundComponent} + </motion.div> + ); + }, [ + activeTab, + hasAnyContent, + placeholder, + chatLines, + actionBarComponent, + titleComponent, + soundLabel, + soundComponent, + ]); return ( <div aria-label="Minecraft notification preview" - className="relative overflow-hidden rounded-xl bg-black font-minecraft" + className="relative overflow-hidden rounded-2xl border border-white/10 bg-black font-minecraft shadow-2xl" ref={rootRef} role="img" style={{ width: "100%", aspectRatio: "16/9", - maxWidth: "1280px", - margin: "0 auto", ...scaleVars, }} > <BackgroundImage /> + <div className="absolute inset-0 bg-gradient-to-b from-black/10 via-black/30 to-black/80" /> + <div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.2),transparent_45%)]" /> - <AnimatePresence>{chatComponent}</AnimatePresence> - - <AnimatePresence>{actionBarComponent}</AnimatePresence> + <AnimatePresence mode="wait">{previewContent}</AnimatePresence> - <AnimatePresence>{titleComponent}</AnimatePresence> - - <AnimatePresence>{soundComponent}</AnimatePresence> + <div className="absolute right-4 bottom-3 rounded-full border border-white/10 bg-black/60 px-3 py-1 text-[10px] text-white/70 uppercase tracking-[0.2em]"> + Preview + </div> </div> ); } diff --git a/components/notification-generator/types.ts b/components/notification-generator/types.ts index 072f8372..d4bc12dc 100644 --- a/components/notification-generator/types.ts +++ b/components/notification-generator/types.ts @@ -15,6 +15,7 @@ export interface NotificationConfig { export interface MinecraftPreviewProps { notification: NotificationConfig; + activeTab: TabType; } export type TabType = "chat" | "actionbar" | "title" | "sound" | "advanced";