add front-comments

This commit is contained in:
isUnknown 2024-09-10 17:14:38 +02:00
parent c3ea78cab5
commit c9f4af7e58
53 changed files with 2921 additions and 1 deletions

@ -1 +0,0 @@
Subproject commit a20a49fe8be9300cca38e95e7d84db47497a646f

View file

@ -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

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) <Year> <Your Name>
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.

View file

@ -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 `<head>` of your header: `<?php snippet('front-comments') ?>`.
### 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
<?php snippet('front-comments', [
'position' => '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
<?php snippet('front-comments', ['location' => 'assets']) ?>
```
This will copy the files into `/assets/front-comments`, which should resolve the issue.

View file

@ -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.*

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="69px" height="69px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:1;">
<g transform="matrix(1,0,0,1,-1153.51,-780.928)">
<g id="accept" transform="matrix(1,0,0,1,0,-100.662)">
<g transform="matrix(2.38354,0,0,2.30609,-1649.56,-1364.25)">
<rect x="1176.01" y="973.873" width="28.728" height="29.829" style="fill:rgb(218,255,219);"/>
</g>
<g transform="matrix(-0.430964,0.430964,-0.94321,-0.94321,2508.67,1310.41)">
<path d="M1027.23,906.598L1094.61,906.598" style="fill:none;stroke:black;stroke-width:3.86px;"/>
</g>
<g transform="matrix(-0.268904,-0.268904,0.94321,-0.94321,604.052,2057.06)">
<path d="M1027.23,906.598L1094.61,906.598" style="fill:none;stroke:black;stroke-width:4.08px;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="95px" height="94px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-908.875,-874.606)">
<g id="chat-box" transform="matrix(0.656013,0,0,0.670644,264.055,406.976)">
<g id="chat-box-empty">
<g id="chat-box1" serif:id="chat-box">
<path d="M1017.23,802.993L999.922,819.368L999.922,713.9L1122.27,713.9L1122.27,802.993L1017.23,802.993Z" style="fill:white;"/>
<path d="M1019.7,808.958L993.824,833.432L993.824,707.936L1128.37,707.936L1128.37,808.958L1019.7,808.958ZM1017.23,802.993L1122.27,802.993L1122.27,713.9L999.922,713.9L999.922,819.368L1017.23,802.993Z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="117px" height="117px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
<g transform="matrix(1,0,0,1,-1002.94,-858.534)">
<g id="chat-box-plus">
<g id="chat-box" transform="matrix(0.656013,0,0,0.670644,363.616,406.976)">
<path d="M1017.23,802.993L999.922,819.368L999.922,713.9L1122.27,713.9L1122.27,802.993L1017.23,802.993Z" style="fill:white;"/>
<path d="M1019.7,808.958L993.824,833.432L993.824,707.936L1128.37,707.936L1128.37,808.958L1019.7,808.958ZM1017.23,802.993L1122.27,802.993L1122.27,713.9L999.922,713.9L999.922,819.368L1017.23,802.993Z"/>
</g>
<g id="plus" transform="matrix(1,0,0,1,0,9.96404)">
<g transform="matrix(6.12323e-17,0.529301,-1,3.24103e-17,1967.51,345.056)">
<path d="M1027.23,906.598L1094.61,906.598" style="fill:none;stroke:black;stroke-width:5px;"/>
</g>
<g transform="matrix(-0.529301,-3.59987e-17,1.51287e-16,-1,1622.51,1813.2)">
<path d="M1027.23,906.598L1094.61,906.598" style="fill:none;stroke:black;stroke-width:5px;"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="99px" height="78px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-911.073,-1009.05)">
<g id="chat-box-text-select" transform="matrix(1,0,0,1,-99.561,132.245)">
<g id="chat-box" transform="matrix(0.656013,0,0,0.670644,363.616,406.976)">
<rect x="999.922" y="713.9" width="122.35" height="89.093" style="fill:white;"/>
<path d="M993.824,808.958L993.824,707.936L1128.37,707.936L1128.37,808.958L993.824,808.958ZM1122.27,802.993L1122.27,713.9L999.922,713.9L999.922,802.993L1122.27,802.993Z"/>
</g>
<g transform="matrix(1,0,0,1,102.561,-132.245)">
<path d="M966.593,1032.03L947.778,1032.03L957.563,1032.03L957.563,1063.28L947.781,1063.28L966.575,1063.28" style="fill:none;stroke:black;stroke-width:4px;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M24 12L18.3431 17.6569L16.9289 16.2426L21.1716 12L16.9289 7.75736L18.3431 6.34315L24 12ZM2.82843 12L7.07107 16.2426L5.65685 17.6569L0 12L5.65685 6.34315L7.07107 7.75736L2.82843 12ZM9.78845 21H7.66009L14.2116 3H16.3399L9.78845 21Z"></path></svg>

After

Width:  |  Height:  |  Size: 333 B

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="69px" height="69px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:1;">
<g transform="matrix(1,0,0,1,-1153.51,-881.59)">
<g id="decline">
<g transform="matrix(2.38354,0,0,2.30609,-1649.56,-1364.25)">
<rect x="1176.01" y="973.873" width="28.728" height="29.829" style="fill:rgb(255,200,200);"/>
</g>
<g transform="matrix(-0.5732,0.5732,-1.08294,-1.08294,2777.67,1290.18)">
<path d="M1027.23,906.598L1094.61,906.598" style="fill:none;stroke:black;stroke-width:3.26px;"/>
</g>
<g transform="matrix(-0.5732,-0.5732,1.08294,-1.08294,814.15,2506.47)">
<path d="M1027.23,906.598L1094.61,906.598" style="fill:none;stroke:black;stroke-width:3.26px;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="69px" height="69px" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-1156.78,-953.31)">
<g id="delete" transform="matrix(1.47438,0,0,1.47438,-564.996,-468.55)">
<g transform="matrix(1.61664,0,0,1.56411,-733.384,-558.866)">
<rect x="1176.01" y="973.873" width="28.728" height="29.829" style="fill:white;"/>
</g>
<g id="delete1" serif:id="delete" transform="matrix(1.51836,0,0,1.51836,1172.75,969.558)">
<path d="M17,6L22,6L22,8L20,8L20,21C20,21.552 19.552,22 19,22L5,22C4.448,22 4,21.552 4,21L4,8L2,8L2,6L7,6L7,3C7,2.448 7.448,2 8,2L16,2C16.552,2 17,2.448 17,3L17,6ZM18,8L6,8L6,20L18,20L18,8ZM9,11L11,11L11,17L9,17L9,11ZM13,11L15,11L15,17L13,17L13,11ZM9,4L9,6L15,6L15,4L9,4Z" style="fill-rule:nonzero;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.54429 2.67305C5.81644 2.49995 6.13587 2.41612 6.45799 2.43329C6.78102 2.4505 7.09056 2.56841 7.34318 2.77049L7.34405 2.77119C7.59044 2.96879 7.76998 3.2372 7.85866 3.5399L9.30537 7.96754H14.6944L16.1411 3.5399C16.2298 3.23722 16.4093 2.96879 16.6557 2.77116L16.6604 2.76745C16.9128 2.56777 17.2209 2.45133 17.5424 2.43423C17.8638 2.41712 18.1826 2.50023 18.4547 2.67197L18.4571 2.67347C18.7307 2.84735 18.9427 3.10328 19.0624 3.40486L19.0664 3.41491L21.5393 9.86622C21.9619 10.9712 22.0136 12.1836 21.6865 13.3205C21.3594 14.4574 20.6715 15.457 19.7263 16.1685L12.9955 21.2331L12.9945 21.2338C12.7066 21.4513 12.3554 21.5692 11.9943 21.5692C11.6332 21.5692 11.2819 21.4513 10.9939 21.2337L4.26254 16.1683C3.32063 15.4562 2.63541 14.4574 2.30989 13.3224C1.98437 12.1873 2.03616 10.9772 2.45747 9.8741L4.93724 3.40497C5.0571 3.10297 5.26966 2.84673 5.54429 2.67305ZM6.35534 4.73567L4.16029 10.4639C3.87993 11.2013 3.82298 12.0676 4.04049 12.8261C4.25704 13.5811 4.71123 14.2461 5.33544 14.7225L11.9943 19.7329L18.6484 14.7265C19.2789 14.2502 19.7379 13.5822 19.9563 12.8227C20.1751 12.0624 20.1148 11.1847 19.8328 10.4455L17.6444 4.73558L16.0001 9.76791H7.9996L6.35534 4.73567Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C17.5222 2 22 5.97778 22 10.8889C22 13.9556 19.5111 16.4444 16.4444 16.4444H14.4778C13.5556 16.4444 12.8111 17.1889 12.8111 18.1111C12.8111 18.5333 12.9778 18.9222 13.2333 19.2111C13.5 19.5111 13.6667 19.9 13.6667 20.3333C13.6667 21.2556 12.9 22 12 22C6.47778 22 2 17.5222 2 12C2 6.47778 6.47778 2 12 2ZM10.8111 18.1111C10.8111 16.0843 12.451 14.4444 14.4778 14.4444H16.4444C18.4065 14.4444 20 12.851 20 10.8889C20 7.1392 16.4677 4 12 4C7.58235 4 4 7.58235 4 12C4 16.19 7.2226 19.6285 11.324 19.9718C10.9948 19.4168 10.8111 18.7761 10.8111 18.1111ZM7.5 12C6.67157 12 6 11.3284 6 10.5C6 9.67157 6.67157 9 7.5 9C8.32843 9 9 9.67157 9 10.5C9 11.3284 8.32843 12 7.5 12ZM16.5 12C15.6716 12 15 11.3284 15 10.5C15 9.67157 15.6716 9 16.5 9C17.3284 9 18 9.67157 18 10.5C18 11.3284 17.3284 12 16.5 12ZM12 9C11.1716 9 10.5 8.32843 10.5 7.5C10.5 6.67157 11.1716 6 12 6C12.8284 6 13.5 6.67157 13.5 7.5C13.5 8.32843 12.8284 9 12 9Z"></path></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M17.8492 11.805L17.1421 11.0979L7.24264 20.9974H3V16.7547L14.3137 5.44101L19.9706 11.0979C20.3611 11.4884 20.3611 12.1216 19.9706 12.5121L12.8995 19.5831L11.4853 18.1689L17.8492 11.805ZM18.5563 2.61258L21.3848 5.44101C21.7753 5.83153 21.7753 6.4647 21.3848 6.85522L19.9706 8.26943L15.7279 4.02679L17.1421 2.61258C17.5327 2.22206 18.1658 2.22206 18.5563 2.61258Z"></path></svg>

After

Width:  |  Height:  |  Size: 465 B

View file

@ -0,0 +1 @@
h2[data-v-019dfc9e]{margin-bottom:1rem}.k-table .k-table-index-column[data-v-019dfc9e]{width:6rem}

File diff suppressed because one or more lines are too long

View file

@ -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");
}

View file

@ -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.

View file

@ -0,0 +1,201 @@
<?php
namespace AdrienPayet\FrontComments;
class Comment
{
private $page;
private $data;
private $options;
public function __construct($commentData, $page)
{
$this->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;
}
}

View file

@ -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"
}
}

View file

@ -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"
}

View file

@ -0,0 +1,45 @@
<?php
use AdrienPayet\FrontComments\Comment;
return [
'pattern' => '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);
}
}
];

View file

@ -0,0 +1,19 @@
<?php
use AdrienPayet\FrontComments\Comment;
return [
'pattern' => '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;
}
];

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

View file

@ -0,0 +1,43 @@
<?php
return [
'pattern' => '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;
}
];

View file

@ -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}

View file

@ -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:'<path fill="none" d="M0 0h24v24H0z"></path><path d="M5.54429 2.67305C5.81644 2.49995 6.13587 2.41612 6.45799 2.43329C6.78102 2.4505 7.09056 2.56841 7.34318 2.77049L7.34405 2.77119C7.59044 2.96879 7.76998 3.2372 7.85866 3.5399L9.30537 7.96754H14.6944L16.1411 3.5399C16.2298 3.23722 16.4093 2.96879 16.6557 2.77116L16.6604 2.76745C16.9128 2.56777 17.2209 2.45133 17.5424 2.43423C17.8638 2.41712 18.1826 2.50023 18.4547 2.67197L18.4571 2.67347C18.7307 2.84735 18.9427 3.10328 19.0624 3.40486L19.0664 3.41491L21.5393 9.86622C21.9619 10.9712 22.0136 12.1836 21.6865 13.3205C21.3594 14.4574 20.6715 15.457 19.7263 16.1685L12.9955 21.2331L12.9945 21.2338C12.7066 21.4513 12.3554 21.5692 11.9943 21.5692C11.6332 21.5692 11.2819 21.4513 10.9939 21.2337L4.26254 16.1683C3.32063 15.4562 2.63541 14.4574 2.30989 13.3224C1.98437 12.1873 2.03616 10.9772 2.45747 9.8741L4.93724 3.40497C5.0571 3.10297 5.26966 2.84673 5.54429 2.67305ZM6.35534 4.73567L4.16029 10.4639C3.87993 11.2013 3.82298 12.0676 4.04049 12.8261C4.25704 13.5811 4.71123 14.2461 5.33544 14.7225L11.9943 19.7329L18.6484 14.7265C19.2789 14.2502 19.7379 13.5822 19.9563 12.8227C20.1751 12.0624 20.1148 11.1847 19.8328 10.4455L17.6444 4.73558L16.0001 9.76791H7.9996L6.35534 4.73567Z"></path>'},components:{"k-comments-view":k}})})();

View file

@ -0,0 +1,89 @@
<?php
F::loadClasses([
'AdrienPayet\\FrontComments\\Comment' => '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);
}
]
]);

View file

@ -0,0 +1,120 @@
<?php
use AdrienPayet\FrontComments\Comment;
function rec_copy($source, $destination)
{
if (is_dir($source)) {
if (!file_exists($destination)) {
mkdir($destination, 0755, true);
}
$files = scandir($source);
foreach ($files as $file) {
if (!str_starts_with($file, '.')) {
rec_copy("$source/$file", "$destination/$file");
}
}
} else {
try {
copy($source, $destination);
} catch (\Throwable $th) {
throw new Exception($th->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);
}
}

View file

@ -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"
}
}
}
}

View file

@ -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"
]
}
}

View file

@ -0,0 +1,22 @@
<?php
function deleteComment()
{
$jsonRequest = file_get_contents("php://input");
$request = json_decode($jsonRequest, true);
$dataFile = __DIR__ . '/../data/data.json';
$jsonData = file_get_contents($dataFile);
$data = json_decode($jsonData, true);
foreach ($data as $index => $item) {
if ($item['id'] === $request['id']) {
array_splice($data, $index, 1);
break;
}
}
file_put_contents($dataFile, json_encode($data));
return json_encode($data);
}

View file

@ -0,0 +1,32 @@
<?php
function storeComment()
{
$jsonRequest = file_get_contents("php://input");
$request = json_decode($jsonRequest, true);
if (isset($request['position']) && is_string($request['position'])) {
$request['position'] = json_decode($request['position'], true);
}
$dataFile = __DIR__ . '/../data/data.json';
$data = file_get_contents($dataFile);
$jsonData = json_decode($data, true);
$foundIndex = null;
foreach ($jsonData as $index => $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;
}

View file

@ -0,0 +1,7 @@
<?php if ($kirby->user()): ?>
<div class="fc__field" data-field-name="<?= $content->key() ?>" data-content="<?= $content ?>">
<?= $content ?>
</div>
<?php else: ?>
<?= $content ?>
<?php endif ?>

View file

@ -0,0 +1,35 @@
<?php
$comments = $page->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);
?>
<script>
const FCAuthor = '<?= e($kirby->user()->name()->isNotEmpty(), $kirby->user()->name(), $kirby->user()->email()) ?>';
const FCTeam = '<?= $kirby->user()->team() ?>';
const FCPageId = '<?= $page->panel()->id() ?>';
const FCPageUri = '<?= $page->panel()->id() ?>';
const FCCsrf = "<?= csrf() ?>";
const FCComments = <?= $commentsJson ?>;
const FCSuggestions = <?= $suggestionsJson ?>;
const FCFilesPath = '<?= $filesPath ?>';
const FCPosition = '<?= $position ?>';
</script>
<?php
echo js($filesPath . '/index.js');

View file

@ -0,0 +1,6 @@
<style>
:root {
--fc-icon-chat-box-empty: url(<?= $filesPath ?>/icons/chat-box-empty.svg);
}
</style>
<?= css($filesPath . '/style.css');

View file

@ -0,0 +1,21 @@
<?php
if ($kirby->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
]
);
}

View file

@ -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 = `<button class="fc__btn fc__btn-add fc__btn-add--${position} fc__btn-add--free" title="New comment">
<img class="fc__icon fc__plus" src="${Store.filesPath}/icons/chat-box-plus.svg">
</button>`;
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;

View file

@ -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 = `<p>${this.suggestion}</p>`;
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 = `<img class="fc__icon" src="${Store.filePath}/front-comments/icons/decline.svg" />`;
const accept = document.createElement("button");
accept.classList.add("fc__btn");
accept.innerHTML = `<img class="fc__icon" src="${Store.filePath}/front-comments/icons/accept.svg" />`;
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;

View file

@ -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(`<button
id="${this.id}"
class="fc__bubble fc__btn"
style="left: ${this.position.left}; top: ${this.position.top};">
${this.author.slice(0, 1).toUpperCase()}
</button>`);
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
? `<li class="fc__context-item">
<b>Largeur fenêtre</b> : ${this.windowWidth}px <button class="fc__open-window" title="Ouvrir une fenêtre de cette largeur">voir</button>
</li>`
: "";
const userAgentInfoElement = this.userAgent
? `<li class="fc__context-item">
<b>Agent utilisateur</b> : <br>
${this.userAgent}
</li>`
: "";
const contextInfoElement =
this.userAgent || this.windowWidth
? `
<div class="fc__context">
<input id="collapsible" class="fc__toggle" type="checkbox">
<label for="collapsible" class="fc__label-toggle" title="État du navigateur au moment de l'ajout du commentaire">Contexte</label>
<div class="fc__collapsible-content">
<ul class="fc__content-inner">
${windowWidthInfoElement}
${userAgentInfoElement}
</ul>
</div>
</div>
`
: "";
const comment = this._injectNode(`<div
class="fc__comment"
style="left: ${this.position.left}; top: ${this.position.top};"
>
<button class="fc__btn fc__comment-delete" title="Remove comment">
<img class="fc__icon" src="${Store.filesPath}/icons/delete.svg">
</button>
<span class="fc__author">${this.author}</span><br><span class="fc__datetime">${this.date} à ${this.time}</span><br>
<p>${this.message}</p>
${contextInfoElement}
</div>`);
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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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();
// }
// });
// });
});

View file

@ -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;

View file

@ -0,0 +1,177 @@
<template>
<k-inside>
<template v-if="pages.length > 0">
<h1 class="k-header-title">Pages</h1>
<section v-for="page in pages" :key="page.uri">
<h2 class="k-label">{{ page.title }}</h2>
<div class="k-table">
<table>
<thead>
<tr>
<th class="k-comment-author">
{{ $t("adrienpayet.front-comments.author") }}
</th>
<th class="k-comment-message">
{{ $t("adrienpayet.front-comments.message") }}
</th>
<th class="k-comment-date">
{{ $t("adrienpayet.front-comments.date") }}
</th>
<th class="k-comment-time">
{{ $t("adrienpayet.front-comments.time") }}
</th>
<th class="k-comment-actions"></th>
</tr>
</thead>
<tbody>
<tr v-for="comment in page.comments" :key="comment.id">
<td class="k-comment-author">
<div class="author-wrapper">
<k-icon v-if="comment?.team === 'code'" type="code" />
<k-icon v-if="comment?.team === 'design'" type="palette" />
<k-icon v-if="comment?.team === 'content'" type="pen" />
{{ comment.author }}
</div>
</td>
<td class="k-comment-message" v-html="comment.message"></td>
<td class="k-comment-date">
{{ comment.date }}
</td>
<td class="k-comment-time">
{{ comment.time }}
</td>
<td class="k-comment-actions">
<k-options-dropdown
:options="'comments/' + comment.id + '/' + page.uri"
/>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</template>
<h1 v-else class="k-header-title">
{{ $t("adrienpayet.front-comments.no-comment") }}
</h1>
</k-inside>
</template>
<script setup>
const { pages, csrf } = defineProps({
pages: Array,
csrf: String,
});
sortByDate();
function convertToDate(dateString, timeString) {
const [day, month, year] = dateString.split("/");
const [hour, minute] = timeString.split(":");
return new Date(`20${year}-${month}-${day}T${hour}:${minute}:00`);
}
function sortByDate() {
const convertCommentDate = (comment) =>
convertToDate(comment.date, comment.time);
pages.forEach((page) => {
page.comments.sort((commentA, commentB) => {
const dateA = convertCommentDate(commentA);
const dateB = convertCommentDate(commentB);
return dateB - dateA;
});
});
pages.sort((pageA, pageB) => {
const lastCommentA = pageA.comments[pageA.comments.length - 1];
const lastCommentB = pageB.comments[pageB.comments.length - 1];
const dateA = convertCommentDate(lastCommentA);
const dateB = convertCommentDate(lastCommentB);
return dateB - dateA;
});
}
function remove(commentId, pageUri) {
const pageIndex = pages.findIndex((page) =>
page.comments.some((comment) => comment.id === commentId)
);
const commentIndex = pages[pageIndex].comments.findIndex(
(comment) => comment.id === commentId
);
const updatedComments = pages[pageIndex].comments.filter(
(comment) => comment.id !== commentId
);
pages[pageIndex].comments = updatedComments;
const init = {
method: "PATCH",
headers: {
"X-CSRF": csrf,
},
body: JSON.stringify({
comments: updatedComments,
}),
};
fetch("/api/pages/" + pageUri.replace("/", "+"), init)
.then((response) => response.json())
.then((response) => {
console.log(response);
console.log("Page successfully updated.");
})
.catch((error) => {
console.log(error);
});
}
</script>
<style scoped>
h2 {
margin-bottom: 1rem;
}
.k-comment-author .k-icon {
display: inline-block;
margin-right: 1rem;
}
.k-table .k-table-index-column {
width: 6rem;
}
section {
margin-bottom: 2rem;
}
.k-table td {
padding: 0.5rem;
height: 100%;
}
.k-comment-author {
width: 15rem;
}
.author-wrapper {
display: flex;
}
.k-comment-message {
width: 100%;
}
.k-comment-date {
width: 6rem;
}
.k-comment-time {
width: 5rem;
}
.k-comment-actions {
width: 2rem;
}
</style>

View file

@ -0,0 +1,11 @@
import KCommentsView from "./components/KCommentsView.vue";
panel.plugin("adrienpayet/front-comments", {
icons: {
gitlab:
'<path fill="none" d="M0 0h24v24H0z"></path><path d="M5.54429 2.67305C5.81644 2.49995 6.13587 2.41612 6.45799 2.43329C6.78102 2.4505 7.09056 2.56841 7.34318 2.77049L7.34405 2.77119C7.59044 2.96879 7.76998 3.2372 7.85866 3.5399L9.30537 7.96754H14.6944L16.1411 3.5399C16.2298 3.23722 16.4093 2.96879 16.6557 2.77116L16.6604 2.76745C16.9128 2.56777 17.2209 2.45133 17.5424 2.43423C17.8638 2.41712 18.1826 2.50023 18.4547 2.67197L18.4571 2.67347C18.7307 2.84735 18.9427 3.10328 19.0624 3.40486L19.0664 3.41491L21.5393 9.86622C21.9619 10.9712 22.0136 12.1836 21.6865 13.3205C21.3594 14.4574 20.6715 15.457 19.7263 16.1685L12.9955 21.2331L12.9945 21.2338C12.7066 21.4513 12.3554 21.5692 11.9943 21.5692C11.6332 21.5692 11.2819 21.4513 10.9939 21.2337L4.26254 16.1683C3.32063 15.4562 2.63541 14.4574 2.30989 13.3224C1.98437 12.1873 2.03616 10.9772 2.45747 9.8741L4.93724 3.40497C5.0571 3.10297 5.26966 2.84673 5.54429 2.67305ZM6.35534 4.73567L4.16029 10.4639C3.87993 11.2013 3.82298 12.0676 4.04049 12.8261C4.25704 13.5811 4.71123 14.2461 5.33544 14.7225L11.9943 19.7329L18.6484 14.7265C19.2789 14.2502 19.7379 13.5822 19.9563 12.8227C20.1751 12.0624 20.1148 11.1847 19.8328 10.4455L17.6444 4.73558L16.0001 9.76791H7.9996L6.35534 4.73567Z"></path>',
},
components: {
"k-comments-view": KCommentsView,
},
});

View file

@ -0,0 +1,14 @@
<?php
Kirby::plugin('adrienpayet/front-comments', [
'options' => [
'cache' => true,
],
'hooks' => [
'page.update:after' => function ($newPage) {
kirby()
->cache('adrienpayet.front-comments')
->set('foo', 'bar');
}
]
]);

View file

@ -0,0 +1,16 @@
<?php
return [
'adrienpayet.front-comments.comments' => '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',
];

View file

@ -0,0 +1,17 @@
<?php
return [
'adrienpayet.front-comments.comments' => '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',
];