'hybrid app > css3' 카테고리의 다른 글

[css3] box-sizing  (0) 2013.08.20
[css3] max-width  (0) 2013.08.20
[css3] 속성값 여러개 사용할때  (0) 2013.08.20
[css3] !important  (1) 2013.08.20
[css3] MediaQuries 반응형웹  (0) 2013.08.20

'JQuery' 카테고리의 다른 글

[jQuery] 60 Top jQuery Plugins of 2013  (0) 2013.12.16
[jQuery] sliderBar 플러그인  (0) 2013.06.10
[jQuery] Boxer 창만들기 플러그인  (0) 2013.06.10
[jQuery] swipe 플러그인  (0) 2013.06.10

지난번에는 버튼에 여러가지 icon을 넣는 방법을 알아봤었습니다.


이번에는 버튼이미지를 가지고 버튼을 만들어 보겠습니다.


일단 버튼모양의 이미지가 필요합니다. 


이미지는 평상시 버튼이미지와 버튼을 눌렀을때의 이미지 두장이 필요합니다.


이미지는 http://www.iconfinder.com/에서 button으로만 검색해도 쉽게 찾을 수 있습니다.


이미지를 센차프로젝트안에 복사합니다.



어디에 넣든 경로만 알고 참조해 오면 상관 없지만 


필자는 resources -> images 폴더안에 복사해 넣었습니다.


그리고 위 사진에서 sass폴더안에 scss파일에 해당 버튼이미지를 등록시켜 사용하면 됩니다.



전에 icon이미지를 사용할때도 설명했지만 프로젝트를 생성한후 기본적으로 적용되는 scss는 app.scss입니다.


여러분들은 이 scss에 버튼 이미지를 등록하면 됩니다.



위와 같이 버튼 이미지에 이름을 붙여 버튼을 등록 합니다.


그리고 scss파일을 컴파일 해야 등록한 이미지버튼을 사용할 수 있습니다.


scss파일을 컴파일 하면 css가 생성되는데 이 css를 참조하여 우리는 사용하게 되는 것입니다.


컴파일 방법은 


터미널 창에서 sass폴더로 경로를 이동힙니다.


이동방법을 모르면 cd를 입력후 해당 폴더를 터미널 창에 끌어다 놓습니다.




그리고 compass compile <scss파일이름>을 입력해 컴파일 합니다.



컴파일이 끝나면 이미지를 사용할 수 있습니다.


이제 view class에서 다음과 같이 사용하면 됩니다.



cls에 추가한 버튼 이름을 넣고 pressedCls는 버튼을 눌렀을때 사용할 등록한 이미지 이름을 넣어줍니다.


버튼을 눌러보면 잘 적용된걸 확인할 수 있을겁니다.


평상시


눌렀을때


NumberPicker.js


직접 제작한 NumberPicker입니다.


그냥 키패드로 입력받아도 되지만 이렇게만 받아야 하는 상황이 있어 만들어봤습니다.



백의 자리까지 소수점은 첫째짜리까지 입력받습니다.


사용법은 다음과 같습니다.


일단 다운받은 파일을 아래 경로에 복사합니다.


touch -> src -> ux -> field



그리고 view class에 다음과 같이 추가하여 사용하면 됩니다.




'Git' 카테고리의 다른 글

[Git] 관리 항목에서 제외시키기  (0) 2014.08.05
Synology nas에 git설치하기  (0) 2014.04.16

따로 설치 방법을 설명 안해도 될만큼 간단합니다.


홈페이지에서 다운받고 인스톨러로 설치하면 됩니다.


http://nodejs.org/ 홈페이지에 가서 nodejs를 다운 받습니다.



다운받은 페이키지 파일을 실행하고 설치합니다.





'node.js' 카테고리의 다른 글

[node.js] -bash: express: command not found 에러  (0) 2015.06.30
[node.js] connect 모듈  (0) 2013.06.15
[node.js] url 모듈  (0) 2013.06.13
[node.js] 모듈 생성, 추출(exprots, require)  (0) 2013.06.13
[node.js] 간단한 웹서버 만들기  (0) 2013.06.13

phonegap plugin을 사용하면 다양한 네이티브 기능을 가능하게 해준다.


이 플러그인을 직접 만들어보자.


일단 폰갭프로젝트를 생성한다.


http://squll1.tistory.com/entry/phonegap-cordovaios-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1


우선 알아두어야 할 것은 폰갭 플러그인을 만들기 위해서는 CDVPlugin을 상속받아야만 한다.


이제 간단하게 AlertView를 호출하는 플러그인을 만들어 보겠다.



1. 첫번째로 네이티브 기능을 만들어 보겠습니다.


Plugins폴더에 파일을 만듭니다.



Cocoa Touch에 Objective-C class를 선택하고 next를 누릅니다.



class이름을 입력하고 subclass는 CDVPlugin으로 합니다.


next를 누르고 create버튼을 눌러 생성합니다.


~~.h 파일에 에러가 나는데 다음과 같이 수정합니다.




그리고 네이티브 기능을 작성하시면 됩니다.





2. 두번째로 config.xml파일을 열어 방금 작성한 플러그인을 추가 해야 합니다.



name에는 이 플러그인을 사용할 이름과 value는 생성한 클래스의 이름을 입력합니다.





3. 세번째로 자바스크립트에서 플러그인을 호출합니다.


cordova.exec(<successfunction>, <failfunction>, <service>, <action>, [<args>]);


cordova.exec(function(){console.log('success')},function(err){console.log('err')},"AlertView","customAlertView",["플러그인 테스트"]);




이렇게 추가하고 실행 시켜면 다음과 같이 실행된다.



<success function>도 잘 실행된걸 확인할 수 있습니다.



cordova.exec(function(){console.log('success')},function(err){console.log('err')},"AlertView","customAlertView",["플러그인 테스트"]);


이 부분을 다시 살펴보겠습니다.


우선 세번째  "AlertView"는 config.xml에서 플러그인을 추가 했을때 name의 값을 넣어줍니다. 


그리고 네번째 "customAlertView"는 생성한 클래스에서 사용할 메소드명을 넣어줍니다.


다섯번째는 네이티브쪽으로 전달해줄 정보를 넣어줍니다.


위에서 설정한 정보가 전달되고 함수가 실행되어 네이티브 기능이 실행됩니다.


그리고 이렇게 네이티브 기능이 실행된후 잘 실행됐는지 안됐는지 기타 정보들을 담아서 다시 자바 스크립스 상으로 정보를 전달합니다.


코드를 다시 살펴보면



위 코드의 pluginResult와 resultJS를 통해 플러그인이 잘 실행됐는지 잘못실행됐는지 정보를 담아


자바스크립트 쪽으로 정보를 전달합니다.


cordova.exec(function(){console.log('success')},function(err){console.log('err')},"AlertView","customAlertView",["플러그인 테스트"]);


에서 첫번째나 두번째 함수가 실행됩니다.


이 자바스크립트로 전달할때 다른 정보를 담아서 전달 할수도 있습니다.


문자열을 전달할수도 있고 배열에 정보를 담아 전달 할 수 있습니다.


다음과 같이 정보를 담아 전달해 보자 



다음과 같이 정보가 담기는걸 확인 할 수 있다.



이 콜백되는 정보를 받아 적절히 사용해 개발할 경우도 생긴다.


첫번째 <successfunction>에서 정보를 받아온다.


아래와 같이 정보를 받아와 콘솔창에 출력해 본다.



정보를 잘 받아오는걸 확인할 수 있다.



현재 이 글을 작성하는 시점에서 phonegap plugin인 childbrowser를 android에 적용시키면 작동되지 않는다.


플러그인이 이전 1.x대 버전으로 업로드 돼 있어 정상적으로 실행이 안된다.


단순히 페이지만 보여주는 것이라면 


2.3 이상부터는 InAppBrowser가 생겨서 childbrowser를 사용할 필요가 없지만


childbrowser를 통한 oauth인증을 위해서라면 아직 childbrowser가 필요하다.


ChildBrowser.java파일을 다음과 같이 수정하자


/*

 * PhoneGap is available under *either* the terms of the modified BSD license *or* the

 * MIT License (2008). See http://opensource.org/licenses/alphabetical for full text.

 *

 * Copyright (c) 2005-2011, Nitobi Software Inc.

 * Copyright (c) 2010-2011, IBM Corporation

 */

package com.phonegap.plugins.childBrowser;


import java.io.IOException;

import java.io.InputStream;


import org.apache.cordova.api.CordovaPlugin; //수정

import org.apache.cordova.api.PluginResult;

import org.apache.cordova.api.CallbackContext; //추가

import org.json.JSONArray;

import org.json.JSONException;

import org.json.JSONObject;


import android.app.Dialog;

import android.content.Context;

import android.content.DialogInterface;

import android.content.Intent;

import android.graphics.Bitmap;

import android.graphics.BitmapFactory;

import android.net.Uri;

import android.text.InputType;

import android.util.Log;

import android.util.TypedValue;

import android.view.Gravity;

import android.view.KeyEvent;

import android.view.View;

import android.view.Window;

import android.view.WindowManager;

import android.view.WindowManager.LayoutParams;

import android.view.inputmethod.EditorInfo;

import android.view.inputmethod.InputMethodManager;

import android.webkit.WebChromeClient;

import android.webkit.WebSettings;

import android.webkit.WebView;

import android.webkit.WebViewClient;

import android.widget.EditText;

import android.widget.ImageButton;

import android.widget.LinearLayout;

import android.widget.RelativeLayout;


public class ChildBrowser extends CordovaPlugin { //수정


    protected static final String LOG_TAG = "ChildBrowser";

    private static int CLOSE_EVENT = 0;

    private static int LOCATION_CHANGED_EVENT = 1;


//    private String browserCallbackId = null; //삭제

    private CallbackContext context; //추가


    private Dialog dialog;

    private WebView webview;

    private EditText edittext;

    private boolean showLocationBar = true;


    /**

     * Executes the request and returns PluginResult.

     *

     * @param action        The action to execute.

     * @param args          JSONArry of arguments for the plugin.

     * @param callbackId    The callback id used when calling back into JavaScript.

     * @return              A PluginResult object with a status and message.

     */

//    public PluginResult execute(String action, JSONArray args, String callbackId) { //삭제

    @Override

    public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException{  //추가

        PluginResult.Status status = PluginResult.Status.OK;

        this.context=callbackContext;//추가

        String result = "";


        try {

            if (action.equals("showWebPage")) {

//                this.browserCallbackId = callbackId;  //삭제


                // If the ChildBrowser is already open then throw an error

                if (dialog != null && dialog.isShowing()) {

//                    return new PluginResult(PluginResult.Status.ERROR, "ChildBrowser is already open");  //삭제

                this.context.sendPluginResult(new PluginResult(PluginResult.Status.ERROR, "ChlildBrowser is already open"));  //추가

                return true; //추가

                }


                result = this.showWebPage(args.getString(0), args.optJSONObject(1));


                if (result.length() > 0) {

                    status = PluginResult.Status.ERROR;

//                    return new PluginResult(status, result); //삭제

                    this.context.sendPluginResult(new PluginResult(status, result));  //추가

                } else {

                    PluginResult pluginResult = new PluginResult(status, result);

                    pluginResult.setKeepCallback(true);

//                    return pluginResult;  //삭제

                    this.context.sendPluginResult(pluginResult);  //추가

                    return true;  //추가

                }

            }

            else if (action.equals("close")) {

                closeDialog();


                JSONObject obj = new JSONObject();

                obj.put("type", CLOSE_EVENT);


                PluginResult pluginResult = new PluginResult(status, obj);

                pluginResult.setKeepCallback(false);

//                return pluginResult;  //삭제

                this.context.sendPluginResult(pluginResult);  //추가

                return true;  //추가

            }

            else if (action.equals("openExternal")) {

                result = this.openExternal(args.getString(0), args.optBoolean(1));

                if (result.length() > 0) {

                    status = PluginResult.Status.ERROR;

                }

            }

            else {

                status = PluginResult.Status.INVALID_ACTION;

            }

//            return new PluginResult(status, result);  //삭제

            this.context.sendPluginResult(new PluginResult(status, result));  //추가

            return true;  //추가

        } catch (JSONException e) {

//            return new PluginResult(PluginResult.Status.JSON_EXCEPTION);  //삭제

        this.context.sendPluginResult(new PluginResult(PluginResult.Status.JSON_EXCEPTION));  //추가

        return true;  //추가

        }

    }


    /**

     * Display a new browser with the specified URL.

     *

     * @param url           The url to load.

     * @param usePhoneGap   Load url in PhoneGap webview

     * @return              "" if ok, or error message.

     */

    public String openExternal(String url, boolean usePhoneGap) {

        try {

            Intent intent = null;

            if (usePhoneGap) {

                intent = new Intent().setClass(this.cordova.getActivity(), org.apache.cordova.DroidGap.class);

                intent.setData(Uri.parse(url)); // This line will be removed in future.

                intent.putExtra("url", url);


                // Timeout parameter: 60 sec max - May be less if http device timeout is less.

                intent.putExtra("loadUrlTimeoutValue", 60000);


                // These parameters can be configured if you want to show the loading dialog

                intent.putExtra("loadingDialog", "Wait,Loading web page...");   // show loading dialog

                intent.putExtra("hideLoadingDialogOnPageLoad", true);           // hide it once page has completely loaded

            }

            else {

                intent = new Intent(Intent.ACTION_VIEW);

                intent.setData(Uri.parse(url));

            }

            this.cordova.getActivity().startActivity(intent);

            return "";

        } catch (android.content.ActivityNotFoundException e) {

            Log.d(LOG_TAG, "ChildBrowser: Error loading url "+url+":"+ e.toString());

            return e.toString();

        }

    }


    /**

     * Closes the dialog

     */

    private void closeDialog() {

        if (dialog != null) {

            dialog.dismiss();

        }

    }


    /**

     * Checks to see if it is possible to go back one page in history, then does so.

     */

    private void goBack() {

        if (this.webview.canGoBack()) {

            this.webview.goBack();

        }

    }


    /**

     * Checks to see if it is possible to go forward one page in history, then does so.

     */

    private void goForward() {

        if (this.webview.canGoForward()) {

            this.webview.goForward();

        }

    }


    /**

     * Navigate to the new page

     *

     * @param url to load

     */

    private void navigate(String url) {

        InputMethodManager imm = (InputMethodManager)this.cordova.getActivity().getSystemService(Context.INPUT_METHOD_SERVICE);

        imm.hideSoftInputFromWindow(edittext.getWindowToken(), 0);


        if (!url.startsWith("http") && !url.startsWith("file:")) {

            this.webview.loadUrl("http://" + url);

        } else {

            this.webview.loadUrl(url);

        }

        this.webview.requestFocus();

    }



    /**

     * Should we show the location bar?

     *

     * @return boolean

     */

    private boolean getShowLocationBar() {

        return this.showLocationBar;

    }


    /**

     * Display a new browser with the specified URL.

     *

     * @param url           The url to load.

     * @param jsonObject

     */

    public String showWebPage(final String url, JSONObject options) {

        // Determine if we should hide the location bar.

        if (options != null) {

            showLocationBar = options.optBoolean("showLocationBar", true);

        }


        // Create dialog in new thread

        Runnable runnable = new Runnable() {

            /**

             * Convert our DIP units to Pixels

             *

             * @return int

             */

            private int dpToPixels(int dipValue) {

                int value = (int) TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP,

                                                            (float) dipValue,

                                                            cordova.getActivity().getResources().getDisplayMetrics()

                );


                return value;

            }


            public void run() {

                // Let's create the main dialog

                dialog = new Dialog(cordova.getActivity(), android.R.style.Theme_NoTitleBar);

                dialog.getWindow().getAttributes().windowAnimations = android.R.style.Animation_Dialog;

                dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);

                dialog.setCancelable(true);

                dialog.setOnDismissListener(new DialogInterface.OnDismissListener() {

                        public void onDismiss(DialogInterface dialog) {

                            try {

                                JSONObject obj = new JSONObject();

                                obj.put("type", CLOSE_EVENT);


                                sendUpdate(obj, false);

                            } catch (JSONException e) {

                                Log.d(LOG_TAG, "Should never happen");

                            }

                        }

                });


                // Main container layout

                LinearLayout main = new LinearLayout(cordova.getActivity());

                main.setOrientation(LinearLayout.VERTICAL);


                // Toolbar layout

                RelativeLayout toolbar = new RelativeLayout(cordova.getActivity());

                toolbar.setLayoutParams(new RelativeLayout.LayoutParams(LayoutParams.FILL_PARENT, this.dpToPixels(44)));

                toolbar.setPadding(this.dpToPixels(2), this.dpToPixels(2), this.dpToPixels(2), this.dpToPixels(2));

                toolbar.setHorizontalGravity(Gravity.LEFT);

                toolbar.setVerticalGravity(Gravity.TOP);


                // Action Button Container layout

                RelativeLayout actionButtonContainer = new RelativeLayout(cordova.getActivity());

                actionButtonContainer.setLayoutParams(new RelativeLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT));

                actionButtonContainer.setHorizontalGravity(Gravity.LEFT);

                actionButtonContainer.setVerticalGravity(Gravity.CENTER_VERTICAL);

                actionButtonContainer.setId(1);


                // Back button

                ImageButton back = new ImageButton(cordova.getActivity());

                RelativeLayout.LayoutParams backLayoutParams = new RelativeLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.FILL_PARENT);

                backLayoutParams.addRule(RelativeLayout.ALIGN_LEFT);

                back.setLayoutParams(backLayoutParams);

                back.setContentDescription("Back Button");

                back.setId(2);

                try {

                    back.setImageBitmap(loadDrawable("www/childbrowser/icon_arrow_left.png"));

                } catch (IOException e) {

                    Log.e(LOG_TAG, e.getMessage(), e);

                }

                back.setOnClickListener(new View.OnClickListener() {

                    public void onClick(View v) {

                        goBack();

                    }

                });


                // Forward button

                ImageButton forward = new ImageButton(cordova.getActivity());

                RelativeLayout.LayoutParams forwardLayoutParams = new RelativeLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.FILL_PARENT);

                forwardLayoutParams.addRule(RelativeLayout.RIGHT_OF, 2);

                forward.setLayoutParams(forwardLayoutParams);

                forward.setContentDescription("Forward Button");

                forward.setId(3);

                try {

                    forward.setImageBitmap(loadDrawable("www/childbrowser/icon_arrow_right.png"));

                } catch (IOException e) {

                    Log.e(LOG_TAG, e.getMessage(), e);

                }

                forward.setOnClickListener(new View.OnClickListener() {

                    public void onClick(View v) {

                        goForward();

                    }

                });


                // Edit Text Box

                edittext = new EditText(cordova.getActivity());

                RelativeLayout.LayoutParams textLayoutParams = new RelativeLayout.LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT);

                textLayoutParams.addRule(RelativeLayout.RIGHT_OF, 1);

                textLayoutParams.addRule(RelativeLayout.LEFT_OF, 5);

                edittext.setLayoutParams(textLayoutParams);

                edittext.setId(4);

                edittext.setSingleLine(true);

                edittext.setText(url);

                edittext.setInputType(InputType.TYPE_TEXT_VARIATION_URI);

                edittext.setImeOptions(EditorInfo.IME_ACTION_GO);

                edittext.setInputType(InputType.TYPE_NULL); // Will not except input... Makes the text NON-EDITABLE

                edittext.setOnKeyListener(new View.OnKeyListener() {

                    public boolean onKey(View v, int keyCode, KeyEvent event) {

                        // If the event is a key-down event on the "enter" button

                        if ((event.getAction() == KeyEvent.ACTION_DOWN) && (keyCode == KeyEvent.KEYCODE_ENTER)) {

                          navigate(edittext.getText().toString());

                          return true;

                        }

                        return false;

                    }

                });


                // Close button

                ImageButton close = new ImageButton(cordova.getActivity());

                RelativeLayout.LayoutParams closeLayoutParams = new RelativeLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.FILL_PARENT);

                closeLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);

                close.setLayoutParams(closeLayoutParams);

                forward.setContentDescription("Close Button");

                close.setId(5);

                try {

                    close.setImageBitmap(loadDrawable("www/childbrowser/icon_close.png"));

                } catch (IOException e) {

                    Log.e(LOG_TAG, e.getMessage(), e);

                }

                close.setOnClickListener(new View.OnClickListener() {

                    public void onClick(View v) {

                        closeDialog();

                    }

                });


                // WebView

                webview = new WebView(cordova.getActivity());

                webview.setLayoutParams(new LinearLayout.LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT));

                webview.setWebChromeClient(new WebChromeClient());

                WebViewClient client = new ChildBrowserClient(edittext);

                webview.setWebViewClient(client);

                WebSettings settings = webview.getSettings();

                settings.setJavaScriptEnabled(true);

                settings.setJavaScriptCanOpenWindowsAutomatically(true);

                settings.setBuiltInZoomControls(true);

                settings.setPluginsEnabled(true);

                settings.setDomStorageEnabled(true);

                webview.loadUrl(url);

                webview.setId(6);

                webview.getSettings().setLoadWithOverviewMode(true);

                webview.getSettings().setUseWideViewPort(true);

                webview.requestFocus();

                webview.requestFocusFromTouch();


                // Add the back and forward buttons to our action button container layout

                actionButtonContainer.addView(back);

                actionButtonContainer.addView(forward);


                // Add the views to our toolbar

                toolbar.addView(actionButtonContainer);

                toolbar.addView(edittext);

                toolbar.addView(close);


                // Don't add the toolbar if its been disabled

                if (getShowLocationBar()) {

                    // Add our toolbar to our main view/layout

                    main.addView(toolbar);

                }


                // Add our webview to our main view/layout

                main.addView(webview);


                WindowManager.LayoutParams lp = new WindowManager.LayoutParams();

                lp.copyFrom(dialog.getWindow().getAttributes());

                lp.width = WindowManager.LayoutParams.FILL_PARENT;

                lp.height = WindowManager.LayoutParams.FILL_PARENT;


                dialog.setContentView(main);

                dialog.show();

                dialog.getWindow().setAttributes(lp);

            }


          private Bitmap loadDrawable(String filename) throws java.io.IOException {

              InputStream input = cordova.getActivity().getAssets().open(filename);

              return BitmapFactory.decodeStream(input);

          }

        };

        this.cordova.getActivity().runOnUiThread(runnable);

        return "";

    }


    /**

     * Create a new plugin result and send it back to JavaScript

     *

     * @param obj a JSONObject contain event payload information

     */

    private void sendUpdate(JSONObject obj, boolean keepCallback) {

//        if (this.browserCallbackId != null) {  //삭제

    if(this.context!=null){  //추가

            PluginResult result = new PluginResult(PluginResult.Status.OK, obj);

            result.setKeepCallback(keepCallback);

//            this.success(result, this.browserCallbackId);  //삭제

            this.context.sendPluginResult(result);  //추가

        }

    }


    /**

     * The webview client receives notifications about appView

     */

    public class ChildBrowserClient extends WebViewClient {

        EditText edittext;


        /**

         * Constructor.

         *

         * @param mContext

         * @param edittext

         */

        public ChildBrowserClient(EditText mEditText) {

            this.edittext = mEditText;

        }


        /**

         * Notify the host application that a page has started loading.

         *

         * @param view          The webview initiating the callback.

         * @param url           The url of the page.

         */

        @Override

        public void onPageStarted(WebView view, String url,  Bitmap favicon) {

            super.onPageStarted(view, url, favicon);

            String newloc;

            if (url.startsWith("http:") || url.startsWith("https:") || url.startsWith("file:")) {

                newloc = url;

            } else {

                newloc = "http://" + url;

            }


            if (!newloc.equals(edittext.getText().toString())) {

                edittext.setText(newloc);

            }


            try {

                JSONObject obj = new JSONObject();

                obj.put("type", LOCATION_CHANGED_EVENT);

                obj.put("location", url);


                sendUpdate(obj, true);

            } catch (JSONException e) {

                Log.d("ChildBrowser", "This should never happen");

            }

        }

    }

}




+ Recent posts