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 ? `
\u{1F4DD} Learn more
` : ''} +
+ `; + } + 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();