Add Skeleton for GridItem and Image + Refactor components architecture with more modularity + Delete Fruit feature
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"core-js": "^3.6.5",
|
"core-js": "^3.6.5",
|
||||||
"fruit-api": "^1.1.3",
|
"fruit-api": "^1.1.3",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
|
"vue-content-loading": "^1.6.0",
|
||||||
"vue-moment": "^4.1.0",
|
"vue-moment": "^4.1.0",
|
||||||
"vue-router": "^3.2.0",
|
"vue-router": "^3.2.0",
|
||||||
"vuex": "^3.4.0"
|
"vuex": "^3.4.0"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
<Modal v-if="modalIsOpen" />
|
<AddFruit v-if="modalIsOpen" />
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -13,14 +13,14 @@
|
|||||||
import { mapState } from "vuex";
|
import { mapState } from "vuex";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Modal from "@/components/Modal";
|
import AddFruit from "@/components/AddFruit";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "App",
|
name: "App",
|
||||||
components: {
|
components: {
|
||||||
Footer,
|
Footer,
|
||||||
Header,
|
Header,
|
||||||
Modal
|
AddFruit
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(["modalIsOpen"])
|
...mapState(["modalIsOpen"])
|
||||||
|
|||||||
@@ -4,45 +4,23 @@
|
|||||||
<form id="new-fruit">
|
<form id="new-fruit">
|
||||||
<!-- name -->
|
<!-- name -->
|
||||||
<p>
|
<p>
|
||||||
<input type="text" v-model="fruit.name" placeholder="Name" />
|
<input ref="autofocus" type="text" v-model="fruit.name" placeholder="Name" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- image finder (with Unsplash API) -->
|
<!-- image finder (with Unsplash API) -->
|
||||||
<div class="image-uploader">
|
<ImageUnsplash
|
||||||
<label for="fruit-thumbnail">Image</label>
|
@getValue="getImage"
|
||||||
<!-- Loading placeholder -->
|
label="Image"
|
||||||
<Skeleton v-if="loading" width="100%" height="180px" />
|
placeholder="Search: strawberry, apple ..."
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Image preview -->
|
<div class="input-group">
|
||||||
<img
|
<!-- taste -->
|
||||||
class="preview"
|
|
||||||
v-if="!loading && imagePreview"
|
|
||||||
:src="imagePreview.urls.regular"
|
|
||||||
:alt="imagePreview.alt_description"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- API error messages -->
|
|
||||||
<p class="error" v-if="error">{{ error }}</p>
|
|
||||||
|
|
||||||
<!-- Search input -->
|
|
||||||
<div class="search-box">
|
|
||||||
<input
|
|
||||||
id="fruit-thumbnail"
|
|
||||||
type="search"
|
|
||||||
placeholder="Search: strawberry, orange ..."
|
|
||||||
@change="handleSearch"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- taste -->
|
|
||||||
<p>
|
|
||||||
<input type="text" v-model="fruit.taste" placeholder="Taste" />
|
<input type="text" v-model="fruit.taste" placeholder="Taste" />
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- color -->
|
<!-- color -->
|
||||||
<p>
|
|
||||||
<input type="color" v-model="fruit.color" placeholder="Color" />
|
<input type="color" v-model="fruit.color" placeholder="Color" />
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<!-- price -->
|
<!-- price -->
|
||||||
<p>
|
<p>
|
||||||
@@ -63,39 +41,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Skeleton from "./Skeleton";
|
import ImageUnsplash from "./Form/ImageUnsplash/ImageUnsplash";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Modal",
|
name: "AddFruit",
|
||||||
components: { Skeleton },
|
components: { ImageUnsplash },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
fruit: {}
|
||||||
fruit: {},
|
|
||||||
error: null,
|
|
||||||
imagePreview: null
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$refs["autofocus"].focus();
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleUpload(e) {
|
getImage(url) {
|
||||||
console.log(e);
|
this.fruit.image = url;
|
||||||
},
|
|
||||||
async handleSearch(e) {
|
|
||||||
this.error = null;
|
|
||||||
if (e.target.value != "") {
|
|
||||||
this.loading = true;
|
|
||||||
this.$store
|
|
||||||
.dispatch("getImageFromUnsplash", e.target.value)
|
|
||||||
.then(res => (this.imagePreview = res))
|
|
||||||
.catch(err => (this.error = err.message))
|
|
||||||
.finally(() => (this.loading = false));
|
|
||||||
} else this.imagePreview = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less">
|
||||||
.modal {
|
.modal {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: @headerHeight;
|
top: @headerHeight;
|
||||||
@@ -116,14 +83,23 @@ export default {
|
|||||||
box-shadow: 0 1px 4px 1px #d2d2f2;
|
box-shadow: 0 1px 4px 1px #d2d2f2;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|
||||||
label {
|
.input-group {
|
||||||
display: block;
|
display: flex;
|
||||||
font-weight: bold;
|
align-items: center;
|
||||||
margin-bottom: 0.5rem;
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
[type="color"] {
|
||||||
|
width: 35%;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:first-child {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
.image-uploader {
|
.image-unsplash {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -131,55 +107,6 @@ export default {
|
|||||||
background-color: @color-2;
|
background-color: @color-2;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-uploader {
|
|
||||||
.preview,
|
|
||||||
.skeleton {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview {
|
|
||||||
width: 100%;
|
|
||||||
object-fit: scale-down;
|
|
||||||
height: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #d63031;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-box {
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
|
|
||||||
&:focus-within::after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: url("data:image/svg+xml,%3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 350.439 350.439' style='enable-background:new 0 0 350.439 350.439;' xml:space='preserve'%3E%3Cg%3E%3Cpath d='M312.856,36.464H202.507c-4.87,0-8.83,3.961-8.83,8.832v138.422H52.371c-4.87,0-8.832,3.973-8.832,8.843v108.023 c0,4.87,3.961,8.831,8.832,8.831l260.474-0.071c4.873,0,8.838-3.962,8.838-8.843l0.011-255.211 C321.701,40.425,317.737,36.464,312.856,36.464z M175.216,253.617c0,5.724-4.635,10.353-10.356,10.353h-57.814 c-0.392,0-0.775-0.022-1.155-0.065v0.887l8.294,21.002c0.281,0.7,0.057,1.51-0.551,1.965c-0.301,0.229-0.658,0.344-1.013,0.344 c-0.364,0-0.722-0.12-1.025-0.355l-41.251-31.715c-0.416-0.318-0.657-0.816-0.657-1.33c0-0.531,0.241-1.023,0.657-1.345 l41.251-31.712c0.6-0.463,1.433-0.463,2.038-0.011c0.603,0.459,0.827,1.258,0.551,1.958l-7.776,19.694 c0.21-0.011,0.418-0.033,0.637-0.033h47.462v-34.715c0-5.724,4.637-10.353,10.352-10.353c5.721,0,10.356,4.629,10.356,10.353 V253.617z M332.215,7.41H174.623c-10.062,0-18.221,8.159-18.221,18.225v126.679H18.223C8.159,152.314,0,160.479,0,170.541v154.267 c0,10.063,8.159,18.222,18.223,18.222h157.588c0.346,0,0.667-0.087,1.008-0.099h155.375c10.065,0,18.228-8.164,18.228-18.232 V180.074c0-0.066,0.018-0.121,0.018-0.176V25.635C350.439,15.569,342.273,7.41,332.215,7.41z M329.535,300.512 c0,9.205-7.483,16.69-16.69,16.69H204.052c-0.078,0.043-0.383,0.076-0.705,0.076H52.371c-9.202,0-16.688-7.485-16.688-16.689 V192.571c0-9.209,7.485-16.689,16.688-16.689h133.453V45.295c0-9.201,7.485-16.687,16.684-16.687h110.349 c9.204,0,16.689,7.486,16.689,16.687L329.535,300.512z'/%3E%3C/g%3E%3C/svg%3E");
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
top: 50%;
|
|
||||||
right: 0.3rem;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="search"] {
|
|
||||||
padding-right: 2.35rem;
|
|
||||||
border: none;
|
|
||||||
background-color: lighten(#cecece, 15%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="file"] {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
23
src/components/Form/ImageUnsplash/ImageSkeleton.vue
Normal file
23
src/components/Form/ImageUnsplash/ImageSkeleton.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<VueContentLoading
|
||||||
|
:style="{ backgroundColor: '#ffffff', padding: '1rem', borderRadius: '10px' }"
|
||||||
|
primary="#cecece"
|
||||||
|
:width="300"
|
||||||
|
:height="180"
|
||||||
|
>
|
||||||
|
<rect x="0" y="0" rx="4" ry="4" width="300" height="180" />
|
||||||
|
</VueContentLoading>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import VueContentLoading from "vue-content-loading";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "ImageSkeleton",
|
||||||
|
components: {
|
||||||
|
VueContentLoading
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less"></style>
|
||||||
125
src/components/Form/ImageUnsplash/ImageUnsplash.vue
Normal file
125
src/components/Form/ImageUnsplash/ImageUnsplash.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div class="image-unsplash">
|
||||||
|
<label for="search-unsplash">{{ label }}</label>
|
||||||
|
<!-- Loading placeholder -->
|
||||||
|
<ImageSkeleton v-if="loading" />
|
||||||
|
|
||||||
|
<!-- Image preview -->
|
||||||
|
<img
|
||||||
|
class="preview"
|
||||||
|
v-if="!loading && !error && preview"
|
||||||
|
:src="preview.urls.regular"
|
||||||
|
:alt="preview.alt_description"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- API error messages -->
|
||||||
|
<p class="error" v-if="error">{{ error }}</p>
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="search-box">
|
||||||
|
<input id="search-unsplash" type="search" :placeholder="placeholder" @change="handleSearch" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import ImageSkeleton from "./ImageSkeleton";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "ImageUnsplash",
|
||||||
|
components: {
|
||||||
|
ImageSkeleton
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: "Image (from Unsplash)"
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: "Type anything !"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
error: null,
|
||||||
|
preview: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(["loading"])
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async handleSearch(e) {
|
||||||
|
this.error = null;
|
||||||
|
if (e.target.value != "") {
|
||||||
|
await this.$store
|
||||||
|
.dispatch("getImageFromUnsplash", e.target.value)
|
||||||
|
.then(res => {
|
||||||
|
this.preview = res;
|
||||||
|
this.$emit("getValue", res.urls.regular);
|
||||||
|
})
|
||||||
|
.catch(err => (this.error = err.message));
|
||||||
|
} else this.preview = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.image-unsplash {
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview,
|
||||||
|
.skeleton {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: 100%;
|
||||||
|
object-fit: scale-down;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #d63031;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
|
&:focus-within::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: url("data:image/svg+xml,%3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 350.439 350.439' style='enable-background:new 0 0 350.439 350.439;' xml:space='preserve'%3E%3Cg%3E%3Cpath d='M312.856,36.464H202.507c-4.87,0-8.83,3.961-8.83,8.832v138.422H52.371c-4.87,0-8.832,3.973-8.832,8.843v108.023 c0,4.87,3.961,8.831,8.832,8.831l260.474-0.071c4.873,0,8.838-3.962,8.838-8.843l0.011-255.211 C321.701,40.425,317.737,36.464,312.856,36.464z M175.216,253.617c0,5.724-4.635,10.353-10.356,10.353h-57.814 c-0.392,0-0.775-0.022-1.155-0.065v0.887l8.294,21.002c0.281,0.7,0.057,1.51-0.551,1.965c-0.301,0.229-0.658,0.344-1.013,0.344 c-0.364,0-0.722-0.12-1.025-0.355l-41.251-31.715c-0.416-0.318-0.657-0.816-0.657-1.33c0-0.531,0.241-1.023,0.657-1.345 l41.251-31.712c0.6-0.463,1.433-0.463,2.038-0.011c0.603,0.459,0.827,1.258,0.551,1.958l-7.776,19.694 c0.21-0.011,0.418-0.033,0.637-0.033h47.462v-34.715c0-5.724,4.637-10.353,10.352-10.353c5.721,0,10.356,4.629,10.356,10.353 V253.617z M332.215,7.41H174.623c-10.062,0-18.221,8.159-18.221,18.225v126.679H18.223C8.159,152.314,0,160.479,0,170.541v154.267 c0,10.063,8.159,18.222,18.223,18.222h157.588c0.346,0,0.667-0.087,1.008-0.099h155.375c10.065,0,18.228-8.164,18.228-18.232 V180.074c0-0.066,0.018-0.121,0.018-0.176V25.635C350.439,15.569,342.273,7.41,332.215,7.41z M329.535,300.512 c0,9.205-7.483,16.69-16.69,16.69H204.052c-0.078,0.043-0.383,0.076-0.705,0.076H52.371c-9.202,0-16.688-7.485-16.688-16.689 V192.571c0-9.209,7.485-16.689,16.688-16.689h133.453V45.295c0-9.201,7.485-16.687,16.684-16.687h110.349 c9.204,0,16.689,7.486,16.689,16.687L329.535,300.512z'/%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 50%;
|
||||||
|
right: 0.3rem;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[type="search"] {
|
||||||
|
padding-right: 2.35rem;
|
||||||
|
border: none;
|
||||||
|
background-color: lighten(#cecece, 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[type="file"] {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="grid">
|
|
||||||
<GridItem :key="item.id" v-for="item in data" :item="item" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import GridItem from "./GridItem";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "Grid",
|
|
||||||
components: {
|
|
||||||
GridItem
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
data: Array
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="less">
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
column-gap: 1rem;
|
|
||||||
row-gap: 1.5rem;
|
|
||||||
|
|
||||||
@media screen and (min-width: @md) {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: @lg) {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: @xl) {
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
93
src/components/Grid/DeleteItem.vue
Normal file
93
src/components/Grid/DeleteItem.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<button class="delete-btn" @click="showPrompt">
|
||||||
|
🚮
|
||||||
|
</button>
|
||||||
|
<div v-if="openPrompt" :class="{ open: openPrompt }" class="prompt">
|
||||||
|
<h3>You will delete '{{ item.name }}'?</h3>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-cancel" @click="openPrompt = false">No</button>
|
||||||
|
<button class="btn btn-success" @click="removeFruit(item.id)">Yes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "DeleteItem",
|
||||||
|
props: {
|
||||||
|
item: Object,
|
||||||
|
redirect: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
openPrompt: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
showPrompt() {
|
||||||
|
this.openPrompt = true;
|
||||||
|
},
|
||||||
|
removeFruit(id) {
|
||||||
|
this.$store.dispatch("removeFruit", id).then(() => this.redirect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.prompt {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgb(230, 107, 107);
|
||||||
|
background: linear-gradient(90deg, rgba(230, 107, 107, 1) 0%, rgba(214, 48, 49, 1) 100%);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
transition: all 0.4s ease-in-out;
|
||||||
|
color: @color-2;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: @color-2;
|
||||||
|
padding: 0.5rem 1.35rem;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background-color: #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background-color: darken(#cecece, 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 55px;
|
||||||
|
padding: 0.75rem 0.75rem 1.35rem 1.35rem;
|
||||||
|
background: rgb(230, 107, 107);
|
||||||
|
background: linear-gradient(180deg, rgba(230, 107, 107, 0.6) 0%, rgba(214, 48, 49, 1) 100%);
|
||||||
|
border: none;
|
||||||
|
border-bottom-left-radius: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
83
src/components/Grid/Grid.vue
Normal file
83
src/components/Grid/Grid.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="{ error: !loading && !data.length }" class="grid">
|
||||||
|
<!-- Loading placeholder -->
|
||||||
|
<GridItemSkeleton v-if="loading" />
|
||||||
|
<GridItemSkeleton v-if="loading" />
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<GridItem v-else :key="item.id" v-for="item in data" :item="item" />
|
||||||
|
|
||||||
|
<!-- Error messages -->
|
||||||
|
<p v-if="!loading && !data.length" class="alert"><b>💡 Restart API</b>: 0 fruits !</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapState } from "vuex";
|
||||||
|
import GridItemSkeleton from "./GridItemSkeleton";
|
||||||
|
import GridItem from "./GridItem";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Grid",
|
||||||
|
components: {
|
||||||
|
GridItemSkeleton,
|
||||||
|
GridItem
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
data: Array
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapState(["loading"])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
column-gap: 1rem;
|
||||||
|
row-gap: 1.5rem;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: @md) {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: @lg) {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: @xl) {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 80%;
|
||||||
|
background-color: @color-3;
|
||||||
|
color: @color-2;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
@media screen and (min-width: @sm) {
|
||||||
|
width: 50%;
|
||||||
|
max-width: 535px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: rgb(45, 48, 55);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: @color-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<article class="grid-item">
|
<article class="grid-item">
|
||||||
|
<DeleteItem :item="item" />
|
||||||
<router-link :to="`/fruit/${item.id}`">
|
<router-link :to="`/fruit/${item.id}`">
|
||||||
<div class="thumbnail">
|
<div class="thumbnail">
|
||||||
<img :src="item.image" :alt="item.name" />
|
<img :src="item.image" :alt="item.name" />
|
||||||
@@ -19,7 +20,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import DeleteItem from "./DeleteItem";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
name: "GridItem",
|
||||||
|
components: { DeleteItem },
|
||||||
props: {
|
props: {
|
||||||
item: Object
|
item: Object
|
||||||
},
|
},
|
||||||
@@ -33,6 +38,8 @@ export default {
|
|||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.grid-item {
|
.grid-item {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
background-color: @color-2;
|
background-color: @color-2;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
26
src/components/Grid/GridItemSkeleton.vue
Normal file
26
src/components/Grid/GridItemSkeleton.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<VueContentLoading
|
||||||
|
:style="{ backgroundColor: '#ffffff', padding: '1rem', borderRadius: '10px' }"
|
||||||
|
primary="#cecece"
|
||||||
|
:width="300"
|
||||||
|
:height="220"
|
||||||
|
>
|
||||||
|
<rect x="0" y="0" rx="4" ry="4" width="100%" height="100" />
|
||||||
|
<rect x="30" y="110" rx="4" ry="4" width="80%" height="15" />
|
||||||
|
<rect x="100px" y="150" width="100" height="2" />
|
||||||
|
<rect x="100px" y="175" rx="4" ry="4" width="100" height="35" />
|
||||||
|
</VueContentLoading>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import VueContentLoading from "vue-content-loading";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "GridItemSkeleton",
|
||||||
|
components: {
|
||||||
|
VueContentLoading
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less"></style>
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span :style="{ height, width: computedWidth }" class="skeleton" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: "Skeleton",
|
|
||||||
props: {
|
|
||||||
maxWidth: {
|
|
||||||
// The default maxiumum width is 100%.
|
|
||||||
default: 100,
|
|
||||||
type: Number
|
|
||||||
},
|
|
||||||
minWidth: {
|
|
||||||
// Lines have a minimum width of 80%.
|
|
||||||
default: 80,
|
|
||||||
type: Number
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
// Make lines the same height as text.
|
|
||||||
default: "1em",
|
|
||||||
type: String
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
// Make it possible to define a fixed
|
|
||||||
// width instead of using a random one.
|
|
||||||
default: null,
|
|
||||||
type: String
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
computedWidth() {
|
|
||||||
// Either use the given fixed width or
|
|
||||||
// a random width between the given min
|
|
||||||
// and max values.
|
|
||||||
return (
|
|
||||||
this.width ||
|
|
||||||
`${Math.floor(Math.random() * (this.maxWidth - this.minWidth) + this.minWidth)}%`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="less">
|
|
||||||
.skeleton {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
vertical-align: middle;
|
|
||||||
background-color: darken(#dddbdd, 8%);
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
background-image: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(#fff, 0) 0,
|
|
||||||
rgba(#fff, 0.2) 20%,
|
|
||||||
rgba(#fff, 0.5) 60%,
|
|
||||||
rgba(#fff, 0)
|
|
||||||
);
|
|
||||||
animation: shimmer 5s infinite;
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
100% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -9,25 +9,31 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
if (err.message === "Network Error") throw "Check API connectivity ...";
|
if (err.message === "Network Error") throw "Check API connectivity ...";
|
||||||
});
|
})
|
||||||
|
.finally(() => commit("setLoading", false));
|
||||||
},
|
},
|
||||||
getFruit: async ({ commit }, id) => {
|
getFruit: async ({ commit }, id) => {
|
||||||
const response = await axios.get(`http://localhost:3000/fruit/${id}`);
|
await axios.get(`http://localhost:3000/fruit/${id}`).then(res => commit("setFruit", res.data));
|
||||||
commit("setFruit", response.data);
|
|
||||||
},
|
},
|
||||||
getImageFromUnsplash: async (_, keyword) => {
|
removeFruit: async ({ commit }, id) => {
|
||||||
const response = await axios.get(
|
await axios
|
||||||
`https://api.unsplash.com/search/photos?query=${keyword}&w=800&h=400`,
|
.delete(`http://localhost:3000/fruit/${id}`)
|
||||||
{
|
.then(() => commit("removeFruit", id))
|
||||||
|
.catch(err => console.log(err));
|
||||||
|
},
|
||||||
|
getImageFromUnsplash: async ({ commit }, keyword) => {
|
||||||
|
commit("setLoading", true);
|
||||||
|
return await axios
|
||||||
|
.get(`https://api.unsplash.com/search/photos?query=${keyword}&w=800&h=400`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Client-ID NjFwGYsnksnzH2uh12W55aobUpTe0h06oSVACd5cWt0",
|
Authorization: "Client-ID NjFwGYsnksnzH2uh12W55aobUpTe0h06oSVACd5cWt0",
|
||||||
"X-Total": 1
|
"X-Total": 1
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
);
|
.then(res => {
|
||||||
|
if (res.data.total === 0) throw new Error("0 found.");
|
||||||
if (response.data.total === 0) throw new Error("0 found.");
|
return res.data.results[0];
|
||||||
|
})
|
||||||
return response.data.results[0];
|
.finally(() => commit("setLoading", false));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1 @@
|
|||||||
export default {
|
export default {};
|
||||||
parsedFruits: () => {
|
|
||||||
return [{ id: 1 }];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import getters from "./getters";
|
|||||||
export const state = {
|
export const state = {
|
||||||
fruits: [],
|
fruits: [],
|
||||||
fruit: {},
|
fruit: {},
|
||||||
modalIsOpen: false
|
modalIsOpen: false,
|
||||||
|
loading: true
|
||||||
};
|
};
|
||||||
|
|
||||||
Vue.use(Vuex);
|
Vue.use(Vuex);
|
||||||
|
|||||||
@@ -15,5 +15,8 @@ export default {
|
|||||||
},
|
},
|
||||||
toggleModal: state => {
|
toggleModal: state => {
|
||||||
state.modalIsOpen = !state.modalIsOpen;
|
state.modalIsOpen = !state.modalIsOpen;
|
||||||
|
},
|
||||||
|
setLoading: (state, loading) => {
|
||||||
|
state.loading = loading;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<article>
|
<article>
|
||||||
<img :src="fruit.image" :alt="fruit.name" />
|
<img :src="fruit.image" :alt="fruit.name" />
|
||||||
<section>
|
<section>
|
||||||
|
<DeleteItem :item="fruit" :redirect="() => $router.push('/')" />
|
||||||
<h3>
|
<h3>
|
||||||
{{ fruit.name }}
|
{{ fruit.name }}
|
||||||
<span class="tag" :style="{ backgroundColor: fruit.color }">{{ fruit.taste }}</span>
|
<span class="tag" :style="{ backgroundColor: fruit.color }">{{ fruit.taste }}</span>
|
||||||
@@ -25,9 +26,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapState } from "vuex";
|
import { mapState } from "vuex";
|
||||||
|
import DeleteItem from "@/components/Grid/DeleteItem";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "FruitDetails",
|
name: "FruitDetails",
|
||||||
|
components: { DeleteItem },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
modalOpen: true
|
modalOpen: true
|
||||||
@@ -69,6 +72,8 @@ article {
|
|||||||
}
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
background-color: @color-2;
|
background-color: @color-2;
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapState } from "vuex";
|
import { mapState } from "vuex";
|
||||||
import Grid from "@/components/Grid";
|
import Grid from "@/components/Grid/Grid";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Fruits",
|
name: "Fruits",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ let fruit = {
|
|||||||
expires: "2021-04-11T08:54:24.588Z"
|
expires: "2021-04-11T08:54:24.588Z"
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Test Vuex mutations.", () => {
|
describe("Vuex mutations.", () => {
|
||||||
it("tests setFruits mutation.", () => {
|
it("tests setFruits mutation.", () => {
|
||||||
setFruits(state, [fruit]);
|
setFruits(state, [fruit]);
|
||||||
expect(state.fruits[0].id).toBe(3);
|
expect(state.fruits[0].id).toBe(3);
|
||||||
|
|||||||
@@ -11032,6 +11032,13 @@ vm-browserify@^1.0.1:
|
|||||||
resolved "https://registry.npm.taobao.org/vm-browserify/download/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
|
resolved "https://registry.npm.taobao.org/vm-browserify/download/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
|
||||||
integrity sha1-eGQcSIuObKkadfUR56OzKobl3aA=
|
integrity sha1-eGQcSIuObKkadfUR56OzKobl3aA=
|
||||||
|
|
||||||
|
vue-content-loading@^1.6.0:
|
||||||
|
version "1.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue-content-loading/-/vue-content-loading-1.6.0.tgz#8ba1e21e27b8932712bea62b5a00320277a98fdd"
|
||||||
|
integrity sha512-D8vVW8eUhDE1VsX/gMRKjgNEVlPG4E4jKCuQjiv75VwnznFRjCGpoU2LABrSzmf6V/hb8ow8rHrFPuz3jVNsqA==
|
||||||
|
dependencies:
|
||||||
|
vue "^2.5.13"
|
||||||
|
|
||||||
vue-eslint-parser@^7.0.0:
|
vue-eslint-parser@^7.0.0:
|
||||||
version "7.2.0"
|
version "7.2.0"
|
||||||
resolved "https://registry.npm.taobao.org/vue-eslint-parser/download/vue-eslint-parser-7.2.0.tgz?cache=0&sync_timestamp=1606966106135&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fvue-eslint-parser%2Fdownload%2Fvue-eslint-parser-7.2.0.tgz#1e17ae94ca71e617025e05143c8ac5593aacb6ef"
|
resolved "https://registry.npm.taobao.org/vue-eslint-parser/download/vue-eslint-parser-7.2.0.tgz?cache=0&sync_timestamp=1606966106135&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fvue-eslint-parser%2Fdownload%2Fvue-eslint-parser-7.2.0.tgz#1e17ae94ca71e617025e05143c8ac5593aacb6ef"
|
||||||
@@ -11119,7 +11126,7 @@ vue-template-es2015-compiler@^1.6.0, vue-template-es2015-compiler@^1.9.0:
|
|||||||
resolved "https://registry.npm.taobao.org/vue-template-es2015-compiler/download/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
|
resolved "https://registry.npm.taobao.org/vue-template-es2015-compiler/download/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
|
||||||
integrity sha1-HuO8mhbsv1EYvjNLsV+cRvgvWCU=
|
integrity sha1-HuO8mhbsv1EYvjNLsV+cRvgvWCU=
|
||||||
|
|
||||||
vue@^2.6.11:
|
vue@^2.5.13, vue@^2.6.11:
|
||||||
version "2.6.12"
|
version "2.6.12"
|
||||||
resolved "https://registry.npm.taobao.org/vue/download/vue-2.6.12.tgz?cache=0&sync_timestamp=1606946138346&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fvue%2Fdownload%2Fvue-2.6.12.tgz#f5ebd4fa6bd2869403e29a896aed4904456c9123"
|
resolved "https://registry.npm.taobao.org/vue/download/vue-2.6.12.tgz?cache=0&sync_timestamp=1606946138346&other_urls=https%3A%2F%2Fregistry.npm.taobao.org%2Fvue%2Fdownload%2Fvue-2.6.12.tgz#f5ebd4fa6bd2869403e29a896aed4904456c9123"
|
||||||
integrity sha1-9evU+mvShpQD4pqJau1JBEVskSM=
|
integrity sha1-9evU+mvShpQD4pqJau1JBEVskSM=
|
||||||
|
|||||||
Reference in New Issue
Block a user