work on weather services; outbound http request caching; styles updates and icons
This commit is contained in:
parent
7c205632cb
commit
85d72b43d2
@ -69,3 +69,6 @@ color_themes:
|
||||
more_contrast:
|
||||
light: Minimal Light
|
||||
dark: Minimal Dark
|
||||
outbound_http:
|
||||
https_only: false
|
||||
services: { }
|
||||
|
@ -13,3 +13,28 @@ session_cookie:
|
||||
logging:
|
||||
level: debug
|
||||
pretty: true
|
||||
services:
|
||||
#
|
||||
# Docs: <https://openweathermap.org/api>
|
||||
# Access: <https://openweathermap.org/price>
|
||||
openweathermap.org:
|
||||
enabled: true
|
||||
latitude: 45.49607
|
||||
longitude: -122.67139
|
||||
api_key: ''
|
||||
|
||||
#
|
||||
# Docs: <https://www.weatherapi.com/docs/>
|
||||
# Access: <https://www.weatherapi.com/pricing.aspx>
|
||||
weatherapi.com:
|
||||
enabled: true
|
||||
api_key: e18fb4e3257d4adaa6911347231607
|
||||
|
||||
# US only; Provides weather forecasts and alerts.
|
||||
# Docs: <https://www.weather.gov/documentation/services-web-api>
|
||||
# Access: Free; Set a User-Agent below to identify yourself (see docs)
|
||||
weather.gov:
|
||||
enabled: true
|
||||
latitude: 45.4961
|
||||
longitude: -122.6714
|
||||
user_agent: (local.jbrumond.me, james@jbrumond.me)
|
||||
|
40
package-lock.json
generated
40
package-lock.json
generated
@ -9,7 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@doc-utils/color-themes": "^0.1.15",
|
||||
"@doc-utils/color-themes": "^0.1.16",
|
||||
"@fastify/compress": "^6.4.0",
|
||||
"@fastify/etag": "^4.2.0",
|
||||
"@fastify/formbody": "^7.4.0",
|
||||
@ -25,6 +25,8 @@
|
||||
"yaml": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonld": "^1.5.9",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/mustache": "^4.2.2",
|
||||
"@types/node": "^20.4.2",
|
||||
"json-schema": "^0.4.0",
|
||||
@ -33,9 +35,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@doc-utils/color-themes": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz",
|
||||
"integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w=="
|
||||
"version": "0.1.16",
|
||||
"resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.16/color-themes-0.1.16.tgz",
|
||||
"integrity": "sha512-6H0clUE0+mRQXbAHLinLSg8YfG3KkZ9zRhbngUtpwPbUHVt9WrnMkmT9rg2VXb+C+35oM6bRPX3QcJxmZESoLw=="
|
||||
},
|
||||
"node_modules/@fastify/accept-negotiator": {
|
||||
"version": "1.1.0",
|
||||
@ -171,6 +173,18 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsonld": {
|
||||
"version": "1.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonld/-/jsonld-1.5.9.tgz",
|
||||
"integrity": "sha512-K76ImkErPYL2wGPZpNFSKp6wE+h/APecZLJrU7UfDaGqt/f+D9Rrg1aR7VdRrQ6k5DUNRZ2vn9yACwmpOr9QcA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
|
||||
"integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/mustache": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz",
|
||||
@ -2347,9 +2361,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@doc-utils/color-themes": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.15/color-themes-0.1.15.tgz",
|
||||
"integrity": "sha512-P0oIlq4Z0cUOf7P4T2OUfhT/Cn/msl3oHTenVWHxKMDPlIYueR6AhFdRCHTI0A+DxyH+0EIi6CnMq3JYnILI0w=="
|
||||
"version": "0.1.16",
|
||||
"resolved": "https://gitea.jbrumond.me/api/packages/doc-utils/npm/%40doc-utils%2Fcolor-themes/-/0.1.16/color-themes-0.1.16.tgz",
|
||||
"integrity": "sha512-6H0clUE0+mRQXbAHLinLSg8YfG3KkZ9zRhbngUtpwPbUHVt9WrnMkmT9rg2VXb+C+35oM6bRPX3QcJxmZESoLw=="
|
||||
},
|
||||
"@fastify/accept-negotiator": {
|
||||
"version": "1.1.0",
|
||||
@ -2469,6 +2483,18 @@
|
||||
"integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
|
||||
"optional": true
|
||||
},
|
||||
"@types/jsonld": {
|
||||
"version": "1.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonld/-/jsonld-1.5.9.tgz",
|
||||
"integrity": "sha512-K76ImkErPYL2wGPZpNFSKp6wE+h/APecZLJrU7UfDaGqt/f+D9Rrg1aR7VdRrQ6k5DUNRZ2vn9yACwmpOr9QcA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/luxon": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
|
||||
"integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/mustache": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.2.tgz",
|
||||
|
@ -16,6 +16,8 @@
|
||||
"author": "James Brumond <https://jbrumond.me>",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/jsonld": "^1.5.9",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/mustache": "^4.2.2",
|
||||
"@types/node": "^20.4.2",
|
||||
"json-schema": "^0.4.0",
|
||||
@ -23,7 +25,7 @@
|
||||
"typescript": "^5.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@doc-utils/color-themes": "^0.1.15",
|
||||
"@doc-utils/color-themes": "^0.1.16",
|
||||
"@fastify/compress": "^6.4.0",
|
||||
"@fastify/etag": "^4.2.0",
|
||||
"@fastify/formbody": "^7.4.0",
|
||||
|
36
src/conf.ts
36
src/conf.ts
@ -2,7 +2,7 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { join as path_join } from 'path';
|
||||
import { parse as parse_yaml } from 'yaml';
|
||||
import { deep_merge } from './utilities/deep-merge';
|
||||
import { deep_merge } from './utilities/deep';
|
||||
import { OIDCConfig, validate_oidc_conf } from './security/openid-connect';
|
||||
import { PKCECookieConfig, validate_pkce_cookie_conf } from './security/pkce-cookie';
|
||||
import { SnowflakeConfig, validate_snowflake_conf } from './utilities/snowflake-uid';
|
||||
@ -12,6 +12,8 @@ import { Argon2HashConfig } from './security/argon-hash';
|
||||
import { SessionCookieConfig, validate_session_cookie_conf } from './security/session';
|
||||
import { HttpConfig } from './http/server';
|
||||
import { ColorThemeConfig } from './utilities/color-themes';
|
||||
import { ServicesConfig, validate_services_conf } from './services';
|
||||
import { OutboundHttpConfig, validate_outbound_http_conf } from './http/outbound';
|
||||
|
||||
const conf_dir = process.env.CONF_PATH;
|
||||
|
||||
@ -34,6 +36,22 @@ export interface Conf {
|
||||
storage: StorageConfig;
|
||||
argon2: Argon2HashConfig;
|
||||
color_themes: ColorThemeConfig;
|
||||
outbound_http: OutboundHttpConfig;
|
||||
services: ServicesConfig;
|
||||
}
|
||||
|
||||
export type SecretValue = string | { from_env: string; };
|
||||
|
||||
export function resolve_secret_value(value: SecretValue) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (! value?.from_env) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return process.env[value.from_env];
|
||||
}
|
||||
|
||||
export async function load_conf() : Promise<unknown> {
|
||||
@ -100,4 +118,20 @@ export function validate_conf(conf: unknown) : asserts conf is Conf {
|
||||
else {
|
||||
throw new Error('`conf.session_cookie` is missing');
|
||||
}
|
||||
|
||||
if ('outbound_http' in conf) {
|
||||
validate_outbound_http_conf(conf.outbound_http);
|
||||
}
|
||||
|
||||
else {
|
||||
throw new Error('`conf.outbound_http` is missing');
|
||||
}
|
||||
|
||||
if ('services' in conf) {
|
||||
validate_services_conf(conf.services);
|
||||
}
|
||||
|
||||
else {
|
||||
throw new Error('`conf.services` is missing');
|
||||
}
|
||||
}
|
||||
|
41
src/http-web/assets.ts
Normal file
41
src/http-web/assets.ts
Normal file
@ -0,0 +1,41 @@
|
||||
|
||||
import { Req } from '../http/request';
|
||||
import { HttpConfig } from '../http/server';
|
||||
import { HttpWebDependencies } from './server';
|
||||
import { csp_headers } from '../http/content-security-policy';
|
||||
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
|
||||
import { load_template } from '../utilities/mustache';
|
||||
|
||||
export function register_asset_endpoints(http_server: FastifyInstance, conf: HttpConfig, { color_themes }: HttpWebDependencies) {
|
||||
const opts: RouteShorthandOptions = {
|
||||
schema: { },
|
||||
};
|
||||
|
||||
endpoint('forms.css', 'text/css; charset=utf-8');
|
||||
endpoint('popup.css', 'text/css; charset=utf-8');
|
||||
endpoint('structure.css', 'text/css; charset=utf-8');
|
||||
endpoint('typography.css', 'text/css; charset=utf-8');
|
||||
endpoint('color-theme-controls.css', 'text/css; charset=utf-8');
|
||||
endpoint('color-theme-controls.js', 'text/javascript');
|
||||
|
||||
endpoint('weather.gov/styles.css', 'text/css; charset=utf-8');
|
||||
endpoint('weatherapi.com/styles.css', 'text/css; charset=utf-8');
|
||||
|
||||
http_server.get('/themes.css', opts, async (req: Req, res) => {
|
||||
res.status(200);
|
||||
res.header('content-type', 'text/css; charset=utf-8');
|
||||
res.header('cache-control', 'public, max-age=3600');
|
||||
csp_headers(res, conf.exposed_url);
|
||||
return color_themes.css;
|
||||
});
|
||||
|
||||
function endpoint(path: string, media_type: string) {
|
||||
http_server.get('/' + path, opts, async (req: Req, res) => {
|
||||
res.status(200);
|
||||
res.header('content-type', media_type);
|
||||
res.header('cache-control', 'public, max-age=3600');
|
||||
csp_headers(res, conf.exposed_url);
|
||||
return load_template(path);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
|
||||
import { Req } from '../../http/request';
|
||||
import { HttpConfig } from '../../http/server';
|
||||
import { HttpWebDependencies } from '../server';
|
||||
import { csp_headers } from '../../http/content-security-policy';
|
||||
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
|
||||
|
||||
export function register_color_themes_endpoint(http_server: FastifyInstance, conf: HttpConfig, { color_themes }: HttpWebDependencies) {
|
||||
const opts: RouteShorthandOptions = {
|
||||
schema: { },
|
||||
};
|
||||
|
||||
http_server.get('/themes.css', opts, async (req: Req, res) => {
|
||||
res.status(200);
|
||||
res.header('content-type', 'text/css; charset=utf-8');
|
||||
res.header('cache-control', 'public, max-age=3600');
|
||||
csp_headers(res, conf.exposed_url);
|
||||
return color_themes.css;
|
||||
});
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
|
||||
import { Req } from '../../http/request';
|
||||
import { HttpConfig } from '../../http/server';
|
||||
import { HttpWebDependencies } from '../server';
|
||||
import { csp_headers } from '../../http/content-security-policy';
|
||||
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
|
||||
import { load_template } from '../../utilities/mustache';
|
||||
|
||||
export function register_typography_endpoint(http_server: FastifyInstance, conf: HttpConfig, { }: HttpWebDependencies) {
|
||||
const opts: RouteShorthandOptions = {
|
||||
schema: { },
|
||||
};
|
||||
|
||||
http_server.get('/typography.css', opts, async (req: Req, res) => {
|
||||
res.status(200);
|
||||
res.header('content-type', 'text/css; charset=utf-8');
|
||||
res.header('cache-control', 'no-store');
|
||||
// res.header('cache-control', 'public, max-age=3600');
|
||||
csp_headers(res, conf.exposed_url);
|
||||
return load_template('typography.css');
|
||||
});
|
||||
}
|
@ -1,17 +1,18 @@
|
||||
|
||||
import * as sch from '../../utilities/json-schema';
|
||||
import { render_login_page } from './login-page';
|
||||
import { send_html_error } from '../../http/send-error';
|
||||
import { redirect_200_refresh } from '../../http/redirects';
|
||||
import { render_template } from '../../utilities/mustache';
|
||||
|
||||
import type { HttpConfig } from '../../http/server';
|
||||
import type { HttpWebDependencies } from '../server';
|
||||
import type { HttpURL, Locale, Timezone } from '../../utilities/types';
|
||||
import type { FastifyInstance, FastifyRequest, RouteShorthandOptions } from 'fastify';
|
||||
import type { UserinfoResponse } from 'openid-client';
|
||||
import type { ErrorCode, ErrorInfo } from '../../http/send-error';
|
||||
|
||||
export function register_login_callback_endpoint(http_server: FastifyInstance, conf: HttpConfig, deps: HttpWebDependencies) {
|
||||
const { logger, pkce_cookie, oidc, storage, snowflake, session, argon2 } = deps;
|
||||
const { logger, pkce_cookie, oidc, storage, snowflake, session } = deps;
|
||||
const opts: RouteShorthandOptions = {
|
||||
schema: {
|
||||
response: {
|
||||
@ -54,13 +55,13 @@ export function register_login_callback_endpoint(http_server: FastifyInstance, c
|
||||
|
||||
if (! pkce_code_verifier) {
|
||||
log.debug('no pkce code verifier provided');
|
||||
return send_html_error(res, 'oidc_no_pkce_code_verifier', render_login_page);
|
||||
return send_html_error(res, 'oidc_no_pkce_code_verifier', render_login_failed_page);
|
||||
}
|
||||
|
||||
const params = oidc.parse_callback_params(req.url);
|
||||
|
||||
if (! params) {
|
||||
return send_html_error(res, 'oidc_callback_params_invalid', render_login_page);
|
||||
return send_html_error(res, 'oidc_callback_params_invalid', render_login_failed_page);
|
||||
}
|
||||
|
||||
log.debug({ callback_params: params }, 'received callback params');
|
||||
@ -68,7 +69,7 @@ export function register_login_callback_endpoint(http_server: FastifyInstance, c
|
||||
const token_set = await oidc.fetch_token_set(`${conf.exposed_url}/login-callback`, params, pkce_code_verifier);
|
||||
|
||||
if (! token_set) {
|
||||
return send_html_error(res, 'oidc_token_fetch_failed', render_login_page);
|
||||
return send_html_error(res, 'oidc_token_fetch_failed', render_login_failed_page);
|
||||
}
|
||||
|
||||
log.debug('fetched token set; requesting user info');
|
||||
@ -76,7 +77,7 @@ export function register_login_callback_endpoint(http_server: FastifyInstance, c
|
||||
const user_info = await oidc.fetch_user_info(token_set);
|
||||
|
||||
if (! user_info) {
|
||||
return send_html_error(res, 'oidc_userinfo_fetch_failed', render_login_page);
|
||||
return send_html_error(res, 'oidc_userinfo_fetch_failed', render_login_failed_page);
|
||||
}
|
||||
|
||||
log.debug({ oidc_subject: user_info.sub }, 'fetched user info; looking up local user data');
|
||||
@ -124,3 +125,19 @@ function get_zoneinfo(user_info: UserinfoResponse) : Timezone {
|
||||
const from_userinfo = user_info.zoneinfo as Timezone;
|
||||
return from_userinfo || 'Africa/Abidjan';
|
||||
}
|
||||
|
||||
function render_login_failed_page(error_code?: ErrorCode, error?: ErrorInfo) {
|
||||
const view = {
|
||||
login_error_code: error_code,
|
||||
login_error: error,
|
||||
page_title: 'Login Failed',
|
||||
};
|
||||
|
||||
const partial_files = {
|
||||
controls: 'controls.html.mustache',
|
||||
color_theme_controls: 'color-theme-controls.html.mustache',
|
||||
page_content: 'login-failed.html.mustache',
|
||||
};
|
||||
|
||||
return render_template('page.html.mustache', view, { }, partial_files);
|
||||
}
|
||||
|
@ -1,43 +0,0 @@
|
||||
|
||||
import { HttpConfig } from '../../http/server';
|
||||
import { HttpWebDependencies } from '../server';
|
||||
import { ErrorCode, ErrorInfo } from '../../http/send-error';
|
||||
import { redirect_303_see_other } from '../../http/redirects';
|
||||
import { FastifyInstance, FastifyReply, RouteShorthandOptions } from 'fastify';
|
||||
import { csp_headers } from '../../http/content-security-policy';
|
||||
import { render_template } from '../../utilities/mustache';
|
||||
|
||||
export function register_login_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { pkce_cookie, session }: HttpWebDependencies) {
|
||||
const opts: RouteShorthandOptions = {
|
||||
schema: { },
|
||||
};
|
||||
|
||||
http_server.get('/login', opts, async (req, res) => {
|
||||
try {
|
||||
await session.check_login(req);
|
||||
return redirect_303_see_other(res, conf.exposed_url, 'Already logged in');
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
return send_login_page(res);
|
||||
}
|
||||
});
|
||||
|
||||
function send_login_page(res: FastifyReply) {
|
||||
res.status(200);
|
||||
res.header('content-type', 'text/html; charset=utf-8');
|
||||
csp_headers(res, conf.exposed_url);
|
||||
session.reset(res);
|
||||
pkce_cookie.reset(res);
|
||||
return render_login_page();
|
||||
}
|
||||
}
|
||||
|
||||
export function render_login_page(error_code?: ErrorCode, error?: ErrorInfo) {
|
||||
const view = {
|
||||
error_code,
|
||||
error,
|
||||
};
|
||||
|
||||
return render_template('login.html.mustache', view);
|
||||
}
|
@ -6,8 +6,9 @@ import { HttpWebDependencies } from './server';
|
||||
import { csp_headers } from '../http/content-security-policy';
|
||||
import { FastifyInstance, RouteShorthandOptions } from 'fastify';
|
||||
import { render_template } from '../utilities/mustache';
|
||||
import type { NamedLocation } from '../storage/named-location';
|
||||
|
||||
export function register_root_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { session, logger }: HttpWebDependencies) {
|
||||
export function register_root_page_endpoint(http_server: FastifyInstance, conf: HttpConfig, { session, services }: HttpWebDependencies) {
|
||||
const opts: RouteShorthandOptions = {
|
||||
schema: { },
|
||||
};
|
||||
@ -21,17 +22,47 @@ export function register_root_page_endpoint(http_server: FastifyInstance, conf:
|
||||
session.reset(res);
|
||||
}
|
||||
|
||||
const location: NamedLocation = {
|
||||
name: 'Home',
|
||||
latitude: 45.4961,
|
||||
longitude: -122.6714,
|
||||
};
|
||||
|
||||
const [
|
||||
{ value: forecast },
|
||||
{ value: alerts },
|
||||
{ value: weather }
|
||||
] = await Promise.all([
|
||||
services.weather_gov.get_forecast_for_location(location, 'us'),
|
||||
services.weather_gov.get_alerts_for_location(location),
|
||||
services.weatherapi_com.get_current_for_location(location),
|
||||
]);
|
||||
|
||||
const rendered_widgets = await Promise.all([
|
||||
services.weatherapi_com.render_current_weather(location, weather),
|
||||
services.weather_gov.render_forecast(location, forecast, alerts),
|
||||
]);
|
||||
|
||||
res.status(200);
|
||||
res.header('cache-control', 'private, no-cache');
|
||||
res.header('content-type', 'text/html; charset=utf-8');
|
||||
csp_headers(res, conf.exposed_url);
|
||||
return render_root_page(req.session?.user);
|
||||
return render_root_page(req.session?.user, rendered_widgets);
|
||||
});
|
||||
}
|
||||
|
||||
function render_root_page(user: UserData) {
|
||||
function render_root_page(user: UserData, rendered_widgets: string[]) {
|
||||
const view = {
|
||||
user,
|
||||
page_title: 'Dashboard',
|
||||
rendered_widgets,
|
||||
};
|
||||
|
||||
return render_template('root.html.mustache', view);
|
||||
const partial_files = {
|
||||
controls: 'controls.html.mustache',
|
||||
color_theme_controls: 'color-theme-controls.html.mustache',
|
||||
page_content: 'root.html.mustache',
|
||||
};
|
||||
|
||||
return render_template('page.html.mustache', view, { }, partial_files);
|
||||
}
|
||||
|
@ -9,14 +9,15 @@ import { BaseHttpDependencies, HttpConfig, create_http_server } from '../http/se
|
||||
import { SnowflakeProvider } from '../utilities/snowflake-uid';
|
||||
|
||||
import { register_csp_report_endpoint } from '../http/content-security-policy';
|
||||
import { register_asset_endpoints } from './assets';
|
||||
import { register_root_page_endpoint } from './root-page';
|
||||
import { register_login_page_endpoint } from './authentication/login-page';
|
||||
import { register_submit_login_endpoint } from './authentication/submit-login';
|
||||
import { register_login_callback_endpoint } from './authentication/login-callback';
|
||||
import { register_logout_endpoint } from './authentication/logout';
|
||||
import { ColorThemeProvider } from '../utilities/color-themes';
|
||||
import { register_color_themes_endpoint } from './assets/color-themes';
|
||||
import { register_typography_endpoint } from './assets/typography';
|
||||
import { OutboundHttpProvider } from '../http/outbound';
|
||||
import { ServicesProvider } from '../services';
|
||||
import { RSSFeedReaderProvider } from '../utilities/rss-feeds';
|
||||
|
||||
export interface HttpWebDependencies extends BaseHttpDependencies {
|
||||
oidc: OIDCProvider;
|
||||
@ -26,25 +27,24 @@ export interface HttpWebDependencies extends BaseHttpDependencies {
|
||||
session: SessionProvider;
|
||||
snowflake: SnowflakeProvider;
|
||||
color_themes: ColorThemeProvider;
|
||||
outbound_http: OutboundHttpProvider;
|
||||
rss_feed_reader: RSSFeedReaderProvider;
|
||||
services: ServicesProvider;
|
||||
}
|
||||
|
||||
export function create_http_web_server(conf: HttpConfig, deps: HttpWebDependencies) {
|
||||
return create_http_server(conf, deps, {
|
||||
endpoints: [
|
||||
register_csp_report_endpoint,
|
||||
|
||||
// Shared Assets
|
||||
register_color_themes_endpoint,
|
||||
register_typography_endpoint,
|
||||
|
||||
// Root page
|
||||
register_root_page_endpoint,
|
||||
register_asset_endpoints,
|
||||
|
||||
// Login/logout
|
||||
register_login_page_endpoint,
|
||||
register_submit_login_endpoint,
|
||||
register_login_callback_endpoint,
|
||||
register_logout_endpoint,
|
||||
|
||||
// Pages
|
||||
register_root_page_endpoint,
|
||||
],
|
||||
content_parsers: {
|
||||
// 'application/ld+json': json_content_parser,
|
||||
|
200
src/http/outbound.ts
Normal file
200
src/http/outbound.ts
Normal file
@ -0,0 +1,200 @@
|
||||
|
||||
import pino from 'pino';
|
||||
import { URL } from 'url';
|
||||
import { request as http_request, OutgoingHttpHeaders, IncomingHttpHeaders } from 'http';
|
||||
import { request as https_request } from 'https';
|
||||
import { create_request_cache_provider } from './request-cache';
|
||||
import { HttpURL } from '../utilities/types';
|
||||
import { deep_copy } from '../utilities/deep';
|
||||
|
||||
let next_id = 1;
|
||||
const timeout = 5000;
|
||||
|
||||
export interface HttpResult {
|
||||
url: URL;
|
||||
// req: ClientRequest;
|
||||
// res: IncomingMessage;
|
||||
status: number;
|
||||
body: string;
|
||||
headers: IncomingHttpHeaders;
|
||||
}
|
||||
|
||||
export interface OutboundHttpConfig {
|
||||
https_only: boolean;
|
||||
}
|
||||
|
||||
export function validate_outbound_http_conf(conf: unknown) : asserts conf is OutboundHttpConfig {
|
||||
// todo: validate config
|
||||
}
|
||||
|
||||
export type OutboundHttpProvider = ReturnType<typeof create_outbound_http_provider>;
|
||||
|
||||
export function create_outbound_http_provider(conf: OutboundHttpConfig, logger: pino.Logger) {
|
||||
const http_log = logger.child({ logger: 'outbound-http' });
|
||||
const response_cache = create_request_cache_provider({ }, http_log);
|
||||
|
||||
return {
|
||||
req: http_req,
|
||||
get(url_str: HttpURL, headers?: OutgoingHttpHeaders, skip_cache = false) {
|
||||
return http_req('GET', url_str, headers, null, skip_cache);
|
||||
},
|
||||
head(url_str: HttpURL, headers?: OutgoingHttpHeaders, skip_cache = false) {
|
||||
return http_req('HEAD', url_str, headers, null, skip_cache);
|
||||
},
|
||||
post(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
|
||||
return http_req('POST', url_str, headers, body, skip_cache);
|
||||
},
|
||||
put(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
|
||||
return http_req('PUT', url_str, headers, body, skip_cache);
|
||||
},
|
||||
patch(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
|
||||
return http_req('PATCH', url_str, headers, body, skip_cache);
|
||||
},
|
||||
delete(url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
|
||||
return http_req('DELETE', url_str, headers, body, skip_cache);
|
||||
},
|
||||
};
|
||||
|
||||
async function http_req(method: string, url_str: HttpURL, headers?: OutgoingHttpHeaders, body?: string, skip_cache = false) {
|
||||
const url = new URL(url_str);
|
||||
const make_request
|
||||
= url.protocol === 'https:' ? https_request
|
||||
: url.protocol === 'http:' ? http_request
|
||||
: null;
|
||||
|
||||
if (! make_request) {
|
||||
throw new Error('illegal protocol');
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && conf.https_only) {
|
||||
throw new Error('non-secure http requests not allowed (by configuration)');
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
throw new Error('urls containing user credentials not allowed');
|
||||
}
|
||||
|
||||
const req_headers = headers ? deep_copy(headers) : { };
|
||||
|
||||
const result: HttpResult = {
|
||||
url,
|
||||
// req: null,
|
||||
// res: null,
|
||||
status: null,
|
||||
body: null,
|
||||
headers: null,
|
||||
};
|
||||
|
||||
const path = url.pathname + (url.search || '');
|
||||
const port = url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80);
|
||||
const log = http_log.child({
|
||||
req_id: next_id++,
|
||||
method: method,
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: port,
|
||||
path: path,
|
||||
});
|
||||
|
||||
const cached = skip_cache
|
||||
? null
|
||||
: response_cache.check_for_cached_response(method, url_str, req_headers);
|
||||
|
||||
if (cached) {
|
||||
const revalidation = response_cache.determine_revalidation_needed(cached);
|
||||
|
||||
if (! revalidation.must_revalidate) {
|
||||
log.info('serving request from cache (no revalidation required)');
|
||||
result.status = cached.status;
|
||||
result.body = cached.body;
|
||||
result.headers = deep_copy(cached.res_headers);
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
|
||||
log.info('cached response found, but requires revalidation');
|
||||
Object.assign(req_headers, revalidation.headers);
|
||||
}
|
||||
|
||||
log.info('starting outbound http request');
|
||||
|
||||
return new Promise<HttpResult>((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const req = make_request({
|
||||
method: method,
|
||||
protocol: url.protocol,
|
||||
hostname: url.hostname,
|
||||
port: port,
|
||||
path: path,
|
||||
headers: req_headers
|
||||
}, (res) => {
|
||||
// result.res = res;
|
||||
result.status = res.statusCode;
|
||||
|
||||
log.info({
|
||||
status: res.statusCode,
|
||||
duration: Date.now() - start,
|
||||
}, `headers received`);
|
||||
|
||||
switch (result.status) {
|
||||
case 304:
|
||||
log.info({ status: cached.status }, 're-using cached response (not modified)');
|
||||
result.status = cached.status;
|
||||
result.body = cached.body;
|
||||
result.headers = deep_copy(cached.res_headers);
|
||||
response_cache.freshen_cache(cached, res.headers);
|
||||
resolve(result);
|
||||
break;
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
if (cached?.cache_info?.cache_control?.stale_if_error) {
|
||||
log.info({ status: cached.status }, 're-using cached response (response returned an error status, and `stale-if-error` directive was provided)');
|
||||
result.status = cached.status;
|
||||
result.body = cached.body;
|
||||
result.headers = deep_copy(cached.res_headers);
|
||||
resolve(result);
|
||||
break;
|
||||
}
|
||||
// nobreak
|
||||
|
||||
default: {
|
||||
const start = Date.now();
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
log.info({ duration: Date.now() - start }, `response finished`);
|
||||
result.body = data;
|
||||
result.headers = res.headers;
|
||||
response_cache.add_response_to_cache(method, url_str, req_headers, result);
|
||||
resolve(result);
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// result.req = req;
|
||||
|
||||
req.setTimeout(timeout, () => {
|
||||
log.error('request timeout');
|
||||
req.destroy();
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
log.error('request error ' + error.stack);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (body) {
|
||||
req.write(body);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
@ -17,8 +17,13 @@ export interface CacheControl {
|
||||
no_store?: boolean;
|
||||
no_cache?: boolean;
|
||||
max_age?: number;
|
||||
s_max_age?: number;
|
||||
must_revalidate?: boolean;
|
||||
proxy_revalidate?: boolean;
|
||||
immutable?: boolean;
|
||||
stale_while_revalidate?: boolean;
|
||||
stale_if_error?: boolean;
|
||||
must_understand?: boolean;
|
||||
}
|
||||
|
||||
export function parse_cache_headers(headers: IncomingHttpHeaders) {
|
||||
@ -62,12 +67,29 @@ export function parse_cache_headers(headers: IncomingHttpHeaders) {
|
||||
case 'proxy-revalidate':
|
||||
result.cache_control.proxy_revalidate = true;
|
||||
break;
|
||||
case 'immutable':
|
||||
result.cache_control.immutable = true;
|
||||
break;
|
||||
case 'stale-while-revalidate':
|
||||
result.cache_control.stale_while_revalidate = true;
|
||||
break;
|
||||
case 'stale-if-error':
|
||||
result.cache_control.stale_if_error = true;
|
||||
break;
|
||||
case 'must-understand':
|
||||
result.cache_control.must_understand = true;
|
||||
break;
|
||||
default:
|
||||
if (directive.startsWith('max-age=')) {
|
||||
result.cache_control.max_age = parseInt(directive.slice(8), 10);
|
||||
break;
|
||||
}
|
||||
|
||||
if (directive.startsWith('s-max-age=')) {
|
||||
result.cache_control.s_max_age = parseInt(directive.slice(10), 10);
|
||||
break;
|
||||
}
|
||||
|
||||
// todo: log something here about unknown directive
|
||||
}
|
||||
}
|
||||
|
228
src/http/request-cache.ts
Normal file
228
src/http/request-cache.ts
Normal file
@ -0,0 +1,228 @@
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { DateTime } from 'luxon';
|
||||
import { deep_copy } from '../utilities/deep';
|
||||
import { OutgoingHttpHeaders, IncomingHttpHeaders } from 'http';
|
||||
import { parse_cache_headers, ParsedCacheHeaders } from './parse-cache-headers';
|
||||
import type { HttpURL } from '../utilities/types';
|
||||
import type { HttpResult } from './outbound';
|
||||
|
||||
export interface RequestCacheConfig {
|
||||
// todo: support for configurable limits:
|
||||
max_records?: number; // max number of cached responses
|
||||
max_length?: number; // max content-length for one response
|
||||
evict_after?: number; // period of time unused after which a response should be evicted
|
||||
}
|
||||
|
||||
export interface CachedResponse {
|
||||
cache_key: string;
|
||||
cached_time: DateTime;
|
||||
is_stale: boolean;
|
||||
url: HttpURL;
|
||||
method: string;
|
||||
req_headers: OutgoingHttpHeaders;
|
||||
status: number;
|
||||
body: string;
|
||||
res_headers: IncomingHttpHeaders;
|
||||
cache_info: ParsedCacheHeaders;
|
||||
expire_timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export function validate_request_cache_conf(conf: unknown) : asserts conf is RequestCacheConfig {
|
||||
// todo: validate config
|
||||
}
|
||||
|
||||
export type RequestCacheProvider = ReturnType<typeof create_request_cache_provider>;
|
||||
|
||||
export function create_request_cache_provider(conf: RequestCacheConfig, logger: pino.Logger) {
|
||||
type URLCache = Record<string, CachedResponse>;
|
||||
const cached_responses: Record<HttpURL, URLCache> = Object.create(null);
|
||||
|
||||
const cacheable_methods = new Set([ 'GET', 'HEAD' ]);
|
||||
const cacheable_status_codes = new Set([
|
||||
200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, 501
|
||||
]);
|
||||
|
||||
return {
|
||||
/**
|
||||
* Should be called before making a new request to check if a cached response already exists
|
||||
*/
|
||||
check_for_cached_response(method: string, url_str: HttpURL, headers?: OutgoingHttpHeaders) : Readonly<CachedResponse> {
|
||||
if (! cached_responses[url_str]) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [cache_key, cached] of Object.entries(cached_responses[url_str])) {
|
||||
if (cache_key === header_cache_key(method, cached.cache_info.vary, headers)) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Should be called when a cached response is found, to determine what if any revalidation is
|
||||
* required before re-using the response
|
||||
*/
|
||||
determine_revalidation_needed(cached: CachedResponse) {
|
||||
const { cache_control, etag, date, last_modified } = cached.cache_info;
|
||||
|
||||
const must_revalidate = Boolean(cache_control?.must_revalidate || cache_control?.no_cache);
|
||||
const headers: OutgoingHttpHeaders = { };
|
||||
|
||||
if (etag) {
|
||||
headers['if-none-match'] = etag;
|
||||
}
|
||||
|
||||
if (last_modified || date) {
|
||||
headers['if-modified-since'] = last_modified?.toHTTP() ?? date?.toHTTP();
|
||||
}
|
||||
|
||||
else {
|
||||
headers['if-modified-since'] = cached.cached_time.toHTTP();
|
||||
}
|
||||
|
||||
return {
|
||||
must_revalidate,
|
||||
headers,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Should be called when receiving a non-`304 Not Modified` response from a request, either
|
||||
* as a novel request, or during a revalidation request
|
||||
*/
|
||||
add_response_to_cache(method: string, url_str: HttpURL, headers: OutgoingHttpHeaders, result: HttpResult) {
|
||||
if (! cacheable_methods.has(method) || ! cacheable_status_codes.has(result.status)) {
|
||||
logger.info('method/status not cacheable');
|
||||
return;
|
||||
}
|
||||
|
||||
const cache_info = parse_cache_headers(result.headers);
|
||||
|
||||
if (cache_info.cache_control?.no_store || cache_info.cache_control?.private) {
|
||||
logger.info(cache_info, 'response contains cache-control directives that prevent caching');
|
||||
return;
|
||||
}
|
||||
|
||||
const cache_key = header_cache_key(method, cache_info.vary, headers);
|
||||
unset_expire_timer(url_str, cache_key);
|
||||
|
||||
const cached: CachedResponse = {
|
||||
cache_key,
|
||||
cached_time: DateTime.now(),
|
||||
is_stale: false,
|
||||
url: url_str,
|
||||
method,
|
||||
req_headers: deep_copy(headers),
|
||||
status: result.status,
|
||||
body: result.body,
|
||||
res_headers: deep_copy(result.headers),
|
||||
cache_info,
|
||||
expire_timer: null,
|
||||
};
|
||||
|
||||
logger.info(cache_info, 'caching response');
|
||||
store_to_cache(cached, cache_info);
|
||||
},
|
||||
|
||||
/**
|
||||
* Should be called when receiving a `304 Not Modified` response during a revalidation request
|
||||
* to update the cache with more recent freshness info
|
||||
*/
|
||||
freshen_cache(cached: CachedResponse, headers: IncomingHttpHeaders) {
|
||||
const cache_info = parse_cache_headers(headers);
|
||||
unset_expire_timer(cached.url, cached.cache_key);
|
||||
store_to_cache(cached, cache_info);
|
||||
},
|
||||
};
|
||||
|
||||
function store_to_cache(cached: CachedResponse, cache_info: ParsedCacheHeaders) {
|
||||
if (! cached_responses[cached.url]) {
|
||||
cached_responses[cached.url] = Object.create(null);
|
||||
}
|
||||
|
||||
cached_responses[cached.url][cached.cache_key] = cached;
|
||||
cached.is_stale = false;
|
||||
|
||||
if (cache_info.cache_control?.max_age) {
|
||||
let ttl_ms = cache_info.cache_control.max_age * 1000;
|
||||
|
||||
if (cache_info.age) {
|
||||
ttl_ms -= cache_info.age * 1000;
|
||||
}
|
||||
|
||||
if (ttl_ms <= 0) {
|
||||
cached.is_stale = true;
|
||||
return;
|
||||
}
|
||||
|
||||
cached.expire_timer = set_expire_timer(cached.url, cached.cache_key, ttl_ms);
|
||||
}
|
||||
|
||||
else if (cache_info.expires) {
|
||||
const ttl_ms = cache_info.expires.toUnixInteger() - Date.now();
|
||||
|
||||
if (ttl_ms <= 0) {
|
||||
cached.is_stale = true;
|
||||
return;
|
||||
}
|
||||
|
||||
cached.expire_timer = set_expire_timer(cached.url, cached.cache_key, ttl_ms);
|
||||
}
|
||||
|
||||
// If no cache-control or expires header is given, default to the
|
||||
// recommended 10% of time since last-modified
|
||||
else if (cache_info.last_modified) {
|
||||
const last_modified_ago_ms = Date.now() - cache_info.last_modified.toUnixInteger();
|
||||
|
||||
if (last_modified_ago_ms <= 0) {
|
||||
cached.is_stale = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const ttl_ms = (last_modified_ago_ms / 10) | 0;
|
||||
cached.expire_timer = set_expire_timer(cached.url, cached.cache_key, ttl_ms);
|
||||
}
|
||||
}
|
||||
|
||||
function unset_expire_timer(url_str: HttpURL, cache_key: string) {
|
||||
const cached = cached_responses[url_str]?.[cache_key];
|
||||
|
||||
if (cached?.expire_timer) {
|
||||
clearTimeout(cached.expire_timer);
|
||||
}
|
||||
}
|
||||
|
||||
function set_expire_timer(url_str: HttpURL, cache_key: string, ttl_ms: number) {
|
||||
const timer = setTimeout(expire, ttl_ms);
|
||||
timer.unref();
|
||||
return timer;
|
||||
|
||||
function expire() {
|
||||
if (cached_responses[url_str]?.[cache_key]) {
|
||||
cached_responses[url_str][cache_key].is_stale = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function header_cache_key(method: string, vary: string[], headers: OutgoingHttpHeaders) {
|
||||
const parts: string[] = [ method ];
|
||||
|
||||
if (vary?.length) {
|
||||
for (const header of vary) {
|
||||
if (Array.isArray(headers[header])) {
|
||||
const values = (headers[header] as string[]).sort();
|
||||
for (const value of values) {
|
||||
parts.push(`${header}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
else if (headers[header]) {
|
||||
parts.push(`${header}: ${headers[header]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\0');
|
||||
}
|
@ -4,6 +4,7 @@ import { BaseClient, CallbackParamsType, Issuer, TokenSet, TypeOfGenericClient }
|
||||
import { PKCECookieConfig } from './pkce-cookie';
|
||||
import { redirect_302_found } from '../http/redirects';
|
||||
import pino from 'pino';
|
||||
import { SecretValue, resolve_secret_value } from '../conf';
|
||||
|
||||
const scopes = 'openid profile email';
|
||||
|
||||
@ -11,7 +12,7 @@ export interface OIDCConfig {
|
||||
server_url: string;
|
||||
signing_algorithm: 'ES512';
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
client_secret: SecretValue;
|
||||
pkce: PKCECookieConfig;
|
||||
}
|
||||
|
||||
@ -33,7 +34,7 @@ export function create_oidc_provider(conf: OIDCConfig, logger: pino.Logger) {
|
||||
Client = issuer.Client;
|
||||
client = new Client({
|
||||
client_id: conf.client_id,
|
||||
client_secret: conf.client_secret,
|
||||
client_secret: resolve_secret_value(conf.client_secret),
|
||||
id_token_signed_response_alg: conf.signing_algorithm,
|
||||
authorization_signed_response_alg: conf.signing_algorithm,
|
||||
});
|
||||
|
@ -1,12 +1,13 @@
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { rand } from '../utilities/rand';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { Req } from '../http/request';
|
||||
import { resolve_secret_value, SecretValue } from '../conf';
|
||||
import { invalidate_cookie, parse_req_cookies, set_cookie } from '../http/cookies';
|
||||
import type { Req } from '../http/request';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import type { Snowflake } from '../utilities/snowflake-uid';
|
||||
import type { Argon2HashProvider } from './argon-hash';
|
||||
import type { SessionData, StorageProvider, UserData } from '../storage';
|
||||
import { Snowflake } from '../utilities/snowflake-uid';
|
||||
|
||||
export interface SessionKey {
|
||||
full_key: string;
|
||||
@ -20,7 +21,7 @@ export interface SessionCookieConfig {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
ttl: number;
|
||||
pepper: string;
|
||||
pepper: SecretValue;
|
||||
}
|
||||
|
||||
export function validate_session_cookie_conf(conf: unknown) : asserts conf is SessionCookieConfig {
|
||||
@ -29,6 +30,7 @@ export function validate_session_cookie_conf(conf: unknown) : asserts conf is Se
|
||||
|
||||
export function create_session_provider(conf: SessionCookieConfig, logger: pino.Logger, argon2: Argon2HashProvider, storage: StorageProvider) {
|
||||
const session_logger = logger.child({ logger: 'session' });
|
||||
const pepper = resolve_secret_value(conf.pepper);
|
||||
const self = {
|
||||
async generate_key() : Promise<SessionKey> {
|
||||
const bytes = await rand(48);
|
||||
@ -40,7 +42,7 @@ export function create_session_provider(conf: SessionCookieConfig, logger: pino.
|
||||
return { prefix, raw_key, full_key };
|
||||
},
|
||||
hash_key(key: SessionKey) : Promise<string> {
|
||||
return argon2.hash(conf.pepper + key.raw_key);
|
||||
return argon2.hash(pepper + key.raw_key);
|
||||
},
|
||||
parse_key(full_key: string) : SessionKey {
|
||||
const [ prefix, raw_key, ...rest ] = full_key.split('.');
|
||||
@ -52,7 +54,7 @@ export function create_session_provider(conf: SessionCookieConfig, logger: pino.
|
||||
return { prefix, raw_key, full_key };
|
||||
},
|
||||
verify_key(key: SessionKey, key_hash: string) : Promise<boolean> {
|
||||
return argon2.verify(key_hash, conf.pepper + key.raw_key);
|
||||
return argon2.verify(key_hash, pepper + key.raw_key);
|
||||
},
|
||||
write_to_cookie(res: FastifyReply, key: SessionKey) {
|
||||
const session_expire = new Date(Date.now() + (conf.ttl * 1000));
|
||||
|
38
src/services/index.ts
Normal file
38
src/services/index.ts
Normal file
@ -0,0 +1,38 @@
|
||||
|
||||
import { pino } from 'pino';
|
||||
import { create_weather_gov_provider, validate_weather_gov_conf, WeatherGovConfig } from './weather.gov';
|
||||
import type { SecretValue } from '../conf';
|
||||
import type { OutboundHttpProvider } from '../http/outbound';
|
||||
import { create_weatherapi_com_provider, validate_weatherapi_com_conf, WeatherAPIComConfig } from './weatherapi.com';
|
||||
|
||||
export interface ServicesConfig {
|
||||
'weather.gov'?: WeatherGovConfig;
|
||||
'weatherapi.com'?: WeatherAPIComConfig;
|
||||
'openweathermap.org'?: {
|
||||
enabled: boolean;
|
||||
api_key: SecretValue;
|
||||
};
|
||||
}
|
||||
|
||||
export type ServicesProvider = ReturnType<typeof create_services_provider>;
|
||||
|
||||
export function validate_services_conf(conf: unknown) : asserts conf is ServicesConfig {
|
||||
validate_weather_gov_conf(conf['weather.gov']);
|
||||
validate_weatherapi_com_conf(conf['weatherapi.com']);
|
||||
}
|
||||
|
||||
export function create_services_provider(conf: ServicesConfig, logger: pino.Logger, outbound_http: OutboundHttpProvider) {
|
||||
const weather_gov = create_weather_gov_provider(conf['weather.gov'], logger, outbound_http);
|
||||
const weatherapi_com = create_weatherapi_com_provider(conf['weatherapi.com'], logger, outbound_http);
|
||||
|
||||
const ready = Promise.all([
|
||||
weather_gov.ready,
|
||||
weatherapi_com.ready,
|
||||
]);
|
||||
|
||||
return {
|
||||
ready,
|
||||
weather_gov,
|
||||
weatherapi_com,
|
||||
};
|
||||
}
|
184
src/services/weather.gov/index.ts
Normal file
184
src/services/weather.gov/index.ts
Normal file
@ -0,0 +1,184 @@
|
||||
|
||||
import pino from 'pino';
|
||||
import { memo } from '../../utilities/memo';
|
||||
import { deep_freeze } from '../../utilities/deep';
|
||||
import { render_forecast } from './render-forecast';
|
||||
import type { NamedLocation } from '../../storage/named-location';
|
||||
import type { OutboundHttpProvider } from '../../http/outbound';
|
||||
import type { WeatherGovPoints, WeatherGovForecast, WeatherGovAlerts } from './interface';
|
||||
import { HttpURL } from '../../utilities/types';
|
||||
|
||||
export interface WeatherGovConfig {
|
||||
enabled: boolean;
|
||||
user_agent: string;
|
||||
}
|
||||
|
||||
export function validate_weather_gov_conf(conf: unknown) : asserts conf is WeatherGovConfig {
|
||||
// todo: validate config
|
||||
}
|
||||
|
||||
export type WeatherGovProvider = ReturnType<typeof create_weather_gov_provider>;
|
||||
|
||||
export function create_weather_gov_provider(conf: WeatherGovConfig, logger: pino.Logger, outbound_http: OutboundHttpProvider) {
|
||||
const log = logger.child({ logger: 'weather.gov' });
|
||||
|
||||
const get_points_ttl = 1000 * 60 * 60 * 24;
|
||||
const get_points = memo(get_points_ttl, get_points_nocache, { });
|
||||
|
||||
const get_forecast_ttl = 1000 * 60 * 30;
|
||||
const get_forecast = memo(get_forecast_ttl, get_forecast_nocache, { });
|
||||
|
||||
const get_alerts_ttl = 1000 * 30;
|
||||
const get_alerts = memo(get_alerts_ttl, get_alerts_nocache, { });
|
||||
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
|
||||
get enabled() {
|
||||
return conf?.enabled;
|
||||
},
|
||||
|
||||
get_points: if_enabled(get_points),
|
||||
get_forecast: if_enabled(get_forecast),
|
||||
get_alerts: if_enabled(get_alerts),
|
||||
|
||||
async get_forecast_for_lat_long(latitude: number, longitude: number, units: 'us' | 'si') {
|
||||
const { value: points } = await get_points(latitude, longitude);
|
||||
return get_forecast(points.gridId, points.gridX, points.gridY, units, false);
|
||||
},
|
||||
|
||||
async get_hourly_forecast_for_lat_long(latitude: number, longitude: number, units: 'us' | 'si') {
|
||||
const { value: points } = await get_points(latitude, longitude);
|
||||
return get_forecast(points.gridId, points.gridX, points.gridY, units, true);
|
||||
},
|
||||
|
||||
async get_forecast_for_location(location: NamedLocation, units: 'us' | 'si') {
|
||||
const { value: points } = await get_points(location.latitude, location.longitude);
|
||||
return get_forecast(points.gridId, points.gridX, points.gridY, units, false);
|
||||
},
|
||||
|
||||
async get_hourly_forecast_for_location(location: NamedLocation, units: 'us' | 'si') {
|
||||
const { value: points } = await get_points(location.latitude, location.longitude);
|
||||
return get_forecast(points.gridId, points.gridX, points.gridY, units, true);
|
||||
},
|
||||
|
||||
async get_alerts_for_location(location: NamedLocation) {
|
||||
return get_alerts(location.latitude, location.longitude);
|
||||
},
|
||||
|
||||
render_forecast,
|
||||
};
|
||||
|
||||
function if_enabled<T>(func: T) : T {
|
||||
if (conf?.enabled) {
|
||||
return func;
|
||||
}
|
||||
|
||||
return (() => {
|
||||
throw new Error('weather.gov service not enabled');
|
||||
}) as T;
|
||||
}
|
||||
|
||||
async function get_points_nocache(latitude: number, longitude: number) : Promise<Cachable<WeatherGovPoints>> {
|
||||
log.info({ latitude, longitude }, 'fetching points data');
|
||||
|
||||
let { status, headers, body } = await outbound_http.get(`https://api.weather.gov/points/${latitude},${longitude}`, {
|
||||
'accept': 'application/ld+json',
|
||||
'user-agent': conf.user_agent,
|
||||
});
|
||||
|
||||
// Follow a single redirect if given
|
||||
if (status === 301) {
|
||||
({ status, headers, body } = await outbound_http.get(headers.location as HttpURL, {
|
||||
'accept': 'application/ld+json',
|
||||
'user-agent': conf.user_agent,
|
||||
}));
|
||||
}
|
||||
|
||||
if (status === 200) {
|
||||
const data = JSON.parse(body);
|
||||
return cacheable(get_points_ttl, data as WeatherGovPoints);
|
||||
}
|
||||
|
||||
const data = headers['content-type'].startsWith('application/problem+json')
|
||||
? JSON.parse(body)
|
||||
: { status };
|
||||
|
||||
log.error(data, 'failed to fetch points data');
|
||||
throw new Error('failed to fetch points data');
|
||||
}
|
||||
|
||||
async function get_forecast_nocache(gridId: string, gridX: number, gridY: number, units: 'us' | 'si', hourly = false) : Promise<Cachable<WeatherGovForecast>> {
|
||||
log.info({ gridId, gridX, gridY }, 'fetching forecast info');
|
||||
|
||||
const url = `https://api.weather.gov/gridpoints/${gridId}/${gridX},${gridY}/forecast${hourly ? '/hourly' : ''}?units=${units}`;
|
||||
let { status, headers, body } = await outbound_http.get(url as HttpURL, {
|
||||
'accept': 'application/ld+json',
|
||||
'user-agent': conf.user_agent,
|
||||
});
|
||||
|
||||
// Follow a single redirect if given
|
||||
if (status === 301) {
|
||||
({ status, headers, body } = await outbound_http.get(headers.location as HttpURL, {
|
||||
'accept': 'application/ld+json',
|
||||
'user-agent': conf.user_agent,
|
||||
}));
|
||||
}
|
||||
|
||||
if (status === 200) {
|
||||
const data = JSON.parse(body);
|
||||
return cacheable(get_forecast_ttl, data as WeatherGovForecast);
|
||||
}
|
||||
|
||||
const data = headers['content-type'].startsWith('application/problem+json')
|
||||
? JSON.parse(body)
|
||||
: { status };
|
||||
|
||||
log.error(data, 'failed to fetch forecast data');
|
||||
throw new Error('failed to fetch forecast data');
|
||||
}
|
||||
|
||||
async function get_alerts_nocache(latitude: number, longitude: number) : Promise<Cachable<WeatherGovAlerts>> {
|
||||
log.info({ latitude, longitude }, 'fetching alerts info');
|
||||
|
||||
let { status, headers, body } = await outbound_http.get(`https://api.weather.gov/alerts?active=1&point=${latitude},${longitude}`, {
|
||||
'accept': 'application/ld+json',
|
||||
'user-agent': conf.user_agent,
|
||||
});
|
||||
|
||||
// Follow a single redirect if given
|
||||
if (status === 301) {
|
||||
({ status, headers, body } = await outbound_http.get(headers.location as HttpURL, {
|
||||
'accept': 'application/ld+json',
|
||||
'user-agent': conf.user_agent,
|
||||
}));
|
||||
}
|
||||
|
||||
if (status === 200) {
|
||||
const data = JSON.parse(body);
|
||||
return cacheable(get_alerts_ttl, data as WeatherGovAlerts);
|
||||
}
|
||||
|
||||
const data = headers['content-type'].startsWith('application/problem+json')
|
||||
? JSON.parse(body)
|
||||
: { status };
|
||||
|
||||
log.error(data, 'failed to fetch alerts data');
|
||||
throw new Error('failed to fetch alerts data');
|
||||
}
|
||||
}
|
||||
|
||||
function cacheable<T>(ttl: number, value: T) : Cachable<T> {
|
||||
const now = new Date();
|
||||
return deep_freeze({
|
||||
fetched: now,
|
||||
expires: new Date(now.getTime() + ttl),
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
interface Cachable<T> {
|
||||
readonly fetched: Date;
|
||||
readonly expires: Date;
|
||||
readonly value: Readonly<T>;
|
||||
}
|
103
src/services/weather.gov/interface.ts
Normal file
103
src/services/weather.gov/interface.ts
Normal file
@ -0,0 +1,103 @@
|
||||
|
||||
import { ContextDefinition } from 'jsonld';
|
||||
import { HttpURL, ISOTimestamp, Timezone, URN } from '../../utilities/types';
|
||||
|
||||
export interface WeatherGovMeasure<Unit extends `wmoUnit:${string}` = `wmoUnit:${string}`> {
|
||||
unitCode: Unit;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface WeatherGovPoints {
|
||||
'@context': ContextDefinition;
|
||||
geometry: string;
|
||||
cwa: string;
|
||||
forecastOffice: HttpURL;
|
||||
gridId: string;
|
||||
gridX: number;
|
||||
gridY: number;
|
||||
forecast: HttpURL;
|
||||
forecastHourly: HttpURL;
|
||||
forecastGridData: HttpURL;
|
||||
forecastStations: HttpURL;
|
||||
relativeLocation: {
|
||||
city: string;
|
||||
state: string;
|
||||
geometry: string;
|
||||
distance: WeatherGovMeasure<'wmoUnit:m'>;
|
||||
bearing: WeatherGovMeasure<'wmoUnit:degree_(angle)'>;
|
||||
};
|
||||
forecastZone: HttpURL;
|
||||
county: HttpURL;
|
||||
fireWeatherZone: HttpURL;
|
||||
timeZone: Timezone;
|
||||
radarStation: string;
|
||||
}
|
||||
|
||||
export interface WeatherGovForecast {
|
||||
'@context': ContextDefinition;
|
||||
geometry: string;
|
||||
updated: ISOTimestamp;
|
||||
units: 'us' | 'si';
|
||||
forecastGenerator: 'BaselineForecastGenerator' | 'HourlyForecastGenerator';
|
||||
generatedAt: ISOTimestamp;
|
||||
updateTime: ISOTimestamp;
|
||||
validTimes: string;
|
||||
elevation: WeatherGovMeasure<'wmoUnit:m'>;
|
||||
periods: WeatherGovForecastPeriod[];
|
||||
}
|
||||
|
||||
export interface WeatherGovForecastPeriod {
|
||||
number: number;
|
||||
name: string;
|
||||
startTime: ISOTimestamp;
|
||||
endTime: ISOTimestamp;
|
||||
isDaytime: boolean;
|
||||
temperature: number;
|
||||
temperatureUnit: 'F' | 'C';
|
||||
temperatureTrend: unknown; // todo: what is this?
|
||||
probabilityOfPrecipitation: WeatherGovMeasure<'wmoUnit:percent'>;
|
||||
dewPoint: WeatherGovMeasure<'wmoUnit:degC'>;
|
||||
relativeHumidity: WeatherGovMeasure<'wmoUnit:percent'>;
|
||||
windSpeed: `${number} mph` | `${number} km/h`;
|
||||
windDirection: 'N' | 'NNW' | 'NW' | 'WNW' | 'W' | 'NNE' | 'NE' | 'ENE' | 'E'
|
||||
| 'S' | 'SSW' | 'SW' | 'WSW' | 'W' | 'SSE' | 'SE' | 'ESE' | 'E';
|
||||
icon: HttpURL;
|
||||
shortForecast: string;
|
||||
detailedForecast: string;
|
||||
}
|
||||
|
||||
export interface WeatherGovAlerts {
|
||||
'@context': ContextDefinition;
|
||||
'@graph': {
|
||||
'@id': HttpURL;
|
||||
'@type': 'wx:Alert',
|
||||
id: URN<`oid:${string}`>;
|
||||
areaDesc: string;
|
||||
geometry: string;
|
||||
geocode: {
|
||||
SAME: `${number}`[];
|
||||
UGC: string[];
|
||||
};
|
||||
affectedZones: HttpURL[];
|
||||
references: unknown[]; // todo: what is this?
|
||||
sent: ISOTimestamp;
|
||||
effective: ISOTimestamp;
|
||||
onset: ISOTimestamp;
|
||||
expires: ISOTimestamp;
|
||||
ends: ISOTimestamp;
|
||||
status: 'Actual' | 'Exercise' | 'System' | 'Test' | 'Draft';
|
||||
messageType: 'Alert' | 'Update' | 'Cancel';
|
||||
category: string;
|
||||
severity: 'Extreme' | 'Severe' | 'Moderate' | 'Minor' | 'Unknown';
|
||||
certainty: 'Observed' | 'Likely' | 'Possible' | 'Unlikely' | 'Unknown';
|
||||
urgency: 'Immediate' | 'Expected' | 'Future' | 'Past' | 'Unknown';
|
||||
event: string;
|
||||
sender: string;
|
||||
senderName: string;
|
||||
headline: string;
|
||||
description: string;
|
||||
instruction: string;
|
||||
response: string;
|
||||
parameters: Record<string, string[]>;
|
||||
}[];
|
||||
}
|
44
src/services/weather.gov/render-forecast.ts
Normal file
44
src/services/weather.gov/render-forecast.ts
Normal file
@ -0,0 +1,44 @@
|
||||
|
||||
import { icons } from '../../utilities/icons';
|
||||
import { render_template } from '../../utilities/mustache';
|
||||
import type { NamedLocation } from '../../storage/named-location';
|
||||
import type { WeatherGovAlerts, WeatherGovForecast, WeatherGovForecastPeriod } from './interface';
|
||||
|
||||
export function render_forecast(location: NamedLocation, forecast: WeatherGovForecast, alerts: WeatherGovAlerts) {
|
||||
let forecast_today: WeatherGovForecastPeriod[];
|
||||
let forecast_future: WeatherGovForecastPeriod[];
|
||||
const forecast_days: WeatherGovForecastPeriod[] = [ ];
|
||||
const forecast_nights: WeatherGovForecastPeriod[] = [ ];
|
||||
|
||||
if (forecast.periods[0].isDaytime) {
|
||||
forecast_today = forecast.periods.slice(0, 2);
|
||||
forecast_future = forecast.periods.slice(2);
|
||||
}
|
||||
|
||||
else {
|
||||
forecast_today = forecast.periods.slice(0, 1);
|
||||
forecast_future = forecast.periods.slice(1);
|
||||
}
|
||||
|
||||
for (const period of forecast_future) {
|
||||
if (period.isDaytime) {
|
||||
forecast_days.push(period);
|
||||
}
|
||||
|
||||
else {
|
||||
forecast_nights.push(period);
|
||||
}
|
||||
}
|
||||
|
||||
const view = {
|
||||
location,
|
||||
forecast_today,
|
||||
forecast_future,
|
||||
forecast_days,
|
||||
forecast_nights,
|
||||
icons,
|
||||
alerts: alerts['@graph'],
|
||||
};
|
||||
|
||||
return render_template('weather.gov/forecast.html.mustache', view);
|
||||
}
|
96
src/services/weatherapi.com/index.ts
Normal file
96
src/services/weatherapi.com/index.ts
Normal file
@ -0,0 +1,96 @@
|
||||
|
||||
import pino from 'pino';
|
||||
import { memo } from '../../utilities/memo';
|
||||
import { deep_freeze } from '../../utilities/deep';
|
||||
// import { render_forecast } from './render-forecast';
|
||||
import { render_current_weather } from './render-current-weather';
|
||||
import { SecretValue, resolve_secret_value } from '../../conf';
|
||||
import type { OutboundHttpProvider } from '../../http/outbound';
|
||||
import type { WeatherAPIComCurrentWeather, WeatherAPIComLocation } from './interface';
|
||||
import type { NamedLocation } from '../../storage/named-location';
|
||||
|
||||
export interface WeatherAPIComConfig {
|
||||
enabled: boolean;
|
||||
api_key: SecretValue;
|
||||
}
|
||||
|
||||
export function validate_weatherapi_com_conf(conf: unknown) : asserts conf is WeatherAPIComConfig {
|
||||
// todo: validate config
|
||||
}
|
||||
|
||||
export type WeatherAPIComProvider = ReturnType<typeof create_weatherapi_com_provider>;
|
||||
|
||||
export function create_weatherapi_com_provider(conf: WeatherAPIComConfig, logger: pino.Logger, outbound_http: OutboundHttpProvider) {
|
||||
const log = logger.child({ logger: 'weatherapi.com' });
|
||||
const api_key = resolve_secret_value(conf.api_key);
|
||||
|
||||
const get_current_ttl = 1000;
|
||||
// const get_current_ttl = 1000 * 60 * 3;
|
||||
const get_current = memo(get_current_ttl, get_current_nocache, { });
|
||||
|
||||
// const get_forecast_ttl = 1000 * 60 * 30;
|
||||
// const get_forecast = memo(get_forecast_ttl, get_forecast_nocache, { });
|
||||
|
||||
// const get_alerts_ttl = 1000 * 30;
|
||||
// const get_alerts = memo(get_alerts_ttl, get_alerts_nocache, { });
|
||||
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
|
||||
get enabled() {
|
||||
return conf?.enabled;
|
||||
},
|
||||
|
||||
get_current: if_enabled(get_current),
|
||||
get_current_for_location(location: NamedLocation) {
|
||||
return get_current(`${location.latitude},${location.longitude}`);
|
||||
},
|
||||
|
||||
render_current_weather,
|
||||
};
|
||||
|
||||
function if_enabled<T>(func: T) : T {
|
||||
if (conf?.enabled) {
|
||||
return func;
|
||||
}
|
||||
|
||||
return (() => {
|
||||
throw new Error('weatherapi.com service not enabled');
|
||||
}) as T;
|
||||
}
|
||||
|
||||
async function get_current_nocache(query: string) : Promise<Cachable<WeatherAPIComCurrentWeather>> {
|
||||
log.info({ query }, 'fetching current weather');
|
||||
|
||||
let { status, headers, body } = await outbound_http.get(`https://api.weatherapi.com/v1/current.json?key=${api_key}&q=${query}`, {
|
||||
'accept': 'application/json',
|
||||
});
|
||||
|
||||
if (status === 200) {
|
||||
const data = JSON.parse(body);
|
||||
return cacheable(get_current_ttl, data as WeatherAPIComCurrentWeather);
|
||||
}
|
||||
|
||||
const data = headers['content-type'].startsWith('application/json')
|
||||
? JSON.parse(body)
|
||||
: { status };
|
||||
|
||||
log.error(data, 'failed to fetch current weather');
|
||||
throw new Error('failed to fetch current weather');
|
||||
}
|
||||
}
|
||||
|
||||
function cacheable<T>(ttl: number, value: T) : Cachable<T> {
|
||||
const now = new Date();
|
||||
return deep_freeze({
|
||||
fetched: now,
|
||||
expires: new Date(now.getTime() + ttl),
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
interface Cachable<T> {
|
||||
readonly fetched: Date;
|
||||
readonly expires: Date;
|
||||
readonly value: Readonly<T>;
|
||||
}
|
108
src/services/weatherapi.com/interface.ts
Normal file
108
src/services/weatherapi.com/interface.ts
Normal file
@ -0,0 +1,108 @@
|
||||
|
||||
import { HttpURLSchemeless, ISODate, LocalDateTime, Timezone } from '../../utilities/types';
|
||||
|
||||
export interface WeatherAPIComLocation {
|
||||
name: string;
|
||||
region: string;
|
||||
country: string
|
||||
lat: number;
|
||||
lon: number;
|
||||
tz_id: Timezone;
|
||||
localtime_epoch: number;
|
||||
localtime: LocalDateTime;
|
||||
}
|
||||
|
||||
export interface WeatherAPIComCurrentWeather {
|
||||
location: WeatherAPIComLocation;
|
||||
current: WeatherAPIComWeather & {
|
||||
last_updated_epoch: number;
|
||||
last_updated: LocalDateTime;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WeatherAPIComForecast extends WeatherAPIComCurrentWeather {
|
||||
forecast: {
|
||||
forecastday: {
|
||||
date: ISODate;
|
||||
date_epoch: number;
|
||||
day: {
|
||||
maxtemp_c: number;
|
||||
maxtemp_f: number;
|
||||
mintemp_c: number;
|
||||
mintemp_f: number;
|
||||
avgtemp_c: number;
|
||||
avgtemp_f: number;
|
||||
maxwind_mph: number;
|
||||
maxwind_kph: number;
|
||||
totalprecip_mm: number;
|
||||
totalprecip_in: number;
|
||||
totalsnow_cm: number;
|
||||
avgvis_km: number;
|
||||
avgvis_miles: number;
|
||||
avghumidity: number;
|
||||
daily_will_it_rain: number;
|
||||
daily_chance_of_rain: number;
|
||||
daily_will_it_snow: number;
|
||||
daily_chance_of_snow: number;
|
||||
condition: WeatherAPIComCondition;
|
||||
uv: number;
|
||||
};
|
||||
astro: {
|
||||
sunrise: string;
|
||||
sunset: string;
|
||||
moonrise: string;
|
||||
moonset: string;
|
||||
moon_phase: string;
|
||||
moon_illumination: `${bigint}`;
|
||||
is_moon_up: 0 | 1;
|
||||
is_sun_up: 0 | 1;
|
||||
};
|
||||
hour: WeatherAPIComHourForecast[];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface WeatherAPIComCondition {
|
||||
text: string;
|
||||
icon: HttpURLSchemeless;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface WeatherAPIComWeather {
|
||||
temp_c: number;
|
||||
temp_f: number;
|
||||
is_day: number;
|
||||
condition: WeatherAPIComCondition;
|
||||
wind_mph: number;
|
||||
wind_kph: number;
|
||||
wind_degree: number;
|
||||
wind_dir: string;
|
||||
pressure_mb: number;
|
||||
pressure_in: number;
|
||||
precip_mm: number;
|
||||
precip_in: number;
|
||||
humidity: number;
|
||||
cloud: number;
|
||||
feelslike_c: number;
|
||||
feelslike_f: number;
|
||||
vis_km: number;
|
||||
vis_miles: number;
|
||||
gust_mph: number;
|
||||
gust_kph: number;
|
||||
uv: number;
|
||||
}
|
||||
|
||||
export interface WeatherAPIComHourForecast extends WeatherAPIComWeather {
|
||||
time_epoch: number;
|
||||
time: LocalDateTime;
|
||||
windchill_c: number;
|
||||
windchill_f: number;
|
||||
heatindex_c: number;
|
||||
heatindex_f: number;
|
||||
dewpoint_c: number;
|
||||
dewpoint_f: number;
|
||||
will_it_rain: number;
|
||||
chance_of_rain: number;
|
||||
will_it_snow: number;
|
||||
chance_of_snow: number;
|
||||
}
|
15
src/services/weatherapi.com/render-current-weather.ts
Normal file
15
src/services/weatherapi.com/render-current-weather.ts
Normal file
@ -0,0 +1,15 @@
|
||||
|
||||
import { icons } from '../../utilities/icons';
|
||||
import { render_template } from '../../utilities/mustache';
|
||||
import type { NamedLocation } from '../../storage/named-location';
|
||||
import type { WeatherAPIComCurrentWeather } from './interface';
|
||||
|
||||
export function render_current_weather(location: NamedLocation, weather: WeatherAPIComCurrentWeather) {
|
||||
const view = {
|
||||
location,
|
||||
weather,
|
||||
icons,
|
||||
};
|
||||
|
||||
return render_template('weatherapi.com/current.html.mustache', view);
|
||||
}
|
10
src/start.ts
10
src/start.ts
@ -12,6 +12,9 @@ import { create_session_provider } from './security/session';
|
||||
import { create_http_metadata_server } from './http-metadata/server';
|
||||
import { HttpWebDependencies, create_http_web_server } from './http-web/server';
|
||||
import { create_color_theme_provider } from './utilities/color-themes';
|
||||
import { create_outbound_http_provider } from './http/outbound';
|
||||
import { create_services_provider } from './services';
|
||||
import { create_rss_feed_reader_provider } from './utilities/rss-feeds';
|
||||
|
||||
main();
|
||||
|
||||
@ -39,11 +42,15 @@ async function main() {
|
||||
const argon2 = create_argon_hash_provider(conf.argon2);
|
||||
const session = create_session_provider(conf.session_cookie, logger, argon2, storage);
|
||||
const color_themes = create_color_theme_provider(conf.color_themes);
|
||||
const outbound_http = create_outbound_http_provider(conf.outbound_http, logger);
|
||||
const rss_feed_reader = create_rss_feed_reader_provider(logger, outbound_http);
|
||||
const services = create_services_provider(conf.services, logger, outbound_http);
|
||||
|
||||
// Wait for any async init steps
|
||||
await oidc.ready;
|
||||
await storage.ready;
|
||||
await color_themes.ready;
|
||||
await services.ready;
|
||||
|
||||
// Perform any cleanup steps before starting up
|
||||
await storage.cleanup_old_sessions();
|
||||
@ -57,6 +64,9 @@ async function main() {
|
||||
argon2,
|
||||
session,
|
||||
color_themes,
|
||||
outbound_http,
|
||||
rss_feed_reader,
|
||||
services,
|
||||
};
|
||||
|
||||
// Create the main web server
|
||||
|
6
src/storage/named-location.ts
Normal file
6
src/storage/named-location.ts
Normal file
@ -0,0 +1,6 @@
|
||||
|
||||
export interface NamedLocation {
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
@ -23,6 +23,9 @@ export function create_color_theme_provider(conf: ColorThemeConfig) {
|
||||
get ready() {
|
||||
return ready;
|
||||
},
|
||||
get themes() {
|
||||
return structuredClone(conf);
|
||||
},
|
||||
get css() {
|
||||
return css;
|
||||
},
|
||||
|
@ -1,13 +0,0 @@
|
||||
|
||||
export function deep_merge<T>(host: T, donor: T) : T {
|
||||
for (const [key, value] of Object.entries(donor)) {
|
||||
if (value != null && host[key] != null && typeof value === 'object' && typeof host[key] === 'object') {
|
||||
deep_merge(host[key], value);
|
||||
continue;
|
||||
}
|
||||
|
||||
host[key] = value;
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
41
src/utilities/deep.ts
Normal file
41
src/utilities/deep.ts
Normal file
@ -0,0 +1,41 @@
|
||||
|
||||
export function deep_merge<T>(host: T, donor: T) : T {
|
||||
for (const [key, value] of Object.entries(donor)) {
|
||||
if (value != null && host[key] != null && typeof value === 'object' && typeof host[key] === 'object') {
|
||||
deep_merge(host[key], value);
|
||||
continue;
|
||||
}
|
||||
|
||||
host[key] = value;
|
||||
}
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
export function deep_freeze<T>(obj: T) : Readonly<T> {
|
||||
for (const value of Object.values(obj)) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
else if (typeof value === 'object' || typeof value === 'function') {
|
||||
deep_freeze(value);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze(obj);
|
||||
}
|
||||
|
||||
export function deep_copy<T>(obj: T) : T {
|
||||
return structuredClone(obj);
|
||||
|
||||
// try {
|
||||
// return structuredClone(obj);
|
||||
// }
|
||||
|
||||
// catch (error) {
|
||||
// if (error instanceof ReferenceError) {
|
||||
// return JSON.parse(JSON.stringify(obj));
|
||||
// }
|
||||
// }
|
||||
}
|
22
src/utilities/icons.ts
Normal file
22
src/utilities/icons.ts
Normal file
@ -0,0 +1,22 @@
|
||||
|
||||
export const icons: Record<string, string> = Object.create(null);
|
||||
|
||||
const whitespace = /[\s\t\n]+/g;
|
||||
const feather_icons: Record<string, string> = require('../../vendor/feather-icons/icons.json');
|
||||
|
||||
for (const [name, contents] of Object.entries(feather_icons)) {
|
||||
icons[name] = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon ${name}"
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentcolor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>${contents}</svg>
|
||||
`.replace(whitespace, ' ').trim();
|
||||
}
|
||||
|
||||
Object.freeze(icons);
|
@ -51,7 +51,8 @@ export function memo<T extends Func>(ttl: number, func: T, opts: MemoParams<T> =
|
||||
}
|
||||
|
||||
function set_expire(key: string, args: Params<T>, stored: number) {
|
||||
setTimeout(opts.validator ? revalidate(key, args, stored) : invalidate(key), ttl);
|
||||
const timer = setTimeout(opts.validator ? revalidate(key, args, stored) : invalidate(key), ttl);
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
function revalidate(key: string, args: Params<T>, stored: number) {
|
||||
|
50
src/utilities/rss-feeds.ts
Normal file
50
src/utilities/rss-feeds.ts
Normal file
@ -0,0 +1,50 @@
|
||||
|
||||
import pino from 'pino';
|
||||
import type { HttpURL } from './types';
|
||||
import type { OutboundHttpProvider } from '../http/outbound';
|
||||
import { XMLParser, XMLValidator, X2jOptions } from 'fast-xml-parser';
|
||||
|
||||
export type RSSFeedReaderProvider = ReturnType<typeof create_rss_feed_reader_provider>;
|
||||
|
||||
export function create_rss_feed_reader_provider(logger: pino.Logger, outbound_http: OutboundHttpProvider) {
|
||||
const log = logger.child({ logger: 'rss-feed-reader' });
|
||||
|
||||
return {
|
||||
async read_feed(feed_url: HttpURL, skip_cache = false) {
|
||||
const req_headers = {
|
||||
accept: 'application/rss+xml; q=1.0, text/xml; q=0.9',
|
||||
};
|
||||
|
||||
const { status, headers, body } = await outbound_http.get(feed_url, req_headers, skip_cache);
|
||||
|
||||
if (status === 200) {
|
||||
const content_type = headers['content-type'] || '';
|
||||
|
||||
if (content_type.startsWith('text/xml') || content_type.startsWith('application/rss+xml')) {
|
||||
const opts: Partial<X2jOptions> = { };
|
||||
const result = XMLValidator.validate(body, opts);
|
||||
|
||||
if (typeof result === 'object' && result.err) {
|
||||
log.error(result.err, 'RSS feed XML validation error');
|
||||
throw_error('received invalid XML response from server');
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser(opts);
|
||||
return xmlParser.parse(body);
|
||||
}
|
||||
|
||||
else {
|
||||
throw_error('received invalid content type from server');
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
throw_error('received a non-200 response code');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function throw_error(message: string) : never {
|
||||
throw new Error(`failed to fetch RSS feed content; ${message}`);
|
||||
}
|
||||
}
|
@ -17,8 +17,16 @@ export type ISOZoneOffset = 'Z' | `${'+' | '-' | ''}${number}:${number}`;
|
||||
|
||||
export type ISOTimestamp = `${ISODate}T${ISOTime}${ISOZoneOffset}`;
|
||||
|
||||
export type TimeShort = `${number}:${number}`;
|
||||
|
||||
export type LocalDateTime = `${ISODate} ${TimeShort}`;
|
||||
|
||||
export type Timezone = `${string}/${string}`;
|
||||
|
||||
export type Locale = `${string}-${string}`;
|
||||
|
||||
export type HttpURL = `http${'s' | ''}://${string}`;
|
||||
|
||||
export type HttpURLSchemeless = `//${string}`;
|
||||
|
||||
export type URN<T extends string = string> = `urn:${T}`;
|
||||
|
60
templates/color-theme-controls.css
Normal file
60
templates/color-theme-controls.css
Normal file
@ -0,0 +1,60 @@
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
fieldset.radio {
|
||||
width: 30rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
column-gap: 2%;
|
||||
border-color: var(--theme-border-input);
|
||||
}
|
||||
|
||||
fieldset.radio.theme {
|
||||
margin-block: 1rem 2rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
fieldset.radio {
|
||||
width: calc(100vw - 5rem);
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
width: 32%;
|
||||
height: 2rem;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
input {
|
||||
appearance: none;
|
||||
display: block;
|
||||
background: transparent;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
input:not(:checked) {
|
||||
cursor: pointer;
|
||||
background: var(--theme-bg-button-primary);
|
||||
}
|
||||
|
||||
input:checked {
|
||||
background: var(--theme-bg-button-primary-hover);
|
||||
}
|
||||
|
||||
span {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
display: block;
|
||||
height: 100%;
|
||||
line-height: 2rem;
|
||||
}
|
7
templates/color-theme-controls.html.mustache
Normal file
7
templates/color-theme-controls.html.mustache
Normal file
@ -0,0 +1,7 @@
|
||||
<a class="popup-open color-theme-contrast" href="#popup-color-theme-contrast">Colors and Contrast</a>
|
||||
<div class="popup" id="popup-color-theme-contrast" role="dialog">
|
||||
<div class="popup-content">
|
||||
<a class="popup-close" href="#">Close</a>
|
||||
<color-theme-and-contrast-controls></color-theme-and-contrast-controls>
|
||||
</div>
|
||||
</div>
|
130
templates/color-theme-controls.js
Normal file
130
templates/color-theme-controls.js
Normal file
@ -0,0 +1,130 @@
|
||||
(() => {
|
||||
|
||||
const color_theme = 'color_theme';
|
||||
const color_contrast = 'color_contrast';
|
||||
const color_theme_attr = 'data-color-theme';
|
||||
const color_contrast_attr = 'data-color-contrast';
|
||||
|
||||
const override_theme = localStorage.getItem(color_theme);
|
||||
const override_contrast = localStorage.getItem(color_contrast);
|
||||
|
||||
if (override_theme) {
|
||||
document.body.setAttribute(color_theme_attr, override_theme);
|
||||
}
|
||||
|
||||
if (override_contrast) {
|
||||
document.body.setAttribute(color_contrast_attr, override_contrast);
|
||||
}
|
||||
|
||||
const template = `
|
||||
<link rel="stylesheet" href="/typography.css">
|
||||
<link rel="stylesheet" href="/color-theme-controls.css">
|
||||
<div class="popup" title="Color Theme and Contrast Controls">
|
||||
<fieldset class="theme radio">
|
||||
<legend>Color Theme</legend>
|
||||
<label>
|
||||
<input type="radio" name="color_theme" value="auto" ${checked_if(! override_theme)}>
|
||||
<span>Auto</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="color_theme" value="light" ${checked_if(override_theme === 'light')}>
|
||||
<span>Light</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="color_theme" value="dark" ${checked_if(override_theme === 'dark')}>
|
||||
<span>Dark</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset class="contrast radio">
|
||||
<legend>Color Contrast</legend>
|
||||
<label>
|
||||
<input type="radio" name="color_contrast" value="less" ${checked_if(override_contrast === 'less')}>
|
||||
<span>Less Contrast</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="color_contrast" value="default" ${checked_if(! override_contrast)}>
|
||||
<span>Default</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="color_contrast" value="more" ${checked_if(override_contrast === 'more')}>
|
||||
<span>More Contrast</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
`;
|
||||
|
||||
customElements.define('color-theme-and-contrast-controls',
|
||||
class ColorThemeToggleButton extends HTMLElement {
|
||||
#popup = null;
|
||||
#theme_inputs = null;
|
||||
#contrast_inputs = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.shadowRoot.innerHTML = template;
|
||||
this.#popup = this.shadowRoot.querySelector('.popup');
|
||||
this.#theme_inputs = this.#popup.querySelectorAll('.theme input');
|
||||
this.#contrast_inputs = this.#popup.querySelectorAll('.contrast input');
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#popup.addEventListener('change', this.#onChange);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#popup.removeEventListener('change', this.#onChange);
|
||||
}
|
||||
|
||||
#onChange = () => {
|
||||
const theme = this.#theme_input_value;
|
||||
const contrast = this.#contrast_input_value;
|
||||
|
||||
if (theme === 'auto') {
|
||||
localStorage.removeItem(color_theme);
|
||||
document.body.removeAttribute(color_theme_attr);
|
||||
}
|
||||
|
||||
else {
|
||||
localStorage.setItem(color_theme, theme);
|
||||
document.body.setAttribute(color_theme_attr, theme);
|
||||
}
|
||||
|
||||
if (contrast === 'default') {
|
||||
localStorage.removeItem(color_contrast);
|
||||
document.body.removeAttribute(color_contrast_attr);
|
||||
}
|
||||
|
||||
else {
|
||||
localStorage.setItem(color_contrast, contrast);
|
||||
document.body.setAttribute(color_contrast_attr, theme);
|
||||
}
|
||||
};
|
||||
|
||||
get #theme_input_value() {
|
||||
return radio_value(this.#theme_inputs, 'auto');
|
||||
}
|
||||
|
||||
get #contrast_input_value() {
|
||||
return radio_value(this.#contrast_inputs, 'auto');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function checked_if(condition) {
|
||||
return condition ? 'checked' : '';
|
||||
}
|
||||
|
||||
function radio_value(inputs, default_value) {
|
||||
inputs = [ ...inputs ];
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input.checked) {
|
||||
return input.value;
|
||||
}
|
||||
}
|
||||
|
||||
return default_value;
|
||||
}
|
||||
|
||||
})();
|
22
templates/controls.html.mustache
Normal file
22
templates/controls.html.mustache
Normal file
@ -0,0 +1,22 @@
|
||||
<aside class="controls">
|
||||
<div>
|
||||
{{# user }}
|
||||
<p>Logged in as
|
||||
{{# user.name }}{{ user.name }}{{/ user.name }}
|
||||
{{^ user.name }}{{ user.username }}{{/ user.name }}
|
||||
-
|
||||
</p>
|
||||
<form action="/logout" method="POST">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
{{/ user }}
|
||||
|
||||
{{^ user }}
|
||||
<form action="/login" method="POST">
|
||||
<button type="submit">Login with OpenID Connect</button>
|
||||
</form>
|
||||
{{/ user }}
|
||||
</div>
|
||||
|
||||
{{> color_theme_controls }}
|
||||
</aside>
|
143
templates/forms.css
Normal file
143
templates/forms.css
Normal file
@ -0,0 +1,143 @@
|
||||
|
||||
fieldset {
|
||||
border-color: var(--theme-border-input);
|
||||
}
|
||||
|
||||
button, input:is([type='button'], [type='submit'], [type='reset']) {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
label {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
label:not(:first-of-type) {
|
||||
margin-block-start: 2rem;
|
||||
}
|
||||
|
||||
label.radio {
|
||||
display: flex;
|
||||
margin-block: 1rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
display: block;
|
||||
margin-block-start: 0.35rem;
|
||||
margin-block-end: 1rem;
|
||||
border: 0.125rem var(--theme-border-input) solid;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--theme-bg-input);
|
||||
padding: 0.5rem;
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
input[disabled],
|
||||
textarea[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input[type='text']:invalid,
|
||||
input[type='password']:invalid,
|
||||
textarea:invalid {
|
||||
border-color: var(--theme-border-input-invalid);
|
||||
}
|
||||
|
||||
input[type='text'][readonly] {
|
||||
color: var(--theme-text-light);
|
||||
border-color: var(--theme-line);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
input[type='checkbox'] + label {
|
||||
margin-inline-start: 0.5rem;
|
||||
margin-inline-end: 2rem;
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
flex: 0 0 2rem;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
line-height: 1.7rem;
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
::-webkit-input-placeholder {
|
||||
color: var(--theme-text-light);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
margin-block-start: 1rem;
|
||||
margin-inline-end: 1rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
appearance: none;
|
||||
color: var(--theme-text-button-primary);
|
||||
background: var(--theme-bg-button-primary);
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
line-height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
select {
|
||||
display: block;
|
||||
margin-block-start: 0.35rem;
|
||||
margin-block-end: 1rem;
|
||||
border: 0.125rem var(--theme-border-input) solid;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--theme-bg-input);
|
||||
padding: 0.5rem;
|
||||
width: 20rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select[disabled] {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
select option {
|
||||
background-color: var(--theme-bg-input);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
:is(table, .table) :is(input, select) {
|
||||
margin-block: 0;
|
||||
}
|
||||
*/
|
3
templates/local-clock/local-clock.html.mustache
Normal file
3
templates/local-clock/local-clock.html.mustache
Normal file
@ -0,0 +1,3 @@
|
||||
<section data-widget="local-clock" title="Local-time clock">
|
||||
{{! }}
|
||||
</section>
|
5
templates/login-failed.html.mustache
Normal file
5
templates/login-failed.html.mustache
Normal file
@ -0,0 +1,5 @@
|
||||
<aside class="error">
|
||||
<h2>Login Failed</h2>
|
||||
<p><b>Error Code:</b> {{ login_error_code }}</p>
|
||||
<p><b>Error Message:</b> {{ login_error.message }}</p>
|
||||
</aside>
|
@ -1,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Login</title>
|
||||
<link rel="stylesheet" type="text/css" href="/themes.css">
|
||||
<link rel="stylesheet" type="text/css" href="/typography.css">
|
||||
</head>
|
||||
<body>
|
||||
<form action="/login" method="POST">
|
||||
<button type="submit">Login with OpenID Connect</button>
|
||||
</form>
|
||||
|
||||
{{# error_code }}
|
||||
<div>
|
||||
<h4>Login failed</h4>
|
||||
<b>Error Code:</b> {{ error_code }}<br />
|
||||
<b>Error Message:</b> {{ error.message }}
|
||||
</div>
|
||||
{{/ error_code }}
|
||||
</body>
|
||||
</html>
|
16
templates/page.html.mustache
Normal file
16
templates/page.html.mustache
Normal file
@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ page_title }}</title>
|
||||
<link rel="stylesheet" href="/themes.css">
|
||||
<link rel="stylesheet" href="/typography.css">
|
||||
<link rel="stylesheet" href="/forms.css">
|
||||
<link rel="stylesheet" href="/popup.css">
|
||||
<link rel="stylesheet" href="/structure.css">
|
||||
<script src="/color-theme-controls.js" async></script>
|
||||
</head>
|
||||
<body>
|
||||
{{> controls }}
|
||||
{{> page_content }}
|
||||
</body>
|
||||
</html>
|
37
templates/popup.css
Normal file
37
templates/popup.css
Normal file
@ -0,0 +1,37 @@
|
||||
|
||||
.popup {
|
||||
z-index: 1;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
transition: opacity 0.125s linear;
|
||||
background: var(--theme-bg-popup-mask);
|
||||
}
|
||||
|
||||
.popup:target {
|
||||
opacity: 1;
|
||||
user-select: unset;
|
||||
pointer-events: unset;
|
||||
}
|
||||
|
||||
.popup .popup-content {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
background: var(--theme-bg-main);
|
||||
border: 0.125rem solid var(--theme-border-input);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.popup .popup-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 2rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
@ -1,22 +1,9 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Dashboard</title>
|
||||
<link rel="stylesheet" type="text/css" href="/themes.css">
|
||||
<link rel="stylesheet" type="text/css" href="/typography.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Dashboard</h1>
|
||||
</header>
|
||||
|
||||
{{# user }}
|
||||
<p>Logged in as {{ user.name }} ({{ user.username }})</p>
|
||||
<form action="/logout" method="POST">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
{{/ user }}
|
||||
|
||||
{{^ user }}
|
||||
<a href="/login">Login Page</a>
|
||||
{{/ user }}
|
||||
</body>
|
||||
</html>
|
||||
<main>
|
||||
{{# rendered_widgets }}
|
||||
{{{ . }}}
|
||||
{{/ rendered_widgets }}
|
||||
</main>
|
64
templates/structure.css
Normal file
64
templates/structure.css
Normal file
@ -0,0 +1,64 @@
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: var(--theme-bg-main);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== Primary Controls ===== */
|
||||
|
||||
aside.controls {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
aside.controls :is(a, p, button) {
|
||||
display: inline;
|
||||
font-size: 0.8rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
aside.controls form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
aside.controls button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-text-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
aside.controls button:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== Error Box ===== */
|
||||
|
||||
aside.error {
|
||||
margin-block: 4rem;
|
||||
margin-inline: 1rem;
|
||||
padding: 1rem;
|
||||
border: 0.1rem solid var(--theme-border-error-box);
|
||||
background: var(--theme-bg-error-box);
|
||||
}
|
||||
|
||||
aside.error h2 {
|
||||
font-size: 1.2rem;
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
aside.error p {
|
||||
margin-block: 0;
|
||||
color: var(--theme-text-error-box);
|
||||
}
|
@ -3,76 +3,60 @@
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
|
||||
|
||||
{{! ===== Default Themes ===== }}
|
||||
|
||||
body {
|
||||
{{> default_light }}
|
||||
}
|
||||
|
||||
body[data-color-scheme='dark'] {
|
||||
body[data-color-theme='dark'] {
|
||||
{{> default_dark }}
|
||||
}
|
||||
|
||||
{{# less_contrast }}
|
||||
body[data-color-contrast='less'] {
|
||||
{{> less_contrast_light }}
|
||||
}
|
||||
|
||||
body[data-color-theme='dark'][data-color-contrast='less'] {
|
||||
{{> less_contrast_dark }}
|
||||
}
|
||||
{{/ less_contrast }}
|
||||
|
||||
{{# more_contrast }}
|
||||
body[data-color-contrast='more'] {
|
||||
{{> more_contrast_light }}
|
||||
}
|
||||
|
||||
body[data-color-theme='dark'][data-color-contrast='more'] {
|
||||
{{> more_contrast_dark }}
|
||||
}
|
||||
{{/ more_contrast }}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
{{> default_dark }}
|
||||
}
|
||||
|
||||
body[data-color-scheme='light'] {
|
||||
body[data-color-theme='light'] {
|
||||
{{> default_light }}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
{{! ===== High Contrast Themes ===== }}
|
||||
|
||||
{{# more_contrast }}
|
||||
@media (prefers-contrast: more) {
|
||||
body {
|
||||
{{> more_contrast_light }}
|
||||
}
|
||||
|
||||
body[data-color-scheme='dark'] {
|
||||
{{> more_contrast_dark }}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
{{> more_contrast_dark }}
|
||||
}
|
||||
|
||||
body[data-color-scheme='light'] {
|
||||
{{> more_contrast_light }}
|
||||
}
|
||||
}
|
||||
}
|
||||
{{/ more_contrast }}
|
||||
|
||||
|
||||
|
||||
{{! ===== Low Contrast Themes ===== }}
|
||||
|
||||
{{# less_contrast }}
|
||||
@media (prefers-contrast: less) {
|
||||
body {
|
||||
{{> less_contrast_light }}
|
||||
}
|
||||
|
||||
body[data-color-scheme='dark'] {
|
||||
{{# less_contrast }}
|
||||
body[data-color-contrast='less'] {
|
||||
{{> less_contrast_dark }}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
{{> less_contrast_dark }}
|
||||
}
|
||||
|
||||
body[data-color-scheme='light'] {
|
||||
body[data-color-theme='light'][data-color-contrast='less'] {
|
||||
{{> less_contrast_light }}
|
||||
}
|
||||
{{/ less_contrast }}
|
||||
|
||||
{{# more_contrast }}
|
||||
body[data-color-contrast='more'] {
|
||||
{{> more_contrast_dark }}
|
||||
}
|
||||
|
||||
body[data-color-theme='light'][data-color-contrast='more'] {
|
||||
{{> more_contrast_light }}
|
||||
}
|
||||
{{/ more_contrast }}
|
||||
}
|
||||
{{/ less_contrast }}
|
||||
|
@ -1,17 +1,36 @@
|
||||
|
||||
:root {
|
||||
--font-heading: 'Open Sans', sans-serif;
|
||||
--font-body: 'Open Sans', sans-serif;
|
||||
--font-heading: Verdana, sans-serif;
|
||||
--font-body: Verdana, sans-serif;
|
||||
--font-monospace: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
|
||||
font-size: 16px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
:root {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 2000px) {
|
||||
:root {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== Font Families ===== */
|
||||
|
||||
h1, h2, h3, h4, h5, h6,
|
||||
th, dt {
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
p, td, dd, figcaption, li, blockquote {
|
||||
p, td, dd, figcaption, li, blockquote,
|
||||
input, textarea, select, option, optgroup, legend, fieldset, label, button {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
@ -24,3 +43,110 @@ b, i, u, q,
|
||||
strong, em, mark, cite {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== Font Sizes ===== */
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
th, td, dt, dd,
|
||||
p, figcaption, li, blockquote,
|
||||
pre,
|
||||
input, textarea, select, option, optgroup, legend, fieldset, label, button {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
a, span,
|
||||
b, i, u, q,
|
||||
strong, em, mark, cite,
|
||||
code, samp {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== Colors ===== */
|
||||
|
||||
::selection {
|
||||
color: var(--theme-text-selection);
|
||||
background: var(--theme-bg-text-selection);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6,
|
||||
th, dt {
|
||||
color: var(--theme-text-heading)
|
||||
}
|
||||
|
||||
p, td, dd, figcaption, li, blockquote,
|
||||
input, textarea, select, option, optgroup, legend, fieldset, label {
|
||||
color: var(--theme-text-body)
|
||||
}
|
||||
|
||||
button, input:is([type='button'], [type='submit'], [type='reset']) {
|
||||
color: var(--theme-text-button-primary);
|
||||
background: var(--theme-bg-button-primary);
|
||||
}
|
||||
|
||||
:is(button, input:is([type='button'], [type='submit'], [type='reset'])):hover {
|
||||
background: var(--theme-bg-button-primary-hover);
|
||||
}
|
||||
|
||||
:is(button, input:is([type='button'], [type='submit'], [type='reset'])).secondary {
|
||||
color: var(--theme-text-button-secondary);
|
||||
background: var(--theme-bg-button-secondary);
|
||||
}
|
||||
|
||||
:is(button, input:is([type='button'], [type='submit'], [type='reset'])).secondary:hover {
|
||||
background: var(--theme-bg-button-secondary-hover);
|
||||
}
|
||||
|
||||
pre, code, samp {
|
||||
color: var(--theme-code-normal);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--theme-text-link);
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: var(--theme-text-link-active);
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: var(--theme-text-link-visited);
|
||||
}
|
||||
|
||||
mark {
|
||||
color: var(--theme-text-highlight);
|
||||
background: var(--theme-bg-text-highlight);
|
||||
}
|
||||
|
||||
span,
|
||||
b, i, u, q,
|
||||
strong, em, cite {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
|
78
templates/weather.gov/forecast.html.mustache
Normal file
78
templates/weather.gov/forecast.html.mustache
Normal file
@ -0,0 +1,78 @@
|
||||
<section data-widget="weather-gov-forecast" title="Weather Forecast for {{ location.name }}">
|
||||
<link rel="stylesheet" href="/weather.gov/styles.css">
|
||||
|
||||
<div class="flex-row">
|
||||
<div class="today">
|
||||
{{# forecast_today }}
|
||||
<div class="{{# isDaytime }}day{{/ isDaytime }}{{^ isDaytime }}night{{/ isDaytime }}">
|
||||
<h2>{{ name }}</h2>
|
||||
<p class="condition">{{ shortForecast }}</p>
|
||||
<p class="temp">
|
||||
{{{ icons.thermometer }}}
|
||||
<span>{{ temperature }}<sup>{{ temperatureUnit }}</sup></span>
|
||||
</p>
|
||||
<p class="wind">
|
||||
{{{ icons.wind }}}
|
||||
<span>{{ windSpeed }} <!-- / {{ windDirection }} --></span>
|
||||
</p>
|
||||
</div>
|
||||
{{/ forecast_today }}
|
||||
</div>
|
||||
|
||||
<table class="future">
|
||||
<tr class="day">
|
||||
{{# forecast_days }}
|
||||
<td>
|
||||
{{# . }}
|
||||
<h3>{{ name }}</h3>
|
||||
<p class="condition">{{ shortForecast }}</p>
|
||||
<p class="temp">
|
||||
{{{ icons.thermometer }}}
|
||||
<span>{{ temperature }}<sup>{{ temperatureUnit }}</sup></span>
|
||||
</p>
|
||||
<p class="wind">
|
||||
{{{ icons.wind }}}
|
||||
<span>{{ windSpeed }} <!-- / {{ windDirection }} --></span>
|
||||
</p>
|
||||
{{/ . }}
|
||||
</td>
|
||||
{{/ forecast_days }}
|
||||
</tr>
|
||||
<tr class="night">
|
||||
{{# forecast_nights }}
|
||||
<td>
|
||||
{{# . }}
|
||||
<h3>{{ name }}</h3>
|
||||
<p class="condition">{{ shortForecast }}</p>
|
||||
<p class="temp">
|
||||
{{{ icons.thermometer }}}
|
||||
<span>{{ temperature }}<sup>{{ temperatureUnit }}</sup></span>
|
||||
</p>
|
||||
<p class="wind">
|
||||
{{{ icons.wind }}}
|
||||
<span>{{ windSpeed }} <!-- / {{ windDirection }} --></span>
|
||||
</p>
|
||||
{{/ . }}
|
||||
</td>
|
||||
{{/ forecast_nights }}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{# alerts.length }}
|
||||
<ul class="alerts" title="Alerts">
|
||||
{{# alerts }}
|
||||
<li>
|
||||
<h2>{{ event }}</h2>
|
||||
<p class="headline">{{ headline }}</p>
|
||||
<p class="description">{{ description }}</p>
|
||||
<p class="instruction">{{ instruction }}</p>
|
||||
</li>
|
||||
{{/ alerts }}
|
||||
</ul>
|
||||
{{/ alerts.length }}
|
||||
|
||||
<p class="powered-by">
|
||||
Powered by <a href="https://www.weather.gov/documentation/services-web-api" rel="external nofollow noreferrer">weather.gov</a>
|
||||
</p>
|
||||
</section>
|
0
templates/weather.gov/hourly-forecast.html.mustache
Normal file
0
templates/weather.gov/hourly-forecast.html.mustache
Normal file
83
templates/weather.gov/styles.css
Normal file
83
templates/weather.gov/styles.css
Normal file
@ -0,0 +1,83 @@
|
||||
|
||||
[data-widget='weather-gov-forecast'] {
|
||||
padding: 1rem;
|
||||
max-width: 70rem;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] .alerts {
|
||||
margin-block: 1rem;
|
||||
padding-inline: 1rem;
|
||||
border: 0.1rem solid var(--theme-border-error-box);
|
||||
background: var(--theme-bg-error-box);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] .alerts li {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] .alerts p {
|
||||
color: var(--theme-text-error-box);
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] .alerts p.headline {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] .flex-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] .today {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
row-gap: 2rem;
|
||||
padding: 1rem;
|
||||
margin-block-end: 1rem;
|
||||
background: var(--theme-bg-light);
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] svg.icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 1rem;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] h2 {
|
||||
font-size: 1.2rem;
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] .today p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] .today svg.icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] h3 {
|
||||
font-size: 0.8rem;
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] p {
|
||||
font-size: 0.8rem;
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] p:is(.wind, .temp) {
|
||||
display: flex;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] table {
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
[data-widget='weather-gov-forecast'] td {
|
||||
padding-inline: 1rem;
|
||||
}
|
21
templates/weatherapi.com/current.html.mustache
Normal file
21
templates/weatherapi.com/current.html.mustache
Normal file
@ -0,0 +1,21 @@
|
||||
<section data-widget="weatherapi-com-current" title="Current Weather for {{ location.name }}">
|
||||
<link rel="stylesheet" href="/weatherapi.com/styles.css">
|
||||
|
||||
<p class="condition">
|
||||
<span>{{ weather.current.condition.text }}</span>
|
||||
</p>
|
||||
|
||||
<p class="temp">
|
||||
{{{ icons.thermometer }}}
|
||||
<span>{{ weather.current.temp_f }}<sup>F</sup> / {{ weather.current.temp_c }}<sup>C</sup></span>
|
||||
</p>
|
||||
|
||||
<p class="wind">
|
||||
{{{ icons.wind }}}
|
||||
<span>{{ weather.current.wind_mph }} mph / {{ weather.current.wind_kph }} km/h / {{ weather.current.wind_dir }}</span>
|
||||
</p>
|
||||
|
||||
<p class="powered-by">
|
||||
Powered by <a href="https://www.weatherapi.com/docs/" rel="external nofollow noreferrer">WeatherAPI.com</a>
|
||||
</p>
|
||||
</section>
|
32
templates/weatherapi.com/styles.css
Normal file
32
templates/weatherapi.com/styles.css
Normal file
@ -0,0 +1,32 @@
|
||||
|
||||
[data-widget='weatherapi-com-current'] {
|
||||
width: 20rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
[data-widget='weatherapi-com-current'] svg.icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
flex: 0 0 1.2rem;
|
||||
}
|
||||
|
||||
[data-widget='weatherapi-com-current'] h2 {
|
||||
font-size: 1.2rem;
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
[data-widget='weatherapi-com-current'] p:is(.condition, .wind, .temp) {
|
||||
display: flex;
|
||||
column-gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
[data-widget='weatherapi-com-current'] p.condition {
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
[data-widget='weatherapi-com-current'] p.powered-by {
|
||||
font-size: 0.8rem;
|
||||
margin-block-start: 1rem;
|
||||
}
|
289
vendor/feather-icons/icons.json
vendored
Normal file
289
vendor/feather-icons/icons.json
vendored
Normal file
@ -0,0 +1,289 @@
|
||||
{
|
||||
"activity": "<polyline points=\"22 12 18 12 15 21 9 3 6 12 2 12\"></polyline>",
|
||||
"airplay": "<path d=\"M5 17H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-1\"></path><polygon points=\"12 15 17 21 7 21 12 15\"></polygon>",
|
||||
"alert-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"></line>",
|
||||
"alert-octagon": "<polygon points=\"7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2\"></polygon><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"></line>",
|
||||
"alert-triangle": "<path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"></path><line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"13\"></line><line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"></line>",
|
||||
"align-center": "<line x1=\"18\" y1=\"10\" x2=\"6\" y2=\"10\"></line><line x1=\"21\" y1=\"6\" x2=\"3\" y2=\"6\"></line><line x1=\"21\" y1=\"14\" x2=\"3\" y2=\"14\"></line><line x1=\"18\" y1=\"18\" x2=\"6\" y2=\"18\"></line>",
|
||||
"align-justify": "<line x1=\"21\" y1=\"10\" x2=\"3\" y2=\"10\"></line><line x1=\"21\" y1=\"6\" x2=\"3\" y2=\"6\"></line><line x1=\"21\" y1=\"14\" x2=\"3\" y2=\"14\"></line><line x1=\"21\" y1=\"18\" x2=\"3\" y2=\"18\"></line>",
|
||||
"align-left": "<line x1=\"17\" y1=\"10\" x2=\"3\" y2=\"10\"></line><line x1=\"21\" y1=\"6\" x2=\"3\" y2=\"6\"></line><line x1=\"21\" y1=\"14\" x2=\"3\" y2=\"14\"></line><line x1=\"17\" y1=\"18\" x2=\"3\" y2=\"18\"></line>",
|
||||
"align-right": "<line x1=\"21\" y1=\"10\" x2=\"7\" y2=\"10\"></line><line x1=\"21\" y1=\"6\" x2=\"3\" y2=\"6\"></line><line x1=\"21\" y1=\"14\" x2=\"3\" y2=\"14\"></line><line x1=\"21\" y1=\"18\" x2=\"7\" y2=\"18\"></line>",
|
||||
"anchor": "<circle cx=\"12\" cy=\"5\" r=\"3\"></circle><line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"8\"></line><path d=\"M5 12H2a10 10 0 0 0 20 0h-3\"></path>",
|
||||
"aperture": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"14.31\" y1=\"8\" x2=\"20.05\" y2=\"17.94\"></line><line x1=\"9.69\" y1=\"8\" x2=\"21.17\" y2=\"8\"></line><line x1=\"7.38\" y1=\"12\" x2=\"13.12\" y2=\"2.06\"></line><line x1=\"9.69\" y1=\"16\" x2=\"3.95\" y2=\"6.06\"></line><line x1=\"14.31\" y1=\"16\" x2=\"2.83\" y2=\"16\"></line><line x1=\"16.62\" y1=\"12\" x2=\"10.88\" y2=\"21.94\"></line>",
|
||||
"archive": "<polyline points=\"21 8 21 21 3 21 3 8\"></polyline><rect x=\"1\" y=\"3\" width=\"22\" height=\"5\"></rect><line x1=\"10\" y1=\"12\" x2=\"14\" y2=\"12\"></line>",
|
||||
"arrow-down-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"8 12 12 16 16 12\"></polyline><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"16\"></line>",
|
||||
"arrow-down-left": "<line x1=\"17\" y1=\"7\" x2=\"7\" y2=\"17\"></line><polyline points=\"17 17 7 17 7 7\"></polyline>",
|
||||
"arrow-down-right": "<line x1=\"7\" y1=\"7\" x2=\"17\" y2=\"17\"></line><polyline points=\"17 7 17 17 7 17\"></polyline>",
|
||||
"arrow-down": "<line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"></line><polyline points=\"19 12 12 19 5 12\"></polyline>",
|
||||
"arrow-left-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"12 8 8 12 12 16\"></polyline><line x1=\"16\" y1=\"12\" x2=\"8\" y2=\"12\"></line>",
|
||||
"arrow-left": "<line x1=\"19\" y1=\"12\" x2=\"5\" y2=\"12\"></line><polyline points=\"12 19 5 12 12 5\"></polyline>",
|
||||
"arrow-right-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"12 16 16 12 12 8\"></polyline><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
|
||||
"arrow-right": "<line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line><polyline points=\"12 5 19 12 12 19\"></polyline>",
|
||||
"arrow-up-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"16 12 12 8 8 12\"></polyline><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"8\"></line>",
|
||||
"arrow-up-left": "<line x1=\"17\" y1=\"17\" x2=\"7\" y2=\"7\"></line><polyline points=\"7 17 7 7 17 7\"></polyline>",
|
||||
"arrow-up-right": "<line x1=\"7\" y1=\"17\" x2=\"17\" y2=\"7\"></line><polyline points=\"7 7 17 7 17 17\"></polyline>",
|
||||
"arrow-up": "<line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"5\"></line><polyline points=\"5 12 12 5 19 12\"></polyline>",
|
||||
"at-sign": "<circle cx=\"12\" cy=\"12\" r=\"4\"></circle><path d=\"M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94\"></path>",
|
||||
"award": "<circle cx=\"12\" cy=\"8\" r=\"7\"></circle><polyline points=\"8.21 13.89 7 23 12 20 17 23 15.79 13.88\"></polyline>",
|
||||
"bar-chart-2": "<line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"10\"></line><line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"4\"></line><line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"14\"></line>",
|
||||
"bar-chart": "<line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"10\"></line><line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"4\"></line><line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"16\"></line>",
|
||||
"battery-charging": "<path d=\"M5 18H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3.19M15 6h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3.19\"></path><line x1=\"23\" y1=\"13\" x2=\"23\" y2=\"11\"></line><polyline points=\"11 6 7 12 13 12 9 18\"></polyline>",
|
||||
"battery": "<rect x=\"1\" y=\"6\" width=\"18\" height=\"12\" rx=\"2\" ry=\"2\"></rect><line x1=\"23\" y1=\"13\" x2=\"23\" y2=\"11\"></line>",
|
||||
"bell-off": "<path d=\"M13.73 21a2 2 0 0 1-3.46 0\"></path><path d=\"M18.63 13A17.89 17.89 0 0 1 18 8\"></path><path d=\"M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14\"></path><path d=\"M18 8a6 6 0 0 0-9.33-5\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
|
||||
"bell": "<path d=\"M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9\"></path><path d=\"M13.73 21a2 2 0 0 1-3.46 0\"></path>",
|
||||
"bluetooth": "<polyline points=\"6.5 6.5 17.5 17.5 12 23 12 1 17.5 6.5 6.5 17.5\"></polyline>",
|
||||
"bold": "<path d=\"M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z\"></path><path d=\"M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z\"></path>",
|
||||
"book-open": "<path d=\"M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z\"></path><path d=\"M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z\"></path>",
|
||||
"book": "<path d=\"M4 19.5A2.5 2.5 0 0 1 6.5 17H20\"></path><path d=\"M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z\"></path>",
|
||||
"bookmark": "<path d=\"M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path>",
|
||||
"box": "<path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path><polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline><line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line>",
|
||||
"briefcase": "<rect x=\"2\" y=\"7\" width=\"20\" height=\"14\" rx=\"2\" ry=\"2\"></rect><path d=\"M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16\"></path>",
|
||||
"calendar": "<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"16\" y1=\"2\" x2=\"16\" y2=\"6\"></line><line x1=\"8\" y1=\"2\" x2=\"8\" y2=\"6\"></line><line x1=\"3\" y1=\"10\" x2=\"21\" y2=\"10\"></line>",
|
||||
"camera-off": "<line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line><path d=\"M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v9.34m-7.72-2.06a4 4 0 1 1-5.56-5.56\"></path>",
|
||||
"camera": "<path d=\"M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z\"></path><circle cx=\"12\" cy=\"13\" r=\"4\"></circle>",
|
||||
"cast": "<path d=\"M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6\"></path><line x1=\"2\" y1=\"20\" x2=\"2.01\" y2=\"20\"></line>",
|
||||
"check-circle": "<path d=\"M22 11.08V12a10 10 0 1 1-5.93-9.14\"></path><polyline points=\"22 4 12 14.01 9 11.01\"></polyline>",
|
||||
"check-square": "<polyline points=\"9 11 12 14 22 4\"></polyline><path d=\"M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11\"></path>",
|
||||
"check": "<polyline points=\"20 6 9 17 4 12\"></polyline>",
|
||||
"chevron-down": "<polyline points=\"6 9 12 15 18 9\"></polyline>",
|
||||
"chevron-left": "<polyline points=\"15 18 9 12 15 6\"></polyline>",
|
||||
"chevron-right": "<polyline points=\"9 18 15 12 9 6\"></polyline>",
|
||||
"chevron-up": "<polyline points=\"18 15 12 9 6 15\"></polyline>",
|
||||
"chevrons-down": "<polyline points=\"7 13 12 18 17 13\"></polyline><polyline points=\"7 6 12 11 17 6\"></polyline>",
|
||||
"chevrons-left": "<polyline points=\"11 17 6 12 11 7\"></polyline><polyline points=\"18 17 13 12 18 7\"></polyline>",
|
||||
"chevrons-right": "<polyline points=\"13 17 18 12 13 7\"></polyline><polyline points=\"6 17 11 12 6 7\"></polyline>",
|
||||
"chevrons-up": "<polyline points=\"17 11 12 6 7 11\"></polyline><polyline points=\"17 18 12 13 7 18\"></polyline>",
|
||||
"chrome": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><circle cx=\"12\" cy=\"12\" r=\"4\"></circle><line x1=\"21.17\" y1=\"8\" x2=\"12\" y2=\"8\"></line><line x1=\"3.95\" y1=\"6.06\" x2=\"8.54\" y2=\"14\"></line><line x1=\"10.88\" y1=\"21.94\" x2=\"15.46\" y2=\"14\"></line>",
|
||||
"circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle>",
|
||||
"clipboard": "<path d=\"M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2\"></path><rect x=\"8\" y=\"2\" width=\"8\" height=\"4\" rx=\"1\" ry=\"1\"></rect>",
|
||||
"clock": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polyline points=\"12 6 12 12 16 14\"></polyline>",
|
||||
"cloud-drizzle": "<line x1=\"8\" y1=\"19\" x2=\"8\" y2=\"21\"></line><line x1=\"8\" y1=\"13\" x2=\"8\" y2=\"15\"></line><line x1=\"16\" y1=\"19\" x2=\"16\" y2=\"21\"></line><line x1=\"16\" y1=\"13\" x2=\"16\" y2=\"15\"></line><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"></line><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"17\"></line><path d=\"M20 16.58A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25\"></path>",
|
||||
"cloud-lightning": "<path d=\"M19 16.9A5 5 0 0 0 18 7h-1.26a8 8 0 1 0-11.62 9\"></path><polyline points=\"13 11 9 17 15 17 11 23\"></polyline>",
|
||||
"cloud-off": "<path d=\"M22.61 16.95A5 5 0 0 0 18 10h-1.26a8 8 0 0 0-7.05-6M5 5a8 8 0 0 0 4 15h9a5 5 0 0 0 1.7-.3\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
|
||||
"cloud-rain": "<line x1=\"16\" y1=\"13\" x2=\"16\" y2=\"21\"></line><line x1=\"8\" y1=\"13\" x2=\"8\" y2=\"21\"></line><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"23\"></line><path d=\"M20 16.58A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25\"></path>",
|
||||
"cloud-snow": "<path d=\"M20 17.58A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25\"></path><line x1=\"8\" y1=\"16\" x2=\"8.01\" y2=\"16\"></line><line x1=\"8\" y1=\"20\" x2=\"8.01\" y2=\"20\"></line><line x1=\"12\" y1=\"18\" x2=\"12.01\" y2=\"18\"></line><line x1=\"12\" y1=\"22\" x2=\"12.01\" y2=\"22\"></line><line x1=\"16\" y1=\"16\" x2=\"16.01\" y2=\"16\"></line><line x1=\"16\" y1=\"20\" x2=\"16.01\" y2=\"20\"></line>",
|
||||
"cloud": "<path d=\"M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z\"></path>",
|
||||
"code": "<polyline points=\"16 18 22 12 16 6\"></polyline><polyline points=\"8 6 2 12 8 18\"></polyline>",
|
||||
"codepen": "<polygon points=\"12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5 12 2\"></polygon><line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"15.5\"></line><polyline points=\"22 8.5 12 15.5 2 8.5\"></polyline><polyline points=\"2 15.5 12 8.5 22 15.5\"></polyline><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"8.5\"></line>",
|
||||
"codesandbox": "<path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path><polyline points=\"7.5 4.21 12 6.81 16.5 4.21\"></polyline><polyline points=\"7.5 19.79 7.5 14.6 3 12\"></polyline><polyline points=\"21 12 16.5 14.6 16.5 19.79\"></polyline><polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline><line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line>",
|
||||
"coffee": "<path d=\"M18 8h1a4 4 0 0 1 0 8h-1\"></path><path d=\"M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z\"></path><line x1=\"6\" y1=\"1\" x2=\"6\" y2=\"4\"></line><line x1=\"10\" y1=\"1\" x2=\"10\" y2=\"4\"></line><line x1=\"14\" y1=\"1\" x2=\"14\" y2=\"4\"></line>",
|
||||
"columns": "<path d=\"M12 3h7a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-7m0-18H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7m0-18v18\"></path>",
|
||||
"command": "<path d=\"M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z\"></path>",
|
||||
"compass": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polygon points=\"16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76\"></polygon>",
|
||||
"copy": "<rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"></path>",
|
||||
"corner-down-left": "<polyline points=\"9 10 4 15 9 20\"></polyline><path d=\"M20 4v7a4 4 0 0 1-4 4H4\"></path>",
|
||||
"corner-down-right": "<polyline points=\"15 10 20 15 15 20\"></polyline><path d=\"M4 4v7a4 4 0 0 0 4 4h12\"></path>",
|
||||
"corner-left-down": "<polyline points=\"14 15 9 20 4 15\"></polyline><path d=\"M20 4h-7a4 4 0 0 0-4 4v12\"></path>",
|
||||
"corner-left-up": "<polyline points=\"14 9 9 4 4 9\"></polyline><path d=\"M20 20h-7a4 4 0 0 1-4-4V4\"></path>",
|
||||
"corner-right-down": "<polyline points=\"10 15 15 20 20 15\"></polyline><path d=\"M4 4h7a4 4 0 0 1 4 4v12\"></path>",
|
||||
"corner-right-up": "<polyline points=\"10 9 15 4 20 9\"></polyline><path d=\"M4 20h7a4 4 0 0 0 4-4V4\"></path>",
|
||||
"corner-up-left": "<polyline points=\"9 14 4 9 9 4\"></polyline><path d=\"M20 20v-7a4 4 0 0 0-4-4H4\"></path>",
|
||||
"corner-up-right": "<polyline points=\"15 14 20 9 15 4\"></polyline><path d=\"M4 20v-7a4 4 0 0 1 4-4h12\"></path>",
|
||||
"cpu": "<rect x=\"4\" y=\"4\" width=\"16\" height=\"16\" rx=\"2\" ry=\"2\"></rect><rect x=\"9\" y=\"9\" width=\"6\" height=\"6\"></rect><line x1=\"9\" y1=\"1\" x2=\"9\" y2=\"4\"></line><line x1=\"15\" y1=\"1\" x2=\"15\" y2=\"4\"></line><line x1=\"9\" y1=\"20\" x2=\"9\" y2=\"23\"></line><line x1=\"15\" y1=\"20\" x2=\"15\" y2=\"23\"></line><line x1=\"20\" y1=\"9\" x2=\"23\" y2=\"9\"></line><line x1=\"20\" y1=\"14\" x2=\"23\" y2=\"14\"></line><line x1=\"1\" y1=\"9\" x2=\"4\" y2=\"9\"></line><line x1=\"1\" y1=\"14\" x2=\"4\" y2=\"14\"></line>",
|
||||
"credit-card": "<rect x=\"1\" y=\"4\" width=\"22\" height=\"16\" rx=\"2\" ry=\"2\"></rect><line x1=\"1\" y1=\"10\" x2=\"23\" y2=\"10\"></line>",
|
||||
"crop": "<path d=\"M6.13 1L6 16a2 2 0 0 0 2 2h15\"></path><path d=\"M1 6.13L16 6a2 2 0 0 1 2 2v15\"></path>",
|
||||
"crosshair": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"22\" y1=\"12\" x2=\"18\" y2=\"12\"></line><line x1=\"6\" y1=\"12\" x2=\"2\" y2=\"12\"></line><line x1=\"12\" y1=\"6\" x2=\"12\" y2=\"2\"></line><line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"18\"></line>",
|
||||
"database": "<ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\"></ellipse><path d=\"M21 12c0 1.66-4 3-9 3s-9-1.34-9-3\"></path><path d=\"M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5\"></path>",
|
||||
"delete": "<path d=\"M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z\"></path><line x1=\"18\" y1=\"9\" x2=\"12\" y2=\"15\"></line><line x1=\"12\" y1=\"9\" x2=\"18\" y2=\"15\"></line>",
|
||||
"disc": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><circle cx=\"12\" cy=\"12\" r=\"3\"></circle>",
|
||||
"divide-circle": "<line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"16\"></line><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"8\"></line><circle cx=\"12\" cy=\"12\" r=\"10\"></circle>",
|
||||
"divide-square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"16\"></line><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"8\"></line>",
|
||||
"divide": "<circle cx=\"12\" cy=\"6\" r=\"2\"></circle><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line><circle cx=\"12\" cy=\"18\" r=\"2\"></circle>",
|
||||
"dollar-sign": "<line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"23\"></line><path d=\"M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6\"></path>",
|
||||
"download-cloud": "<polyline points=\"8 17 12 21 16 17\"></polyline><line x1=\"12\" y1=\"12\" x2=\"12\" y2=\"21\"></line><path d=\"M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29\"></path>",
|
||||
"download": "<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"7 10 12 15 17 10\"></polyline><line x1=\"12\" y1=\"15\" x2=\"12\" y2=\"3\"></line>",
|
||||
"dribbble": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><path d=\"M8.56 2.75c4.37 6.03 6.02 9.42 8.03 17.72m2.54-15.38c-3.72 4.35-8.94 5.66-16.88 5.85m19.5 1.9c-3.5-.93-6.63-.82-8.94 0-2.58.92-5.01 2.86-7.44 6.32\"></path>",
|
||||
"droplet": "<path d=\"M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z\"></path>",
|
||||
"edit-2": "<path d=\"M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z\"></path>",
|
||||
"edit-3": "<path d=\"M12 20h9\"></path><path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\"></path>",
|
||||
"edit": "<path d=\"M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7\"></path><path d=\"M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z\"></path>",
|
||||
"external-link": "<path d=\"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6\"></path><polyline points=\"15 3 21 3 21 9\"></polyline><line x1=\"10\" y1=\"14\" x2=\"21\" y2=\"3\"></line>",
|
||||
"eye-off": "<path d=\"M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
|
||||
"eye": "<path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"></path><circle cx=\"12\" cy=\"12\" r=\"3\"></circle>",
|
||||
"facebook": "<path d=\"M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z\"></path>",
|
||||
"fast-forward": "<polygon points=\"13 19 22 12 13 5 13 19\"></polygon><polygon points=\"2 19 11 12 2 5 2 19\"></polygon>",
|
||||
"feather": "<path d=\"M20.24 12.24a6 6 0 0 0-8.49-8.49L5 10.5V19h8.5z\"></path><line x1=\"16\" y1=\"8\" x2=\"2\" y2=\"22\"></line><line x1=\"17.5\" y1=\"15\" x2=\"9\" y2=\"15\"></line>",
|
||||
"figma": "<path d=\"M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z\"></path><path d=\"M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z\"></path><path d=\"M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z\"></path><path d=\"M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z\"></path><path d=\"M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z\"></path>",
|
||||
"file-minus": "<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path><polyline points=\"14 2 14 8 20 8\"></polyline><line x1=\"9\" y1=\"15\" x2=\"15\" y2=\"15\"></line>",
|
||||
"file-plus": "<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path><polyline points=\"14 2 14 8 20 8\"></polyline><line x1=\"12\" y1=\"18\" x2=\"12\" y2=\"12\"></line><line x1=\"9\" y1=\"15\" x2=\"15\" y2=\"15\"></line>",
|
||||
"file-text": "<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"></path><polyline points=\"14 2 14 8 20 8\"></polyline><line x1=\"16\" y1=\"13\" x2=\"8\" y2=\"13\"></line><line x1=\"16\" y1=\"17\" x2=\"8\" y2=\"17\"></line><polyline points=\"10 9 9 9 8 9\"></polyline>",
|
||||
"file": "<path d=\"M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z\"></path><polyline points=\"13 2 13 9 20 9\"></polyline>",
|
||||
"film": "<rect x=\"2\" y=\"2\" width=\"20\" height=\"20\" rx=\"2.18\" ry=\"2.18\"></rect><line x1=\"7\" y1=\"2\" x2=\"7\" y2=\"22\"></line><line x1=\"17\" y1=\"2\" x2=\"17\" y2=\"22\"></line><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line><line x1=\"2\" y1=\"7\" x2=\"7\" y2=\"7\"></line><line x1=\"2\" y1=\"17\" x2=\"7\" y2=\"17\"></line><line x1=\"17\" y1=\"17\" x2=\"22\" y2=\"17\"></line><line x1=\"17\" y1=\"7\" x2=\"22\" y2=\"7\"></line>",
|
||||
"filter": "<polygon points=\"22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3\"></polygon>",
|
||||
"flag": "<path d=\"M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z\"></path><line x1=\"4\" y1=\"22\" x2=\"4\" y2=\"15\"></line>",
|
||||
"folder-minus": "<path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"></path><line x1=\"9\" y1=\"14\" x2=\"15\" y2=\"14\"></line>",
|
||||
"folder-plus": "<path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"></path><line x1=\"12\" y1=\"11\" x2=\"12\" y2=\"17\"></line><line x1=\"9\" y1=\"14\" x2=\"15\" y2=\"14\"></line>",
|
||||
"folder": "<path d=\"M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z\"></path>",
|
||||
"framer": "<path d=\"M5 16V9h14V2H5l14 14h-7m-7 0l7 7v-7m-7 0h7\"></path>",
|
||||
"frown": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><path d=\"M16 16s-1.5-2-4-2-4 2-4 2\"></path><line x1=\"9\" y1=\"9\" x2=\"9.01\" y2=\"9\"></line><line x1=\"15\" y1=\"9\" x2=\"15.01\" y2=\"9\"></line>",
|
||||
"gift": "<polyline points=\"20 12 20 22 4 22 4 12\"></polyline><rect x=\"2\" y=\"7\" width=\"20\" height=\"5\"></rect><line x1=\"12\" y1=\"22\" x2=\"12\" y2=\"7\"></line><path d=\"M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z\"></path><path d=\"M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z\"></path>",
|
||||
"git-branch": "<line x1=\"6\" y1=\"3\" x2=\"6\" y2=\"15\"></line><circle cx=\"18\" cy=\"6\" r=\"3\"></circle><circle cx=\"6\" cy=\"18\" r=\"3\"></circle><path d=\"M18 9a9 9 0 0 1-9 9\"></path>",
|
||||
"git-commit": "<circle cx=\"12\" cy=\"12\" r=\"4\"></circle><line x1=\"1.05\" y1=\"12\" x2=\"7\" y2=\"12\"></line><line x1=\"17.01\" y1=\"12\" x2=\"22.96\" y2=\"12\"></line>",
|
||||
"git-merge": "<circle cx=\"18\" cy=\"18\" r=\"3\"></circle><circle cx=\"6\" cy=\"6\" r=\"3\"></circle><path d=\"M6 21V9a9 9 0 0 0 9 9\"></path>",
|
||||
"git-pull-request": "<circle cx=\"18\" cy=\"18\" r=\"3\"></circle><circle cx=\"6\" cy=\"6\" r=\"3\"></circle><path d=\"M13 6h3a2 2 0 0 1 2 2v7\"></path><line x1=\"6\" y1=\"9\" x2=\"6\" y2=\"21\"></line>",
|
||||
"github": "<path d=\"M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22\"></path>",
|
||||
"gitlab": "<path d=\"M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 0 1-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 0 1 4.82 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0 1 18.6 2a.43.43 0 0 1 .58 0 .42.42 0 0 1 .11.18l2.44 7.51L23 13.45a.84.84 0 0 1-.35.94z\"></path>",
|
||||
"globe": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>",
|
||||
"grid": "<rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"></rect><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"></rect><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\"></rect><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"></rect>",
|
||||
"hard-drive": "<line x1=\"22\" y1=\"12\" x2=\"2\" y2=\"12\"></line><path d=\"M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z\"></path><line x1=\"6\" y1=\"16\" x2=\"6.01\" y2=\"16\"></line><line x1=\"10\" y1=\"16\" x2=\"10.01\" y2=\"16\"></line>",
|
||||
"hash": "<line x1=\"4\" y1=\"9\" x2=\"20\" y2=\"9\"></line><line x1=\"4\" y1=\"15\" x2=\"20\" y2=\"15\"></line><line x1=\"10\" y1=\"3\" x2=\"8\" y2=\"21\"></line><line x1=\"16\" y1=\"3\" x2=\"14\" y2=\"21\"></line>",
|
||||
"headphones": "<path d=\"M3 18v-6a9 9 0 0 1 18 0v6\"></path><path d=\"M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z\"></path>",
|
||||
"heart": "<path d=\"M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z\"></path>",
|
||||
"help-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><path d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3\"></path><line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"></line>",
|
||||
"hexagon": "<path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path>",
|
||||
"home": "<path d=\"M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z\"></path><polyline points=\"9 22 9 12 15 12 15 22\"></polyline>",
|
||||
"image": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"></circle><polyline points=\"21 15 16 10 5 21\"></polyline>",
|
||||
"inbox": "<polyline points=\"22 12 16 12 14 15 10 15 8 12 2 12\"></polyline><path d=\"M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z\"></path>",
|
||||
"info": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"8\" x2=\"12.01\" y2=\"8\"></line>",
|
||||
"instagram": "<rect x=\"2\" y=\"2\" width=\"20\" height=\"20\" rx=\"5\" ry=\"5\"></rect><path d=\"M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z\"></path><line x1=\"17.5\" y1=\"6.5\" x2=\"17.51\" y2=\"6.5\"></line>",
|
||||
"italic": "<line x1=\"19\" y1=\"4\" x2=\"10\" y2=\"4\"></line><line x1=\"14\" y1=\"20\" x2=\"5\" y2=\"20\"></line><line x1=\"15\" y1=\"4\" x2=\"9\" y2=\"20\"></line>",
|
||||
"key": "<path d=\"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4\"></path>",
|
||||
"layers": "<polygon points=\"12 2 2 7 12 12 22 7 12 2\"></polygon><polyline points=\"2 17 12 22 22 17\"></polyline><polyline points=\"2 12 12 17 22 12\"></polyline>",
|
||||
"layout": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"3\" y1=\"9\" x2=\"21\" y2=\"9\"></line><line x1=\"9\" y1=\"21\" x2=\"9\" y2=\"9\"></line>",
|
||||
"life-buoy": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><circle cx=\"12\" cy=\"12\" r=\"4\"></circle><line x1=\"4.93\" y1=\"4.93\" x2=\"9.17\" y2=\"9.17\"></line><line x1=\"14.83\" y1=\"14.83\" x2=\"19.07\" y2=\"19.07\"></line><line x1=\"14.83\" y1=\"9.17\" x2=\"19.07\" y2=\"4.93\"></line><line x1=\"14.83\" y1=\"9.17\" x2=\"18.36\" y2=\"5.64\"></line><line x1=\"4.93\" y1=\"19.07\" x2=\"9.17\" y2=\"14.83\"></line>",
|
||||
"link-2": "<path d=\"M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3\"></path><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
|
||||
"link": "<path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"></path><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"></path>",
|
||||
"linkedin": "<path d=\"M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z\"></path><rect x=\"2\" y=\"9\" width=\"4\" height=\"12\"></rect><circle cx=\"4\" cy=\"4\" r=\"2\"></circle>",
|
||||
"list": "<line x1=\"8\" y1=\"6\" x2=\"21\" y2=\"6\"></line><line x1=\"8\" y1=\"12\" x2=\"21\" y2=\"12\"></line><line x1=\"8\" y1=\"18\" x2=\"21\" y2=\"18\"></line><line x1=\"3\" y1=\"6\" x2=\"3.01\" y2=\"6\"></line><line x1=\"3\" y1=\"12\" x2=\"3.01\" y2=\"12\"></line><line x1=\"3\" y1=\"18\" x2=\"3.01\" y2=\"18\"></line>",
|
||||
"loader": "<line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"6\"></line><line x1=\"12\" y1=\"18\" x2=\"12\" y2=\"22\"></line><line x1=\"4.93\" y1=\"4.93\" x2=\"7.76\" y2=\"7.76\"></line><line x1=\"16.24\" y1=\"16.24\" x2=\"19.07\" y2=\"19.07\"></line><line x1=\"2\" y1=\"12\" x2=\"6\" y2=\"12\"></line><line x1=\"18\" y1=\"12\" x2=\"22\" y2=\"12\"></line><line x1=\"4.93\" y1=\"19.07\" x2=\"7.76\" y2=\"16.24\"></line><line x1=\"16.24\" y1=\"7.76\" x2=\"19.07\" y2=\"4.93\"></line>",
|
||||
"lock": "<rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\" ry=\"2\"></rect><path d=\"M7 11V7a5 5 0 0 1 10 0v4\"></path>",
|
||||
"log-in": "<path d=\"M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4\"></path><polyline points=\"10 17 15 12 10 7\"></polyline><line x1=\"15\" y1=\"12\" x2=\"3\" y2=\"12\"></line>",
|
||||
"log-out": "<path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\"></path><polyline points=\"16 17 21 12 16 7\"></polyline><line x1=\"21\" y1=\"12\" x2=\"9\" y2=\"12\"></line>",
|
||||
"mail": "<path d=\"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z\"></path><polyline points=\"22,6 12,13 2,6\"></polyline>",
|
||||
"map-pin": "<path d=\"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z\"></path><circle cx=\"12\" cy=\"10\" r=\"3\"></circle>",
|
||||
"map": "<polygon points=\"1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6\"></polygon><line x1=\"8\" y1=\"2\" x2=\"8\" y2=\"18\"></line><line x1=\"16\" y1=\"6\" x2=\"16\" y2=\"22\"></line>",
|
||||
"maximize-2": "<polyline points=\"15 3 21 3 21 9\"></polyline><polyline points=\"9 21 3 21 3 15\"></polyline><line x1=\"21\" y1=\"3\" x2=\"14\" y2=\"10\"></line><line x1=\"3\" y1=\"21\" x2=\"10\" y2=\"14\"></line>",
|
||||
"maximize": "<path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path>",
|
||||
"meh": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"8\" y1=\"15\" x2=\"16\" y2=\"15\"></line><line x1=\"9\" y1=\"9\" x2=\"9.01\" y2=\"9\"></line><line x1=\"15\" y1=\"9\" x2=\"15.01\" y2=\"9\"></line>",
|
||||
"menu": "<line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\"></line><line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"></line><line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\"></line>",
|
||||
"message-circle": "<path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\"></path>",
|
||||
"message-square": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"></path>",
|
||||
"mic-off": "<line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line><path d=\"M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6\"></path><path d=\"M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23\"></path><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"23\"></line><line x1=\"8\" y1=\"23\" x2=\"16\" y2=\"23\"></line>",
|
||||
"mic": "<path d=\"M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z\"></path><path d=\"M19 10v2a7 7 0 0 1-14 0v-2\"></path><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"23\"></line><line x1=\"8\" y1=\"23\" x2=\"16\" y2=\"23\"></line>",
|
||||
"minimize-2": "<polyline points=\"4 14 10 14 10 20\"></polyline><polyline points=\"20 10 14 10 14 4\"></polyline><line x1=\"14\" y1=\"10\" x2=\"21\" y2=\"3\"></line><line x1=\"3\" y1=\"21\" x2=\"10\" y2=\"14\"></line>",
|
||||
"minimize": "<path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path>",
|
||||
"minus-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
|
||||
"minus-square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
|
||||
"minus": "<line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line>",
|
||||
"monitor": "<rect x=\"2\" y=\"3\" width=\"20\" height=\"14\" rx=\"2\" ry=\"2\"></rect><line x1=\"8\" y1=\"21\" x2=\"16\" y2=\"21\"></line><line x1=\"12\" y1=\"17\" x2=\"12\" y2=\"21\"></line>",
|
||||
"moon": "<path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"></path>",
|
||||
"more-horizontal": "<circle cx=\"12\" cy=\"12\" r=\"1\"></circle><circle cx=\"19\" cy=\"12\" r=\"1\"></circle><circle cx=\"5\" cy=\"12\" r=\"1\"></circle>",
|
||||
"more-vertical": "<circle cx=\"12\" cy=\"12\" r=\"1\"></circle><circle cx=\"12\" cy=\"5\" r=\"1\"></circle><circle cx=\"12\" cy=\"19\" r=\"1\"></circle>",
|
||||
"mouse-pointer": "<path d=\"M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z\"></path><path d=\"M13 13l6 6\"></path>",
|
||||
"move": "<polyline points=\"5 9 2 12 5 15\"></polyline><polyline points=\"9 5 12 2 15 5\"></polyline><polyline points=\"15 19 12 22 9 19\"></polyline><polyline points=\"19 9 22 12 19 15\"></polyline><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"22\"></line>",
|
||||
"music": "<path d=\"M9 18V5l12-2v13\"></path><circle cx=\"6\" cy=\"18\" r=\"3\"></circle><circle cx=\"18\" cy=\"16\" r=\"3\"></circle>",
|
||||
"navigation-2": "<polygon points=\"12 2 19 21 12 17 5 21 12 2\"></polygon>",
|
||||
"navigation": "<polygon points=\"3 11 22 2 13 21 11 13 3 11\"></polygon>",
|
||||
"octagon": "<polygon points=\"7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2\"></polygon>",
|
||||
"package": "<line x1=\"16.5\" y1=\"9.4\" x2=\"7.5\" y2=\"4.21\"></line><path d=\"M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z\"></path><polyline points=\"3.27 6.96 12 12.01 20.73 6.96\"></polyline><line x1=\"12\" y1=\"22.08\" x2=\"12\" y2=\"12\"></line>",
|
||||
"paperclip": "<path d=\"M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48\"></path>",
|
||||
"pause-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"10\" y1=\"15\" x2=\"10\" y2=\"9\"></line><line x1=\"14\" y1=\"15\" x2=\"14\" y2=\"9\"></line>",
|
||||
"pause": "<rect x=\"6\" y=\"4\" width=\"4\" height=\"16\"></rect><rect x=\"14\" y=\"4\" width=\"4\" height=\"16\"></rect>",
|
||||
"pen-tool": "<path d=\"M12 19l7-7 3 3-7 7-3-3z\"></path><path d=\"M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z\"></path><path d=\"M2 2l7.586 7.586\"></path><circle cx=\"11\" cy=\"11\" r=\"2\"></circle>",
|
||||
"percent": "<line x1=\"19\" y1=\"5\" x2=\"5\" y2=\"19\"></line><circle cx=\"6.5\" cy=\"6.5\" r=\"2.5\"></circle><circle cx=\"17.5\" cy=\"17.5\" r=\"2.5\"></circle>",
|
||||
"phone-call": "<path d=\"M15.05 5A5 5 0 0 1 19 8.95M15.05 1A9 9 0 0 1 23 8.94m-1 7.98v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
|
||||
"phone-forwarded": "<polyline points=\"19 1 23 5 19 9\"></polyline><line x1=\"15\" y1=\"5\" x2=\"23\" y2=\"5\"></line><path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
|
||||
"phone-incoming": "<polyline points=\"16 2 16 8 22 8\"></polyline><line x1=\"23\" y1=\"1\" x2=\"16\" y2=\"8\"></line><path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
|
||||
"phone-missed": "<line x1=\"23\" y1=\"1\" x2=\"17\" y2=\"7\"></line><line x1=\"17\" y1=\"1\" x2=\"23\" y2=\"7\"></line><path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
|
||||
"phone-off": "<path d=\"M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91\"></path><line x1=\"23\" y1=\"1\" x2=\"1\" y2=\"23\"></line>",
|
||||
"phone-outgoing": "<polyline points=\"23 7 23 1 17 1\"></polyline><line x1=\"16\" y1=\"8\" x2=\"23\" y2=\"1\"></line><path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
|
||||
"phone": "<path d=\"M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z\"></path>",
|
||||
"pie-chart": "<path d=\"M21.21 15.89A10 10 0 1 1 8 2.83\"></path><path d=\"M22 12A10 10 0 0 0 12 2v10z\"></path>",
|
||||
"play-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><polygon points=\"10 8 16 12 10 16 10 8\"></polygon>",
|
||||
"play": "<polygon points=\"5 3 19 12 5 21 5 3\"></polygon>",
|
||||
"plus-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"16\"></line><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
|
||||
"plus-square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"16\"></line><line x1=\"8\" y1=\"12\" x2=\"16\" y2=\"12\"></line>",
|
||||
"plus": "<line x1=\"12\" y1=\"5\" x2=\"12\" y2=\"19\"></line><line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line>",
|
||||
"pocket": "<path d=\"M4 3h16a2 2 0 0 1 2 2v6a10 10 0 0 1-10 10A10 10 0 0 1 2 11V5a2 2 0 0 1 2-2z\"></path><polyline points=\"8 10 12 14 16 10\"></polyline>",
|
||||
"power": "<path d=\"M18.36 6.64a9 9 0 1 1-12.73 0\"></path><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"12\"></line>",
|
||||
"printer": "<polyline points=\"6 9 6 2 18 2 18 9\"></polyline><path d=\"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2\"></path><rect x=\"6\" y=\"14\" width=\"12\" height=\"8\"></rect>",
|
||||
"radio": "<circle cx=\"12\" cy=\"12\" r=\"2\"></circle><path d=\"M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14\"></path>",
|
||||
"refresh-ccw": "<polyline points=\"1 4 1 10 7 10\"></polyline><polyline points=\"23 20 23 14 17 14\"></polyline><path d=\"M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15\"></path>",
|
||||
"refresh-cw": "<polyline points=\"23 4 23 10 17 10\"></polyline><polyline points=\"1 20 1 14 7 14\"></polyline><path d=\"M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15\"></path>",
|
||||
"repeat": "<polyline points=\"17 1 21 5 17 9\"></polyline><path d=\"M3 11V9a4 4 0 0 1 4-4h14\"></path><polyline points=\"7 23 3 19 7 15\"></polyline><path d=\"M21 13v2a4 4 0 0 1-4 4H3\"></path>",
|
||||
"rewind": "<polygon points=\"11 19 2 12 11 5 11 19\"></polygon><polygon points=\"22 19 13 12 22 5 22 19\"></polygon>",
|
||||
"rotate-ccw": "<polyline points=\"1 4 1 10 7 10\"></polyline><path d=\"M3.51 15a9 9 0 1 0 2.13-9.36L1 10\"></path>",
|
||||
"rotate-cw": "<polyline points=\"23 4 23 10 17 10\"></polyline><path d=\"M20.49 15a9 9 0 1 1-2.12-9.36L23 10\"></path>",
|
||||
"rss": "<path d=\"M4 11a9 9 0 0 1 9 9\"></path><path d=\"M4 4a16 16 0 0 1 16 16\"></path><circle cx=\"5\" cy=\"19\" r=\"1\"></circle>",
|
||||
"save": "<path d=\"M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z\"></path><polyline points=\"17 21 17 13 7 13 7 21\"></polyline><polyline points=\"7 3 7 8 15 8\"></polyline>",
|
||||
"scissors": "<circle cx=\"6\" cy=\"6\" r=\"3\"></circle><circle cx=\"6\" cy=\"18\" r=\"3\"></circle><line x1=\"20\" y1=\"4\" x2=\"8.12\" y2=\"15.88\"></line><line x1=\"14.47\" y1=\"14.48\" x2=\"20\" y2=\"20\"></line><line x1=\"8.12\" y1=\"8.12\" x2=\"12\" y2=\"12\"></line>",
|
||||
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"></circle><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line>",
|
||||
"send": "<line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\"></line><polygon points=\"22 2 15 22 11 13 2 9 22 2\"></polygon>",
|
||||
"server": "<rect x=\"2\" y=\"2\" width=\"20\" height=\"8\" rx=\"2\" ry=\"2\"></rect><rect x=\"2\" y=\"14\" width=\"20\" height=\"8\" rx=\"2\" ry=\"2\"></rect><line x1=\"6\" y1=\"6\" x2=\"6.01\" y2=\"6\"></line><line x1=\"6\" y1=\"18\" x2=\"6.01\" y2=\"18\"></line>",
|
||||
"settings": "<circle cx=\"12\" cy=\"12\" r=\"3\"></circle><path d=\"M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z\"></path>",
|
||||
"share-2": "<circle cx=\"18\" cy=\"5\" r=\"3\"></circle><circle cx=\"6\" cy=\"12\" r=\"3\"></circle><circle cx=\"18\" cy=\"19\" r=\"3\"></circle><line x1=\"8.59\" y1=\"13.51\" x2=\"15.42\" y2=\"17.49\"></line><line x1=\"15.41\" y1=\"6.51\" x2=\"8.59\" y2=\"10.49\"></line>",
|
||||
"share": "<path d=\"M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8\"></path><polyline points=\"16 6 12 2 8 6\"></polyline><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"15\"></line>",
|
||||
"shield-off": "<path d=\"M19.69 14a6.9 6.9 0 0 0 .31-2V5l-8-3-3.16 1.18\"></path><path d=\"M4.73 4.73L4 5v7c0 6 8 10 8 10a20.29 20.29 0 0 0 5.62-4.38\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
|
||||
"shield": "<path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"></path>",
|
||||
"shopping-bag": "<path d=\"M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z\"></path><line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"></line><path d=\"M16 10a4 4 0 0 1-8 0\"></path>",
|
||||
"shopping-cart": "<circle cx=\"9\" cy=\"21\" r=\"1\"></circle><circle cx=\"20\" cy=\"21\" r=\"1\"></circle><path d=\"M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6\"></path>",
|
||||
"shuffle": "<polyline points=\"16 3 21 3 21 8\"></polyline><line x1=\"4\" y1=\"20\" x2=\"21\" y2=\"3\"></line><polyline points=\"21 16 21 21 16 21\"></polyline><line x1=\"15\" y1=\"15\" x2=\"21\" y2=\"21\"></line><line x1=\"4\" y1=\"4\" x2=\"9\" y2=\"9\"></line>",
|
||||
"sidebar": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"9\" y1=\"3\" x2=\"9\" y2=\"21\"></line>",
|
||||
"skip-back": "<polygon points=\"19 20 9 12 19 4 19 20\"></polygon><line x1=\"5\" y1=\"19\" x2=\"5\" y2=\"5\"></line>",
|
||||
"skip-forward": "<polygon points=\"5 4 15 12 5 20 5 4\"></polygon><line x1=\"19\" y1=\"5\" x2=\"19\" y2=\"19\"></line>",
|
||||
"slack": "<path d=\"M14.5 10c-.83 0-1.5-.67-1.5-1.5v-5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5z\"></path><path d=\"M20.5 10H19V8.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z\"></path><path d=\"M9.5 14c.83 0 1.5.67 1.5 1.5v5c0 .83-.67 1.5-1.5 1.5S8 21.33 8 20.5v-5c0-.83.67-1.5 1.5-1.5z\"></path><path d=\"M3.5 14H5v1.5c0 .83-.67 1.5-1.5 1.5S2 16.33 2 15.5 2.67 14 3.5 14z\"></path><path d=\"M14 14.5c0-.83.67-1.5 1.5-1.5h5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-5c-.83 0-1.5-.67-1.5-1.5z\"></path><path d=\"M15.5 19H14v1.5c0 .83.67 1.5 1.5 1.5s1.5-.67 1.5-1.5-.67-1.5-1.5-1.5z\"></path><path d=\"M10 9.5C10 8.67 9.33 8 8.5 8h-5C2.67 8 2 8.67 2 9.5S2.67 11 3.5 11h5c.83 0 1.5-.67 1.5-1.5z\"></path><path d=\"M8.5 5H10V3.5C10 2.67 9.33 2 8.5 2S7 2.67 7 3.5 7.67 5 8.5 5z\"></path>",
|
||||
"slash": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"4.93\" y1=\"4.93\" x2=\"19.07\" y2=\"19.07\"></line>",
|
||||
"sliders": "<line x1=\"4\" y1=\"21\" x2=\"4\" y2=\"14\"></line><line x1=\"4\" y1=\"10\" x2=\"4\" y2=\"3\"></line><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"3\"></line><line x1=\"20\" y1=\"21\" x2=\"20\" y2=\"16\"></line><line x1=\"20\" y1=\"12\" x2=\"20\" y2=\"3\"></line><line x1=\"1\" y1=\"14\" x2=\"7\" y2=\"14\"></line><line x1=\"9\" y1=\"8\" x2=\"15\" y2=\"8\"></line><line x1=\"17\" y1=\"16\" x2=\"23\" y2=\"16\"></line>",
|
||||
"smartphone": "<rect x=\"5\" y=\"2\" width=\"14\" height=\"20\" rx=\"2\" ry=\"2\"></rect><line x1=\"12\" y1=\"18\" x2=\"12.01\" y2=\"18\"></line>",
|
||||
"smile": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><path d=\"M8 14s1.5 2 4 2 4-2 4-2\"></path><line x1=\"9\" y1=\"9\" x2=\"9.01\" y2=\"9\"></line><line x1=\"15\" y1=\"9\" x2=\"15.01\" y2=\"9\"></line>",
|
||||
"speaker": "<rect x=\"4\" y=\"2\" width=\"16\" height=\"20\" rx=\"2\" ry=\"2\"></rect><circle cx=\"12\" cy=\"14\" r=\"4\"></circle><line x1=\"12\" y1=\"6\" x2=\"12.01\" y2=\"6\"></line>",
|
||||
"square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect>",
|
||||
"star": "<polygon points=\"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2\"></polygon>",
|
||||
"stop-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><rect x=\"9\" y=\"9\" width=\"6\" height=\"6\"></rect>",
|
||||
"sun": "<circle cx=\"12\" cy=\"12\" r=\"5\"></circle><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"></line><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"></line><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"></line><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"></line><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"></line><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"></line><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"></line><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"></line>",
|
||||
"sunrise": "<path d=\"M17 18a5 5 0 0 0-10 0\"></path><line x1=\"12\" y1=\"2\" x2=\"12\" y2=\"9\"></line><line x1=\"4.22\" y1=\"10.22\" x2=\"5.64\" y2=\"11.64\"></line><line x1=\"1\" y1=\"18\" x2=\"3\" y2=\"18\"></line><line x1=\"21\" y1=\"18\" x2=\"23\" y2=\"18\"></line><line x1=\"18.36\" y1=\"11.64\" x2=\"19.78\" y2=\"10.22\"></line><line x1=\"23\" y1=\"22\" x2=\"1\" y2=\"22\"></line><polyline points=\"8 6 12 2 16 6\"></polyline>",
|
||||
"sunset": "<path d=\"M17 18a5 5 0 0 0-10 0\"></path><line x1=\"12\" y1=\"9\" x2=\"12\" y2=\"2\"></line><line x1=\"4.22\" y1=\"10.22\" x2=\"5.64\" y2=\"11.64\"></line><line x1=\"1\" y1=\"18\" x2=\"3\" y2=\"18\"></line><line x1=\"21\" y1=\"18\" x2=\"23\" y2=\"18\"></line><line x1=\"18.36\" y1=\"11.64\" x2=\"19.78\" y2=\"10.22\"></line><line x1=\"23\" y1=\"22\" x2=\"1\" y2=\"22\"></line><polyline points=\"16 5 12 9 8 5\"></polyline>",
|
||||
"table": "<path d=\"M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18\"></path>",
|
||||
"tablet": "<rect x=\"4\" y=\"2\" width=\"16\" height=\"20\" rx=\"2\" ry=\"2\"></rect><line x1=\"12\" y1=\"18\" x2=\"12.01\" y2=\"18\"></line>",
|
||||
"tag": "<path d=\"M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z\"></path><line x1=\"7\" y1=\"7\" x2=\"7.01\" y2=\"7\"></line>",
|
||||
"target": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><circle cx=\"12\" cy=\"12\" r=\"6\"></circle><circle cx=\"12\" cy=\"12\" r=\"2\"></circle>",
|
||||
"terminal": "<polyline points=\"4 17 10 11 4 5\"></polyline><line x1=\"12\" y1=\"19\" x2=\"20\" y2=\"19\"></line>",
|
||||
"thermometer": "<path d=\"M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z\"></path>",
|
||||
"thumbs-down": "<path d=\"M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17\"></path>",
|
||||
"thumbs-up": "<path d=\"M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3\"></path>",
|
||||
"toggle-left": "<rect x=\"1\" y=\"5\" width=\"22\" height=\"14\" rx=\"7\" ry=\"7\"></rect><circle cx=\"8\" cy=\"12\" r=\"3\"></circle>",
|
||||
"toggle-right": "<rect x=\"1\" y=\"5\" width=\"22\" height=\"14\" rx=\"7\" ry=\"7\"></rect><circle cx=\"16\" cy=\"12\" r=\"3\"></circle>",
|
||||
"tool": "<path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\"></path>",
|
||||
"trash-2": "<polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"></path><line x1=\"10\" y1=\"11\" x2=\"10\" y2=\"17\"></line><line x1=\"14\" y1=\"11\" x2=\"14\" y2=\"17\"></line>",
|
||||
"trash": "<polyline points=\"3 6 5 6 21 6\"></polyline><path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\"></path>",
|
||||
"trello": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><rect x=\"7\" y=\"7\" width=\"3\" height=\"9\"></rect><rect x=\"14\" y=\"7\" width=\"3\" height=\"5\"></rect>",
|
||||
"trending-down": "<polyline points=\"23 18 13.5 8.5 8.5 13.5 1 6\"></polyline><polyline points=\"17 18 23 18 23 12\"></polyline>",
|
||||
"trending-up": "<polyline points=\"23 6 13.5 15.5 8.5 10.5 1 18\"></polyline><polyline points=\"17 6 23 6 23 12\"></polyline>",
|
||||
"triangle": "<path d=\"M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z\"></path>",
|
||||
"truck": "<rect x=\"1\" y=\"3\" width=\"15\" height=\"13\"></rect><polygon points=\"16 8 20 8 23 11 23 16 16 16 16 8\"></polygon><circle cx=\"5.5\" cy=\"18.5\" r=\"2.5\"></circle><circle cx=\"18.5\" cy=\"18.5\" r=\"2.5\"></circle>",
|
||||
"tv": "<rect x=\"2\" y=\"7\" width=\"20\" height=\"15\" rx=\"2\" ry=\"2\"></rect><polyline points=\"17 2 12 7 7 2\"></polyline>",
|
||||
"twitch": "<path d=\"M21 2H3v16h5v4l4-4h5l4-4V2zm-10 9V7m5 4V7\"></path>",
|
||||
"twitter": "<path d=\"M23 3a10.9 10.9 0 0 1-3.14 1.53 4.48 4.48 0 0 0-7.86 3v1A10.66 10.66 0 0 1 3 4s-4 9 5 13a11.64 11.64 0 0 1-7 2c9 5 20 0 20-11.5a4.5 4.5 0 0 0-.08-.83A7.72 7.72 0 0 0 23 3z\"></path>",
|
||||
"type": "<polyline points=\"4 7 4 4 20 4 20 7\"></polyline><line x1=\"9\" y1=\"20\" x2=\"15\" y2=\"20\"></line><line x1=\"12\" y1=\"4\" x2=\"12\" y2=\"20\"></line>",
|
||||
"umbrella": "<path d=\"M23 12a11.05 11.05 0 0 0-22 0zm-5 7a3 3 0 0 1-6 0v-7\"></path>",
|
||||
"underline": "<path d=\"M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3\"></path><line x1=\"4\" y1=\"21\" x2=\"20\" y2=\"21\"></line>",
|
||||
"unlock": "<rect x=\"3\" y=\"11\" width=\"18\" height=\"11\" rx=\"2\" ry=\"2\"></rect><path d=\"M7 11V7a5 5 0 0 1 9.9-1\"></path>",
|
||||
"upload-cloud": "<polyline points=\"16 16 12 12 8 16\"></polyline><line x1=\"12\" y1=\"12\" x2=\"12\" y2=\"21\"></line><path d=\"M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3\"></path><polyline points=\"16 16 12 12 8 16\"></polyline>",
|
||||
"upload": "<path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><polyline points=\"17 8 12 3 7 8\"></polyline><line x1=\"12\" y1=\"3\" x2=\"12\" y2=\"15\"></line>",
|
||||
"user-check": "<path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><polyline points=\"17 11 19 13 23 9\"></polyline>",
|
||||
"user-minus": "<path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><line x1=\"23\" y1=\"11\" x2=\"17\" y2=\"11\"></line>",
|
||||
"user-plus": "<path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><line x1=\"20\" y1=\"8\" x2=\"20\" y2=\"14\"></line><line x1=\"23\" y1=\"11\" x2=\"17\" y2=\"11\"></line>",
|
||||
"user-x": "<path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><line x1=\"18\" y1=\"8\" x2=\"23\" y2=\"13\"></line><line x1=\"23\" y1=\"8\" x2=\"18\" y2=\"13\"></line>",
|
||||
"user": "<path d=\"M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2\"></path><circle cx=\"12\" cy=\"7\" r=\"4\"></circle>",
|
||||
"users": "<path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"9\" cy=\"7\" r=\"4\"></circle><path d=\"M23 21v-2a4 4 0 0 0-3-3.87\"></path><path d=\"M16 3.13a4 4 0 0 1 0 7.75\"></path>",
|
||||
"video-off": "<path d=\"M16 16v1a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h2m5.66 0H14a2 2 0 0 1 2 2v3.34l1 1L23 7v10\"></path><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
|
||||
"video": "<polygon points=\"23 7 16 12 23 17 23 7\"></polygon><rect x=\"1\" y=\"5\" width=\"15\" height=\"14\" rx=\"2\" ry=\"2\"></rect>",
|
||||
"voicemail": "<circle cx=\"5.5\" cy=\"11.5\" r=\"4.5\"></circle><circle cx=\"18.5\" cy=\"11.5\" r=\"4.5\"></circle><line x1=\"5.5\" y1=\"16\" x2=\"18.5\" y2=\"16\"></line>",
|
||||
"volume-1": "<polygon points=\"11 5 6 9 2 9 2 15 6 15 11 19 11 5\"></polygon><path d=\"M15.54 8.46a5 5 0 0 1 0 7.07\"></path>",
|
||||
"volume-2": "<polygon points=\"11 5 6 9 2 9 2 15 6 15 11 19 11 5\"></polygon><path d=\"M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07\"></path>",
|
||||
"volume-x": "<polygon points=\"11 5 6 9 2 9 2 15 6 15 11 19 11 5\"></polygon><line x1=\"23\" y1=\"9\" x2=\"17\" y2=\"15\"></line><line x1=\"17\" y1=\"9\" x2=\"23\" y2=\"15\"></line>",
|
||||
"volume": "<polygon points=\"11 5 6 9 2 9 2 15 6 15 11 19 11 5\"></polygon>",
|
||||
"watch": "<circle cx=\"12\" cy=\"12\" r=\"7\"></circle><polyline points=\"12 9 12 12 13.5 13.5\"></polyline><path d=\"M16.51 17.35l-.35 3.83a2 2 0 0 1-2 1.82H9.83a2 2 0 0 1-2-1.82l-.35-3.83m.01-10.7l.35-3.83A2 2 0 0 1 9.83 1h4.35a2 2 0 0 1 2 1.82l.35 3.83\"></path>",
|
||||
"wifi-off": "<line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line><path d=\"M16.72 11.06A10.94 10.94 0 0 1 19 12.55\"></path><path d=\"M5 12.55a10.94 10.94 0 0 1 5.17-2.39\"></path><path d=\"M10.71 5.05A16 16 0 0 1 22.58 9\"></path><path d=\"M1.42 9a15.91 15.91 0 0 1 4.7-2.88\"></path><path d=\"M8.53 16.11a6 6 0 0 1 6.95 0\"></path><line x1=\"12\" y1=\"20\" x2=\"12.01\" y2=\"20\"></line>",
|
||||
"wifi": "<path d=\"M5 12.55a11 11 0 0 1 14.08 0\"></path><path d=\"M1.42 9a16 16 0 0 1 21.16 0\"></path><path d=\"M8.53 16.11a6 6 0 0 1 6.95 0\"></path><line x1=\"12\" y1=\"20\" x2=\"12.01\" y2=\"20\"></line>",
|
||||
"wind": "<path d=\"M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2\"></path>",
|
||||
"x-circle": "<circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"15\" y1=\"9\" x2=\"9\" y2=\"15\"></line><line x1=\"9\" y1=\"9\" x2=\"15\" y2=\"15\"></line>",
|
||||
"x-octagon": "<polygon points=\"7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2\"></polygon><line x1=\"15\" y1=\"9\" x2=\"9\" y2=\"15\"></line><line x1=\"9\" y1=\"9\" x2=\"15\" y2=\"15\"></line>",
|
||||
"x-square": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"></rect><line x1=\"9\" y1=\"9\" x2=\"15\" y2=\"15\"></line><line x1=\"15\" y1=\"9\" x2=\"9\" y2=\"15\"></line>",
|
||||
"x": "<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"></line><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"></line>",
|
||||
"youtube": "<path d=\"M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z\"></path><polygon points=\"9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02\"></polygon>",
|
||||
"zap-off": "<polyline points=\"12.41 6.75 13 2 10.57 4.92\"></polyline><polyline points=\"18.57 12.91 21 10 15.66 10\"></polyline><polyline points=\"8 8 3 14 12 14 11 22 16 16\"></polyline><line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\"></line>",
|
||||
"zap": "<polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"></polygon>",
|
||||
"zoom-in": "<circle cx=\"11\" cy=\"11\" r=\"8\"></circle><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line><line x1=\"11\" y1=\"8\" x2=\"11\" y2=\"14\"></line><line x1=\"8\" y1=\"11\" x2=\"14\" y2=\"11\"></line>",
|
||||
"zoom-out": "<circle cx=\"11\" cy=\"11\" r=\"8\"></circle><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line><line x1=\"8\" y1=\"11\" x2=\"14\" y2=\"11\"></line>"
|
||||
}
|
24
vendor/feather-icons/license
vendored
Normal file
24
vendor/feather-icons/license
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
https://github.com/feathericons/feather/blob/master/LICENSE
|
||||
---
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2017 Cole Bemis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
6
vendor/feather-icons/readme.md
vendored
Normal file
6
vendor/feather-icons/readme.md
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
|
||||
<https://github.com/feathericons/feather>
|
||||
|
||||
`icons.json` here is sourced from `dist/icons.json` from the bundle, version 4.29.0.
|
||||
|
||||
This is intentionally not installed from `npm install feather-icons` because that package includes all of `core-js` as a dependency (which this project gets zero benefit from and is very large, impacting container image size).
|
Loading…
x
Reference in New Issue
Block a user