feat: Add weather widget with apps system
- Weather widget in top-left slot with compact pill display - Click to expand 7-day forecast - Uses apps system via Pulsar with Open-Meteo fallback - Click outside to close expanded view - Throttled updates (1/sec) when moving map Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e635d0e7f6
commit
3c3a6639e4
|
|
@ -0,0 +1,370 @@
|
|||
/**
|
||||
* AppHost - Runtime manager for app instances
|
||||
*
|
||||
* Manages:
|
||||
* - App instance lifecycle (mount/unmount)
|
||||
* - Event bus for app communication
|
||||
* - Bridge between apps and backend
|
||||
* - Map context distribution
|
||||
*/
|
||||
|
||||
import { getApp } from './AppRegistry';
|
||||
import { EVENT_TYPES } from './types';
|
||||
import { loadAuth } from '../auth/authStorage';
|
||||
|
||||
// Generate unique IDs
|
||||
function generateId() {
|
||||
return 'ai_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
function generateRequestId() {
|
||||
return 'req_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AppHost instance
|
||||
* @returns {AppHost}
|
||||
*/
|
||||
export function createAppHost() {
|
||||
/** @type {Map<string, import('./types').AppInstance>} */
|
||||
const instances = new Map();
|
||||
|
||||
/** @type {Map<string, Set<function>>} */
|
||||
const eventListeners = new Map();
|
||||
|
||||
/** @type {import('./types').MapContext | null} */
|
||||
let currentMapContext = null;
|
||||
|
||||
/** @type {Set<function>} */
|
||||
const mapContextListeners = new Set();
|
||||
|
||||
// API base URL
|
||||
function apiBase() {
|
||||
const raw = (import.meta?.env?.VITE_API_BASE_URL || '').toString().replace(/\/+$/, '');
|
||||
if (raw) return raw;
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const host = window.location.hostname || '';
|
||||
if (host.startsWith('www.')) {
|
||||
return `https://${host.replace(/^www\./, '')}`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send action to backend
|
||||
* @param {string} appInstanceId
|
||||
* @param {string} action
|
||||
* @param {Object} payload
|
||||
* @returns {Promise<{requestId: string}>}
|
||||
*/
|
||||
async function sendAction(appInstanceId, action, payload) {
|
||||
const instance = instances.get(appInstanceId);
|
||||
if (!instance) {
|
||||
throw new Error(`Unknown app instance: ${appInstanceId}`);
|
||||
}
|
||||
|
||||
const auth = loadAuth();
|
||||
const userId = auth?.user?.id || auth?.user?.sub || 'anonymous';
|
||||
const requestId = generateRequestId();
|
||||
|
||||
const envelope = {
|
||||
v: 1,
|
||||
requestId,
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
userId,
|
||||
appId: instance.appId,
|
||||
appInstanceId,
|
||||
action,
|
||||
payload,
|
||||
context: {
|
||||
clientId: getClientId(),
|
||||
traceId: requestId,
|
||||
},
|
||||
};
|
||||
|
||||
const url = `${apiBase()}/api/apps/${appInstanceId}/in`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(auth?.access_token ? { Authorization: `Bearer ${auth.access_token}` } : {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(envelope),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`App action failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
return { requestId };
|
||||
} catch (err) {
|
||||
console.error('AppHost sendAction error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event to a specific app instance
|
||||
* @param {string} appInstanceId
|
||||
* @param {Object} event
|
||||
*/
|
||||
function emitToApp(appInstanceId, event) {
|
||||
const listeners = eventListeners.get(appInstanceId);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (err) {
|
||||
console.error('App event listener error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit event to all apps
|
||||
* @param {Object} event
|
||||
*/
|
||||
function emitToAllApps(event) {
|
||||
for (const appInstanceId of instances.keys()) {
|
||||
emitToApp(appInstanceId, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events for an app instance
|
||||
* @param {string} appInstanceId
|
||||
* @param {function} handler
|
||||
* @returns {function} Unsubscribe function
|
||||
*/
|
||||
function onAppEvent(appInstanceId, handler) {
|
||||
if (!eventListeners.has(appInstanceId)) {
|
||||
eventListeners.set(appInstanceId, new Set());
|
||||
}
|
||||
eventListeners.get(appInstanceId).add(handler);
|
||||
|
||||
return () => {
|
||||
const listeners = eventListeners.get(appInstanceId);
|
||||
if (listeners) {
|
||||
listeners.delete(handler);
|
||||
if (listeners.size === 0) {
|
||||
eventListeners.delete(appInstanceId);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map context and notify apps
|
||||
* @param {import('./types').MapContext} context
|
||||
*/
|
||||
function updateMapContext(context) {
|
||||
const prevSectorId = currentMapContext?.sectorId;
|
||||
currentMapContext = context;
|
||||
|
||||
// Notify map context listeners
|
||||
for (const listener of mapContextListeners) {
|
||||
try {
|
||||
listener(context);
|
||||
} catch (err) {
|
||||
console.error('Map context listener error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit to all apps if sector changed
|
||||
if (context.sectorId !== prevSectorId) {
|
||||
emitToAllApps({
|
||||
type: EVENT_TYPES.LOCATION_CHANGED,
|
||||
payload: context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current map context
|
||||
* @returns {import('./types').MapContext | null}
|
||||
*/
|
||||
function getMapContext() {
|
||||
return currentMapContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to map context changes
|
||||
* @param {function} handler
|
||||
* @returns {function} Unsubscribe function
|
||||
*/
|
||||
function onMapContextChange(handler) {
|
||||
mapContextListeners.add(handler);
|
||||
// Immediately call with current context if available
|
||||
if (currentMapContext) {
|
||||
try {
|
||||
handler(currentMapContext);
|
||||
} catch (err) {
|
||||
console.error('Map context handler error:', err);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
mapContextListeners.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a bridge object for an app instance
|
||||
* @param {string} appInstanceId
|
||||
* @returns {import('./types').AppHostBridge}
|
||||
*/
|
||||
function createBridge(appInstanceId) {
|
||||
return {
|
||||
sendAction: (action, payload) => sendAction(appInstanceId, action, payload),
|
||||
onEvent: (eventType, handler) => {
|
||||
// Wrap handler to filter by event type
|
||||
const wrappedHandler = (event) => {
|
||||
if (!eventType || event.type === eventType) {
|
||||
handler(event);
|
||||
}
|
||||
};
|
||||
return onAppEvent(appInstanceId, wrappedHandler);
|
||||
},
|
||||
getMapContext,
|
||||
onMapContextChange,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install an app instance
|
||||
* @param {string} appId
|
||||
* @param {Object} [options]
|
||||
* @returns {import('./types').AppInstance}
|
||||
*/
|
||||
function installApp(appId, options = {}) {
|
||||
const definition = getApp(appId);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown app: ${appId}`);
|
||||
}
|
||||
|
||||
const appInstanceId = options.appInstanceId || generateId();
|
||||
const auth = loadAuth();
|
||||
|
||||
const instance = {
|
||||
appInstanceId,
|
||||
appId,
|
||||
userId: auth?.user?.id || auth?.user?.sub || 'anonymous',
|
||||
slot: options.slot || definition.defaultSlot,
|
||||
order: options.order ?? definition.order ?? 0,
|
||||
config: options.config || {},
|
||||
enabled: options.enabled !== false,
|
||||
};
|
||||
|
||||
instances.set(appInstanceId, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall an app instance
|
||||
* @param {string} appInstanceId
|
||||
*/
|
||||
function uninstallApp(appInstanceId) {
|
||||
instances.delete(appInstanceId);
|
||||
eventListeners.delete(appInstanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all installed instances
|
||||
* @returns {import('./types').AppInstance[]}
|
||||
*/
|
||||
function getInstalledApps() {
|
||||
return Array.from(instances.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instances for a specific slot
|
||||
* @param {string} slotId
|
||||
* @returns {import('./types').AppInstance[]}
|
||||
*/
|
||||
function getAppsForSlot(slotId) {
|
||||
return getInstalledApps()
|
||||
.filter(inst => inst.slot === slotId && inst.enabled)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an instance by ID
|
||||
* @param {string} appInstanceId
|
||||
* @returns {import('./types').AppInstance | undefined}
|
||||
*/
|
||||
function getInstance(appInstanceId) {
|
||||
return instances.get(appInstanceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WS message for apps
|
||||
* @param {Object} message
|
||||
*/
|
||||
function handleWSMessage(message) {
|
||||
// Check if this is an app event
|
||||
if (message.type === 'app.event' && message.appInstanceId) {
|
||||
emitToApp(message.appInstanceId, message.event || message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Instance management
|
||||
installApp,
|
||||
uninstallApp,
|
||||
getInstalledApps,
|
||||
getAppsForSlot,
|
||||
getInstance,
|
||||
|
||||
// Event bus
|
||||
emitToApp,
|
||||
emitToAllApps,
|
||||
onAppEvent,
|
||||
handleWSMessage,
|
||||
|
||||
// Map context
|
||||
updateMapContext,
|
||||
getMapContext,
|
||||
onMapContextChange,
|
||||
|
||||
// Bridge factory
|
||||
createBridge,
|
||||
|
||||
// Direct action (for internal use)
|
||||
sendAction,
|
||||
};
|
||||
}
|
||||
|
||||
// Client ID for tracking
|
||||
let clientId = null;
|
||||
function getClientId() {
|
||||
if (!clientId) {
|
||||
clientId = 'c_' + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let defaultHost = null;
|
||||
|
||||
/**
|
||||
* Get the default AppHost instance
|
||||
* @returns {ReturnType<typeof createAppHost>}
|
||||
*/
|
||||
export function getDefaultAppHost() {
|
||||
if (!defaultHost) {
|
||||
defaultHost = createAppHost();
|
||||
}
|
||||
return defaultHost;
|
||||
}
|
||||
|
||||
export default {
|
||||
createAppHost,
|
||||
getDefaultAppHost,
|
||||
};
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* AppOverlay - Widget slot positioning and styling
|
||||
*/
|
||||
|
||||
.app-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: 500;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Slot positioning */
|
||||
.app-slot {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: calc(100% - 16px);
|
||||
}
|
||||
|
||||
.app-slot-top-left {
|
||||
top: 78px; /* Below search bar */
|
||||
left: 13px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-slot-top-right {
|
||||
top: 70px;
|
||||
right: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.app-slot-bottom-left {
|
||||
bottom: 100px; /* Above bottom controls */
|
||||
left: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-slot-bottom-right {
|
||||
bottom: 100px;
|
||||
right: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.app-slot-bottom-sheet {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Widget container - full card style */
|
||||
.widget-container {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Compact widget - no extra styling */
|
||||
.widget-compact {
|
||||
/* Widget handles its own styling */
|
||||
}
|
||||
|
||||
.app-slot-bottom-sheet .widget-container {
|
||||
max-width: 100%;
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Widget internal styling */
|
||||
.widget-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
}
|
||||
|
||||
.widget-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.widget-header-title i {
|
||||
color: #60a5fa;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.widget-header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.widget-header-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.widget-header-btn:hover {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.widget-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.widget-loading i {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.widget-error {
|
||||
padding: 12px;
|
||||
color: #f87171;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.app-slot-top-left,
|
||||
.app-slot-top-right {
|
||||
top: 60px;
|
||||
}
|
||||
|
||||
.app-slot-bottom-left,
|
||||
.app-slot-bottom-right {
|
||||
bottom: 140px;
|
||||
}
|
||||
|
||||
.widget-container {
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.app-slot-bottom-sheet .widget-container {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* AppOverlay - Renders widget slots over the map
|
||||
*
|
||||
* This component creates the overlay layer with positioned slots
|
||||
* for app widgets. Each slot can contain multiple widgets.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import WidgetSlot from './slots/WidgetSlot';
|
||||
import { SLOT_IDS } from './types';
|
||||
import { getDefaultAppHost } from './AppHost';
|
||||
import './AppOverlay.css';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {ReturnType<typeof import('./AppHost').createAppHost>} [props.appHost] - AppHost instance
|
||||
* @param {boolean} [props.visible=true] - Whether the overlay is visible
|
||||
*/
|
||||
export default function AppOverlay({ appHost: providedAppHost, visible = true }) {
|
||||
const appHost = providedAppHost || getDefaultAppHost();
|
||||
const [slots, setSlots] = useState({});
|
||||
const [updateTrigger, setUpdateTrigger] = useState(0);
|
||||
|
||||
// Refresh slot contents when apps change
|
||||
const refreshSlots = useCallback(() => {
|
||||
const newSlots = {};
|
||||
for (const slotId of Object.values(SLOT_IDS)) {
|
||||
newSlots[slotId] = appHost.getAppsForSlot(slotId);
|
||||
}
|
||||
setSlots(newSlots);
|
||||
}, [appHost]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSlots();
|
||||
}, [refreshSlots, updateTrigger]);
|
||||
|
||||
// Expose refresh method for external triggering
|
||||
useEffect(() => {
|
||||
// Simple polling to detect new apps (can be replaced with event-based)
|
||||
const interval = setInterval(() => {
|
||||
setUpdateTrigger((t) => t + 1);
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAnyWidgets = Object.values(slots).some((s) => s && s.length > 0);
|
||||
if (!hasAnyWidgets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-overlay">
|
||||
{/* Top Left */}
|
||||
{slots[SLOT_IDS.TOP_LEFT]?.length > 0 && (
|
||||
<div className="app-slot app-slot-top-left">
|
||||
<WidgetSlot
|
||||
slotId={SLOT_IDS.TOP_LEFT}
|
||||
instances={slots[SLOT_IDS.TOP_LEFT]}
|
||||
appHost={appHost}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Right */}
|
||||
{slots[SLOT_IDS.TOP_RIGHT]?.length > 0 && (
|
||||
<div className="app-slot app-slot-top-right">
|
||||
<WidgetSlot
|
||||
slotId={SLOT_IDS.TOP_RIGHT}
|
||||
instances={slots[SLOT_IDS.TOP_RIGHT]}
|
||||
appHost={appHost}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Left */}
|
||||
{slots[SLOT_IDS.BOTTOM_LEFT]?.length > 0 && (
|
||||
<div className="app-slot app-slot-bottom-left">
|
||||
<WidgetSlot
|
||||
slotId={SLOT_IDS.BOTTOM_LEFT}
|
||||
instances={slots[SLOT_IDS.BOTTOM_LEFT]}
|
||||
appHost={appHost}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Right */}
|
||||
{slots[SLOT_IDS.BOTTOM_RIGHT]?.length > 0 && (
|
||||
<div className="app-slot app-slot-bottom-right">
|
||||
<WidgetSlot
|
||||
slotId={SLOT_IDS.BOTTOM_RIGHT}
|
||||
instances={slots[SLOT_IDS.BOTTOM_RIGHT]}
|
||||
appHost={appHost}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Sheet */}
|
||||
{slots[SLOT_IDS.BOTTOM_SHEET]?.length > 0 && (
|
||||
<div className="app-slot app-slot-bottom-sheet">
|
||||
<WidgetSlot
|
||||
slotId={SLOT_IDS.BOTTOM_SHEET}
|
||||
instances={slots[SLOT_IDS.BOTTOM_SHEET]}
|
||||
appHost={appHost}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* AppRegistry - Central registry for app definitions
|
||||
*
|
||||
* Apps register themselves here with their mount functions and metadata.
|
||||
* This is the "catalog" of available apps - not instances.
|
||||
*/
|
||||
|
||||
import { SLOT_IDS } from './types';
|
||||
|
||||
/** @type {Map<string, import('./types').AppDefinition>} */
|
||||
const registry = new Map();
|
||||
|
||||
/**
|
||||
* Register an app definition
|
||||
* @param {import('./types').AppDefinition} definition
|
||||
*/
|
||||
export function registerApp(definition) {
|
||||
if (!definition.appId) {
|
||||
throw new Error('App definition must have appId');
|
||||
}
|
||||
if (!definition.mountWidget) {
|
||||
throw new Error('App definition must have mountWidget function');
|
||||
}
|
||||
if (registry.has(definition.appId)) {
|
||||
console.warn(`App ${definition.appId} already registered, overwriting`);
|
||||
}
|
||||
registry.set(definition.appId, {
|
||||
order: 0,
|
||||
defaultSlot: SLOT_IDS.TOP_RIGHT,
|
||||
permissions: {},
|
||||
...definition,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an app definition by ID
|
||||
* @param {string} appId
|
||||
* @returns {import('./types').AppDefinition | undefined}
|
||||
*/
|
||||
export function getApp(appId) {
|
||||
return registry.get(appId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered apps
|
||||
* @returns {import('./types').AppDefinition[]}
|
||||
*/
|
||||
export function getAllApps() {
|
||||
return Array.from(registry.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app is registered
|
||||
* @param {string} appId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasApp(appId) {
|
||||
return registry.has(appId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an app (mainly for testing)
|
||||
* @param {string} appId
|
||||
*/
|
||||
export function unregisterApp(appId) {
|
||||
registry.delete(appId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered apps (mainly for testing)
|
||||
*/
|
||||
export function clearRegistry() {
|
||||
registry.clear();
|
||||
}
|
||||
|
||||
export default {
|
||||
registerApp,
|
||||
getApp,
|
||||
getAllApps,
|
||||
hasApp,
|
||||
unregisterApp,
|
||||
clearRegistry,
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Apps System - Main Entry Point
|
||||
*
|
||||
* Exports all apps system components and initializes built-in apps.
|
||||
*/
|
||||
|
||||
// Core exports
|
||||
export { registerApp, getApp, getAllApps, hasApp } from './AppRegistry';
|
||||
export { createAppHost, getDefaultAppHost } from './AppHost';
|
||||
export { createMapContextBridge } from './providers/MapContextBridge';
|
||||
export { SLOT_IDS, EVENT_TYPES } from './types';
|
||||
|
||||
// Components
|
||||
export { default as AppOverlay } from './AppOverlay';
|
||||
export { default as WidgetSlot } from './slots/WidgetSlot';
|
||||
|
||||
// Built-in apps
|
||||
import { registerWeatherApp } from './weather';
|
||||
|
||||
/**
|
||||
* Initialize all built-in apps
|
||||
* Call this once at app startup
|
||||
*/
|
||||
export function initializeApps() {
|
||||
registerWeatherApp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup demo apps for testing
|
||||
* Creates instances of built-in apps
|
||||
*
|
||||
* @param {ReturnType<typeof import('./AppHost').createAppHost>} appHost
|
||||
*/
|
||||
export function setupDemoApps(appHost) {
|
||||
// Install weather widget
|
||||
appHost.installApp('weather', {
|
||||
appInstanceId: 'ai_weather_demo',
|
||||
config: {
|
||||
units: 'metric',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
initializeApps,
|
||||
setupDemoApps,
|
||||
};
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* MapContextBridge - Connects MapLibre events to the AppHost
|
||||
*
|
||||
* Listens to map 'moveend' events, computes sector ID,
|
||||
* and emits context.location.changed to apps with deduplication and throttling.
|
||||
*/
|
||||
|
||||
import { getDefaultAppHost } from '../AppHost';
|
||||
|
||||
/**
|
||||
* Calculate a stable sector ID from map state
|
||||
* Format: "{zoomBucket}:{lat2d}:{lng2d}"
|
||||
*
|
||||
* @param {number} zoom
|
||||
* @param {number} lat
|
||||
* @param {number} lng
|
||||
* @returns {string}
|
||||
*/
|
||||
function computeSectorId(zoom, lat, lng) {
|
||||
const zoomBucket = Math.floor(zoom);
|
||||
const lat2d = lat.toFixed(2);
|
||||
const lng2d = lng.toFixed(2);
|
||||
return `${zoomBucket}:${lat2d}:${lng2d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a map context from MapLibre map state
|
||||
* @param {Object} map - MapLibre map instance
|
||||
* @returns {import('../types').MapContext}
|
||||
*/
|
||||
function createMapContext(map) {
|
||||
const center = map.getCenter();
|
||||
const bounds = map.getBounds();
|
||||
const zoom = map.getZoom();
|
||||
|
||||
const lat = center.lat;
|
||||
const lng = center.lng;
|
||||
const sectorId = computeSectorId(zoom, lat, lng);
|
||||
|
||||
return {
|
||||
center: { lat, lng },
|
||||
bbox: {
|
||||
west: bounds.getWest(),
|
||||
south: bounds.getSouth(),
|
||||
east: bounds.getEast(),
|
||||
north: bounds.getNorth(),
|
||||
},
|
||||
zoom,
|
||||
sectorId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a MapContextBridge instance
|
||||
*
|
||||
* @param {Object} map - MapLibre map instance
|
||||
* @param {Object} [options]
|
||||
* @param {number} [options.throttleMs=500] - Minimum time between emissions
|
||||
* @param {ReturnType<typeof import('../AppHost').createAppHost>} [options.appHost] - AppHost instance
|
||||
* @returns {Object} Bridge instance with cleanup method
|
||||
*/
|
||||
export function createMapContextBridge(map, options = {}) {
|
||||
const {
|
||||
throttleMs = 500,
|
||||
appHost = getDefaultAppHost(),
|
||||
} = options;
|
||||
|
||||
let lastSectorId = null;
|
||||
let lastEmitTime = 0;
|
||||
let pendingTimeout = null;
|
||||
let isDestroyed = false;
|
||||
|
||||
/**
|
||||
* Process map state and emit if sector changed
|
||||
*/
|
||||
function processMapState() {
|
||||
if (isDestroyed || !map) return;
|
||||
|
||||
const now = Date.now();
|
||||
const context = createMapContext(map);
|
||||
|
||||
// Check if sector actually changed
|
||||
if (context.sectorId === lastSectorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttle: if we emitted recently, schedule for later
|
||||
const timeSinceLastEmit = now - lastEmitTime;
|
||||
if (timeSinceLastEmit < throttleMs) {
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
}
|
||||
pendingTimeout = setTimeout(() => {
|
||||
pendingTimeout = null;
|
||||
processMapState();
|
||||
}, throttleMs - timeSinceLastEmit);
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit the update
|
||||
lastSectorId = context.sectorId;
|
||||
lastEmitTime = now;
|
||||
appHost.updateMapContext(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle moveend event
|
||||
*/
|
||||
function onMoveEnd() {
|
||||
processMapState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle styledata event (map ready)
|
||||
*/
|
||||
function onStyleData() {
|
||||
// Initial context when style loads
|
||||
processMapState();
|
||||
}
|
||||
|
||||
// Attach event listeners
|
||||
map.on('moveend', onMoveEnd);
|
||||
map.on('styledata', onStyleData);
|
||||
|
||||
// Initial emit if map is ready
|
||||
if (map.isStyleLoaded()) {
|
||||
processMapState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup and remove listeners
|
||||
*/
|
||||
function destroy() {
|
||||
isDestroyed = true;
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
pendingTimeout = null;
|
||||
}
|
||||
map.off('moveend', onMoveEnd);
|
||||
map.off('styledata', onStyleData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force an immediate context update
|
||||
*/
|
||||
function forceUpdate() {
|
||||
if (!isDestroyed && map) {
|
||||
const context = createMapContext(map);
|
||||
lastSectorId = context.sectorId;
|
||||
lastEmitTime = Date.now();
|
||||
appHost.updateMapContext(context);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
destroy,
|
||||
forceUpdate,
|
||||
getLastSectorId: () => lastSectorId,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
createMapContextBridge,
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* WidgetSlot - Renders widgets for a specific slot position
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { getApp } from '../AppRegistry';
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {string} props.slotId - The slot identifier
|
||||
* @param {import('../types').AppInstance[]} props.instances - App instances for this slot
|
||||
* @param {ReturnType<typeof import('../AppHost').createAppHost>} props.appHost - AppHost instance
|
||||
* @param {string} [props.className] - Additional CSS class
|
||||
*/
|
||||
export default function WidgetSlot({ slotId, instances, appHost, className = '' }) {
|
||||
if (!instances || instances.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`widget-slot widget-slot-${slotId.replace(':', '-')} ${className}`}>
|
||||
{instances.map((instance) => {
|
||||
const definition = getApp(instance.appId);
|
||||
if (!definition) {
|
||||
console.warn(`No definition found for app: ${instance.appId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const bridge = appHost.createBridge(instance.appInstanceId);
|
||||
const Widget = definition.mountWidget;
|
||||
|
||||
// Compact widgets don't get the container styling
|
||||
const isCompact = definition.compact === true;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={instance.appInstanceId}
|
||||
className={isCompact ? 'widget-compact' : 'widget-container'}
|
||||
data-app-id={instance.appId}
|
||||
data-instance-id={instance.appInstanceId}
|
||||
>
|
||||
<Widget
|
||||
appInstanceId={instance.appInstanceId}
|
||||
appHost={bridge}
|
||||
config={instance.config}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Apps System Type Definitions
|
||||
*
|
||||
* These are JSDoc type definitions for the widget/apps system.
|
||||
* Architecture is designed to be iframe-compatible in the future.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {'mapOverlay:topLeft' | 'mapOverlay:topRight' | 'mapOverlay:bottomLeft' | 'mapOverlay:bottomRight' | 'mapOverlay:bottomSheet'} SlotId
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AppPermission
|
||||
* @property {boolean} [location] - Can receive location updates
|
||||
* @property {boolean} [user] - Can access user info
|
||||
* @property {boolean} [network] - Can make network requests
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AppDefinition
|
||||
* @property {string} appId - Unique app identifier
|
||||
* @property {string} name - Display name
|
||||
* @property {string} [description] - App description
|
||||
* @property {string} [icon] - Font Awesome icon class
|
||||
* @property {SlotId} defaultSlot - Default slot for this app
|
||||
* @property {number} [order] - Order within slot (lower = first)
|
||||
* @property {AppPermission} permissions - Required permissions
|
||||
* @property {function(AppWidgetProps): JSX.Element} mountWidget - Widget component factory
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AppInstance
|
||||
* @property {string} appInstanceId - Unique instance ID (ai_xxx)
|
||||
* @property {string} appId - App definition ID
|
||||
* @property {string} userId - Owner user ID
|
||||
* @property {SlotId} slot - Current slot placement
|
||||
* @property {number} order - Order in slot
|
||||
* @property {Object} [config] - Instance-specific config
|
||||
* @property {boolean} enabled - Is instance active
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AppWidgetProps
|
||||
* @property {string} appInstanceId - Instance ID
|
||||
* @property {AppHostBridge} appHost - Bridge to host services
|
||||
* @property {Object} [config] - Instance config
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AppHostBridge
|
||||
* @property {function(string, Object): void} sendAction - Send action to backend
|
||||
* @property {function(string, function): function} onEvent - Subscribe to events, returns unsubscribe
|
||||
* @property {function(): MapContext} getMapContext - Get current map context
|
||||
* @property {function(string, function): function} onMapContextChange - Subscribe to map context changes
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} MapContext
|
||||
* @property {Object} center - {lat: number, lng: number}
|
||||
* @property {Object} bbox - {west: number, south: number, east: number, north: number}
|
||||
* @property {number} zoom - Current zoom level
|
||||
* @property {string} sectorId - Stable sector identifier for deduping
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AppEvent
|
||||
* @property {string} type - Event type (e.g., 'weather.updated')
|
||||
* @property {Object} payload - Event payload
|
||||
* @property {string} [requestId] - Correlation ID
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AppActionEnvelope
|
||||
* @property {number} v - Version (1)
|
||||
* @property {string} requestId - UUID
|
||||
* @property {number} ts - Unix timestamp
|
||||
* @property {string} userId - User ID
|
||||
* @property {string} appId - App ID
|
||||
* @property {string} appInstanceId - Instance ID
|
||||
* @property {string} action - Action name
|
||||
* @property {Object} payload - Action payload
|
||||
* @property {Object} context - Client context
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AppEventEnvelope
|
||||
* @property {number} v - Version (1)
|
||||
* @property {number} ts - Unix timestamp
|
||||
* @property {string} userId - User ID
|
||||
* @property {string} appInstanceId - Instance ID
|
||||
* @property {string} type - "app.event"
|
||||
* @property {AppEvent} event - The actual event
|
||||
*/
|
||||
|
||||
export const SLOT_IDS = {
|
||||
TOP_LEFT: 'mapOverlay:topLeft',
|
||||
TOP_RIGHT: 'mapOverlay:topRight',
|
||||
BOTTOM_LEFT: 'mapOverlay:bottomLeft',
|
||||
BOTTOM_RIGHT: 'mapOverlay:bottomRight',
|
||||
BOTTOM_SHEET: 'mapOverlay:bottomSheet',
|
||||
};
|
||||
|
||||
export const EVENT_TYPES = {
|
||||
LOCATION_CHANGED: 'context.location.changed',
|
||||
APP_EVENT: 'app.event',
|
||||
};
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* WeatherWidget - Compact weather pill (same style as old widget)
|
||||
*
|
||||
* Calls weather service on us02 via apps system (Pulsar)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
|
||||
// Emoji icons for weather
|
||||
const ICON_MAP = {
|
||||
'sun': '☀️', 'moon': '🌙', 'cloud-sun': '⛅', 'cloud': '☁️',
|
||||
'smog': '🌫️', 'cloud-rain': '🌧️', 'cloud-showers-heavy': '🌧️',
|
||||
'snowflake': '❄️', 'cloud-sun-rain': '🌦️', 'cloud-meatball': '🌨️', 'bolt': '⛈️'
|
||||
};
|
||||
|
||||
// WMO codes to icon names
|
||||
const WMO_TO_ICON = {
|
||||
0: 'sun', 1: 'sun', 2: 'cloud-sun', 3: 'cloud',
|
||||
45: 'smog', 48: 'smog',
|
||||
51: 'cloud-rain', 53: 'cloud-rain', 55: 'cloud-rain',
|
||||
61: 'cloud-rain', 63: 'cloud-showers-heavy', 65: 'cloud-showers-heavy',
|
||||
71: 'snowflake', 73: 'snowflake', 75: 'snowflake', 77: 'snowflake',
|
||||
80: 'cloud-sun-rain', 81: 'cloud-showers-heavy', 82: 'cloud-showers-heavy',
|
||||
85: 'cloud-meatball', 86: 'cloud-meatball',
|
||||
95: 'bolt', 96: 'bolt', 99: 'bolt',
|
||||
};
|
||||
|
||||
// Day name helper - dateStr is "YYYY-MM-DD"
|
||||
function getDayName(dateStr) {
|
||||
// Parse as local date (not UTC) by using date parts
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const dateOnly = new Date(date);
|
||||
dateOnly.setHours(0, 0, 0, 0);
|
||||
if (dateOnly.getTime() === today.getTime()) return "Auj.";
|
||||
if (dateOnly.getTime() === tomorrow.getTime()) return "Dem.";
|
||||
return date.toLocaleDateString("fr-FR", { weekday: "short" });
|
||||
}
|
||||
|
||||
export default function WeatherWidget({ appInstanceId, appHost, config = {} }) {
|
||||
const [weather, setWeather] = useState(null);
|
||||
const [daily, setDaily] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [lastSectorId, setLastSectorId] = useState(null);
|
||||
const pendingRequestRef = useRef(null);
|
||||
const lastCoordsRef = useRef({ lat: null, lon: null });
|
||||
const widgetRef = useRef(null);
|
||||
const lastRequestTimeRef = useRef(0);
|
||||
|
||||
// Fallback: fetch directly from Open-Meteo (defined first to avoid circular dep)
|
||||
const fetchWeatherDirect = useCallback(async (lat, lon) => {
|
||||
try {
|
||||
lastCoordsRef.current = { lat, lon };
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code,is_day,relative_humidity_2m,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const current = data.current;
|
||||
if (!current) return;
|
||||
|
||||
const wmoCode = current.weather_code || 0;
|
||||
const isDay = current.is_day === 1;
|
||||
let iconName = WMO_TO_ICON[wmoCode] || 'cloud';
|
||||
if (!isDay && (wmoCode === 0 || wmoCode === 1)) iconName = 'moon';
|
||||
|
||||
// Get city name
|
||||
let city = `${lat.toFixed(1)}, ${lon.toFixed(1)}`;
|
||||
try {
|
||||
const geoRes = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`, {
|
||||
headers: { 'User-Agent': 'Sociowire/1.0' }
|
||||
});
|
||||
if (geoRes.ok) {
|
||||
const geo = await geoRes.json();
|
||||
const addr = geo.address || {};
|
||||
city = addr.city || addr.town || addr.village || addr.municipality || city;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
setWeather({
|
||||
icon: ICON_MAP[iconName] || '🌡️',
|
||||
city,
|
||||
temp: Math.round(current.temperature_2m),
|
||||
humidity: current.relative_humidity_2m,
|
||||
wind: Math.round(current.wind_speed_10m),
|
||||
isDay,
|
||||
});
|
||||
|
||||
// Set daily forecast
|
||||
if (data.daily) {
|
||||
const dailyData = data.daily.time.map((date, i) => ({
|
||||
date,
|
||||
code: data.daily.weather_code[i],
|
||||
max: Math.round(data.daily.temperature_2m_max[i]),
|
||||
min: Math.round(data.daily.temperature_2m_min[i]),
|
||||
precip: data.daily.precipitation_sum[i],
|
||||
}));
|
||||
setDaily(dailyData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Direct weather fetch failed:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Send weather request via apps system
|
||||
const requestWeather = useCallback(async (lat, lon) => {
|
||||
const requestId = `weather_${Date.now()}`;
|
||||
pendingRequestRef.current = requestId;
|
||||
|
||||
try {
|
||||
// Send action to apps-gateway -> Pulsar -> weather-service on us02
|
||||
await appHost.sendAction('weather.get', { lat, lon, requestId });
|
||||
} catch (err) {
|
||||
console.warn('Weather request failed, using fallback:', err);
|
||||
// Fallback: direct Open-Meteo call
|
||||
fetchWeatherDirect(lat, lon);
|
||||
}
|
||||
}, [appHost, fetchWeatherDirect]);
|
||||
|
||||
// Handle weather response from apps system (via WebSocket)
|
||||
useEffect(() => {
|
||||
// Listen for weather.data events
|
||||
const unsubscribe = appHost.onEvent('weather.data', (event) => {
|
||||
const data = event.payload || event;
|
||||
if (data.city && data.current) {
|
||||
const iconName = data.current.weather_icon || 'cloud';
|
||||
setWeather({
|
||||
icon: ICON_MAP[iconName] || '🌡️',
|
||||
city: data.city,
|
||||
temp: Math.round(data.current.temperature || 0),
|
||||
isDay: data.current.is_day !== false,
|
||||
});
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [appHost]);
|
||||
|
||||
// Handle map context changes (throttled to 1 request per second)
|
||||
const handleMapContextChange = useCallback((context) => {
|
||||
if (!context || context.sectorId === lastSectorId) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastRequestTimeRef.current < 1000) return; // Throttle: 1 second min
|
||||
|
||||
setLastSectorId(context.sectorId);
|
||||
lastRequestTimeRef.current = now;
|
||||
|
||||
const lat = context.center?.lat || 0;
|
||||
const lon = context.center?.lng || context.center?.lon || 0;
|
||||
|
||||
if (lat !== 0 || lon !== 0) {
|
||||
requestWeather(lat, lon);
|
||||
}
|
||||
}, [lastSectorId, requestWeather]);
|
||||
|
||||
// Subscribe to map context
|
||||
useEffect(() => {
|
||||
const unsubscribe = appHost.onMapContextChange(handleMapContextChange);
|
||||
return unsubscribe;
|
||||
}, [appHost, handleMapContextChange]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
|
||||
const handleClickOutside = (e) => {
|
||||
if (widgetRef.current && !widgetRef.current.contains(e.target)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Use capture phase to catch clicks before they reach the map
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
return () => document.removeEventListener('click', handleClickOutside, true);
|
||||
}, [expanded]);
|
||||
|
||||
// No weather yet - show nothing (like old widget)
|
||||
if (!weather) return null;
|
||||
|
||||
// Determine background based on weather
|
||||
const isSunny = weather.icon === '☀️' || weather.icon === '⛅';
|
||||
const isNight = weather.icon === '🌙';
|
||||
const isRainy = weather.icon === '🌧️' || weather.icon === '🌦️' || weather.icon === '⛈️';
|
||||
const isSnow = weather.icon === '❄️' || weather.icon === '🌨️';
|
||||
|
||||
const bg = isNight
|
||||
? 'linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.9))'
|
||||
: isSunny
|
||||
? 'linear-gradient(135deg, rgba(56, 189, 248, 0.85), rgba(14, 165, 233, 0.85))'
|
||||
: isRainy
|
||||
? 'linear-gradient(135deg, rgba(71, 85, 105, 0.9), rgba(51, 65, 85, 0.9))'
|
||||
: isSnow
|
||||
? 'linear-gradient(135deg, rgba(148, 163, 184, 0.9), rgba(203, 213, 225, 0.9))'
|
||||
: 'linear-gradient(135deg, rgba(100, 116, 139, 0.85), rgba(71, 85, 105, 0.85))';
|
||||
|
||||
// Collapsed pill view
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div
|
||||
ref={widgetRef}
|
||||
onClick={() => setExpanded(true)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
padding: '5px 12px',
|
||||
borderRadius: '16px',
|
||||
background: bg,
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
color: isSnow ? '#1e293b' : '#fff',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
backdropFilter: 'blur(8px)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{weather.icon}</span>
|
||||
<span>{weather.city}:</span>
|
||||
<span style={{ fontWeight: '700' }}>{weather.temp}°</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded view with 7-day forecast
|
||||
return (
|
||||
<div
|
||||
ref={widgetRef}
|
||||
style={{
|
||||
background: 'rgba(15, 23, 42, 0.95)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(148, 163, 184, 0.2)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
|
||||
color: '#e2e8f0',
|
||||
width: '280px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header with current weather */}
|
||||
<div
|
||||
onClick={() => setExpanded(false)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: bg,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '32px' }}>{weather.icon}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: isSnow ? '#1e293b' : '#fff' }}>
|
||||
{weather.temp}°
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: isSnow ? '#334155' : 'rgba(255,255,255,0.8)' }}>
|
||||
{weather.city}
|
||||
</div>
|
||||
</div>
|
||||
<i className="fa-solid fa-chevron-up" style={{ color: isSnow ? '#1e293b' : '#fff', opacity: 0.6 }} />
|
||||
</div>
|
||||
|
||||
{/* Current details */}
|
||||
<div style={{ padding: '10px 12px', display: 'flex', gap: '16px', borderBottom: '1px solid rgba(148,163,184,0.15)' }}>
|
||||
{weather.humidity != null && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#94a3b8' }}>
|
||||
<span>💧</span> {weather.humidity}%
|
||||
</div>
|
||||
)}
|
||||
{weather.wind != null && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#94a3b8' }}>
|
||||
<span>🌬️</span> {weather.wind} km/h
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 7-day forecast */}
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{ padding: '0 12px 6px', fontSize: '11px', color: '#64748b', fontWeight: '600', textTransform: 'uppercase' }}>
|
||||
Prévisions 7 jours
|
||||
</div>
|
||||
{daily && daily.slice(0, 7).map((d, i) => {
|
||||
const dayIcon = ICON_MAP[WMO_TO_ICON[d.code] || 'cloud'] || '🌡️';
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '6px 12px',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '40px', fontSize: '12px', color: '#94a3b8' }}>{getDayName(d.date)}</span>
|
||||
<span style={{ fontSize: '16px' }}>{dayIcon}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: '13px', fontWeight: '600', width: '30px', textAlign: 'right' }}>{d.max}°</span>
|
||||
<span style={{ fontSize: '12px', color: '#64748b', width: '30px', textAlign: 'right' }}>{d.min}°</span>
|
||||
{d.precip > 0 && (
|
||||
<span style={{ fontSize: '11px', color: '#60a5fa', width: '45px', textAlign: 'right' }}>💧{d.precip.toFixed(1)}</span>
|
||||
)}
|
||||
{d.precip <= 0 && <span style={{ width: '45px' }} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Weather App Registration
|
||||
*/
|
||||
|
||||
import { registerApp } from '../AppRegistry';
|
||||
import { SLOT_IDS } from '../types';
|
||||
import WeatherWidget from './WeatherWidget';
|
||||
|
||||
export function registerWeatherApp() {
|
||||
registerApp({
|
||||
appId: 'weather',
|
||||
name: 'Weather',
|
||||
description: 'Shows current weather for the map location',
|
||||
icon: 'fa-solid fa-cloud-sun',
|
||||
defaultSlot: SLOT_IDS.TOP_LEFT,
|
||||
order: 10,
|
||||
compact: true, // Use minimal styling (pill)
|
||||
permissions: {
|
||||
location: true,
|
||||
network: true,
|
||||
},
|
||||
mountWidget: WeatherWidget,
|
||||
});
|
||||
}
|
||||
|
||||
export { WeatherWidget };
|
||||
export default { registerWeatherApp };
|
||||
Loading…
Reference in New Issue