diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..5f32c576
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+*.lock
+.parcel-cache/
+*.css.map
+*.js.map
\ No newline at end of file
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..5afafd98
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v23.11.0
diff --git a/assets/icons/heart.png b/assets/icons/heart.png
new file mode 100644
index 00000000..07dad0f0
Binary files /dev/null and b/assets/icons/heart.png differ
diff --git a/assets/scripts/index.ts b/assets/scripts/index.ts
new file mode 100644
index 00000000..bb9a5d26
--- /dev/null
+++ b/assets/scripts/index.ts
@@ -0,0 +1,91 @@
+import Toastify from 'toastify-js';
+
+const favoritePosts = {
+ init() {
+ if (!this.component) return;
+
+ this.addEventListeners();
+ },
+
+ addEventListeners() {
+ this.component.addEventListener('click', this.handleClick.bind(this));
+ },
+
+ async favoritePost() {
+ // @ts-ignore
+ fetch(wpApiSettings.root + wpApiSettings.rest_namespace + '/favorite-posts', {
+ method: 'POST',
+ body: JSON.stringify({
+ post_id: this.component.dataset.postId
+ }),
+ headers: {
+ 'Content-Type': 'application/json',
+ // @ts-ignore
+ 'X-WP-Nonce': wpApiSettings.nonce
+ }
+ })
+ .then(response => response.json())
+ .then(data => {
+ Toastify({ text: data.message }).showToast();
+
+ this.component.classList.add('is-favorite');
+ })
+ .catch(error => {
+ Toastify({
+ text: 'Failed to add favorite post',
+ className: 'error',
+ style: {
+ background: 'linear-gradient(to right, #ff0000, #ff4747)',
+ }
+ }).showToast();
+ });
+ },
+
+ async handleClick() {
+ !!this.dialogLoader && this.dialogLoader.showModal();
+
+ if (this.component.classList.contains('is-favorite')) {
+ await this.removeFavoritePost();
+ } else {
+ await this.favoritePost();
+ }
+
+ !!this.dialogLoader && this.dialogLoader.close();
+ },
+
+ async removeFavoritePost() {
+ // @ts-ignore
+ fetch(wpApiSettings.root + wpApiSettings.rest_namespace + '/favorite-posts', {
+ method: 'DELETE',
+ body: JSON.stringify({
+ post_id: this.component.dataset.postId
+ }),
+ headers: {
+ 'Content-Type': 'application/json',
+ // @ts-ignore
+ 'X-WP-Nonce': wpApiSettings.nonce
+ }
+ })
+ .then(response => response.json())
+ .then(data => {
+ Toastify({ text: data.message }).showToast();
+
+ this.component.classList.remove('is-favorite');
+ })
+ .catch(error => {
+ Toastify({
+ text: 'Failed to remove favorite post',
+ className: 'error',
+ style: {
+ background: 'linear-gradient(to right, #ff0000, #ff4747)',
+ }
+ }).showToast();
+ });
+ },
+
+ component: document.querySelector('.favorite-post-button'),
+ dialogLoader: document.querySelector('#dialog-loader'),
+ favoritePostsPopover: document.querySelector('#favorite-posts-popover'),
+}
+
+favoritePosts.init();
diff --git a/assets/scss/index.scss b/assets/scss/index.scss
new file mode 100644
index 00000000..ae81e4fa
--- /dev/null
+++ b/assets/scss/index.scss
@@ -0,0 +1,61 @@
+@import './loader.scss';
+
+.favorite-post-button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ display: block;
+ outline: none;
+ opacity: 0.5;
+ padding: 0;
+ transition: all 0.3s ease-in-out;
+
+ &:hover,
+ &.is-favorite {
+ opacity: 1;
+ transform: scale(2);
+ }
+
+ img {
+ height: 32px;
+ pointer-events: none;
+ }
+}
+
+#dialog-loader {
+ background-color: transparent;
+ border: none;
+ opacity: 0;
+ padding: 150px;
+ transform: scaleY(0);
+ transition: opacity 0.7s ease-out,
+ transform 0.7s ease-out,
+ overlay 0.7s ease-out allow-discrete,
+ display 0.7s ease-out allow-discrete;
+
+ &:open {
+ opacity: 1;
+ transform: scaleY(1);
+ }
+
+ /* Styles the *starting point* of the open state */
+ /* Must come AFTER the dialog:open rule due to equal specificity */
+ @starting-style {
+ &:open {
+ opacity: 0;
+ transform: scaleY(0);
+ }
+ }
+
+ &::backdrop {
+ background-color: rgba(255, 255, 255, 0);
+ transition: background-color 0.7s ease-out allow-discrete;
+ }
+
+ &:open::backdrop {
+ background-color: rgba(255, 255, 255, 0.5);
+ backdrop-filter: blur(10px);
+ }
+
+ .loader { width: 96px; }
+}
\ No newline at end of file
diff --git a/assets/scss/loader.scss b/assets/scss/loader.scss
new file mode 100644
index 00000000..df7f3335
--- /dev/null
+++ b/assets/scss/loader.scss
@@ -0,0 +1,25 @@
+/* HTML:
*/
+.loader {
+ width: 50px;
+ aspect-ratio: 1;
+ color:#dc1818;
+ background:
+ radial-gradient(circle at 60% 65%, currentColor 62%, #0000 65%) top left,
+ radial-gradient(circle at 40% 65%, currentColor 62%, #0000 65%) top right,
+ linear-gradient(to bottom left, currentColor 42%,#0000 43%) bottom left ,
+ linear-gradient(to bottom right,currentColor 42%,#0000 43%) bottom right;
+ background-size: 50% 50%;
+ background-repeat: no-repeat;
+ position: relative;
+}
+.loader:after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: inherit;
+ opacity: 0.4;
+ animation: l3 1s infinite;
+}
+@keyframes l3 {
+ to {transform:scale(1.8);opacity:0}
+}
\ No newline at end of file
diff --git a/dist/index.css b/dist/index.css
new file mode 100644
index 00000000..39c92818
--- /dev/null
+++ b/dist/index.css
@@ -0,0 +1,87 @@
+.loader {
+ aspect-ratio: 1;
+ color: #dc1818;
+ background-color: #0000;
+ background-image: radial-gradient(circle at 60% 65%, currentColor 62%, #0000 65%), radial-gradient(circle at 40% 65%, currentColor 62%, #0000 65%), linear-gradient(to bottom left, currentColor 42%, #0000 43%), linear-gradient(to bottom right, currentColor 42%, #0000 43%);
+ background-position: 0 0, 100% 0, 0 100%, 100% 100%;
+ background-repeat: no-repeat;
+ background-size: 50% 50%;
+ background-attachment: scroll, scroll, scroll, scroll;
+ background-origin: padding-box, padding-box, padding-box, padding-box;
+ background-clip: border-box, border-box, border-box, border-box;
+ width: 50px;
+ position: relative;
+}
+
+.loader:after {
+ content: "";
+ background: inherit;
+ opacity: .4;
+ animation: 1s infinite l3;
+ position: absolute;
+ inset: 0;
+}
+
+@keyframes l3 {
+ to {
+ opacity: 0;
+ transform: scale(1.8);
+ }
+}
+
+.favorite-post-button {
+ cursor: pointer;
+ opacity: .5;
+ background: none;
+ border: none;
+ outline: none;
+ padding: 0;
+ transition: all .3s ease-in-out;
+ display: block;
+}
+
+.favorite-post-button:hover, .favorite-post-button.is-favorite {
+ opacity: 1;
+ transform: scale(2);
+}
+
+.favorite-post-button img {
+ pointer-events: none;
+ height: 32px;
+}
+
+#dialog-loader {
+ opacity: 0;
+ transition: opacity .7s ease-out, transform .7s ease-out, overlay .7s ease-out allow-discrete, display .7s ease-out allow-discrete;
+ background-color: #0000;
+ border: none;
+ padding: 150px;
+ transform: scaleY(0);
+}
+
+#dialog-loader:open {
+ opacity: 1;
+ transform: scaleY(1);
+}
+
+@starting-style {
+ #dialog-loader:open {
+ opacity: 0;
+ transform: scaleY(0);
+ }
+}
+
+#dialog-loader::backdrop {
+ transition: background-color .7s ease-out allow-discrete;
+ background-color: #fff0;
+}
+
+#dialog-loader:open::backdrop {
+ backdrop-filter: blur(10px);
+ background-color: #ffffff80;
+}
+
+#dialog-loader .loader {
+ width: 96px;
+}
+/*# sourceMappingURL=index.css.map */
diff --git a/dist/index.js b/dist/index.js
new file mode 100644
index 00000000..62b86687
--- /dev/null
+++ b/dist/index.js
@@ -0,0 +1,1120 @@
+// modules are defined as an array
+// [ module function, map of requires ]
+//
+// map of requires is short require name -> numeric require
+//
+// anything defined in a previous bundle is accessed via the
+// orig method which is the require for previous bundles
+
+(function (
+ modules,
+ entry,
+ mainEntry,
+ parcelRequireName,
+ externals,
+ distDir,
+ publicUrl,
+ devServer
+) {
+ /* eslint-disable no-undef */
+ var globalObject =
+ typeof globalThis !== 'undefined'
+ ? globalThis
+ : typeof self !== 'undefined'
+ ? self
+ : typeof window !== 'undefined'
+ ? window
+ : typeof global !== 'undefined'
+ ? global
+ : {};
+ /* eslint-enable no-undef */
+
+ // Save the require from previous bundle to this closure if any
+ var previousRequire =
+ typeof globalObject[parcelRequireName] === 'function' &&
+ globalObject[parcelRequireName];
+
+ var importMap = previousRequire.i || {};
+ var cache = previousRequire.cache || {};
+ // Do not use `require` to prevent Webpack from trying to bundle this call
+ var nodeRequire =
+ typeof module !== 'undefined' &&
+ typeof module.require === 'function' &&
+ module.require.bind(module);
+
+ function newRequire(name, jumped) {
+ if (!cache[name]) {
+ if (!modules[name]) {
+ if (externals[name]) {
+ return externals[name];
+ }
+ // if we cannot find the module within our internal map or
+ // cache jump to the current global require ie. the last bundle
+ // that was added to the page.
+ var currentRequire =
+ typeof globalObject[parcelRequireName] === 'function' &&
+ globalObject[parcelRequireName];
+ if (!jumped && currentRequire) {
+ return currentRequire(name, true);
+ }
+
+ // If there are other bundles on this page the require from the
+ // previous one is saved to 'previousRequire'. Repeat this as
+ // many times as there are bundles until the module is found or
+ // we exhaust the require chain.
+ if (previousRequire) {
+ return previousRequire(name, true);
+ }
+
+ // Try the node require function if it exists.
+ if (nodeRequire && typeof name === 'string') {
+ return nodeRequire(name);
+ }
+
+ var err = new Error("Cannot find module '" + name + "'");
+ err.code = 'MODULE_NOT_FOUND';
+ throw err;
+ }
+
+ localRequire.resolve = resolve;
+ localRequire.cache = {};
+
+ var module = (cache[name] = new newRequire.Module(name));
+
+ modules[name][0].call(
+ module.exports,
+ localRequire,
+ module,
+ module.exports,
+ globalObject
+ );
+ }
+
+ return cache[name].exports;
+
+ function localRequire(x) {
+ var res = localRequire.resolve(x);
+ if (res === false) {
+ return {};
+ }
+ // Synthesize a module to follow re-exports.
+ if (Array.isArray(res)) {
+ var m = {__esModule: true};
+ res.forEach(function (v) {
+ var key = v[0];
+ var id = v[1];
+ var exp = v[2] || v[0];
+ var x = newRequire(id);
+ if (key === '*') {
+ Object.keys(x).forEach(function (key) {
+ if (
+ key === 'default' ||
+ key === '__esModule' ||
+ Object.prototype.hasOwnProperty.call(m, key)
+ ) {
+ return;
+ }
+
+ Object.defineProperty(m, key, {
+ enumerable: true,
+ get: function () {
+ return x[key];
+ },
+ });
+ });
+ } else if (exp === '*') {
+ Object.defineProperty(m, key, {
+ enumerable: true,
+ value: x,
+ });
+ } else {
+ Object.defineProperty(m, key, {
+ enumerable: true,
+ get: function () {
+ if (exp === 'default') {
+ return x.__esModule ? x.default : x;
+ }
+ return x[exp];
+ },
+ });
+ }
+ });
+ return m;
+ }
+ return newRequire(res);
+ }
+
+ function resolve(x) {
+ var id = modules[name][1][x];
+ return id != null ? id : x;
+ }
+ }
+
+ function Module(moduleName) {
+ this.id = moduleName;
+ this.bundle = newRequire;
+ this.require = nodeRequire;
+ this.exports = {};
+ }
+
+ newRequire.isParcelRequire = true;
+ newRequire.Module = Module;
+ newRequire.modules = modules;
+ newRequire.cache = cache;
+ newRequire.parent = previousRequire;
+ newRequire.distDir = distDir;
+ newRequire.publicUrl = publicUrl;
+ newRequire.devServer = devServer;
+ newRequire.i = importMap;
+ newRequire.register = function (id, exports) {
+ modules[id] = [
+ function (require, module) {
+ module.exports = exports;
+ },
+ {},
+ ];
+ };
+
+ // Only insert newRequire.load when it is actually used.
+ // The code in this file is linted against ES5, so dynamic import is not allowed.
+ // INSERT_LOAD_HERE
+
+ Object.defineProperty(newRequire, 'root', {
+ get: function () {
+ return globalObject[parcelRequireName];
+ },
+ });
+
+ globalObject[parcelRequireName] = newRequire;
+
+ for (var i = 0; i < entry.length; i++) {
+ newRequire(entry[i]);
+ }
+
+ if (mainEntry) {
+ // Expose entry point to Node, AMD or browser globals
+ // Based on https://github.com/ForbesLindesay/umd/blob/master/template.js
+ var mainExports = newRequire(mainEntry);
+
+ // CommonJS
+ if (typeof exports === 'object' && typeof module !== 'undefined') {
+ module.exports = mainExports;
+
+ // RequireJS
+ } else if (typeof define === 'function' && define.amd) {
+ define(function () {
+ return mainExports;
+ });
+ }
+ }
+})({"1Mq6V":[function(require,module,exports,__globalThis) {
+var global = arguments[3];
+var HMR_HOST = null;
+var HMR_PORT = 1234;
+var HMR_SERVER_PORT = 1234;
+var HMR_SECURE = false;
+var HMR_ENV_HASH = "d6ea1d42532a7575";
+var HMR_USE_SSE = false;
+module.bundle.HMR_BUNDLE_ID = "78fcd0ac8e9bd240";
+"use strict";
+/* global HMR_HOST, HMR_PORT, HMR_SERVER_PORT, HMR_ENV_HASH, HMR_SECURE, HMR_USE_SSE, chrome, browser, __parcel__import__, __parcel__importScripts__, ServiceWorkerGlobalScope */ /*::
+import type {
+ HMRAsset,
+ HMRMessage,
+} from '@parcel/reporter-dev-server/src/HMRServer.js';
+interface ParcelRequire {
+ (string): mixed;
+ cache: {|[string]: ParcelModule|};
+ hotData: {|[string]: mixed|};
+ Module: any;
+ parent: ?ParcelRequire;
+ isParcelRequire: true;
+ modules: {|[string]: [Function, {|[string]: string|}]|};
+ HMR_BUNDLE_ID: string;
+ root: ParcelRequire;
+}
+interface ParcelModule {
+ hot: {|
+ data: mixed,
+ accept(cb: (Function) => void): void,
+ dispose(cb: (mixed) => void): void,
+ // accept(deps: Array | string, cb: (Function) => void): void,
+ // decline(): void,
+ _acceptCallbacks: Array<(Function) => void>,
+ _disposeCallbacks: Array<(mixed) => void>,
+ |};
+}
+interface ExtensionContext {
+ runtime: {|
+ reload(): void,
+ getURL(url: string): string;
+ getManifest(): {manifest_version: number, ...};
+ |};
+}
+declare var module: {bundle: ParcelRequire, ...};
+declare var HMR_HOST: string;
+declare var HMR_PORT: string;
+declare var HMR_SERVER_PORT: string;
+declare var HMR_ENV_HASH: string;
+declare var HMR_SECURE: boolean;
+declare var HMR_USE_SSE: boolean;
+declare var chrome: ExtensionContext;
+declare var browser: ExtensionContext;
+declare var __parcel__import__: (string) => Promise;
+declare var __parcel__importScripts__: (string) => Promise;
+declare var globalThis: typeof self;
+declare var ServiceWorkerGlobalScope: Object;
+*/ var OVERLAY_ID = '__parcel__error__overlay__';
+var OldModule = module.bundle.Module;
+function Module(moduleName) {
+ OldModule.call(this, moduleName);
+ this.hot = {
+ data: module.bundle.hotData[moduleName],
+ _acceptCallbacks: [],
+ _disposeCallbacks: [],
+ accept: function(fn) {
+ this._acceptCallbacks.push(fn || function() {});
+ },
+ dispose: function(fn) {
+ this._disposeCallbacks.push(fn);
+ }
+ };
+ module.bundle.hotData[moduleName] = undefined;
+}
+module.bundle.Module = Module;
+module.bundle.hotData = {};
+var checkedAssets /*: {|[string]: boolean|} */ , disposedAssets /*: {|[string]: boolean|} */ , assetsToDispose /*: Array<[ParcelRequire, string]> */ , assetsToAccept /*: Array<[ParcelRequire, string]> */ , bundleNotFound = false;
+function getHostname() {
+ return HMR_HOST || (typeof location !== 'undefined' && location.protocol.indexOf('http') === 0 ? location.hostname : 'localhost');
+}
+function getPort() {
+ return HMR_PORT || (typeof location !== 'undefined' ? location.port : HMR_SERVER_PORT);
+}
+// eslint-disable-next-line no-redeclare
+let WebSocket = globalThis.WebSocket;
+if (!WebSocket && typeof module.bundle.root === 'function') try {
+ // eslint-disable-next-line no-global-assign
+ WebSocket = module.bundle.root('ws');
+} catch {
+// ignore.
+}
+var hostname = getHostname();
+var port = getPort();
+var protocol = HMR_SECURE || typeof location !== 'undefined' && location.protocol === 'https:' && ![
+ 'localhost',
+ '127.0.0.1',
+ '0.0.0.0'
+].includes(hostname) ? 'wss' : 'ws';
+// eslint-disable-next-line no-redeclare
+var parent = module.bundle.parent;
+if (!parent || !parent.isParcelRequire) {
+ // Web extension context
+ var extCtx = typeof browser === 'undefined' ? typeof chrome === 'undefined' ? null : chrome : browser;
+ // Safari doesn't support sourceURL in error stacks.
+ // eval may also be disabled via CSP, so do a quick check.
+ var supportsSourceURL = false;
+ try {
+ (0, eval)('throw new Error("test"); //# sourceURL=test.js');
+ } catch (err) {
+ supportsSourceURL = err.stack.includes('test.js');
+ }
+ var ws;
+ if (HMR_USE_SSE) ws = new EventSource('/__parcel_hmr');
+ else try {
+ // If we're running in the dev server's node runner, listen for messages on the parent port.
+ let { workerData, parentPort } = module.bundle.root('node:worker_threads') /*: any*/ ;
+ if (workerData !== null && workerData !== void 0 && workerData.__parcel) {
+ parentPort.on('message', async (message)=>{
+ try {
+ await handleMessage(message);
+ parentPort.postMessage('updated');
+ } catch {
+ parentPort.postMessage('restart');
+ }
+ });
+ // After the bundle has finished running, notify the dev server that the HMR update is complete.
+ queueMicrotask(()=>parentPort.postMessage('ready'));
+ }
+ } catch {
+ if (typeof WebSocket !== 'undefined') try {
+ ws = new WebSocket(protocol + '://' + hostname + (port ? ':' + port : '') + '/');
+ } catch (err) {
+ // Ignore cloudflare workers error.
+ if (err.message && !err.message.includes('Disallowed operation called within global scope')) console.error(err.message);
+ }
+ }
+ if (ws) {
+ // $FlowFixMe
+ ws.onmessage = async function(event /*: {data: string, ...} */ ) {
+ var data /*: HMRMessage */ = JSON.parse(event.data);
+ await handleMessage(data);
+ };
+ if (ws instanceof WebSocket) {
+ ws.onerror = function(e) {
+ if (e.message) console.error(e.message);
+ };
+ ws.onclose = function() {
+ console.warn("[parcel] \uD83D\uDEA8 Connection to the HMR server was lost");
+ };
+ }
+ }
+}
+async function handleMessage(data /*: HMRMessage */ ) {
+ checkedAssets = {} /*: {|[string]: boolean|} */ ;
+ disposedAssets = {} /*: {|[string]: boolean|} */ ;
+ assetsToAccept = [];
+ assetsToDispose = [];
+ bundleNotFound = false;
+ if (data.type === 'reload') fullReload();
+ else if (data.type === 'update') {
+ // Remove error overlay if there is one
+ if (typeof document !== 'undefined') removeErrorOverlay();
+ let assets = data.assets;
+ // Handle HMR Update
+ let handled = assets.every((asset)=>{
+ return asset.type === 'css' || asset.type === 'js' && hmrAcceptCheck(module.bundle.root, asset.id, asset.depsByBundle);
+ });
+ // Dispatch a custom event in case a bundle was not found. This might mean
+ // an asset on the server changed and we should reload the page. This event
+ // gives the client an opportunity to refresh without losing state
+ // (e.g. via React Server Components). If e.preventDefault() is not called,
+ // we will trigger a full page reload.
+ if (handled && bundleNotFound && assets.some((a)=>a.envHash !== HMR_ENV_HASH) && typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') handled = !window.dispatchEvent(new CustomEvent('parcelhmrreload', {
+ cancelable: true
+ }));
+ if (handled) {
+ console.clear();
+ // Dispatch custom event so other runtimes (e.g React Refresh) are aware.
+ if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') window.dispatchEvent(new CustomEvent('parcelhmraccept'));
+ await hmrApplyUpdates(assets);
+ hmrDisposeQueue();
+ // Run accept callbacks. This will also re-execute other disposed assets in topological order.
+ let processedAssets = {};
+ for(let i = 0; i < assetsToAccept.length; i++){
+ let id = assetsToAccept[i][1];
+ if (!processedAssets[id]) {
+ hmrAccept(assetsToAccept[i][0], id);
+ processedAssets[id] = true;
+ }
+ }
+ } else fullReload();
+ }
+ if (data.type === 'error') {
+ // Log parcel errors to console
+ for (let ansiDiagnostic of data.diagnostics.ansi){
+ let stack = ansiDiagnostic.codeframe ? ansiDiagnostic.codeframe : ansiDiagnostic.stack;
+ console.error("\uD83D\uDEA8 [parcel]: " + ansiDiagnostic.message + '\n' + stack + '\n\n' + ansiDiagnostic.hints.join('\n'));
+ }
+ if (typeof document !== 'undefined') {
+ // Render the fancy html overlay
+ removeErrorOverlay();
+ var overlay = createErrorOverlay(data.diagnostics.html);
+ // $FlowFixMe
+ document.body.appendChild(overlay);
+ }
+ }
+}
+function removeErrorOverlay() {
+ var overlay = document.getElementById(OVERLAY_ID);
+ if (overlay) {
+ overlay.remove();
+ console.log("[parcel] \u2728 Error resolved");
+ }
+}
+function createErrorOverlay(diagnostics) {
+ var overlay = document.createElement('div');
+ overlay.id = OVERLAY_ID;
+ let errorHTML = '';
+ for (let diagnostic of diagnostics){
+ let stack = diagnostic.frames.length ? diagnostic.frames.reduce((p, frame)=>{
+ return `${p}
+
${frame.location}
+${frame.code}`;
+ }, '') : diagnostic.stack;
+ errorHTML += `
+
+
+ \u{1F6A8} ${diagnostic.message}
+
+
${stack}
+
+ ${diagnostic.hints.map((hint)=>"
\uD83D\uDCA1 " + hint + '
').join('')}
+
+ ${diagnostic.documentation ? `
` : ''}
+
+ `;
+ }
+ errorHTML += '
';
+ overlay.innerHTML = errorHTML;
+ return overlay;
+}
+function fullReload() {
+ if (typeof location !== 'undefined' && 'reload' in location) location.reload();
+ else if (typeof extCtx !== 'undefined' && extCtx && extCtx.runtime && extCtx.runtime.reload) extCtx.runtime.reload();
+ else try {
+ let { workerData, parentPort } = module.bundle.root('node:worker_threads') /*: any*/ ;
+ if (workerData !== null && workerData !== void 0 && workerData.__parcel) parentPort.postMessage('restart');
+ } catch (err) {
+ console.error("[parcel] \u26A0\uFE0F An HMR update was not accepted. Please restart the process.");
+ }
+}
+function getParents(bundle, id) /*: Array<[ParcelRequire, string]> */ {
+ var modules = bundle.modules;
+ if (!modules) return [];
+ var parents = [];
+ var k, d, dep;
+ for(k in modules)for(d in modules[k][1]){
+ dep = modules[k][1][d];
+ if (dep === id || Array.isArray(dep) && dep[dep.length - 1] === id) parents.push([
+ bundle,
+ k
+ ]);
+ }
+ if (bundle.parent) parents = parents.concat(getParents(bundle.parent, id));
+ return parents;
+}
+function updateLink(link) {
+ var href = link.getAttribute('href');
+ if (!href) return;
+ var newLink = link.cloneNode();
+ newLink.onload = function() {
+ if (link.parentNode !== null) // $FlowFixMe
+ link.parentNode.removeChild(link);
+ };
+ newLink.setAttribute('href', // $FlowFixMe
+ href.split('?')[0] + '?' + Date.now());
+ // $FlowFixMe
+ link.parentNode.insertBefore(newLink, link.nextSibling);
+}
+var cssTimeout = null;
+function reloadCSS() {
+ if (cssTimeout || typeof document === 'undefined') return;
+ cssTimeout = setTimeout(function() {
+ var links = document.querySelectorAll('link[rel="stylesheet"]');
+ for(var i = 0; i < links.length; i++){
+ // $FlowFixMe[incompatible-type]
+ var href /*: string */ = links[i].getAttribute('href');
+ var hostname = getHostname();
+ var servedFromHMRServer = hostname === 'localhost' ? new RegExp('^(https?:\\/\\/(0.0.0.0|127.0.0.1)|localhost):' + getPort()).test(href) : href.indexOf(hostname + ':' + getPort());
+ var absolute = /^https?:\/\//i.test(href) && href.indexOf(location.origin) !== 0 && !servedFromHMRServer;
+ if (!absolute) updateLink(links[i]);
+ }
+ cssTimeout = null;
+ }, 50);
+}
+function hmrDownload(asset) {
+ if (asset.type === 'js') {
+ if (typeof document !== 'undefined') {
+ let script = document.createElement('script');
+ script.src = asset.url + '?t=' + Date.now();
+ if (asset.outputFormat === 'esmodule') script.type = 'module';
+ return new Promise((resolve, reject)=>{
+ var _document$head;
+ script.onload = ()=>resolve(script);
+ script.onerror = reject;
+ (_document$head = document.head) === null || _document$head === void 0 || _document$head.appendChild(script);
+ });
+ } else if (typeof importScripts === 'function') {
+ // Worker scripts
+ if (asset.outputFormat === 'esmodule') return import(asset.url + '?t=' + Date.now());
+ else return new Promise((resolve, reject)=>{
+ try {
+ importScripts(asset.url + '?t=' + Date.now());
+ resolve();
+ } catch (err) {
+ reject(err);
+ }
+ });
+ }
+ }
+}
+async function hmrApplyUpdates(assets) {
+ global.parcelHotUpdate = Object.create(null);
+ let scriptsToRemove;
+ try {
+ // If sourceURL comments aren't supported in eval, we need to load
+ // the update from the dev server over HTTP so that stack traces
+ // are correct in errors/logs. This is much slower than eval, so
+ // we only do it if needed (currently just Safari).
+ // https://bugs.webkit.org/show_bug.cgi?id=137297
+ // This path is also taken if a CSP disallows eval.
+ if (!supportsSourceURL) {
+ let promises = assets.map((asset)=>{
+ var _hmrDownload;
+ return (_hmrDownload = hmrDownload(asset)) === null || _hmrDownload === void 0 ? void 0 : _hmrDownload.catch((err)=>{
+ // Web extension fix
+ if (extCtx && extCtx.runtime && extCtx.runtime.getManifest().manifest_version == 3 && typeof ServiceWorkerGlobalScope != 'undefined' && global instanceof ServiceWorkerGlobalScope) {
+ extCtx.runtime.reload();
+ return;
+ }
+ throw err;
+ });
+ });
+ scriptsToRemove = await Promise.all(promises);
+ }
+ assets.forEach(function(asset) {
+ hmrApply(module.bundle.root, asset);
+ });
+ } finally{
+ delete global.parcelHotUpdate;
+ if (scriptsToRemove) scriptsToRemove.forEach((script)=>{
+ if (script) {
+ var _document$head2;
+ (_document$head2 = document.head) === null || _document$head2 === void 0 || _document$head2.removeChild(script);
+ }
+ });
+ }
+}
+function hmrApply(bundle /*: ParcelRequire */ , asset /*: HMRAsset */ ) {
+ var modules = bundle.modules;
+ if (!modules) return;
+ if (asset.type === 'css') reloadCSS();
+ else if (asset.type === 'js') {
+ let deps = asset.depsByBundle[bundle.HMR_BUNDLE_ID];
+ if (deps) {
+ if (modules[asset.id]) {
+ // Remove dependencies that are removed and will become orphaned.
+ // This is necessary so that if the asset is added back again, the cache is gone, and we prevent a full page reload.
+ let oldDeps = modules[asset.id][1];
+ for(let dep in oldDeps)if (!deps[dep] || deps[dep] !== oldDeps[dep]) {
+ let id = oldDeps[dep];
+ let parents = getParents(module.bundle.root, id);
+ if (parents.length === 1) hmrDelete(module.bundle.root, id);
+ }
+ }
+ if (supportsSourceURL) // Global eval. We would use `new Function` here but browser
+ // support for source maps is better with eval.
+ (0, eval)(asset.output);
+ // $FlowFixMe
+ let fn = global.parcelHotUpdate[asset.id];
+ modules[asset.id] = [
+ fn,
+ deps
+ ];
+ }
+ // Always traverse to the parent bundle, even if we already replaced the asset in this bundle.
+ // This is required in case modules are duplicated. We need to ensure all instances have the updated code.
+ if (bundle.parent) hmrApply(bundle.parent, asset);
+ }
+}
+function hmrDelete(bundle, id) {
+ let modules = bundle.modules;
+ if (!modules) return;
+ if (modules[id]) {
+ // Collect dependencies that will become orphaned when this module is deleted.
+ let deps = modules[id][1];
+ let orphans = [];
+ for(let dep in deps){
+ let parents = getParents(module.bundle.root, deps[dep]);
+ if (parents.length === 1) orphans.push(deps[dep]);
+ }
+ // Delete the module. This must be done before deleting dependencies in case of circular dependencies.
+ delete modules[id];
+ delete bundle.cache[id];
+ // Now delete the orphans.
+ orphans.forEach((id)=>{
+ hmrDelete(module.bundle.root, id);
+ });
+ } else if (bundle.parent) hmrDelete(bundle.parent, id);
+}
+function hmrAcceptCheck(bundle /*: ParcelRequire */ , id /*: string */ , depsByBundle /*: ?{ [string]: { [string]: string } }*/ ) {
+ checkedAssets = {};
+ if (hmrAcceptCheckOne(bundle, id, depsByBundle)) return true;
+ // Traverse parents breadth first. All possible ancestries must accept the HMR update, or we'll reload.
+ let parents = getParents(module.bundle.root, id);
+ let accepted = false;
+ while(parents.length > 0){
+ let v = parents.shift();
+ let a = hmrAcceptCheckOne(v[0], v[1], null);
+ if (a) // If this parent accepts, stop traversing upward, but still consider siblings.
+ accepted = true;
+ else if (a !== null) {
+ // Otherwise, queue the parents in the next level upward.
+ let p = getParents(module.bundle.root, v[1]);
+ if (p.length === 0) {
+ // If there are no parents, then we've reached an entry without accepting. Reload.
+ accepted = false;
+ break;
+ }
+ parents.push(...p);
+ }
+ }
+ return accepted;
+}
+function hmrAcceptCheckOne(bundle /*: ParcelRequire */ , id /*: string */ , depsByBundle /*: ?{ [string]: { [string]: string } }*/ ) {
+ var modules = bundle.modules;
+ if (!modules) return;
+ if (depsByBundle && !depsByBundle[bundle.HMR_BUNDLE_ID]) {
+ // If we reached the root bundle without finding where the asset should go,
+ // there's nothing to do. Mark as "accepted" so we don't reload the page.
+ if (!bundle.parent) {
+ bundleNotFound = true;
+ return true;
+ }
+ return hmrAcceptCheckOne(bundle.parent, id, depsByBundle);
+ }
+ if (checkedAssets[id]) return null;
+ checkedAssets[id] = true;
+ var cached = bundle.cache[id];
+ if (!cached) return true;
+ assetsToDispose.push([
+ bundle,
+ id
+ ]);
+ if (cached && cached.hot && cached.hot._acceptCallbacks.length) {
+ assetsToAccept.push([
+ bundle,
+ id
+ ]);
+ return true;
+ }
+ return false;
+}
+function hmrDisposeQueue() {
+ // Dispose all old assets.
+ for(let i = 0; i < assetsToDispose.length; i++){
+ let id = assetsToDispose[i][1];
+ if (!disposedAssets[id]) {
+ hmrDispose(assetsToDispose[i][0], id);
+ disposedAssets[id] = true;
+ }
+ }
+ assetsToDispose = [];
+}
+function hmrDispose(bundle /*: ParcelRequire */ , id /*: string */ ) {
+ var cached = bundle.cache[id];
+ bundle.hotData[id] = {};
+ if (cached && cached.hot) cached.hot.data = bundle.hotData[id];
+ if (cached && cached.hot && cached.hot._disposeCallbacks.length) cached.hot._disposeCallbacks.forEach(function(cb) {
+ cb(bundle.hotData[id]);
+ });
+ delete bundle.cache[id];
+}
+function hmrAccept(bundle /*: ParcelRequire */ , id /*: string */ ) {
+ // Execute the module.
+ bundle(id);
+ // Run the accept callbacks in the new version of the module.
+ var cached = bundle.cache[id];
+ if (cached && cached.hot && cached.hot._acceptCallbacks.length) {
+ let assetsToAlsoAccept = [];
+ cached.hot._acceptCallbacks.forEach(function(cb) {
+ let additionalAssets = cb(function() {
+ return getParents(module.bundle.root, id);
+ });
+ if (Array.isArray(additionalAssets) && additionalAssets.length) assetsToAlsoAccept.push(...additionalAssets);
+ });
+ if (assetsToAlsoAccept.length) {
+ let handled = assetsToAlsoAccept.every(function(a) {
+ return hmrAcceptCheck(a[0], a[1]);
+ });
+ if (!handled) return fullReload();
+ hmrDisposeQueue();
+ }
+ }
+}
+
+},{}],"1jwFz":[function(require,module,exports,__globalThis) {
+var _indexTs = require("./assets/scripts/index.ts");
+var _indexScss = require("./assets/scss/index.scss");
+
+},{"./assets/scripts/index.ts":"cmXY9","./assets/scss/index.scss":"6ebKA"}],"cmXY9":[function(require,module,exports,__globalThis) {
+var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js");
+var _toastifyJs = require("toastify-js");
+var _toastifyJsDefault = parcelHelpers.interopDefault(_toastifyJs);
+const favoritePosts = {
+ init () {
+ if (!this.component) return;
+ this.addEventListeners();
+ },
+ addEventListeners () {
+ this.component.addEventListener('click', this.handleClick.bind(this));
+ },
+ async favoritePost () {
+ // @ts-ignore
+ fetch(wpApiSettings.root + wpApiSettings.rest_namespace + '/favorite-posts', {
+ method: 'POST',
+ body: JSON.stringify({
+ post_id: this.component.dataset.postId
+ }),
+ headers: {
+ 'Content-Type': 'application/json',
+ // @ts-ignore
+ 'X-WP-Nonce': wpApiSettings.nonce
+ }
+ }).then((response)=>response.json()).then((data)=>{
+ (0, _toastifyJsDefault.default)({
+ text: data.message
+ }).showToast();
+ this.component.classList.add('is-favorite');
+ }).catch((error)=>{
+ (0, _toastifyJsDefault.default)({
+ text: 'Failed to add favorite post',
+ className: 'error',
+ style: {
+ background: 'linear-gradient(to right, #ff0000, #ff4747)'
+ }
+ }).showToast();
+ });
+ },
+ async handleClick () {
+ !!this.dialogLoader && this.dialogLoader.showModal();
+ if (this.component.classList.contains('is-favorite')) await this.removeFavoritePost();
+ else await this.favoritePost();
+ !!this.dialogLoader && this.dialogLoader.close();
+ },
+ async removeFavoritePost () {
+ // @ts-ignore
+ fetch(wpApiSettings.root + wpApiSettings.rest_namespace + '/favorite-posts', {
+ method: 'DELETE',
+ body: JSON.stringify({
+ post_id: this.component.dataset.postId
+ }),
+ headers: {
+ 'Content-Type': 'application/json',
+ // @ts-ignore
+ 'X-WP-Nonce': wpApiSettings.nonce
+ }
+ }).then((response)=>response.json()).then((data)=>{
+ (0, _toastifyJsDefault.default)({
+ text: data.message
+ }).showToast();
+ this.component.classList.remove('is-favorite');
+ }).catch((error)=>{
+ (0, _toastifyJsDefault.default)({
+ text: 'Failed to remove favorite post',
+ className: 'error',
+ style: {
+ background: 'linear-gradient(to right, #ff0000, #ff4747)'
+ }
+ }).showToast();
+ });
+ },
+ component: document.querySelector('.favorite-post-button'),
+ dialogLoader: document.querySelector('#dialog-loader'),
+ favoritePostsPopover: document.querySelector('#favorite-posts-popover')
+};
+favoritePosts.init();
+
+},{"toastify-js":"96k49","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"96k49":[function(require,module,exports,__globalThis) {
+/*!
+ * Toastify js 1.12.0
+ * https://github.com/apvarun/toastify-js
+ * @license MIT licensed
+ *
+ * Copyright (C) 2018 Varun A P
+ */ (function(root, factory) {
+ if (module.exports) module.exports = factory();
+ else root.Toastify = factory();
+})(this, function(global) {
+ // Object initialization
+ var Toastify = function(options) {
+ // Returning a new init object
+ return new Toastify.lib.init(options);
+ }, // Library version
+ version = "1.12.0";
+ // Set the default global options
+ Toastify.defaults = {
+ oldestFirst: true,
+ text: "Toastify is awesome!",
+ node: undefined,
+ duration: 3000,
+ selector: undefined,
+ callback: function() {},
+ destination: undefined,
+ newWindow: false,
+ close: false,
+ gravity: "toastify-top",
+ positionLeft: false,
+ position: '',
+ backgroundColor: '',
+ avatar: "",
+ className: "",
+ stopOnFocus: true,
+ onClick: function() {},
+ offset: {
+ x: 0,
+ y: 0
+ },
+ escapeMarkup: true,
+ ariaLive: 'polite',
+ style: {
+ background: ''
+ }
+ };
+ // Defining the prototype of the object
+ Toastify.lib = Toastify.prototype = {
+ toastify: version,
+ constructor: Toastify,
+ // Initializing the object with required parameters
+ init: function(options) {
+ // Verifying and validating the input object
+ if (!options) options = {};
+ // Creating the options object
+ this.options = {};
+ this.toastElement = null;
+ // Validating the options
+ this.options.text = options.text || Toastify.defaults.text; // Display message
+ this.options.node = options.node || Toastify.defaults.node; // Display content as node
+ this.options.duration = options.duration === 0 ? 0 : options.duration || Toastify.defaults.duration; // Display duration
+ this.options.selector = options.selector || Toastify.defaults.selector; // Parent selector
+ this.options.callback = options.callback || Toastify.defaults.callback; // Callback after display
+ this.options.destination = options.destination || Toastify.defaults.destination; // On-click destination
+ this.options.newWindow = options.newWindow || Toastify.defaults.newWindow; // Open destination in new window
+ this.options.close = options.close || Toastify.defaults.close; // Show toast close icon
+ this.options.gravity = options.gravity === "bottom" ? "toastify-bottom" : Toastify.defaults.gravity; // toast position - top or bottom
+ this.options.positionLeft = options.positionLeft || Toastify.defaults.positionLeft; // toast position - left or right
+ this.options.position = options.position || Toastify.defaults.position; // toast position - left or right
+ this.options.backgroundColor = options.backgroundColor || Toastify.defaults.backgroundColor; // toast background color
+ this.options.avatar = options.avatar || Toastify.defaults.avatar; // img element src - url or a path
+ this.options.className = options.className || Toastify.defaults.className; // additional class names for the toast
+ this.options.stopOnFocus = options.stopOnFocus === undefined ? Toastify.defaults.stopOnFocus : options.stopOnFocus; // stop timeout on focus
+ this.options.onClick = options.onClick || Toastify.defaults.onClick; // Callback after click
+ this.options.offset = options.offset || Toastify.defaults.offset; // toast offset
+ this.options.escapeMarkup = options.escapeMarkup !== undefined ? options.escapeMarkup : Toastify.defaults.escapeMarkup;
+ this.options.ariaLive = options.ariaLive || Toastify.defaults.ariaLive;
+ this.options.style = options.style || Toastify.defaults.style;
+ if (options.backgroundColor) this.options.style.background = options.backgroundColor;
+ // Returning the current object for chaining functions
+ return this;
+ },
+ // Building the DOM element
+ buildToast: function() {
+ // Validating if the options are defined
+ if (!this.options) throw "Toastify is not initialized";
+ // Creating the DOM object
+ var divElement = document.createElement("div");
+ divElement.className = "toastify on " + this.options.className;
+ // Positioning toast to left or right or center
+ if (!!this.options.position) divElement.className += " toastify-" + this.options.position;
+ else // To be depreciated in further versions
+ if (this.options.positionLeft === true) {
+ divElement.className += " toastify-left";
+ console.warn('Property `positionLeft` will be depreciated in further versions. Please use `position` instead.');
+ } else // Default position
+ divElement.className += " toastify-right";
+ // Assigning gravity of element
+ divElement.className += " " + this.options.gravity;
+ if (this.options.backgroundColor) // This is being deprecated in favor of using the style HTML DOM property
+ console.warn('DEPRECATION NOTICE: "backgroundColor" is being deprecated. Please use the "style.background" property.');
+ // Loop through our style object and apply styles to divElement
+ for(var property in this.options.style)divElement.style[property] = this.options.style[property];
+ // Announce the toast to screen readers
+ if (this.options.ariaLive) divElement.setAttribute('aria-live', this.options.ariaLive);
+ // Adding the toast message/node
+ if (this.options.node && this.options.node.nodeType === Node.ELEMENT_NODE) // If we have a valid node, we insert it
+ divElement.appendChild(this.options.node);
+ else {
+ if (this.options.escapeMarkup) divElement.innerText = this.options.text;
+ else divElement.innerHTML = this.options.text;
+ if (this.options.avatar !== "") {
+ var avatarElement = document.createElement("img");
+ avatarElement.src = this.options.avatar;
+ avatarElement.className = "toastify-avatar";
+ if (this.options.position == "left" || this.options.positionLeft === true) // Adding close icon on the left of content
+ divElement.appendChild(avatarElement);
+ else // Adding close icon on the right of content
+ divElement.insertAdjacentElement("afterbegin", avatarElement);
+ }
+ }
+ // Adding a close icon to the toast
+ if (this.options.close === true) {
+ // Create a span for close element
+ var closeElement = document.createElement("button");
+ closeElement.type = "button";
+ closeElement.setAttribute("aria-label", "Close");
+ closeElement.className = "toast-close";
+ closeElement.innerHTML = "✖";
+ // Triggering the removal of toast from DOM on close click
+ closeElement.addEventListener("click", (function(event) {
+ event.stopPropagation();
+ this.removeElement(this.toastElement);
+ window.clearTimeout(this.toastElement.timeOutValue);
+ }).bind(this));
+ //Calculating screen width
+ var width = window.innerWidth > 0 ? window.innerWidth : screen.width;
+ // Adding the close icon to the toast element
+ // Display on the right if screen width is less than or equal to 360px
+ if ((this.options.position == "left" || this.options.positionLeft === true) && width > 360) // Adding close icon on the left of content
+ divElement.insertAdjacentElement("afterbegin", closeElement);
+ else // Adding close icon on the right of content
+ divElement.appendChild(closeElement);
+ }
+ // Clear timeout while toast is focused
+ if (this.options.stopOnFocus && this.options.duration > 0) {
+ var self = this;
+ // stop countdown
+ divElement.addEventListener("mouseover", function(event) {
+ window.clearTimeout(divElement.timeOutValue);
+ });
+ // add back the timeout
+ divElement.addEventListener("mouseleave", function() {
+ divElement.timeOutValue = window.setTimeout(function() {
+ // Remove the toast from DOM
+ self.removeElement(divElement);
+ }, self.options.duration);
+ });
+ }
+ // Adding an on-click destination path
+ if (typeof this.options.destination !== "undefined") divElement.addEventListener("click", (function(event) {
+ event.stopPropagation();
+ if (this.options.newWindow === true) window.open(this.options.destination, "_blank");
+ else window.location = this.options.destination;
+ }).bind(this));
+ if (typeof this.options.onClick === "function" && typeof this.options.destination === "undefined") divElement.addEventListener("click", (function(event) {
+ event.stopPropagation();
+ this.options.onClick();
+ }).bind(this));
+ // Adding offset
+ if (typeof this.options.offset === "object") {
+ var x = getAxisOffsetAValue("x", this.options);
+ var y = getAxisOffsetAValue("y", this.options);
+ var xOffset = this.options.position == "left" ? x : "-" + x;
+ var yOffset = this.options.gravity == "toastify-top" ? y : "-" + y;
+ divElement.style.transform = "translate(" + xOffset + "," + yOffset + ")";
+ }
+ // Returning the generated element
+ return divElement;
+ },
+ // Displaying the toast
+ showToast: function() {
+ // Creating the DOM object for the toast
+ this.toastElement = this.buildToast();
+ // Getting the root element to with the toast needs to be added
+ var rootElement;
+ if (typeof this.options.selector === "string") rootElement = document.getElementById(this.options.selector);
+ else if (this.options.selector instanceof HTMLElement || typeof ShadowRoot !== 'undefined' && this.options.selector instanceof ShadowRoot) rootElement = this.options.selector;
+ else rootElement = document.body;
+ // Validating if root element is present in DOM
+ if (!rootElement) throw "Root element is not defined";
+ // Adding the DOM element
+ var elementToInsert = Toastify.defaults.oldestFirst ? rootElement.firstChild : rootElement.lastChild;
+ rootElement.insertBefore(this.toastElement, elementToInsert);
+ // Repositioning the toasts in case multiple toasts are present
+ Toastify.reposition();
+ if (this.options.duration > 0) this.toastElement.timeOutValue = window.setTimeout((function() {
+ // Remove the toast from DOM
+ this.removeElement(this.toastElement);
+ }).bind(this), this.options.duration); // Binding `this` for function invocation
+ // Supporting function chaining
+ return this;
+ },
+ hideToast: function() {
+ if (this.toastElement.timeOutValue) clearTimeout(this.toastElement.timeOutValue);
+ this.removeElement(this.toastElement);
+ },
+ // Removing the element from the DOM
+ removeElement: function(toastElement) {
+ // Hiding the element
+ // toastElement.classList.remove("on");
+ toastElement.className = toastElement.className.replace(" on", "");
+ // Removing the element from DOM after transition end
+ window.setTimeout((function() {
+ // remove options node if any
+ if (this.options.node && this.options.node.parentNode) this.options.node.parentNode.removeChild(this.options.node);
+ // Remove the element from the DOM, only when the parent node was not removed before.
+ if (toastElement.parentNode) toastElement.parentNode.removeChild(toastElement);
+ // Calling the callback function
+ this.options.callback.call(toastElement);
+ // Repositioning the toasts again
+ Toastify.reposition();
+ }).bind(this), 400); // Binding `this` for function invocation
+ }
+ };
+ // Positioning the toasts on the DOM
+ Toastify.reposition = function() {
+ // Top margins with gravity
+ var topLeftOffsetSize = {
+ top: 15,
+ bottom: 15
+ };
+ var topRightOffsetSize = {
+ top: 15,
+ bottom: 15
+ };
+ var offsetSize = {
+ top: 15,
+ bottom: 15
+ };
+ // Get all toast messages on the DOM
+ var allToasts = document.getElementsByClassName("toastify");
+ var classUsed;
+ // Modifying the position of each toast element
+ for(var i = 0; i < allToasts.length; i++){
+ // Getting the applied gravity
+ if (containsClass(allToasts[i], "toastify-top") === true) classUsed = "toastify-top";
+ else classUsed = "toastify-bottom";
+ var height = allToasts[i].offsetHeight;
+ classUsed = classUsed.substr(9, classUsed.length - 1);
+ // Spacing between toasts
+ var offset = 15;
+ var width = window.innerWidth > 0 ? window.innerWidth : screen.width;
+ // Show toast in center if screen with less than or equal to 360px
+ if (width <= 360) {
+ // Setting the position
+ allToasts[i].style[classUsed] = offsetSize[classUsed] + "px";
+ offsetSize[classUsed] += height + offset;
+ } else if (containsClass(allToasts[i], "toastify-left") === true) {
+ // Setting the position
+ allToasts[i].style[classUsed] = topLeftOffsetSize[classUsed] + "px";
+ topLeftOffsetSize[classUsed] += height + offset;
+ } else {
+ // Setting the position
+ allToasts[i].style[classUsed] = topRightOffsetSize[classUsed] + "px";
+ topRightOffsetSize[classUsed] += height + offset;
+ }
+ }
+ // Supporting function chaining
+ return this;
+ };
+ // Helper function to get offset.
+ function getAxisOffsetAValue(axis, options) {
+ if (options.offset[axis]) {
+ if (isNaN(options.offset[axis])) return options.offset[axis];
+ else return options.offset[axis] + 'px';
+ }
+ return '0px';
+ }
+ function containsClass(elem, yourClass) {
+ if (!elem || typeof yourClass !== "string") return false;
+ else if (elem.className && elem.className.trim().split(/\s+/gi).indexOf(yourClass) > -1) return true;
+ else return false;
+ }
+ // Setting up the prototype for the init object
+ Toastify.lib.init.prototype = Toastify.lib;
+ // Returning the Toastify function to be assigned to the window object/module
+ return Toastify;
+});
+
+},{}],"gkKU3":[function(require,module,exports,__globalThis) {
+exports.interopDefault = function(a) {
+ return a && a.__esModule ? a : {
+ default: a
+ };
+};
+exports.defineInteropFlag = function(a) {
+ Object.defineProperty(a, '__esModule', {
+ value: true
+ });
+};
+exports.exportAll = function(source, dest) {
+ Object.keys(source).forEach(function(key) {
+ if (key === 'default' || key === '__esModule' || Object.prototype.hasOwnProperty.call(dest, key)) return;
+ Object.defineProperty(dest, key, {
+ enumerable: true,
+ get: function() {
+ return source[key];
+ }
+ });
+ });
+ return dest;
+};
+exports.export = function(dest, destName, get) {
+ Object.defineProperty(dest, destName, {
+ enumerable: true,
+ get: get
+ });
+};
+
+},{}],"6ebKA":[function() {},{}]},["1Mq6V","1jwFz"], "1jwFz", "parcelRequire2f3a", {})
+
+//# sourceMappingURL=index.js.map
diff --git a/index.ts b/index.ts
new file mode 100644
index 00000000..7d9c2a4e
--- /dev/null
+++ b/index.ts
@@ -0,0 +1,2 @@
+import './assets/scripts/index.ts';
+import './assets/scss/index.scss';
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..afcdea77
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "wordpress-back-end-challenge",
+ "version": "1.0.0",
+ "source": "index.ts",
+ "repository": "git@github.com:Apiki/wordpress-back-end-challenge.git",
+ "author": "Kayo Tusthler ",
+ "license": "MIT",
+ "devDependencies": {
+ "@parcel/transformer-sass": "2.16.4",
+ "parcel": "2.16.4"
+ },
+ "scripts": {
+ "dev": "parcel watch index.ts",
+ "build": "parcel build index.ts"
+ },
+ "dependencies": {
+ "toastify-js": "^1.12.0"
+ }
+}
diff --git a/src/dbHandler.php b/src/dbHandler.php
new file mode 100644
index 00000000..4c5f7eb9
--- /dev/null
+++ b/src/dbHandler.php
@@ -0,0 +1,64 @@
+wpdb = $wpdb;
+ $this->table_name = $this->wpdb->prefix . 'favorite_posts';
+ }
+
+ public function createTable() {
+ $table = $this->table_name;
+ $charset_collate = $this->wpdb->get_charset_collate();
+
+ $sql = "CREATE TABLE $table (
+ 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 DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY user_id_post_id (user_id, post_id)
+ ) $charset_collate;";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ dbDelta($sql);
+ }
+
+ private function dropTable() {
+ $this->wpdb->query("DROP TABLE IF EXISTS $this->table_name");
+ }
+
+ public function getAllFavoritesByUserId($user_id) {
+ $query = $this->wpdb->prepare("SELECT * FROM $this->table_name WHERE user_id = %d", $user_id);
+ return $this->wpdb->get_results($query);
+ }
+
+ public function getFavoriteByUserAndPost($user_id, $post_id) {
+ $query = $this->wpdb->prepare("SELECT * FROM $this->table_name WHERE user_id = %d AND post_id = %d", $user_id, $post_id);
+ return $this->wpdb->get_results($query);
+ }
+
+ public function addFavorite($user_id, $post_id) {
+ $existing = $this->getFavoriteByUserAndPost($user_id, $post_id);
+
+ if (!empty($existing)) :
+ return (int) $existing[0]->id;
+ endif;
+
+ $result = $this->wpdb->insert($this->table_name, ['user_id' => $user_id, 'post_id' => $post_id]);
+
+ return $this->wpdb->insert_id;
+ }
+
+ public function removeFavorite($user_id, $post_id) {
+ return $this->wpdb->delete($this->table_name, ['user_id' => $user_id, 'post_id' => $post_id]);
+ }
+}
\ No newline at end of file
diff --git a/src/restHandler.php b/src/restHandler.php
new file mode 100644
index 00000000..15f3771c
--- /dev/null
+++ b/src/restHandler.php
@@ -0,0 +1,148 @@
+dbHandler = new DbHandler();
+ $this->restNamespace = self::getRestNamespace();
+
+ add_action('rest_api_init', [$this, 'registerRoutes']);
+ }
+
+ public static function getRestNamespace() {
+ return 'wordpress-back-end-challenge/v1';
+ }
+
+ public function registerRoutes() {
+ register_rest_route($this->restNamespace, '/favorite-posts', [
+ 'methods' => 'GET',
+ 'callback' => [$this, 'getFavoritePosts'],
+ 'permission_callback' => function () {
+ return is_user_logged_in() ? true : new WP_Error( 'rest_not_logged_in', 'You must be logged in to access this endpoint.', array( 'status' => 401 ) );
+ },
+ ]);
+
+ register_rest_route($this->restNamespace, '/favorite-posts', [
+ 'methods' => 'POST',
+ 'callback' => [$this, 'addFavoritePost'],
+ 'permission_callback' => function () {
+ return is_user_logged_in() ? true : new WP_Error( 'rest_not_logged_in', 'You must be logged in to access this endpoint.', array( 'status' => 401 ) );
+ },
+ 'args' => [
+ 'post_id' => [
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ]
+ ],
+ ]);
+
+ register_rest_route($this->restNamespace, '/favorite-posts', [
+ 'methods' => 'DELETE',
+ 'callback' => [$this, 'removeFavoritePost'],
+ 'permission_callback' => function () {
+ return is_user_logged_in() ? true : new WP_Error( 'rest_not_logged_in', 'You must be logged in to access this endpoint.', array( 'status' => 401 ) );
+ },
+ 'args' => [
+ 'post_id' => [
+ 'type' => 'integer',
+ 'required' => true,
+ 'sanitize_callback' => 'absint',
+ ]
+ ],
+ ]);
+ }
+
+ public function getFavoritePosts($request) {
+ $favorites = $this->dbHandler->getAllFavoritesByUserId(get_current_user_id());
+
+ if ($favorites) {
+ return new \WP_REST_Response([
+ 'code' => 'favorite_posts_fetched',
+ 'status' => 'success',
+ 'message' => 'Favorite posts fetched',
+ 'data' => $favorites
+ ], 200);
+ }
+
+ return new \WP_REST_Response([
+ 'code' => 'failed_to_fetch_favorite_posts',
+ 'status' => 'error',
+ 'message' => 'Failed to fetch favorite posts',
+ 'data' => []
+ ], 200);
+ }
+
+ public function addFavoritePost($request) {
+ $params = $request->get_json_params();
+
+ $post_id = isset($params['post_id']) ? absint($params['post_id']) : absint($request->get_param('post_id'));
+
+ if (!$post_id) {
+ return new \WP_REST_Response([
+ 'code' => 'invalid_favorite_post_params',
+ 'data' => [],
+ 'message' => 'Invalid post_id',
+ 'status' => 'error'
+ ], 400);
+ }
+
+ $result = $this->dbHandler->addFavorite(get_current_user_id(), $post_id);
+
+ if ($result) {
+ return new \WP_REST_Response([
+ 'code' => 'favorite_post_added',
+ 'data' => [
+ 'post_id' => $post_id
+ ],
+ 'message' => 'Favorite post added',
+ 'status' => 'success'
+ ], 200);
+ }
+
+ return new \WP_REST_Response([
+ 'code' => 'failed_to_add_favorite_post',
+ 'data' => [],
+ 'message' => 'Failed to add favorite post',
+ 'status' => 'error',
+ ], 400);
+ }
+
+ public function removeFavoritePost($request) {
+ $params = $request->get_json_params();
+ $post_id = isset($params['post_id']) ? absint($params['post_id']) : absint($request->get_param('post_id'));
+
+ if (!$post_id) {
+ return new \WP_REST_Response([
+ 'code' => 'invalid_favorite_post_params',
+ 'data' => [],
+ 'message' => 'Invalid post_id',
+ 'status' => 'error'
+ ], 400);
+ }
+
+ $result = $this->dbHandler->removeFavorite(get_current_user_id(), $post_id);
+
+ if ($result) {
+ return new \WP_REST_Response([
+ 'code' => 'favorite_post_removed',
+ 'data' => [],
+ 'status' => 'success',
+ 'message' => 'Favorite post removed',
+ ], 200);
+ }
+
+ return new \WP_REST_Response([
+ 'code' => 'failed_to_remove_favorite_post',
+ 'data' => [],
+ 'status' => 'error',
+ 'message' => 'Failed to remove favorite post'
+ ], 400);
+ }
+}
diff --git a/wordpress-back-end-challenge.php b/wordpress-back-end-challenge.php
new file mode 100644
index 00000000..9b7dfa60
--- /dev/null
+++ b/wordpress-back-end-challenge.php
@@ -0,0 +1,110 @@
+createTable();
+}
+
+register_activation_hook(__FILE__, __NAMESPACE__ . '\\favorite_posts_plugin_activate');
+
+
+class WordPressBackEndChallenge {
+ private $dbHandler;
+
+ public function __construct() {
+ $this->dbHandler = new DbHandler();
+ new RestHandler();
+
+ add_filter( 'the_content', [$this, 'addFavoritePostIcon'] );
+ add_action( 'wp_enqueue_scripts', [$this, 'enqueueScripts'] );
+ add_action( 'wp_footer', [$this, 'addDialogLoader'] );
+ }
+
+ public function addDialogLoader() {
+ echo '';
+ }
+
+ public function addFavoritePostIcon( $content )
+ {
+ if ( !is_singular() || !in_the_loop() || !is_main_query() || !is_user_logged_in() ) :
+ return $content;
+ endif;
+
+ $is_favorite = $this->dbHandler->getFavoriteByUserAndPost(get_current_user_id(), get_the_ID());
+
+ $custom_content = "";
+
+ $content = $custom_content . $content;
+
+ return $content;
+ }
+
+ public function enqueueScripts() {
+ if (!is_user_logged_in() || !is_singular()) :
+ return;
+ endif;
+
+ wp_enqueue_script(
+ 'toastify',
+ 'https://cdn.jsdelivr.net/npm/toastify-js',
+ [],
+ '1.12.0',
+ [
+ 'in_footer' => true,
+ 'strategy' => 'async'
+ ]
+ );
+
+ wp_enqueue_script(
+ 'wordpress-back-end-challenge',
+ plugin_dir_url( __FILE__ ) . 'dist/index.js',
+ [],
+ filemtime(plugin_dir_path( __FILE__ ) . 'dist/index.js'),
+ [
+ 'in_footer' => true,
+ 'strategy' => 'async'
+ ]
+ );
+
+ wp_localize_script(
+ 'wordpress-back-end-challenge',
+ 'wpApiSettings',
+ [
+ 'rest_namespace' => RestHandler::getRestNamespace(),
+ 'root' => esc_url_raw(rest_url()),
+ 'nonce' => wp_create_nonce('wp_rest'),
+ ]
+ );
+
+ wp_enqueue_style(
+ 'wordpress-back-end-challenge',
+ plugin_dir_url( __FILE__ ) . 'dist/index.css',
+ [],
+ filemtime(plugin_dir_path( __FILE__ ) . 'dist/index.css')
+ );
+
+ wp_enqueue_style(
+ 'toastify',
+ 'https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css',
+ [],
+ '1.12.0'
+ );
+ }
+}
+
+new WordPressBackEndChallenge();