More work on default theme and components

This commit is contained in:
Alice Gaudon 2021-11-08 00:24:53 +01:00
parent f1ae6f6a7b
commit 4a8a1f2da8
25 changed files with 1665 additions and 62 deletions

View File

@ -36,6 +36,7 @@
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.6",
"@types/express-session": "^1.17.0",
"@types/feather-icons": "^4.7.0",
"@types/formidable": "^1.0.31",
"@types/geoip-lite": "^1.1.31",
"@types/jest": "^26.0.4",
@ -61,6 +62,7 @@
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-svelte3": "^3.1.2",
"feather-icons": "^4.28.0",
"jest": "^26.1.0",
"jest-resolve": "^26.6.2",
"jest-ts-webcompat-resolver": "^1.0.0",

View File

@ -10,6 +10,7 @@ import MagicLinkAuthMethod from "./auth/magic_link/MagicLinkAuthMethod.js";
import MagicLinkController from "./auth/magic_link/MagicLinkController.js";
import MagicLinkWebSocketListener from "./auth/magic_link/MagicLinkWebSocketListener.js";
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagicLinksSessionNotUniqueMigration.js";
import AddApprovedFieldToUsersTableMigration from "./auth/migrations/AddApprovedFieldToUsersTableMigration.js";
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration.js";
import AddNameToUsersMigration from "./auth/migrations/AddNameToUsersMigration.js";
import CreateUsersAndUserEmailsTableMigration from "./auth/migrations/CreateUsersAndUserEmailsTableMigration.js";
@ -64,7 +65,7 @@ export default class TestApp extends Application {
}
protected getMigrations(): MigrationType<Migration>[] {
return MIGRATIONS;
return [...MIGRATIONS, AddApprovedFieldToUsersTableMigration];
}
protected async init(): Promise<void> {
@ -140,6 +141,14 @@ export default class TestApp extends Application {
this.get('/tests', (req, res) => {
res.render('tests');
}, 'tests');
this.get('/design', (req, res) => {
req.flash('success', 'Success.');
req.flash('info', 'Info.');
req.flash('warning', 'Warning.');
req.flash('error', 'Error.');
req.flash('error-alert', 'Error alert.');
res.render('design');
}, 'design');
}
}());
}

View File

@ -0,0 +1,81 @@
/* vietnamese */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('Nunito Sans Light'), local('NunitoSans-Light'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8WAc5iU1EQVg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('Nunito Sans Light'), local('NunitoSans-Light'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8WAc5jU1EQVg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: local('Nunito Sans Light'), local('NunitoSans-Light'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8WAc5tU1E.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Nunito Sans Regular'), local('NunitoSans-Regular'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe0qMImSLYBIv1o4X1M8cceyI9tScg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Nunito Sans Regular'), local('NunitoSans-Regular'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe0qMImSLYBIv1o4X1M8ccezI9tScg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local('Nunito Sans Regular'), local('NunitoSans-Regular'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe0qMImSLYBIv1o4X1M8cce9I9s.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Nunito Sans Bold'), local('NunitoSans-Bold'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8GBs5iU1EQVg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Nunito Sans Bold'), local('NunitoSans-Bold'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8GBs5jU1EQVg.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: local('Nunito Sans Bold'), local('NunitoSans-Bold'), url(https://fonts.gstatic.com/s/nunitosans/v5/pe03MImSLYBIv1o4X1M8cc8GBs5tU1E.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@ -0,0 +1,129 @@
@import "vars";
@mixin darkMode() {
@media (prefers-color-scheme: dark) {
@content;
}
}
@mixin surface($shadowStrength: 0) {
background-color: var(--surface);
color: var(--on-surface);
a {
color: var(--primary-on-surface);
&:hover {
color: var(--primary-light-on-surface);
}
}
:global(a) {
color: var(--primary-on-surface);
&:hover {
color: var(--primary-light-on-surface);
}
}
// States modifiers
.primary:not(.bold) {
--color: var(--primary-on-surface);
--background-color: var(--surface);
}
.info:not(.bold) {
--color: var(--info-on-surface);
--background-color: var(--surface);
}
.success:not(.bold) {
--color: var(--success-on-surface);
--background-color: var(--surface);
}
.warning:not(.bold) {
--color: var(--warning-on-surface);
--background-color: var(--surface);
}
.error:not(.bold), .danger:not(.bold) {
--color: var(--error-on-surface);
--background-color: var(--surface);
}
// Buttons
button {
--background-color: var(--surface);
}
button:hover::after {
background-color: var(--on-surface);
}
:global(button:hover::after) {
background-color: var(--on-surface);
}
@if ($shadowStrength > 0) {
box-shadow: 0 #{$shadowStrength}px #{$shadowStrength}px #00000045;
}
}
// --- Responsivity ---
@mixin mobile-le {
@media (max-width: $mobileThreshold - 1px) {
@content;
}
}
@mixin mobile-ge {
@content;
}
@mixin medium-le {
@media (max-width: $desktopThreshold - 1px) {
@content;
}
}
@mixin medium-ge {
@media (min-width: $mobileThreshold) {
@content;
}
}
@mixin large-le {
@content;
}
@mixin large-ge {
@media (min-width: $desktopThreshold) {
@content;
}
}
@mixin container {
width: 100%;
max-width: 100%;
padding-left: 8px;
padding-right: 8px;
@include medium-ge {
margin-left: auto;
margin-right: auto;
padding-left: 16px;
padding-right: 16px;
}
@include large-ge {
width: $desktopThreshold;
}
}
@mixin fake-hide {
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
}

View File

@ -1 +1,110 @@
$primary: #ff0080;
//
// --- Color palette ---
//
$onLight: #222;
$onDark: #eee;
// Primary
$primary: #0046af;
$primaryLight: lighten($primary, 10%);
$onPrimary: $onDark;
$primaryOnBackground: $primary;
$primaryLightOnBackground: $primaryLight;
$primaryOnSurface: $primary;
$primaryLightOnSurface: $primaryLight;
$primaryDarkMode: #0054c9;
$primaryLightDarkMode: lighten($primaryDarkMode, 23%);
$onPrimaryDarkMode: $onDark;
$primaryOnBackgroundDarkMode: lighten($primaryDarkMode, 20%);
$primaryLightOnBackgroundDarkMode: $primaryLightDarkMode;
$primaryOnSurfaceDarkMode: lighten($primaryDarkMode, 29%);
$primaryLightOnSurfaceDarkMode: lighten($primaryOnSurfaceDarkMode, 15%);
// Secondary
$secondary: #f21170;
$onSecondary: $onLight;
$secondaryDarkMode: $secondary;
$onSecondaryDarkMode: $onSecondary;
// Background
$backgroundBase: #eee;
$background: mix($backgroundBase, $primary, 98%);
$onBackground: $onLight;
$backgroundBaseDarkMode: #222;
$backgroundDarkMode: mix($backgroundBaseDarkMode, $primaryDarkMode, 98%);
$onBackgroundDarkMode: $onDark;
// Surface
$surface: lighten($background, 6%);
$onSurface: $onLight;
$surfaceDarkMode: darken($backgroundDarkMode, 4.5%);
$onSurfaceDarkMode: $onDark;
// Input
$input: darken($surface, 5%);
$onInput: $onLight;
$inputDarkMode: darken($surfaceDarkMode, 5%);
$onInputDarkMode: $onDark;
//
// --- Layout ---
//
$header: $surface;
$headerDarkMode: $surfaceDarkMode;
$headerContainer: true;
$headerHeight: 72px;
$footer: transparent;
//
// --- State palette ---
//
$info: #4499ff;
$onInfo: darken($info, 50%);
$infoOnBackground: darken($info, 20%);
$infoOnSurface: darken($info, 20%);
$infoDarkMode: darken($info, 40%);
$onInfoDarkMode: lighten($info, 20%);
$infoOnBackgroundDarkMode: $info;
$infoOnSurfaceDarkMode: $info;
$success: #55ff55;
$onSuccess: darken($success, 45%);
$successOnBackground: darken($success, 45%);
$successOnSurface: darken($success, 45%);
$successDarkMode: darken($success, 45%);
$onSuccessDarkMode: lighten($success, 20%);
$successOnBackgroundDarkMode: $success;
$successOnSurfaceDarkMode: $success;
$warning: #ffcc00;
$onWarning: darken($warning, 30%);
$warningOnBackground: darken($warning, 25%);
$warningOnSurface: darken($warning, 25%);
$warningDarkMode: darken($warning, 30%);
$onWarningDarkMode: lighten($warning, 20%);
$warningOnBackgroundDarkMode: $warning;
$warningOnSurfaceDarkMode: $warning;
$error: #ff0000;
$onError: darken($error, 40%);
$errorOnBackground: darken($error, 10%);
$errorOnSurface: darken($error, 10%);
$errorDarkMode: darken($error, 30%);
$onErrorDarkMode: lighten($error, 20%);
$errorOnBackgroundDarkMode: lighten($error, 15%);
$errorOnSurfaceDarkMode: lighten($error, 3%);
//
// --- Responsivity ---
//
$mobileThreshold: 632px;
$desktopThreshold: 940px;

View File

@ -1,9 +1,255 @@
@import "vars";
@import "helpers";
@import "fonts";
@import "../../../node_modules/normalize.css/normalize";
// --- Css variables, dark mode ---
:root {
// Primary
--primary: #{$primary};
--primary-light: #{$primaryLight};
--on-primary: #{$onPrimary};
--primary-on-background: #{$primaryOnBackground};
--primary-light-on-background: #{$primaryLightOnBackground};
--primary-on-surface: #{$primaryOnSurface};
--primary-light-on-surface: #{$primaryLightOnSurface};
// Secondary
--secondary: #{$secondary};
--on-secondary: #{$onSecondary};
// Background
--background: #{$background};
--on-background: #{$onBackground};
// Surface
--surface: #{$surface};
--on-surface: #{$onSurface};
// Input
--input: #{$input};
--on-input: #{$onInput};
// States
--info: #{$info};
--success: #{$success};
--warning: #{$warning};
--error: #{$error};
// States text
--on-info: #{$onInfo};
--on-success: #{$onSuccess};
--on-warning: #{$onWarning};
--on-error: #{$onError};
// States text on background
--info-on-background: #{$infoOnBackground};
--success-on-background: #{$successOnBackground};
--warning-on-background: #{$warningOnBackground};
--error-on-background: #{$errorOnBackground};
// States text on surface
--info-on-surface: #{$infoOnSurface};
--success-on-surface: #{$successOnSurface};
--warning-on-surface: #{$warningOnSurface};
--error-on-surface: #{$errorOnSurface};
@include darkMode {
// Primary
--primary: #{$primaryDarkMode};
--primary-light: #{$primaryLightDarkMode};
--on-primary: #{$onPrimaryDarkMode};
--primary-on-background: #{$primaryOnBackgroundDarkMode};
--primary-light-on-background: #{$primaryLightOnBackgroundDarkMode};
--primary-on-surface: #{$primaryOnSurfaceDarkMode};
--primary-light-on-surface: #{$primaryLightOnSurfaceDarkMode};
// Secondary
--secondary: #{$secondaryDarkMode};
--on-secondary: #{$onSecondaryDarkMode};
// Background
--background: #{$backgroundDarkMode};
--on-background: #{$onBackgroundDarkMode};
// Surface
--surface: #{$surfaceDarkMode};
--on-surface: #{$onSurfaceDarkMode};
// Input
--input: #{$inputDarkMode};
--on-input: #{$onInputDarkMode};
// States
--info: #{$infoDarkMode};
--success: #{$successDarkMode};
--warning: #{$warningDarkMode};
--error: #{$errorDarkMode};
// States text
--on-info: #{$onInfoDarkMode};
--on-success: #{$onSuccessDarkMode};
--on-warning: #{$onWarningDarkMode};
--on-error: #{$onErrorDarkMode};
// States text on background
--info-on-background: #{$infoOnBackgroundDarkMode};
--success-on-background: #{$successOnBackgroundDarkMode};
--warning-on-background: #{$warningOnBackgroundDarkMode};
--error-on-background: #{$errorOnBackgroundDarkMode};
// States text on surface
--info-on-surface: #{$infoOnSurfaceDarkMode};
--success-on-surface: #{$successOnSurfaceDarkMode};
--warning-on-surface: #{$warningOnSurfaceDarkMode};
--error-on-surface: #{$errorOnSurfaceDarkMode};
}
--color: var(--on-background);
--background-color: var(--background);
}
:focus-visible,
button:focus-visible,
[type="button"]:focus-visible,
[type="reset"]:focus-visible,
[type="submit"]:focus-visible {
outline: 3px solid var(--primary-light);
outline-offset: 2px;
}
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
color: var(--color);
background-color: var(--background-color);
a {
color: var(--primary-on-background);
&:hover {
color: var(--primary-light-on-background);
}
}
}
h1 {
text-align: center;
}
a {
text-decoration: none;
.feather.feather-external-link { //todo add js
--icon-size: 16px;
margin-left: 4px;
margin-top: -3px;
}
}
ul {
list-style-type: '- ';
}
.primary, .bold {
--color: var(--primary-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-primary);
--background-color: var(--primary);
}
}
.info {
--color: var(--info-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-info);
--background-color: var(--info);
}
}
.success {
--color: var(--success-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-success);
--background-color: var(--success);
}
}
.warning {
--color: var(--warning-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-warning);
--background-color: var(--warning);
}
}
.error, .danger {
--color: var(--error-on-background);
--background-color: var(--background);
&.bold {
--color: var(--on-error);
--background-color: var(--error);
}
}
button, .button {
position: relative;
display: inline-flex;
margin: 8px;
padding: 12px 16px;
border: 1px solid var(--color);
color: var(--color);
background-color: var(--background-color);
cursor: pointer;
text-transform: uppercase;
font-size: 16px;
line-height: 16px;
font-weight: bolder;
border-radius: 5px;
overflow: hidden;
&.bold {
border: 0;
}
.feather {
--icon-size: 16px;
margin-right: 8px;
}
.feather.last {
margin-right: 0;
margin-left: 8px;
}
&:hover::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--on-background);
opacity: 0.2;
}
}

View File

@ -1 +1,26 @@
@import "vars";
@import "helpers";
@import "base";
@import "panel";
html {
height: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100%;
margin: 0;
font-family: "Nunito Sans", sans-serif;
font-size: 16px;
background-color: var(--background);
color: var(--on-background);
> * {
flex-shrink: 0;
}
}

View File

@ -0,0 +1,50 @@
@import "vars";
@import "helpers";
.panel {
position: relative;
margin: 16px 0 48px;
padding: 8px;
border-radius: 5px;
@include surface;
p {
margin: 16px 8px;
}
> .feather:first-child {
--icon-size: 24px;
position: absolute;
opacity: 0.2;
top: 8px;
left: 8px;
}
> h1, > h2, > h3, > h4, > h5, > h6 {
display: flex;
flex-direction: row;
align-items: center;
position: relative;
text-align: center;
margin-top: 4px;
font-size: 24px;
line-height: 1;
.feather {
--icon-size: 24px;
margin: 0 16px 0 0;
opacity: 0.2;
}
&::after {
content: "";
flex: 1;
margin: 0 16px;
height: 0;
border-bottom: 1px solid var(--on-surface);
opacity: 0.2;
}
}
}

46
src/assets/scss/tip.scss Normal file
View File

@ -0,0 +1,46 @@
@import "vars";
@mixin tip {
position: relative;
.tip {
visibility: hidden;
position: absolute;
z-index: 10000;
pointer-events: none;
display: block;
width: max-content;
height: 30px;
padding: 4px 8px;
line-height: 22px;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
text-align: center;
font-size: 18px;
color: var(--on-surface);
opacity: 0;
transition: opacity ease-out 100ms, visibility step-end 150ms;
transition-delay: 0ms;
background-color: var(--surface);
border-radius: 5px;
text-transform: initial;
font-weight: initial;
&.top {
top: auto;
bottom: calc(100% + 8px);
}
}
&:hover, &:active {
.tip {
visibility: visible;
opacity: 1;
transition: opacity ease-out 100ms;
transition-delay: 150ms;
}
}
}

View File

@ -0,0 +1,10 @@
import feather from "feather-icons";
let alreadyReplaced = false;
export function replaceIcons(once: boolean = true) {
if (!once || !alreadyReplaced) {
alreadyReplaced = true;
feather.replace();
}
}

View File

@ -0,0 +1,49 @@
<script>
import Icon from "./utils/Icon.svelte";
</script>
<p style="display: flex; flex-direction: column; align-items: start">
<button>Default button</button>
<button class="bold">Default bold button</button>
<button class="primary">
<Icon name="square"/>
Primary button
</button>
<button class="primary bold">
<Icon name="square"/>
Primary bold button
</button>
<button class="success">
<Icon name="check"/>
Success button
</button>
<button class="success bold">
<Icon name="check"/>
Success bold button
</button>
<button class="info">
<Icon name="info"/>
Info button
</button>
<button class="info bold">
<Icon name="info"/>
Info bold button
</button>
<button class="warning">
<Icon name="alert-triangle"/>
Warning button
</button>
<button class="warning bold">
<Icon name="alert-triangle"/>
Warning bold button
</button>
<button class="error">
<Icon name="x-circle"/>
Error button
</button>
<button class="error bold">
<Icon name="x-circle"/>
Error bold button
</button>
</p>

View File

@ -3,9 +3,14 @@
import Message from "./Message.svelte";
export let flashed = $locals.flash();
console.log('Flashed:', flashed)
</script>
<style lang="scss">
.messages :global(.message:not(:last-child)) {
margin-bottom: 8px;
}
</style>
<div class="messages">
{#if flashed}
{#each Object.entries(flashed) as [key, bag], i}

View File

@ -1,12 +1,112 @@
<script>
import Icon from "../utils/Icon.svelte";
export let type;
export let content;
export let raw = false;
export let discreet = false;
export let sticky = false;
let icon = undefined;
switch (type) {
case 'success':
icon = 'check';
break;
case 'info':
icon = 'info';
break;
case 'warning':
icon = 'alert-triangle';
break;
case 'error':
icon = 'x-circle';
break;
case 'error-alert':
icon = 'alert-circle';
break;
}
let message;
function hide() {
message.remove();
}
</script>
<div class="message" class:message-discreet={discreet} data-type="{type}">
<i class="icon"></i>
<style lang="scss">
@import "../../scss/vars";
.message {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 16px;
border-radius: 5px;
:global(.feather) {
--icon-size: 24px;
margin-right: 8px;
}
&:not(&-discreet) {
background-color: rgba(255, 255, 255, 0.33);
&[data-type=info], &[data-type=question] {
color: var(--on-info);
background-color: var(--info);
}
&[data-type=success] {
color: var(--on-success);
background-color: var(--success);
}
&[data-type=warning] {
color: var(--on-warning);
background-color: var(--warning);
}
&[data-type=error], &[data-type=error-alert] {
color: var(--on-error);
background-color: var(--error);
}
}
&-discreet {
color: var(--on-surface);
.feather {
--icon-size: 20px;
}
}
.content {
flex-grow: 1;
}
button {
background-color: transparent;
color: inherit;
margin: 0;
padding: 0;
border: 0;
:global(.feather) {
margin: 0;
}
}
}
</style>
<div class="message" class:message-discreet={discreet} data-type="{type}" bind:this={message}>
{#if icon}
<Icon name={icon}/>
{/if}
<span class="content">
{#if raw}
{@html content}
@ -14,4 +114,10 @@
{content}
{/if}
</span>
{#if !sticky}
<button type="button" on:click={hide}>
<Icon name="x"/>
</button>
{/if}
</div>

View File

@ -0,0 +1,123 @@
<script>
import {onMount} from "svelte";
import Icon from "../utils/Icon.svelte";
let open = false;
let locked = false;
function stopPropagation(e) {
e.stopPropagation();
}
function openMenu() {
if (locked) return;
open = true;
}
function closeMenu() {
if (locked) return;
open = false;
}
function lock() {
locked = true;
window.requestAnimationFrame(() => {
locked = false;
});
}
let nav;
onMount(() => {
nav.querySelectorAll('ul li > a, ul li > form > button')
.forEach(el => {
el.addEventListener('focus', () => {
openMenu();
});
el.addEventListener('blur', () => {
closeMenu();
});
});
});
</script>
<style lang="scss">
@import "../../scss/vars";
@import "../../scss/helpers";
nav {
top: 0;
left: 0;
height: 100%;
padding: 16px;
font-size: 16px;
@include medium-le {
z-index: 1;
position: fixed;
padding: 16px;
@include surface(3);
transition: transform ease-out 150ms;
&:not(.open) {
transform: translateX(-100%);
}
}
ul {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
list-style: none;
align-items: center;
@include large-ge {
display: flex;
flex-direction: row;
}
}
}
button {
width: #{$headerHeight - 16px};
height: #{$headerHeight - 16px};
margin: 8px;
padding: 8px;
border: 0;
justify-content: center;
align-items: center;
background: var(--surface);
color: var(--on-surface);
border-radius: $headerHeight;
:global(.feather) {
--icon-size: 28px;
margin: 0;
}
@include large-ge {
display: none;
}
}
</style>
<svelte:window on:click={closeMenu}/>
<button on:click={openMenu} on:click={stopPropagation}
on:focus={openMenu} on:blur={closeMenu}
tabindex="0" aria-label="Toggle menu">
<Icon name="menu"/>
</button>
<nav class:open on:click={openMenu} on:click={stopPropagation} on:mousedown={lock} bind:this={nav}
aria-hidden={open ? 'false' : 'true'}>
<ul>
<slot/>
</ul>
</nav>

View File

@ -1,5 +1,6 @@
<script>
import CsrfTokenField from "../utils/CsrfTokenField.svelte";
import Icon from "../utils/Icon.svelte";
import Form from "../utils/Form.svelte";
export let href;
export let icon;
@ -7,17 +8,69 @@
export let action = false;
</script>
<style lang="scss">
@import "../../scss/helpers";
li {
display: flex;
flex-direction: row;
align-items: center;
line-height: 1;
@include medium-le {
margin-top: 8px;
}
@include large-ge {
margin-left: 8px;
}
a {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: auto;
padding: 8px;
border-radius: 3px;
&:hover {
background-color: rgba(0, 0, 0, 0.07);
@include darkMode {
background-color: rgba(255, 255, 255, 0.07);
}
}
text-transform: uppercase;
}
:global(form) {
width: 100%;
:global(button) {
display: flex;
width: 100%;
margin: 0;
padding: 8px;
}
}
:global(.feather) {
--icon-size: 16px;
margin-right: 8px;
}
}
</style>
<li>
{#if action}
<form action={href} method="POST">
<button><i data-feather={icon}></i> <span class="tip">{text}</span></button>
<CsrfTokenField/>
</form>
<Form action={href} submitIcon={icon} submitText={text}/>
{:else}
<a href={href}>
<i data-feather={icon}></i> <span class="tip">{text}</span>
</a>
<Icon name={icon}/>
<span class="tip">{text}</span></a>
{/if}
</li>

View File

@ -1,11 +1,14 @@
<script lang="ts">
import {route} from "../../../common/Routing.js";
import {Pagination} from "../../../common/Pagination.js";
import Icon from "../utils/Icon.svelte";
export let pagination: string;
export let routeName: string;
export let contextSize: number;
if (typeof contextSize !== 'number') contextSize = parseInt(contextSize);
$: paginationObj = pagination ? Pagination.deserialize(pagination) : null;
</script>
@ -14,7 +17,7 @@
<ul>
{#if paginationObj.hasPrevious()}
<li><a href={route(routeName, {page: paginationObj.page - 1})}>
<i data-feather="chevron-left"></i> Previous
<Icon name="chevron-left"/> Previous
</a></li>
{#each paginationObj.previousPages(contextSize) as i}
@ -38,7 +41,7 @@
{/each}
<li><a href={route(routeName, {page: paginationObj.page + 1})}>
Next <i data-feather="chevron-right"></i>
Next <Icon name="chevron-right"/>
</a></li>
{/if}
</ul>

View File

@ -0,0 +1,119 @@
<script>
import {route} from "../../common/Routing";
import BaseLayout from "./layouts/BaseLayout.svelte";
import Message from "./components/Message.svelte";
import Form from "./utils/Form.svelte";
import Field from "./utils/Field.svelte";
import Pagination from "./components/Pagination.svelte";
import {Pagination as PaginationData} from "../../common/Pagination.js";
import Breadcrumb from "./components/Breadcrumb.svelte";
import Icon from "./utils/Icon.svelte";
import DesignButtons from "./DesignButtons.svelte";
const paginationData = new PaginationData(20, 20, 1000).serialize();
</script>
<BaseLayout title="Design test">
<DesignButtons/>
<div class="panel">
<h2>
<Icon name="git-commit"/>
Common elements
</h2>
<p>Paragraph</p>
<p>Paragraph, <a href={route('home')}>Link</a></p>
<ul>
<li>Unordered</li>
<li>List</li>
</ul>
<ol>
<li>Ordered</li>
<li>List</li>
</ol>
<DesignButtons/>
</div>
<div class="panel">
<h2>
<Icon name="eye-off"/>
Discreet messages
</h2>
<Message type="success" content="Discreet success." discreet/>
<Message type="info" content="Discreet info." discreet/>
<Message type="warning" content="Discreet warning." discreet/>
<Message type="error" content="Discreet sticky error." discreet sticky/>
<Message type="error-alert" content="Discreet error-alert." discreet/>
</div>
<div class="panel">
<h2>
<Icon name="file-text"/>
Forms
</h2>
<Form action="javascript: void(0)"
submitText="Submit" submitIcon="check"
confirm="Are you sure?">
<Field type="text" name="text" placeholder="Text field" icon="type"
hint="This is a hint" validation={{message: 'This is a validation error'}}/>
<Field type="password" name="password" placeholder="Password field" icon="key"
hint="This is a hint" validation={{message: 'This is a validation error'}}/>
<Field type="duration" name="duration" placeholder="Duration field" icon="clock" extraData={[
'h',
'm',
's',
]} hint="This is a hint" validation={{message: 'This is a validation error'}}/>
<Field type="select" name="select" placeholder="Select field" icon="list" extraData={[
'Option 1',
'Option 2',
'Option 3',
]} hint="This is a hint" validation={{message: 'This is a validation error'}}/>
<Field type="textarea" name="textarea" placeholder="Textarea field" icon="file-text"
hint="This is a hint" validation={{message: 'This is a validation error'}}/>
<Field type="checkbox" name="checkbox" placeholder="Checkbox field" icon="check-square"
hint="This is a hint" validation={{message: 'This is a validation error'}}/>
<Field type="color" name="color" placeholder="Color field" icon="aperture"
hint="This is a hint" validation={{message: 'This is a validation error'}}/>
<Field type="file" name="file" placeholder="Choose a file" icon="upload"
hint="This is a hint" validation={{message: 'This is a validation error'}}/>
<Field type="file" name="files" placeholder="Choose files" icon="upload" multiple
hint="This is a hint" validation={{message: 'This is a validation error'}}/>
<Field type="text" name="disabled_text" placeholder="Disabled text field" icon="type" disabled
hint="This is a hint" validation={{message: 'This is a validation error'}}/>
</Form>
</div>
<div class="panel">
<h2>
<Icon name="file"/>
Pagination
</h2>
<Pagination routeName="home" contextSize="3" pagination={paginationData}/>
</div>
<div class="panel">
<h2>
<Icon name="map-pin"/>
Breadcrumb
</h2>
<Breadcrumb currentPageTitle="Design test" pages={[
{link: route('home'), title: 'Home'},
{link: route('home'), title: 'Home again'},
{link: route('design'), title: 'self'},
]}/>
</div>
</BaseLayout>

View File

@ -4,13 +4,15 @@
import BaseLayout from "./layouts/BaseLayout.svelte";
</script>
<BaseLayout title="{$locals.app.name}">
<BaseLayout title="{$locals.app.name}" h1={false}>
<div class="panel">
<h1>swaf - Svelte Web Application Framework</h1>
<p>Welcome to {$locals.app.name}!</p>
<nav>
<ul>
<li><a href={route('tests')}>Frontend tests</a></li>
<li><a href={route('design')}>Design test</a></li>
</ul>
</nav>
</div>

View File

@ -3,6 +3,7 @@
import {route} from "../../../common/Routing.js";
import FlashMessages from "../components/FlashMessages.svelte";
import NavMenuItem from "../components/NavMenuItem.svelte";
import NavMenu from "../components/NavMenu.svelte";
export let title: string;
export let h1: string = title;
@ -11,13 +12,63 @@
</script>
<style lang="scss">
@import "../../scss/vars";
@import "../../scss/helpers";
header {
text-align: center;
@if $headerContainer {
@include container;
}
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 0;
height: $headerHeight;
@include medium-le {
z-index: 1;
position: sticky;
top: 0;
flex-direction: row-reverse;
@include surface(3);
}
.logo {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 8px;
font-size: 24px;
img {
flex-shrink: 0;
width: initial;
height: calc(#{$headerHeight} - 16px);
margin-right: 8px;
padding: 8px;
}
}
}
main {
@include container;
flex-grow: 1;
}
footer {
padding: 8px;
text-align: center;
}
.flash-messages {
@include container;
}
</style>
<svelte:head>
@ -41,25 +92,22 @@
</svelte:head>
<header>
<a href="/" class="logo"><img src="/img/logo.svg" alt="Logo"> {$locals.app.name}</a>
<nav>
<button id="menu-button"><i data-feather="menu"></i></button>
<ul id="main-menu">
{#if $locals.user}
{#if $locals.user.is_admin}
<NavMenuItem href={route('backend')} icon="settings" text="Backend"/>
{/if}
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'}/>
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
{:else}
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
<a href="/" class="logo"><img src="/img/logo.svg" alt="{$locals.app.name} logo"> {$locals.app.name}</a>
<NavMenu>
{#if $locals.user}
{#if $locals.user.is_admin}
<NavMenuItem href={route('backend')} icon="settings" text="Backend"/>
{/if}
</ul>
</nav>
<NavMenuItem href={route('account')} icon="user" text={$locals.user.name || 'Account'}/>
<NavMenuItem href={route('logout')} icon="log-out" text="Logout" action/>
{:else}
<NavMenuItem href={route('auth')} icon="log-in" text="Log in / Register"/>
{/if}
</NavMenu>
</header>
<div class="container">
<div class="flash-messages">
<FlashMessages/>
</div>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import {locals} from '../../ts/stores.js';
import {FileSize} from "../../../common/FileSize.js";
import Message from "../components/Message.svelte";
import Icon from "./Icon.svelte";
import {getContext} from "svelte";
@ -12,11 +13,11 @@
export let hint: string | undefined = undefined;
export let extraData: string[] | undefined = undefined;
export let icon: string | undefined = undefined;
export let validation: { message: string, value?: string } | undefined = $locals.validation()?.[name];
const formId = getContext('formId');
const fieldId = `${formId}-${name}-field`;
const validation = $locals.validation()?.[name];
const previousFormData = $locals.previousFormData() || [];
value = type !== 'hidden' && previousFormData[name] || value || validation?.value || '';
@ -38,63 +39,384 @@
}
}
function handleInput(e) {
function focusInput() {
if (input) {
if (['file', 'checkbox', 'color'].indexOf(input.type) >= 0) {
input.click();
} else {
input.focus();
}
} else {
this.querySelector('input')?.focus();
}
}
function handleInput() {
// in here, you can switch on type and implement
// whatever behaviour you need
value = type.match(/^(number|range)$/)
? +e.target.value
: e.target.value;
? +this.value
: this.value;
if (this.type === 'file') {
handleFileInput();
}
}
let input: HTMLInputElement;
function chooseFile() {
input.click();
}
let files: FileList | undefined;
function handleFileInput() {
files = input.files;
}
</script>
<style lang="scss">
@import "../../scss/helpers";
.form-field:not(.hidden) {
display: flex;
flex-direction: column;
margin: 16px auto;
.control {
position: relative;
display: flex;
align-items: start;
flex-direction: row;
color: var(--on-input);
background-color: var(--input);
border-radius: 5px;
> :global(.feather.icon) {
--icon-size: 24px;
margin: 18px;
opacity: 0.75;
}
}
label {
position: absolute;
z-index: 1;
left: 68px;
top: 22px;
user-select: none;
font-size: 16px;
transition-property: top, font-size;
transition-duration: 150ms;
transition-timing-function: ease-out;
cursor: text;
}
&.disabled {
opacity: 0.5;
&, * {
cursor: not-allowed;
}
}
input, select, textarea {
z-index: 1;
border: 0;
color: inherit;
background: transparent;
font-size: 16px;
border-radius: 5px;
outline-offset: 0;
}
&:not(.empty), select ~, [type="file"] ~, [type="color"] ~, :focus ~ {
.sections label {
top: 8px;
font-size: 14px;
}
}
input,
select,
select,
textarea,
.form-display,
.textarea-growing-wrapper,
.textarea-growing-wrapper:after {
display: block;
margin-left: -60px;
padding: 32px 8px 8px 68px;
width: 100%;
height: 60px;
}
select {
position: relative;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
cursor: pointer;
&::-ms-expand {
display: none;
}
& + :global(.feather) {
position: absolute;
pointer-events: none;
right: 8px;
top: 30px;
transition: transform 150ms ease-out;
}
// TODO: Temporary
&:focus + :global(.feather) {
transform: rotateX(180deg);
}
}
.textarea-growing-wrapper {
display: grid;
flex-grow: 1;
height: revert;
padding: 0;
&:after {
content: attr(data-value) " ";
color: red;
box-sizing: border-box;
font: inherit;
visibility: hidden;
}
textarea {
height: auto;
resize: none;
font-family: inherit;
overflow: hidden;
}
&:after, textarea {
grid-area: 1 / 1 / 2 / 2;
margin-left: revert;
min-height: 100px;
height: revert;
white-space: pre-wrap;
word-wrap: anywhere;
}
}
input[type=color] {
height: calc(32px + 8px + 32px);
}
&.checkbox, &.color, &.file {
input, label {
cursor: pointer;
}
}
&.checkbox {
.control {
display: flex;
flex-direction: row;
align-items: stretch;
flex-grow: 1;
}
input {
width: 20px;
height: 20px;
margin: auto 8px;
text-align: left;
& ~ .sections {
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: center;
label {
position: static;
font-size: 16px;
}
}
}
}
&.file {
input {
@include fake-hide;
}
label {
position: static;
display: block;
padding: 8px;
font-size: revert !important;
}
}
.files {
display: flex;
flex-direction: column;
align-items: start;
.file {
margin: 8px;
padding: 8px;
color: var(--on-primary);
background: var(--primary);
border-radius: 5px;
> div {
display: inline-block;
padding: 8px;
}
.name {
font-size: 20px;
:global(.feather) {
--icon-size: 24px;
margin-right: 8px;
}
}
}
}
fieldset {
display: flex;
flex-direction: row;
align-items: start;
margin: 16px auto;
.time-input {
position: relative;
flex: 1;
margin: 0;
label {
left: 8px;
}
}
> :not(.form-field) {
padding: 32px 8px 8px 8px;
}
+ {
.error, .hint {
margin-top: -16px;
margin-bottom: 16px;
}
}
}
}
.form-field, fieldset + {
.error, .hint {
padding: 2px 2px 2px 4px;
text-align: left;
font-size: 14px;
color: var(--color);
}
}
</style>
{#if type === 'hidden'}
{#if validation}
<Message type="error" content={validation.message}/>
{/if}
<input type="hidden" name={name} value={value}>
{:else}
<div class="form-field" class:inline={type === 'checkbox'}>
<div class="control">
<div class="form-field"
class:checkbox={type === 'checkbox'}
class:color={type === 'color'}
class:file={type === 'file'}
class:empty={value === ''}
class:disabled={Object.keys($$restProps).indexOf('disabled') >= 0}>
<div class="control" on:click={focusInput}>
{#if icon}
<Icon name={icon}/>
{/if}
{#if type === 'duration'}
<div class="input-group">
<fieldset>
<legend>{placeholder}</legend>
{#each extraData as f}
<div class="time-input">
<input type="number" name="{name}[{f}]" id="{fieldId}-{f}"
value={durationValue(f)}
min="0" max={(f === 's' || f === 'm') && '60' || undefined}
{...$$restProps}>
<label for="{fieldId}-{f}">{{ f }}</label>
</div>
<div class="time-input">
<input type="number" name="{name}[{f}]" id="{fieldId}-{f}"
value={durationValue(f)}
min="0" max={(f === 's' || f === 'm') && '60' || undefined}
{...$$restProps} on:click={e => e.stopPropagation()}>
<label for="{fieldId}-{f}" on:click={e => e.stopPropagation()}>{f}</label>
</div>
{/each}
</div>
</fieldset>
{:else if type === 'select'}
<select name={name} id={fieldId} {...$$restProps} on:input={handleInput}>
<select name={name} id={fieldId} {...$$restProps} bind:this={input} on:input={handleInput}>
{#each extraData as option}
<option value={(option.display === undefined || option.value !== undefined) && (option.value || option)}
selected={value === (option.value || option)}>{option.display || option}</option>
<option value={(option.display === undefined || option.value !== undefined) && (option.value || option)}
selected={value === (option.value || option)}>{option.display || option}</option>
{/each}
</select>
<i data-feather="chevron-down"></i>
<Icon name="chevron-down"/>
{:else if type === 'textarea'}
<textarea {name} id={fieldId} bind:value={value} {...$$restProps}></textarea>
<div class="textarea-growing-wrapper" data-value={value}>
<textarea {name} id={fieldId} {value} {...$$restProps} bind:this={input}
on:input={handleInput}></textarea>
</div>
{:else if type === 'checkbox'}
<input {type} {name} id={fieldId} checked={value === 'on'} {...$$restProps}>
<input {type} {name} id={fieldId} checked={value === 'on'} {...$$restProps} bind:this={input}>
{:else}
<input {type} {name} id={fieldId} {value} {...$$restProps} on:input={handleInput}>
<input {type} {name} id={fieldId} {value} {...$$restProps} bind:this={input} on:input={handleInput}
tabindex={type === 'file' ? '-1' : undefined}>
{/if}
<label for="{fieldId}{type === 'duration' && '-' + extraData[0] || ''}">{@html placeholder || ''}<slot/></label>
<div class="sections">
{#if type !== 'duration'}
<label for={fieldId}>{@html placeholder || ''}
<slot/>
</label>
{/if}
{#if type === 'file'}
{#if files}
<div class="files">
{#each files as file}
<div class="file">
<div class="name" title="Type: {file.type}">
<Icon name="file"/> {file.name}
</div>
<div class="size" title="{file.size} bytes">
{FileSize.humanizeFileSize(file.size, true)}
</div>
</div>
{/each}
</div>
{/if}
<button type="button" on:click={chooseFile}>Browse...</button>
{/if}
</div>
</div>
{#if validation}
<div class="error"><i data-feather="x-circle"></i> {validation.message}</div>
<div class="error">
<Icon name="alert-circle"/> {validation.message}</div>
{/if}
{#if hint}
<div class="hint"><i data-feather="info"></i> {hint}</div>
<div class="hint">
<Icon name="info"/> {hint}</div>
{/if}
</div>
{/if}

View File

@ -1,11 +1,50 @@
<script lang="ts">
import {replaceIcons} from "../../ts/featherIcons.js";
import {afterUpdate, onMount} from "svelte";
export let name: string;
onMount(() => {
replaceIcons();
});
afterUpdate(() => {
replaceIcons(false);
});
</script>
<style lang="scss">
// ---
// --- Feather
// ---
:global(.feather) {
display: inline-flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: var(--icon-size);
height: var(--icon-size);
--icon-size: 16px;
font-size: var(--icon-size);
stroke: currentColor;
stroke-width: 2;
stroke-linecap: square;
stroke-linejoin: miter;
fill: none;
vertical-align: middle;
h1 > &, h2 > &, h3 > & {
--icon-size: 24px;
}
}
</style>
{#if name}
{#if name.startsWith('fa') }
<i class="{name} feather icon"></i>
<i class="{name} feather icon" aria-hidden="true" {...$$restProps}></i>
{:else}
<i data-feather="{name}" class="icon"></i>
<i data-feather="{name}" class="feather icon" aria-hidden="true" {...$$restProps}></i>
{/if}
{/if}

View File

@ -34,6 +34,8 @@ export class Pagination {
}
public previousPages(contextSize: number): number[] {
if (typeof contextSize !== 'number') throw new Error('contextSize must be a number');
const pages = [];
let i = 1;
@ -60,6 +62,8 @@ export class Pagination {
}
public nextPages(contextSize: number): number[] {
if (typeof contextSize !== 'number') throw new Error('contextSize must be a number');
const pages = [];
let i = this.page + 1;

View File

@ -4,7 +4,6 @@ import {Router} from "express";
import session from "express-session";
import ApplicationComponent from "../ApplicationComponent.js";
import ViewEngine from "../frontend/ViewEngine.js";
import SecurityError from "../SecurityError.js";
import FrontendToolsComponent from "./FrontendToolsComponent.js";
import RedisComponent from "./RedisComponent.js";
@ -82,6 +81,7 @@ export default class SessionComponent extends ApplicationComponent {
success: req.flash('success'),
warning: req.flash('warning'),
error: req.flash('error'),
'error-alert': req.flash('error-alert'),
};
}
return _flash._messages;

View File

@ -1,4 +1,4 @@
import Pagination from "../../src/common/Pagination.js";
import {Pagination} from "../../src/common/Pagination.js";
describe('Pagination', () => {
const pagination = new Pagination(3, 5, 31);

View File

@ -754,6 +754,11 @@
"@types/qs" "*"
"@types/serve-static" "*"
"@types/feather-icons@^4.7.0":
version "4.7.0"
resolved "https://registry.yarnpkg.com/@types/feather-icons/-/feather-icons-4.7.0.tgz#ec66bc046bcd1513835f87541ecef54b50c57ec9"
integrity sha512-vflOrmlHMGIGVN4AEl6ErPCNKBLcP1ehEpLqnJkTgf69r5QmJzy7BF1WzbBc8Hqs9KffROPT/JqlSKH4o5vB/w==
"@types/formidable@^1.0.31":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@types/formidable/-/formidable-1.2.1.tgz#d17006b48acd314df9bcfa04236b3e32de0cc817"
@ -1966,6 +1971,11 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
classnames@^2.2.5:
version "2.3.1"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
clean-css@^4.2.1:
version "4.2.3"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
@ -2263,6 +2273,11 @@ copy-descriptor@^0.1.0:
resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
core-js@^3.1.3:
version "3.14.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.14.0.tgz#62322b98c71cc2018b027971a69419e2425c2a6c"
integrity sha512-3s+ed8er9ahK+zJpp9ZtuVcDoFzHNiZsPbNAAE4KXgrRHbjSqqNN6xGSXq6bq7TZIbKj4NLrLb6bJ5i+vSVjHA==
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@ -3481,6 +3496,14 @@ fd-slicer@~1.1.0:
dependencies:
pend "~1.2.0"
feather-icons@^4.28.0:
version "4.28.0"
resolved "https://registry.yarnpkg.com/feather-icons/-/feather-icons-4.28.0.tgz#e1892a401fe12c4559291770ff6e68b0168e760f"
integrity sha512-gRdqKESXRBUZn6Nl0VBq2wPHKRJgZz7yblrrc2lYsS6odkNFDnA4bqvrlEVRUPjE1tFax+0TdbJKZ31ziJuzjg==
dependencies:
classnames "^2.2.5"
core-js "^3.1.3"
figures@^1.3.5:
version "1.7.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"