diff --git a/apiki-favorites/apiki-favorites.php b/apiki-favorites/apiki-favorites.php new file mode 100644 index 00000000..d3cb96cb --- /dev/null +++ b/apiki-favorites/apiki-favorites.php @@ -0,0 +1,36 @@ +initialize(); +} + +apiki_favorites_run(); \ No newline at end of file diff --git a/apiki-favorites/assets/apiki-favorites.js b/apiki-favorites/assets/apiki-favorites.js new file mode 100644 index 00000000..821d2047 --- /dev/null +++ b/apiki-favorites/assets/apiki-favorites.js @@ -0,0 +1,60 @@ +(function () { + 'use strict'; + + function updateButtonState(button, favorited, text) { + button.setAttribute('data-favorited', favorited ? 'true' : 'false'); + button.setAttribute('aria-pressed', favorited ? 'true' : 'false'); + button.disabled = false; + button.textContent = text; + } + + function handleClick(event) { + var button = event.target; + + if (!button.classList.contains('apiki-favorite-button')) { + return; + } + + event.preventDefault(); + + if (!window.apikiFavorites || !window.apikiFavorites.isLoggedIn) { + window.alert(window.apikiFavorites.loginRequiredText); + return; + } + + var postId = parseInt(button.getAttribute('data-post-id'), 10); + var favorited = button.getAttribute('data-favorited') === 'true'; + var method = favorited ? 'DELETE' : 'POST'; + var nextText = favorited ? window.apikiFavorites.favoriteText : window.apikiFavorites.unfavoriteText; + + if (!postId) { + return; + } + + button.disabled = true; + button.textContent = window.apikiFavorites.loadingText; + + window.fetch(window.apikiFavorites.restUrl + postId, { + method: method, credentials: 'same-origin', headers: { + 'X-WP-Nonce': window.apikiFavorites.nonce, 'Content-Type': 'application/json' + } + }) + .then(function (response) { + if (!response.ok) { + throw new Error('Request failed'); + } + + return response.json(); + }) + .then(function () { + updateButtonState(button, !favorited, nextText); + }) + .catch(function () { + button.disabled = false; + button.textContent = favorited ? window.apikiFavorites.unfavoriteText : window.apikiFavorites.favoriteText; + window.alert(window.apikiFavorites.errorText); + }); + } + + document.addEventListener('click', handleClick); +}()); \ No newline at end of file diff --git a/apiki-favorites/includes/class-apiki-favorites-installer.php b/apiki-favorites/includes/class-apiki-favorites-installer.php new file mode 100644 index 00000000..f28ec9d9 --- /dev/null +++ b/apiki-favorites/includes/class-apiki-favorites-installer.php @@ -0,0 +1,46 @@ +prefix . 'apiki_favorites'; + } + + private static function createTable(): void + { + global $wpdb; + + $table_name = self::getTableName(); + $charset_collate = $wpdb->get_charset_collate(); + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $sql = "CREATE TABLE {$table_name} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY user_post_unique (user_id, post_id), + KEY user_id (user_id), + KEY post_id (post_id) + ) {$charset_collate};"; + + dbDelta( $sql ); + } +} \ No newline at end of file diff --git a/apiki-favorites/includes/class-apiki-favorites-plugin.php b/apiki-favorites/includes/class-apiki-favorites-plugin.php new file mode 100644 index 00000000..12dc96ee --- /dev/null +++ b/apiki-favorites/includes/class-apiki-favorites-plugin.php @@ -0,0 +1,38 @@ +repository = new Apiki_Favorites_Repository(); + $this->rest_controller = new Apiki_Favorites_REST_Controller($this->repository); + $this->shortcode = new Apiki_Favorites_Shortcode($this->repository); + } + + public function initialize(): void + { + add_action('rest_api_init', array($this->rest_controller, 'register_routes')); + add_action('wp_enqueue_scripts', array($this->shortcode, 'registerAssets')); + add_shortcode('apiki_favorite_button', array($this->shortcode, 'showButton')); + add_shortcode('apiki_favorite_list', array($this->shortcode, 'showFavoriteList')); + } +} \ No newline at end of file diff --git a/apiki-favorites/includes/class-apiki-favorites-repository.php b/apiki-favorites/includes/class-apiki-favorites-repository.php new file mode 100644 index 00000000..d74e322e --- /dev/null +++ b/apiki-favorites/includes/class-apiki-favorites-repository.php @@ -0,0 +1,119 @@ +table_name = Apiki_Favorites_Installer::getTableName(); + } + + /** + * @param int $user_id + * @param int $post_id + * @return bool + */ + public function favorite($user_id, $post_id): bool + { + global $wpdb; + + $existing = $this->exists($user_id, $post_id); + + if ($existing) { + return true; + } + + $inserted = $wpdb->insert( + $this->table_name, + array( + 'user_id' => (int)$user_id, + 'post_id' => (int)$post_id, + 'created_at' => current_time('mysql', true), + ), + array( + '%d', + '%d', + '%s', + ) + ); + + return false !== $inserted; + } + + /** + * @param int $user_id + * @param int $post_id + * @return bool + */ + public function unfavorite($user_id, $post_id): bool + { + global $wpdb; + + $deleted = $wpdb->delete( + $this->table_name, + array( + 'user_id' => (int)$user_id, + 'post_id' => (int)$post_id, + ), + array( + '%d', + '%d', + ) + ); + + return false !== $deleted; + } + + /** + * @param int $user_id + * @param int $post_id + * @return bool + */ + public function exists($user_id, $post_id): bool + { + global $wpdb; + + $sql = $wpdb->prepare( + "SELECT 1 FROM {$this->table_name} WHERE user_id = %d AND post_id = %d LIMIT 1", + (int)$user_id, + (int)$post_id + ); + + $result = $wpdb->get_var($sql); + + return !empty($result); + } + + /** + * @param int $user_id + * @return array + */ + public function get_user_favorites($user_id): array + { + global $wpdb; + + $sql = $wpdb->prepare( + "SELECT post_id, created_at + FROM {$this->table_name} + WHERE user_id = %d + ORDER BY created_at DESC", + (int)$user_id + ); + + $results = $wpdb->get_results($sql, ARRAY_A); + + if (empty($results)) { + return array(); + } + + return $results; + } +} \ No newline at end of file diff --git a/apiki-favorites/includes/class-apiki-favorites-rest-controller.php b/apiki-favorites/includes/class-apiki-favorites-rest-controller.php new file mode 100644 index 00000000..dcd238ef --- /dev/null +++ b/apiki-favorites/includes/class-apiki-favorites-rest-controller.php @@ -0,0 +1,265 @@ +repository = $repository; + } + + public function register_routes(): void + { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array($this, 'get_items'), + 'permission_callback' => array($this, 'permissions_check'), + ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P\d+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array($this, 'get_item'), + 'permission_callback' => array($this, 'permissions_check'), + 'args' => $this->get_post_id_args(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array($this, 'create_item'), + 'permission_callback' => array($this, 'permissions_check'), + 'args' => $this->get_post_id_args(), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array($this, 'delete_item'), + 'permission_callback' => array($this, 'permissions_check'), + 'args' => $this->get_post_id_args(), + ), + ) + ); + } + + /** + * @param WP_REST_Request $request + * @return WP_REST_Response|WP_Error + */ + public function get_items($request): WP_Error|WP_REST_Response + { + $user_id = get_current_user_id(); + $favorites = $this->repository->get_user_favorites($user_id); + $items = array(); + + foreach ($favorites as $favorite) { + $post = get_post((int)$favorite['post_id']); + + if (!$post || 'publish' !== $post->post_status) { + continue; + } + + $items[] = array( + 'post_id' => (int)$post->ID, + 'title' => get_the_title($post), + 'permalink' => get_permalink($post), + 'favorited' => true, + 'created_at' => mysql_to_rfc3339($favorite['created_at']), + ); + } + + return new WP_REST_Response( + array( + 'items' => $items, + 'total' => count($items), + ), + 200 + ); + } + + /** + * @param WP_REST_Request $request + * @return WP_REST_Response|WP_Error + */ + public function get_item($request): WP_Error|WP_REST_Response|WP_Post + { + $post_id = (int)$request->get_param('post_id'); + $post = $this->validate_post($post_id); + + if (is_wp_error($post)) { + return $post; + } + + $user_id = get_current_user_id(); + $favorited = $this->repository->exists($user_id, $post_id); + + return new WP_REST_Response( + array( + 'post_id' => $post_id, + 'favorited' => $favorited, + ), + 200 + ); + } + + /** + * @param WP_REST_Request $request + * @return WP_Error|WP_REST_Response|WP_Post + */ + public function create_item($request): WP_Error|WP_REST_Response|WP_Post + { + $post_id = (int)$request->get_param('post_id'); + $post = $this->validate_post($post_id); + + if (is_wp_error($post)) { + return $post; + } + + $user_id = get_current_user_id(); + $result = $this->repository->favorite($user_id, $post_id); + + if (!$result) { + return new WP_Error( + 'apiki_favorites_insert_error', + __('Não foi possível favoritar o post.', 'apiki-favorites'), + array('status' => 500) + ); + } + + return new WP_REST_Response( + array( + 'message' => __('Post favoritado com sucesso.', 'apiki-favorites'), + 'post_id' => $post_id, + 'favorited' => true, + ), + 201 + ); + } + + /** + * @param WP_REST_Request $request + * @return WP_Error|WP_REST_Response|WP_Post + */ + public function delete_item($request): WP_Error|WP_REST_Response|WP_Post + { + $post_id = (int)$request->get_param('post_id'); + $post = $this->validate_post($post_id); + + if (is_wp_error($post)) { + return $post; + } + + $user_id = get_current_user_id(); + $result = $this->repository->unfavorite($user_id, $post_id); + + if (false === $result) { + return new WP_Error( + 'apiki_favorites_delete_error', + __('Não foi possível desfavoritar o post.', 'apiki-favorites'), + array('status' => 500) + ); + } + + return new WP_REST_Response( + array( + 'message' => __('Post desfavoritado com sucesso.', 'apiki-favorites'), + 'post_id' => $post_id, + 'favorited' => false, + ), + 200 + ); + } + + /** + * @return array + */ + private function get_post_id_args(): array + { + return array( + 'post_id' => array( + 'required' => true, + 'validate_callback' => array($this, 'validate_post_id_param'), + 'sanitize_callback' => 'absint', + ), + ); + } + + /** + * @param $value + * @return bool + */ + public function validate_post_id_param($value): bool + { + return is_numeric($value) && (int)$value > 0; + } + + /** + * @return bool|WP_Error + */ + public function permissions_check(): WP_Error|bool + { + if (!is_user_logged_in()) { + return new WP_Error( + 'apiki_favorites_forbidden', + __('Usuário não autenticado.', 'apiki-favorites'), + array('status' => rest_authorization_required_code()) + ); + } + + return true; + } + + /** + * @param int $post_id + * @return WP_Post|WP_Error + */ + private function validate_post(int $post_id): WP_Error|WP_Post + { + $post = get_post($post_id); + + if (!$post || 'post' !== $post->post_type) { + return new WP_Error( + 'apiki_favorites_invalid_post', + __('Post inválido.', 'apiki-favorites'), + array('status' => 404) + ); + } + + if ('publish' !== $post->post_status) { + return new WP_Error( + 'apiki_favorites_unavailable_post', + __('Post indisponível para favoritamento.', 'apiki-favorites'), + array('status' => 404) + ); + } + + return $post; + } +} \ No newline at end of file diff --git a/apiki-favorites/includes/class-apiki-favorites-shortcode.php b/apiki-favorites/includes/class-apiki-favorites-shortcode.php new file mode 100644 index 00000000..1867f802 --- /dev/null +++ b/apiki-favorites/includes/class-apiki-favorites-shortcode.php @@ -0,0 +1,162 @@ +repository = $repository; + } + + public function registerAssets(): void + { + wp_register_script( + 'apiki-favorites', + APIKI_FAVORITES_URL . 'assets/apiki-favorites.js', + array(), + APIKI_FAVORITES_VERSION, + true + ); + + wp_localize_script( + 'apiki-favorites', + 'apikiFavorites', + array( + 'restUrl' => esc_url_raw(rest_url('apiki/v1/favorites/')), + 'nonce' => wp_create_nonce('wp_rest'), + 'isLoggedIn' => is_user_logged_in(), + 'favoriteText' => __('Favoritar', 'apiki-favorites'), + 'unfavoriteText' => __('Desfavoritar', 'apiki-favorites'), + 'loadingText' => __('Processando...', 'apiki-favorites'), + 'loginRequiredText' => __('Você precisa estar logado para favoritar posts.', 'apiki-favorites'), + 'errorText' => __('Ocorreu um erro ao processar sua solicitação.', 'apiki-favorites'), + ) + ); + } + + /** + * @param array $attrs + * @return string + */ + public function showButton(array $attrs): string + { + $attrs = shortcode_atts( + array( + 'post_id' => 0, + 'label' => '', + ), + $attrs, + 'apiki_favorite_button' + ); + + $post_id = absint($attrs['post_id']); + + if (!$post_id && is_singular('post')) { + $post_id = get_the_ID(); + } + + if (!$post_id) { + return ''; + } + + $post = get_post($post_id); + + if (!$post || 'post' !== $post->post_type || 'publish' !== $post->post_status) { + return ''; + } + + $is_logged_in = is_user_logged_in(); + $is_favorited = false; + + if ($is_logged_in) { + $is_favorited = $this->repository->exists(get_current_user_id(), $post_id); + } + + $button_label = $attrs['label']; + + if ('' === $button_label) { + $button_label = $is_favorited ? __('Desfavoritar', 'apiki-favorites') : __('Favoritar', 'apiki-favorites'); + } + + wp_enqueue_script('apiki-favorites'); + + ob_start(); + ?> + + __('Você ainda não possui posts favoritos.', 'apiki-favorites'), + ), + $attrs, + 'apiki_favorite_list' + ); + + $favorites = $this->repository->get_user_favorites(get_current_user_id()); + + if (empty($favorites)) { + return '

' . esc_html($attrs['empty_message']) . '

'; + } + + $items = array(); + + foreach ($favorites as $favorite) { + $post_id = isset($favorite['post_id']) ? absint($favorite['post_id']) : 0; + + if (!$post_id) { + continue; + } + + $post = get_post($post_id); + + if (!$post || 'post' !== $post->post_type || 'publish' !== $post->post_status) { + continue; + } + + $items[] = sprintf( + '
  • %2$s
  • ', + esc_url(get_permalink($post_id)), + esc_html(get_the_title($post_id)) + ); + } + + if (empty($items)) { + return '

    ' . esc_html($attrs['empty_message']) . '

    '; + } + + return ''; + } +} \ No newline at end of file