diff --git a/OnlyPrompt.Frontend/js/node-pwa.js b/OnlyPrompt.Frontend/js/node-pwa.js index 960965f..b3a8a26 100644 --- a/OnlyPrompt.Frontend/js/node-pwa.js +++ b/OnlyPrompt.Frontend/js/node-pwa.js @@ -211,9 +211,9 @@ export class Component extends EventTarget { throw new Error("Component definition is not defined"); } - onInit() {} - onBeforeRender() {} - onAfterRender() {} + onInit() { } + onBeforeRender() { } + onAfterRender() { } onDestroy() { this._eventBindings.forEach((b) => b.detach()); } @@ -221,26 +221,21 @@ export class Component extends EventTarget { // ─── Router init ─── -export function initRouter(outletId, includeId, routes) { +export function initRouter(outletId, routes) { const outletRef = document.getElementById(outletId); - const includeRef = document.getElementById(includeId); if (!outletRef) { console.error(`Outlet element with id '${outletId}' not found`); return null; } - if (!includeRef) { - console.error(`Include element with id '${includeId}' not found`); - return null; - } - return new Router(outletRef, includeRef, routes); + return new Router(outletRef, routes); } // ─── Router ─── export class Router { - constructor(outletRef, includeRef, routes) { + constructor(outletRef, routes) { this.outletRef = outletRef; - this.includeRef = includeRef; + this.shadowRoot = outletRef.attachShadow({ mode: "open" }); this.routes = routes; this.templateCache = new Map(); this.activeController = null; @@ -248,8 +243,7 @@ export class Router { this._boundUpdateHandler = null; this._rendering = false; this._pendingUpdate = false; - outletRef.textContent = "Loading..."; - includeRef.innerHTML = ""; + this.shadowRoot.textContent = "Loading..."; navigation.addEventListener("navigate", this.handleNavigate.bind(this)); } @@ -312,7 +306,10 @@ export class Router { } insertIncludes(route) { - this.includeRef.innerHTML = ""; + // Remove previous component styles from shadow root + this.shadowRoot + .querySelectorAll("link[data-component-style]") + .forEach((l) => l.remove()); const paths = route.ComponentDefinition.stylesPath; if (!paths) return; const list = Array.isArray(paths) ? paths : [paths]; @@ -320,7 +317,8 @@ export class Router { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = p; - this.includeRef.appendChild(link); + link.setAttribute("data-component-style", ""); + this.shadowRoot.appendChild(link); } } @@ -335,13 +333,20 @@ export class Router { const result = this.activeTemplate.render(this.activeController); this.activeRenderResult = result; - // Clear outlet, append fragment - this.outletRef.textContent = ""; - this.outletRef.appendChild(result.fragment); + // Clear shadow root content (keep style links), append fragment + for (const child of Array.from(this.shadowRoot.childNodes)) { + if ( + child.nodeType !== Node.ELEMENT_NODE || + !child.hasAttribute("data-component-style") + ) { + child.remove(); + } + } + this.shadowRoot.appendChild(result.fragment); - // Wire events + two-way bindings on the live DOM - this.wireEvents(this.activeController, this.outletRef); - this.wireViewChildren(this.activeController, this.outletRef); + // Wire events + two-way bindings on the live shadow DOM + this.wireEvents(this.activeController, this.shadowRoot); + this.wireViewChildren(this.activeController, this.shadowRoot); this.activeController.onAfterRender(); this._rendering = false; diff --git a/OnlyPrompt.Frontend/js/node-test.js b/OnlyPrompt.Frontend/js/node-test.js index a91333c..d405f5b 100644 --- a/OnlyPrompt.Frontend/js/node-test.js +++ b/OnlyPrompt.Frontend/js/node-test.js @@ -1,11 +1,5 @@ -import { - initRouter, - Route, - ViewChild, - Component, - ComponentInput, - ComponentDefinition, -} from "./node-pwa.js"; +import { initRouter, Route, ViewChild, Component, ComponentInput, ComponentDefinition } from "./node-pwa.js"; +import { IntInput } from "./utils.js"; class TestComponent extends Component { static get definition() { @@ -21,14 +15,15 @@ class TestComponent extends Component { }); } - count = new ComponentInput(0); + count = new ComponentInput(0, IntInput); name = new ComponentInput("World"); incrementCount() { - this.count++; + const currentCount = this.count(); + this.count.set(currentCount + 1); } } const route1 = new Route("counter", TestComponent); const route2 = new Route("counter/:count", TestComponent); -initRouter("routerOutlet", "styleOutlet", [route1, route2]); +initRouter("routerOutlet", [route1, route2]); diff --git a/OnlyPrompt.Frontend/js/utils.js b/OnlyPrompt.Frontend/js/utils.js new file mode 100644 index 0000000..39b9f2d --- /dev/null +++ b/OnlyPrompt.Frontend/js/utils.js @@ -0,0 +1,176 @@ +// ─── parseDate (C# DateTime.ParseExact style) ─── +// Parses a date string using an exact format pattern. +// Supported tokens: +// yyyy — 4-digit year +// yy — 2-digit year (assumes 2000+) +// MM — 2-digit month (01–12) +// M — 1 or 2-digit month +// dd — 2-digit day (01–31) +// d — 1 or 2-digit day +// HH — 2-digit hour 24h (00–23) +// H — 1 or 2-digit hour 24h +// hh — 2-digit hour 12h (01–12) +// h — 1 or 2-digit hour 12h +// mm — 2-digit minute (00–59) +// m — 1 or 2-digit minute +// ss — 2-digit second (00–59) +// s — 1 or 2-digit second +// fff — 3-digit milliseconds +// ff — 2-digit (tenths + hundredths) +// f — 1-digit (tenths) +// tt — AM/PM +// t — A/P +// Z — literal 'Z' (UTC marker) +// K — timezone offset (+HH:mm, -HH:mm, or Z) +// +// All other characters are matched literally. +// Returns Date on success, null on failure. + +const TOKEN_ORDER = [ + "yyyy", "yy", + "MM", "M", + "dd", "d", + "HH", "H", "hh", "h", + "mm", "m", + "ss", "s", + "fff", "ff", "f", + "tt", "t", + "K", "Z", +]; + +const TOKEN_PATTERNS = { + yyyy: "(\\d{4})", + yy: "(\\d{2})", + MM: "(\\d{2})", + M: "(\\d{1,2})", + dd: "(\\d{2})", + d: "(\\d{1,2})", + HH: "(\\d{2})", + H: "(\\d{1,2})", + hh: "(\\d{2})", + h: "(\\d{1,2})", + mm: "(\\d{2})", + m: "(\\d{1,2})", + ss: "(\\d{2})", + s: "(\\d{1,2})", + fff: "(\\d{3})", + ff: "(\\d{2})", + f: "(\\d{1})", + tt: "(AM|PM|am|pm)", + t: "([AaPp])", + K: "(Z|[+-]\\d{2}:\\d{2})", + Z: "(Z)", +}; + +function buildFormatRegex(format) { + const groups = []; + let regexStr = ""; + let i = 0; + + while (i < format.length) { + let matched = false; + for (const token of TOKEN_ORDER) { + if (format.startsWith(token, i)) { + groups.push(token); + regexStr += TOKEN_PATTERNS[token]; + i += token.length; + matched = true; + break; + } + } + if (!matched) { + // Escape literal character + regexStr += format[i].replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + i++; + } + } + + return { regex: new RegExp("^" + regexStr + "$"), groups }; +} + +export function parseDate(input, format) { + if (input == null || format == null) return null; + if (input instanceof Date) return input; + + const str = String(input); + const { regex, groups } = buildFormatRegex(format); + const match = str.match(regex); + if (!match) return null; + + let year = 0, month = 1, day = 1; + let hour = 0, minute = 0, second = 0, ms = 0; + let isPM = false, isAM = false; + let tzOffset = null; // minutes + + for (let g = 0; g < groups.length; g++) { + const token = groups[g]; + const val = match[g + 1]; + + switch (token) { + case "yyyy": year = parseInt(val, 10); break; + case "yy": year = 2000 + parseInt(val, 10); break; + case "MM": + case "M": month = parseInt(val, 10); break; + case "dd": + case "d": day = parseInt(val, 10); break; + case "HH": + case "H": hour = parseInt(val, 10); break; + case "hh": + case "h": hour = parseInt(val, 10); break; + case "mm": + case "m": minute = parseInt(val, 10); break; + case "ss": + case "s": second = parseInt(val, 10); break; + case "fff": ms = parseInt(val, 10); break; + case "ff": ms = parseInt(val, 10) * 10; break; + case "f": ms = parseInt(val, 10) * 100; break; + case "tt": isPM = val.toUpperCase() === "PM"; isAM = val.toUpperCase() === "AM"; break; + case "t": isPM = val.toUpperCase() === "P"; isAM = val.toUpperCase() === "A"; break; + case "Z": tzOffset = 0; break; + case "K": + if (val === "Z") { + tzOffset = 0; + } else { + const sign = val[0] === "+" ? 1 : -1; + const [h, m] = val.substring(1).split(":").map(Number); + tzOffset = sign * (h * 60 + m); + } + break; + } + } + + // 12h → 24h conversion + if (isPM && hour < 12) hour += 12; + if (isAM && hour === 12) hour = 0; + + if (tzOffset !== null) { + // Build UTC date, apply offset + const utcMs = Date.UTC(year, month - 1, day, hour, minute, second, ms); + return new Date(utcMs - tzOffset * 60000); + } + + return new Date(year, month - 1, day, hour, minute, second, ms); +} + +// ─── Input transform presets ─── + +export const IntInput = { transform: parseInt }; +export const FloatInput = { transform: parseFloat }; +export const BoolInput = { + transform: (v) => { + if (typeof v === "boolean") return v; + if (typeof v === "string") { + if (v.toLowerCase() === "true") return true; + if (v.toLowerCase() === "false") return false; + } + return Boolean(v); + }, +}; + +export function DateInput(format) { + return { + transform: (v) => parseDate(v, format), + }; +} + +export const IsoDateInput = DateInput("yyyy-MM-ddTHH:mm:ssK"); \ No newline at end of file