feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

86
smart-app-city/frontend/node_modules/badgin/README.md generated vendored Normal file
View File

@@ -0,0 +1,86 @@
# Badgin
The [Badging API](https://web.dev/badging-api/) is a new web platform API that allows installed web apps to set an application-wide badge, shown in an operating-system-specific place associated with the application (such as the shelf or home screen). The Badging API works on Windows, and macOS, in Chrome 81 or later. It has also been confirmed to work on Edge 84 or later. Support for Chrome OS is in development and will be available in a future release of Chrome. On Android, the Badging API is not supported. Instead, Android automatically shows a badge on app icon for the installed web app when there is an unread notification, just as for Android apps. Since this API is not available everywhere, `badgin` safely falls back to alternatives.
## via badge
Currently, the native badge is only displayed if you install the web application to your home screen (view [prerequisites](https://developers.google.com/web/fundamentals/app-install-banners)). The screenshot shows the application in the dock of macOS.
![](https://github.com/jaulz/badgin/raw/master/assets/screenshots/standalone_osx.png)
## via favicon
If the native badge is not available, the favicon will be used and a small badge will be added.
![](https://github.com/jaulz/badgin/raw/master/assets/screenshots/favicon.png)
## via title
If the favicon is not available, the badge will be added as a prefix to the title.
![](https://github.com/jaulz/badgin/raw/master/assets/screenshots/title.png)
## Demo
You can find a demo at https://jaulz.github.io/badgin/ where you can see the different options. If you want to see the native badge, you need to install the app to your home screen (check out the plus icon in the address bar).
## Installation
The module can be installed by running:
```
yarn add --save badgin
```
## Usage
Just use the library as following:
```js
import badgin from 'badgin'
badgin.set(1) // set value
badgin.set() // set indicator only
badgin.clear()
```
### Options
The following options can be used:
```js
{
method: 'Badging' | 'Favicon' | 'Title'
favicon: {
backgroundColor: string = '#424242'
color: string = '#ffffff'
indicator: string = '!'
radius: number = 3
size: number = 7
horizontalMargin: number = 0
verticalMargin: number = 0
horizontalPadding: number = 1
verticalPadding: number = 1
}
title: {
indicator: string = '!'
}
}
```
And you can use it like this:
```js
badgin.set(1, {
favicon: {
width: 9,
background: '#549A2F',
},
})
```
## License / Credits
MIT
This is a refactored fork of the original Tinycon library, Tinycon is released under the MIT license. Tinycon was inspired by [Notificon](https://github.com/makeable/Notificon).

View File

@@ -0,0 +1,17 @@
import { Value } from './index';
declare global {
interface Window {
ExperimentalBadge?: {
set: (value?: number) => void;
clear: () => void;
};
}
interface Navigator {
setExperimentalAppBadge?: (value?: number) => void;
clearExperimentalAppBadge?: () => void;
}
}
export declare function isAvailable(): boolean;
export declare function set(value: Value): boolean;
export declare function clear(): void;
//# sourceMappingURL=badging.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"badging.d.ts","sourceRoot":"","sources":["src/badging.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAI/B,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,iBAAiB,CAAC,EAAE;YAClB,GAAG,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;YAC7B,KAAK,EAAE,MAAM,IAAI,CAAA;SAClB,CAAA;KACF;IAED,UAAU,SAAS;QACjB,uBAAuB,CAAC,EAAE,CAAC,KAAK,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;QAClD,yBAAyB,CAAC,EAAE,MAAM,IAAI,CAAA;KACvC;CACF;AAqCD,wBAAgB,WAAW,YAc1B;AAED,wBAAgB,GAAG,CAAC,KAAK,EAAE,KAAK,WAmB/B;AAED,wBAAgB,KAAK,SAUpB"}

View File

@@ -0,0 +1,2 @@
export default function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any>;
//# sourceMappingURL=deepMerge.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"deepMerge.d.ts","sourceRoot":"","sources":["src/deepMerge.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,OAAO,UAAU,SAAS,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,uBAW5B"}

View File

@@ -0,0 +1,18 @@
import { Value } from './index';
export declare type Options = {
backgroundColor: string;
color: string;
indicator: string;
radius: number;
size: number;
horizontalMargin: number;
verticalMargin: number;
horizontalPadding: number;
verticalPadding: number;
};
export declare const DefaultValue: Value;
export declare const DefaultOptions: Options;
export declare function isAvailable(): boolean;
export declare function set(value: Value, options?: Partial<Options>): boolean;
export declare function clear(): void;
//# sourceMappingURL=favicon.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"favicon.d.ts","sourceRoot":"","sources":["src/favicon.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAG/B,oBAAY,OAAO,GAAG;IACpB,eAAe,EAAE,MAAM,CAAA;IACvB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB,EAAE,MAAM,CAAA;IACxB,cAAc,EAAE,MAAM,CAAA;IACtB,iBAAiB,EAAE,MAAM,CAAA;IACzB,eAAe,EAAE,MAAM,CAAA;CACxB,CAAA;AAMD,eAAO,MAAM,YAAY,EAAE,KAAS,CAAA;AAEpC,eAAO,MAAM,cAAc,EAAE,OAU5B,CAAA;AAwPD,wBAAgB,WAAW,YAE1B;AAED,wBAAgB,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,WAqD3D;AAED,wBAAgB,KAAK,SA4BpB"}

View File

@@ -0,0 +1,22 @@
import * as favicon from './favicon';
import * as title from './title';
export declare type Value = number | undefined;
export interface Interface {
set: (value: Value) => void;
clear: () => void;
}
export declare type Method = 'Badging' | 'Favicon' | 'Title';
export interface Options {
method: Method;
favicon: Partial<favicon.Options>;
title: Partial<title.Options>;
}
/**
* Sets badge
*/
export declare function set(value: Value, options?: Partial<Options>): void;
/**
* Clears badge
*/
export declare function clear(): void;
//# sourceMappingURL=index.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["src/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,OAAO,MAAM,WAAW,CAAA;AACpC,OAAO,KAAK,KAAK,MAAM,SAAS,CAAA;AAEhC,oBAAY,KAAK,GAAG,MAAM,GAAG,SAAS,CAAA;AAEtC,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAA;IAC3B,KAAK,EAAE,MAAM,IAAI,CAAA;CAClB;AAED,oBAAY,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,CAAA;AAEpD,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;IACjC,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAA;CAC9B;AAED;;GAEG;AACH,wBAAgB,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,GAAE,OAAO,CAAC,OAAO,CAAM,QAsB/D;AAED;;GAEG;AACH,wBAAgB,KAAK,SAIpB"}

View File

@@ -0,0 +1,445 @@
let warnedBefore = false;
const warn = () => {
if (warnedBefore) {
return;
}
// We will only warn the user if the Badging API is not available at all
if ('ExperimentalBadge' in window || 'setExperimentalAppBadge' in navigator) {
return;
}
console.warn('Badging API must be enabled. Please check here how you can enable it: https://developers.google.com/web/updates/2018/12/badging-api#use');
warnedBefore = true;
};
const current = {
mediaQuery: null,
value: 0,
};
function isVersion1Available() {
return 'ExperimentalBadge' in window && !!window.ExperimentalBadge;
}
function isVersion2Available() {
return ('setExperimentalAppBadge' in navigator &&
!!navigator.setExperimentalAppBadge &&
'clearExperimentalAppBadge' in navigator &&
!!navigator.clearExperimentalAppBadge);
}
function isAvailable() {
if (!current.mediaQuery) {
current.mediaQuery = window.matchMedia('(display-mode: standalone)');
// Get notified once app is installed
current.mediaQuery.onchange = event => {
set(current.value);
};
}
return (current.mediaQuery.matches &&
(isVersion1Available() || isVersion2Available()));
}
function set(value) {
current.value = value;
if (!isAvailable()) {
warn();
return false;
}
// Sets the badge to contents (an integer), or to "flag" if contents is omitted. If contents is 0, clears the badge for the matching app(s).
// See details here: https://github.com/WICG/badging/blob/master/explainer.md#the-api
if (isVersion1Available()) {
window.ExperimentalBadge.set(value);
return true;
}
else if (isVersion2Available()) {
navigator.setExperimentalAppBadge(value);
return true;
}
return false;
}
function clear() {
if (!isAvailable()) {
return;
}
if (isVersion1Available()) {
window.ExperimentalBadge.clear();
}
else if (isVersion2Available()) {
navigator.clearExperimentalAppBadge();
}
}
function deepMerge(target, source) {
// Iterate through `source` properties and if an `Object` set property to merge of `target` and `source` properties
for (const key of Object.keys(source)) {
if (source[key] instanceof Object)
Object.assign(source[key], deepMerge(target[key], source[key]));
}
// Join `target` and modified `source`
Object.assign(target || {}, source);
return target;
}
function isPositiveNumber(value) {
return (typeof value !== 'undefined' && Number.isInteger(value) && value >= 0);
}
const DefaultValue = 0;
const DefaultOptions = {
backgroundColor: '#424242',
color: '#ffffff',
indicator: '!',
radius: 3,
size: 7,
horizontalMargin: 0,
verticalMargin: 0,
horizontalPadding: 1,
verticalPadding: 1,
};
const isFirefox = navigator.userAgent.indexOf('Firefox') > -1;
// Get all favicons of the page
const getFavicons = () => {
const links = document.head.getElementsByTagName('link');
const favicons = [];
for (let i = 0; i < links.length; i++) {
const link = links[i];
const href = link.getAttribute('href');
const rel = link.getAttribute('rel');
if (!href) {
continue;
}
if (!rel) {
continue;
}
if (rel.split(' ').indexOf('icon') === -1) {
continue;
}
favicons.push(link);
}
return favicons;
};
// Get the favicon with the best quality of the document
const getBestFavicon = () => {
const favicons = getFavicons();
let bestFavicon = null;
let bestSize = 0;
for (let i = 0; i < favicons.length; i++) {
const favicon = favicons[i];
const href = favicon.getAttribute('href');
const sizes = favicon.getAttribute('sizes');
// If the href looks like it's an SVG, it's the best we can get
if (href === null || href === void 0 ? void 0 : href.endsWith('.svg')) {
return favicon;
}
// If the link does not have a "sizes" attribute, we use it only if we haven't found anything else yet
if (!sizes) {
if (!bestFavicon) {
bestFavicon = favicon;
bestSize = 0;
}
continue;
}
// If we find an icon with sizes "any", it's the best we can get
if (sizes === 'any') {
return favicon;
}
// Otherwise we will try to find the maximum size
const size = parseInt(sizes.split('x')[0], 10);
if (Number.isNaN(size)) {
if (!bestFavicon) {
bestFavicon = favicon;
bestSize = 0;
}
continue;
}
if (size > bestSize) {
bestFavicon = favicon;
bestSize = size;
continue;
}
}
return bestFavicon;
};
// References to the favicons that we need to track in order to reset and update the counters
const current$1 = {
favicons: null,
bestFavicon: null,
bestFaviconImage: null,
value: DefaultValue,
options: DefaultOptions,
};
// Get size depending on screen density
const devicePixelRatioListener = window.matchMedia('screen and (min-resolution: 2dppx)');
const getRatio = () => {
return Math.ceil(window.devicePixelRatio) || 1;
};
const handleRatioChange = () => {
set$1(current$1.value, current$1.options);
};
const getIconSize = () => {
return 16 * getRatio();
};
// Update favicon
const setFavicon = (url) => {
if (!url) {
return;
}
// Remove previous favicons
for (const favicon of getFavicons()) {
if (favicon.parentNode) {
favicon.parentNode.removeChild(favicon);
}
}
// Create new favicon
const newFavicon = document.createElement('link');
newFavicon.id = 'badgin';
newFavicon.type = 'image/x-icon';
newFavicon.rel = 'icon favicon';
newFavicon.href = url;
document.getElementsByTagName('head')[0].appendChild(newFavicon);
};
// Draw the favicon
const drawFavicon = (image, value, options) => {
const iconSize = getIconSize();
const canvas = document.createElement('canvas');
canvas.width = iconSize;
canvas.height = iconSize;
const context = canvas.getContext('2d');
if (!context) {
return;
}
// Draw new image
image.width = iconSize;
image.height = iconSize;
context.drawImage(image, 0, 0, image.width, image.height);
// Draw bubble on the top
drawBubble(context, value, options);
// Refresh tag in page
setFavicon(canvas.toDataURL());
};
// Draws the bubble on the canvas
const drawBubble = (context, value, options) => {
const ratio = getRatio();
const iconSize = getIconSize();
// Do we need to render the bubble at all?
let finalValue = '';
if (isPositiveNumber(value)) {
if (value === 0) {
finalValue = '';
}
else if (value < 100) {
finalValue = String(value);
}
else {
finalValue = '99+';
}
}
else {
finalValue = options.indicator;
}
// Return early
if (!finalValue) {
return;
}
// Calculate text width initially
const textHeight = options.size - 2;
const font = `${options.size * ratio}px Arial`;
context.font = font;
const { width: textWidth } = context.measureText(finalValue);
context.restore();
// Calculate position etc.
const width = textWidth + 2 * options.horizontalPadding;
const height = textHeight * ratio + 2 * options.verticalPadding;
const top = iconSize - height - options.verticalMargin;
const left = iconSize - width - options.horizontalMargin;
const bottom = 16 * ratio - options.verticalMargin;
const right = 16 * ratio - options.horizontalMargin;
const radius = options.radius;
// Bubble
context.globalAlpha = 1;
context.fillStyle = options.backgroundColor;
context.strokeStyle = options.backgroundColor;
context.lineWidth = 0;
context.beginPath();
context.moveTo(left + radius, top);
context.quadraticCurveTo(left, top, left, top + radius);
context.lineTo(left, bottom - radius);
context.quadraticCurveTo(left, bottom, left + radius, bottom);
context.lineTo(right - radius, bottom);
context.quadraticCurveTo(right, bottom, right, bottom - radius);
context.lineTo(right, top + radius);
context.quadraticCurveTo(right, top, right - radius, top);
context.closePath();
context.fill();
context.save();
// Value
context.font = font;
context.fillStyle = options.color;
context.textAlign = 'center';
context.textBaseline = 'hanging';
context.fillText(finalValue, left + width / 2, top + options.verticalPadding + (isFirefox ? 1 : 0));
context.save();
/*
// Helper line
context.restore()
context.strokeStyle = '#ff0000'
context.moveTo(0, top + height / 2)
context.lineTo(iconSize, top + height / 2)
context.stroke()
context.save()
*/
};
function isAvailable$1() {
return !!getBestFavicon();
}
function set$1(value, options) {
// Remember options
current$1.value = value;
deepMerge(current$1.options, options || {});
if (!isAvailable$1()) {
return false;
}
// Remember favicons
if (!current$1.bestFavicon) {
const bestFavicon = getBestFavicon();
if (bestFavicon) {
const bestFaviconImage = document.createElement('img');
// Allow cross origin resource requests if the image is not a data:uri
if (!bestFavicon.href.match(/^data/)) {
bestFaviconImage.crossOrigin = 'anonymous';
}
// Load image
bestFaviconImage.src = bestFavicon.href;
// Store for next time
current$1.bestFavicon = bestFavicon;
current$1.bestFaviconImage = bestFaviconImage;
}
// Once the device pixel ratio changes we set the value again
devicePixelRatioListener.addEventListener('change', handleRatioChange);
}
if (!current$1.favicons) {
current$1.favicons = getFavicons();
}
// The image is required for setting the badge
if (!current$1.bestFaviconImage) {
return false;
}
// If we have the image, we can draw immediately
if (current$1.bestFaviconImage.complete) {
drawFavicon(current$1.bestFaviconImage, current$1.value, current$1.options);
return true;
}
// Otherwise we will wait for the load event
current$1.bestFaviconImage.addEventListener('load', function () {
drawFavicon(this, current$1.value, current$1.options);
});
return true;
}
function clear$1() {
if (!isAvailable$1()) {
return;
}
// Reset value and options
current$1.value = DefaultValue;
current$1.options = DefaultOptions;
// Remove old listener
devicePixelRatioListener.removeEventListener('change', handleRatioChange);
if (current$1.favicons) {
// Remove current favicons
for (const favicon of getFavicons()) {
if (favicon.parentNode) {
favicon.parentNode.removeChild(favicon);
}
}
// Recreate old favicons
for (const favicon of current$1.favicons) {
document.head.appendChild(favicon);
}
current$1.favicons = null;
current$1.bestFavicon = null;
current$1.bestFaviconImage = null;
}
}
const defaultOptions = {
indicator: '!',
};
const current$2 = {
title: null,
value: 0,
options: defaultOptions,
};
function changeTitle(title, value, options) {
let newTitle = title;
if (isPositiveNumber(value)) {
if (value === 0) {
newTitle = title;
}
else {
newTitle = `(${value}) ${title}`;
}
}
else {
newTitle = `(${options.indicator}) ${title}`;
}
const element = document.querySelector('title');
if (element) {
element.childNodes[0].nodeValue = newTitle;
}
}
function set$2(value, options) {
if (current$2.title === null) {
current$2.title = document.title;
// Watch changes of title
Object.defineProperty(document, 'title', {
get: () => {
return current$2.title;
},
set: title => {
current$2.title = title;
changeTitle(current$2.title, current$2.value, current$2.options);
},
});
}
// Remember value and options
current$2.value = value;
deepMerge(current$2.options, options || {});
// Trigger change
document.title = document.title;
return true;
}
function clear$2() {
current$2.value = 0;
// Trigger change
document.title = document.title;
}
/**
* Sets badge
*/
function set$3(value, options = {}) {
switch (options.method) {
case undefined:
case 'Badging': {
if (set(value)) {
// Break only if method is explicitly requested
if (options.method === 'Badging') {
break;
}
}
}
case 'Favicon': {
if (set$1(value, options.favicon)) {
break;
}
}
default: {
set$2(value, options.title);
}
}
}
/**
* Clears badge
*/
function clear$3() {
clear();
clear$1();
clear$2();
}
export { clear$3 as clear, set$3 as set };

View File

@@ -0,0 +1 @@
var badgin=function(e){"use strict";let t=!1;const n={mediaQuery:null,value:0};function i(){return"ExperimentalBadge"in window&&!!window.ExperimentalBadge}function a(){return"setExperimentalAppBadge"in navigator&&!!navigator.setExperimentalAppBadge&&"clearExperimentalAppBadge"in navigator&&!!navigator.clearExperimentalAppBadge}function o(){return n.mediaQuery||(n.mediaQuery=window.matchMedia("(display-mode: standalone)"),n.mediaQuery.onchange=e=>{r(n.value)}),n.mediaQuery.matches&&(i()||a())}function r(e){return n.value=e,o()?i()?(window.ExperimentalBadge.set(e),!0):!!a()&&(navigator.setExperimentalAppBadge(e),!0):(t||"ExperimentalBadge"in window||"setExperimentalAppBadge"in navigator||(console.warn("Badging API must be enabled. Please check here how you can enable it: https://developers.google.com/web/updates/2018/12/badging-api#use"),t=!0),!1)}function l(e,t){for(const n of Object.keys(t))t[n]instanceof Object&&Object.assign(t[n],l(e[n],t[n]));return Object.assign(e||{},t),e}function c(e){return void 0!==e&&Number.isInteger(e)&&e>=0}const d={backgroundColor:"#424242",color:"#ffffff",indicator:"!",radius:3,size:7,horizontalMargin:0,verticalMargin:0,horizontalPadding:1,verticalPadding:1},s=navigator.userAgent.indexOf("Firefox")>-1,u=()=>{const e=document.head.getElementsByTagName("link"),t=[];for(let n=0;n<e.length;n++){const i=e[n],a=i.getAttribute("href"),o=i.getAttribute("rel");a&&(o&&-1!==o.split(" ").indexOf("icon")&&t.push(i))}return t},g=()=>{const e=u();let t=null,n=0;for(let i=0;i<e.length;i++){const a=e[i],o=a.getAttribute("href"),r=a.getAttribute("sizes");if(null==o?void 0:o.endsWith(".svg"))return a;if(!r){t||(t=a,n=0);continue}if("any"===r)return a;const l=parseInt(r.split("x")[0],10);Number.isNaN(l)?t||(t=a,n=0):l>n&&(t=a,n=l)}return t},v={favicons:null,bestFavicon:null,bestFaviconImage:null,value:0,options:d},f=window.matchMedia("screen and (min-resolution: 2dppx)"),m=()=>Math.ceil(window.devicePixelRatio)||1,p=()=>{E(v.value,v.options)},h=()=>16*m(),b=(e,t,n)=>{const i=h(),a=document.createElement("canvas");a.width=i,a.height=i;const o=a.getContext("2d");o&&(e.width=i,e.height=i,o.drawImage(e,0,0,e.width,e.height),w(o,t,n),(e=>{if(!e)return;for(const e of u())e.parentNode&&e.parentNode.removeChild(e);const t=document.createElement("link");t.id="badgin",t.type="image/x-icon",t.rel="icon favicon",t.href=e,document.getElementsByTagName("head")[0].appendChild(t)})(a.toDataURL()))},w=(e,t,n)=>{const i=m(),a=h();let o="";if(o=c(t)?0===t?"":t<100?String(t):"99+":n.indicator,!o)return;const r=n.size-2,l=n.size*i+"px Arial";e.font=l;const{width:d}=e.measureText(o);e.restore();const u=d+2*n.horizontalPadding,g=a-(r*i+2*n.verticalPadding)-n.verticalMargin,v=a-u-n.horizontalMargin,f=16*i-n.verticalMargin,p=16*i-n.horizontalMargin,b=n.radius;e.globalAlpha=1,e.fillStyle=n.backgroundColor,e.strokeStyle=n.backgroundColor,e.lineWidth=0,e.beginPath(),e.moveTo(v+b,g),e.quadraticCurveTo(v,g,v,g+b),e.lineTo(v,f-b),e.quadraticCurveTo(v,f,v+b,f),e.lineTo(p-b,f),e.quadraticCurveTo(p,f,p,f-b),e.lineTo(p,g+b),e.quadraticCurveTo(p,g,p-b,g),e.closePath(),e.fill(),e.save(),e.font=l,e.fillStyle=n.color,e.textAlign="center",e.textBaseline="hanging",e.fillText(o,v+u/2,g+n.verticalPadding+(s?1:0)),e.save()};function x(){return!!g()}function E(e,t){if(v.value=e,l(v.options,t||{}),!x())return!1;if(!v.bestFavicon){const e=g();if(e){const t=document.createElement("img");e.href.match(/^data/)||(t.crossOrigin="anonymous"),t.src=e.href,v.bestFavicon=e,v.bestFaviconImage=t}f.addEventListener("change",p)}return v.favicons||(v.favicons=u()),!!v.bestFaviconImage&&(v.bestFaviconImage.complete?(b(v.bestFaviconImage,v.value,v.options),!0):(v.bestFaviconImage.addEventListener("load",(function(){b(this,v.value,v.options)})),!0))}const y={title:null,value:0,options:{indicator:"!"}};function B(e,t){return null===y.title&&(y.title=document.title,Object.defineProperty(document,"title",{get:()=>y.title,set:e=>{y.title=e,function(e,t,n){let i=e;i=c(t)?0===t?e:`(${t}) ${e}`:`(${n.indicator}) ${e}`;const a=document.querySelector("title");a&&(a.childNodes[0].nodeValue=i)}(y.title,y.value,y.options)}})),y.value=e,l(y.options,t||{}),document.title=document.title,!0}return e.clear=function(){o()&&(i()?window.ExperimentalBadge.clear():a()&&navigator.clearExperimentalAppBadge()),function(){if(x()&&(v.value=0,v.options=d,f.removeEventListener("change",p),v.favicons)){for(const e of u())e.parentNode&&e.parentNode.removeChild(e);for(const e of v.favicons)document.head.appendChild(e);v.favicons=null,v.bestFavicon=null,v.bestFaviconImage=null}}(),y.value=0,document.title=document.title},e.set=function(e,t={}){switch(t.method){case void 0:case"Badging":if(r(e)&&"Badging"===t.method)break;case"Favicon":if(E(e,t.favicon))break;default:B(e,t.title)}},e}({});

View File

@@ -0,0 +1,450 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
let warnedBefore = false;
const warn = () => {
if (warnedBefore) {
return;
}
// We will only warn the user if the Badging API is not available at all
if ('ExperimentalBadge' in window || 'setExperimentalAppBadge' in navigator) {
return;
}
console.warn('Badging API must be enabled. Please check here how you can enable it: https://developers.google.com/web/updates/2018/12/badging-api#use');
warnedBefore = true;
};
const current = {
mediaQuery: null,
value: 0,
};
function isVersion1Available() {
return 'ExperimentalBadge' in window && !!window.ExperimentalBadge;
}
function isVersion2Available() {
return ('setExperimentalAppBadge' in navigator &&
!!navigator.setExperimentalAppBadge &&
'clearExperimentalAppBadge' in navigator &&
!!navigator.clearExperimentalAppBadge);
}
function isAvailable() {
if (!current.mediaQuery) {
current.mediaQuery = window.matchMedia('(display-mode: standalone)');
// Get notified once app is installed
current.mediaQuery.onchange = event => {
set(current.value);
};
}
return (current.mediaQuery.matches &&
(isVersion1Available() || isVersion2Available()));
}
function set(value) {
current.value = value;
if (!isAvailable()) {
warn();
return false;
}
// Sets the badge to contents (an integer), or to "flag" if contents is omitted. If contents is 0, clears the badge for the matching app(s).
// See details here: https://github.com/WICG/badging/blob/master/explainer.md#the-api
if (isVersion1Available()) {
window.ExperimentalBadge.set(value);
return true;
}
else if (isVersion2Available()) {
navigator.setExperimentalAppBadge(value);
return true;
}
return false;
}
function clear() {
if (!isAvailable()) {
return;
}
if (isVersion1Available()) {
window.ExperimentalBadge.clear();
}
else if (isVersion2Available()) {
navigator.clearExperimentalAppBadge();
}
}
function deepMerge(target, source) {
// Iterate through `source` properties and if an `Object` set property to merge of `target` and `source` properties
for (const key of Object.keys(source)) {
if (source[key] instanceof Object)
Object.assign(source[key], deepMerge(target[key], source[key]));
}
// Join `target` and modified `source`
Object.assign(target || {}, source);
return target;
}
function isPositiveNumber(value) {
return (typeof value !== 'undefined' && Number.isInteger(value) && value >= 0);
}
const DefaultValue = 0;
const DefaultOptions = {
backgroundColor: '#424242',
color: '#ffffff',
indicator: '!',
radius: 3,
size: 7,
horizontalMargin: 0,
verticalMargin: 0,
horizontalPadding: 1,
verticalPadding: 1,
};
const isFirefox = navigator.userAgent.indexOf('Firefox') > -1;
// Get all favicons of the page
const getFavicons = () => {
const links = document.head.getElementsByTagName('link');
const favicons = [];
for (let i = 0; i < links.length; i++) {
const link = links[i];
const href = link.getAttribute('href');
const rel = link.getAttribute('rel');
if (!href) {
continue;
}
if (!rel) {
continue;
}
if (rel.split(' ').indexOf('icon') === -1) {
continue;
}
favicons.push(link);
}
return favicons;
};
// Get the favicon with the best quality of the document
const getBestFavicon = () => {
const favicons = getFavicons();
let bestFavicon = null;
let bestSize = 0;
for (let i = 0; i < favicons.length; i++) {
const favicon = favicons[i];
const href = favicon.getAttribute('href');
const sizes = favicon.getAttribute('sizes');
// If the href looks like it's an SVG, it's the best we can get
if (href === null || href === void 0 ? void 0 : href.endsWith('.svg')) {
return favicon;
}
// If the link does not have a "sizes" attribute, we use it only if we haven't found anything else yet
if (!sizes) {
if (!bestFavicon) {
bestFavicon = favicon;
bestSize = 0;
}
continue;
}
// If we find an icon with sizes "any", it's the best we can get
if (sizes === 'any') {
return favicon;
}
// Otherwise we will try to find the maximum size
const size = parseInt(sizes.split('x')[0], 10);
if (Number.isNaN(size)) {
if (!bestFavicon) {
bestFavicon = favicon;
bestSize = 0;
}
continue;
}
if (size > bestSize) {
bestFavicon = favicon;
bestSize = size;
continue;
}
}
return bestFavicon;
};
// References to the favicons that we need to track in order to reset and update the counters
const current$1 = {
favicons: null,
bestFavicon: null,
bestFaviconImage: null,
value: DefaultValue,
options: DefaultOptions,
};
// Get size depending on screen density
const devicePixelRatioListener = window.matchMedia('screen and (min-resolution: 2dppx)');
const getRatio = () => {
return Math.ceil(window.devicePixelRatio) || 1;
};
const handleRatioChange = () => {
set$1(current$1.value, current$1.options);
};
const getIconSize = () => {
return 16 * getRatio();
};
// Update favicon
const setFavicon = (url) => {
if (!url) {
return;
}
// Remove previous favicons
for (const favicon of getFavicons()) {
if (favicon.parentNode) {
favicon.parentNode.removeChild(favicon);
}
}
// Create new favicon
const newFavicon = document.createElement('link');
newFavicon.id = 'badgin';
newFavicon.type = 'image/x-icon';
newFavicon.rel = 'icon favicon';
newFavicon.href = url;
document.getElementsByTagName('head')[0].appendChild(newFavicon);
};
// Draw the favicon
const drawFavicon = (image, value, options) => {
const iconSize = getIconSize();
const canvas = document.createElement('canvas');
canvas.width = iconSize;
canvas.height = iconSize;
const context = canvas.getContext('2d');
if (!context) {
return;
}
// Draw new image
image.width = iconSize;
image.height = iconSize;
context.drawImage(image, 0, 0, image.width, image.height);
// Draw bubble on the top
drawBubble(context, value, options);
// Refresh tag in page
setFavicon(canvas.toDataURL());
};
// Draws the bubble on the canvas
const drawBubble = (context, value, options) => {
const ratio = getRatio();
const iconSize = getIconSize();
// Do we need to render the bubble at all?
let finalValue = '';
if (isPositiveNumber(value)) {
if (value === 0) {
finalValue = '';
}
else if (value < 100) {
finalValue = String(value);
}
else {
finalValue = '99+';
}
}
else {
finalValue = options.indicator;
}
// Return early
if (!finalValue) {
return;
}
// Calculate text width initially
const textHeight = options.size - 2;
const font = `${options.size * ratio}px Arial`;
context.font = font;
const { width: textWidth } = context.measureText(finalValue);
context.restore();
// Calculate position etc.
const width = textWidth + 2 * options.horizontalPadding;
const height = textHeight * ratio + 2 * options.verticalPadding;
const top = iconSize - height - options.verticalMargin;
const left = iconSize - width - options.horizontalMargin;
const bottom = 16 * ratio - options.verticalMargin;
const right = 16 * ratio - options.horizontalMargin;
const radius = options.radius;
// Bubble
context.globalAlpha = 1;
context.fillStyle = options.backgroundColor;
context.strokeStyle = options.backgroundColor;
context.lineWidth = 0;
context.beginPath();
context.moveTo(left + radius, top);
context.quadraticCurveTo(left, top, left, top + radius);
context.lineTo(left, bottom - radius);
context.quadraticCurveTo(left, bottom, left + radius, bottom);
context.lineTo(right - radius, bottom);
context.quadraticCurveTo(right, bottom, right, bottom - radius);
context.lineTo(right, top + radius);
context.quadraticCurveTo(right, top, right - radius, top);
context.closePath();
context.fill();
context.save();
// Value
context.font = font;
context.fillStyle = options.color;
context.textAlign = 'center';
context.textBaseline = 'hanging';
context.fillText(finalValue, left + width / 2, top + options.verticalPadding + (isFirefox ? 1 : 0));
context.save();
/*
// Helper line
context.restore()
context.strokeStyle = '#ff0000'
context.moveTo(0, top + height / 2)
context.lineTo(iconSize, top + height / 2)
context.stroke()
context.save()
*/
};
function isAvailable$1() {
return !!getBestFavicon();
}
function set$1(value, options) {
// Remember options
current$1.value = value;
deepMerge(current$1.options, options || {});
if (!isAvailable$1()) {
return false;
}
// Remember favicons
if (!current$1.bestFavicon) {
const bestFavicon = getBestFavicon();
if (bestFavicon) {
const bestFaviconImage = document.createElement('img');
// Allow cross origin resource requests if the image is not a data:uri
if (!bestFavicon.href.match(/^data/)) {
bestFaviconImage.crossOrigin = 'anonymous';
}
// Load image
bestFaviconImage.src = bestFavicon.href;
// Store for next time
current$1.bestFavicon = bestFavicon;
current$1.bestFaviconImage = bestFaviconImage;
}
// Once the device pixel ratio changes we set the value again
devicePixelRatioListener.addEventListener('change', handleRatioChange);
}
if (!current$1.favicons) {
current$1.favicons = getFavicons();
}
// The image is required for setting the badge
if (!current$1.bestFaviconImage) {
return false;
}
// If we have the image, we can draw immediately
if (current$1.bestFaviconImage.complete) {
drawFavicon(current$1.bestFaviconImage, current$1.value, current$1.options);
return true;
}
// Otherwise we will wait for the load event
current$1.bestFaviconImage.addEventListener('load', function () {
drawFavicon(this, current$1.value, current$1.options);
});
return true;
}
function clear$1() {
if (!isAvailable$1()) {
return;
}
// Reset value and options
current$1.value = DefaultValue;
current$1.options = DefaultOptions;
// Remove old listener
devicePixelRatioListener.removeEventListener('change', handleRatioChange);
if (current$1.favicons) {
// Remove current favicons
for (const favicon of getFavicons()) {
if (favicon.parentNode) {
favicon.parentNode.removeChild(favicon);
}
}
// Recreate old favicons
for (const favicon of current$1.favicons) {
document.head.appendChild(favicon);
}
current$1.favicons = null;
current$1.bestFavicon = null;
current$1.bestFaviconImage = null;
}
}
const defaultOptions = {
indicator: '!',
};
const current$2 = {
title: null,
value: 0,
options: defaultOptions,
};
function changeTitle(title, value, options) {
let newTitle = title;
if (isPositiveNumber(value)) {
if (value === 0) {
newTitle = title;
}
else {
newTitle = `(${value}) ${title}`;
}
}
else {
newTitle = `(${options.indicator}) ${title}`;
}
const element = document.querySelector('title');
if (element) {
element.childNodes[0].nodeValue = newTitle;
}
}
function set$2(value, options) {
if (current$2.title === null) {
current$2.title = document.title;
// Watch changes of title
Object.defineProperty(document, 'title', {
get: () => {
return current$2.title;
},
set: title => {
current$2.title = title;
changeTitle(current$2.title, current$2.value, current$2.options);
},
});
}
// Remember value and options
current$2.value = value;
deepMerge(current$2.options, options || {});
// Trigger change
document.title = document.title;
return true;
}
function clear$2() {
current$2.value = 0;
// Trigger change
document.title = document.title;
}
/**
* Sets badge
*/
function set$3(value, options = {}) {
switch (options.method) {
case undefined:
case 'Badging': {
if (set(value)) {
// Break only if method is explicitly requested
if (options.method === 'Badging') {
break;
}
}
}
case 'Favicon': {
if (set$1(value, options.favicon)) {
break;
}
}
default: {
set$2(value, options.title);
}
}
}
/**
* Clears badge
*/
function clear$3() {
clear();
clear$1();
clear$2();
}
exports.clear = clear$3;
exports.set = set$3;

View File

@@ -0,0 +1,3 @@
import { Value } from './index';
export default function isPositiveNumber(value: Value): value is number;
//# sourceMappingURL=isPositiveNumber.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"isPositiveNumber.d.ts","sourceRoot":"","sources":["src/isPositiveNumber.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAE/B,MAAM,CAAC,OAAO,UAAU,gBAAgB,CAAC,KAAK,EAAE,KAAK,GAAG,KAAK,IAAI,MAAM,CAItE"}

View File

@@ -0,0 +1,11 @@
import { Value } from './index';
export declare type Options = {
indicator: string;
};
declare type Title = string | null;
export declare const defaultOptions: Options;
export declare function changeTitle(title: Title, value: Value, options: Options): void;
export declare function set(value: Value, options?: Partial<Options>): boolean;
export declare function clear(): void;
export {};
//# sourceMappingURL=title.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"title.d.ts","sourceRoot":"","sources":["src/title.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA;AAG/B,oBAAY,OAAO,GAAG;IACpB,SAAS,EAAE,MAAM,CAAA;CAClB,CAAA;AAED,aAAK,KAAK,GAAG,MAAM,GAAG,IAAI,CAAA;AAE1B,eAAO,MAAM,cAAc,EAAE,OAE5B,CAAA;AAQD,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,QAiBvE;AAED,wBAAgB,GAAG,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,WAwB3D;AAED,wBAAgB,KAAK,SAKpB"}

View File

@@ -0,0 +1,59 @@
{
"name": "badgin",
"version": "1.2.3",
"description": "Badgin makes it easy to subtly notify the user that there is some new activity that might require their attention, or it can be used to indicate a small amount of information, such as an unread count.",
"author": "Julian Hundeloh",
"homepage": "https://github.com/jaulz/badgin",
"main": "build/index.js",
"module": "build/index.es.js",
"files": [
"build"
],
"types": "build/index.d.ts",
"license": "MIT",
"scripts": {
"test": "",
"build": "rm -rf ./build/ && rollup -c",
"watch": "concurrently --kill-others \"live-server\" \"rollup -cw\"",
"prepublishOnly": "yarn run ci",
"ci": "yarn build"
},
"repository": {
"type": "git",
"url": "https://github.com/jaulz/badgin"
},
"devDependencies": {
"@types/lodash.merge": "^4.6.6",
"concurrently": "^5.3.0",
"cz-conventional-changelog": "^3.3.0",
"husky": "^4.3.0",
"live-server": "^1.2.1",
"prettier": "^2.1.2",
"pretty-quick": "^3.0.2",
"rollup": "^2.28.2",
"rollup-plugin-commonjs": "^10.0.0",
"rollup-plugin-node-resolve": "^5.0.0",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.27.3",
"semantic-release": "^17.1.2",
"typescript": "^4.0.3"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"prettier": {
"singleQuote": true,
"trailingComma": "es5",
"semi": false
},
"husky": {
"hooks": {
"post-merge": "yarn install",
"post-rewrite": "yarn install",
"pre-commit": "pretty-quick --staged --verbose"
}
},
"dependencies": {}
}