@@ -183,9 +199,40 @@ function Fields({
>
Explore
-
>
)
@@ -196,6 +243,7 @@ Fields.propTypes = {
onSubmit: PropTypes.func.isRequired,
onExplore: PropTypes.func.isRequired,
onShare: PropTypes.func.isRequired,
+ onFavoriteAdd: PropTypes.func.isRequired,
refExplore: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
diff --git a/start-client/src/components/common/builder/Loading.js b/start-client/src/components/common/builder/Loading.js
index e61fee269e..8b8222a96b 100644
--- a/start-client/src/components/common/builder/Loading.js
+++ b/start-client/src/components/common/builder/Loading.js
@@ -12,8 +12,9 @@ export default function Loading() {
diff --git a/start-client/src/components/common/favorite/Add.js b/start-client/src/components/common/favorite/Add.js
new file mode 100644
index 0000000000..b36f080c22
--- /dev/null
+++ b/start-client/src/components/common/favorite/Add.js
@@ -0,0 +1,185 @@
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import React, { useEffect, useRef, useContext, useState } from 'react'
+import { CSSTransition, TransitionGroup } from 'react-transition-group'
+import { clearAllBodyScrollLocks, disableBodyScroll } from 'body-scroll-lock'
+import queryString from 'query-string'
+import { toast } from 'react-toastify'
+import FieldInput from '../builder/FieldInput'
+import { Button } from '../form'
+import { AppContext } from '../../reducer/App'
+import { InitializrContext } from '../../reducer/Initializr'
+import {
+ getLabelFromList,
+ getLabelFromDepsList,
+ getBookmarkDefaultName,
+} from './Utils'
+
+function FavoriteItem({ value }) {
+ const { config } = useContext(AppContext)
+ const params = queryString.parse(value)
+ const deps = get(params, 'dependencies', '')
+ .split(',')
+ .filter(dep => !!dep)
+ return (
+
+
+
+ Project{' '}
+
+ {getLabelFromList(
+ get(config, 'lists.project'),
+ get(params, 'type')
+ )}
+
+ {`, `}
+ Language{' '}
+
+ {getLabelFromList(
+ get(config, 'lists.language'),
+ get(params, 'language')
+ )}
+
+ {`, `}
+ Spring Boot{' '}
+
+ {getLabelFromList(
+ get(config, 'lists.boot'),
+ get(params, 'platformVersion')
+ )}
+
+
+
+ {deps.length === 0 && 'No dependency'}
+ {deps.length > 0 && (
+ <>
+ Dependencies:{' '}
+
+ {deps
+ .map(dep =>
+ getLabelFromDepsList(get(config, 'lists.dependencies'), dep)
+ )
+ .join(', ')}
+
+ >
+ )}
+
+
+
+ )
+}
+
+FavoriteItem.propTypes = {
+ value: PropTypes.string.isRequired,
+}
+
+function Add({ open, onClose }) {
+ const wrapper = useRef(null)
+ const { share } = useContext(InitializrContext)
+ const { dispatch, favoriteOptions } = useContext(AppContext)
+ const [name, setName] = useState()
+ const input = useRef(null)
+
+ const title = get(favoriteOptions, 'title', '') || 'Bookmark'
+ const button = get(favoriteOptions, 'button', '') || 'Save'
+ const value = get(favoriteOptions, 'favorite.value', '') || share
+ const nameFav = get(favoriteOptions, 'favorite.name', '')
+
+ useEffect(() => {
+ setName(nameFav || `${getBookmarkDefaultName()}`)
+ }, [setName, open, nameFav])
+
+ useEffect(() => {
+ const clickOutside = event => {
+ const children = get(wrapper, 'current')
+ if (children && !children.contains(event.target)) {
+ onClose()
+ }
+ }
+ document.addEventListener('mousedown', clickOutside)
+ return () => {
+ document.removeEventListener('mousedown', clickOutside)
+ }
+ }, [onClose])
+
+ useEffect(() => {
+ if (get(wrapper, 'current') && open) {
+ disableBodyScroll(get(wrapper, 'current'))
+ }
+ if (get(input, 'current')) {
+ get(input, 'current').focus()
+ }
+ return () => {
+ clearAllBodyScrollLocks()
+ }
+ }, [wrapper, open])
+
+ const onSubmit = e => {
+ e.preventDefault()
+ if (!get(favoriteOptions, 'button', '')) {
+ dispatch({ type: 'ADD_FAVORITE', payload: { values: value, name } })
+ toast.success('Project bookmarked')
+ } else {
+ dispatch({
+ type: 'UPDATE_FAVORITE',
+ payload: { name, favorite: favoriteOptions.favorite },
+ })
+ }
+ onClose()
+ }
+
+ return (
+
+ {open && (
+
+
+
+ )}
+
+ )
+}
+
+Add.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+}
+
+export default Add
diff --git a/start-client/src/components/common/favorite/Favorite.js b/start-client/src/components/common/favorite/Favorite.js
new file mode 100644
index 0000000000..2116f98dfb
--- /dev/null
+++ b/start-client/src/components/common/favorite/Favorite.js
@@ -0,0 +1,26 @@
+import '../../../styles/favorite.scss'
+
+import PropTypes from 'prop-types'
+import React from 'react'
+
+import Modal from './Modal'
+import Add from './Add'
+import { Overlay } from '../form'
+
+function Favorite({ open, add, onClose }) {
+ return (
+ <>
+
+
+
+ >
+ )
+}
+
+Favorite.propTypes = {
+ open: PropTypes.bool.isRequired,
+ add: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+}
+
+export default Favorite
diff --git a/start-client/src/components/common/favorite/Modal.js b/start-client/src/components/common/favorite/Modal.js
new file mode 100644
index 0000000000..a587c6c8dd
--- /dev/null
+++ b/start-client/src/components/common/favorite/Modal.js
@@ -0,0 +1,191 @@
+import PropTypes from 'prop-types'
+import get from 'lodash/get'
+import React, { useEffect, useRef, useContext } from 'react'
+import { CSSTransition, TransitionGroup } from 'react-transition-group'
+import { clearAllBodyScrollLocks, disableBodyScroll } from 'body-scroll-lock'
+import queryString from 'query-string'
+import { AppContext } from '../../reducer/App'
+import { getLabelFromList, getLabelFromDepsList } from './Utils'
+import { IconEdit, IconDelete } from '../icons'
+
+function FavoriteItem({ name, value, onClose, onRemove, onUpdate }) {
+ const { config } = useContext(AppContext)
+ const params = queryString.parse(value)
+ const deps = get(params, 'dependencies', '')
+ .split(',')
+ .filter(dep => !!dep)
+ return (
+
+ {
+ onClose()
+ }}
+ >
+ {name}
+
+
+ Project{' '}
+
+ {getLabelFromList(
+ get(config, 'lists.project'),
+ get(params, 'type')
+ )}
+
+ {`, `}
+ Language{' '}
+
+ {getLabelFromList(
+ get(config, 'lists.language'),
+ get(params, 'language')
+ )}
+
+ {`, `}
+ Spring Boot{' '}
+
+ {getLabelFromList(
+ get(config, 'lists.boot'),
+ get(params, 'platformVersion')
+ )}
+
+
+
+ {deps.length === 0 && 'No dependency'}
+ {deps.length > 0 && (
+ <>
+ Dependencies:{' '}
+
+ {deps
+ .map(dep =>
+ getLabelFromDepsList(
+ get(config, 'lists.dependencies'),
+ dep
+ )
+ )
+ .join(', ')}
+
+ >
+ )}
+
+
+
+
+
+
+ )
+}
+
+FavoriteItem.propTypes = {
+ name: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onRemove: PropTypes.func.isRequired,
+ onUpdate: PropTypes.func.isRequired,
+}
+
+function Modal({ open, onClose }) {
+ const wrapper = useRef(null)
+ const { favorites, dispatch } = useContext(AppContext)
+
+ useEffect(() => {
+ const clickOutside = event => {
+ const children = get(wrapper, 'current')
+ if (children && !children.contains(event.target)) {
+ onClose()
+ }
+ }
+ document.addEventListener('mousedown', clickOutside)
+ return () => {
+ document.removeEventListener('mousedown', clickOutside)
+ }
+ }, [onClose])
+
+ useEffect(() => {
+ if (get(wrapper, 'current') && open) {
+ disableBodyScroll(get(wrapper, 'current'))
+ }
+ return () => {
+ clearAllBodyScrollLocks()
+ }
+ }, [wrapper, open])
+
+ const onRemove = favorite => {
+ dispatch({ type: 'REMOVE_FAVORITE', payload: favorite })
+ }
+
+ const onUpdate = favorite => {
+ dispatch({
+ type: 'UPDATE',
+ payload: {
+ favorite: false,
+ favoriteAdd: true,
+ favoriteOptions: {
+ title: 'Edit bookmark',
+ button: 'Update',
+ back: 'favorite',
+ favorite,
+ },
+ },
+ })
+ }
+
+ return (
+
+ {open && (
+
+
+
+
+
Bookmarks
+
+
+
+ {favorites.map(favorite => (
+ {
+ onRemove(favorite)
+ }}
+ onUpdate={() => {
+ onUpdate(favorite)
+ }}
+ />
+ ))}
+
+
+
+
+
+
+ )}
+
+ )
+}
+
+Modal.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+}
+
+export default Modal
diff --git a/start-client/src/components/common/favorite/Utils.js b/start-client/src/components/common/favorite/Utils.js
new file mode 100644
index 0000000000..624bc782e0
--- /dev/null
+++ b/start-client/src/components/common/favorite/Utils.js
@@ -0,0 +1,16 @@
+import { DateTime } from 'luxon'
+
+export function getLabelFromList(list, key) {
+ return list.find(item => item.key === key)?.text || key
+}
+
+export function getLabelFromDepsList(list, key) {
+ return list.find(item => item.id === key)?.name || key
+}
+
+export function getBookmarkDefaultName() {
+ const date = DateTime.now()
+ return `Bookmark ${date.toLocaleString(
+ DateTime.DATE_SHORT
+ )} ${date.toLocaleString(DateTime.TIME_24_SIMPLE)}`
+}
diff --git a/start-client/src/components/common/favorite/index.js b/start-client/src/components/common/favorite/index.js
new file mode 100644
index 0000000000..ab8695c9c6
--- /dev/null
+++ b/start-client/src/components/common/favorite/index.js
@@ -0,0 +1 @@
+export { default as Favorite } from './Favorite'
diff --git a/start-client/src/components/common/form/Button.js b/start-client/src/components/common/form/Button.js
index 20b08218b6..f70e2a5dbd 100644
--- a/start-client/src/components/common/form/Button.js
+++ b/start-client/src/components/common/form/Button.js
@@ -6,13 +6,16 @@ function Button({
onClick,
children,
variant,
+ className,
hotkey,
refButton,
disabled,
}) {
return (
- {!isOpen && !lock && histories.length > 0 && (
+ {!isOpen && !lock && (
<>
-
-
{
- dispatch({ type: 'UPDATE', payload: { history: true } })
- }}
- >
-
-
+ {favorites.length > 0 && (
+ <>
+
+
{
+ dispatch({
+ type: 'UPDATE',
+ payload: { favorite: true },
+ })
+ }}
+ >
+
+
+ >
+ )}
+ {histories.length > 0 && (
+ <>
+
+
{
+ dispatch({ type: 'UPDATE', payload: { history: true } })
+ }}
+ >
+
+
+ >
+ )}
>
)}
diff --git a/start-client/src/components/reducer/App.js b/start-client/src/components/reducer/App.js
index 6f5763ff8e..fb36a5f153 100644
--- a/start-client/src/components/reducer/App.js
+++ b/start-client/src/components/reducer/App.js
@@ -13,6 +13,8 @@ export const defaultAppContext = {
explore: false,
share: false,
history: false,
+ favorite: false,
+ favoriteAdd: false,
nav: false,
list: false,
theme: 'light',
@@ -22,7 +24,14 @@ export const defaultAppContext = {
list: [],
groups: [],
},
+ favoriteOptions: {
+ title: '',
+ button: '',
+ favorite: null,
+ back: '',
+ },
histories: [],
+ favorites: [],
}
const localStorage =
@@ -80,6 +89,14 @@ export function reducer(state, action) {
if (key === 'theme') {
localStorage.setItem('springtheme', value)
}
+ if (key === 'favoriteAdd' && !value) {
+ newState.favoriteOptions = {
+ title: '',
+ button: '',
+ favorite: null,
+ back: '',
+ }
+ }
return key
})
return newState
@@ -110,7 +127,18 @@ export function reducer(state, action) {
const histories = localStorage.getItem('histories')
? JSON.parse(localStorage.getItem('histories'))
: []
- return { ...state, complete: true, config: json, dependencies, histories }
+
+ const favorites = localStorage.getItem('favorites')
+ ? JSON.parse(localStorage.getItem('favorites'))
+ : []
+ return {
+ ...state,
+ complete: true,
+ config: json,
+ dependencies,
+ histories,
+ favorites,
+ }
}
case 'ADD_HISTORY': {
const newHistory = get(action, 'payload')
@@ -128,6 +156,49 @@ export function reducer(state, action) {
localStorage.setItem('histories', JSON.stringify([]))
return { ...state, histories: [] }
}
+ case 'ADD_FAVORITE': {
+ const favorites = [
+ {
+ date: new Date().toISOString(),
+ name: get(action, 'payload.name'),
+ value: get(action, 'payload.values'),
+ },
+ ...state.favorites,
+ ]
+ localStorage.setItem('favorites', JSON.stringify(favorites))
+ return { ...state, favorites }
+ }
+ case 'UPDATE_FAVORITE': {
+ const favoriteToUpdate = get(action, 'payload.favorite')
+ const favorites = state.favorites.map(item => {
+ if (
+ item.name === favoriteToUpdate.name &&
+ item.date === favoriteToUpdate.date &&
+ item.value === favoriteToUpdate.value
+ ) {
+ return {
+ ...item,
+ name: get(action, 'payload.name'),
+ }
+ }
+ return item
+ })
+ localStorage.setItem('favorites', JSON.stringify(favorites))
+ return { ...state, favorites }
+ }
+ case 'REMOVE_FAVORITE': {
+ const favoriteToRemove = get(action, 'payload')
+ const favorites = state.favorites.filter(
+ item =>
+ !(
+ item.name === favoriteToRemove.name &&
+ item.date === favoriteToRemove.date &&
+ item.value === favoriteToRemove.value
+ )
+ )
+ localStorage.setItem('favorites', JSON.stringify(favorites))
+ return { ...state, favorites }
+ }
default:
return state
}
diff --git a/start-client/src/styles/_dark.scss b/start-client/src/styles/_dark.scss
index 6b00b3cfb0..91d96ae387 100644
--- a/start-client/src/styles/_dark.scss
+++ b/start-client/src/styles/_dark.scss
@@ -401,16 +401,34 @@ body.dark {
background: $dark-background;
}
- .modal-share .modal-header {
- background: $dark-background;
- border-bottom: 1px solid $dark-border;
+ .modal-share,
+ .modal-add-favorite,
+ .modal-favorite {
+ .modal-header {
+ background: $dark-background;
+ border-bottom: 1px solid $dark-border;
+ }
}
- .modal-history-container {
+ .modal-history-container,
+ .modal-favorite-container,
+ .modal-add-favorite-container {
border: 1px solid $dark-border;
}
.modal-content {
background: $dark-background;
}
+ .modal-add-favorite .favorite-desc {
+ background: $dark-background-secondary;
+ color: $dark-color;
+ }
+ .modal-favorite button.edit,
+ .modal-favorite button.remove,
+ .modal-share .modal-content button.favorite {
+ color: white;
+ }
+ .actions .button.clicked {
+ color: #000;
+ }
.modal-content .list a.item {
background: $dark-background-secondary;
color: $dark-color;
diff --git a/start-client/src/styles/_main.scss b/start-client/src/styles/_main.scss
index 3151915409..7b21087917 100644
--- a/start-client/src/styles/_main.scss
+++ b/start-client/src/styles/_main.scss
@@ -582,13 +582,32 @@ button.button {
span {
padding: 0.9rem 1.5rem 0.8rem;
}
+ &.clicked {
+ color: white;
+ &:before {
+ opacity: 1;
+ }
+ }
&:focus {
box-shadow: 0 0 0 4px darken($light-border, 6);
}
- &:last-child {
+ &.last-child {
margin-right: 0;
}
}
+ .dropdown {
+ position: relative;
+ .dropdown-items {
+ position: absolute;
+ bottom: 35px;
+ left: -50px;
+ text-align: center;
+ .button {
+ margin: 4px 0;
+ width: 160px;
+ }
+ }
+ }
}
.colset-main {
@@ -1320,7 +1339,7 @@ ul.dependencies-list {
width: 249.42px;
}
&-share {
- width: 119px;
+ width: 62.3px;
}
&-dep {
width: 241.8px;
diff --git a/start-client/src/styles/_responsive.scss b/start-client/src/styles/_responsive.scss
index 3ebe0d3953..827ac116dc 100644
--- a/start-client/src/styles/_responsive.scss
+++ b/start-client/src/styles/_responsive.scss
@@ -244,7 +244,7 @@
width: 88.55px;
}
.placeholder-button-share {
- width: 82.77px;
+ width: 33px;
}
.placeholder-button-download {
width: 108.73px;
diff --git a/start-client/src/styles/favorite.scss b/start-client/src/styles/favorite.scss
new file mode 100644
index 0000000000..ba82f2002d
--- /dev/null
+++ b/start-client/src/styles/favorite.scss
@@ -0,0 +1,197 @@
+@import 'variables';
+@import 'mixins';
+
+$w_arrow: 12px;
+$w: 1000px;
+$w2: 500px;
+
+.modal-add-favorite,
+.modal-favorite {
+ z-index: 10000;
+ position: fixed;
+ top: 50px;
+ left: 0;
+ right: 0;
+
+ .modal-favorite-container,
+ .modal-add-favorite-container {
+ max-width: $w;
+ margin: 0 auto;
+ background: white;
+ }
+
+ .modal-add-favorite-container {
+ max-width: $w2;
+ }
+
+ @include transition(all $spring-transition-duration);
+ &:before {
+ $h: 60px;
+ content: ' ';
+ height: $h;
+ width: $w;
+ position: absolute;
+ bottom: -$h;
+ left: 0;
+ }
+ .modal-content {
+ padding: $spring-8points * 3;
+ padding-top: $spring-8points;
+ padding-bottom: $spring-8points * 2;
+ max-height: 70vh;
+ overflow: auto;
+ .list {
+ .name {
+ font-weight: bold;
+ display: block;
+ }
+ ul {
+ padding: 0;
+ margin: 0 0 10px;
+ }
+ li {
+ position: relative;
+ list-style: none;
+ padding: 1px 0;
+ margin: 0;
+ }
+ a.item {
+ display: block;
+ position: relative;
+ background: $light-background-seconday;
+ border-radius: 3px;
+ text-decoration: none;
+ padding: 5px 10px;
+ color: $light-color;
+ padding-right: 80px;
+ &:hover {
+ background: lighten($light-background-seconday, 2);
+ a {
+ opacity: 1;
+ }
+ }
+ }
+ .time {
+ width: 80px;
+ }
+ .time,
+ .desc,
+ .main,
+ .deps {
+ display: block;
+ }
+ }
+ }
+ .modal-header {
+ position: relative;
+ padding: 6px $spring-8points * 2 2px;
+ border-bottom: 1px solid #ebebeb;
+ h1 {
+ font-size: $spring-8points * 2.5;
+ line-height: $spring-8points * 2.5;
+ font-weight: 600;
+ }
+ .button {
+ position: absolute;
+ top: 11px;
+ right: 11px;
+ font-size: $spring-font-size - 3;
+ line-height: 0.7rem;
+ margin-right: 0;
+ }
+ }
+ .modal-action {
+ text-align: center;
+ border-top: 1px solid $light-border;
+ padding: 16px 0 8px;
+ }
+ button.remove,
+ button.edit {
+ $size: 38px;
+ display: block;
+ position: absolute;
+ width: $size;
+ right: 10px;
+ top: 50%;
+ margin-top: -(calc($size / 2));
+ border: 0 none;
+ cursor: pointer;
+ background: transparent;
+ opacity: 0.4;
+ @include outline;
+ @include transition(all 150ms);
+ .a-content {
+ display: block;
+ outline: none;
+ box-shadow: none;
+ padding: 8px;
+ }
+ svg {
+ display: block;
+ }
+ &:hover {
+ opacity: 0.8;
+ }
+ &:focus {
+ opacity: 1;
+ }
+ }
+ button.edit {
+ $size: 32px;
+ right: 45px;
+ width: $size;
+ margin-top: -(calc($size / 2));
+ svg {
+ .st0 {
+ fill: #000;
+ }
+ }
+ }
+}
+
+.modal-enter {
+ opacity: 0;
+}
+
+.modal-enter-active {
+ opacity: 1;
+ transition: all 300ms;
+}
+
+.modal-exit {
+ opacity: 1;
+}
+
+.modal-exit-active {
+ opacity: 0;
+ transition: all 300ms;
+}
+
+.modal-add-favorite {
+ .control-inline {
+ flex: none;
+ display: block;
+ label {
+ text-align: left;
+ flex: none;
+ display: block;
+ }
+ .input {
+ margin: 0;
+ }
+ }
+ .modal-action {
+ padding: 16px 0 16px;
+ }
+ .favorite-desc {
+ background: $light-background-seconday;
+ padding: 8px;
+ margin: 8px 0;
+ border-radius: 3px;
+ .deps {
+ display: block;
+ }
+ }
+}
+
+@import 'responsive';
diff --git a/start-client/src/styles/history.scss b/start-client/src/styles/history.scss
index 9d1ace6786..cfde1e9156 100644
--- a/start-client/src/styles/history.scss
+++ b/start-client/src/styles/history.scss
@@ -46,6 +46,7 @@ $w: 1000px;
list-style: none;
padding: 1px 0;
margin: 0;
+ position: relative;
}
a.item {
position: relative;
@@ -73,6 +74,36 @@ $w: 1000px;
display: block;
}
}
+ button.favorite {
+ $size: 42px;
+ display: block;
+ position: absolute;
+ width: $size;
+ right: 10px;
+ top: 50%;
+ margin-top: -(calc($size / 2)-4);
+ border: 0 none;
+ cursor: pointer;
+ background: transparent;
+ opacity: 0.4;
+ @include outline;
+ @include transition(all 150ms);
+ .a-content {
+ display: block;
+ outline: none;
+ box-shadow: none;
+ padding: 8px;
+ }
+ svg {
+ display: block;
+ }
+ &:hover {
+ opacity: 0.8;
+ }
+ &:focus {
+ opacity: 1;
+ }
+ }
}
.modal-header {
position: relative;
diff --git a/start-client/webpack.common.js b/start-client/webpack.common.js
index aed7ad0ac7..582bf9f0bc 100644
--- a/start-client/webpack.common.js
+++ b/start-client/webpack.common.js
@@ -50,7 +50,16 @@ const config = {
},
{
test: /\.s[ac]ss$/i,
- use: ['style-loader', 'css-loader', 'sass-loader'],
+ use: [
+ 'style-loader',
+ 'css-loader',
+ {
+ loader: 'sass-loader',
+ options: {
+ warnRuleAsWarning: false,
+ },
+ },
+ ],
},
{
test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
diff --git a/start-client/webpack.prod.js b/start-client/webpack.prod.js
index e255d8e928..15c96637ac 100644
--- a/start-client/webpack.prod.js
+++ b/start-client/webpack.prod.js
@@ -25,7 +25,7 @@ const config = {
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
- openAnalyzer: true,
+ openAnalyzer: false,
generateStatsFile: true,
statsFilename: '../analysis/stats.json',
reportFilename: '../analysis/bundle-analyzer.html',