diff --git a/site/plugins/front-comments b/site/plugins/front-comments deleted file mode 160000 index a20a49f..0000000 --- a/site/plugins/front-comments +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a20a49fe8be9300cca38e95e7d84db47497a646f diff --git a/site/plugins/front-comments/.editorconfig b/site/plugins/front-comments/.editorconfig new file mode 100644 index 0000000..3b762c9 --- /dev/null +++ b/site/plugins/front-comments/.editorconfig @@ -0,0 +1,20 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.php] +indent_size = 4 + +[*.md,*.txt] +trim_trailing_whitespace = false +insert_final_newline = false + +[composer.json] +indent_size = 4 diff --git a/site/plugins/front-comments/LICENSE.md b/site/plugins/front-comments/LICENSE.md new file mode 100755 index 0000000..8e663d7 --- /dev/null +++ b/site/plugins/front-comments/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/site/plugins/front-comments/README.md b/site/plugins/front-comments/README.md new file mode 100755 index 0000000..bf0da4d --- /dev/null +++ b/site/plugins/front-comments/README.md @@ -0,0 +1,98 @@ +![Kirby Front Comments plugin cover](./doc/cover.jpg) + +# Kirby front comments plugin + +The Kirby Front Comments plugin allows you to add comments anywhere in the front-end of your Kirby website, making it easy for admins, developers, designers and other collaborators to communicate and collaborate directly on the site. It automatically integrate contextual information to each comment, such as user agent, window width, date and time, making it a valuable tool for debugging and troubleshooting. + +In addition, the plugin can be connected to your GitHub, GitLab (including Framagit) account, allowing you to create and close issues directly from your website. This makes it easy to track and manage issues, and to keep all your collaboration in one place. + +## Setup + +### Install + +**Composer is the recommended way to install the plugin.** Run the following command in your terminal: + +``` +composer require adrienpayet/front-comments +``` + +Alternatively, you can download this repository and add it to the /site/plugins directory of your project. + +### Include the snippet + +Add the snippet anywhere inside the `` of your header: ``. + +### Connect with GitHub or GitLab repository (optional) + +To create issues directly from comments on the website, add the repository informations to the plugin `repo` option : + +`/site/config/config.php`: + +```php +return [ + 'adrienpayet.front-comments.repo.service' => 'github', // REQUIRED - 'github' | 'gitlab' | 'framagit' (case insensive) + 'adrienpayet.front-comments.repo.token' => 'glpat-xxxxxxxxxxxxxxxxxxxx', // REQUIRED - access token (see below) + 'adrienpayet.front-comments.repo.owner' => 'username', // REQUIRED + 'adrienpayet.front-comments.repo.name' => 'repository name', // REQUIRED - 'Your Plugin Name' | 'your-plugin-name' + 'adrienpayet.front-comments.repo.labels' => ['bug', 'front-end'], // OPTIONAL - ['label 1', 'label 2'] (Default value is set to ['front-comments']). +] +``` + +**Use the string notation to set each option individually. Setting the plugins options as an array would overwrite some necessary default options.** + +_Learn how to create and manage personnal access tokens on [GitHub](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) and [GitLab](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token)._ + +## Use + +### Add new comments + +Logged in users can add comments directly in the front-end by clicking the button located by default in the bottom right corner of the screen. See below how to [change its position](#customize). + +### Manage comments though the panel + +To view and manage all comments, visit `your-website.com/panel/comments`, also is accessible from the default panel menu. To add the comments global view to your custom panel menu, call 'comments' in your `/site/config/config.php` : + +```php +'panel' => [ + 'menu' => [ + 'custom-menu-item' => [...], + '-', + + 'comments' + + 'users', + ] +] +``` + +From this section, you can create and delete comments, as well as create or visit linked issues. + +Please note that **issues can only be created through this section**. However, deleting a comment, whether from the front-end or the panel, will automatically close any linked issues. This ensures that your comments and issues are always synchronized and up-to-date. + +## Customize + +### Change the add button position + +The default position of the add button is set to bottom right. You can change it by passing a `position` property to the snippet : + +```php + 'bottom-right' // default position + // alternatives : + 'position' => 'bottom-left' + 'position' => 'top-left' + 'position' => 'top-right' +]) ?> +``` + +## Troubleshooting + +### Fix 403 error (rare) + +In rare cases, the plugin files may not load due to 403 errors (check the JavaScript console). Fix it by passing a `'location' => 'assets'` property to the snippet: + +```php + 'assets']) ?> +``` + +This will copy the files into `/assets/front-comments`, which should resolve the issue. diff --git a/site/plugins/front-comments/SECURITY.md b/site/plugins/front-comments/SECURITY.md new file mode 100644 index 0000000..3726336 --- /dev/null +++ b/site/plugins/front-comments/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +*Use this section to tell people about which versions of your project are currently being supported with security updates.* + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +*Use this section to tell people how to report a vulnerability.* + +*Tell them where to go, how often they can expect to get an update on a reported vulnerability, what to expect if the vulnerability is accepted or declined, etc.* diff --git a/site/plugins/front-comments/assets/icons/accept.svg b/site/plugins/front-comments/assets/icons/accept.svg new file mode 100644 index 0000000..76352cb --- /dev/null +++ b/site/plugins/front-comments/assets/icons/accept.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/site/plugins/front-comments/assets/icons/chat-box-empty.svg b/site/plugins/front-comments/assets/icons/chat-box-empty.svg new file mode 100644 index 0000000..34b364d --- /dev/null +++ b/site/plugins/front-comments/assets/icons/chat-box-empty.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/site/plugins/front-comments/assets/icons/chat-box-plus.svg b/site/plugins/front-comments/assets/icons/chat-box-plus.svg new file mode 100644 index 0000000..ec99631 --- /dev/null +++ b/site/plugins/front-comments/assets/icons/chat-box-plus.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/site/plugins/front-comments/assets/icons/chat-box-text-select.svg b/site/plugins/front-comments/assets/icons/chat-box-text-select.svg new file mode 100644 index 0000000..7326afc --- /dev/null +++ b/site/plugins/front-comments/assets/icons/chat-box-text-select.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/site/plugins/front-comments/assets/icons/code.svg b/site/plugins/front-comments/assets/icons/code.svg new file mode 100644 index 0000000..b8d8005 --- /dev/null +++ b/site/plugins/front-comments/assets/icons/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/plugins/front-comments/assets/icons/decline.svg b/site/plugins/front-comments/assets/icons/decline.svg new file mode 100644 index 0000000..cd5a56d --- /dev/null +++ b/site/plugins/front-comments/assets/icons/decline.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/site/plugins/front-comments/assets/icons/delete.svg b/site/plugins/front-comments/assets/icons/delete.svg new file mode 100644 index 0000000..a18c05e --- /dev/null +++ b/site/plugins/front-comments/assets/icons/delete.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/site/plugins/front-comments/assets/icons/gitlab.svg b/site/plugins/front-comments/assets/icons/gitlab.svg new file mode 100644 index 0000000..b4ae72b --- /dev/null +++ b/site/plugins/front-comments/assets/icons/gitlab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/plugins/front-comments/assets/icons/palette.svg b/site/plugins/front-comments/assets/icons/palette.svg new file mode 100644 index 0000000..8115984 --- /dev/null +++ b/site/plugins/front-comments/assets/icons/palette.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/plugins/front-comments/assets/icons/pen.svg b/site/plugins/front-comments/assets/icons/pen.svg new file mode 100644 index 0000000..a8b4ba1 --- /dev/null +++ b/site/plugins/front-comments/assets/icons/pen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/plugins/front-comments/assets/index.css b/site/plugins/front-comments/assets/index.css new file mode 100644 index 0000000..1afa00c --- /dev/null +++ b/site/plugins/front-comments/assets/index.css @@ -0,0 +1 @@ +h2[data-v-019dfc9e]{margin-bottom:1rem}.k-table .k-table-index-column[data-v-019dfc9e]{width:6rem} diff --git a/site/plugins/front-comments/assets/index.js b/site/plugins/front-comments/assets/index.js new file mode 100644 index 0000000..f145fcd --- /dev/null +++ b/site/plugins/front-comments/assets/index.js @@ -0,0 +1,35 @@ +(function(){"use strict";const m={comments:[],suggestions:[],author:null,page:{id:null,uri:null},csrf:null,save(u){const t={};t[u]=this[u];const d={method:"PATCH",headers:{"X-CSRF":this.csrf},body:JSON.stringify(t).replaceAll("_","")};return fetch("/api/pages/"+this.page.id,d).then(c=>c.ok?c.json():Promise.reject("Front comments plugin - can't add comment: "+c.statusText)).then(c=>{console.log(c),console.log("Front comments plugin - comment successfully added.")}).catch(c=>Promise.reject(c))}};var R=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function G(u){return u&&u.__esModule&&Object.prototype.hasOwnProperty.call(u,"default")?u.default:u}var U={exports:{}};(function(u,t){(function(d,c){u.exports=c()})(R,function(){var d=1e3,c=6e4,b=36e5,P="millisecond",w="second",C="minute",D="hour",M="day",j="week",y="month",J="quarter",x="year",T="date",Z="Invalid Date",it=/^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/,rt=/\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g,ot={name:"en",weekdays:"Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"),months:"January_February_March_April_May_June_July_August_September_October_November_December".split("_"),ordinal:function(r){var s=["th","st","nd","rd"],e=r%100;return"["+r+(s[(e-20)%10]||s[e]||s[0])+"]"}},I=function(r,s,e){var i=String(r);return!i||i.length>=s?r:""+Array(s+1-i.length).join(e)+r},at={s:I,z:function(r){var s=-r.utcOffset(),e=Math.abs(s),i=Math.floor(e/60),n=e%60;return(s<=0?"+":"-")+I(i,2,"0")+":"+I(n,2,"0")},m:function r(s,e){if(s.date()1)return r(a[0])}else{var l=s.name;L[l]=s,n=l}return!i&&n&&(k=n),n||!i&&k},_=function(r,s){if(N(r))return r.clone();var e=typeof s=="object"?s:{};return e.date=r,e.args=arguments,new H(e)},h=at;h.l=F,h.i=N,h.w=function(r,s){return _(r,{locale:s.$L,utc:s.$u,x:s.$x,$offset:s.$offset})};var H=function(){function r(e){this.$L=F(e.locale,null,!0),this.parse(e),this.$x=this.$x||e.x||{},this[K]=!0}var s=r.prototype;return s.parse=function(e){this.$d=function(i){var n=i.date,o=i.utc;if(n===null)return new Date(NaN);if(h.u(n))return new Date;if(n instanceof Date)return new Date(n);if(typeof n=="string"&&!/Z$/i.test(n)){var a=n.match(it);if(a){var l=a[2]-1||0,f=(a[7]||"0").substring(0,3);return o?new Date(Date.UTC(a[1],l,a[3]||1,a[4]||0,a[5]||0,a[6]||0,f)):new Date(a[1],l,a[3]||1,a[4]||0,a[5]||0,a[6]||0,f)}}return new Date(n)}(e),this.init()},s.init=function(){var e=this.$d;this.$y=e.getFullYear(),this.$M=e.getMonth(),this.$D=e.getDate(),this.$W=e.getDay(),this.$H=e.getHours(),this.$m=e.getMinutes(),this.$s=e.getSeconds(),this.$ms=e.getMilliseconds()},s.$utils=function(){return h},s.isValid=function(){return this.$d.toString()!==Z},s.isSame=function(e,i){var n=_(e);return this.startOf(i)<=n&&n<=this.endOf(i)},s.isAfter=function(e,i){return _(e) + ${this.author.slice(0,1).toUpperCase()} + `);return this.team&&(t.classList.add("fc__team-icon"),t.classList.add(`fc__team-icon--${this.team}`)),t.addEventListener("mouseenter",()=>{setTimeout(()=>{this.expand()},10)}),this.node=t,t}expand(){const t=this.windowWidth?`
  • + Largeur fenêtre : ${this.windowWidth}px +
  • `:"",d=this.userAgent?`
  • + Agent utilisateur :
    + ${this.userAgent} +
  • `:"",c=this.userAgent||this.windowWidth?` +
    + + +
    +
      + ${t} + ${d} +
    +
    +
    + `:"",b=this._injectNode(`
    + + ${this.author}
    ${this.date} à ${this.time}
    + +

    ${this.message}

    + ${c} +
    `);q.fixOffscreen(b),b.querySelector(".fc__comment-delete").addEventListener("click",()=>{this.remove()});const w=document.querySelector(".fc__open-window");return w&&w.addEventListener("click",()=>{window.open(window.location.href,"",`width=${this.windowWidth}, height=800`)}),b.addEventListener("mouseleave",()=>{setTimeout(()=>{this.toBubble()},10)}),this.node=b,b}_injectNode(t){this.node&&document.body.removeChild(this.node);const d=document.createElement("div");d.innerHTML=t;const c=d.firstChild;return document.body.appendChild(c),c}async remove(){const t={method:"PATCH"};fetch(`/comments/delete/${this.id}/${m.page.uri}.json`,t).then(d=>d.json()).then(d=>{m.comments=m.comments.filter(c=>c.id!=this.id),document.body.removeChild(this.node)}).catch(d=>{console.log(d)})}}class et{constructor(){this._panel,this._textArea}create(){this._panel=this._createPanel(),this._textArea=this._createTextArea();const t=this._createBtns();this._panel.appendChild(this._textArea),this._panel.appendChild(t),document.body.appendChild(this._panel),this._textArea.focus(),q.fixOffscreen(this._panel),document.querySelector(".fc__edition-panel__remove-btn").addEventListener("click",()=>{document.body.removeChild(this._panel)})}_createPanel(){const t=document.createElement("div");return t.classList.add("fc__edition-panel"),t.style.left=this._getMousePos().x+"px",t.style.top=this._getMousePos().y+window.scrollY+"px",t}_createBtns(){const t=document.createElement("div");t.classList.add("fc__edition-panel__btns");const d=document.createElement("button");d.classList.add("fc__edition-panel__remove-btn"),d.textContent="supprimer";const c=document.createElement("button");return c.classList.add("fc__edition-panel__save-btn"),c.textContent="enregistrer",t.appendChild(d),t.appendChild(c),t}_getMousePos(){return{x:event.clientX,y:event.clientY}}_createTextArea(){const t=document.createElement("textarea");return t.classList.add("fc__text"),t}_getPos(){const t=this._panel.offsetLeft/((window.innerWidth+window.pageXOffset)/100);return{top:this._panel.style.top,left:t+"vw"}}}class z{constructor(){this.position={},this.author="",this.team="",this.message="",this.id=Date.now(),this.date=X().format("DD/MM/YY"),this.time=X().format("HH:mm"),this.windowWidth=window.innerWidth,this.userAgent=navigator.userAgent}setPosition(t){return this.position=t,this}setAuthor(t){return this.author=t,this}setTeam(t){return this.team=t,this}setMessage(t){return this.message=t,this}setId(t){return this.id=t,this}setDate(t){return this.date=t,this}setTime(t){return this.time=t,this}setWindowWidth(t){return this.windowWidth=t,this}setUserAgent(t){return this.userAgent=t,this}build(){return!this.position||!this.author||!this.message?(console.error("Missing required parameters for Comment: position, author, message",this),null):new tt(this.position,this.author,this.message,this.id,this.date,this.time,this.windowWidth,this.userAgent,this.team)}}class nt extends et{create(){super.create(),document.querySelector(".fc__edition-panel__save-btn").addEventListener("click",()=>{this._createComment()}),this._textArea.addEventListener("keydown",t=>{t.key==="Enter"&&!t.shiftKey&&this._createComment(),t.key==="Escape"&&document.body.removeChild(this._panel)})}async _createComment(){const t=new z().setAuthor(m.author).setTeam(m.team).setMessage(this._textArea.value).setPosition({top:this._getPos().top,left:this._getPos().left}).build();try{m.comments.push(t),await m.save("comments"),t.toBubble(),document.body.removeChild(this._panel)}catch(d){m.comments=m.comments.filter(c=>c.id!==t.id),document.body.removeChild(t.node),console.error("Create comment failed:",d)}return t}}class st{constructor(t){this._button=this._createButton(t),this._initEventListeners()}_createButton(t){const d=document.createElement("div");d.innerHTML=``;const c=d.firstChild;return document.body.appendChild(c),c}_initEventListeners(){this._button.addEventListener("click",this._handleClick.bind(this)),window.addEventListener("mousemove",this._handleMouseMove.bind(this)),window.addEventListener("keydown",this._handleKeyDown.bind(this))}_handleClick(t){this._button.classList.contains("fc__btn-add--move")&&(new nt().create(),this._resetPosition()),this._button.classList.toggle("fc__btn-add--move")}_handleMouseMove(t){this._button.classList.contains("fc__btn-add--move")&&(this._button.style.left=t.clientX-48/2+"px",this._button.style.top=t.clientY+window.scrollY-48/2+"px")}_handleKeyDown(t){t.key==="Escape"&&(this._button.classList.remove("fc__btn-add--move"),this._resetPosition())}_resetPosition(){this._button.style.left="",this._button.style.top=""}}document.addEventListener("DOMContentLoaded",()=>{m.author=FCAuthor,m.team=FCTeam.length>0?FCTeam:void 0,m.page.id=FCPageId,m.page.uri=FCPageUri,m.csrf=FCCsrf,m.filesPath=FCFilesPath;try{FCComments.forEach(u=>{try{const t=new z().setPosition(u.position).setAuthor(u.author).setMessage(u.message).setId(u.id).setDate(u.date).setTime(u.time).setWindowWidth(u.windowWidth).setUserAgent(u.userAgent).setTeam(u==null?void 0:u.team).build();t.toBubble(),m.comments.push(t)}catch(t){console.error("Plugin Front Comments : can't parse comments : ",t),console.log("Comments : ",FCComments)}})}catch(u){console.error("Can't parse comments : ",u),console.log("Comments : ",FCComments)}new st(FCPosition)})})(); diff --git a/site/plugins/front-comments/assets/style.css b/site/plugins/front-comments/assets/style.css new file mode 100644 index 0000000..086d608 --- /dev/null +++ b/site/plugins/front-comments/assets/style.css @@ -0,0 +1,336 @@ +:root { + --fc-border: 2px solid #000; + --fc-border-light: 1px solid #999; + --fc-font-size-m: 1.1rem; + --fc-font-size-s: calc(var(--fc-font-size-m) / 1.3); +} + +button { + transition: all 0.2s ease-in-out; +} + +.fc__btn { + all: initial; + border: none !important; + background-color: transparent !important; + padding: 0 !important; + cursor: pointer !important; +} +.fc__btn:not(.fc__bubble):hover, +.fc__btn-add.fc__btn-add--move { + filter: invert(100%); +} +.fc__icon { + width: 100%; +} + +.fc__bubble, +.fc__btn-add { + z-index: 999; +} +.fc__btn-add:focus { + outline: none !important; +} + +.fc__edition-panel, +.fc__btn-add, +.fc__bubble { + z-index: 999; +} + +.fc__btn-add { + position: fixed; + width: 3rem; + height: 2.5rem; + cursor: pointer; +} +.fc__btn-add--bottom-right { + bottom: 1rem; + right: 1rem; +} +.fc__btn-add--bottom-left { + bottom: 1rem; + left: 1rem; +} +.fc__btn-add--top-left { + top: 1rem; + left: 1rem; +} +.fc__btn-add--top-right { + top: 1rem; + right: 1rem; +} + +.fc__btn-add .fc__plus { + width: 3rem; + height: 3rem; + display: inline-block; + transform: translateY(-2px); +} +.fc__btn-add:not(.fc__btn-add--move) { + top: auto !important; + left: auto !important; +} +.fc__btn-add.fc__btn-add--move { + position: absolute; + cursor: none !important; +} + +.fc__btn-add--field { + right: 5rem; +} + +.fc__edition-panel { + --panel-width: 15rem; + position: absolute; + padding-bottom: 0; +} + +.fc__edition-panel--left { + transform: translateX(calc(0rem - var(--panel-width))); +} +.fc__edition-panel--top { + transform: translateY(calc(0rem - 150px)); +} + +.fc__edition-panel textarea { + position: relative !important; + resize: none !important; + color: #000 !important; + font-family: sans-serif !important; + font-weight: 100 !important; + + width: var(--panel-width) !important; + height: 7rem !important; + outline: none !important; + border: var(--fc-border) !important; + padding: 0.5rem !important; +} + +.fc__edition-panel textarea:focus { + outline: none !important; +} + +.fc__edition-panel__btns { + display: flex; + background-color: #fff; + color: #000; + margin-top: 0.5rem; +} + +.fc__edition-panel__btns button { + all: initial; + + color: #000 !important; + font-family: sans-serif; + font-weight: 100; + text-align: center; + font-size: 1rem; + + width: 100%; + padding: 0.5rem; + border: var(--fc-border) !important; + background-color: #fff; + cursor: pointer; +} +.fc__edition-panel__btns button:first-child { + border-right: none !important; +} + +.fc__edition-panel__btns button:hover { + background-color: #000 !important; + color: #fff !important; +} + +.fc__bubble { + position: absolute !important; + display: flex !important; + justify-content: center !important; + align-items: center !important; + + width: 3rem !important; + height: 2.9rem !important; + scroll-margin-top: 6rem !important; + + color: #000 !important; + font-family: sans-serif !important; + font-size: var(--fc-font-size-m) !important; + font-weight: 500 !important; + + background-image: var(--fc-icon-chat-box-empty) !important; + background-size: 3rem 2.5rem !important; + background-position: -2px 4px !important; + background-repeat: no-repeat !important; +} + +.fc__comment { + position: absolute !important; + z-index: 999 !important; + + width: 20rem !important; + padding: 1rem !important; + background-color: #fff !important; + color: #000 !important; + border: var(--fc-border) !important !important; +} + +.fc__comment * { + color: #000 !important; + font-family: sans-serif !important; + font-weight: 100 !important; +} + +.fc__comment p { + font-size: 1rem !important; + margin: 1rem 0 !important; +} + +.fc__comment-delete { + position: absolute; + right: 1rem; + top: 1rem; +} + +.fc__icon { + width: 1.5rem; + height: 1.5rem; +} + +.fc__comment, +.fc__edition-panel { + font-family: sans-serif; + font-size: var(--fc-font-size-m); + font-weight: 100; +} + +/* ================= CONTEXT ================= */ +#collapsible { + display: none; +} + +.fc__label-toggle { + display: block; + font-size: var(--fc-font-size-s); + cursor: pointer; +} + +.fc__label-toggle::before { + content: " "; + display: inline-block; + + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 5px solid currentColor; + + vertical-align: middle; + margin-right: 0.7rem; + transform: translateY(-2px); + + transition: transform 0.2s ease-out; +} + +.fc__collapsible-content { + max-height: 0px; + overflow: hidden; + + transition: max-height 0.25s ease-in-out; +} + +.fc__toggle:checked + .fc__label-toggle + .fc__collapsible-content { + max-height: 100vh; +} +.fc__toggle:checked + .fc__label-toggle::before { + transform: rotate(90deg) translateX(-3px); +} + +.fc__content-inner { + margin: 0; + margin-top: 1rem; + padding-left: 0; +} + +.fc__context-item { + list-style-type: none; + font-size: var(--fc-font-size-s); + line-height: 1.3; +} +.fc__context-item:not(:last-child) { + padding-bottom: 0.5rem; + margin-bottom: 0.5rem; + border-bottom: 1px dotted #000; +} + +.fc__open-window { + all: initial; + background-color: transparent !important; + border: 1px solid #000 !important; + border-radius: 0 !important; + cursor: pointer !important; + margin-left: 1rem !important; + padding: 0.1rem 0.3rem !important; +} + +.fc__open-window:hover { + background-color: #000 !important; + color: #fff !important; +} +/* ================= END CONTEXT ================= */ + +.fc__author { + font-weight: 400; +} + +.fc__datetime { + font-size: var(--fc-font-size-s); +} + +.fc__suggestion { + position: relative; + padding: 0.1rem 0.2rem; + margin: 0 0.2rem; + background-color: inherit; + border: var(--fc-border); +} +.fc__suggestion:hover { + color: #fff; + background-color: #000; +} +.fc__suggestion--edit { + background-color: rgba(0, 0, 0, 0.1) !important; +} + +.fc__suggestion-bubble { + position: absolute; + z-index: 999; + width: 20vw; + background-color: #fff; + color: #000; + border: var(--fc-border); + padding: 1rem; + left: -0.1rem; + bottom: -5.5rem; +} + +.fc__suggestion-bubble__btns { + position: absolute; + top: 1rem; + right: 1rem; + display: flex; + column-gap: 0.5rem; +} + +.fc__team-icon::before { + width: 1rem; + height: 1rem; + margin-right: 0.2rem; + transform: translateY(-0.1rem); +} +.fc__team-icon--code::before { + content: url("/media/plugins/adrienpayet/front-comments/icons/code.svg"); +} +.fc__team-icon--design::before { + content: url("/media/plugins/adrienpayet/front-comments/icons/palette.svg"); +} +.fc__team-icon--content::before { + content: url("/media/plugins/adrienpayet/front-comments/icons/pen.svg"); +} diff --git a/site/plugins/front-comments/blueprints/fields/team.yml b/site/plugins/front-comments/blueprints/fields/team.yml new file mode 100644 index 0000000..30a0126 --- /dev/null +++ b/site/plugins/front-comments/blueprints/fields/team.yml @@ -0,0 +1,14 @@ +label: Équipe +type: toggles +grow: false +options: + - value: content + text: Contenu + icon: pen + - value: design + text: Design + icon: palette + - value: code + text: Code + icon: code +help: Pour identifier vos commentaires. diff --git a/site/plugins/front-comments/classes/Comment.php b/site/plugins/front-comments/classes/Comment.php new file mode 100644 index 0000000..44af57d --- /dev/null +++ b/site/plugins/front-comments/classes/Comment.php @@ -0,0 +1,201 @@ +page = $page; + $this->data = $commentData; + $this->options = option('adrienpayet.front-comments'); + + if ($this->options['repo.service']) { + $this->options['repo.service'] = strtolower($this->options['repo.service']); + $this->options['repo.name'] = strtolower($this->options['repo.name']); + $this->options['repo.name'] = str_replace(' ', '-', $this->options['repo.name']); + } + } + + + private function _getCreateIssueRequestUrl() + { + $id = urlencode($this->options['repo.owner'] . '/' . $this->options['repo.name']); + + $baseUrl = null; + if ($this->options['repo.service'] === 'github') { + $baseUrl = "https://api.github.com/repos/"; + } elseif ($this->options['repo.service'] === 'framagit') { + $baseUrl = "https://framagit.org/api/v4/projects/"; + } elseif ($this->options['repo.service'] === 'gitlab') { + $baseUrl = "https://gitlab.com/api/v4/projects/"; + } + + $url = null; + if ($this->options['repo.service'] === 'github') { + $url = $baseUrl . $id . "/issues?access_token=" . $this->options['repo.token']; + } else { + $url = $baseUrl . $id . "/issues?private_token=" . $this->options['repo.token']; + } + + return $url; + } + + private function _formatData() + { + $description = $this->_createDescription(); + + $data = array( + 'title' => $this->data['message'], + 'labels' => option('adrienpayet.front-comments.labels') + ); + + if ($this->options['repo.service'] === 'github') { + $data['body'] = $description; + } else { + $data['description'] = $description; + } + + return $data; + } + + private function _createDescription() + { + $description = 'Comment created by **' . $this->data['author'] . '** '; + $description .= 'the **' . $this->data['date'] . '** at ' . $this->data['time'] . ' '; + $description .= ' in a window of **' . $this->data['windowWidth'] . 'px width**. '; + $description .= 'User agent: ' . $this->data['userAgent'] . ' . '; + $description .= '[**SEE THE COMMENT**](' . site()->url() . '/' . $this->page->uri() . '/#' . $this->data['id'] .')'; + return $description; + } + + public function createIssue() + { + $requestUrl = $this->_getCreateIssueRequestUrl(); + $data = $this->_formatData(); + + $curl = curl_init(); + + curl_setopt_array($curl, [ + CURLOPT_URL => $requestUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 0, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_POSTFIELDS => json_encode($data), + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + ], + ]); + + $response = curl_exec($curl); + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + + if ($httpCode != 201) { + throw new Exception("Failed to create issue: {$httpCode}", 1); + } + + curl_close($curl); + + $responseData = json_decode($response); + + if (isset($responseData->id)) { + $this->data['issue-id'] = $responseData->iid; + $this->data['issue-url'] = $this->options['repo.service'] === 'github' ? $responseData->html_url : $responseData->web_url; + } else { + throw new Exception("Invalid response format", 1); + } + } + + + public function closeIssue() + { + $requestUrl = $this->_getCloseIssueRequestUrl(); + + $data = null; + if ($this->options['repo.service'] === 'github') { + $data = json_encode([ + 'state' => 'closed' + ]); + } else { + $data = json_encode([ + 'state_event' => 'close' + ]); + } + + $curl = curl_init(); + + curl_setopt_array($curl, [ + CURLOPT_URL => $requestUrl, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 0, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => $this->options['repo.service'] === 'github' ? 'PATCH' : 'PUT', + CURLOPT_POSTFIELDS => $data, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + ], + ]); + + $response = curl_exec($curl); + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + + if ($httpCode != 200) { + throw new Exception("Failed to close issue: {$httpCode}", 1); + } + + curl_close($curl); + } + + private function _getCloseIssueRequestUrl() + { + $id = urlencode($this->options['repo.owner'] . '/' . $this->options['repo.name']); + + $baseUrl = null; + if ($this->options['repo.service'] === 'github') { + $baseUrl = "https://api.github.com/repos/"; + } elseif ($this->options['repo.service'] === 'framagit') { + $baseUrl = "https://framagit.org/api/v4/projects/"; + } elseif ($this->options['repo.service'] === 'gitlab') { + $baseUrl = "https://gitlab.com/api/v4/projects/"; + } + + $issueId = $this->data['issue-id']; + + $url = null; + if ($this->options['repo.service'] === 'github') { + $url = $baseUrl . $id . "/issues/" . $issueId . "?access_token=" . $this->options['repo.token']; + } else { + $url = $baseUrl . $id . "/issues/" . $issueId . "?private_token=" . $this->options['repo.token']; + } + + return $url; + } + + + public function id() + { + return $this->data['id']; + } + + public function data() + { + return $this->data; + } + + public function hasIssue() + { + return isset($this->data['issue-id']) && strlen($this->data['issue-id']) > 0; + } + +} diff --git a/site/plugins/front-comments/composer.json b/site/plugins/front-comments/composer.json new file mode 100755 index 0000000..9621d25 --- /dev/null +++ b/site/plugins/front-comments/composer.json @@ -0,0 +1,26 @@ +{ + "name": "adrienpayet/front-comments", + "description": "Kirby plugin for adding comments anywhere in front-end pages.", + "license": "MIT", + "type": "kirby-plugin", + "version": "0.11.2", + "homepage": "https://framagit.org/isUnknown/kirby-front-comments", + "authors": [ + { + "name": "Adrien Payet", + "email": "adrien.payet@outlook.com" + } + ], + "require": { + "getkirby/composer-installer": "^1.1" + }, + "config": { + "allow-plugins": { + "getkirby/composer-installer": true + }, + "preferred-install": "dist" + }, + "extra": { + "installer-name": "front-comments" + } +} diff --git a/site/plugins/front-comments/composer.lock b/site/plugins/front-comments/composer.lock new file mode 100644 index 0000000..ec1ddb0 --- /dev/null +++ b/site/plugins/front-comments/composer.lock @@ -0,0 +1,66 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "2898e83ce5a2a538f1eea79c257844d2", + "packages": [ + { + "name": "getkirby/composer-installer", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/getkirby/composer-installer.git", + "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getkirby/composer-installer/zipball/c98ece30bfba45be7ce457e1102d1b169d922f3d", + "reference": "c98ece30bfba45be7ce457e1102d1b169d922f3d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "^1.8 || ^2.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Kirby\\ComposerInstaller\\Plugin" + }, + "autoload": { + "psr-4": { + "Kirby\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Kirby's custom Composer installer for the Kirby CMS and for Kirby plugins", + "homepage": "https://getkirby.com", + "support": { + "issues": "https://github.com/getkirby/composer-installer/issues", + "source": "https://github.com/getkirby/composer-installer/tree/1.2.1" + }, + "funding": [ + { + "url": "https://getkirby.com/buy", + "type": "custom" + } + ], + "time": "2020-12-28T12:54:39+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/site/plugins/front-comments/dialogs/create-issue.php b/site/plugins/front-comments/dialogs/create-issue.php new file mode 100644 index 0000000..ff321ef --- /dev/null +++ b/site/plugins/front-comments/dialogs/create-issue.php @@ -0,0 +1,45 @@ + 'comments/create-issue/(:any)/(:all)', + 'load' => function (string $commentId, string $pageUri) { + return [ + 'component' => 'k-text-dialog', + 'props' => [ + 'text' => t('adrienpayet.front-comments.confirm-create-issue') + ] + ]; + }, + 'submit' => function (string $commentId, string $pageUri) { + + $page = Find::page($pageUri); + $comments = $page->comments()->toData('yaml'); + + $commentData = getCommentData($comments, $commentId); + + $comment = new Comment($commentData, $page); + + try { + $comment->createIssue(); + $comments = array_map( + function ($item) use ($comment) { + if ($item['id'] == $comment->id()) { + return $comment->data(); + } + return $item; + }, + $comments + ); + $page->update( + [ + 'comments' => $comments + ] + ); + return true; + } catch (\Throwable $th) { + throw new Exception($th->getMessage(), 1); + } + } +]; diff --git a/site/plugins/front-comments/dialogs/delete-comment.php b/site/plugins/front-comments/dialogs/delete-comment.php new file mode 100644 index 0000000..b417725 --- /dev/null +++ b/site/plugins/front-comments/dialogs/delete-comment.php @@ -0,0 +1,19 @@ + 'comments/delete/(:any)/(:all)', + 'load' => function (string $commentId, string $pageUri) { + return [ + 'component' => 'k-remove-dialog', + 'props' => [ + 'text' => t('adrienpayet.front-comments.confirm-delete-comment') + ] + ]; + }, + 'submit' => function (string $commentId, string $pageUri) { + deleteComment($pageUri, $commentId); + return true; + } +]; diff --git a/site/plugins/front-comments/doc/cover.jpg b/site/plugins/front-comments/doc/cover.jpg new file mode 100644 index 0000000..a68bf4f Binary files /dev/null and b/site/plugins/front-comments/doc/cover.jpg differ diff --git a/site/plugins/front-comments/dropdowns/comment.php b/site/plugins/front-comments/dropdowns/comment.php new file mode 100644 index 0000000..a5285c5 --- /dev/null +++ b/site/plugins/front-comments/dropdowns/comment.php @@ -0,0 +1,43 @@ + 'comments/(:any)/(:all)', + 'action' => function (string $commentId, string $pageUri) { + $options = [ + [ + 'text' => t('adrienpayet.front-comments.see'), + 'icon' => 'preview', + 'link' => site()->url() . '/' . $pageUri . '/#' . $commentId, + ], + [ + 'text' => t('adrienpayet.front-comments.remove'), + 'icon' => 'remove', + 'dialog' => 'comments/delete/' . $commentId . '/' . $pageUri + ] + ]; + + if (option('adrienpayet.front-comments.repo.name')) { + $page = Find::page($pageUri); + $comments = $page->comments()->toData('yaml'); + + $commentData = getCommentData($comments, $commentId); + $service = strtolower(option('adrienpayet.front-comments.repo.service')); + + if (isset($commentData['issue-id']) && strlen($commentData['issue-id']) > 0) { + $options[] = [ + 'text' => t('adrienpayet.front-comments.see-issue'), + 'icon' => $service === 'github' ? 'github' : 'gitlab', + 'link' => $commentData['issue-url'], + ]; + } else { + $options[] = [ + 'text' => t('adrienpayet.front-comments.create-issue'), + 'icon' => $service === 'github' ? 'github' : 'gitlab', + 'dialog' => 'comments/create-issue/' . $commentId . '/' . $pageUri + ]; + } + } + + return $options; + } +]; diff --git a/site/plugins/front-comments/index.css b/site/plugins/front-comments/index.css new file mode 100644 index 0000000..5ef05eb --- /dev/null +++ b/site/plugins/front-comments/index.css @@ -0,0 +1 @@ +h2[data-v-15db4f00]{margin-bottom:1rem}.k-comment-author .k-icon[data-v-15db4f00]{display:inline-block;margin-right:1rem}.k-table .k-table-index-column[data-v-15db4f00]{width:6rem}section[data-v-15db4f00]{margin-bottom:2rem}.k-table td[data-v-15db4f00]{padding:.5rem;height:100%}.k-comment-author[data-v-15db4f00]{width:15rem}.author-wrapper[data-v-15db4f00]{display:flex}.k-comment-message[data-v-15db4f00]{width:100%}.k-comment-date[data-v-15db4f00]{width:6rem}.k-comment-time[data-v-15db4f00]{width:5rem}.k-comment-actions[data-v-15db4f00]{width:2rem} diff --git a/site/plugins/front-comments/index.js b/site/plugins/front-comments/index.js new file mode 100644 index 0000000..a5013a0 --- /dev/null +++ b/site/plugins/front-comments/index.js @@ -0,0 +1 @@ +(function(){"use strict";const g="";function C(l,t,e,d,n,m,r,c){var s=typeof l=="function"?l.options:l;t&&(s.render=t,s.staticRenderFns=e,s._compiled=!0),d&&(s.functional=!0),m&&(s._scopeId="data-v-"+m);var o;if(r?(o=function(i){i=i||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,!i&&typeof __VUE_SSR_CONTEXT__<"u"&&(i=__VUE_SSR_CONTEXT__),n&&n.call(this,i),i&&i._registeredComponents&&i._registeredComponents.add(r)},s._ssrRegister=o):n&&(o=c?function(){n.call(this,(s.functional?this.parent:this).$root.$options.shadowRoot)}:n),o)if(s.functional){s._injectStyles=o;var _=s.render;s.render=function(y,f){return o.call(f),_(y,f)}}else{var a=s.beforeCreate;s.beforeCreate=a?[].concat(a,o):[o]}return{exports:l,options:s}}const u={__name:"KCommentsView",props:{pages:Array,csrf:String},setup(l){const{pages:t,csrf:e}=l;n();function d(r,c){const[s,o,_]=r.split("/"),[a,i]=c.split(":");return new Date(`20${_}-${o}-${s}T${a}:${i}:00`)}function n(){const r=c=>d(c.date,c.time);t.forEach(c=>{c.comments.sort((s,o)=>{const _=r(s);return r(o)-_})}),t.sort((c,s)=>{const o=c.comments[c.comments.length-1],_=s.comments[s.comments.length-1],a=r(o);return r(_)-a})}function m(r,c){const s=t.findIndex(a=>a.comments.some(i=>i.id===r));t[s].comments.findIndex(a=>a.id===r);const o=t[s].comments.filter(a=>a.id!==r);t[s].comments=o;const _={method:"PATCH",headers:{"X-CSRF":e},body:JSON.stringify({comments:o})};fetch("/api/pages/"+c.replace("/","+"),_).then(a=>a.json()).then(a=>{console.log(a),console.log("Page successfully updated.")}).catch(a=>{console.log(a)})}return{__sfc:!0,convertToDate:d,sortByDate:n,remove:m}}};var p=function(){var t=this,e=t._self._c;return t._self._setupProxy,e("k-inside",[t.pages.length>0?[e("h1",{staticClass:"k-header-title"},[t._v("Pages")]),t._l(t.pages,function(d){return e("section",{key:d.uri},[e("h2",{staticClass:"k-label"},[t._v(t._s(d.title))]),e("div",{staticClass:"k-table"},[e("table",[e("thead",[e("tr",[e("th",{staticClass:"k-comment-author"},[t._v(" "+t._s(t.$t("adrienpayet.front-comments.author"))+" ")]),e("th",{staticClass:"k-comment-message"},[t._v(" "+t._s(t.$t("adrienpayet.front-comments.message"))+" ")]),e("th",{staticClass:"k-comment-date"},[t._v(" "+t._s(t.$t("adrienpayet.front-comments.date"))+" ")]),e("th",{staticClass:"k-comment-time"},[t._v(" "+t._s(t.$t("adrienpayet.front-comments.time"))+" ")]),e("th",{staticClass:"k-comment-actions"})])]),e("tbody",t._l(d.comments,function(n){return e("tr",{key:n.id},[e("td",{staticClass:"k-comment-author"},[e("div",{staticClass:"author-wrapper"},[(n==null?void 0:n.team)==="code"?e("k-icon",{attrs:{type:"code"}}):t._e(),(n==null?void 0:n.team)==="design"?e("k-icon",{attrs:{type:"palette"}}):t._e(),(n==null?void 0:n.team)==="content"?e("k-icon",{attrs:{type:"pen"}}):t._e(),t._v(" "+t._s(n.author)+" ")],1)]),e("td",{staticClass:"k-comment-message",domProps:{innerHTML:t._s(n.message)}}),e("td",{staticClass:"k-comment-date"},[t._v(" "+t._s(n.date)+" ")]),e("td",{staticClass:"k-comment-time"},[t._v(" "+t._s(n.time)+" ")]),e("td",{staticClass:"k-comment-actions"},[e("k-options-dropdown",{attrs:{options:"comments/"+n.id+"/"+d.uri}})],1)])}),0)])])])})]:e("h1",{staticClass:"k-header-title"},[t._v(" "+t._s(t.$t("adrienpayet.front-comments.no-comment"))+" ")])],2)},h=[],v=C(u,p,h,!1,null,"15db4f00",null,null);const k=v.exports;panel.plugin("adrienpayet/front-comments",{icons:{gitlab:''},components:{"k-comments-view":k}})})(); diff --git a/site/plugins/front-comments/index.php b/site/plugins/front-comments/index.php new file mode 100755 index 0000000..0781df6 --- /dev/null +++ b/site/plugins/front-comments/index.php @@ -0,0 +1,89 @@ + 'classes/Comment.php' +], __DIR__); + +require_once __DIR__ . '/lib/functions.php'; + +Kirby::plugin('adrienpayet/front-comments', [ + 'translations' => [ + 'en' => require_once(__DIR__ . '/translations/en.php'), + 'fr' => require_once(__DIR__ . '/translations/fr.php') + ], + 'options' => [ + 'cache' => true, + 'repo.service' => null, + 'repo.token' => null, + 'repo.owner' => null, + 'repo.name' => null, + 'repo.labels' => ['front-comments'] + ], + 'blueprints' => [ + 'fields/front-comments/team' => __DIR__ . '/blueprints/fields/team.yml' + ], + 'icons' => [], + 'areas' => [ + 'comments' => function ($kirby) { + return [ + 'label' => t('adrienpayet.front-comments.comments'), + 'icon' => 'chat', + 'menu' => true, + 'link' => 'comments', + 'dropdowns' => [ + require __DIR__ . '/dropdowns/comment.php' + ], + 'dialogs' => [ + require __DIR__ . '/dialogs/delete-comment.php', + require __DIR__ . '/dialogs/create-issue.php' + ], + 'views' => [ + [ + 'pattern' => 'comments', + 'action' => function () { + return [ + 'component' => 'k-comments-view', + 'title' => t('adrienpayet.front-comments.comments'), + 'props' => [ + 'pages' => function () { + $pages = array_values(kirby()->cache('adrienpayet.front-comments')->getOrSet('commented-pages', function () { return [] ;})); + return $pages; + }, + 'csrf' => csrf() + ], + ]; + } + ] + ], + ]; + } + ], + 'snippets' => [ + 'front-comments' => __DIR__ . '/snippets/front-comments.php', + 'front-comments-script' => __DIR__ . '/snippets/front-comments-script.php', + 'front-comments-style' => __DIR__ . '/snippets/front-comments-style.php', + 'front-comments-field' => __DIR__ . '/snippets/front-comments-field.php', + ], + 'routes' => [ + [ + 'pattern' => '/store-comment.json', + 'method' => 'POST', + 'action' => function () { + include_once __DIR__ . '/routes/store-comment.php'; + return storeComment(); + } + ], + [ + 'pattern' => '/comments/delete/(:any)/(:all).json', + 'method' => 'PATCH', + 'action' => function ($commentId, $pageUri) { + return json_encode(deleteComment($pageUri, $commentId)); + } + ] + ], + 'hooks' => [ + 'page.update:after' => function ($newPage) { + cacheFrontComments($newPage); + } + ] +]); diff --git a/site/plugins/front-comments/lib/functions.php b/site/plugins/front-comments/lib/functions.php new file mode 100644 index 0000000..cd01756 --- /dev/null +++ b/site/plugins/front-comments/lib/functions.php @@ -0,0 +1,120 @@ +getMessage(), 1); + } + } +} + +function deleteDir(string $dirPath): void +{ + if (!is_dir($dirPath)) { + throw new InvalidArgumentException("$dirPath must be a directory"); + } + if (substr($dirPath, strlen($dirPath) - 1, 1) != '/') { + $dirPath .= '/'; + } + $files = glob($dirPath . '*', GLOB_MARK); + foreach ($files as $file) { + if (is_dir($file)) { + deleteDir($file); + } else { + unlink($file); + } + } + rmdir($dirPath); +} + +function setFiles($location) +{ + $destination = __DIR__ . '/../../../../assets/front-comments'; + + if ($location === 'media') { + if (is_dir($destination)) { + deleteDir($destination); + } + } else { + $source = __DIR__ . '/../assets'; + if (!is_dir($destination)) { + mkdir($destination, 0755, true); + } + + rec_copy($source, $destination); + } +} + +function getCommentData($comments, $commentId) +{ + + $comment = array_filter( + $comments, + function ($item) use ($commentId) { + return $item['id'] == $commentId; + } + ); + + $comment = current($comment); + + return $comment; + +} + +function deleteComment($pageUri, $commentId) +{ + $page = Find::page($pageUri); + + $comments = $page->comments()->toData('yaml'); + + $newComments = []; + foreach ($comments as $item) { + $comment = new Comment($item, $page); + if ($comment->id() == $commentId) { + if ($comment->hasIssue()) { + $comment->closeIssue(); + } + } else { + $newComments[] = $comment->data(); + } + } + + $newPage = $page->update( + [ + 'comments' => $newComments + ] + ); + + return $newPage->comments()->toData('yaml'); +} + +function cacheFrontComments($newPage) +{ + if ($newPage->comments()->exists() && $newPage->comments()->isNotEmpty()) { + $cache = kirby()->cache('adrienpayet.front-comments'); + $comments = $cache->getOrSet('commented-pages', function () { return []; }); + $comments[$newPage->uuid()->id()] = [ + 'uri' => $newPage->uri(), + 'title' => $newPage->title()->value(), + 'url' => $newPage->url(), + 'comments' => $newPage->comments()->yaml(), + ]; + + $cache->set('commented-pages', $comments); + } +} diff --git a/site/plugins/front-comments/package-lock.json b/site/plugins/front-comments/package-lock.json new file mode 100644 index 0000000..febfce0 --- /dev/null +++ b/site/plugins/front-comments/package-lock.json @@ -0,0 +1,355 @@ +{ + "name": "front-comments", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "dayjs": "^1.11.10" + }, + "devDependencies": { + "concurrently": "^8.2.2" + } + }, + "node_modules/@babel/runtime": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz", + "integrity": "sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/dayjs": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==", + "dev": true + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + } + } +} diff --git a/site/plugins/front-comments/package.json b/site/plugins/front-comments/package.json new file mode 100644 index 0000000..3a32912 --- /dev/null +++ b/site/plugins/front-comments/package.json @@ -0,0 +1,23 @@ +{ + "scripts": { + "dev": "concurrently \"npm run dev:front\" \"npm run dev:panel\"", + "dev:front": "npx -y kirbyup src/front/index.js --out-dir assets/ --watch", + "dev:panel": "npx -y kirbyup src/panel/index.js --watch", + "build": "npm run build:front && npm run build:panel", + "build:front": "npx -y kirbyup src/front/index.js --out-dir assets/", + "build:panel": "npx -y kirbyup src/panel/index.js" + }, + "dependencies": { + "dayjs": "^1.11.10" + }, + "devDependencies": { + "concurrently": "^8.2.2" + }, + "archive": { + "exclude": [ + ".git", + ".gitignore", + ".gitattributes" + ] + } +} diff --git a/site/plugins/front-comments/routes/delete-comment.php b/site/plugins/front-comments/routes/delete-comment.php new file mode 100644 index 0000000..5182ea2 --- /dev/null +++ b/site/plugins/front-comments/routes/delete-comment.php @@ -0,0 +1,22 @@ + $item) { + if ($item['id'] === $request['id']) { + array_splice($data, $index, 1); + break; + } + } + + file_put_contents($dataFile, json_encode($data)); + + return json_encode($data); +} diff --git a/site/plugins/front-comments/routes/store-comment.php b/site/plugins/front-comments/routes/store-comment.php new file mode 100644 index 0000000..dc60ea6 --- /dev/null +++ b/site/plugins/front-comments/routes/store-comment.php @@ -0,0 +1,32 @@ + $item) { + if ($item['id'] === $request['id']) { + $foundIndex = $index; + break; + } + } + + if (is_null($foundIndex)) { + $jsonData[] = $request; + } else { + $jsonData[$foundIndex] = $request; + } + + file_put_contents($dataFile, json_encode($jsonData)); + return $jsonData; +} diff --git a/site/plugins/front-comments/snippets/front-comments-field.php b/site/plugins/front-comments/snippets/front-comments-field.php new file mode 100644 index 0000000..f5d4604 --- /dev/null +++ b/site/plugins/front-comments/snippets/front-comments-field.php @@ -0,0 +1,7 @@ +user()): ?> +
    + +
    + + + \ No newline at end of file diff --git a/site/plugins/front-comments/snippets/front-comments-script.php b/site/plugins/front-comments/snippets/front-comments-script.php new file mode 100644 index 0000000..eae8227 --- /dev/null +++ b/site/plugins/front-comments/snippets/front-comments-script.php @@ -0,0 +1,35 @@ +comments()->exists() && $page->comments()->isNotEmpty() + ? $page->comments()->toData('yaml') + : []; + +$suggestions = $page->suggestions()->exists() && $page->suggestions()->isNotEmpty() + ? $page->suggestions()->toData('yaml') + : []; + +foreach ($comments as &$comment) { + $comment['message'] = nl2br($comment['message']); +} + +foreach ($suggestions as &$suggestion) { + $suggestion['message'] = nl2br($suggestion['message']); +} + + +$commentsJson = json_encode($comments, JSON_HEX_APOS); +$suggestionsJson = json_encode($suggestions, JSON_HEX_APOS); +?> + + + :root { + --fc-icon-chat-box-empty: url(/icons/chat-box-empty.svg); + } + +user()) { + $position = isset($position) ? $position : 'bottom-right'; + $location = isset($location) ? $location : 'media'; + + $filesPath = $location === 'media' ? + '/media/plugins/adrienpayet/front-comments' + : '/assets/front-comments'; + setFiles($location); + + + echo snippet('front-comments-style', ['filesPath' => $filesPath]); + echo snippet( + 'front-comments-script', + [ + 'filesPath' => $filesPath, + 'position' => $position + ] + ); +} diff --git a/site/plugins/front-comments/src/front/classes/AddBtn.js b/site/plugins/front-comments/src/front/classes/AddBtn.js new file mode 100644 index 0000000..f4b2d24 --- /dev/null +++ b/site/plugins/front-comments/src/front/classes/AddBtn.js @@ -0,0 +1,89 @@ +import CommentPanel from "./edition-panels/CommentPanel.js"; +import Store from "../store.js"; + +/** + * Class representing the Add Button. + */ +class AddButton { + /** + * Create an AddButton. + * @param {string} selector - The CSS selector for the button. + */ + constructor(position) { + this._button = this._createButton(position); + this._initEventListeners(); + } + + /** + * Create and return a new button element with the specified HTML content. + * @return {HTMLButtonElement} The created button element. + * @private + */ + _createButton(position) { + const div = document.createElement("div"); + div.innerHTML = ``; + const button = div.firstChild; + document.body.appendChild(button); + return button; + } + + /** + * Initialize the event listeners for the button. + * @private + */ + _initEventListeners() { + this._button.addEventListener("click", this._handleClick.bind(this)); + window.addEventListener("mousemove", this._handleMouseMove.bind(this)); + window.addEventListener("keydown", this._handleKeyDown.bind(this)); + } + + /** + * Handle the click event on the button. + * @private + */ + _handleClick(event) { + if (this._button.classList.contains("fc__btn-add--move")) { + const commentPanel = new CommentPanel(); + commentPanel.create(); + this._resetPosition(); + } + this._button.classList.toggle("fc__btn-add--move"); + } + + /** + * Handle the mousemove event on the window. + * @private + */ + _handleMouseMove(event) { + if (this._button.classList.contains("fc__btn-add--move")) { + const iconSize = 48; + this._button.style.left = event.clientX - iconSize / 2 + "px"; + this._button.style.top = + event.clientY + window.scrollY - iconSize / 2 + "px"; + } + } + + /** + * Handle the keydown event on the window. + * @private + */ + _handleKeyDown(event) { + if (event.key === "Escape") { + this._button.classList.remove("fc__btn-add--move"); + this._resetPosition(); + } + } + + /** + * Reset the position of the button. + * @private + */ + _resetPosition() { + this._button.style.left = ""; + this._button.style.top = ""; + } +} + +export default AddButton; diff --git a/site/plugins/front-comments/src/front/classes/Suggestion.js b/site/plugins/front-comments/src/front/classes/Suggestion.js new file mode 100644 index 0000000..47dbf4b --- /dev/null +++ b/site/plugins/front-comments/src/front/classes/Suggestion.js @@ -0,0 +1,224 @@ +import dayjs from "dayjs"; +import Store from "../store.js"; + +class Suggestion { + constructor( + author, + context, + target, + suggestion, + fieldName, + id = Date.now(), + date = dayjs().format("DD/MM/YY"), + time = dayjs().format("HH:mm") + ) { + this.author = author; + this.context = context; + this.target = target; + this.suggestion = suggestion; + this.fieldName = fieldName; + this.id = id; + this.date = date; + this.time = time; + this.node; + this.highlightTargetInField(); + } + + highlightTargetInField() { + const highlight = document.querySelector(".fc__suggestion--edit"); + if (highlight) { + this.node = highlight; + highlight.classList.remove("fc__suggestion--edit"); + highlight.classList.add("fc__suggestion"); + } else { + const { target, fieldName } = this; + const fieldElement = this.getFieldElement(fieldName); + const textNodes = this.getTextNodes(fieldElement); + this.highlightTarget(textNodes, target); + } + + if (this.node) { + this.node.addEventListener("mouseenter", () => { + this._bubble = this.createBubble(); + this.node.appendChild(this._bubble); + }); + this.node.addEventListener("mouseleave", () => { + this.node.removeChild(this._bubble); + }); + } else { + this.remove(); + } + } + + getFieldElement(fieldName) { + return document.querySelector(`[data-field-name="${fieldName}"]`); + } + + getTextNodes(fieldElement) { + const textNodes = []; + const childNodes = Array.from(fieldElement.childNodes); + + childNodes.forEach((node) => { + if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== "") { + textNodes.push(node); + } else if (node.hasChildNodes()) { + textNodes.push(...this.getChildTextNodes(node)); + } + }); + + return textNodes; + } + + highlightTarget(textNodes, target) { + textNodes.forEach((textNode) => { + const targetIndex = textNode.textContent.indexOf(target); + + if ( + targetIndex >= 0 && + this.matchContext(textNode, target, targetIndex) + ) { + this.node = this.insertHighlightedTarget(textNode, target, targetIndex); + } else { + // this.remove(); + } + }); + } + + matchContext(textNode, target, targetIndex) { + const beforeMatch = textNode.textContent.slice( + Math.max(0, targetIndex - 30), + targetIndex + ); + const afterMatch = textNode.textContent.slice( + targetIndex + target.length, + targetIndex + target.length + 30 + ); + + return ( + beforeMatch === this.context.before && afterMatch === this.context.after + ); + } + + insertHighlightedTarget(textNode, target, targetIndex) { + const highlightedTarget = this.createHighlightedTarget(target); + const beforeTarget = this.createTextNode( + textNode.textContent.slice(0, targetIndex) + ); + const afterTarget = this.createTextNode( + textNode.textContent.slice(targetIndex + target.length) + ); + + textNode.parentNode.insertBefore(beforeTarget, textNode); + textNode.parentNode.insertBefore(highlightedTarget, textNode); + textNode.parentNode.insertBefore(afterTarget, textNode); + textNode.remove(); + return highlightedTarget; + } + + createHighlightedTarget(target) { + const highlightedTarget = document.createElement("span"); + highlightedTarget.classList.add("fc__suggestion"); + highlightedTarget.textContent = target; + return highlightedTarget; + } + + createTextNode(text) { + return document.createTextNode(text); + } + + getChildTextNodes(node) { + const textNodes = []; + const childNodes = Array.from(node.childNodes); + + childNodes.forEach((childNode) => { + if ( + childNode.nodeType === Node.TEXT_NODE && + childNode.textContent.trim() !== "" + ) { + textNodes.push(childNode); + } else if (childNode.hasChildNodes()) { + textNodes.push(...this.getChildTextNodes(childNode)); + } + }); + + return textNodes; + } + + createBubble() { + const bubble = document.createElement("div"); + bubble.className = "fc__suggestion-bubble"; + bubble.innerHTML = `

    ${this.suggestion}

    `; + + bubble.appendChild(this.createBtns()); + return bubble; + } + + createBtns() { + const btns = document.createElement("div"); + btns.classList.add("fc__suggestion-bubble__btns"); + const decline = document.createElement("button"); + decline.classList.add("fc__btn"); + decline.innerHTML = ``; + + const accept = document.createElement("button"); + accept.classList.add("fc__btn"); + accept.innerHTML = ``; + + decline.addEventListener("click", () => { + this.remove(); + }); + + accept.addEventListener("click", () => { + this.push(); + }); + + btns.appendChild(decline); + btns.appendChild(accept); + return btns; + } + + remove() { + Store.suggestions = Store.suggestions.filter( + (suggestion) => suggestion.id != this.id + ); + if (this.node !== undefined) { + this.node.parentNode.removeChild(this.node); + } + Store.save("suggestions"); + } + + push() { + const field = this.getFieldElement(this.fieldName); + + const data = {}; + data[this.fieldName] = field.dataset.content.replace( + this.target, + this.suggestion + ); + + const init = { + method: "PATCH", + headers: { + "X-CSRF": Store.csrf, + }, + body: JSON.stringify(data).replaceAll("_", ""), + }; + fetch("/api/pages/" + Store.page, init) + .then((response) => response.json()) + .then((response) => { + console.log(response); + console.log("Page comments successfully updated."); + }) + .catch((error) => { + console.log(error); + }); + + Store.suggestions = Store.suggestions.filter( + (suggestion) => suggestion.id != this.id + ); + Store.save("suggestions"); + this.node.outerHTML = this.suggestion; + } +} + +export default Suggestion; diff --git a/site/plugins/front-comments/src/front/classes/comments/Comment.js b/site/plugins/front-comments/src/front/classes/comments/Comment.js new file mode 100644 index 0000000..f0cde11 --- /dev/null +++ b/site/plugins/front-comments/src/front/classes/comments/Comment.js @@ -0,0 +1,163 @@ +import Store from "../../store.js"; +import dayjs from "dayjs"; +import Helpers from "../../composables/helpers.js"; + +/** + * Represents a comment on a webpage. + */ +class Comment { + /** + * Create a Comment. + * @param {Object} position - The positions where the comment should be displayed. + */ + constructor( + position, + author, + message, + id, + date, + time, + windowWidth, + userAgent, + team + ) { + this.position = position; + this.author = author; + this.message = message; + this.id = id; + this.date = date; + this.time = time; + this.windowWidth = windowWidth; + this.userAgent = userAgent; + this.team = team; + this.node; + } + + toBubble() { + const bubble = this._injectNode(``); + + if (this.team) { + bubble.classList.add("fc__team-icon"); + bubble.classList.add(`fc__team-icon--${this.team}`); + } + + bubble.addEventListener("mouseenter", () => { + setTimeout(() => { + this.expand(); + }, 10); + }); + + this.node = bubble; + return bubble; + } + + /** + * Show the comment message. + */ + expand() { + const windowWidthInfoElement = this.windowWidth + ? `
  • + Largeur fenêtre : ${this.windowWidth}px +
  • ` + : ""; + const userAgentInfoElement = this.userAgent + ? `
  • + Agent utilisateur :
    + ${this.userAgent} +
  • ` + : ""; + const contextInfoElement = + this.userAgent || this.windowWidth + ? ` +
    + + +
    +
      + ${windowWidthInfoElement} + ${userAgentInfoElement} +
    +
    +
    + ` + : ""; + + const comment = this._injectNode(`
    + + ${this.author}
    ${this.date} à ${this.time}
    + +

    ${this.message}

    + ${contextInfoElement} +
    `); + + Helpers.fixOffscreen(comment); + + const deleteBtn = comment.querySelector(".fc__comment-delete"); + + deleteBtn.addEventListener("click", () => { + this.remove(); + }); + + const openWindowBtn = document.querySelector(".fc__open-window"); + + if (openWindowBtn) { + openWindowBtn.addEventListener("click", () => { + window.open( + window.location.href, + "", + `width=${this.windowWidth}, height=800` + ); + }); + } + + comment.addEventListener("mouseleave", () => { + setTimeout(() => { + this.toBubble(); + }, 10); + }); + + this.node = comment; + return comment; + } + + _injectNode(htmlString) { + if (this.node) { + document.body.removeChild(this.node); + } + const div = document.createElement("div"); + div.innerHTML = htmlString; + const node = div.firstChild; + document.body.appendChild(node); + return node; + } + + /** + * Hide the comment message. + */ + async remove() { + const init = { + method: "PATCH", + }; + fetch(`/comments/delete/${this.id}/${Store.page.uri}.json`, init) + .then((res) => res.json()) + .then((json) => { + Store.comments = Store.comments.filter((item) => item.id != this.id); + document.body.removeChild(this.node); + }) + .catch((error) => { + console.log(error); + }); + } +} + +export default Comment; diff --git a/site/plugins/front-comments/src/front/classes/comments/CommentBuilder.js b/site/plugins/front-comments/src/front/classes/comments/CommentBuilder.js new file mode 100644 index 0000000..45b1b52 --- /dev/null +++ b/site/plugins/front-comments/src/front/classes/comments/CommentBuilder.js @@ -0,0 +1,95 @@ +import Store from "../../store.js"; +import dayjs from "dayjs"; +import Helpers from "../../composables/helpers.js"; +import Comment from "./Comment.js"; + +class CommentBuilder { + constructor() { + this.position = {}; + this.author = ""; + this.team = ""; + this.message = ""; + this.id = Date.now(); + this.date = dayjs().format("DD/MM/YY"); + this.time = dayjs().format("HH:mm"); + this.windowWidth = window.innerWidth; + this.userAgent = navigator.userAgent; + } + + /** + * + * @param {Object} position - An object with two properties: top and left. + * @param {string} position.top - The vertical position of the comment in CSS format (e.g. "10px", "50%"). + * @param {string} position.left - The horizontal position of the comment in CSS format (e.g. "10px", "50%"). + * @returns {CommentBuilder} The CommentBuilder instance for method chaining. + */ + setPosition(position) { + this.position = position; + return this; + } + + setAuthor(author) { + this.author = author; + return this; + } + + setTeam(team) { + this.team = team; + return this; + } + + setMessage(message) { + this.message = message; + return this; + } + + setId(id) { + this.id = id; + return this; + } + + setDate(date) { + this.date = date; + return this; + } + + setTime(time) { + this.time = time; + return this; + } + + setWindowWidth(windowWidth) { + this.windowWidth = windowWidth; + return this; + } + + setUserAgent(userAgent) { + this.userAgent = userAgent; + return this; + } + + build() { + if (!this.position || !this.author || !this.message) { + console.error( + "Missing required parameters for Comment: position, author, message", + this + ); + return null; + } + const comment = new Comment( + this.position, + this.author, + this.message, + this.id, + this.date, + this.time, + this.windowWidth, + this.userAgent, + this.team + ); + + return comment; + } +} + +export default CommentBuilder; diff --git a/site/plugins/front-comments/src/front/classes/edition-panels/CommentPanel.js b/site/plugins/front-comments/src/front/classes/edition-panels/CommentPanel.js new file mode 100644 index 0000000..907a1e8 --- /dev/null +++ b/site/plugins/front-comments/src/front/classes/edition-panels/CommentPanel.js @@ -0,0 +1,52 @@ +import Panel from "./Panel.js"; +import Store from "../../store.js"; +import Comment from "../comments/Comment.js"; +import CommentBuilder from "../comments/CommentBuilder.js"; + +class CommentPanel extends Panel { + create() { + super.create(); + document + .querySelector(".fc__edition-panel__save-btn") + .addEventListener("click", () => { + this._createComment(); + }); + this._textArea.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { + this._createComment(); + } + if (event.key === "Escape") { + document.body.removeChild(this._panel); + } + }); + } + + async _createComment() { + const comment = new CommentBuilder() + .setAuthor(Store.author) + .setTeam(Store.team) + .setMessage(this._textArea.value) + .setPosition({ + top: this._getPos().top, + left: this._getPos().left, + }) + .build(); + + try { + Store.comments.push(comment); + await Store.save("comments"); + comment.toBubble(); + document.body.removeChild(this._panel); + } catch (error) { + Store.comments = Store.comments.filter( + (storedComment) => storedComment.id !== comment.id + ); + document.body.removeChild(comment.node); + console.error("Create comment failed:", error); + } + + return comment; + } +} + +export default CommentPanel; diff --git a/site/plugins/front-comments/src/front/classes/edition-panels/Panel.js b/site/plugins/front-comments/src/front/classes/edition-panels/Panel.js new file mode 100644 index 0000000..d89e2f4 --- /dev/null +++ b/site/plugins/front-comments/src/front/classes/edition-panels/Panel.js @@ -0,0 +1,90 @@ +import Comment from "../comments/Comment.js"; +import Store from "../../store.js"; +import Helpers from "../../composables/helpers.js"; + +class Panel { + constructor() { + this._panel; + this._textArea; + } + + create() { + this._panel = this._createPanel(); + this._textArea = this._createTextArea(); + const btns = this._createBtns(); + this._panel.appendChild(this._textArea); + this._panel.appendChild(btns); + document.body.appendChild(this._panel); + this._textArea.focus(); + + Helpers.fixOffscreen(this._panel); + + document + .querySelector(".fc__edition-panel__remove-btn") + .addEventListener("click", () => { + document.body.removeChild(this._panel); + }); + } + + _createPanel() { + const panel = document.createElement("div"); + panel.classList.add("fc__edition-panel"); + + const panelWidth = 240; + const panelHeight = 300; + + panel.style.left = this._getMousePos().x + "px"; + panel.style.top = this._getMousePos().y + window.scrollY + "px"; + + return panel; + } + + _createBtns() { + const btns = document.createElement("div"); + btns.classList.add("fc__edition-panel__btns"); + + const removeBtn = document.createElement("button"); + removeBtn.classList.add("fc__edition-panel__remove-btn"); + removeBtn.textContent = "supprimer"; + + const saveBtn = document.createElement("button"); + saveBtn.classList.add("fc__edition-panel__save-btn"); + saveBtn.textContent = "enregistrer"; + + btns.appendChild(removeBtn); + btns.appendChild(saveBtn); + + return btns; + } + + _getMousePos() { + return { + x: event.clientX, + y: event.clientY, + }; + } + + _createTextArea() { + const textArea = document.createElement("textarea"); + textArea.classList.add("fc__text"); + return textArea; + } + + /** + * Get the position of the panel relative to the viewport. + * @returns {Object} An object containing the top and left position. + * @property {string} top - The vertical position of the comment in pixels (px). + * @property {string} left - The horizontal position of the comment in viewport width units (vw). + */ + _getPos() { + const relativeLeft = + this._panel.offsetLeft / ((window.innerWidth + window.pageXOffset) / 100); + + return { + top: this._panel.style.top, + left: relativeLeft + "vw", + }; + } +} + +export default Panel; diff --git a/site/plugins/front-comments/src/front/classes/edition-panels/SuggestionPanel.js b/site/plugins/front-comments/src/front/classes/edition-panels/SuggestionPanel.js new file mode 100644 index 0000000..fa884e3 --- /dev/null +++ b/site/plugins/front-comments/src/front/classes/edition-panels/SuggestionPanel.js @@ -0,0 +1,74 @@ +import Panel from "./Panel.js"; +import Store from "../../store.js"; +import Suggestion from "../Suggestion.js"; + +class SuggestionPanel extends Panel { + constructor(field) { + super(); + this._field = field; + this._selection = window.getSelection(); + this._target = this._selection.toString(); + } + create() { + this._highlight(); + this._context = this._getContext(); + super.create(); + document + .querySelector(".fc__edition-panel__save-btn") + .addEventListener("click", () => { + this._createSuggestion(); + }); + this._textArea.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + this._createSuggestion(); + } + if (event.key === "Escape") { + document.body.removeChild(this._panel); + } + }); + } + + _createSuggestion() { + const suggestion = new Suggestion( + Store.author, + this._context, + this._target, + this._textArea.value, + this._field.dataset.fieldName + ); + Store.suggestions.push(suggestion); + Store.save("suggestions"); + document.body.removeChild(this._panel); + return suggestion; + } + + _highlight() { + if (this._selection.toString() !== "") { + const range = this._selection.getRangeAt(0); + const span = document.createElement("span"); + span.className = "fc__suggestion--edit"; + range.surroundContents(span); + } + } + + _getContext() { + if (this._selection.toString() !== "") { + const textNode = this._selection.anchorNode; + const textContent = textNode.textContent; + const selectedText = this._selection.toString(); + const index = textContent.indexOf(selectedText); + const before = textContent.slice(Math.max(0, index - 30), index); + const after = textContent.slice( + index + selectedText.length, + index + selectedText.length + 30 + ); + const context = { + before: before, + after: after, + }; + return context; + } + } +} + +export default SuggestionPanel; diff --git a/site/plugins/front-comments/src/front/composables/helpers.js b/site/plugins/front-comments/src/front/composables/helpers.js new file mode 100644 index 0000000..c44f1d2 --- /dev/null +++ b/site/plugins/front-comments/src/front/composables/helpers.js @@ -0,0 +1,30 @@ +class Helpers { + static fixOffscreen(node) { + const margin = 16; + + if (!node) { + console.error("fixOffscreen: node does not exist"); + return; + } + + const XDiff = + window.innerWidth + + window.pageXOffset - + (node.offsetLeft + node.offsetWidth + window.pageXOffset); + + const YDiff = + window.innerHeight + + window.pageYOffset - + (node.offsetTop + node.offsetHeight); + + if (XDiff <= 0) { + node.style.transform = `translateX(${XDiff - margin}px)`; + } + + if (YDiff <= 0) { + node.style.transform += `translateY(${YDiff - margin}px)`; + } + } +} + +export default Helpers; diff --git a/site/plugins/front-comments/src/front/index.js b/site/plugins/front-comments/src/front/index.js new file mode 100644 index 0000000..9fccb03 --- /dev/null +++ b/site/plugins/front-comments/src/front/index.js @@ -0,0 +1,73 @@ +import Comment from "./classes/comments/Comment.js"; +import Panel from "./classes/edition-panels/Panel.js"; +import Store from "./store.js"; +import AddBtn from "./classes/AddBtn.js"; +import SuggestionPanel from "./classes/edition-panels/SuggestionPanel.js"; +import Suggestion from "./classes/Suggestion.js"; +import CommentBuilder from "./classes/comments/CommentBuilder.js"; + +document.addEventListener("DOMContentLoaded", () => { + Store.author = FCAuthor; + Store.team = FCTeam.length > 0 ? FCTeam : undefined; + Store.page.id = FCPageId; + Store.page.uri = FCPageUri; + Store.csrf = FCCsrf; + Store.filesPath = FCFilesPath; + + try { + FCComments.forEach((item) => { + try { + const comment = new CommentBuilder() + .setPosition(item.position) + .setAuthor(item.author) + .setMessage(item.message) + .setId(item.id) + .setDate(item.date) + .setTime(item.time) + .setWindowWidth(item.windowWidth) + .setUserAgent(item.userAgent) + .setTeam(item?.team) + .build(); + + comment.toBubble(); + + Store.comments.push(comment); + } catch (error) { + console.error("Plugin Front Comments : can't parse comments : ", error); + console.log("Comments : ", FCComments); + } + }); + } catch (error) { + console.error("Can't parse comments : ", error); + console.log("Comments : ", FCComments); + } + + const addBtn = new AddBtn(FCPosition); + + // const suggestions = JSON.parse(FCSuggestions); + // suggestions.forEach((item) => { + // const suggestion = new Suggestion( + // item.author, + // item.context, + // item.target, + // item.suggestion, + // item.fieldName, + // item.id, + // item.date, + // item.time + // ); + // Store.suggestions.push(suggestion); + // }); + // document.querySelectorAll(".fc__field").forEach((field) => { + // field.addEventListener("mouseup", (event) => { + // const selection = window.getSelection().toString(); + // if ( + // selection.length > 0 && + // !event.target.classList.contains("fc__suggestion-bubble") + // ) { + // const suggestionPanel = new SuggestionPanel(field); + // suggestionPanel.create(); + // } + // }); + // }); +}); diff --git a/site/plugins/front-comments/src/front/store.js b/site/plugins/front-comments/src/front/store.js new file mode 100644 index 0000000..dfffb86 --- /dev/null +++ b/site/plugins/front-comments/src/front/store.js @@ -0,0 +1,40 @@ +const Store = { + comments: [], + suggestions: [], + author: null, + page: { + id: null, + uri: null, + }, + csrf: null, + save(key) { + const data = {}; + data[key] = this[key]; + const init = { + method: "PATCH", + headers: { + "X-CSRF": this.csrf, + }, + body: JSON.stringify(data).replaceAll("_", ""), + }; + + return fetch("/api/pages/" + this.page.id, init) + .then((response) => { + if (!response.ok) { + return Promise.reject( + "Front comments plugin - can't add comment: " + response.statusText + ); + } + return response.json(); + }) + .then((response) => { + console.log(response); + console.log("Front comments plugin - comment successfully added."); + }) + .catch((error) => { + return Promise.reject(error); + }); + }, +}; + +export default Store; diff --git a/site/plugins/front-comments/src/panel/components/KCommentsView.vue b/site/plugins/front-comments/src/panel/components/KCommentsView.vue new file mode 100644 index 0000000..dccf7e6 --- /dev/null +++ b/site/plugins/front-comments/src/panel/components/KCommentsView.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/site/plugins/front-comments/src/panel/index.js b/site/plugins/front-comments/src/panel/index.js new file mode 100644 index 0000000..44d0196 --- /dev/null +++ b/site/plugins/front-comments/src/panel/index.js @@ -0,0 +1,11 @@ +import KCommentsView from "./components/KCommentsView.vue"; + +panel.plugin("adrienpayet/front-comments", { + icons: { + gitlab: + '', + }, + components: { + "k-comments-view": KCommentsView, + }, +}); diff --git a/site/plugins/front-comments/test.php b/site/plugins/front-comments/test.php new file mode 100644 index 0000000..378cef6 --- /dev/null +++ b/site/plugins/front-comments/test.php @@ -0,0 +1,14 @@ + [ + 'cache' => true, + ], + 'hooks' => [ + 'page.update:after' => function ($newPage) { + kirby() + ->cache('adrienpayet.front-comments') + ->set('foo', 'bar'); + } + ] +]); diff --git a/site/plugins/front-comments/translations/en.php b/site/plugins/front-comments/translations/en.php new file mode 100644 index 0000000..f5bed21 --- /dev/null +++ b/site/plugins/front-comments/translations/en.php @@ -0,0 +1,16 @@ + 'Comments', + 'adrienpayet.front-comments.remove' => 'Remove', + 'adrienpayet.front-comments.see' => 'See', + 'adrienpayet.front-comments.see-issue' => 'Go to the issue', + 'adrienpayet.front-comments.create-issue' => 'Create an issue', + 'adrienpayet.front-comments.confirm-create-issue' => 'Do you really want to create an issue ?', + 'adrienpayet.front-comments.confirm-delete-comment' => 'Do you really want to remove this comment ?', + 'adrienpayet.front-comments.author' => 'Author', + 'adrienpayet.front-comments.message' => 'Message', + 'adrienpayet.front-comments.date' => 'Date', + 'adrienpayet.front-comments.time' => 'Time', + 'adrienpayet.front-comments.add-comment' => 'Add a comment', +]; diff --git a/site/plugins/front-comments/translations/fr.php b/site/plugins/front-comments/translations/fr.php new file mode 100644 index 0000000..2fdc444 --- /dev/null +++ b/site/plugins/front-comments/translations/fr.php @@ -0,0 +1,17 @@ + 'Commentaires', + 'adrienpayet.front-comments.remove' => 'Supprimer', + 'adrienpayet.front-comments.see' => 'Voir', + 'adrienpayet.front-comments.see-issue' => 'Voir le ticket', + 'adrienpayet.front-comments.create-issue' => 'Créer un ticket', + 'adrienpayet.front-comments.confirm-create-issue' => 'Voulez-vous vraiment créer un ticket ?', + 'adrienpayet.front-comments.confirm-delete-comment' => 'Voulez-vous vraiment supprimer ce commentaire ?', + 'adrienpayet.front-comments.author' => 'Auteur', + 'adrienpayet.front-comments.message' => 'Message', + 'adrienpayet.front-comments.date' => 'Date', + 'adrienpayet.front-comments.time' => 'Heure', + 'adrienpayet.front-comments.no-comment' => 'Aucun commentaire', + 'adrienpayet.front-comments.add-comment' => 'Ajouter un commentaire', +];