diff --git a/arma/client/addons/bank/ui/_site/bank.css b/arma/client/addons/bank/ui/_site/bank.css index 88b0334..e47ed66 100644 --- a/arma/client/addons/bank/ui/_site/bank.css +++ b/arma/client/addons/bank/ui/_site/bank.css @@ -4,6 +4,9 @@ --bg-surface-hover: #f1f5f9; --primary: #475569; --primary-hover: #1e293b; + --window-blue: #12325b; + --window-blue-border: #214978; + --window-blue-highlight: #d7e5f8; --text-main: #1f2937; --text-muted: #64748b; --text-inverse: #f8fafc; @@ -13,6 +16,11 @@ --footer-bg: #1e293b; } +html, +body { + height: 100%; +} + body { font-family: 'Inter', system-ui, -apple-system, sans-serif; margin: 0; @@ -20,16 +28,107 @@ body { background: var(--bg-app); color: var(--text-main); line-height: 1.6; + overflow: hidden; } #app { - min-height: 100vh; + height: 100vh; + overflow: hidden; +} + +.app-shell { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; } main { display: flex; flex-direction: column; - min-height: 100vh; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} + +.window-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1.25rem; + background: linear-gradient(180deg, var(--window-blue) 0%, #0d2643 100%); + border-bottom: 1px solid var(--window-blue-border); + color: var(--text-inverse); + box-shadow: 0 10px 24px rgb(18 50 91 / 0.24); + position: sticky; + top: 0; + z-index: 30; + flex-shrink: 0; +} + +.window-titlebar-brand { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.window-titlebar-kicker { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgb(215 229 248 / 0.78); +} + +.window-titlebar-title { + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-inverse); +} + +.window-titlebar-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.window-control-btn { + min-width: 2.5rem; + padding: 0.45rem 0.7rem; + border-radius: 6px; + border: 1px solid rgb(215 229 248 / 0.22); + background: rgb(255 255 255 / 0.08); + color: var(--window-blue-highlight); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.window-control-btn:hover { + background: rgb(255 255 255 / 0.08); + box-shadow: none; + transform: none; +} + +.window-control-btn:disabled { + opacity: 0.55; +} + +.window-control-btn.is-close { + cursor: pointer; + opacity: 1; + border-color: rgb(255 255 255 / 0.24); +} + +.window-control-btn.is-close:hover { + background: rgb(255 255 255 / 0.18); } .container { @@ -102,22 +201,6 @@ main { font-family: 'Consolas', 'Monaco', monospace; } -.btn-signout { - background: transparent; - color: var(--text-muted); - border: 1px solid var(--border); - padding: 0.5rem 1rem; - font-size: 0.85rem; - - &:hover { - background: var(--bg-surface-hover); - color: var(--primary-hover); - border-color: var(--primary); - transform: none; - box-shadow: none; - } -} - .content { display: grid; grid-template-columns: 1fr 1fr; @@ -343,3 +426,15 @@ form { } } } + +@media (max-width: 720px) { + .window-titlebar { + flex-direction: column; + align-items: flex-start; + } + + .window-titlebar-controls { + width: 100%; + justify-content: flex-end; + } +} diff --git a/arma/client/addons/bank/ui/_site/bank.js b/arma/client/addons/bank/ui/_site/bank.js index bc53805..7b5209b 100644 --- a/arma/client/addons/bank/ui/_site/bank.js +++ b/arma/client/addons/bank/ui/_site/bank.js @@ -70,16 +70,44 @@ function Navbar() { h('div', { className: 'profile-info' }, h('span', { className: 'profile-label' }, 'Account'), h('span', { className: 'profile-id' }, uid) - ), - h('button', { - className: 'btn-signout', - onClick: () => sendEvent('bank::close', {}) - }, 'Sign Out') + ) ) ) ); } +function WindowTitleBar() { + return h('div', { className: 'window-titlebar' }, + h('div', { className: 'window-titlebar-brand' }, + h('span', { className: 'window-titlebar-kicker' }, 'FDIC Workspace'), + h('span', { className: 'window-titlebar-title' }, 'Global Financial Network') + ), + h('div', { className: 'window-titlebar-controls' }, + h('button', { + type: 'button', + className: 'window-control-btn', + disabled: true, + title: 'Minimize unavailable', + 'aria-label': 'Minimize unavailable' + }, '-'), + h('button', { + type: 'button', + className: 'window-control-btn', + disabled: true, + title: 'Maximize unavailable', + 'aria-label': 'Maximize unavailable' + }, '[ ]'), + h('button', { + type: 'button', + className: 'window-control-btn is-close', + onClick: () => sendEvent('bank::close', {}), + title: 'Close', + 'aria-label': 'Close banking interface' + }, 'X') + ) + ); +} + function TransactionHistory() { const state = store.getState(); const transactions = state.transactions || []; @@ -295,12 +323,15 @@ function Footer() { } function App() { - return h('main', null, - Navbar(), - h('div', { className: 'container' }, - BankDashboard() - ), - Footer() + return h('div', { className: 'app-shell' }, + WindowTitleBar(), + h('main', null, + Navbar(), + h('div', { className: 'container' }, + BankDashboard() + ), + Footer() + ) ); } diff --git a/arma/client/addons/notifications/CfgSounds.hpp b/arma/client/addons/notifications/CfgSounds.hpp new file mode 100644 index 0000000..ae86ed6 --- /dev/null +++ b/arma/client/addons/notifications/CfgSounds.hpp @@ -0,0 +1,9 @@ +class CfgSounds { + sounds[] += {QGVAR(notify)}; + + class GVAR(notify) { + name = QGVAR(notify); + sound[] = {QPATHTOF2(sounds\notify.ogg), 1, 1}; + titles[] = {}; + }; +}; diff --git a/arma/client/addons/notifications/XEH_postInitClient.sqf b/arma/client/addons/notifications/XEH_postInitClient.sqf index 072b241..6eada6a 100644 --- a/arma/client/addons/notifications/XEH_postInitClient.sqf +++ b/arma/client/addons/notifications/XEH_postInitClient.sqf @@ -11,5 +11,6 @@ [QGVAR(recieveNotification), { params [["_type", "", [""]], ["_title", "", [""]], ["_content", "", [""]], ["_duration", 4000, [4000]]]; + playSound QGVAR(notify); GVAR(NotificationClass) call ["create", [_type, _title, _content, _duration]]; }] call CFUNC(addEventHandler); diff --git a/arma/client/addons/notifications/config.cpp b/arma/client/addons/notifications/config.cpp index fdb31cc..e458924 100644 --- a/arma/client/addons/notifications/config.cpp +++ b/arma/client/addons/notifications/config.cpp @@ -16,6 +16,7 @@ class CfgPatches { }; }; +#include "CfgSounds.hpp" #include "CfgEventHandlers.hpp" #include "ui\RscCommon.hpp" #include "ui\RscNotifications.hpp" diff --git a/arma/client/addons/notifications/sounds/notify.ogg b/arma/client/addons/notifications/sounds/notify.ogg new file mode 100644 index 0000000..06e8125 Binary files /dev/null and b/arma/client/addons/notifications/sounds/notify.ogg differ diff --git a/arma/client/addons/notifications/ui/_site/index.html b/arma/client/addons/notifications/ui/_site/index.html index b07114a..d1d769a 100644 --- a/arma/client/addons/notifications/ui/_site/index.html +++ b/arma/client/addons/notifications/ui/_site/index.html @@ -1,35 +1,44 @@ - + + + + + Forge - Notification System + + + - - - -
- - + const script = document.createElement("script"); + script.text = js; + document.head.appendChild(script); + }); + + + +
+
+
+ + diff --git a/arma/client/addons/notifications/ui/_site/script.js b/arma/client/addons/notifications/ui/_site/script.js index baf5b88..7bb10f2 100644 --- a/arma/client/addons/notifications/ui/_site/script.js +++ b/arma/client/addons/notifications/ui/_site/script.js @@ -3,10 +3,10 @@ //============================================================================= const NotificationActionTypes = { - ADD_NOTIFICATION: 'ADD_NOTIFICATION', - REMOVE_NOTIFICATION: 'REMOVE_NOTIFICATION', - CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS', - UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION' + ADD_NOTIFICATION: "ADD_NOTIFICATION", + REMOVE_NOTIFICATION: "REMOVE_NOTIFICATION", + CLEAR_NOTIFICATIONS: "CLEAR_NOTIFICATIONS", + UPDATE_NOTIFICATION: "UPDATE_NOTIFICATION", }; const notificationActions = { @@ -15,25 +15,25 @@ const notificationActions = { payload: { id: Date.now() + Math.random(), timestamp: Date.now(), - type: 'info', - title: 'Notification', - message: 'Default message', + type: "info", + title: "Notification", + message: "Default message", duration: 0, - status: 'showing', - ...notification - } + status: "showing", + ...notification, + }, }), removeNotification: (id) => ({ type: NotificationActionTypes.REMOVE_NOTIFICATION, - payload: { id } + payload: { id }, }), clearNotifications: () => ({ - type: NotificationActionTypes.CLEAR_NOTIFICATIONS + type: NotificationActionTypes.CLEAR_NOTIFICATIONS, }), updateNotification: (id, updates) => ({ type: NotificationActionTypes.UPDATE_NOTIFICATION, - payload: { id, updates } - }) + payload: { id, updates }, + }), }; //============================================================================= @@ -42,7 +42,7 @@ const notificationActions = { const notificationInitialState = { notifications: [], - maxNotifications: 5 + maxNotifications: 3, }; function notificationReducer(state = notificationInitialState, action = {}) { @@ -53,24 +53,36 @@ function notificationReducer(state = notificationInitialState, action = {}) { if (newNotifications.length >= state.maxNotifications) { newNotifications = newNotifications.slice(1); } - return { ...state, notifications: [...newNotifications, action.payload] }; + return { + ...state, + notifications: [...newNotifications, action.payload], + }; } case NotificationActionTypes.REMOVE_NOTIFICATION: { if (!action.payload || !action.payload.id) return state; return { ...state, - notifications: state.notifications.filter(n => n.id !== action.payload.id) + notifications: state.notifications.filter( + (n) => n.id !== action.payload.id, + ), }; } case NotificationActionTypes.CLEAR_NOTIFICATIONS: return { ...state, notifications: [] }; case NotificationActionTypes.UPDATE_NOTIFICATION: { - if (!action.payload || !action.payload.id || !action.payload.updates) return state; + if ( + !action.payload || + !action.payload.id || + !action.payload.updates + ) + return state; return { ...state, - notifications: state.notifications.map(n => - n.id === action.payload.id ? { ...n, ...action.payload.updates } : n - ) + notifications: state.notifications.map((n) => + n.id === action.payload.id + ? { ...n, ...action.payload.updates } + : n, + ), }; } default: @@ -95,19 +107,22 @@ class Store { dispatch(action) { this.state = this.reducer(this.state, action); - this.listeners.forEach(listener => listener(this.state)); + this.listeners.forEach((listener) => listener(this.state)); return action; } subscribe(listener) { this.listeners.push(listener); return () => { - this.listeners = this.listeners.filter(l => l !== listener); + this.listeners = this.listeners.filter((l) => l !== listener); }; } } -const notificationStore = new Store(notificationReducer, notificationInitialState); +const notificationStore = new Store( + notificationReducer, + notificationInitialState, +); //============================================================================= // #region SELECTORS @@ -115,7 +130,7 @@ const notificationStore = new Store(notificationReducer, notificationInitialStat const notificationSelectors = { getNotifications: (state) => state.notifications, - getMaxNotifications: (state) => state.maxNotifications + getMaxNotifications: (state) => state.maxNotifications, }; //============================================================================= @@ -126,13 +141,14 @@ class NotificationUI { constructor(store) { this.store = store; this.unsubscribe = null; - this.container = document.getElementById('notification-container'); + this.container = document.getElementById("notification-container"); this.renderedNotifications = new Map(); + this.dismissTimers = new Map(); } init() { if (!this.container) { - console.error('Notification container not found'); + console.error("Notification container not found"); return; } this.unsubscribe = this.store.subscribe((state) => this.render(state)); @@ -141,7 +157,13 @@ class NotificationUI { destroy() { if (this.unsubscribe) this.unsubscribe(); - this.renderedNotifications.forEach(el => { + this.dismissTimers.forEach((timers) => { + clearTimeout(timers.hideTimer); + clearTimeout(timers.removeTimer); + clearTimeout(timers.progressTimer); + }); + this.dismissTimers.clear(); + this.renderedNotifications.forEach((el) => { if (el.parentNode) el.parentNode.removeChild(el); }); this.renderedNotifications.clear(); @@ -151,16 +173,17 @@ class NotificationUI { const notifications = notificationSelectors.getNotifications(state); // Remove notifications no longer present - const currentIds = new Set(notifications.map(n => n.id)); + const currentIds = new Set(notifications.map((n) => n.id)); for (const [id, el] of this.renderedNotifications.entries()) { if (!currentIds.has(id)) { + this.clearDismissTimers(id); if (el.parentNode) el.parentNode.removeChild(el); this.renderedNotifications.delete(id); } } // Add or update notifications - notifications.forEach(notification => { + notifications.forEach((notification) => { if (!notification || !notification.id) return; if (!this.renderedNotifications.has(notification.id)) { this.createNotificationElement(notification); @@ -170,45 +193,133 @@ class NotificationUI { }); } + clearDismissTimers(id) { + const timers = this.dismissTimers.get(id); + if (!timers) return; + + clearTimeout(timers.hideTimer); + clearTimeout(timers.removeTimer); + clearTimeout(timers.progressTimer); + this.dismissTimers.delete(id); + } + + escapeHTML(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + normalizeType(type) { + const supportedTypes = new Set([ + "success", + "danger", + "warning", + "info", + ]); + return supportedTypes.has(type) ? type : "info"; + } + + formatTypeLabel(type) { + const labels = { + success: "Success", + danger: "Critical", + warning: "Warning", + info: "Info", + }; + return labels[this.normalizeType(type)] || labels.info; + } + + getDurationLabel(duration) { + if (!(duration > 0)) return "Pinned"; + const seconds = Math.max(1, Math.round(duration / 100) / 10); + return `${seconds.toFixed(1)}s`; + } + + getTimestampLabel(timestamp) { + const date = new Date(timestamp || Date.now()); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${hours}:${minutes}`; + } + createNotificationElement(notification) { - const el = document.createElement('div'); - el.className = `notification ${notification.type || 'info'}`; + const type = this.normalizeType(notification.type); + const title = this.escapeHTML(notification.title || "Notification"); + const message = this.escapeHTML(notification.message || "No message"); + const isPersistent = !(notification.duration > 0); + const el = document.createElement("div"); + el.className = `notification ${type}${isPersistent ? " is-persistent" : ""}`; el.dataset.id = notification.id; el.innerHTML = ` -
-
${notification.title || 'Notification'}
+
+
+
${title}
+
Forge alert
+
+
+
+ ${this.formatTypeLabel(type)} + ${this.getTimestampLabel(notification.timestamp)} +
+
${message}
+ +
-
${notification.message || 'No message'}
- ${notification.duration > 0 ? '
' : ''} + ${notification.duration > 0 ? '
' : ""} `; this.container.appendChild(el); this.renderedNotifications.set(notification.id, el); - setTimeout(() => el.classList.add('show'), 10); + requestAnimationFrame(() => { + requestAnimationFrame(() => el.classList.add("show")); + }); // Set progress bar animation duration if (notification.duration > 0) { - const progressBar = el.querySelector('.notification-progress-bar'); + const progressBar = el.querySelector(".notification-progress-bar"); if (progressBar) { - progressBar.style.transition = `width ${notification.duration}ms linear`; - progressBar.style.width = '100%'; - setTimeout(() => { - progressBar.style.width = '0%'; - }, 20); + progressBar.style.transitionDuration = `${notification.duration}ms`; + const progressTimer = setTimeout(() => { + progressBar.style.transform = "scaleX(0)"; + }, 30); + this.dismissTimers.set(notification.id, { progressTimer }); } - setTimeout(() => { - notificationStore.dispatch(notificationActions.updateNotification(notification.id, { status: 'hiding' })); - setTimeout(() => { - notificationStore.dispatch(notificationActions.removeNotification(notification.id)); - }, 300); + const hideTimer = setTimeout(() => { + notificationStore.dispatch( + notificationActions.updateNotification(notification.id, { + status: "hiding", + }), + ); }, notification.duration); + const removeTimer = setTimeout(() => { + this.clearDismissTimers(notification.id); + notificationStore.dispatch( + notificationActions.removeNotification(notification.id), + ); + }, notification.duration + 260); + + const existingTimers = + this.dismissTimers.get(notification.id) || {}; + this.dismissTimers.set(notification.id, { + ...existingTimers, + hideTimer, + removeTimer, + }); } } updateNotificationElement(notification) { const el = this.renderedNotifications.get(notification.id); if (!el) return; - if (notification.status === 'hiding') el.classList.add('hide'); + if (notification.status === "hiding") { + el.classList.add("hide"); + } } } @@ -220,7 +331,11 @@ let notificationUI = null; let notificationUIInitialized = false; function notifyArmaNotificationReady() { - if (window.parent && window.parent !== window && typeof window.parent.postMessage === "function") { + if ( + window.parent && + window.parent !== window && + typeof window.parent.postMessage === "function" + ) { window.parent.postMessage({ event: "notifications::ready" }, "*"); } if (typeof A3API !== "undefined" && typeof A3API.SendAlert === "function") { @@ -230,34 +345,44 @@ function notifyArmaNotificationReady() { function initializeNotifications() { if (notificationUIInitialized) { - console.log('Notification system already initialized, skipping...'); + console.log("Notification system already initialized, skipping..."); return; } notificationUI = new NotificationUI(notificationStore); notificationUI.init(); notificationUIInitialized = true; - console.log('Notification system is ready!'); + console.log("Notification system is ready!"); notifyArmaNotificationReady(); } // Expose global notification API const showNotification = (type, title, message, duration) => { - return notificationStore.dispatch(notificationActions.addNotification({ type, title, message, duration })); + return notificationStore.dispatch( + notificationActions.addNotification({ type, title, message, duration }), + ); }; const clearAllNotifications = () => { return notificationStore.dispatch(notificationActions.clearNotifications()); }; +window.showNotification = showNotification; +window.clearAllNotifications = clearAllNotifications; +window.ForgeNotifications = { + show: showNotification, + clear: clearAllNotifications, +}; // Listen for global notification events (for Arma/SQF or other scripts) -window.addEventListener('forge:notify', function (e) { +window.addEventListener("forge:notify", function (e) { if (!e || !e.detail) return; const { type, title, message, duration } = e.detail; showNotification(type, title, message, duration); }); // Auto-initialize if DOM is already loaded when script executes -if (document.readyState !== 'loading') { +if (document.readyState !== "loading") { initializeNotifications(); } else { - document.addEventListener('DOMContentLoaded', initializeNotifications, { once: true }); -} \ No newline at end of file + document.addEventListener("DOMContentLoaded", initializeNotifications, { + once: true, + }); +} diff --git a/arma/client/addons/notifications/ui/_site/styles.css b/arma/client/addons/notifications/ui/_site/styles.css index aa651f2..685c9b4 100644 --- a/arma/client/addons/notifications/ui/_site/styles.css +++ b/arma/client/addons/notifications/ui/_site/styles.css @@ -1,26 +1,27 @@ :root { - --bg-surface: #ffffff; - --bg-surface-hover: #f1f5f9; - --primary: #475569; - --primary-hover: #1e293b; - --text-main: #1f2937; - --text-muted: #64748b; - --border: #e2e8f0; - --radius: 8px; - --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - - --success: #10b981; - --success-bg: #ecfdf5; - --success-border: #a7f3d0; - --danger: #ef4444; - --danger-bg: #fef2f2; - --danger-border: #fecaca; - --warning: #f59e0b; - --warning-bg: #fffbeb; - --warning-border: #fde68a; - --info: #3b82f6; - --info-bg: #eff6ff; - --info-border: #bfdbfe; + --hud-top: 30px; + --hud-right: 20px; + --header-bg: linear-gradient( + 180deg, + rgba(13, 37, 69, 0.98) 0%, + rgba(8, 24, 48, 0.98) 100% + ); + --body-bg: rgba(242, 238, 228, 0.97); + --panel-edge: rgba(121, 166, 212, 0.22); + --panel-shadow: 0 14px 24px rgba(0, 0, 0, 0.34); + --header-text: rgba(235, 243, 255, 0.98); + --header-muted: rgba(166, 189, 221, 0.8); + --body-text: rgba(31, 45, 64, 0.96); + --body-muted: rgba(86, 102, 122, 0.82); + --body-faint: rgba(111, 126, 144, 0.76); + --success: #6de2b3; + --success-soft: rgba(109, 226, 179, 0.12); + --danger: #ff7b7b; + --danger-soft: rgba(255, 123, 123, 0.12); + --warning: #ffd36b; + --warning-soft: rgba(255, 211, 107, 0.12); + --info: #78b9ff; + --info-soft: rgba(120, 185, 255, 0.12); } * { @@ -29,182 +30,216 @@ box-sizing: border-box; } +html, body { - font-family: 'Inter', system-ui, -apple-system, sans-serif; - background: transparent; min-height: 100vh; - color: var(--text-main); +} + +body { + background: transparent; + color: var(--body-text); + font-family: "Bahnschrift", "Segoe UI", Tahoma, sans-serif; + letter-spacing: 0.01em; + overflow: hidden; pointer-events: none; + user-select: none; +} + +.notifications-hud { + position: fixed; + top: var(--hud-top); + right: var(--hud-right); + width: 384px; + max-width: calc(100vw - 24px); + z-index: 1000; } .notification-container { - position: fixed; - top: 80px; - right: 24px; - z-index: 1000; - width: 360px; - pointer-events: auto; + display: grid; + gap: 8px; } .notification { - background: var(--bg-surface); - border: 1px solid var(--border); - border-left: 4px solid var(--primary); - border-radius: var(--radius); - box-shadow: 0 4px 12px rgb(0 0 0 / 0.1), 0 2px 4px rgb(0 0 0 / 0.05); - margin-bottom: 12px; - padding: 1rem 1.25rem; - width: 100%; - transform: translateX(calc(100% + 24px)); - transition: all 0.2s ease; position: relative; overflow: hidden; + background: transparent; + border: 1px solid var(--panel-edge); + border-radius: 12px; + box-shadow: var(--panel-shadow); + opacity: 0; + transform: translateX(28px) scale(0.985); + transition: + opacity 0.18s ease, + transform 0.18s ease, + border-color 0.18s ease; +} - &.show { - transform: translateX(0); - } +.notification::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.08), + transparent 24% + ); + pointer-events: none; +} - &.hide { - transform: translateX(calc(100% + 24px)); - opacity: 0; - } +.notification.show { + opacity: 1; + transform: translateX(0) scale(1); +} - &.success { - background: var(--success-bg); - border-color: var(--success-border); - border-left-color: var(--success); +.notification.hide { + opacity: 0; + transform: translateX(32px) scale(0.985); +} - .notification-title { - color: #065f46; - } +.notification-inner { + position: relative; + display: block; + padding: 0; +} - .notification-message { - color: #047857; - } +.notification-body { + display: flex; + flex-direction: column; + gap: 7px; + min-width: 0; + padding: 10px 11px 11px; + background: var(--body-bg); +} - .notification-progress-bar { - background: var(--success); - } - } - - &.danger { - background: var(--danger-bg); - border-color: var(--danger-border); - border-left-color: var(--danger); - - .notification-title { - color: #991b1b; - } - - .notification-message { - color: #b91c1c; - } - - .notification-progress-bar { - background: var(--danger); - } - } - - &.warning { - background: var(--warning-bg); - border-color: var(--warning-border); - border-left-color: var(--warning); - - .notification-title { - color: #92400e; - } - - .notification-message { - color: #b45309; - } - - .notification-progress-bar { - background: var(--warning); - } - } - - &.info { - background: var(--info-bg); - border-color: var(--info-border); - border-left-color: var(--info); - - .notification-title { - color: #1e40af; - } - - .notification-message { - color: #1d4ed8; - } - - .notification-progress-bar { - background: var(--info); - } - } +.notification-meta, +.notification-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } .notification-header { display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.375rem; + flex-direction: column; + gap: 6px; + padding: 10px 11px; + background: var(--header-bg); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.notification-meta { + color: var(--body-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.notification-badge { + padding: 3px 6px; + border-radius: 999px; + border: 1px solid currentColor; + background: rgba(34, 51, 74, 0.08); +} + +.notification-time { + color: var(--body-faint); } .notification-title { - font-weight: 600; - font-size: 0.875rem; + color: var(--header-text); + font-size: 14px; + font-weight: 700; + line-height: 1.1; +} + +.notification-subtitle { + color: var(--header-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; - letter-spacing: 0.025em; - flex: 1; - color: var(--primary-hover); } .notification-message { - color: var(--text-muted); - font-size: 0.875rem; - line-height: 1.5; - word-wrap: break-word; + color: var(--body-text); + font-size: 12px; + line-height: 1.35; + word-break: break-word; +} + +.notification-footer { + color: var(--body-faint); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } .notification-progress { - position: absolute; - bottom: 0; - left: 0; - height: 3px; - background: var(--bg-surface-hover); - width: 100%; - border-radius: 0 0 var(--radius) var(--radius); + height: 4px; + background: rgba(34, 51, 74, 0.16); } .notification-progress-bar { height: 100%; - width: 0%; - transform-origin: left; - border-radius: 0 0 var(--radius) var(--radius); - transition: width linear; + width: 100%; + background: currentColor; + opacity: 0.95; + transform: scaleX(1); + transform-origin: left center; + transition: transform linear; } -@media (max-width: 768px) { - .notification-container { - left: 24px; - right: 24px; - width: auto; - } - - .notification { - transform: translateY(-100%); - - &.show { - transform: translateY(0); - } - - &.hide { - transform: translateY(-100%); - } - } +.notification.is-persistent .notification-progress { + display: none; } -@media (max-width: 500px) { - .notification-container { - width: calc(100vw - 48px); +.notification.success { + color: var(--success); + border-color: rgba(109, 226, 179, 0.24); +} + +.notification.success .notification-badge { + background-color: var(--success-soft); +} + +.notification.danger { + color: var(--danger); + border-color: rgba(255, 123, 123, 0.24); +} + +.notification.danger .notification-badge { + background-color: var(--danger-soft); +} + +.notification.warning { + color: var(--warning); + border-color: rgba(255, 211, 107, 0.24); +} + +.notification.warning .notification-badge { + background-color: var(--warning-soft); +} + +.notification.info { + color: var(--info); + border-color: rgba(120, 185, 255, 0.24); +} + +.notification.info .notification-badge { + background-color: var(--info-soft); +} + +@media (max-width: 720px) { + :root { + --hud-top: 18px; + --hud-right: 12px; + } + + .notifications-hud { + width: calc(100vw - 16px); + max-width: calc(100vw - 16px); } } diff --git a/arma/client/addons/org/ui/_site/base.css b/arma/client/addons/org/ui/_site/base.css index a05b977..376a1ab 100644 --- a/arma/client/addons/org/ui/_site/base.css +++ b/arma/client/addons/org/ui/_site/base.css @@ -4,6 +4,9 @@ --bg-surface-hover: #f1f5f9; --primary: #475569; --primary-hover: #1e293b; + --window-blue: #12325b; + --window-blue-border: #214978; + --window-blue-highlight: #d7e5f8; --text-main: #1f2937; --text-muted: #64748b; --text-inverse: #f8fafc; @@ -13,6 +16,11 @@ --footer-bg: #1e293b; } +html, +body { + height: 100%; +} + body { font-family: "Inter", @@ -24,16 +32,107 @@ body { background: var(--bg-app); color: var(--text-main); line-height: 1.6; + overflow: hidden; } #app { - min-height: 100vh; + height: 100vh; + overflow: hidden; +} + +.app-shell { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; } main { display: flex; flex-direction: column; - min-height: 100vh; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} + +.window-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1.25rem; + background: linear-gradient(180deg, var(--window-blue) 0%, #0d2643 100%); + border-bottom: 1px solid var(--window-blue-border); + color: var(--text-inverse); + box-shadow: 0 10px 24px rgb(18 50 91 / 0.24); + position: sticky; + top: 0; + z-index: 30; + flex-shrink: 0; +} + +.window-titlebar-brand { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.window-titlebar-kicker { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + color: rgb(215 229 248 / 0.78); +} + +.window-titlebar-title { + font-size: 0.95rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--text-inverse); +} + +.window-titlebar-controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.window-control-btn { + min-width: 2.5rem; + padding: 0.45rem 0.7rem; + border-radius: 6px; + border: 1px solid rgb(215 229 248 / 0.22); + background: rgb(255 255 255 / 0.08); + color: var(--window-blue-highlight); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +.window-control-btn:hover { + background: rgb(255 255 255 / 0.08); + box-shadow: none; + transform: none; +} + +.window-control-btn:disabled { + opacity: 0.55; +} + +.window-control-btn.is-close { + cursor: pointer; + opacity: 1; + border-color: rgb(255 255 255 / 0.24); +} + +.window-control-btn.is-close:hover { + background: rgb(255 255 255 / 0.18); } .container { @@ -156,6 +255,16 @@ button { } @media (max-width: 960px) { + .window-titlebar { + flex-direction: column; + align-items: flex-start; + } + + .window-titlebar-controls { + width: 100%; + justify-content: flex-end; + } + .container { padding: 1.5rem; } diff --git a/arma/client/addons/org/ui/_site/components/AppShell.js b/arma/client/addons/org/ui/_site/components/AppShell.js index aa56bfc..2d07e4e 100644 --- a/arma/client/addons/org/ui/_site/components/AppShell.js +++ b/arma/client/addons/org/ui/_site/components/AppShell.js @@ -5,6 +5,60 @@ RegistryApp.components = RegistryApp.components || {}; + function WindowTitleBar({ title, onClose }) { + return h( + "div", + { className: "window-titlebar" }, + h( + "div", + { className: "window-titlebar-brand" }, + h( + "span", + { className: "window-titlebar-kicker" }, + "ORBIS Workspace", + ), + h("span", { className: "window-titlebar-title" }, title), + ), + h( + "div", + { className: "window-titlebar-controls" }, + h( + "button", + { + type: "button", + className: "window-control-btn", + disabled: true, + title: "Minimize unavailable", + "aria-label": "Minimize unavailable", + }, + "-", + ), + h( + "button", + { + type: "button", + className: "window-control-btn", + disabled: true, + title: "Maximize unavailable", + "aria-label": "Maximize unavailable", + }, + "[ ]", + ), + h( + "button", + { + type: "button", + className: "window-control-btn is-close", + onClick: onClose, + title: "Close", + "aria-label": "Close organization interface", + }, + "X", + ), + ), + ); + } + RegistryApp.components.App = function App() { const Navbar = window.SharedUI.componentFns.Navbar; const Header = window.SharedUI.componentFns.Header; @@ -23,7 +77,6 @@ : view === "portal" ? "Organization Portal" : "Entry Hub"; - const actionLabel = view === "portal" ? "Sign Out" : "Close"; const footerSections = [ { title: "Registry Resources", @@ -65,12 +118,14 @@ if (view === "portal" && PortalApp) { return h( "div", - null, + { className: "app-shell" }, + WindowTitleBar({ + title: "Global Organization Network", + onClose: closeRegistry, + }), Navbar({ title: "Global Organization Network", viewLabel, - actionLabel, - onAction: closeRegistry, }), PortalApp(), ); @@ -84,24 +139,30 @@ } return h( - "main", - null, - Navbar({ + "div", + { className: "app-shell" }, + WindowTitleBar({ title: "Global Organization Network", - viewLabel, - actionLabel, - onAction: closeRegistry, + onClose: closeRegistry, }), h( - "div", - { className: "container" }, - Header({ + "main", + null, + Navbar({ title: "Global Organization Network", - onTitleClick: () => store.setView("home"), + viewLabel, }), - mainContent, + h( + "div", + { className: "container" }, + Header({ + title: "Global Organization Network", + onTitleClick: () => store.setView("home"), + }), + mainContent, + ), + Footer({ sections: footerSections }), ), - Footer({ sections: footerSections }), ); }; })(); diff --git a/arma/client/addons/org/ui/_site/components/navbar.js b/arma/client/addons/org/ui/_site/components/navbar.js index 2b096bb..427841b 100644 --- a/arma/client/addons/org/ui/_site/components/navbar.js +++ b/arma/client/addons/org/ui/_site/components/navbar.js @@ -113,15 +113,17 @@ ${scopeSelector} .app-close-btn:hover { "div", { className: "app-navbar-actions" }, h("span", { className: "app-navbar-view" }, viewLabel), - h( - "button", - { - type: "button", - className: "app-close-btn", - onClick: onAction, - }, - actionLabel, - ), + actionLabel && typeof onAction === "function" + ? h( + "button", + { + type: "button", + className: "app-close-btn", + onClick: onAction, + }, + actionLabel, + ) + : null, ), ), ); diff --git a/arma/client/extra/notify.wav b/arma/client/extra/notify.wav new file mode 100644 index 0000000..88a18e1 Binary files /dev/null and b/arma/client/extra/notify.wav differ