add kirby-loop plugin with French translations
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
All checks were successful
Deploy / Deploy to Production (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8ea5f0c462
commit
ab7fd8b2ea
74 changed files with 16423 additions and 2 deletions
|
|
@ -29,7 +29,8 @@
|
|||
"php-http/guzzle7-adapter": "^1.1",
|
||||
"mailersend/mailersend": "^0.28.0",
|
||||
"sylvainjule/code-editor": "^1.0",
|
||||
"tobimori/kirby-seo": "^1.1"
|
||||
"tobimori/kirby-seo": "^1.1",
|
||||
"moinframe/kirby-loop": "^1.0"
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
|
|
|
|||
63
composer.lock
generated
63
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "30f9edc8f90ec79150fffac01e3b80fd",
|
||||
"content-hash": "e9154be9f46dbe7bc999d4706958afae",
|
||||
"packages": [
|
||||
{
|
||||
"name": "beberlei/assert",
|
||||
|
|
@ -1317,6 +1317,67 @@
|
|||
},
|
||||
"time": "2016-12-13T01:01:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "moinframe/kirby-loop",
|
||||
"version": "1.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moinframe/kirby-loop.git",
|
||||
"reference": "1e7732a075e96ecca119032175f9a048cfa4784e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/moinframe/kirby-loop/zipball/1e7732a075e96ecca119032175f9a048cfa4784e",
|
||||
"reference": "1e7732a075e96ecca119032175f9a048cfa4784e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"getkirby/cms": "^4.0||^5.0",
|
||||
"getkirby/composer-installer": "^1.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0"
|
||||
},
|
||||
"type": "kirby-plugin",
|
||||
"extra": {
|
||||
"installer-name": "loop"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Moinframe\\Loop\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Justus Kraft",
|
||||
"email": "justus@moinfra.me",
|
||||
"homepage": "https://moinfra.me"
|
||||
}
|
||||
],
|
||||
"description": "Interactive feedback tool for Kirby CMS websites that allows users to add contextual comments directly on page elements",
|
||||
"homepage": "https://github.com/moinframe/kirby-loop",
|
||||
"keywords": [
|
||||
"cms",
|
||||
"comments",
|
||||
"feedback",
|
||||
"kirby",
|
||||
"loop",
|
||||
"plugin",
|
||||
"review"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://moinfra.me/docs/moinframe-loop",
|
||||
"issues": "https://github.com/moinframe/kirby-loop/issues",
|
||||
"source": "https://github.com/moinframe/kirby-loop"
|
||||
},
|
||||
"time": "2025-07-08T18:20:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nyholm/psr7",
|
||||
"version": "1.8.2",
|
||||
|
|
|
|||
|
|
@ -26,4 +26,6 @@ return [
|
|||
'hooks' => [
|
||||
'page.create:after' => require __DIR__ . '/hooks/prefill-test-adress-list.php',
|
||||
],
|
||||
|
||||
'moinframe.loop.language' => 'fr',
|
||||
];
|
||||
|
|
|
|||
20
site/plugins/loop/.editorconfig
Normal file
20
site/plugins/loop/.editorconfig
Normal 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
|
||||
3
site/plugins/loop/.paradocs.json
Normal file
3
site/plugins/loop/.paradocs.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"title": "Kirby Loop"
|
||||
}
|
||||
25
site/plugins/loop/.release-it.json
Normal file
25
site/plugins/loop/.release-it.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"git": {
|
||||
"requireUpstream": true,
|
||||
"push": true,
|
||||
"tagName": "v${version}",
|
||||
"commitMessage": "chore: release v${version}"
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"web": true
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"preset": "conventionalcommits",
|
||||
"infile": "CHANGELOG.md"
|
||||
},
|
||||
"@release-it/bumper": {
|
||||
"in": "composer.json",
|
||||
"out": "composer.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
90
site/plugins/loop/CHANGELOG.md
Normal file
90
site/plugins/loop/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Changelog
|
||||
|
||||
## [1.0.1](///compare/v1.0.0...v1.0.1) (2025-07-08)
|
||||
|
||||
### Features
|
||||
|
||||
* add allow draft pages option 351471e
|
||||
* add major version check 02d3690
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add token to fetch universally (if set) 9c33fdd
|
||||
* check for valid draft token 091a135
|
||||
* check for verified token with kirby 4 too dec2d34
|
||||
* remove draft page access docs 7e71ee9
|
||||
* remove draftaccess option 98148a0
|
||||
* show ui only if comments fetch goes through a5188f0
|
||||
* use default minification b9ce0f6
|
||||
|
||||
## [1.0.0](///compare/v1.0.0-beta.7...v1.0.0) (2025-07-05)
|
||||
|
||||
## [1.0.0-beta.7](///compare/v1.0.0-beta.6...v1.0.0-beta.7) (2025-07-05)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use autoloader for classes 7464553
|
||||
|
||||
## [1.0.0-beta.6](///compare/v1.0.0-beta.5...v1.0.0-beta.6) (2025-07-02)
|
||||
|
||||
### Features
|
||||
|
||||
* add screenshot c3ef6f3
|
||||
|
||||
## [1.0.0-beta.5](///compare/v1.0.0-beta.4...v1.0.0-beta.5) (2025-06-30)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* decode html entities cf00460
|
||||
* make sure to add a guest name before replying or commenting 763c904
|
||||
|
||||
## [1.0.0-beta.4](///compare/v1.0.0-beta.3...v1.0.0-beta.4) (2025-06-30)
|
||||
|
||||
### Features
|
||||
|
||||
* add aria labels to improve voice over 63fc81d
|
||||
* improve reply voiceover 93e294e
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* cleanup icons f956a34
|
||||
* date display 9a4ad00
|
||||
* header button focus styles 26ebb19
|
||||
* hide reply button on solved 545a094
|
||||
* improve context menu voiceover a6c514a
|
||||
* outline on comment header a9c2d37
|
||||
* pulse marker 8ec0bd5
|
||||
* skip focus if panel closed ee23bde
|
||||
* switch icons, little style fixes 958f0d1
|
||||
* use dialog element for panel f3dff13
|
||||
|
||||
## [1.0.0-beta.3](///compare/v1.0.0-beta.2...v1.0.0-beta.3) (2025-06-22)
|
||||
|
||||
### Features
|
||||
|
||||
* dark theme e34269f
|
||||
* add dark theme 5255d64
|
||||
* add theme option 51e3b67
|
||||
* add theming docs 2d74d63
|
||||
* better shadows, rename theme light to default 90e6e6c
|
||||
* refactor css custom properties 6daf0de
|
||||
* refactor css custom properties f6ffb0b
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* frosted glass style 8807e3d
|
||||
* Plugin name in paradocs 62ac94b
|
||||
* remove cursor property, don’t set to auto 7cf72c2
|
||||
|
||||
## [1.0.0-beta.2](///compare/v1.0.0-beta.1...v1.0.0-beta.2) (2025-06-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle $page variable in hook 113f77d
|
||||
* installer name 77c9e18
|
||||
* update types b79d7b6
|
||||
* use unified api base with language included 109d850
|
||||
|
||||
## [1.0.0-beta.1](///compare/v1.0.0-beta.0...v1.0.0-beta.1) (2025-06-21)
|
||||
|
||||
## 1.0.0-beta.0 (2025-06-21)
|
||||
97
site/plugins/loop/CLAUDE.md
Normal file
97
site/plugins/loop/CLAUDE.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Structure
|
||||
|
||||
This is a Kirby CMS plugin that provides a feedback tool for web pages. The architecture consists of:
|
||||
|
||||
**Backend (PHP):**
|
||||
- Kirby plugin structure with main entry point at `index.php`
|
||||
- Core logic in `src/App.php` with database abstraction
|
||||
- API routes in `src/Routes.php` for comment management
|
||||
- Models in `src/Models/` for Comment and Reply entities
|
||||
- Database layer in `src/Database.php`
|
||||
|
||||
**Frontend (Svelte):**
|
||||
- Svelte 5 component library in `frontend/src/`
|
||||
- Builds to ES modules in `assets/` directory
|
||||
- Uses Vite for build process with custom element compilation
|
||||
- State management via Svelte stores in `frontend/src/store/`
|
||||
|
||||
## Development Commands
|
||||
|
||||
**Frontend Development:**
|
||||
```bash
|
||||
# Start development server
|
||||
pnpm dev
|
||||
|
||||
# Build assets
|
||||
pnpm build
|
||||
|
||||
# Type checking
|
||||
pnpm --filter=frontend run check
|
||||
```
|
||||
|
||||
**PHP Development:**
|
||||
```bash
|
||||
# Static analysis
|
||||
vendor/bin/phpstan analyse
|
||||
|
||||
# PHP analysis level 8 with strict rules
|
||||
# Configuration in phpstan.neon
|
||||
```
|
||||
|
||||
**Documentation:**
|
||||
Use context7 to find out about Kirby CMS, Documentation for this plugin is placed in the `docs/` folder.
|
||||
|
||||
## Key Architecture Details
|
||||
|
||||
**Plugin Integration:**
|
||||
- Auto-injects feedback component into all HTML pages via `page.render:after` hook
|
||||
- Component snippet located at `snippets/loop/app.php`
|
||||
- Requires authenticated users (see `Middleware::auth()`)
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /loop/comments/{pageId}` - Get comments for a specific page
|
||||
- `POST /loop/comment/new` - Create new comment
|
||||
- `POST /loop/comment/reply` - Reply to existing comment
|
||||
- `POST /loop/comment/resolve` - Mark comment as resolved
|
||||
- `POST /loop/comment/unresolve` - Mark comment as unresolved
|
||||
- `POST /loop/guest/name` - Set guest name for non-authenticated users
|
||||
|
||||
**Data Flow:**
|
||||
- Comments are tied to Kirby page ids
|
||||
- Position tracking via CSS selectors and page coordinates
|
||||
- Validation happens at model level before database operations
|
||||
|
||||
## Translations
|
||||
|
||||
**IMPORTANT: When adding new translatable text to the frontend:**
|
||||
|
||||
1. **Add translation key to PHP backend** (`index.php`):
|
||||
```php
|
||||
'moinframe.loop.ui.component.key' => 'Default English text',
|
||||
```
|
||||
|
||||
2. **Add translation key to snippet** (`snippets/loop/app.php`):
|
||||
```php
|
||||
'ui.component.key' => t('moinframe.loop.ui.component.key'),
|
||||
```
|
||||
|
||||
3. **Use translation in Svelte components**:
|
||||
```svelte
|
||||
{t("ui.component.key", "Default fallback text")}
|
||||
```
|
||||
|
||||
**Translation Architecture:**
|
||||
- PHP translations defined in `index.php` under `'translations'` key
|
||||
- Frontend translations passed via `snippets/loop/app.php`
|
||||
- Svelte components use `t()` function from `store/translations.svelte.ts`
|
||||
- Always provide fallback text in components for development
|
||||
|
||||
## Linting and Code Quality
|
||||
|
||||
- Biome for frontend linting (config in `biome.json`)
|
||||
- PHPStan level 8 analysis with strict rules
|
||||
- TypeScript checking via `svelte-check`
|
||||
21
site/plugins/loop/LICENSE.md
Executable file
21
site/plugins/loop/LICENSE.md
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Justus Kraft
|
||||
|
||||
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.
|
||||
96
site/plugins/loop/README.md
Executable file
96
site/plugins/loop/README.md
Executable file
|
|
@ -0,0 +1,96 @@
|
|||

|
||||
# Kirby Loop
|
||||
|
||||
Stay in the loop. A powerful visual feedback plugin for Kirby CMS that allows users to add comments directly on web pages by clicking on elements. Perfect for client reviews, content collaboration, and team feedback workflows.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎯 **Click-to-comment**: Toggle between navigate mode for normal browsing and comment mode to click anywhere and add feedback
|
||||
- 🌍 **Multi-language support**: Full support for Kirby's multi-language sites with automatic language detection
|
||||
- 💬 **Threaded comments**: Reply to comments for contextual discussions
|
||||
- 🔒 **Authentication**: Choose whether to restrict access to authenticated users only or allow guest commenting
|
||||
- 🎨 **Theming**: Built-in light/dark themes with full customization support
|
||||
- ⚙️ **Auto-injection**: Automatically inject into all pages or manually control placement
|
||||
- 🗄️ **Local storage**: All data stored locally in SQLite - no external dependencies
|
||||
|
||||
## How It Works
|
||||
|
||||
Kirby Loop transforms your website into a collaborative workspace where teams can provide feedback directly on web pages.
|
||||
|
||||
**Visual Context**: Users can click on any element to leave specific comments, creating a direct connection between feedback and content.
|
||||
|
||||
**Streamlined Communication**: Team members, clients, and stakeholders can point out issues and suggest improvements right where they see them.
|
||||
|
||||
**Organized Discussions**: Comments support threaded replies and can be marked as resolved to maintain a clean feedback pipeline.
|
||||
|
||||
**Privacy & Data Control**: All feedback data is stored locally in a SQLite database on your server - no external services or cloud dependencies.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Install**: `composer require moinframe/kirby-loop`
|
||||
2. **Use**: Kirby Loop is automatically active on all pages for authenticated users
|
||||
3. **Configure**: Customize settings in `site/config/config.php` (optional)
|
||||
|
||||
## Documentation
|
||||
|
||||
Detailed documentation is available in the `docs/` folder:
|
||||
|
||||
- **[Installation Guide](https://moinfra.me/docs/moinframe-loop/01-installation)** - Complete installation instructions
|
||||
- **[Configuration Guide](https://moinfra.me/docs/moinframe-loop/02-configuration)** - All configuration options and advanced settings
|
||||
- **[Multi-Language Support](https://moinfra.me/docs/moinframe-loop/03-multi-language)** - Setup and customization for multi-language sites
|
||||
- **[API Reference](https://moinfra.me/docs/moinframe-loop/05-api)** - API documentation
|
||||
- **[Theming Guide](https://moinfra.me/docs/moinframe-loop/04-theming)** - Theme customization and creating custom themes
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
Add these options to your `site/config/config.php`:
|
||||
|
||||
```php
|
||||
return [
|
||||
// Enable/disable loop (default: true)
|
||||
'moinframe.loop.enabled' => true,
|
||||
|
||||
// Or use a callback for conditional enabling
|
||||
'moinframe.loop.enabled' => function($page) {
|
||||
return in_array($page->template()->name(), ['article', 'blog']);
|
||||
},
|
||||
|
||||
// Disable auto-injection (default: true)
|
||||
'moinframe.loop.auto-inject' => false,
|
||||
|
||||
// Set header position: 'top' or 'bottom' (default: 'top')
|
||||
'moinframe.loop.position' => 'bottom',
|
||||
|
||||
// Make feedback public (default: false - requires auth)
|
||||
'moinframe.loop.public' => true,
|
||||
|
||||
// Force UI language (default: null - auto-detect)
|
||||
'moinframe.loop.language' => 'de',
|
||||
|
||||
// Set theme: 'default', 'dark', or custom theme name
|
||||
'moinframe.loop.theme' => 'dark',
|
||||
];
|
||||
```
|
||||
|
||||
See the [Configuration Guide](https://moinfra.me/docs/moinframe-loop/02-configuration) for all available options.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Kirby CMS 4.0+
|
||||
- PHP 8.3+
|
||||
- SQLite support
|
||||
|
||||
## Important Notes
|
||||
|
||||
> [!WARNING]
|
||||
> Pages with the snippet automatically have Kirby's page **cache** **disabled**. This is necessary for CSRF token validation and User authentication checks.
|
||||
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: See the [Documentation](https://moinfra.me/docs/moinframe-loop) for installation and usage instructions
|
||||
- **Issues**: Report bugs on [GitHub Issues](https://github.com/moinframe/kirby-loop/issues)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE.md](LICENSE.md)
|
||||
3941
site/plugins/loop/assets/loop.js
Normal file
3941
site/plugins/loop/assets/loop.js
Normal file
File diff suppressed because one or more lines are too long
15
site/plugins/loop/biome.json
Normal file
15
site/plugins/loop/biome.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"overrides": [
|
||||
{
|
||||
"include": ["*.svelte", "*.astro", "*.vue"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"useConst": "off",
|
||||
"useImportType": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
55
site/plugins/loop/composer.json
Executable file
55
site/plugins/loop/composer.json
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"name": "moinframe/kirby-loop",
|
||||
"description": "Interactive feedback tool for Kirby CMS websites that allows users to add contextual comments directly on page elements",
|
||||
"homepage": "https://github.com/moinframe/kirby-loop",
|
||||
"license": "MIT",
|
||||
"type": "kirby-plugin",
|
||||
"version": "1.0.1",
|
||||
"keywords": [
|
||||
"kirby",
|
||||
"cms",
|
||||
"plugin",
|
||||
"feedback",
|
||||
"comments",
|
||||
"review",
|
||||
"loop"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Justus Kraft",
|
||||
"email": "justus@moinfra.me",
|
||||
"homepage": "https://moinfra.me"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"getkirby/composer-installer": "^1.1",
|
||||
"getkirby/cms": "^4.0||^5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Moinframe\\Loop\\": "src/"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"docs": "https://moinfra.me/docs/moinframe-loop",
|
||||
"source": "https://github.com/moinframe/kirby-loop",
|
||||
"issues": "https://github.com/moinframe/kirby-loop/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"analyse": "vendor/bin/phpstan analyse"
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"allow-plugins": {
|
||||
"getkirby/composer-installer": true
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"installer-name": "loop"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^2.0"
|
||||
}
|
||||
}
|
||||
1358
site/plugins/loop/composer.lock
generated
Normal file
1358
site/plugins/loop/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
90
site/plugins/loop/docs/01-installation.md
Normal file
90
site/plugins/loop/docs/01-installation.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
---
|
||||
title: Installation
|
||||
---
|
||||
|
||||
This guide covers all installation methods for the Kirby Loop plugin.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before installing the plugin, ensure your system meets these requirements:
|
||||
|
||||
- **Kirby CMS**: Version 4.0 or higher
|
||||
- **PHP**: Version 8.3 or higher
|
||||
- **SQLite**: Support enabled (usually included by default in PHP)
|
||||
|
||||
## Installation Methods
|
||||
|
||||
### Method 1: Composer (Recommended)
|
||||
|
||||
Composer is the preferred installation method
|
||||
|
||||
```bash
|
||||
composer require moinframe/kirby-loop
|
||||
```
|
||||
|
||||
### Method 2: Manual Installation
|
||||
|
||||
For environments where Composer isn't available or preferred:
|
||||
|
||||
1. **Download the plugin**
|
||||
- Visit the [GitHub releases page](https://github.com/moinframe/kirby-loop/releases)
|
||||
- Download the latest version as a ZIP file
|
||||
|
||||
2. **Extract and place**
|
||||
- Unzip the downloaded archive
|
||||
- Rename the folder to `loop` (remove version numbers)
|
||||
- Move the folder to `/site/plugins/loop`
|
||||
|
||||
3. **Verify installation**
|
||||
- The plugin folder should contain `index.php` and other plugin files
|
||||
- Your final structure should be: `/site/plugins/loop/index.php`
|
||||
|
||||
### Method 3: Git Submodule
|
||||
|
||||
For projects using Git version control, submodules provide a clean way to include the plugin:
|
||||
|
||||
```bash
|
||||
git submodule add https://github.com/moinframe/kirby-loop.git site/plugins/loop
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
After successful installation:
|
||||
|
||||
1. **Configuration**: See [Configuration Guide](https://moinfra.me/docs/moinframe-loop/02-configuration) for customization options
|
||||
2. **Multi-language**: If using multiple languages, review [Multi-language Setup](https://moinfra.me/docs/moinframe-loop/03-multi-language)
|
||||
3. **API Integration**: For custom implementations, check the [API Reference](https://moinfra.me/docs/moinframe-loop/05-api)
|
||||
|
||||
## Updating
|
||||
|
||||
### Composer Updates
|
||||
```bash
|
||||
composer update moinframe/kirby-loop
|
||||
```
|
||||
|
||||
### Manual Updates
|
||||
1. Download the new version
|
||||
2. Replace the plugin folder (backup first!)
|
||||
3. Clear any caches
|
||||
|
||||
### Git Submodule Updates
|
||||
```bash
|
||||
git submodule update --remote site/plugins/loop
|
||||
git add site/plugins/loop
|
||||
git commit -m "Update loop plugin"
|
||||
```
|
||||
|
||||
## Uninstallation
|
||||
|
||||
To remove the plugin:
|
||||
|
||||
1. **Remove plugin files**:
|
||||
- Composer: `composer remove moinframe/kirby-loop`
|
||||
- Manual: Delete `/site/plugins/loop/` folder
|
||||
- Git submodule: `git submodule deinit site/plugins/loop`
|
||||
|
||||
2. **Clean up data** (optional):
|
||||
- Delete `/site/logs/loop/` directory to remove all comments
|
||||
- Remove configuration from `site/config/config.php`
|
||||
|
||||
3. **Clear caches**: Clear any site caches to ensure complete removal
|
||||
252
site/plugins/loop/docs/02-configuration.md
Normal file
252
site/plugins/loop/docs/02-configuration.md
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
---
|
||||
title: Configuration
|
||||
---
|
||||
|
||||
You can customize the plugin's look and behavior by adding configuration options.
|
||||
Add configuration options to your `site/config/config.php` file:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Your existing Kirby configuration...
|
||||
|
||||
// Loop Configuration
|
||||
'moinframe.loop' => [
|
||||
'auto-inject' => true,
|
||||
...
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Enable/Disable Tool
|
||||
|
||||
**Option**: `moinframe.loop.enabled`
|
||||
**Type**: `boolean|callable`
|
||||
**Default**: `true`
|
||||
|
||||
Controls whether loop is enabled globally or conditionally.
|
||||
|
||||
```php
|
||||
// Simple boolean enable/disable
|
||||
'moinframe.loop.enabled' => false, // Disables globally
|
||||
|
||||
// Use a callback for dynamic control
|
||||
'moinframe.loop.enabled' => function($page) {
|
||||
// Only enable for specific templates
|
||||
return in_array($page->template()->name(), ['article', 'blog']);
|
||||
},
|
||||
|
||||
// Filter by page status
|
||||
'moinframe.loop.enabled' => function($page) {
|
||||
return $page->status() === 'published';
|
||||
},
|
||||
|
||||
// Complex conditions
|
||||
'moinframe.loop.enabled' => function($page) {
|
||||
return $page->template()->name() === 'article'
|
||||
&& $page->status() === 'published'
|
||||
&& !$page->archived()->toBool();
|
||||
}
|
||||
```
|
||||
|
||||
**Callback function receives:**
|
||||
- `$page` - The current Kirby page object
|
||||
|
||||
**Common use cases:**
|
||||
- Disable feedback on specific page templates
|
||||
- Enable only for published content
|
||||
- Conditional enabling based on page fields or metadata
|
||||
|
||||
**Note**: This option is checked both during auto-injection and manual snippet usage.
|
||||
|
||||
### Auto-Injection
|
||||
|
||||
**Option**: `moinframe.loop.auto-inject`
|
||||
**Type**: `boolean`
|
||||
**Default**: `true`
|
||||
|
||||
Controls whether loop is automatically injected into all pages.
|
||||
|
||||
```php
|
||||
// Disable auto-injection (requires manual snippet placement)
|
||||
'moinframe.loop.auto-inject' => false,
|
||||
```
|
||||
|
||||
When disabled, you must manually add the snippet to your templates:
|
||||
|
||||
```php
|
||||
<?php snippet('loop/app') ?>
|
||||
```
|
||||
|
||||
**Use cases for disabling auto-injection:**
|
||||
- Custom page templates where you want precise control
|
||||
- JavaScript-based routing (Swup, Taxi.js) that needs manual initialization
|
||||
- Conditional loading based on user roles or page types
|
||||
|
||||
### Position
|
||||
|
||||
**Option**: `moinframe.loop.position`
|
||||
**Type**: `string`
|
||||
**Default**: `'top'`
|
||||
**Values**: `'top'` | `'bottom'`
|
||||
|
||||
Sets the position of loop header on the page.
|
||||
|
||||
```php
|
||||
// Position header at bottom of page
|
||||
'moinframe.loop.position' => 'bottom',
|
||||
```
|
||||
|
||||
### Database Path
|
||||
|
||||
**Option**: `moinframe.loop.database`
|
||||
**Type**: `string|null`
|
||||
**Default**: `null` (uses `site/logs/loop/comments.sqlite`)
|
||||
|
||||
Customize the SQLite database location.
|
||||
|
||||
```php
|
||||
// Custom database path
|
||||
'moinframe.loop.database' => '/custom/path/comments.sqlite',
|
||||
|
||||
// Alternative locations
|
||||
'moinframe.loop.database' => kirby()->root('content') . '/feedback.sqlite',
|
||||
'moinframe.loop.database' => '/var/www/data/feedback.sqlite',
|
||||
```
|
||||
|
||||
**Important considerations:**
|
||||
- Path must be absolute
|
||||
- Directory must exist and be writable
|
||||
- Consider backup strategies for custom locations
|
||||
- Ensure path is outside web root for security
|
||||
|
||||
### Public Access
|
||||
|
||||
**Option**: `moinframe.loop.public`
|
||||
**Type**: `boolean`
|
||||
**Default**: `false`
|
||||
|
||||
Controls whether loop requires authentication.
|
||||
|
||||
```php
|
||||
// Allow public access (no authentication required)
|
||||
'moinframe.loop.public' => true,
|
||||
```
|
||||
|
||||
**Security implications:**
|
||||
- `false` (default): Only authenticated panel users can see/use the tool
|
||||
- `true`: Anyone can add comments
|
||||
|
||||
**Recommended for public access:**
|
||||
- Internal staging environments
|
||||
- Client review sites with controlled access
|
||||
- Public beta feedback collection
|
||||
|
||||
### Language Override
|
||||
|
||||
**Option**: `moinframe.loop.language`
|
||||
**Type**: `string|null`
|
||||
**Default**: `null` (auto-detect from Kirby)
|
||||
|
||||
Force a specific UI language regardless of the current page language.
|
||||
|
||||
```php
|
||||
// Force German UI
|
||||
'moinframe.loop.language' => 'de',
|
||||
|
||||
// Force English UI
|
||||
'moinframe.loop.language' => 'en',
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Single-language sites with non-English content but English-speaking editors
|
||||
- Multi-language sites where editors prefer consistent UI language
|
||||
|
||||
### Theme
|
||||
|
||||
**Option**: `moinframe.loop.theme`
|
||||
**Type**: `string`
|
||||
**Default**: `'default'`
|
||||
**Values**: `'default'` | `'dark'` | custom theme name
|
||||
|
||||
Sets the visual theme for the loop interface.
|
||||
|
||||
```php
|
||||
// Use dark theme
|
||||
'moinframe.loop.theme' => 'dark',
|
||||
|
||||
// Use custom theme
|
||||
'moinframe.loop.theme' => 'custom',
|
||||
```
|
||||
|
||||
**Available themes:**
|
||||
- `'default'` - Light theme with clean, bright interface
|
||||
- `'dark'` - Dark theme for low-light environments
|
||||
- Custom theme names - See [Theming Guide](https://moinfra.me/docs/moinframe-loop/04-theming) for creating custom themes
|
||||
|
||||
### Welcome Dialog
|
||||
|
||||
The welcome dialog introduces new users to loop functionality.
|
||||
|
||||
#### Enable/Disable Welcome Dialog
|
||||
|
||||
**Option**: `moinframe.loop.welcome.enabled`
|
||||
**Type**: `boolean`
|
||||
**Default**: `true`
|
||||
|
||||
```php
|
||||
// Disable welcome dialog
|
||||
'moinframe.loop.welcome.enabled' => false,
|
||||
```
|
||||
|
||||
#### Custom Welcome Headline
|
||||
|
||||
**Option**: `moinframe.loop.welcome.headline`
|
||||
**Type**: `string|null`
|
||||
**Default**: `null` (uses default translation)
|
||||
|
||||
```php
|
||||
// Custom welcome headline
|
||||
'moinframe.loop.welcome.headline' => 'Welcome to Our Review Tool!',
|
||||
```
|
||||
|
||||
#### Custom Welcome Text
|
||||
|
||||
**Option**: `moinframe.loop.welcome.text`
|
||||
**Type**: `string|null`
|
||||
**Default**: `null` (uses default translation)
|
||||
|
||||
```php
|
||||
// Custom welcome message
|
||||
'moinframe.loop.welcome.text' => 'Click anywhere on the page to leave feedback. Use the toggle button to switch between navigation and comment modes.',
|
||||
```
|
||||
|
||||
## Manual Snippet Usage
|
||||
|
||||
When auto-injection is disabled, you have full control over when and where loop appears.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```php
|
||||
<?php snippet('loop/app') ?>
|
||||
```
|
||||
|
||||
### Conditional Loading
|
||||
|
||||
```php
|
||||
<?php if ($kirby->user() && $kirby->user()->role()->isAdmin()): ?>
|
||||
<?php snippet('loop/app') ?>
|
||||
<?php endif ?>
|
||||
```
|
||||
|
||||
> [!TIPP]
|
||||
> Manual snippets also respect the `enabled` configuration option. If you've set up conditional enabling via the `enabled` option, you don't need to duplicate that logic in your template - the snippet will automatically check the enabled status.
|
||||
|
||||
|
||||
## Caching Behavior
|
||||
|
||||
> [!WARNING]
|
||||
> Pages with loop automatically have Kirby's page **cache** **disabled**. This is necessary for CSRF token validation and User authentication checks.
|
||||
94
site/plugins/loop/docs/03-multi-language.md
Normal file
94
site/plugins/loop/docs/03-multi-language.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
title: Multi-Language
|
||||
---
|
||||
|
||||
Kirby Loop provides comprehensive support for multi-language Kirby sites, including automatic language detection and customizable UI translations.
|
||||
|
||||
## How Multi-Language Support Works
|
||||
|
||||
The plugin automatically detects and adapts to your Kirby site's language configuration. No additional configuration is required - the plugin works automatically with Kirby's multi-language setup.
|
||||
|
||||
- **Single-language sites**: Uses the en translations
|
||||
- **Multi-language sites**: Detects the current page language and adapts accordingly
|
||||
|
||||
|
||||
|
||||
## UI Language Override
|
||||
|
||||
### Forcing a Specific UI Language
|
||||
|
||||
By default, loop UI adapts to the current page language. You can override this behavior:
|
||||
|
||||
```php
|
||||
// Always show German UI regardless of page language
|
||||
'moinframe.loop.language' => 'de',
|
||||
|
||||
// Always show English UI regardless of page language
|
||||
'moinframe.loop.language' => 'en',
|
||||
```
|
||||
|
||||
### Use Cases for Language Override
|
||||
|
||||
**Consistent Editor Experience:**
|
||||
```php
|
||||
// Editors prefer English UI even on German pages
|
||||
'moinframe.loop.language' => 'en',
|
||||
```
|
||||
|
||||
**Single-Language website with non english content:**
|
||||
```php
|
||||
// German content site with German-speaking editors
|
||||
'moinframe.loop.language' => 'de',
|
||||
```
|
||||
|
||||
|
||||
## Built-in Translations
|
||||
|
||||
The plugin includes complete translations for:
|
||||
- English (en) - Default
|
||||
- German (de)
|
||||
|
||||
## Custom Translations
|
||||
|
||||
### Adding New Languages
|
||||
|
||||
To add support for additional languages, create or extend your Kirby language files:
|
||||
|
||||
```php
|
||||
// site/languages/fr.php
|
||||
<?php
|
||||
|
||||
return [
|
||||
'code' => 'fr',
|
||||
'default' => false,
|
||||
'direction' => 'ltr',
|
||||
'locale' => 'fr_FR',
|
||||
'name' => 'Français',
|
||||
'translations' => [
|
||||
// UI Elements
|
||||
'moinframe.loop.ui.header.title' => 'Commentaires',
|
||||
...
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
### Overriding Existing Translations
|
||||
|
||||
Customize existing translations by adding them to your language files:
|
||||
|
||||
```php
|
||||
// site/languages/en.php - Override English defaults
|
||||
return [
|
||||
'code' => 'en',
|
||||
'default' => true,
|
||||
'translations' => [
|
||||
'moinframe.loop.ui.header.title' => 'Page Feedback',
|
||||
'moinframe.loop.ui.comment.placeholder' => 'What needs attention?',
|
||||
'moinframe.loop.ui.welcome.headline' => 'Welcome to Our Review Tool',
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
### Translation Key Reference
|
||||
|
||||
For a complete list of available translation keys, see the [plugin's index file](https://github.com/moinframe/kirby-loop/blob/main/index.php).
|
||||
110
site/plugins/loop/docs/04-theming.md
Normal file
110
site/plugins/loop/docs/04-theming.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
---
|
||||
title: Theming
|
||||
---
|
||||
|
||||
Kirby Loop comes with built-in theming support, allowing you to customize the visual appearance to match your brand or provide different user experiences. The plugin includes a default (light) theme and a dark theme, with support for creating custom themes.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Setting a Theme
|
||||
|
||||
Configure the theme in your `site/config/config.php`:
|
||||
|
||||
```php
|
||||
return [
|
||||
// Set theme: 'default', 'dark', or custom theme name
|
||||
'moinframe.loop.theme' => 'dark',
|
||||
];
|
||||
```
|
||||
|
||||
**Available options:**
|
||||
- `'default'` - Light theme (default)
|
||||
- `'dark'` - Dark theme
|
||||
- Custom theme name
|
||||
|
||||
## Creating Custom Themes
|
||||
|
||||
Custom themes are CSS files that override the default color and styling tokens. The theming system uses CSS custom properties (variables) for easy customization.
|
||||
|
||||
|
||||
### Basic Custom Theme
|
||||
|
||||
Here's a minimal custom theme example:
|
||||
|
||||
```css
|
||||
/* frontend/src/styles/theme-custom.css */
|
||||
kirby-loop[theme="custom"] {
|
||||
/* Accent color */
|
||||
--color-accent-l: 0.6;
|
||||
--color-accent-c: 0.15;
|
||||
--color-accent-h: 280; /* Purple accent */
|
||||
|
||||
/* Neutral color lightness values */
|
||||
--color-neutral-l-0: 0.98;
|
||||
--color-neutral-l-100: 0.92;
|
||||
--color-neutral-l-200: 0.86;
|
||||
--color-neutral-l-300: 0.7;
|
||||
--color-neutral-l-400: 0.6;
|
||||
--color-neutral-l-500: 0.5;
|
||||
--color-neutral-l-600: 0.4;
|
||||
--color-neutral-l-700: 0.3;
|
||||
--color-neutral-l-800: 0.15;
|
||||
--color-neutral-l-900: 0.05;
|
||||
--color-neutral-l-1000: 0;
|
||||
}
|
||||
```
|
||||
|
||||
### Configure Your Custom Theme
|
||||
|
||||
Set your custom theme in the configuration:
|
||||
|
||||
```php
|
||||
// site/config/config.php
|
||||
return [
|
||||
'moinframe.loop.theme' => 'custom',
|
||||
];
|
||||
```
|
||||
|
||||
## Theme Architecture
|
||||
|
||||
### Color System
|
||||
|
||||
The theming system uses OKLCH color space for consistent, perceptually uniform colors:
|
||||
|
||||
```css
|
||||
/* Accent colors */
|
||||
--color-accent-l: 0.7; /* Lightness (0-1) */
|
||||
--color-accent-c: 0.12; /* Chroma/saturation (0-0.4) */
|
||||
--color-accent-h: 220; /* Hue (0-360) */
|
||||
|
||||
/* Neutral colors */
|
||||
--color-neutral-l-0: 1; /* Lightest */
|
||||
--color-neutral-l-100: 0.95;
|
||||
--color-neutral-l-200: 0.9;
|
||||
/* ... */
|
||||
--color-neutral-l-900: 0.05;
|
||||
--color-neutral-l-1000: 0; /* Darkest */
|
||||
```
|
||||
|
||||
### Advanced Customization
|
||||
|
||||
You can override any design token in your custom theme:
|
||||
|
||||
```css
|
||||
kirby-loop[theme="custom"] {
|
||||
/* Colors */
|
||||
--color-accent-l: 0.65;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-h: 15; /* Orange accent */
|
||||
|
||||
/* Shadows with custom opacity */
|
||||
--shadow-s: 0 0.1em 0.25em oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.15);
|
||||
|
||||
/* Custom border radius */
|
||||
--border-radius: 0.5rem;
|
||||
--border-radius-rounded: 1rem;
|
||||
|
||||
/* Custom fonts */
|
||||
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
```
|
||||
325
site/plugins/loop/docs/05-api.md
Normal file
325
site/plugins/loop/docs/05-api.md
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
---
|
||||
title: API Reference
|
||||
---
|
||||
|
||||
Kirby Loop provides a RESTful API for managing comments and feedback. All endpoints include CSRF protection.
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints require authentication, controlled by the `moinframe.loop.public` configuration option:
|
||||
|
||||
- **Default (private)**: Only authenticated Kirby users can access the API
|
||||
- **Public mode**: Anyone can access the API
|
||||
|
||||
## CSRF Protection
|
||||
|
||||
All API requests must include a valid CSRF token in the request header:
|
||||
|
||||
```javascript
|
||||
fetch('/loop/comments/page-id', {
|
||||
headers: {
|
||||
'X-CSRF-Token': '<csrf-token>'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Base URL Structure
|
||||
|
||||
### Single Language Sites
|
||||
```
|
||||
/loop/comments/{pageId}
|
||||
/loop/comment/new
|
||||
/loop/comment/reply
|
||||
/loop/comment/resolve
|
||||
/loop/comment/unresolve
|
||||
/loop/guest/name
|
||||
```
|
||||
|
||||
### Multi-Language Sites
|
||||
```
|
||||
/{language}/loop/comments/{pageId}
|
||||
/{language}/loop/comment/new
|
||||
/{language}/loop/comment/reply
|
||||
/{language}/loop/comment/resolve
|
||||
/{language}/loop/comment/unresolve
|
||||
/{language}/loop/guest/name
|
||||
```
|
||||
|
||||
Where `{language}` is the language code (e.g., `en`, `de`).
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /loop/comments/{pageId}
|
||||
|
||||
Retrieve all comments for a specific page.
|
||||
|
||||
**Parameters:**
|
||||
- `pageId` (string): The page ID or 'home' for the homepage
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"comments": [
|
||||
{
|
||||
"id": 1,
|
||||
"author": "John Doe",
|
||||
"url": "https://example.com/page",
|
||||
"page": "page-uuid",
|
||||
"comment": "This needs to be updated",
|
||||
"selector": ".header h1",
|
||||
"selectorOffsetX": 10,
|
||||
"selectorOffsetY": 20,
|
||||
"pagePositionX": 150,
|
||||
"pagePositionY": 300,
|
||||
"timestamp": 1640995200,
|
||||
"lang": "en",
|
||||
"status": "OPEN",
|
||||
"replies": [
|
||||
{
|
||||
"id": 1,
|
||||
"author": "jane.smith",
|
||||
"comment": "I'll fix this",
|
||||
"parentId": 1,
|
||||
"timestamp": 1640995800
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400`: Page not found
|
||||
- `401`: Unauthorized (if authentication required)
|
||||
- `403`: CSRF token invalid
|
||||
|
||||
### POST /loop/comment/new
|
||||
|
||||
Create a new comment on a page.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"comment": "This section needs clarification",
|
||||
"url": "https://example.com/page",
|
||||
"selector": ".content p:nth-child(3)",
|
||||
"selectorOffsetX": 15,
|
||||
"selectorOffsetY": 25,
|
||||
"pagePositionX": 200,
|
||||
"pagePositionY": 450,
|
||||
"pageId": "projects/project-alpha"
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `comment` (string): The comment text (HTML stripped and sanitized)
|
||||
- `url` (string): The full URL where the comment was made
|
||||
- `selector` (string): CSS selector for the commented element
|
||||
- `selectorOffsetX` (number): X offset within the selected element
|
||||
- `selectorOffsetY` (number): Y offset within the selected element
|
||||
- `pagePositionX` (number): X position on the page
|
||||
- `pagePositionY` (number): Y position on the page
|
||||
- `pageId` (string): Kirby page ID or 'home'
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"comment": {
|
||||
"id": 15,
|
||||
"author": "John Doe",
|
||||
"url": "https://example.com/page",
|
||||
"page": "page-uuid",
|
||||
"comment": "This section needs clarification",
|
||||
"selector": ".content p:nth-child(3)",
|
||||
"selectorOffsetX": 15,
|
||||
"selectorOffsetY": 25,
|
||||
"pagePositionX": 200,
|
||||
"pagePositionY": 450,
|
||||
"timestamp": 1640995200,
|
||||
"lang": "en",
|
||||
"status": "OPEN",
|
||||
"replies": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400`: Missing required fields, invalid selector format, or invalid data
|
||||
- `401`: Unauthorized
|
||||
- `403`: CSRF token invalid or disabled
|
||||
- `404`: Page not found
|
||||
|
||||
### POST /loop/comment/reply
|
||||
|
||||
Add a reply to an existing comment.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"comment": "I'll handle this update",
|
||||
"parentId": 15
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `comment` (string): The reply text (HTML stripped and sanitized)
|
||||
- `parentId` (number): ID of the parent comment
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"reply": {
|
||||
"id": 3,
|
||||
"author": "John Doe",
|
||||
"comment": "I'll handle this update",
|
||||
"parentId": 15,
|
||||
"timestamp": 1640995800
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400`: Missing required fields
|
||||
- `401`: Unauthorized
|
||||
- `403`: CSRF token invalid or disabled
|
||||
|
||||
### POST /loop/comment/resolve
|
||||
|
||||
Mark a comment as resolved.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"id": 15
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `id` (number): The comment ID to resolve
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400`: Missing comment ID
|
||||
- `401`: Unauthorized
|
||||
- `403`: CSRF token invalid or disabled
|
||||
|
||||
### POST /loop/comment/unresolve
|
||||
|
||||
Mark a resolved comment as unresolved.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"id": 15
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `id` (number): The comment ID to unresolve
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400`: Missing comment ID
|
||||
- `401`: Unauthorized
|
||||
- `403`: CSRF token invalid or disabled
|
||||
|
||||
### POST /loop/guest/name
|
||||
|
||||
Set a guest name for non-authenticated users (when public mode is enabled).
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "John Doe"
|
||||
}
|
||||
```
|
||||
|
||||
**Required Fields:**
|
||||
- `name` (string): The guest user's name
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"name": "John Doe"
|
||||
}
|
||||
```
|
||||
|
||||
**Error Responses:**
|
||||
- `400`: Missing or empty name
|
||||
- `401`: Unauthorized
|
||||
- `403`: CSRF token invalid or disabled
|
||||
|
||||
## Data Models
|
||||
|
||||
### Comment Object
|
||||
|
||||
```typescript
|
||||
interface Comment {
|
||||
id: number;
|
||||
author: string; // Resolved display name (user name, email prefix, or guest name)
|
||||
url: string; // Full URL where comment was made
|
||||
page: string; // Page UUID
|
||||
comment: string; // Sanitized comment text
|
||||
selector: string; // CSS selector for target element
|
||||
selectorOffsetX: number; // X offset within element (float)
|
||||
selectorOffsetY: number; // Y offset within element (float)
|
||||
pagePositionX: number; // X position on page (float)
|
||||
pagePositionY: number; // Y position on page (float)
|
||||
timestamp: number; // Unix timestamp
|
||||
lang: string; // Language code
|
||||
status: string; // Status: OPEN, RESOLVED
|
||||
replies: Reply[]; // Array of replies
|
||||
}
|
||||
```
|
||||
|
||||
### Reply Object
|
||||
|
||||
```typescript
|
||||
interface Reply {
|
||||
id: number;
|
||||
author: string; // Resolved display name (user name, email prefix, or guest name)
|
||||
comment: string; // Sanitized reply text
|
||||
parentId: number; // Parent comment ID
|
||||
timestamp: number; // Unix timestamp
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The api endpoints return consistent error responses. For more details, switch on the debug mode in your Kirby Installation.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Human-readable error message",
|
||||
"code": "ERROR_CODE" // Optional error code
|
||||
}
|
||||
```
|
||||
|
||||
### Common Error Codes
|
||||
|
||||
- `CSRF_INVALID`: CSRF token is missing or invalid
|
||||
- `PAGE_NOT_FOUND`: Specified page doesn't exist
|
||||
- `FIELD_REQUIRED`: Required field is missing
|
||||
- `UNAUTHORIZED`: Authentication required but not provided
|
||||
- `INVALID_SELECTOR`: Invalid selector format
|
||||
- `INVALID_NAME`: Invalid guest name
|
||||
- `DISABLED`: Tool is disabled
|
||||
3
site/plugins/loop/frontend/.vscode/extensions.json
vendored
Normal file
3
site/plugins/loop/frontend/.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
27
site/plugins/loop/frontend/package.json
Normal file
27
site/plugins/loop/frontend/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "loop-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tsconfig/svelte": "^5.0.4",
|
||||
"@types/node": "^22.13.10",
|
||||
"browserslist": "^4.24.4",
|
||||
"lightningcss": "^1.29.3",
|
||||
"svelte": "^5.20.2",
|
||||
"svelte-check": "^4.1.4",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
1505
site/plugins/loop/frontend/pnpm-lock.yaml
generated
Normal file
1505
site/plugins/loop/frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
216
site/plugins/loop/frontend/src/App.svelte
Normal file
216
site/plugins/loop/frontend/src/App.svelte
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<svelte:options customElement="kirby-loop" />
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Header from "./lib/Header.svelte";
|
||||
import Marker from "./lib/Marker.svelte";
|
||||
import Panel from "./lib/Panel.svelte";
|
||||
import store, { addReply, getComments } from "./store/api.svelte";
|
||||
import setNewMarker from "./composables/setNewMarker";
|
||||
import { addComment } from "./store/api.svelte";
|
||||
import CommentDialog from "./lib/CommentDialog.svelte";
|
||||
import WelcomeDialog from "./lib/WelcomeDialog.svelte";
|
||||
import { formData, reset } from "./store/form.svelte";
|
||||
import { overlay, guestName } from "./store/ui.svelte";
|
||||
import { setTranslations } from "./store/translations.svelte";
|
||||
import type {
|
||||
LoopProps,
|
||||
ReplyPayload,
|
||||
CommentPayload,
|
||||
MarkerPosition,
|
||||
} from "./types";
|
||||
|
||||
const {
|
||||
position,
|
||||
language,
|
||||
apibase,
|
||||
pageId,
|
||||
authenticated,
|
||||
"welcome-enabled": welcomeEnabled,
|
||||
"welcome-headline": welcomeHeadline,
|
||||
"welcome-text": welcomeText,
|
||||
translations,
|
||||
}: LoopProps = $props();
|
||||
|
||||
let showLoop = $state(false);
|
||||
|
||||
// Feedback Dialog
|
||||
let showModal = $state(false);
|
||||
let welcomeDialog: { showModal: () => void; close: () => void };
|
||||
let isAuthenticated = $derived(authenticated === "true");
|
||||
let isWelcomeEnabled = $derived(welcomeEnabled === "true");
|
||||
|
||||
// Filter comments to show only non-resolved ones for markers
|
||||
const visibleComments = $derived(
|
||||
store.comments.filter((c) => c.status !== "RESOLVED"),
|
||||
);
|
||||
|
||||
// Session storage key for tracking welcome dialog dismissal (global)
|
||||
const welcomeDismissedKey = "loop-welcome-dismissed";
|
||||
|
||||
// Check if welcome was dismissed for authenticated users
|
||||
const isWelcomeDismissed = () => {
|
||||
if (!isAuthenticated) return false;
|
||||
return sessionStorage.getItem(welcomeDismissedKey) === "true";
|
||||
};
|
||||
|
||||
// Mark welcome as dismissed for authenticated users
|
||||
const markWelcomeDismissed = () => {
|
||||
if (isAuthenticated) {
|
||||
sessionStorage.setItem(welcomeDismissedKey, "true");
|
||||
}
|
||||
};
|
||||
// Default state for markers
|
||||
let newMarker: MarkerPosition | null = $state(null);
|
||||
|
||||
/**
|
||||
* Scroll a marker into view
|
||||
* @param id The id of the marker
|
||||
*/
|
||||
const scrollIntoView = (id: string) => {
|
||||
const marker = $host().shadowRoot?.getElementById(`marker-${id}`);
|
||||
if (marker) marker.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Click to add a new comment
|
||||
* @param e The click event
|
||||
*/
|
||||
const clickToComment = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const clickedOnLoop =
|
||||
target.nodeName === "KIRBY-LOOP" || target.parentElement?.closest("loop");
|
||||
|
||||
// Do nothing if feedback mode is off or the click is on loop elements
|
||||
if (!overlay.open || clickedOnLoop) return;
|
||||
|
||||
// For non-authenticated users, require a guest name before allowing comments
|
||||
if (!isAuthenticated && !guestName.get()) {
|
||||
welcomeDialog?.showModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get new marker
|
||||
const marker = setNewMarker(e);
|
||||
if (!marker) return;
|
||||
newMarker = marker;
|
||||
// Open comment form dialog
|
||||
showModal = true;
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
showModal = false;
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleSubmit = (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// For non-authenticated users, require a guest name before allowing comments or replies
|
||||
if (!isAuthenticated && !guestName.get()) {
|
||||
welcomeDialog?.showModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const { text, parentId } = formData;
|
||||
// submit is a reply
|
||||
if (parentId) {
|
||||
const reply: ReplyPayload = {
|
||||
parentId,
|
||||
comment: text,
|
||||
};
|
||||
// add reply to api
|
||||
addReply(reply);
|
||||
// reset form data
|
||||
reset();
|
||||
// submit is a comment
|
||||
} else {
|
||||
if (!newMarker) return;
|
||||
|
||||
// Use language from component attribute
|
||||
const lang = language || "";
|
||||
|
||||
const comment: CommentPayload = {
|
||||
url: window.location.href,
|
||||
comment: text,
|
||||
parentId: null,
|
||||
lang,
|
||||
pageId,
|
||||
...newMarker,
|
||||
};
|
||||
|
||||
// close modal
|
||||
showModal = false;
|
||||
// add comment to api
|
||||
addComment(comment);
|
||||
// reset form data
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize translations
|
||||
const translationsData = JSON.parse(translations || "{}");
|
||||
setTranslations(translationsData);
|
||||
|
||||
showLoop = await getComments(pageId);
|
||||
|
||||
// Initialize guest name from session storage
|
||||
guestName.get();
|
||||
|
||||
// Show welcome dialog on page load if enabled and conditions are met
|
||||
if (isWelcomeEnabled && showLoop) {
|
||||
// For authenticated users, show only if not dismissed
|
||||
// For unauthenticated users, show if no guest name is set (mandatory)
|
||||
if (
|
||||
(isAuthenticated && !isWelcomeDismissed()) ||
|
||||
(!isAuthenticated && !guestName.get())
|
||||
) {
|
||||
welcomeDialog?.showModal();
|
||||
}
|
||||
}
|
||||
// Even if welcome is disabled, show dialog for non-authenticated users without a name
|
||||
else if (!isAuthenticated && !guestName.get() && showLoop) {
|
||||
welcomeDialog?.showModal();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (overlay.open) {
|
||||
document.body.style.setProperty(
|
||||
"cursor",
|
||||
`url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23Z' stroke='white' stroke-width='0.75'/%3E%3Cpath d='M15 12H12M12 12H9M12 12V9M12 12V15' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3Cpath d='M15 12H12M12 12H9M12 12V9M12 12V15' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"), auto`,
|
||||
);
|
||||
} else {
|
||||
document.body.style.removeProperty("cursor");
|
||||
}
|
||||
|
||||
document.documentElement.classList.toggle(
|
||||
"loop-overlay-open",
|
||||
overlay.open,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document on:click={clickToComment} />
|
||||
|
||||
{#if showLoop}
|
||||
<Header {position} commentsCount={visibleComments.length} />
|
||||
|
||||
<Panel {scrollIntoView} {handleSubmit} {cancel} />
|
||||
|
||||
{#each visibleComments as comment (comment.id)}
|
||||
<Marker {comment} />
|
||||
{/each}
|
||||
|
||||
<CommentDialog {handleSubmit} {showModal} {newMarker} {cancel} />
|
||||
{/if}
|
||||
|
||||
<WelcomeDialog
|
||||
bind:this={welcomeDialog}
|
||||
headline={welcomeHeadline || ""}
|
||||
text={welcomeText || ""}
|
||||
authenticated={isAuthenticated}
|
||||
welcomeEnabled={isWelcomeEnabled}
|
||||
onDismiss={markWelcomeDismissed}
|
||||
/>
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* Decodes HTML entities in a string
|
||||
* @param text The text that may contain HTML entities
|
||||
* @returns The decoded text
|
||||
*/
|
||||
export function decodeHTMLEntities(text: string): string {
|
||||
const entityMap: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
'/': '/',
|
||||
'`': '`',
|
||||
'=': '='
|
||||
};
|
||||
|
||||
return text.replace(/&[#\w]+;/g, (entity) => entityMap[entity] || entity);
|
||||
}
|
||||
31
site/plugins/loop/frontend/src/composables/formatDate.ts
Normal file
31
site/plugins/loop/frontend/src/composables/formatDate.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { t, tt } from "../store/translations.svelte";
|
||||
|
||||
export function formatDate(timestamp: number, humanize = true): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diffInMs = now.getTime() - date.getTime();
|
||||
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
|
||||
const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
|
||||
const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Show relative time for up to 3 days
|
||||
if (humanize && diffInDays <= 3) {
|
||||
if (diffInMinutes < 1) {
|
||||
return t("ui.time.just_now", "just now");
|
||||
} else if (diffInMinutes === 1) {
|
||||
return t("ui.time.minute_ago", "a minute ago");
|
||||
} else if (diffInMinutes < 60) {
|
||||
return tt("ui.time.minutes_ago", "{count} minutes ago", { count: diffInMinutes.toString() });
|
||||
} else if (diffInHours === 1) {
|
||||
return t("ui.time.hour_ago", "an hour ago");
|
||||
} else if (diffInHours < 24) {
|
||||
return tt("ui.time.hours_ago", "{count} hours ago", { count: diffInHours.toString() });
|
||||
} else if (diffInDays === 1) {
|
||||
return t("ui.time.yesterday", "yesterday");
|
||||
} else {
|
||||
return tt("ui.time.days_ago", "{count} days ago", { count: diffInDays.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
return date.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function formatDateISO(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toISOString();
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { getDocumentHeight } from "./getDocumentHeight";
|
||||
import { getDocumentWidth } from "./getDocumentWidth";
|
||||
|
||||
export const getDialogPosition = (marker: { pagePositionX: number, pagePositionY: number } | null,
|
||||
dialogElement: HTMLDialogElement | null): { left: number, top: number } => {
|
||||
// Default position (fallback)
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
if (!marker || !dialogElement) return { left, top };
|
||||
|
||||
// Get marker position
|
||||
left = marker.pagePositionX;
|
||||
top = marker.pagePositionY;
|
||||
|
||||
// Get dialog dimensions
|
||||
const dialogWidth = dialogElement.offsetWidth;
|
||||
const dialogHeight = dialogElement.offsetHeight;
|
||||
|
||||
// Get document dimensions
|
||||
const documentWidth = getDocumentWidth();
|
||||
const documentHeight = getDocumentHeight();
|
||||
|
||||
// Ensure dialog doesn't go off-screen to the right
|
||||
if (left + dialogWidth > documentWidth) {
|
||||
left = documentWidth - dialogWidth;
|
||||
}
|
||||
|
||||
// Ensure dialog doesn't go off-screen to the bottom
|
||||
if (top + dialogHeight > documentHeight) {
|
||||
top = documentHeight - dialogHeight;
|
||||
}
|
||||
|
||||
// Ensure dialog doesn't go off-screen to the left or top
|
||||
left = Math.max(0, left);
|
||||
top = Math.max(0, top);
|
||||
|
||||
return { left, top };
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Get the entire document height, including scrollable area
|
||||
export const getDocumentHeight = (): number => {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
return Math.max(
|
||||
body.scrollHeight,
|
||||
body.offsetHeight,
|
||||
html.clientHeight,
|
||||
html.scrollHeight,
|
||||
html.offsetHeight
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Get the entire document width, including scrollable area
|
||||
export const getDocumentWidth = (): number => {
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
return Math.max(
|
||||
body.scrollWidth,
|
||||
body.offsetWidth,
|
||||
html.clientWidth,
|
||||
html.scrollWidth,
|
||||
html.offsetWidth
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
export const getSelectorOffset = (e: MouseEvent, element: HTMLElement): { selectorOffsetX: number, selectorOffsetY: number } => {
|
||||
// Get absolute click position (relative to the document)
|
||||
const clickX = e.pageX;
|
||||
const clickY = e.pageY;
|
||||
|
||||
// Get element's position relative to the document
|
||||
const rect = element.getBoundingClientRect();
|
||||
const elementX = rect.left + window.scrollX;
|
||||
const elementY = rect.top + window.scrollY;
|
||||
|
||||
// Calculate relative offsets
|
||||
const offsetXRel = clickX - elementX;
|
||||
const offsetYRel = clickY - elementY;
|
||||
|
||||
// Convert to percentages
|
||||
let offsetX = (offsetXRel / element.offsetWidth) * 100;
|
||||
let offsetY = (offsetYRel / element.offsetHeight) * 100;
|
||||
|
||||
// Round to 2 decimal places
|
||||
offsetX = Number(offsetX.toFixed(2));
|
||||
offsetY = Number(offsetY.toFixed(2));
|
||||
|
||||
return {
|
||||
selectorOffsetX: offsetX, selectorOffsetY: offsetY
|
||||
};
|
||||
}
|
||||
32
site/plugins/loop/frontend/src/composables/setNewMarker.ts
Normal file
32
site/plugins/loop/frontend/src/composables/setNewMarker.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useGenerateSelector } from "./useGenerateSelector";
|
||||
import { getSelectorOffset } from "./getSelectorOffset";
|
||||
|
||||
export const setNewMarker = (e: MouseEvent) => {
|
||||
|
||||
const selector = useGenerateSelector(e);
|
||||
const element: HTMLElement | null = document.querySelector(selector);
|
||||
|
||||
// error out, if no selector found
|
||||
if (!element) return;
|
||||
|
||||
const { selectorOffsetX, selectorOffsetY } = getSelectorOffset(e, element);
|
||||
|
||||
// Store absolute position on the page
|
||||
let pagePositionX = e.pageX;
|
||||
let pagePositionY = e.pageY;
|
||||
|
||||
// Round to 2 digits
|
||||
pagePositionX = Number(pagePositionX.toFixed(2));
|
||||
pagePositionY = Number(pagePositionY.toFixed(2));
|
||||
|
||||
return {
|
||||
selector,
|
||||
selectorOffsetX,
|
||||
selectorOffsetY,
|
||||
pagePositionX,
|
||||
pagePositionY
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default setNewMarker;
|
||||
|
|
@ -0,0 +1,352 @@
|
|||
/**
|
||||
* CSS Selector Generator Composable
|
||||
* Generates reliable, unique CSS selectors for clicked DOM elements
|
||||
*/
|
||||
|
||||
type SelectorStrategy = {
|
||||
name: string;
|
||||
generator: (element: Element) => string | null;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main composable function to generate CSS selector from click event
|
||||
* @param event - Mouse event from user click
|
||||
* @returns CSS selector string that uniquely identifies the clicked element
|
||||
*/
|
||||
export function useGenerateSelector(event: MouseEvent): string {
|
||||
const target = event.target as Element;
|
||||
|
||||
if (!target) {
|
||||
throw new Error('No target element found in event');
|
||||
}
|
||||
|
||||
// Try each strategy in priority order
|
||||
const strategies = getSelectorStrategies();
|
||||
|
||||
for (const strategy of strategies) {
|
||||
try {
|
||||
const selector = strategy.generator(target);
|
||||
|
||||
if (selector && validateSelector(selector, target)) {
|
||||
return selector;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Strategy ${strategy.name} failed:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Ultimate fallback - generate a path selector
|
||||
return generatePathSelector(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define selector generation strategies in priority order
|
||||
*/
|
||||
function getSelectorStrategies(): SelectorStrategy[] {
|
||||
return [
|
||||
{
|
||||
name: 'ID',
|
||||
priority: 1,
|
||||
generator: (element: Element) => {
|
||||
if (element.id && isValidId(element.id)) {
|
||||
return `#${CSS.escape(element.id)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Unique Attributes',
|
||||
priority: 2,
|
||||
generator: (element: Element) => {
|
||||
const uniqueAttrs = ['data-testid', 'data-id', 'name', 'for'];
|
||||
|
||||
for (const attr of uniqueAttrs) {
|
||||
const value = element.getAttribute(attr);
|
||||
if (value) {
|
||||
const selector = `${element.tagName.toLowerCase()}[${attr}="${CSS.escape(value)}"]`;
|
||||
if (isUniqueSelector(selector)) {
|
||||
return selector;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Semantic Attributes',
|
||||
priority: 3,
|
||||
generator: (element: Element) => {
|
||||
const semanticAttrs = [
|
||||
'aria-label',
|
||||
'aria-labelledby',
|
||||
'role',
|
||||
'type',
|
||||
'placeholder',
|
||||
'title',
|
||||
'alt'
|
||||
];
|
||||
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const selectors: string[] = [tagName];
|
||||
|
||||
for (const attr of semanticAttrs) {
|
||||
const value = element.getAttribute(attr);
|
||||
if (value) {
|
||||
selectors.push(`[${attr}="${CSS.escape(value)}"]`);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectors.length > 1) {
|
||||
const selector = selectors.join('');
|
||||
if (isUniqueSelector(selector)) {
|
||||
return selector;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Structural Attributes',
|
||||
priority: 4,
|
||||
generator: (element: Element) => {
|
||||
const structuralAttrs = ['href', 'src', 'action', 'value'];
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
for (const attr of structuralAttrs) {
|
||||
const value = element.getAttribute(attr);
|
||||
if (value && value.length > 0) {
|
||||
const selector = `${tagName}[${attr}="${CSS.escape(value)}"]`;
|
||||
if (isUniqueSelector(selector)) {
|
||||
return selector;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Class Combinations',
|
||||
priority: 5,
|
||||
generator: (element: Element) => {
|
||||
const classes = getStableClasses(element);
|
||||
|
||||
if (classes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
// Try single class first
|
||||
for (const className of classes) {
|
||||
const selector = `${tagName}.${CSS.escape(className)}`;
|
||||
if (isUniqueSelector(selector)) {
|
||||
return selector;
|
||||
}
|
||||
}
|
||||
|
||||
// Try combinations of classes
|
||||
if (classes.length >= 2) {
|
||||
const classSelector = classes.slice(0, 3).map(c => `.${CSS.escape(c)}`).join('');
|
||||
const selector = `${tagName}${classSelector}`;
|
||||
if (isUniqueSelector(selector)) {
|
||||
return selector;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Parent Context',
|
||||
priority: 6,
|
||||
generator: (element: Element) => {
|
||||
const parent = element.parentElement;
|
||||
if (!parent) return null;
|
||||
|
||||
// Try to get a unique selector for parent
|
||||
const parentSelector = getSimpleSelector(parent);
|
||||
if (!parentSelector) return null;
|
||||
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const siblingIndex = getSiblingIndex(element);
|
||||
|
||||
if (siblingIndex > 0) {
|
||||
const selector = `${parentSelector} > ${tagName}:nth-of-type(${siblingIndex})`;
|
||||
if (isUniqueSelector(selector)) {
|
||||
return selector;
|
||||
}
|
||||
}
|
||||
|
||||
// Try with classes
|
||||
const classes = getStableClasses(element);
|
||||
if (classes.length > 0) {
|
||||
const selector = `${parentSelector} > ${tagName}.${CSS.escape(classes[0])}`;
|
||||
if (isUniqueSelector(selector)) {
|
||||
return selector;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stable classes (excluding utility/state classes)
|
||||
*/
|
||||
function getStableClasses(element: Element): string[] {
|
||||
const classes = Array.from(element.classList);
|
||||
|
||||
// Filter out common utility/state classes
|
||||
const unstablePatterns = [
|
||||
/^(is-|has-|js-)/, // State prefixes
|
||||
/^(active|disabled|loading|selected|hover|focus)/, // State classes
|
||||
/^[a-z]+-[0-9]+$/, // Generated classes like 'item-123'
|
||||
/^(sm-|md-|lg-|xl-)/, // Responsive utilities
|
||||
/^(m-|p-|w-|h-|text-|bg-)/, // Tailwind-like utilities
|
||||
/^[a-f0-9]{6,}$/, // Hash-like classes
|
||||
];
|
||||
|
||||
return classes.filter(className => {
|
||||
return !unstablePatterns.some(pattern => pattern.test(className));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a path-based selector as fallback
|
||||
*/
|
||||
function generatePathSelector(element: Element): string {
|
||||
const path: string[] = [];
|
||||
let current: Element | null = element;
|
||||
|
||||
while (current && current !== document.body && path.length < 5) {
|
||||
const selector = getElementSelector(current);
|
||||
path.unshift(selector);
|
||||
|
||||
// Check if this partial path is unique
|
||||
const partialSelector = path.join(' > ');
|
||||
if (isUniqueSelector(partialSelector)) {
|
||||
return partialSelector;
|
||||
}
|
||||
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a simple selector for an element
|
||||
*/
|
||||
function getSimpleSelector(element: Element): string | null {
|
||||
// Try ID first
|
||||
if (element.id && isValidId(element.id)) {
|
||||
return `#${CSS.escape(element.id)}`;
|
||||
}
|
||||
|
||||
// Try unique attributes
|
||||
const uniqueAttrs = ['data-testid', 'data-id', 'name'];
|
||||
for (const attr of uniqueAttrs) {
|
||||
const value = element.getAttribute(attr);
|
||||
if (value) {
|
||||
return `[${attr}="${CSS.escape(value)}"]`;
|
||||
}
|
||||
}
|
||||
|
||||
// Try tag + first stable class
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const classes = getStableClasses(element);
|
||||
|
||||
if (classes.length > 0) {
|
||||
return `${tagName}.${CSS.escape(classes[0])}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selector for element in path
|
||||
*/
|
||||
function getElementSelector(element: Element): string {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
// Use ID if available
|
||||
if (element.id && isValidId(element.id)) {
|
||||
return `#${CSS.escape(element.id)}`;
|
||||
}
|
||||
|
||||
// Use classes if available
|
||||
const classes = getStableClasses(element);
|
||||
if (classes.length > 0) {
|
||||
return `${tagName}.${CSS.escape(classes[0])}`;
|
||||
}
|
||||
|
||||
// Use nth-of-type for siblings
|
||||
const index = getSiblingIndex(element);
|
||||
if (index > 1) {
|
||||
return `${tagName}:nth-of-type(${index})`;
|
||||
}
|
||||
|
||||
return tagName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sibling index for nth-of-type
|
||||
*/
|
||||
function getSiblingIndex(element: Element): number {
|
||||
let index = 1;
|
||||
let sibling = element.previousElementSibling;
|
||||
|
||||
while (sibling) {
|
||||
if (sibling.tagName === element.tagName) {
|
||||
index++;
|
||||
}
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a selector uniquely identifies the target element
|
||||
*/
|
||||
function validateSelector(selector: string, target: Element): boolean {
|
||||
try {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
return matches.length === 1 && matches[0] === target;
|
||||
} catch (error) {
|
||||
console.warn(`Invalid selector: ${selector}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a selector matches exactly one element
|
||||
*/
|
||||
function isUniqueSelector(selector: string): boolean {
|
||||
try {
|
||||
const matches = document.querySelectorAll(selector);
|
||||
return matches.length === 1;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ID is valid (not auto-generated)
|
||||
*/
|
||||
function isValidId(id: string): boolean {
|
||||
// Skip IDs that look auto-generated
|
||||
const invalidPatterns = [
|
||||
/^[a-f0-9]{8,}$/, // Hex strings
|
||||
/^(ember|react|vue)[0-9]+/, // Framework generated
|
||||
/^[0-9]+$/, // Pure numbers
|
||||
/^temp-/, // Temporary prefixes
|
||||
];
|
||||
|
||||
return !invalidPatterns.some(pattern => pattern.test(id));
|
||||
}
|
||||
130
site/plugins/loop/frontend/src/composables/useResizeHandler.ts
Normal file
130
site/plugins/loop/frontend/src/composables/useResizeHandler.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Performance-optimized resize handler with debouncing and RAF
|
||||
*/
|
||||
|
||||
interface ResizeCallback {
|
||||
(): void;
|
||||
}
|
||||
|
||||
interface ResizeHandlerOptions {
|
||||
/** Debounce delay in milliseconds (default: 100) */
|
||||
debounceDelay?: number;
|
||||
/** Whether to use requestAnimationFrame (default: true) */
|
||||
useRAF?: boolean;
|
||||
}
|
||||
|
||||
class ResizeHandler {
|
||||
private callbacks = new Set<ResizeCallback>();
|
||||
private debounceTimer: number | null = null;
|
||||
private rafId: number | null = null;
|
||||
private isListening = false;
|
||||
private options: Required<ResizeHandlerOptions>;
|
||||
|
||||
constructor(options: ResizeHandlerOptions = {}) {
|
||||
this.options = {
|
||||
debounceDelay: options.debounceDelay ?? 100,
|
||||
useRAF: options.useRAF ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
private handleResize = () => {
|
||||
// Clear existing timers
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
|
||||
// Debounce the resize event
|
||||
this.debounceTimer = window.setTimeout(() => {
|
||||
if (this.options.useRAF) {
|
||||
// Use RAF for smooth updates
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.executeCallbacks();
|
||||
});
|
||||
} else {
|
||||
this.executeCallbacks();
|
||||
}
|
||||
}, this.options.debounceDelay);
|
||||
};
|
||||
|
||||
private executeCallbacks() {
|
||||
this.callbacks.forEach(callback => {
|
||||
try {
|
||||
callback();
|
||||
} catch (error) {
|
||||
console.error('Error in resize callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startListening() {
|
||||
if (!this.isListening) {
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
this.isListening = true;
|
||||
}
|
||||
}
|
||||
|
||||
private stopListening() {
|
||||
if (this.isListening) {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
this.isListening = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a callback to be executed on resize
|
||||
*/
|
||||
subscribe(callback: ResizeCallback): () => void {
|
||||
this.callbacks.add(callback);
|
||||
this.startListening();
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.callbacks.delete(callback);
|
||||
if (this.callbacks.size === 0) {
|
||||
this.stopListening();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources
|
||||
*/
|
||||
destroy() {
|
||||
this.callbacks.clear();
|
||||
this.stopListening();
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for global use
|
||||
const globalResizeHandler = new ResizeHandler();
|
||||
|
||||
/**
|
||||
* Svelte composable for handling window resize events with performance optimization
|
||||
* @param callback Function to call on resize
|
||||
* @param options Configuration options
|
||||
* @returns Cleanup function
|
||||
*/
|
||||
export function useResizeHandler(
|
||||
callback: ResizeCallback,
|
||||
options?: ResizeHandlerOptions
|
||||
): () => void {
|
||||
if (options) {
|
||||
// Create a new handler with custom options
|
||||
const handler = new ResizeHandler(options);
|
||||
return handler.subscribe(callback);
|
||||
} else {
|
||||
// Use the global handler
|
||||
return globalResizeHandler.subscribe(callback);
|
||||
}
|
||||
}
|
||||
|
||||
export default useResizeHandler;
|
||||
22
site/plugins/loop/frontend/src/lib/Author.svelte
Normal file
22
site/plugins/loop/frontend/src/lib/Author.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
const { initials } = $props();
|
||||
</script>
|
||||
|
||||
<div class="author">
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
<style scoped>
|
||||
.author {
|
||||
font-size: var(--author-avatar-font-size);
|
||||
text-transform: uppercase;
|
||||
color: var(--author-avatar-color);
|
||||
background-color: var(--author-avatar-background-color);
|
||||
aspect-ratio: 1;
|
||||
flex: 0 0 var(--author-avatar-size);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--author-avatar-border-radius);
|
||||
}
|
||||
</style>
|
||||
256
site/plugins/loop/frontend/src/lib/Button.svelte
Normal file
256
site/plugins/loop/frontend/src/lib/Button.svelte
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
<script lang="ts">
|
||||
const {
|
||||
onclick,
|
||||
onmouseenter,
|
||||
onmouseout,
|
||||
onblur,
|
||||
active = false,
|
||||
type = "button",
|
||||
style = "",
|
||||
disabled = false,
|
||||
ariaLabel = "",
|
||||
id = "",
|
||||
ariaHaspopup = "",
|
||||
ariaExpanded = "",
|
||||
ariaControls = "",
|
||||
}: {
|
||||
onclick?: () => void;
|
||||
onmouseenter?: () => void;
|
||||
onmouseout?: () => void;
|
||||
onblur?: () => void;
|
||||
active?: boolean;
|
||||
type?: "reset" | "submit" | "button";
|
||||
style?: string;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
id?: string;
|
||||
ariaHaspopup?: string;
|
||||
ariaExpanded?: string;
|
||||
ariaControls?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
{onclick}
|
||||
class="button {style}"
|
||||
{type}
|
||||
class:is-active={active}
|
||||
aria-label={ariaLabel}
|
||||
{id}
|
||||
aria-haspopup={ariaHaspopup === "menu" ? "menu" : null}
|
||||
aria-expanded={ariaExpanded === "true" ? true : ariaExpanded === "false" ? false : null}
|
||||
aria-controls={ariaControls || null}
|
||||
{disabled}
|
||||
{onmouseenter}
|
||||
{onmouseout}
|
||||
{onblur}
|
||||
>
|
||||
<slot name="icon" />
|
||||
{#if $$slots.default}<span><slot /></span>{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button {
|
||||
appearance: none;
|
||||
background-color: var(--button-background);
|
||||
color: var(--button-color);
|
||||
padding: var(--button-padding);
|
||||
border: 0;
|
||||
font-family: var(--font-family);
|
||||
letter-spacing: 0.01em;
|
||||
border-radius: var(--button-border-radius);
|
||||
display: inline-flex;
|
||||
gap: var(--button-gap);
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: var(--button-font-size);
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
font-weight: var(--button-font-weight);
|
||||
transition: var(--button-transition);
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
height: var(--button-height);
|
||||
outline-color: var(--button-outline-color);
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: var(--button-outline-offset);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--button-hover-color);
|
||||
background-color: var(--button-hover-background);
|
||||
}
|
||||
|
||||
span {
|
||||
text-overflow: ellipsis;
|
||||
overflow-x: clip;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&.button--header {
|
||||
--icon-size: 1.25rem;
|
||||
background-color: var(--button-header-background);
|
||||
height: var(--button-header-height);
|
||||
padding: var(--button-header-padding);
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
mix-blend-mode: var(--button-header-blend-mode);
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: var(--border-radius-rounded);
|
||||
border-bottom-left-radius: var(--border-radius-rounded);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--button-header-hover-background);
|
||||
}
|
||||
}
|
||||
|
||||
&.button--panel {
|
||||
background-color: var(--button-panel-background);
|
||||
height: auto;
|
||||
padding: var(--button-panel-padding);
|
||||
border-radius: var(--border-radius-rounded);
|
||||
border: 0;
|
||||
span {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&.button--solid {
|
||||
background-color: var(--button-solid-background);
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--button-solid-hover-color);
|
||||
background-color: var(--button-solid-hover-background);
|
||||
}
|
||||
}
|
||||
|
||||
&.button--small {
|
||||
height: var(--button-small-height);
|
||||
font-size: var(--button-small-font-size);
|
||||
}
|
||||
|
||||
&.button--icon {
|
||||
background-color: var(--button-icon-background);
|
||||
color: var(--button-icon-color);
|
||||
height: var(--button-icon-height);
|
||||
box-shadow: var(--button-icon-shadow);
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
font-size: var(--button-icon-font-size);
|
||||
border-radius: var(--button-icon-border-radius);
|
||||
border: 0;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--button-icon-hover-background);
|
||||
color: var(--button-icon-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.button--marker {
|
||||
background-color: var(--button-marker-background);
|
||||
color: var(--button-marker-color);
|
||||
padding: 0;
|
||||
height: var(--marker-size);
|
||||
width: var(--marker-size);
|
||||
font-weight: var(--button-marker-font-weight);
|
||||
border-radius: var(--button-marker-border-radius);
|
||||
border: 0;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.button--marker-highlighted {
|
||||
background-color: var(--button-marker-highlighted-background);
|
||||
color: var(--button-marker-highlighted-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.button--marker-open {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
|
||||
&.button--filter {
|
||||
background-color: var(--button-filter-background);
|
||||
color: var(--button-filter-color);
|
||||
height: var(--button-filter-height);
|
||||
flex: 1;
|
||||
font-size: var(--button-filter-font-size);
|
||||
padding: var(--button-filter-padding);
|
||||
border-radius: var(--button-filter-border-radius);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--button-filter-hover-color);
|
||||
background-color: var(--button-filter-hover-background);
|
||||
}
|
||||
|
||||
&.button--filter-active {
|
||||
background-color: var(--button-filter-active-background);
|
||||
color: var(--button-filter-active-color);
|
||||
font-weight: var(--button-filter-active-font-weight);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--button-filter-active-background);
|
||||
color: var(--button-filter-active-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.button--menu-item {
|
||||
background-color: var(--button-menu-item-background);
|
||||
color: var(--button-menu-item-color);
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: var(--button-menu-item-padding);
|
||||
border-radius: var(--button-menu-item-border-radius);
|
||||
font-size: var(--button-menu-item-font-size);
|
||||
gap: var(--button-menu-item-gap);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--button-menu-item-hover-background);
|
||||
color: var(--button-menu-item-hover-color);
|
||||
}
|
||||
|
||||
&.button--menu-item-active {
|
||||
background-color: var(--button-menu-item-active-background);
|
||||
color: var(--button-menu-item-active-color);
|
||||
font-weight: var(--button-menu-item-active-font-weight);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: var(--button-menu-item-active-background);
|
||||
color: var(--button-menu-item-active-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--button-active-background);
|
||||
color: var(--button-active-color);
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: var(--button-active-color);
|
||||
background-color: var(--button-active-background);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: var(--button-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
&:hover {
|
||||
color: var(--button-disabled-hover-color);
|
||||
background-color: var(--button-disabled-hover-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
227
site/plugins/loop/frontend/src/lib/Comment.svelte
Normal file
227
site/plugins/loop/frontend/src/lib/Comment.svelte
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<script lang="ts">
|
||||
import { resolveComment, unresolveComment } from "../store/api.svelte";
|
||||
import { t } from "../store/translations.svelte";
|
||||
import type { Comment } from "../types";
|
||||
import { panel } from "../store/ui.svelte";
|
||||
import Button from "./Button.svelte";
|
||||
import CommentForm from "./CommentForm.svelte";
|
||||
import Reply from "./Reply.svelte";
|
||||
import { formatDate } from "../composables/formatDate";
|
||||
import { formatDateISO } from "../composables/formatDateISO";
|
||||
import { decodeHTMLEntities } from "../composables/decodeHTMLEntities";
|
||||
const {
|
||||
comment,
|
||||
scrollIntoView,
|
||||
handleSubmit,
|
||||
cancel,
|
||||
}: {
|
||||
comment: Comment;
|
||||
scrollIntoView: (id: number) => void;
|
||||
handleSubmit: (e: SubmitEvent) => void;
|
||||
cancel: () => void;
|
||||
} = $props();
|
||||
|
||||
let openReplyForm = $state(false);
|
||||
let detailsOpen = $state(
|
||||
comment.replies?.length > 0 && !panel.showResolvedOnly,
|
||||
);
|
||||
</script>
|
||||
|
||||
<details
|
||||
id="comment-{comment.id}"
|
||||
class="comment comment--{comment.status}"
|
||||
class:comment--current={panel.currentCommentId === comment.id}
|
||||
bind:open={detailsOpen}
|
||||
>
|
||||
<summary
|
||||
class="comment__header"
|
||||
aria-label="{t(
|
||||
'ui.comment.summary.aria.label',
|
||||
'Comment by',
|
||||
)} {comment.author}: {decodeHTMLEntities(comment.comment)}"
|
||||
>
|
||||
<Button
|
||||
style="button--marker button--marker-{comment.status} {panel.currentCommentId ===
|
||||
comment.id
|
||||
? 'button--marker-highlighted'
|
||||
: ''}"
|
||||
onclick={() => scrollIntoView(comment.id)}
|
||||
onmouseenter={() => (panel.pulseMarkerId = comment.id)}
|
||||
onmouseout={() => (panel.pulseMarkerId = 0)}
|
||||
ariaLabel={`${t("ui.comment.maker.aria.label", "Jump to marker")} ${comment.id}`}
|
||||
>
|
||||
{comment.id}
|
||||
</Button>
|
||||
<div class="comment__content">
|
||||
<header>
|
||||
<strong>{comment.author}</strong>
|
||||
<time
|
||||
datetime={formatDateISO(comment.timestamp)}
|
||||
title={formatDate(comment.timestamp, false)}
|
||||
>
|
||||
{formatDate(comment.timestamp)}
|
||||
</time>
|
||||
</header>
|
||||
<div class="comment__text">{decodeHTMLEntities(comment.comment)}</div>
|
||||
</div>
|
||||
|
||||
{#if !detailsOpen}
|
||||
<Button
|
||||
style="button--solid button--small comment__replies-count"
|
||||
ariaLabel={`${t("ui.comment.replies.aria.label", "Show replies")} ${comment.id}`}
|
||||
onclick={() => {
|
||||
detailsOpen = !detailsOpen;
|
||||
}}
|
||||
>
|
||||
{comment.replies?.length > 0 ? `+${comment.replies.length}` : "+"}
|
||||
</Button>
|
||||
{/if}
|
||||
</summary>
|
||||
|
||||
{#if comment.replies?.length > 0}
|
||||
<ul class="comment__replies">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
<li>
|
||||
<Reply {reply} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<footer>
|
||||
{#if openReplyForm}
|
||||
<CommentForm
|
||||
handleSubmit={(e) => {
|
||||
openReplyForm = false;
|
||||
handleSubmit(e);
|
||||
}}
|
||||
cancel={() => {
|
||||
openReplyForm = false;
|
||||
cancel();
|
||||
}}
|
||||
parentId={comment.id}
|
||||
/>
|
||||
{:else}
|
||||
<div class="buttons">
|
||||
{#if comment.status === "OPEN"}
|
||||
<Button style="button--solid" onclick={() => (openReplyForm = true)}>
|
||||
{t("ui.reply.submit", "Reply")}
|
||||
</Button>
|
||||
<Button onclick={() => resolveComment(comment)}>
|
||||
{t("ui.comment.mark.solved", "Resolve")}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button onclick={() => unresolveComment(comment)}>
|
||||
{t("ui.comment.mark.unsolved", "Reopen")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</footer>
|
||||
</details>
|
||||
|
||||
<style>
|
||||
.comment {
|
||||
--loop-marker-background: var(--comment-marker-background);
|
||||
--loop-marker-color: var(--comment-marker-color);
|
||||
--marker-size: var(--comment-avatar-size);
|
||||
position: relative;
|
||||
|
||||
> * {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: var(--comment-line-offset);
|
||||
top: 1.5rem;
|
||||
width: var(--comment-line-width);
|
||||
height: calc(100% - 4rem);
|
||||
background-color: var(--comment-line-background);
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.comment:not([open]) {
|
||||
&::after {
|
||||
height: calc(100% - 2.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
.comment__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--comment-header-font-size);
|
||||
padding: var(--comment-header-padding);
|
||||
align-items: flex-start;
|
||||
gap: var(--comment-header-gap);
|
||||
cursor: pointer;
|
||||
border-radius: var(--comment-header-border-radius);
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--comment-header-outline-color);
|
||||
outline-offset: var(--comment-header-outline-offset);
|
||||
}
|
||||
|
||||
:global(.comment__replies-count) {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: var(--space-s);
|
||||
min-width: var(--comment-avatar-size);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
gap: var(--comment-author-gap);
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: var(--comment-author-margin-bottom);
|
||||
time {
|
||||
font-size: var(--comment-timestamp-font-size);
|
||||
color: var(--comment-timestamp-color);
|
||||
}
|
||||
}
|
||||
|
||||
.comment__content {
|
||||
padding: var(--comment-content-padding);
|
||||
background-color: var(--comment-content-background);
|
||||
border-radius: var(--comment-content-border-radius);
|
||||
flex: 1;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: var(--comment-content-background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.comment__text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
.comment__replies {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--comment-replies-padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--comment-replies-gap);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--comment-footer-gap);
|
||||
padding: var(--comment-footer-padding);
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--comment-buttons-gap);
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
56
site/plugins/loop/frontend/src/lib/CommentDialog.svelte
Normal file
56
site/plugins/loop/frontend/src/lib/CommentDialog.svelte
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script lang="ts">
|
||||
import { getDialogPosition } from "../composables/getDialogPosition";
|
||||
import CommentForm from "./CommentForm.svelte";
|
||||
const { handleSubmit, showModal, newMarker, cancel } = $props();
|
||||
let dialogElement: HTMLDialogElement;
|
||||
let dialogPosition: { left: number; top: number } = $state({
|
||||
left: 0,
|
||||
top: 0,
|
||||
});
|
||||
let ready = $state(false);
|
||||
$effect(() => {
|
||||
if (showModal) {
|
||||
dialogElement.showModal();
|
||||
dialogPosition = getDialogPosition(newMarker, dialogElement);
|
||||
ready = true;
|
||||
} else {
|
||||
dialogElement.close();
|
||||
ready = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
onclose={cancel}
|
||||
bind:this={dialogElement}
|
||||
class:is-visible={ready}
|
||||
style="--left: {dialogPosition.left}px; --top: {dialogPosition.top}px;"
|
||||
>
|
||||
<CommentForm {handleSubmit} {cancel} />
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog {
|
||||
--loop-textarea-font-size: var(--comment-dialog-textarea-font-size);
|
||||
position: var(--comment-dialog-position);
|
||||
top: var(--top);
|
||||
left: var(--left);
|
||||
max-width: var(--comment-dialog-max-width);
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
border-radius: var(--comment-dialog-border-radius);
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
box-shadow: var(--comment-dialog-shadow);
|
||||
&.is-visible {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&::backdrop {
|
||||
background-color: var(--comment-dialog-backdrop-background);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
99
site/plugins/loop/frontend/src/lib/CommentForm.svelte
Normal file
99
site/plugins/loop/frontend/src/lib/CommentForm.svelte
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<script lang="ts">
|
||||
import Button from "./Button.svelte";
|
||||
import { t } from "../store/translations.svelte";
|
||||
const {
|
||||
handleSubmit,
|
||||
cancel,
|
||||
parentId = null,
|
||||
}: {
|
||||
handleSubmit: (e: SubmitEvent) => void;
|
||||
cancel: () => void;
|
||||
parentId?: number | null;
|
||||
} = $props();
|
||||
import { formData } from "../store/form.svelte";
|
||||
formData.parentId = parentId ? Number(parentId) : null;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const form = (e.currentTarget as Element)?.closest("form");
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSubmit} method="POST">
|
||||
<div class="input">
|
||||
<textarea
|
||||
bind:value={formData.text}
|
||||
name="comment"
|
||||
placeholder={parentId
|
||||
? t("ui.reply.placeholder", "Write a reply...")
|
||||
: t("ui.comment.placeholder", "Enter your comment...")}
|
||||
onkeydown={handleKeydown}
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="keyboard-hint">
|
||||
{t("ui.comment.keyboardHint", "⌘+Enter or Ctrl+Enter to submit")}
|
||||
</div>
|
||||
<footer>
|
||||
<Button type="submit" style="button--solid">
|
||||
{parentId
|
||||
? t("ui.reply.submit", "Reply")
|
||||
: t("ui.comment.submit", "Submit")}
|
||||
</Button>
|
||||
<Button onclick={cancel}>{t("ui.comment.cancel", "Cancel")}</Button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
form {
|
||||
padding: 0;
|
||||
cursor: auto;
|
||||
background-color: var(--comment-form-background);
|
||||
color: var(--comment-form-color);
|
||||
border-radius: var(--comment-form-border-radius);
|
||||
overflow: hidden;
|
||||
border: var(--comment-form-border);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
height: var(--comment-form-textarea-height);
|
||||
resize: none;
|
||||
padding: var(--comment-form-textarea-padding);
|
||||
box-sizing: border-box;
|
||||
background-color: var(--comment-form-textarea-background);
|
||||
font-family: var(--comment-form-textarea-font-family);
|
||||
font-size: var(--comment-form-textarea-font-size);
|
||||
color: currentColor;
|
||||
margin: 0;
|
||||
|
||||
&:focus-visible,
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: var(--comment-form-footer-padding);
|
||||
display: flex;
|
||||
gap: var(--comment-form-footer-gap);
|
||||
:global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard-hint {
|
||||
font-size: var(--comment-form-hint-font-size);
|
||||
color: var(--comment-form-hint-color);
|
||||
padding: var(--comment-form-hint-padding);
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
187
site/plugins/loop/frontend/src/lib/ContextMenu.svelte
Normal file
187
site/plugins/loop/frontend/src/lib/ContextMenu.svelte
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<script lang="ts">
|
||||
import { panel } from "../store/ui.svelte";
|
||||
import { t } from "../store/translations.svelte";
|
||||
import Button from "./Button.svelte";
|
||||
import IconDots from "./Icon/IconDots.svelte";
|
||||
import IconSettings from "./Icon/IconSettings.svelte";
|
||||
|
||||
let contextMenu: HTMLElement;
|
||||
let triggerButton: HTMLElement;
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (contextMenu.matches(":popover-open")) {
|
||||
contextMenu.hidePopover();
|
||||
} else {
|
||||
contextMenu.showPopover();
|
||||
// Position the popover relative to the trigger button
|
||||
positionMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const positionMenu = () => {
|
||||
if (!triggerButton || !contextMenu) return;
|
||||
|
||||
const buttonRect = triggerButton.getBoundingClientRect();
|
||||
const menuRect = contextMenu.getBoundingClientRect();
|
||||
|
||||
// Position above and to the left of the button
|
||||
const top = buttonRect.top - menuRect.height - 8;
|
||||
const left = buttonRect.left - menuRect.width + buttonRect.width;
|
||||
|
||||
contextMenu.style.position = "fixed";
|
||||
contextMenu.style.top = `${Math.max(8, top)}px`;
|
||||
contextMenu.style.left = `${Math.max(8, left)}px`;
|
||||
contextMenu.style.margin = "0";
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
contextMenu.hidePopover();
|
||||
};
|
||||
|
||||
const setFilter = (showResolved: boolean) => {
|
||||
panel.showResolvedOnly = showResolved;
|
||||
closeMenu();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="context-menu-container">
|
||||
<div class="context-menu-trigger" bind:this={triggerButton}>
|
||||
<Button
|
||||
onclick={toggleMenu}
|
||||
ariaLabel={t("ui.panel.menu.open", "Open menu")}
|
||||
style="button--icon"
|
||||
id="context-menu-trigger"
|
||||
ariaHaspopup="menu"
|
||||
ariaExpanded={contextMenu?.matches(":popover-open") ? "true" : "false"}
|
||||
ariaControls="context-menu"
|
||||
>
|
||||
<IconSettings slot="icon" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={contextMenu}
|
||||
class="context-menu"
|
||||
popover="auto"
|
||||
role="menu"
|
||||
aria-labelledby="context-menu-trigger"
|
||||
id="context-menu"
|
||||
>
|
||||
<div class="menu-section">
|
||||
<div class="menu-section-title">
|
||||
{t("ui.panel.menu.filter.title", "Show Comments")}
|
||||
</div>
|
||||
<div class="filter-options">
|
||||
<Button
|
||||
style="button--menu-item {!panel.showResolvedOnly
|
||||
? 'button--menu-item-active'
|
||||
: ''}"
|
||||
onclick={() => setFilter(false)}
|
||||
ariaLabel={!panel.showResolvedOnly
|
||||
? t(
|
||||
"ui.panel.filter.open.active",
|
||||
"Show open comments (currently selected)",
|
||||
)
|
||||
: t("ui.panel.filter.open.inactive", "Show open comments")}
|
||||
>
|
||||
<span
|
||||
class="filter-dot filter-dot--open"
|
||||
slot="icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{t("ui.panel.filter.open", "Open")}
|
||||
</Button>
|
||||
<Button
|
||||
style="button--menu-item {panel.showResolvedOnly
|
||||
? 'button--menu-item-active'
|
||||
: ''}"
|
||||
onclick={() => setFilter(true)}
|
||||
ariaLabel={panel.showResolvedOnly
|
||||
? t(
|
||||
"ui.panel.filter.resolved.active",
|
||||
"Show resolved comments (currently selected)",
|
||||
)
|
||||
: t("ui.panel.filter.resolved.inactive", "Show resolved comments")}
|
||||
>
|
||||
<span
|
||||
class="filter-dot filter-dot--resolved"
|
||||
slot="icon"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{t("ui.panel.filter.resolved", "Resolved")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.context-menu-container {
|
||||
position: absolute;
|
||||
bottom: var(--context-menu-container-bottom);
|
||||
right: var(--context-menu-container-right);
|
||||
z-index: var(--context-menu-container-z-index);
|
||||
}
|
||||
|
||||
.context-menu-trigger {
|
||||
width: var(--context-menu-trigger-size);
|
||||
height: var(--context-menu-trigger-size);
|
||||
border-radius: var(--context-menu-trigger-border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
background: var(--context-menu-background);
|
||||
border: 0;
|
||||
border-radius: var(--context-menu-border-radius);
|
||||
box-shadow: var(--context-menu-shadow);
|
||||
padding: var(--context-menu-padding);
|
||||
min-width: var(--context-menu-min-width);
|
||||
position: fixed;
|
||||
margin: 0;
|
||||
|
||||
&::backdrop {
|
||||
background: var(--context-menu-backdrop-background);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--context-menu-section-gap);
|
||||
}
|
||||
|
||||
.menu-section-title {
|
||||
font-size: var(--context-menu-title-font-size);
|
||||
font-weight: var(--context-menu-title-font-weight);
|
||||
color: var(--context-menu-title-color);
|
||||
padding: 0;
|
||||
margin-bottom: var(--context-menu-title-margin-bottom);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--context-menu-title-letter-spacing);
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--context-menu-filter-gap);
|
||||
}
|
||||
|
||||
.filter-dot {
|
||||
width: var(--context-menu-filter-dot-size);
|
||||
height: var(--context-menu-filter-dot-size);
|
||||
border-radius: var(--context-menu-filter-dot-border-radius);
|
||||
display: inline-block;
|
||||
margin-right: var(--context-menu-filter-dot-margin-right);
|
||||
}
|
||||
|
||||
.filter-dot--open {
|
||||
background: var(--context-menu-filter-dot-open-background);
|
||||
}
|
||||
|
||||
.filter-dot--resolved {
|
||||
background: var(--context-menu-filter-dot-resolved-background);
|
||||
}
|
||||
</style>
|
||||
89
site/plugins/loop/frontend/src/lib/Header.svelte
Normal file
89
site/plugins/loop/frontend/src/lib/Header.svelte
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<script lang="ts">
|
||||
import { panel, overlay } from "../store/ui.svelte";
|
||||
import { t } from "../store/translations.svelte";
|
||||
import IconComment from "./Icon/IconComment.svelte";
|
||||
import Button from "./Button.svelte";
|
||||
import IconBrowse from "./Icon/IconBrowse.svelte";
|
||||
|
||||
const {
|
||||
position,
|
||||
commentsCount,
|
||||
}: {
|
||||
position: "top" | "bottom";
|
||||
commentsCount: number;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<header class:bottom={position === "bottom"}>
|
||||
<div class="toggle">
|
||||
<Button
|
||||
onclick={() => {
|
||||
overlay.open = false;
|
||||
}}
|
||||
active={!overlay.open}
|
||||
style="button--header"
|
||||
>
|
||||
<IconBrowse --size="1.5em" slot="icon" />
|
||||
{t("ui.header.browse.mode", "Browse")}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={() => {
|
||||
overlay.open = true;
|
||||
}}
|
||||
style="button--header"
|
||||
active={overlay.open}
|
||||
>
|
||||
<IconComment --size="1.5em" slot="icon" />
|
||||
{t("ui.header.comment.mode", "Comment")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onclick={() => (panel.open = !panel.open)}
|
||||
style="button--panel"
|
||||
ariaLabel={`${commentsCount} ${t("ui.header.aria.count", "unresolved comments")}`}
|
||||
>
|
||||
<span class="count">{commentsCount}</span>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
header {
|
||||
position: var(--header-position);
|
||||
top: var(--header-top);
|
||||
left: 50%;
|
||||
max-width: 100%;
|
||||
transform: var(--header-transform);
|
||||
color: var(--header-color);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
border-radius: var(--header-border-radius);
|
||||
z-index: var(--header-z-index);
|
||||
backdrop-filter: var(--header-backdrop-filter);
|
||||
box-shadow: var(--shadow-l), var(--shadow-light-edge),
|
||||
var(--shadow-dark-edge);
|
||||
background: var(--header-background);
|
||||
|
||||
&.bottom {
|
||||
top: auto;
|
||||
bottom: var(--header-bottom-position);
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
width: var(--header-count-size);
|
||||
height: var(--header-count-size);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--header-count-border-radius);
|
||||
backdrop-filter: var(--header-count-backdrop-filter);
|
||||
box-shadow: var(--shadow-s), var(--shadow-light-edge),
|
||||
var(--shadow-dark-edge);
|
||||
background: var(--header-count-background);
|
||||
}
|
||||
</style>
|
||||
179
site/plugins/loop/frontend/src/lib/Marker.svelte
Normal file
179
site/plugins/loop/frontend/src/lib/Marker.svelte
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import type { Comment } from "../types";
|
||||
import { panel } from "../store/ui.svelte";
|
||||
import useResizeHandler from "../composables/useResizeHandler";
|
||||
import { getDocumentHeight } from "../composables/getDocumentHeight";
|
||||
import Button from "./Button.svelte";
|
||||
const { comment }: { comment: Comment } = $props();
|
||||
|
||||
let shouldPulse = $state(false);
|
||||
|
||||
// Listen for marker pulse triggers from the panel
|
||||
$effect(() => {
|
||||
shouldPulse = panel.pulseMarkerId === comment.id;
|
||||
});
|
||||
|
||||
let markerElement: HTMLElement | null = $state(null);
|
||||
let targetElement: HTMLElement | null = $state(null);
|
||||
let unsubscribeResize: (() => void) | null = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
// Delay initial positioning to ensure DOM is fully rendered
|
||||
requestAnimationFrame(() => {
|
||||
positionMarker();
|
||||
});
|
||||
|
||||
// Subscribe to resize events for repositioning markers
|
||||
unsubscribeResize = useResizeHandler(() => {
|
||||
positionMarker();
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up resize listener
|
||||
if (unsubscribeResize) {
|
||||
unsubscribeResize();
|
||||
}
|
||||
});
|
||||
|
||||
function positionMarker() {
|
||||
if (!comment || !markerElement) return;
|
||||
|
||||
try {
|
||||
targetElement = document.querySelector(comment.selector);
|
||||
|
||||
let absoluteX: number;
|
||||
let absoluteY: number;
|
||||
|
||||
if (targetElement) {
|
||||
// Position based on selector if element is found
|
||||
const targetRect = targetElement.getBoundingClientRect();
|
||||
|
||||
// Calculate position using the percentage values directly
|
||||
const offsetXInPixels =
|
||||
(targetRect.width * comment.selectorOffsetX) / 100;
|
||||
const offsetYInPixels =
|
||||
(targetRect.height * comment.selectorOffsetY) / 100;
|
||||
|
||||
// Calculate absolute position accounting for scroll
|
||||
absoluteX = targetRect.left + window.scrollX + offsetXInPixels;
|
||||
absoluteY = targetRect.top + window.scrollY + offsetYInPixels;
|
||||
} else {
|
||||
// Fallback to absolute page position if selector is not found
|
||||
absoluteX = Number(comment.pagePositionX);
|
||||
absoluteY = Number(comment.pagePositionY);
|
||||
}
|
||||
|
||||
// Get marker dimensions for boundary calculations
|
||||
const markerRect = markerElement.getBoundingClientRect();
|
||||
const markerWidth = markerRect.width || 32; // fallback to default marker size
|
||||
const markerHeight = markerRect.height || 32;
|
||||
|
||||
// Calculate half dimensions for transform: translate(-50%, -50%)
|
||||
const halfWidth = markerWidth / 2;
|
||||
const halfHeight = markerHeight / 2;
|
||||
|
||||
// Get document and viewport dimensions
|
||||
const documentHeight = getDocumentHeight();
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
// Calculate boundaries
|
||||
// X-axis: constrain to viewport to prevent horizontal scrolling
|
||||
const minX = halfWidth;
|
||||
const maxX = viewportWidth - halfWidth;
|
||||
|
||||
// Y-axis: constrain to document height to prevent overflow but allow positioning anywhere in document
|
||||
const minY = halfHeight;
|
||||
const maxY = documentHeight - halfHeight;
|
||||
|
||||
// Constrain position within boundaries
|
||||
const constrainedX = Math.max(minX, Math.min(maxX, absoluteX));
|
||||
const constrainedY = Math.max(minY, Math.min(maxY, absoluteY));
|
||||
|
||||
// Set absolute position
|
||||
markerElement.style.left = `${constrainedX}px`;
|
||||
markerElement.style.top = `${constrainedY}px`;
|
||||
} catch (error) {
|
||||
console.error("Error positioning marker:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter(id: number) {
|
||||
panel.currentCommentId = id;
|
||||
}
|
||||
|
||||
function handleMouseOut() {
|
||||
panel.currentCommentId = 0;
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
panel.open = true;
|
||||
// Scroll to comment element
|
||||
const commentElement = document
|
||||
.querySelector(`loop`)
|
||||
?.shadowRoot?.querySelector(`#comment-${comment.id}`);
|
||||
if (commentElement) {
|
||||
commentElement.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if comment}
|
||||
<div
|
||||
bind:this={markerElement}
|
||||
class="marker marker--{comment.status}"
|
||||
class:marker--pulse={shouldPulse}
|
||||
id="marker-{comment.id}"
|
||||
>
|
||||
<Button
|
||||
onmouseenter={() => handleMouseEnter(comment.id)}
|
||||
onmouseout={handleMouseOut}
|
||||
onblur={handleMouseOut}
|
||||
onclick={handleClick}
|
||||
style="button--marker button--marker-{comment.status}"
|
||||
>
|
||||
{comment.id}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.marker {
|
||||
position: var(--marker-position);
|
||||
z-index: var(--marker-z-index);
|
||||
transform: var(--marker-transform);
|
||||
border-radius: var(--marker-border-radius);
|
||||
}
|
||||
|
||||
.marker--pulse {
|
||||
animation: kirby-loop-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes kirby-loop-pulse {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 0 0 var(--color-accent),
|
||||
0 0 0 0 rgba(128, 128, 128, 0.3),
|
||||
0 0 0 0 rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
30% {
|
||||
box-shadow:
|
||||
0 0 0 8px transparent,
|
||||
0 0 0 0 rgba(128, 128, 128, 0.3),
|
||||
0 0 0 0 rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
60% {
|
||||
box-shadow:
|
||||
0 0 0 8px rgba(128, 128, 128, 0.15),
|
||||
0 0 0 12px transparent,
|
||||
0 0 0 0 rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 16px transparent,
|
||||
0 0 0 12px transparent,
|
||||
0 0 0 8px transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
181
site/plugins/loop/frontend/src/lib/Panel.svelte
Normal file
181
site/plugins/loop/frontend/src/lib/Panel.svelte
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
<script lang="ts">
|
||||
import { store } from "../store/api.svelte";
|
||||
import { panel } from "../store/ui.svelte";
|
||||
import { t } from "../store/translations.svelte";
|
||||
import Comment from "./Comment.svelte";
|
||||
const { scrollIntoView, handleSubmit, cancel } = $props();
|
||||
import Button from "./Button.svelte";
|
||||
import ContextMenu from "./ContextMenu.svelte";
|
||||
import IconChat from "./Icon/IconChat.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let dialogEl: HTMLDialogElement;
|
||||
|
||||
// Filter comments based on resolved status
|
||||
const filteredComments = $derived(
|
||||
panel.showResolvedOnly
|
||||
? store.comments.filter((comment) => comment.status === "RESOLVED")
|
||||
: store.comments.filter((comment) => comment.status === "OPEN"),
|
||||
);
|
||||
|
||||
// Sync dialog state with panel store
|
||||
$effect(() => {
|
||||
if (!dialogEl) return;
|
||||
|
||||
if (panel.open && !dialogEl.open) {
|
||||
dialogEl.show();
|
||||
} else if (!panel.open && dialogEl.open) {
|
||||
dialogEl.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle ESC key to close panel
|
||||
onMount(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && panel.open) {
|
||||
panel.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
return () => document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
// Handle dialog close event
|
||||
function handleDialogClose() {
|
||||
panel.open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
class="panel"
|
||||
class:open={panel.open}
|
||||
onclose={handleDialogClose}
|
||||
>
|
||||
<header>
|
||||
<Button
|
||||
onclick={() => (panel.open = !panel.open)}
|
||||
style="button--header"
|
||||
ariaLabel={t("ui.panel.open", "Open comments")}
|
||||
>
|
||||
<IconChat slot="icon" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<ul class="threads" data-lenis-prevent inert={!panel.open}>
|
||||
{#if filteredComments.length === 0}
|
||||
<li class="no-threads">
|
||||
<p>
|
||||
{t("ui.panel.no.comments", "No comments yet.")}
|
||||
</p>
|
||||
</li>
|
||||
{:else if filteredComments.length === 0 && panel.showResolvedOnly}
|
||||
<li class="no-threads">
|
||||
<p>{t("ui.panel.no.resolved", "No resolved comments yet.")}</p>
|
||||
</li>
|
||||
{:else}
|
||||
{#each filteredComments as comment (comment.id)}
|
||||
<li>
|
||||
<Comment {comment} {scrollIntoView} {cancel} {handleSubmit} />
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
<ContextMenu />
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
position: var(--panel-position);
|
||||
right: var(--panel-right);
|
||||
top: var(--panel-top);
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
transform: var(--panel-transform-closed);
|
||||
width: var(--panel-width);
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
height: var(--panel-height);
|
||||
border: 0;
|
||||
color: var(--panel-color);
|
||||
border-radius: var(--panel-border-radius);
|
||||
border-top-left-radius: var(--panel-border-top-left-radius);
|
||||
transition: var(--panel-transition);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: var(--panel-z-index);
|
||||
cursor: auto;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
width: var(--panel-mobile-width);
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: var(--panel-transform-open);
|
||||
box-shadow: var(--panel-shadow);
|
||||
header {
|
||||
transform: var(--panel-header-transform-open);
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
transform: var(--panel-header-transform-closed);
|
||||
border-top-left-radius: var(--panel-header-border-radius);
|
||||
border-bottom-left-radius: var(--panel-header-border-radius);
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--panel-header-gap);
|
||||
backdrop-filter: var(--panel-header-backdrop-filter);
|
||||
background: var(--panel-header-background);
|
||||
box-shadow: var(--shadow-l), var(--shadow-light-edge),
|
||||
var(--shadow-dark-edge);
|
||||
transition: transform var(--transition-duration) var(--transition-easing);
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: var(--panel-header-transform-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.threads {
|
||||
flex: 1 1 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--panel-threads-padding);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--panel-threads-background);
|
||||
backdrop-filter: var(--panel-threads-backdrop);
|
||||
z-index: 2;
|
||||
border-radius: var(--panel-threads-border-radius);
|
||||
border-top-left-radius: var(--panel-threads-border-top-left-radius);
|
||||
scrollbar-width: var(--panel-threads-scrollbar-width);
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
li {
|
||||
+ li {
|
||||
margin-top: var(--panel-threads-item-margin);
|
||||
}
|
||||
}
|
||||
.no-threads {
|
||||
text-align: center;
|
||||
padding: var(--panel-no-threads-padding);
|
||||
font-size: var(--panel-no-threads-font-size);
|
||||
color: var(--panel-no-threads-color);
|
||||
margin-block: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
site/plugins/loop/frontend/src/lib/Reply.svelte
Normal file
67
site/plugins/loop/frontend/src/lib/Reply.svelte
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<script lang="ts">
|
||||
import type { Reply } from "../types";
|
||||
import Author from "./Author.svelte";
|
||||
import { t } from "../store/translations.svelte";
|
||||
import { formatDate } from "../composables/formatDate";
|
||||
import { formatDateISO } from "../composables/formatDateISO";
|
||||
import { decodeHTMLEntities } from "../composables/decodeHTMLEntities";
|
||||
export let reply: Reply;
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="reply"
|
||||
data-id={reply.id}
|
||||
aria-label="{t(
|
||||
'ui.reply.aria.label',
|
||||
'Reply by',
|
||||
)} {reply.author}: {decodeHTMLEntities(reply.comment)}"
|
||||
>
|
||||
<Author initials={reply.author.substring(0, 1)} />
|
||||
<div class="reply__content">
|
||||
<header>
|
||||
<strong>{reply.author}</strong>
|
||||
<time
|
||||
datetime={formatDateISO(reply.timestamp)}
|
||||
title={formatDate(reply.timestamp, false)}
|
||||
>
|
||||
{formatDate(reply.timestamp)}
|
||||
</time>
|
||||
</header>
|
||||
<div class="reply__text">{decodeHTMLEntities(reply.comment)}</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.reply {
|
||||
display: flex;
|
||||
gap: var(--reply-gap);
|
||||
flex-direction: row;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.reply__content {
|
||||
padding: var(--reply-content-padding);
|
||||
background-color: var(--reply-content-background);
|
||||
border-radius: var(--reply-content-border-radius);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
gap: var(--reply-header-gap);
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: var(--reply-header-margin-bottom);
|
||||
time {
|
||||
font-size: var(--reply-timestamp-font-size);
|
||||
color: var(--reply-timestamp-color);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: var(--reply-content-background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.reply__text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
</style>
|
||||
168
site/plugins/loop/frontend/src/lib/WelcomeDialog.svelte
Normal file
168
site/plugins/loop/frontend/src/lib/WelcomeDialog.svelte
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<script lang="ts">
|
||||
import Button from "./Button.svelte";
|
||||
import { guestName } from "../store/ui.svelte";
|
||||
import { setGuestName } from "../store/api.svelte";
|
||||
import { t } from "../store/translations.svelte";
|
||||
|
||||
const {
|
||||
headline,
|
||||
text,
|
||||
authenticated,
|
||||
welcomeEnabled = true,
|
||||
onDismiss,
|
||||
}: {
|
||||
headline: string;
|
||||
text: string;
|
||||
authenticated: boolean;
|
||||
welcomeEnabled?: boolean;
|
||||
onDismiss?: () => void;
|
||||
} = $props();
|
||||
|
||||
let dialog: HTMLDialogElement;
|
||||
let name = $state("");
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
export const showModal = () => dialog?.showModal();
|
||||
export const close = () => dialog?.close();
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!authenticated) {
|
||||
if (name.trim() && !isSubmitting) {
|
||||
isSubmitting = true;
|
||||
try {
|
||||
await setGuestName(name.trim());
|
||||
guestName.set(name.trim());
|
||||
dialog?.close();
|
||||
} catch (error) {
|
||||
console.error("Failed to set guest name:", error);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For authenticated users, mark as dismissed when submitting
|
||||
onDismiss?.();
|
||||
dialog?.close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
dialog?.close();
|
||||
}
|
||||
|
||||
function handleDialogClose() {
|
||||
// Reset form when dialog closes
|
||||
name = "";
|
||||
isSubmitting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialog} onclose={handleDialogClose}>
|
||||
<form onsubmit={handleSubmit}>
|
||||
{#if welcomeEnabled}
|
||||
<h2>{headline}</h2>
|
||||
<p class="welcome-text">{text}</p>
|
||||
{/if}
|
||||
|
||||
{#if !authenticated}
|
||||
<div class="name-section" class:no-welcome={!welcomeEnabled}>
|
||||
<div class="input">
|
||||
<input
|
||||
bind:value={name}
|
||||
type="text"
|
||||
placeholder={t(
|
||||
"ui.welcome.guest.name.placeholder",
|
||||
"Enter your name",
|
||||
)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer>
|
||||
<Button type="submit" style="button--solid" disabled={isSubmitting}>
|
||||
{#if !authenticated}
|
||||
{isSubmitting ? "Saving..." : t("ui.welcome.continue", "Continue")}
|
||||
{:else}
|
||||
{t("ui.welcome.continue", "Continue")}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button onclick={handleCancel} disabled={isSubmitting}>
|
||||
{t("ui.welcome.dismiss", "Dismiss")}
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
dialog {
|
||||
backdrop-filter: var(--welcome-dialog-backdrop-filter);
|
||||
border: var(--welcome-dialog-border);
|
||||
border-radius: var(--welcome-dialog-border-radius);
|
||||
box-shadow: var(--welcome-dialog-shadow);
|
||||
width: 100%;
|
||||
max-width: var(--welcome-dialog-max-width);
|
||||
padding: 0;
|
||||
background: var(--welcome-dialog-background);
|
||||
&::backdrop {
|
||||
background: var(--welcome-dialog-backdrop-background);
|
||||
backdrop-filter: var(--welcome-dialog-backdrop-backdrop-filter);
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
padding: var(--welcome-dialog-form-padding);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: var(--welcome-dialog-title-margin);
|
||||
font-size: var(--welcome-dialog-title-font-size);
|
||||
color: var(--welcome-dialog-title-color);
|
||||
font-weight: var(--welcome-dialog-title-font-weight);
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
margin: var(--welcome-dialog-text-margin);
|
||||
font-size: var(--welcome-dialog-text-font-size);
|
||||
color: var(--welcome-dialog-text-color);
|
||||
line-height: var(--welcome-dialog-text-line-height);
|
||||
}
|
||||
|
||||
.name-section {
|
||||
margin-bottom: var(--welcome-dialog-name-section-margin);
|
||||
}
|
||||
|
||||
.name-section.no-welcome {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: var(--welcome-dialog-input-border);
|
||||
border-radius: var(--welcome-dialog-input-border-radius);
|
||||
padding: var(--welcome-dialog-input-padding);
|
||||
box-sizing: border-box;
|
||||
font-family: var(--welcome-dialog-input-font-family);
|
||||
font-size: var(--welcome-dialog-input-font-size);
|
||||
color: var(--welcome-dialog-input-color);
|
||||
background: var(--welcome-dialog-input-background);
|
||||
|
||||
&:focus-visible {
|
||||
outline-color: var(--welcome-dialog-input-outline-color);
|
||||
outline-offset: var(--welcome-dialog-input-outline-offset);
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
gap: var(--welcome-dialog-footer-gap);
|
||||
}
|
||||
|
||||
footer :global(button) {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
5
site/plugins/loop/frontend/src/main.ts
Normal file
5
site/plugins/loop/frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import App from './App.svelte'
|
||||
import "./styles/variables.css"
|
||||
import "./styles/app.css"
|
||||
|
||||
export default App;
|
||||
111
site/plugins/loop/frontend/src/store/api.svelte.ts
Normal file
111
site/plugins/loop/frontend/src/store/api.svelte.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type { Comment, CommentPayload, Reply, ReplyPayload } from '../types';
|
||||
|
||||
export const store: { comments: Comment[] } = $state({
|
||||
comments: []
|
||||
});
|
||||
|
||||
const apiPrefix = 'loop';
|
||||
const KirbyLoop = document.querySelector('kirby-loop');
|
||||
const csrfToken = KirbyLoop?.getAttribute('csrf-token') || '';
|
||||
const apiBase = KirbyLoop?.getAttribute('apibase') || '/';
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken || ''
|
||||
};
|
||||
|
||||
const buildApiUrl = (endpoint: string): string => {
|
||||
const url = new URL(`${apiBase}/${apiPrefix}/${endpoint}`, window.location.origin);
|
||||
|
||||
// Add token query params from current page if they exist
|
||||
const currentParams = new URLSearchParams(window.location.search);
|
||||
const token = currentParams.get('token') || currentParams.get('_token');
|
||||
if (token) {
|
||||
url.searchParams.set(currentParams.has('token') ? 'token' : '_token', token);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const getComments = async (pageId: string): Promise<boolean> => {
|
||||
const url = buildApiUrl(`comments/${pageId}`);
|
||||
const response = await fetch(url, {
|
||||
headers
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
store.comments = data.comments;
|
||||
}
|
||||
return data.status === 'ok';
|
||||
}
|
||||
|
||||
export const addComment = async (comment: CommentPayload) => {
|
||||
const url = buildApiUrl('comment/new');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(comment)
|
||||
});
|
||||
const data: { comment: Comment, status: string } = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
store.comments = [data.comment, ...store.comments];
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveComment = async (comment: Comment) => {
|
||||
const url = buildApiUrl('comment/resolve');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ id: comment.id })
|
||||
});
|
||||
const data: { success: boolean } = await response.json();
|
||||
if (data.success) {
|
||||
const commentIndex = store.comments.findIndex(c => c.id === comment.id);
|
||||
if (commentIndex !== -1) {
|
||||
store.comments[commentIndex].status = 'RESOLVED';
|
||||
}
|
||||
}
|
||||
return data.success;
|
||||
}
|
||||
|
||||
export const unresolveComment = async (comment: Comment) => {
|
||||
const url = buildApiUrl('comment/unresolve');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ id: comment.id })
|
||||
});
|
||||
const data: { success: boolean } = await response.json();
|
||||
if (data.success) {
|
||||
const commentIndex = store.comments.findIndex(c => c.id === comment.id);
|
||||
if (commentIndex !== -1) {
|
||||
store.comments[commentIndex].status = 'OPEN';
|
||||
}
|
||||
}
|
||||
return data.success;
|
||||
}
|
||||
|
||||
export const setGuestName = async (name: string) => {
|
||||
const response = await fetch(buildApiUrl('guest/name'), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export const addReply = async (reply: ReplyPayload) => {
|
||||
const url = buildApiUrl('comment/reply');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(reply)
|
||||
});
|
||||
const data: { reply: Reply, status: string } = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
const parent = store.comments.find(c => c.id === data.reply.parentId)
|
||||
if (parent) parent.replies = [...parent.replies, data.reply];
|
||||
}
|
||||
}
|
||||
|
||||
export default store;
|
||||
11
site/plugins/loop/frontend/src/store/form.svelte.ts
Normal file
11
site/plugins/loop/frontend/src/store/form.svelte.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { FormData } from '../types';
|
||||
|
||||
export const formData: FormData = $state({
|
||||
text: "",
|
||||
parentId: null
|
||||
});
|
||||
|
||||
export const reset = () => {
|
||||
formData.text = ""
|
||||
formData.parentId = null
|
||||
}
|
||||
19
site/plugins/loop/frontend/src/store/translations.svelte.ts
Normal file
19
site/plugins/loop/frontend/src/store/translations.svelte.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
let translations = $state<Record<string, string>>({});
|
||||
|
||||
export const t = (key: string, fallback?: string): string => {
|
||||
return translations[key] || fallback || key;
|
||||
};
|
||||
|
||||
export const tt = (key: string, fallback: string, replacements: Record<string, string>): string => {
|
||||
let text = translations[key] || fallback || key;
|
||||
|
||||
for (const [placeholder, value] of Object.entries(replacements)) {
|
||||
text = text.replace(`{${placeholder}}`, value);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
export const setTranslations = (newTranslations: Record<string, string>) => {
|
||||
translations = newTranslations;
|
||||
};
|
||||
34
site/plugins/loop/frontend/src/store/ui.svelte.ts
Normal file
34
site/plugins/loop/frontend/src/store/ui.svelte.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export const panel = $state({
|
||||
open: false,
|
||||
currentCommentId: 0,
|
||||
showResolvedOnly: false,
|
||||
pulseMarkerId: 0
|
||||
});
|
||||
export const overlay = $state({ open: false });
|
||||
|
||||
// Guest name management
|
||||
let guestNameValue = $state("");
|
||||
|
||||
export const guestName = {
|
||||
get value() {
|
||||
return guestNameValue;
|
||||
},
|
||||
set(name: string) {
|
||||
guestNameValue = name;
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('loop-guest-name', name);
|
||||
}
|
||||
},
|
||||
get() {
|
||||
if (!guestNameValue && typeof window !== 'undefined') {
|
||||
guestNameValue = sessionStorage.getItem('loop-guest-name') || "";
|
||||
}
|
||||
return guestNameValue;
|
||||
},
|
||||
clear() {
|
||||
guestNameValue = "";
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('loop-guest-name');
|
||||
}
|
||||
}
|
||||
};
|
||||
13
site/plugins/loop/frontend/src/styles/app.css
Normal file
13
site/plugins/loop/frontend/src/styles/app.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
kirby-loop {
|
||||
font-family: var(--font-family);
|
||||
line-height: var(--line-height);
|
||||
font-weight: var(--font-weight-normal);
|
||||
font-size: var(--font-size-7);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html.loop-overlay-open {
|
||||
a {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
35
site/plugins/loop/frontend/src/styles/theme-dark.css
Normal file
35
site/plugins/loop/frontend/src/styles/theme-dark.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
kirby-loop[theme="dark"] {
|
||||
/* Accent lightness values */
|
||||
--color-accent-l: 0.85;
|
||||
|
||||
/* Neutral lightness values */
|
||||
--color-neutral-l-0: 0;
|
||||
--color-neutral-l-100: 0.1;
|
||||
--color-neutral-l-200: 0.2;
|
||||
--color-neutral-l-300: 0.45;
|
||||
--color-neutral-l-400: 0.5;
|
||||
--color-neutral-l-600: 0.55;
|
||||
--color-neutral-l-500: 0.6;
|
||||
--color-neutral-l-700: 0.7;
|
||||
--color-neutral-l-800: 0.8;
|
||||
--color-neutral-l-900: 0.95;
|
||||
--color-neutral-l-1000: 1;
|
||||
|
||||
/* Shadow tokens */
|
||||
--shadow-s: 0 0.1em 0.25em oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.1);
|
||||
--shadow-m: 0 2px 8px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
|
||||
0 8px 16px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
|
||||
0 16px 24px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
|
||||
--shadow-l: 0 4px 16px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
|
||||
0 12px 32px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
|
||||
0 24px 48px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.16),
|
||||
0 48px 80px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
|
||||
--shadow-light-edge: inset 1px 1px 1px oklch(var(--color-neutral-l-1000) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
|
||||
--shadow-dark-edge: inset -1px -1px 1px oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
|
||||
|
||||
/* Background tokens */
|
||||
--background-glass: linear-gradient(135deg, transparent, var(--color-base-background-o-50));
|
||||
|
||||
/* Panel */
|
||||
--panel-threads-background: oklch(var(--color-neutral-l-200) var(--color-neutral-c) var(--color-neutral-h) / 0.99)
|
||||
}
|
||||
23
site/plugins/loop/frontend/src/styles/theme-default.css
Normal file
23
site/plugins/loop/frontend/src/styles/theme-default.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
kirby-loop {
|
||||
/* Color Customization */
|
||||
--color-neutral-h: 900;
|
||||
--color-neutral-c: 0;
|
||||
--color-accent-h: 900;
|
||||
--color-accent-c: 0.18;
|
||||
--color-accent-l: 0.75;
|
||||
--color-accent-dark-factor: 0.4;
|
||||
--color-accent-light-factor: 1.2;
|
||||
|
||||
/* Neutral lightness values */
|
||||
--color-neutral-l-0: 1;
|
||||
--color-neutral-l-100: 0.95;
|
||||
--color-neutral-l-200: 0.9;
|
||||
--color-neutral-l-300: 0.7;
|
||||
--color-neutral-l-400: 0.6;
|
||||
--color-neutral-l-600: 0.4;
|
||||
--color-neutral-l-500: 0.5;
|
||||
--color-neutral-l-700: 0.3;
|
||||
--color-neutral-l-800: 0.2;
|
||||
--color-neutral-l-900: 0.1;
|
||||
--color-neutral-l-1000: 0;
|
||||
}
|
||||
421
site/plugins/loop/frontend/src/styles/variables.css
Normal file
421
site/plugins/loop/frontend/src/styles/variables.css
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
@import "./theme-default.css";
|
||||
@import "./theme-dark.css";
|
||||
|
||||
kirby-loop {
|
||||
/* Colors */
|
||||
--color-base: var(--color-neutral-900);
|
||||
--color-base-background: var(--color-neutral-0);
|
||||
|
||||
--color-base-background-o-5: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.05);
|
||||
--color-base-background-o-10: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.1);
|
||||
--color-base-background-o-20: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.2);
|
||||
--color-base-background-o-50: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.5);
|
||||
--color-base-background-o-60: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.6);
|
||||
--color-base-background-o-75: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.75);
|
||||
--color-base-background-o-95: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.95);
|
||||
|
||||
--color-accent-light: oklch(calc(var(--color-accent-l) * var(--color-accent-light-factor)) var(--color-accent-c) var(--color-accent-h));
|
||||
--color-accent: oklch(var(--color-accent-l) var(--color-accent-c) var(--color-accent-h));
|
||||
--color-accent-dark: oklch(calc(var(--color-accent-l) * var(--color-accent-dark-factor)) var(--color-accent-c) var(--color-accent-h));
|
||||
|
||||
--color-neutral-0: oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-100: oklch(var(--color-neutral-l-100) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-200: oklch(var(--color-neutral-l-200) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-300: oklch(var(--color-neutral-l-300) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-400: oklch(var(--color-neutral-l-400) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-500: oklch(var(--color-neutral-l-500) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-600: oklch(var(--color-neutral-l-600) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-700: oklch(var(--color-neutral-l-700) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-800: oklch(var(--color-neutral-l-800) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-900: oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h));
|
||||
--color-neutral-1000: oklch(var(--color-neutral-l-1000) var(--color-neutral-c) var(--color-neutral-h));
|
||||
|
||||
--color-success: oklch(0.65 0.15 150);
|
||||
--color-warning: oklch(0.75 0.15 80);
|
||||
--color-error: oklch(0.65 0.18 25);
|
||||
--color-info: oklch(0.65 0.15 220);
|
||||
|
||||
--font-family: -apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
sans-serif;
|
||||
|
||||
--line-height: 1.4;
|
||||
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 550;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
--font-size-3: clamp(1.9531rem, 1.4262rem + 1.7565vw, 3.5339rem);
|
||||
--font-size-4: clamp(1.5625rem, 1.2503rem + 1.0408vw, 2.4992rem);
|
||||
--font-size-5: clamp(1.25rem, 1.0775rem + 0.575vw, 1.7675rem);
|
||||
--font-size-6: clamp(1rem, 0.9167rem + 0.2778vw, 1.25rem);
|
||||
--font-size-7: clamp(0.8rem, 0.772rem + 0.0934vw, 0.884rem);
|
||||
--font-size-8: clamp(0.6252rem, 0.6449rem + -0.0165vw, 0.64rem);
|
||||
|
||||
--border-radius-s: 0.125rem;
|
||||
--border-radius: 0.25rem;
|
||||
--border-radius-rounded: 4096px;
|
||||
|
||||
--space-2xs: clamp(0.25rem, 0.2292rem + 0.0694vw, 0.3125rem);
|
||||
--space-xs: clamp(0.5rem, 0.4583rem + 0.1389vw, 0.625rem);
|
||||
--space-s: clamp(1rem, 0.9167rem + 0.2778vw, 1.25rem);
|
||||
--space-m: clamp(1.5rem, 1.375rem + 0.4167vw, 1.875rem);
|
||||
--space-l: clamp(2rem, 1.8333rem + 0.5556vw, 2.5rem);
|
||||
--space-2xs-xs: clamp(0.25rem, 0.125rem + 0.4167vw, 0.625rem);
|
||||
--space-xs-s: clamp(0.5rem, 0.25rem + 0.8333vw, 1.25rem);
|
||||
--space-s-m: clamp(1rem, 0.7083rem + 0.9722vw, 1.875rem);
|
||||
--space-m-l: clamp(1.5rem, 1.1667rem + 1.1111vw, 2.5rem);
|
||||
--space-s-l: clamp(1rem, 0.5rem + 1.6667vw, 2.5rem);
|
||||
|
||||
/* Shadow tokens */
|
||||
--shadow-s: 0 0.1em 0.25em oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.1);
|
||||
--shadow-m: 0 2px 8px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
|
||||
0 8px 16px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
|
||||
0 16px 24px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
|
||||
--shadow-l: 0 4px 16px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08),
|
||||
0 12px 32px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.12),
|
||||
0 24px 48px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.16),
|
||||
0 48px 80px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.08);
|
||||
--shadow-light-edge: inset 1px 1px 1px oklch(var(--color-neutral-l-0) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
|
||||
--shadow-dark-edge: inset 0 -1px 1px oklch(var(--color-neutral-l-900) var(--color-neutral-c) var(--color-neutral-h) / 0.3);
|
||||
|
||||
/* Backdrop tokens */
|
||||
--backdrop-blur: blur(6px);
|
||||
--backdrop-glass: var(--backdrop-blur) saturate(1.4) brightness(1.2);
|
||||
|
||||
/* Background tokens */
|
||||
--background-glass: linear-gradient(135deg, transparent, var(--color-base-background-o-95));
|
||||
--background-glass-frosted: linear-gradient(0deg, var(--color-base-background-o-75) 0%, var(--color-base-background-o-95) 50%);
|
||||
|
||||
/* Opacity tokens */
|
||||
--opacity-subtle: 0.5;
|
||||
--opacity-medium: 0.7;
|
||||
--opacity-strong: 0.9;
|
||||
|
||||
/* Outline tokens */
|
||||
--outline-color: var(--color-accent);
|
||||
--outline-offset: 0.25rem;
|
||||
|
||||
/* Transition tokens */
|
||||
--transition-duration: 0.2s;
|
||||
--transition-duration-jump: 0.4s;
|
||||
--transition-easing-jump: cubic-bezier(0.44, 1.2, 0.64, 1);
|
||||
--transition-easing: cubic-bezier(0, 0, 0.2, 1);
|
||||
|
||||
/* Z-index tokens */
|
||||
--z-loop-marker: 9998;
|
||||
--z-loop-panel: 9999;
|
||||
--z-loop-dialog: 10000;
|
||||
|
||||
/* Author */
|
||||
--author-avatar-color: var(--color-neutral-600);
|
||||
--author-avatar-background-color: var(--color-neutral-100);
|
||||
--author-avatar-size: 2.5rem;
|
||||
--author-avatar-border-radius: var(--border-radius-rounded);
|
||||
--author-avatar-font-size: var(--font-size-6);
|
||||
|
||||
/* Button */
|
||||
--button-background: transparent;
|
||||
--button-color: var(--color-neutral-600);
|
||||
--button-border-radius: var(--border-radius);
|
||||
--button-padding: 0 var(--space-xs);
|
||||
--button-gap: var(--space-2xs);
|
||||
--button-font-size: var(--font-size-7);
|
||||
--button-font-weight: var(--font-weight-medium);
|
||||
--button-height: 2.25rem;
|
||||
--button-transition: var(--transition-duration) var(--transition-easing);
|
||||
--button-outline-color: var(--outline-color);
|
||||
--button-outline-offset: var(--outline-offset);
|
||||
|
||||
--button-hover-color: var(--color-neutral-900);
|
||||
--button-hover-background: var(--color-neutral-200);
|
||||
|
||||
--button-header-background: transparent;
|
||||
--button-header-height: 3rem;
|
||||
--button-header-padding: 0 var(--space-s);
|
||||
--button-header-hover-background: var(--color-base-background-o-95);
|
||||
--button-header-blend-mode: multiply;
|
||||
|
||||
--button-panel-background: transparent;
|
||||
--button-panel-padding: 0 calc(var(--space-s) * 0.4);
|
||||
|
||||
--button-solid-background: var(--color-neutral-100);
|
||||
--button-solid-hover-color: var(--color-neutral-900);
|
||||
--button-solid-hover-background: var(--color-neutral-200);
|
||||
|
||||
--button-small-height: 1.5rem;
|
||||
--button-small-font-size: var(--font-size-7);
|
||||
|
||||
--button-icon-background: var(--color-neutral-0);
|
||||
--button-icon-color: var(--color-neutral-500);
|
||||
--button-icon-height: 3rem;
|
||||
--button-icon-shadow: var(--shadow-s);
|
||||
--button-icon-border-radius: var(--border-radius-rounded);
|
||||
--button-icon-font-size: var(--font-size-6);
|
||||
--button-icon-hover-background: var(--color-neutral-200);
|
||||
--button-icon-hover-color: var(--color-neutral-900);
|
||||
|
||||
--button-marker-background: var(--color-accent);
|
||||
--button-marker-color: var(--color-accent-dark);
|
||||
--button-marker-font-weight: var(--font-weight-bold);
|
||||
--button-marker-border-radius: var(--border-radius-rounded);
|
||||
--button-marker-highlighted-background: var(--color-accent);
|
||||
--button-marker-highlighted-color: var(--color-accent-dark);
|
||||
|
||||
--button-filter-background: transparent;
|
||||
--button-filter-color: var(--color-neutral-500);
|
||||
--button-filter-height: 1.75rem;
|
||||
--button-filter-font-size: var(--font-size-8);
|
||||
--button-filter-padding: 0 var(--space-xs);
|
||||
--button-filter-border-radius: calc(var(--border-radius) - 2px);
|
||||
--button-filter-hover-color: var(--color-neutral-700);
|
||||
--button-filter-hover-background: var(--color-neutral-200);
|
||||
--button-filter-active-background: var(--color-base-background);
|
||||
--button-filter-active-color: var(--color-base);
|
||||
--button-filter-active-font-weight: var(--font-weight-medium);
|
||||
|
||||
--button-menu-item-background: transparent;
|
||||
--button-menu-item-color: var(--color-neutral-700);
|
||||
--button-menu-item-padding: var(--space-2xs) var(--space-xs);
|
||||
--button-menu-item-border-radius: calc(var(--border-radius) - 2px);
|
||||
--button-menu-item-font-size: var(--font-size-7);
|
||||
--button-menu-item-gap: var(--space-2xs);
|
||||
--button-menu-item-hover-background: var(--color-neutral-100);
|
||||
--button-menu-item-hover-color: var(--color-neutral-900);
|
||||
--button-menu-item-active-background: var(--color-accent-light);
|
||||
--button-menu-item-active-color: var(--color-accent-dark);
|
||||
--button-menu-item-active-font-weight: var(--font-weight-medium);
|
||||
|
||||
--button-active-background: var(--color-accent);
|
||||
--button-active-color: var(--color-accent-dark);
|
||||
|
||||
--button-disabled-opacity: var(--opacity-subtle);
|
||||
--button-disabled-hover-color: var(--color-neutral-700);
|
||||
--button-disabled-hover-background: var(--color-neutral-100);
|
||||
|
||||
/* Comment */
|
||||
--comment-avatar-size: 2.5rem;
|
||||
--comment-marker-background: var(--color-neutral-200);
|
||||
--comment-marker-color: var(--color-neutral-800);
|
||||
--comment-line-background: var(--color-neutral-100);
|
||||
--comment-line-width: 0.1rem;
|
||||
--comment-line-offset: calc(var(--space-s) + var(--comment-avatar-size) / 2);
|
||||
|
||||
--comment-header-font-size: var(--font-size-7);
|
||||
--comment-header-padding: var(--space-s);
|
||||
--comment-header-gap: var(--space-s);
|
||||
--comment-header-outline-color: var(--outline-color);
|
||||
--comment-header-outline-offset: -2px;
|
||||
--comment-header-border-radius: var(--border-radius);
|
||||
|
||||
--comment-content-padding: var(--space-xs);
|
||||
--comment-content-background: var(--color-neutral-100);
|
||||
--comment-content-background-dark: var(--color-neutral-200);
|
||||
--comment-content-border-radius: var(--border-radius);
|
||||
|
||||
--comment-author-gap: var(--space-xs);
|
||||
--comment-author-margin-bottom: var(--space-2xs);
|
||||
--comment-timestamp-font-size: var(--font-size-8);
|
||||
--comment-timestamp-color: var(--color-neutral-300);
|
||||
|
||||
--comment-replies-padding: 0 var(--space-s);
|
||||
--comment-replies-gap: var(--space-s);
|
||||
|
||||
--comment-footer-padding: var(--space-s);
|
||||
--comment-footer-gap: var(--space-s);
|
||||
--comment-buttons-gap: var(--space-xs);
|
||||
|
||||
/* CommentDialog */
|
||||
--comment-dialog-position: absolute;
|
||||
--comment-dialog-max-width: 300px;
|
||||
--comment-dialog-border-radius: var(--border-radius);
|
||||
--comment-dialog-shadow: var(--shadow-s);
|
||||
--comment-dialog-backdrop-background: transparent;
|
||||
--comment-dialog-textarea-font-size: var(--font-size-6);
|
||||
|
||||
/* CommentForm */
|
||||
--comment-form-background: var(--color-base-background);
|
||||
--comment-form-color: var(--color-base);
|
||||
--comment-form-border: 1px solid var(--color-neutral-200);
|
||||
--comment-form-border-radius: var(--border-radius);
|
||||
|
||||
--comment-form-textarea-height: 15ch;
|
||||
--comment-form-textarea-padding: var(--space-s);
|
||||
--comment-form-textarea-background: var(--color-base-background);
|
||||
--comment-form-textarea-font-family: var(--font-family);
|
||||
--comment-form-textarea-font-size: var(--font-size-7);
|
||||
|
||||
--comment-form-footer-padding: var(--space-xs);
|
||||
--comment-form-footer-gap: var(--space-xs);
|
||||
|
||||
--comment-form-hint-font-size: var(--font-size-8);
|
||||
--comment-form-hint-color: var(--color-neutral-300);
|
||||
--comment-form-hint-padding: 0 var(--space-xs) var(--space-xs) var(--space-xs);
|
||||
|
||||
/* ContextMenu */
|
||||
--context-menu-container-bottom: var(--space-s);
|
||||
--context-menu-container-right: var(--space-s);
|
||||
--context-menu-container-z-index: 10;
|
||||
|
||||
--context-menu-trigger-size: 2.5rem;
|
||||
--context-menu-trigger-border-radius: var(--border-radius-rounded);
|
||||
|
||||
--context-menu-background: var(--color-base-background);
|
||||
--context-menu-border-radius: var(--border-radius);
|
||||
--context-menu-shadow: var(--shadow-s);
|
||||
--context-menu-padding: var(--space-xs);
|
||||
--context-menu-min-width: 12rem;
|
||||
--context-menu-backdrop-background: transparent;
|
||||
|
||||
--context-menu-section-gap: var(--space-2xs);
|
||||
|
||||
--context-menu-title-font-size: var(--font-size-8);
|
||||
--context-menu-title-font-weight: var(--font-weight-medium);
|
||||
--context-menu-title-color: var(--color-neutral-500);
|
||||
--context-menu-title-margin-bottom: var(--space-2xs);
|
||||
--context-menu-title-letter-spacing: 0.05em;
|
||||
|
||||
--context-menu-filter-gap: 1px;
|
||||
|
||||
--context-menu-filter-dot-size: 0.5em;
|
||||
--context-menu-filter-dot-border-radius: 50%;
|
||||
--context-menu-filter-dot-margin-right: var(--space-2xs);
|
||||
--context-menu-filter-dot-open-background: var(--color-accent);
|
||||
--context-menu-filter-dot-resolved-background: var(--color-neutral-400);
|
||||
|
||||
/* Header */
|
||||
--header-position: fixed;
|
||||
--header-top: var(--space-xs);
|
||||
--header-transform: translateX(-50%);
|
||||
--header-color: var(--color-base);
|
||||
--header-border-radius: var(--border-radius-rounded);
|
||||
--header-z-index: 9999;
|
||||
--header-bottom-position: var(--space-xs);
|
||||
--header-backdrop-filter: var(--backdrop-glass);
|
||||
--header-background: var(--background-glass);
|
||||
|
||||
--header-count-size: 2rem;
|
||||
--header-count-border-radius: var(--border-radius-rounded);
|
||||
--header-count-backdrop-filter: var(--backdrop-glass);
|
||||
--header-count-background: var(--background-glass);
|
||||
|
||||
/* Marker */
|
||||
--marker-size: 2rem;
|
||||
--marker-position: absolute;
|
||||
--marker-z-index: var(--z-loop-marker);
|
||||
--marker-transform: translate(-50%, -50%);
|
||||
--marker-border-radius: var(--border-radius-rounded);
|
||||
|
||||
/* Panel */
|
||||
--panel-width: 380px;
|
||||
--panel-mobile-width: 85svw;
|
||||
--panel-position: fixed;
|
||||
--panel-right: var(--space-xs);
|
||||
--panel-top: var(--space-xs);
|
||||
--panel-height: calc(100svh - var(--space-xs) * 2);
|
||||
--panel-transform-closed: translateX(calc(100% + var(--space-xs)));
|
||||
--panel-transform-open: translateX(0);
|
||||
--panel-color: var(--color-base);
|
||||
--panel-border-radius: var(--border-radius);
|
||||
--panel-border-top-left-radius: 0;
|
||||
--panel-transition: var(--transition-duration-jump) var(--transition-easing-jump);
|
||||
--panel-z-index: var(--z-loop-panel);
|
||||
--panel-shadow: var(--shadow-m);
|
||||
|
||||
--panel-header-transform-closed: translate(-95%);
|
||||
--panel-header-transform-open: translate(calc(-100% + 1px));
|
||||
--panel-header-transform-hover: translate(calc(-100% + 1px));
|
||||
--panel-header-border-radius: var(--border-radius-rounded);
|
||||
--panel-header-gap: var(--space-xs);
|
||||
--panel-header-backdrop-filter: var(--backdrop-glass);
|
||||
--panel-header-background: var(--background-glass);
|
||||
|
||||
--panel-threads-background: var(--color-base-background-o-95);
|
||||
--panel-threads-backdrop: var(--backdrop-blur);
|
||||
--panel-threads-border-radius: var(--border-radius);
|
||||
--panel-threads-border-top-left-radius: 0;
|
||||
--panel-threads-padding: 0 0 var(--space-s) 0;
|
||||
--panel-threads-item-margin: var(--space-s);
|
||||
--panel-threads-scrollbar-width: thin;
|
||||
|
||||
--panel-no-threads-padding: var(--space-s) var(--space-l);
|
||||
--panel-no-threads-font-size: var(--font-size-6);
|
||||
--panel-no-threads-color: var(--color-neutral-300);
|
||||
|
||||
/* Reply */
|
||||
--reply-gap: var(--space-s);
|
||||
--reply-content-padding: var(--space-xs);
|
||||
--reply-content-background: var(--color-neutral-100);
|
||||
--reply-content-background-dark: var(--color-neutral-200);
|
||||
--reply-content-border-radius: var(--border-radius);
|
||||
|
||||
--reply-header-gap: var(--space-xs);
|
||||
--reply-header-margin-bottom: var(--space-2xs);
|
||||
--reply-timestamp-font-size: var(--font-size-8);
|
||||
--reply-timestamp-color: var(--color-neutral-300);
|
||||
|
||||
/* WelcomeDialog */
|
||||
--welcome-dialog-background: var(--background-glass-frosted);
|
||||
--welcome-dialog-backdrop-filter: var(--backdrop-glass);
|
||||
--welcome-dialog-border: 0px;
|
||||
--welcome-dialog-border-radius: var(--border-radius);
|
||||
--welcome-dialog-shadow: var(--shadow-l), var(--shadow-light-edge),
|
||||
var(--shadow-dark-edge);
|
||||
--welcome-dialog-max-width: 500px;
|
||||
|
||||
--welcome-dialog-backdrop-background: var(--color-base-background-o-10);
|
||||
--welcome-dialog-backdrop-backdrop-filter: none;
|
||||
|
||||
--welcome-dialog-form-padding: var(--space-l);
|
||||
|
||||
--welcome-dialog-title-margin: 0 0 var(--space-s) 0;
|
||||
--welcome-dialog-title-font-size: var(--font-size-4);
|
||||
--welcome-dialog-title-color: var(--color-base);
|
||||
--welcome-dialog-title-font-weight: var(--font-weight-bold);
|
||||
|
||||
--welcome-dialog-text-margin: 0 0 var(--space-m) 0;
|
||||
--welcome-dialog-text-font-size: var(--font-size-6);
|
||||
--welcome-dialog-text-color: var(--color-neutral-600);
|
||||
--welcome-dialog-text-line-height: var(--line-height);
|
||||
|
||||
--welcome-dialog-name-section-margin: var(--space-l);
|
||||
|
||||
--welcome-dialog-input-border: 1px solid var(--color-neutral-300);
|
||||
--welcome-dialog-input-border-radius: var(--border-radius-s);
|
||||
--welcome-dialog-input-padding: var(--space-xs);
|
||||
--welcome-dialog-input-font-family: var(--font-family);
|
||||
--welcome-dialog-input-font-size: var(--font-size-6);
|
||||
--welcome-dialog-input-color: var(--color-base);
|
||||
--welcome-dialog-input-background: var(--color-base-background);
|
||||
--welcome-dialog-input-outline-color: var(--outline-color);
|
||||
--welcome-dialog-input-outline-offset: var(--outline-offset);
|
||||
|
||||
--welcome-dialog-footer-gap: var(--space-xs);
|
||||
|
||||
/* Icon */
|
||||
--icon-size: 1em;
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
kirby-loop[data-theme="dark"] {
|
||||
--color-neutral-l-0: 0;
|
||||
--color-neutral-l-100: 0.1;
|
||||
--color-neutral-l-200: 0.2;
|
||||
--color-neutral-l-300: 0.3;
|
||||
--color-neutral-l-400: 0.4;
|
||||
--color-neutral-l-500: 0.5;
|
||||
--color-neutral-l-600: 0.6;
|
||||
--color-neutral-l-700: 0.7;
|
||||
--color-neutral-l-800: 0.9;
|
||||
--color-neutral-l-900: 0.95;
|
||||
--color-neutral-l-1000: 1;
|
||||
}
|
||||
100
site/plugins/loop/frontend/src/types.ts
Normal file
100
site/plugins/loop/frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// TypeScript interfaces for loop
|
||||
|
||||
export interface LoopProps {
|
||||
position: 'top' | 'bottom';
|
||||
language?: string;
|
||||
apibase?: string;
|
||||
pageId: string;
|
||||
authenticated?: 'true' | 'false';
|
||||
'welcome-enabled'?: 'true' | 'false';
|
||||
'welcome-headline'?: string;
|
||||
'welcome-text'?: string;
|
||||
translations?: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number;
|
||||
author: string;
|
||||
url: string;
|
||||
page: string;
|
||||
comment: string;
|
||||
selector: string;
|
||||
selectorOffsetX: number;
|
||||
selectorOffsetY: number;
|
||||
pagePositionX: number;
|
||||
pagePositionY: number;
|
||||
status: string;
|
||||
timestamp: number;
|
||||
lang: string;
|
||||
replies: Reply[];
|
||||
}
|
||||
|
||||
export interface Reply {
|
||||
id?: number;
|
||||
author: string;
|
||||
comment: string;
|
||||
parentId: number | null;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface CommentPayload {
|
||||
url: string;
|
||||
comment: string;
|
||||
selector: string;
|
||||
selectorOffsetX: number;
|
||||
selectorOffsetY: number;
|
||||
pagePositionX: number;
|
||||
pagePositionY: number;
|
||||
parentId: number | null;
|
||||
lang: string;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export interface ReplyPayload {
|
||||
comment: string;
|
||||
parentId: number | null;
|
||||
}
|
||||
|
||||
export interface MarkerPosition {
|
||||
selector: string;
|
||||
selectorOffsetX: number;
|
||||
selectorOffsetY: number;
|
||||
pagePositionX: number;
|
||||
pagePositionY: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
status: 'ok' | 'error';
|
||||
message?: string;
|
||||
code?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export interface CommentsResponse extends ApiResponse {
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
export interface CommentResponse extends ApiResponse {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface ReplyResponse extends ApiResponse {
|
||||
reply: Reply;
|
||||
}
|
||||
|
||||
// Store interfaces
|
||||
export interface FormData {
|
||||
text: string;
|
||||
parentId: number | null;
|
||||
}
|
||||
|
||||
export interface UIState {
|
||||
open: boolean;
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
export interface APIStore {
|
||||
comments: Comment[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
2
site/plugins/loop/frontend/src/vite-env.d.ts
vendored
Normal file
2
site/plugins/loop/frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
10
site/plugins/loop/frontend/svelte.config.js
Normal file
10
site/plugins/loop/frontend/svelte.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
compilerOptions: {
|
||||
customElement: true,
|
||||
},
|
||||
};
|
||||
20
site/plugins/loop/frontend/tsconfig.app.json
Normal file
20
site/plugins/loop/frontend/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||
}
|
||||
7
site/plugins/loop/frontend/tsconfig.json
Normal file
7
site/plugins/loop/frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
site/plugins/loop/frontend/tsconfig.node.json
Normal file
24
site/plugins/loop/frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
69
site/plugins/loop/frontend/vite.config.ts
Normal file
69
site/plugins/loop/frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/// <reference types="vitest" />
|
||||
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
|
||||
import { ViteEjsPlugin } from 'vite-plugin-ejs'
|
||||
import { browserslistToTargets } from 'lightningcss';
|
||||
import browserslist from "browserslist"
|
||||
|
||||
// Isomorphic dirname
|
||||
const _dirname =
|
||||
typeof __dirname !== "undefined"
|
||||
? __dirname
|
||||
: dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Config
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
return {
|
||||
base: env.VITE_DEMO_BASE,
|
||||
compilerOptions: {
|
||||
hmr: !process.env.VITEST && mode !== 'production',
|
||||
},
|
||||
build: {
|
||||
cssMinify: 'lightningcss',
|
||||
minify: true,
|
||||
lib: {
|
||||
entry: resolve(_dirname, "src/main.ts"),
|
||||
name: "Loop",
|
||||
fileName: "loop",
|
||||
formats: ["es"],
|
||||
},
|
||||
outDir: "../assets",
|
||||
},
|
||||
css: {
|
||||
transformer: 'lightningcss',
|
||||
lightningcss: {
|
||||
drafts: {
|
||||
customMedia: true
|
||||
},
|
||||
targets: browserslistToTargets(browserslist(["last 2 versions", ">= 0.4%", "not dead", "Firefox ESR", "not op_mini all", "not and_uc > 0"]))
|
||||
}
|
||||
},
|
||||
define: {
|
||||
APP_VERSION: JSON.stringify(process.env.npm_package_version),
|
||||
},
|
||||
plugins: [
|
||||
svelte({ compilerOptions: { customElement: true } }),
|
||||
cssInjectedByJsPlugin(),
|
||||
ViteEjsPlugin((viteConfig) => ({
|
||||
// viteConfig is the current Vite resolved config
|
||||
env: viteConfig.env,
|
||||
}))
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
},
|
||||
server: {
|
||||
allowedHosts: ['kirby-loop.test'],
|
||||
cors: {
|
||||
// Allow ddev and .test domains
|
||||
origin: /https?:\/\/([A-Za-z0-9\-\.]+)?(\.(ddev\.site|test))(?::\d+)?$/,
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
333
site/plugins/loop/index.php
Normal file
333
site/plugins/loop/index.php
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<?php
|
||||
|
||||
use \Kirby\Cms\App as Kirby;
|
||||
use \Moinframe\Loop\Options;
|
||||
use \Moinframe\Loop\Routes;
|
||||
use \Kirby\Filesystem\F;
|
||||
|
||||
F::loadClasses([
|
||||
'moinframe\\loop\\App' => 'src/App.php',
|
||||
'moinframe\\loop\\Database' => 'src/Database.php',
|
||||
'moinframe\\loop\\Middleware' => 'src/Middleware.php',
|
||||
'moinframe\\loop\\Options' => 'src/Options.php',
|
||||
'moinframe\\loop\\Routes' => 'src/Routes.php',
|
||||
'moinframe\\loop\\Models\\Comment' => 'src/Models/Comment.php',
|
||||
'moinframe\\loop\\Models\\Reply' => 'src/Models/Reply.php',
|
||||
'moinframe\\loop\\Enums\\CommentStatus' => 'src/Enums/CommentStatus.php',
|
||||
], __DIR__);
|
||||
|
||||
Kirby::plugin('moinframe/loop', [
|
||||
'translations' => [
|
||||
'en' => [
|
||||
// General errors
|
||||
'moinframe.loop.csrf.invalid' => 'Invalid CSRF token',
|
||||
'moinframe.loop.field.required' => 'Missing required field: {field}',
|
||||
|
||||
// Page errors
|
||||
'moinframe.loop.page.not.found' => 'Page with id {pageId} not found',
|
||||
'moinframe.loop.page.path.not.found' => 'Page not found: {path}',
|
||||
|
||||
// Comment validation
|
||||
'moinframe.loop.comment.required' => 'Comment text is required',
|
||||
'moinframe.loop.comment.max.length' => 'Comment text must be less than 5000 characters',
|
||||
'moinframe.loop.comment.validation.failed' => 'Comment validation failed: {errors}',
|
||||
'moinframe.loop.comment.validation.error' => 'Comment validation failed: {errors}',
|
||||
'moinframe.loop.comment.creation.failed' => 'Comment creation failed: {error}',
|
||||
'moinframe.loop.comment.add.failed' => 'Failed to add comment: {error}',
|
||||
|
||||
// Reply validation
|
||||
'moinframe.loop.reply.validation.failed' => 'Reply validation failed: {errors}',
|
||||
'moinframe.loop.reply.validation.error' => 'Reply validation failed: {errors}',
|
||||
'moinframe.loop.reply.creation.failed' => 'Reply creation failed: {error}',
|
||||
'moinframe.loop.reply.add.failed' => 'Failed to add reply: {error}',
|
||||
'moinframe.loop.reply.index.error' => 'Reply {index}: {error}',
|
||||
|
||||
// Author validation
|
||||
'moinframe.loop.author.required' => 'Author is required',
|
||||
'moinframe.loop.author.max.length' => 'Author name must be less than 255 characters',
|
||||
|
||||
// Page field validation
|
||||
'moinframe.loop.page.required' => 'Page identifier is required',
|
||||
'moinframe.loop.page.max.length' => 'Page identifier must be less than 255 characters',
|
||||
|
||||
// Selector validation
|
||||
'moinframe.loop.selector.required' => 'Element selector is required',
|
||||
'moinframe.loop.selector.max.length' => 'Element selector must be less than 1000 characters',
|
||||
'moinframe.loop.selector.offset.x.min' => 'Selector offset X must be non-negative',
|
||||
'moinframe.loop.selector.offset.y.min' => 'Selector offset Y must be non-negative',
|
||||
|
||||
// URL validation
|
||||
'moinframe.loop.url.format.invalid' => 'URL format is invalid',
|
||||
'moinframe.loop.url.max.length' => 'URL must be less than 2048 characters',
|
||||
|
||||
// Position validation
|
||||
'moinframe.loop.page.position.x.min' => 'Page position X must be non-negative',
|
||||
'moinframe.loop.page.position.y.min' => 'Page position Y must be non-negative',
|
||||
|
||||
// Other field validation
|
||||
'moinframe.loop.timestamp.min' => 'Timestamp must be non-negative',
|
||||
'moinframe.loop.parent.id.min' => 'Parent ID must be non-negative',
|
||||
'moinframe.loop.parent.id.required' => 'Valid parent comment ID is required',
|
||||
|
||||
// Welcome dialog
|
||||
'moinframe.loop.welcome.headline' => 'Welcome! 👋',
|
||||
'moinframe.loop.welcome.text' => 'We\'re excited to hear your thoughts! This page has an interactive feedback system that lets you comment directly on any element. Simply use the action bar at the {position} of your screen to switch between browsing and commenting mode. When in commenting mode, click anywhere on the page to leave your feedback.',
|
||||
|
||||
// Frontend UI translations
|
||||
'moinframe.loop.ui.comment.placeholder' => 'Enter your comment...',
|
||||
'moinframe.loop.ui.comment.submit' => 'Submit',
|
||||
'moinframe.loop.ui.comment.cancel' => 'Cancel',
|
||||
'moinframe.loop.ui.comment.keyboardHint' => '⌘+Enter or Ctrl+Enter to submit',
|
||||
'moinframe.loop.ui.comment.replies.aria.label' => 'Show replies',
|
||||
'moinframe.loop.ui.reply.placeholder' => 'Write a reply...',
|
||||
'moinframe.loop.ui.reply.submit' => 'Reply',
|
||||
'moinframe.loop.ui.panel.no.comments' => 'No comments. Add your first comment to get started.',
|
||||
'moinframe.loop.ui.header.browse.mode' => 'Browse',
|
||||
'moinframe.loop.ui.header.comment.mode' => 'Comment',
|
||||
'moinframe.loop.ui.header.aria.count' => 'unresolved comments',
|
||||
'moinframe.loop.ui.welcome.guest.name.placeholder' => 'Enter your name',
|
||||
'moinframe.loop.ui.welcome.continue' => 'Continue',
|
||||
'moinframe.loop.ui.welcome.dismiss' => 'Dismiss',
|
||||
'moinframe.loop.ui.header.position.top' => 'top',
|
||||
'moinframe.loop.ui.header.position.bottom' => 'bottom',
|
||||
'moinframe.loop.ui.comment.mark.solved' => 'Resolve',
|
||||
'moinframe.loop.ui.comment.mark.unsolved' => 'Reopen',
|
||||
'moinframe.loop.ui.comment.maker.aria.label' => 'Jump to marker',
|
||||
'moinframe.loop.ui.comment.summary.aria.label' => 'Comment by',
|
||||
'moinframe.loop.ui.reply.aria.label' => 'Reply by',
|
||||
'moinframe.loop.ui.panel.open' => 'Open comments',
|
||||
'moinframe.loop.ui.panel.show.resolved' => 'Show Resolved Only',
|
||||
'moinframe.loop.ui.panel.show.all' => 'Show All Comments',
|
||||
'moinframe.loop.ui.panel.filter.open' => 'Open',
|
||||
'moinframe.loop.ui.panel.filter.resolved' => 'Resolved',
|
||||
'moinframe.loop.ui.panel.filter.open.active' => 'Show open comments (currently selected)',
|
||||
'moinframe.loop.ui.panel.filter.open.inactive' => 'Show open comments',
|
||||
'moinframe.loop.ui.panel.filter.resolved.active' => 'Show resolved comments (currently selected)',
|
||||
'moinframe.loop.ui.panel.filter.resolved.inactive' => 'Show resolved comments',
|
||||
'moinframe.loop.ui.panel.menu.open' => 'Open menu',
|
||||
'moinframe.loop.ui.panel.menu.filter.title' => 'Show Comments',
|
||||
'moinframe.loop.ui.panel.no.resolved' => 'No resolved comments yet.',
|
||||
|
||||
// Time formatting
|
||||
'moinframe.loop.ui.time.just_now' => 'just now',
|
||||
'moinframe.loop.ui.time.minute_ago' => 'a minute ago',
|
||||
'moinframe.loop.ui.time.minutes_ago' => '{count} minutes ago',
|
||||
'moinframe.loop.ui.time.hour_ago' => 'an hour ago',
|
||||
'moinframe.loop.ui.time.hours_ago' => '{count} hours ago',
|
||||
'moinframe.loop.ui.time.yesterday' => 'yesterday',
|
||||
'moinframe.loop.ui.time.days_ago' => '{count} days ago'
|
||||
],
|
||||
'de' => [
|
||||
// General errors
|
||||
'moinframe.loop.csrf.invalid' => 'Ungültiges CSRF-Token',
|
||||
'moinframe.loop.field.required' => 'Pflichtfeld fehlt: {field}',
|
||||
|
||||
// Page errors
|
||||
'moinframe.loop.page.not.found' => 'Seite mit ID {pageId} nicht gefunden',
|
||||
'moinframe.loop.page.path.not.found' => 'Seite nicht gefunden: {path}',
|
||||
|
||||
// Comment validation
|
||||
'moinframe.loop.comment.required' => 'Kommentartext ist erforderlich',
|
||||
'moinframe.loop.comment.max.length' => 'Kommentartext darf maximal 5000 Zeichen lang sein',
|
||||
'moinframe.loop.comment.validation.failed' => 'Kommentar-Validierung fehlgeschlagen: {errors}',
|
||||
'moinframe.loop.comment.validation.error' => 'Kommentar-Validierung fehlgeschlagen: {errors}',
|
||||
'moinframe.loop.comment.creation.failed' => 'Kommentar-Erstellung fehlgeschlagen: {error}',
|
||||
'moinframe.loop.comment.add.failed' => 'Kommentar konnte nicht hinzugefügt werden: {error}',
|
||||
|
||||
// Reply validation
|
||||
'moinframe.loop.reply.validation.failed' => 'Antwort-Validierung fehlgeschlagen: {errors}',
|
||||
'moinframe.loop.reply.validation.error' => 'Antwort-Validierung fehlgeschlagen: {errors}',
|
||||
'moinframe.loop.reply.creation.failed' => 'Antwort-Erstellung fehlgeschlagen: {error}',
|
||||
'moinframe.loop.reply.add.failed' => 'Antwort konnte nicht hinzugefügt werden: {error}',
|
||||
'moinframe.loop.reply.index.error' => 'Antwort {index}: {error}',
|
||||
|
||||
// Author validation
|
||||
'moinframe.loop.author.required' => 'Autor ist erforderlich',
|
||||
'moinframe.loop.author.max.length' => 'Autorname darf maximal 255 Zeichen lang sein',
|
||||
|
||||
// Page field validation
|
||||
'moinframe.loop.page.required' => 'Seiten-Identifikator ist erforderlich',
|
||||
'moinframe.loop.page.max.length' => 'Seiten-Identifikator darf maximal 255 Zeichen lang sein',
|
||||
|
||||
// Selector validation
|
||||
'moinframe.loop.selector.required' => 'Element-Selektor ist erforderlich',
|
||||
'moinframe.loop.selector.max.length' => 'Element-Selektor darf maximal 1000 Zeichen lang sein',
|
||||
'moinframe.loop.selector.offset.x.min' => 'Selektor-Offset X muss nicht-negativ sein',
|
||||
'moinframe.loop.selector.offset.y.min' => 'Selektor-Offset Y muss nicht-negativ sein',
|
||||
|
||||
// URL validation
|
||||
'moinframe.loop.url.format.invalid' => 'URL-Format ist ungültig',
|
||||
'moinframe.loop.url.max.length' => 'URL darf maximal 2048 Zeichen lang sein',
|
||||
|
||||
// Position validation
|
||||
'moinframe.loop.page.position.x.min' => 'Seitenposition X muss nicht-negativ sein',
|
||||
'moinframe.loop.page.position.y.min' => 'Seitenposition Y muss nicht-negativ sein',
|
||||
|
||||
// Other field validation
|
||||
'moinframe.loop.timestamp.min' => 'Zeitstempel muss nicht-negativ sein',
|
||||
'moinframe.loop.parent.id.min' => 'Eltern-ID muss nicht-negativ sein',
|
||||
'moinframe.loop.parent.id.required' => 'Gültige Eltern-Kommentar-ID ist erforderlich',
|
||||
|
||||
// Welcome dialog
|
||||
'moinframe.loop.welcome.headline' => 'Willkommen! 👋',
|
||||
'moinframe.loop.welcome.text' => 'Wir freuen uns auf Ihr Feedback! Diese Seite verfügt über ein interaktives Feedback-System, mit dem Sie direkt zu jedem Element kommentieren können. Verwenden Sie einfach die Aktionsleiste {position} an ihrem Bildschirm, um zwischen Browse- und Kommentar-Modus zu wechseln. Im Kommentar-Modus klicken Sie einfach irgendwo auf die Seite, um Ihr Feedback zu hinterlassen.',
|
||||
|
||||
// Frontend UI translations
|
||||
'moinframe.loop.ui.comment.placeholder' => 'Ihr Kommentar...',
|
||||
'moinframe.loop.ui.comment.submit' => 'Senden',
|
||||
'moinframe.loop.ui.comment.cancel' => 'Abbrechen',
|
||||
'moinframe.loop.ui.comment.keyboardHint' => '⌘+Enter oder Strg+Enter zum Senden',
|
||||
'moinframe.loop.ui.comment.replies.aria.label' => 'Antworten anzeigen',
|
||||
'moinframe.loop.ui.comment.maker.aria.label' => 'Springe zu Marker',
|
||||
'moinframe.loop.ui.comment.summary.aria.label' => 'Kommentar von',
|
||||
'moinframe.loop.ui.reply.aria.label' => 'Antwort von',
|
||||
'moinframe.loop.ui.reply.placeholder' => 'Antwort schreiben...',
|
||||
'moinframe.loop.ui.reply.submit' => 'Antworten',
|
||||
'moinframe.loop.ui.panel.no.comments' => 'Keine Kommentare. Fügen Sie Ihren ersten Kommentar hinzu, um zu beginnen.',
|
||||
'moinframe.loop.ui.panel.open' => 'Kommentare öffnen',
|
||||
'moinframe.loop.ui.header.browse.mode' => 'Navigieren',
|
||||
'moinframe.loop.ui.header.comment.mode' => 'Kommentieren',
|
||||
'moinframe.loop.ui.header.aria.count' => 'offene Kommentare',
|
||||
'moinframe.loop.ui.welcome.guest.name.placeholder' => 'Geben Sie Ihren Namen ein',
|
||||
'moinframe.loop.ui.welcome.continue' => 'Weiter',
|
||||
'moinframe.loop.ui.welcome.dismiss' => 'Schließen',
|
||||
'moinframe.loop.ui.header.position.top' => 'oben',
|
||||
'moinframe.loop.ui.header.position.bottom' => 'unten',
|
||||
'moinframe.loop.ui.comment.mark.solved' => 'Erledigt',
|
||||
'moinframe.loop.ui.comment.mark.unsolved' => 'Wieder öffnen',
|
||||
'moinframe.loop.ui.panel.show.resolved' => 'Nur erledigte anzeigen',
|
||||
'moinframe.loop.ui.panel.show.all' => 'Alle Kommentare anzeigen',
|
||||
'moinframe.loop.ui.panel.filter.open' => 'Offen',
|
||||
'moinframe.loop.ui.panel.filter.resolved' => 'Erledigt',
|
||||
'moinframe.loop.ui.panel.filter.open.active' => 'Offene Kommentare anzeigen (aktuell ausgewählt)',
|
||||
'moinframe.loop.ui.panel.filter.open.inactive' => 'Offene Kommentare anzeigen',
|
||||
'moinframe.loop.ui.panel.filter.resolved.active' => 'Erledigte Kommentare anzeigen (aktuell ausgewählt)',
|
||||
'moinframe.loop.ui.panel.filter.resolved.inactive' => 'Erledigte Kommentare anzeigen',
|
||||
'moinframe.loop.ui.panel.menu.open' => 'Menü öffnen',
|
||||
'moinframe.loop.ui.panel.menu.filter.title' => 'Kommentare anzeigen',
|
||||
'moinframe.loop.ui.panel.no.resolved' => 'Noch keine erledigten Kommentare.',
|
||||
|
||||
// Time formatting
|
||||
'moinframe.loop.ui.time.just_now' => 'gerade eben',
|
||||
'moinframe.loop.ui.time.minute_ago' => 'vor einer Minute',
|
||||
'moinframe.loop.ui.time.minutes_ago' => 'vor {count} Minuten',
|
||||
'moinframe.loop.ui.time.hour_ago' => 'vor einer Stunde',
|
||||
'moinframe.loop.ui.time.hours_ago' => 'vor {count} Stunden',
|
||||
'moinframe.loop.ui.time.yesterday' => 'gestern',
|
||||
'moinframe.loop.ui.time.days_ago' => 'vor {count} Tagen'
|
||||
],
|
||||
'fr' => [
|
||||
// General errors
|
||||
'moinframe.loop.csrf.invalid' => 'Jeton CSRF invalide',
|
||||
'moinframe.loop.field.required' => 'Champ obligatoire manquant : {field}',
|
||||
|
||||
// Page errors
|
||||
'moinframe.loop.page.not.found' => 'Page avec l\'id {pageId} introuvable',
|
||||
'moinframe.loop.page.path.not.found' => 'Page introuvable : {path}',
|
||||
|
||||
// Comment validation
|
||||
'moinframe.loop.comment.required' => 'Le texte du commentaire est requis',
|
||||
'moinframe.loop.comment.max.length' => 'Le commentaire ne doit pas dépasser 5000 caractères',
|
||||
'moinframe.loop.comment.validation.failed' => 'Validation du commentaire échouée : {errors}',
|
||||
'moinframe.loop.comment.validation.error' => 'Validation du commentaire échouée : {errors}',
|
||||
'moinframe.loop.comment.creation.failed' => 'Création du commentaire échouée : {error}',
|
||||
'moinframe.loop.comment.add.failed' => 'Impossible d\'ajouter le commentaire : {error}',
|
||||
|
||||
// Reply validation
|
||||
'moinframe.loop.reply.validation.failed' => 'Validation de la réponse échouée : {errors}',
|
||||
'moinframe.loop.reply.validation.error' => 'Validation de la réponse échouée : {errors}',
|
||||
'moinframe.loop.reply.creation.failed' => 'Création de la réponse échouée : {error}',
|
||||
'moinframe.loop.reply.add.failed' => 'Impossible d\'ajouter la réponse : {error}',
|
||||
'moinframe.loop.reply.index.error' => 'Réponse {index} : {error}',
|
||||
|
||||
// Author validation
|
||||
'moinframe.loop.author.required' => 'L\'auteur est requis',
|
||||
'moinframe.loop.author.max.length' => 'Le nom de l\'auteur ne doit pas dépasser 255 caractères',
|
||||
|
||||
// Page field validation
|
||||
'moinframe.loop.page.required' => 'L\'identifiant de page est requis',
|
||||
'moinframe.loop.page.max.length' => 'L\'identifiant de page ne doit pas dépasser 255 caractères',
|
||||
|
||||
// Selector validation
|
||||
'moinframe.loop.selector.required' => 'Le sélecteur d\'élément est requis',
|
||||
'moinframe.loop.selector.max.length' => 'Le sélecteur d\'élément ne doit pas dépasser 1000 caractères',
|
||||
'moinframe.loop.selector.offset.x.min' => 'L\'offset X du sélecteur doit être positif',
|
||||
'moinframe.loop.selector.offset.y.min' => 'L\'offset Y du sélecteur doit être positif',
|
||||
|
||||
// URL validation
|
||||
'moinframe.loop.url.format.invalid' => 'Format d\'URL invalide',
|
||||
'moinframe.loop.url.max.length' => 'L\'URL ne doit pas dépasser 2048 caractères',
|
||||
|
||||
// Position validation
|
||||
'moinframe.loop.page.position.x.min' => 'La position X doit être positive',
|
||||
'moinframe.loop.page.position.y.min' => 'La position Y doit être positive',
|
||||
|
||||
// Other field validation
|
||||
'moinframe.loop.timestamp.min' => 'L\'horodatage doit être positif',
|
||||
'moinframe.loop.parent.id.min' => 'L\'ID parent doit être positif',
|
||||
'moinframe.loop.parent.id.required' => 'Un ID de commentaire parent valide est requis',
|
||||
|
||||
// Welcome dialog
|
||||
'moinframe.loop.welcome.headline' => 'Nouveauté',
|
||||
'moinframe.loop.welcome.text' => 'Il est désormais possible de commenter directement n\'importe quel élément. Utilisez la barre d\'action en {position} de votre écran pour basculer entre le mode navigation et le mode commentaire. En mode commentaire, cliquez n\'importe où sur la page pour laisser votre message.',
|
||||
|
||||
// Frontend UI translations
|
||||
'moinframe.loop.ui.comment.placeholder' => 'Votre commentaire...',
|
||||
'moinframe.loop.ui.comment.submit' => 'Envoyer',
|
||||
'moinframe.loop.ui.comment.cancel' => 'Annuler',
|
||||
'moinframe.loop.ui.comment.keyboardHint' => '⌘+Entrée ou Ctrl+Entrée pour envoyer',
|
||||
'moinframe.loop.ui.comment.replies.aria.label' => 'Afficher les réponses',
|
||||
'moinframe.loop.ui.comment.maker.aria.label' => 'Aller au marqueur',
|
||||
'moinframe.loop.ui.comment.summary.aria.label' => 'Commentaire de',
|
||||
'moinframe.loop.ui.reply.aria.label' => 'Réponse de',
|
||||
'moinframe.loop.ui.reply.placeholder' => 'Écrire une réponse...',
|
||||
'moinframe.loop.ui.reply.submit' => 'Répondre',
|
||||
'moinframe.loop.ui.panel.no.comments' => 'Aucun commentaire. Ajoutez votre premier commentaire pour commencer.',
|
||||
'moinframe.loop.ui.panel.open' => 'Ouvrir les commentaires',
|
||||
'moinframe.loop.ui.header.browse.mode' => 'Naviguer',
|
||||
'moinframe.loop.ui.header.comment.mode' => 'Commenter',
|
||||
'moinframe.loop.ui.header.aria.count' => 'commentaires non résolus',
|
||||
'moinframe.loop.ui.welcome.guest.name.placeholder' => 'Entrez votre nom',
|
||||
'moinframe.loop.ui.welcome.continue' => 'Continuer',
|
||||
'moinframe.loop.ui.welcome.dismiss' => 'Fermer',
|
||||
'moinframe.loop.ui.header.position.top' => 'haut',
|
||||
'moinframe.loop.ui.header.position.bottom' => 'bas',
|
||||
'moinframe.loop.ui.comment.mark.solved' => 'Résoudre',
|
||||
'moinframe.loop.ui.comment.mark.unsolved' => 'Rouvrir',
|
||||
'moinframe.loop.ui.panel.show.resolved' => 'Afficher les résolus uniquement',
|
||||
'moinframe.loop.ui.panel.show.all' => 'Afficher tous les commentaires',
|
||||
'moinframe.loop.ui.panel.filter.open' => 'Ouverts',
|
||||
'moinframe.loop.ui.panel.filter.resolved' => 'Résolus',
|
||||
'moinframe.loop.ui.panel.filter.open.active' => 'Afficher les commentaires ouverts (sélectionné)',
|
||||
'moinframe.loop.ui.panel.filter.open.inactive' => 'Afficher les commentaires ouverts',
|
||||
'moinframe.loop.ui.panel.filter.resolved.active' => 'Afficher les commentaires résolus (sélectionné)',
|
||||
'moinframe.loop.ui.panel.filter.resolved.inactive' => 'Afficher les commentaires résolus',
|
||||
'moinframe.loop.ui.panel.menu.open' => 'Ouvrir le menu',
|
||||
'moinframe.loop.ui.panel.menu.filter.title' => 'Afficher les commentaires',
|
||||
'moinframe.loop.ui.panel.no.resolved' => 'Aucun commentaire résolu pour le moment.',
|
||||
|
||||
// Time formatting
|
||||
'moinframe.loop.ui.time.just_now' => 'à l\'instant',
|
||||
'moinframe.loop.ui.time.minute_ago' => 'il y a une minute',
|
||||
'moinframe.loop.ui.time.minutes_ago' => 'il y a {count} minutes',
|
||||
'moinframe.loop.ui.time.hour_ago' => 'il y a une heure',
|
||||
'moinframe.loop.ui.time.hours_ago' => 'il y a {count} heures',
|
||||
'moinframe.loop.ui.time.yesterday' => 'hier',
|
||||
'moinframe.loop.ui.time.days_ago' => 'il y a {count} jours'
|
||||
]
|
||||
],
|
||||
'hooks' => [
|
||||
'page.render:after' => function (string $contentType, array $data, string $html, \Kirby\Cms\Page $page) {
|
||||
if ($contentType === 'html' && Options::autoInject() && Options::enabled()) {
|
||||
$snippet = snippet('loop/app', ['page' => $page], true);
|
||||
// @phpstan-ignore-next-line
|
||||
$html = str_replace('</body>', $snippet . '</body>', $html);
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
],
|
||||
'routes' => Routes::register(),
|
||||
'snippets' => [
|
||||
'loop/app' => __DIR__ . '/snippets/loop/app.php'
|
||||
]
|
||||
]);
|
||||
BIN
site/plugins/loop/kirby-loop.png
Normal file
BIN
site/plugins/loop/kirby-loop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 908 KiB |
20
site/plugins/loop/package.json
Normal file
20
site/plugins/loop/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "moinframe-loop",
|
||||
"version": "1.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter=loop-frontend run dev",
|
||||
"build": "pnpm --filter=loop-frontend run build",
|
||||
"release": "release-it"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Justus Kraft <justus@moinfra.me>",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac",
|
||||
"devDependencies": {
|
||||
"@release-it/bumper": "^7.0.1",
|
||||
"@release-it/conventional-changelog": "^10.0.0",
|
||||
"release-it": "^18.1.2"
|
||||
}
|
||||
}
|
||||
28
site/plugins/loop/phpstan.neon
Normal file
28
site/plugins/loop/phpstan.neon
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
parameters:
|
||||
# Level 8 is the highest level of analysis (0 is the lowest)
|
||||
# Adjust based on your project's maturity and needs
|
||||
level: 8
|
||||
|
||||
# Paths to analyze
|
||||
paths:
|
||||
- index.php
|
||||
- src
|
||||
|
||||
# Exclude paths that don't need analysis
|
||||
excludePaths:
|
||||
analyseAndScan:
|
||||
- vendor
|
||||
- node_modules
|
||||
|
||||
# Cache
|
||||
tmpDir: .phpstan-cache
|
||||
|
||||
|
||||
# Use cache
|
||||
cache:
|
||||
nodesByStringCountMax: 512
|
||||
|
||||
# Extension for better type inference
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-strict-rules/rules.neon
|
||||
- vendor/phpstan/phpstan-deprecation-rules/rules.neon
|
||||
2870
site/plugins/loop/pnpm-lock.yaml
generated
Normal file
2870
site/plugins/loop/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
74
site/plugins/loop/snippets/loop/app.php
Normal file
74
site/plugins/loop/snippets/loop/app.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
use Kirby\Cms\App as Kirby;
|
||||
use \Moinframe\Loop\Options;
|
||||
|
||||
/**
|
||||
* Helper function to get translated strings with custom language support
|
||||
* This method only overwrites the translations, the language on the loop needs to stay null if no language is set, otherwise the api won't work
|
||||
* @return array<string, string> Translations
|
||||
*
|
||||
*/
|
||||
function getTranslations(): array
|
||||
{
|
||||
$customLang = Options::language();
|
||||
|
||||
return [
|
||||
'ui.comment.placeholder' => t('moinframe.loop.ui.comment.placeholder', 'Enter your comment...', $customLang),
|
||||
'ui.comment.submit' => t('moinframe.loop.ui.comment.submit', 'Submit', $customLang),
|
||||
'ui.comment.cancel' => t('moinframe.loop.ui.comment.cancel', 'Cancel', $customLang),
|
||||
'ui.comment.keyboardHint' => t('moinframe.loop.ui.comment.keyboardHint', '⌘+Enter or Ctrl+Enter to submit', $customLang),
|
||||
'ui.comment.maker.aria.label' => t('moinframe.loop.ui.comment.maker.aria.label', 'Jump to marker', $customLang),
|
||||
'ui.comment.summary.aria.label' => t('moinframe.loop.ui.comment.summary.aria.label', 'Comment by', $customLang),
|
||||
'ui.reply.aria.label' => t('moinframe.loop.ui.reply.aria.label', 'Reply by', $customLang),
|
||||
'ui.comment.replies.aria.label' => t('moinframe.loop.ui.comment.replies.aria.label', 'Show replies', $customLang),
|
||||
'ui.reply.placeholder' => t('moinframe.loop.ui.reply.placeholder', 'Write a reply...', $customLang),
|
||||
'ui.reply.submit' => t('moinframe.loop.ui.reply.submit', 'Reply', $customLang),
|
||||
'ui.panel.no.comments' => t('moinframe.loop.ui.panel.no.comments', 'No comments yet. Add your first comment to get started.', $customLang),
|
||||
'ui.header.browse.mode' => t('moinframe.loop.ui.header.browse.mode', 'Browse', $customLang),
|
||||
'ui.header.comment.mode' => t('moinframe.loop.ui.header.comment.mode', 'Comment', $customLang),
|
||||
'ui.header.aria.count' => t('moinframe.loop.ui.header.aria.count', 'unresolved comments', $customLang),
|
||||
'ui.welcome.guest.name.placeholder' => t('moinframe.loop.ui.welcome.guest.name.placeholder', 'Enter your name', $customLang),
|
||||
'ui.welcome.continue' => t('moinframe.loop.ui.welcome.continue', 'Continue', $customLang),
|
||||
'ui.welcome.dismiss' => t('moinframe.loop.ui.welcome.dismiss', 'Dismiss', $customLang),
|
||||
'ui.comment.mark.solved' => t('moinframe.loop.ui.comment.mark.solved', 'Resolve', $customLang),
|
||||
'ui.comment.mark.unsolved' => t('moinframe.loop.ui.comment.mark.unsolved', 'Reopen', $customLang),
|
||||
'ui.panel.show.resolved' => t('moinframe.loop.ui.panel.show.resolved', 'Show Resolved Only', $customLang),
|
||||
'ui.panel.show.all' => t('moinframe.loop.ui.panel.show.all', 'Show All Comments', $customLang),
|
||||
'ui.panel.open' => t('moinframe.loop.ui.panel.open', 'Open comments', $customLang),
|
||||
'ui.panel.filter.open' => t('moinframe.loop.ui.panel.filter.open', 'Open', $customLang),
|
||||
'ui.panel.filter.resolved' => t('moinframe.loop.ui.panel.filter.resolved', 'Resolved', $customLang),
|
||||
'ui.panel.filter.open.active' => t('moinframe.loop.ui.panel.filter.open.active', 'Show open comments (currently selected)', $customLang),
|
||||
'ui.panel.filter.open.inactive' => t('moinframe.loop.ui.panel.filter.open.inactive', 'Show open comments', $customLang),
|
||||
'ui.panel.filter.resolved.active' => t('moinframe.loop.ui.panel.filter.resolved.active', 'Show resolved comments (currently selected)', $customLang),
|
||||
'ui.panel.filter.resolved.inactive' => t('moinframe.loop.ui.panel.filter.resolved.inactive', 'Show resolved comments', $customLang),
|
||||
'ui.panel.menu.open' => t('moinframe.loop.ui.panel.menu.open', 'Open menu', $customLang),
|
||||
'ui.panel.menu.filter.title' => t('moinframe.loop.ui.panel.menu.filter.title', 'Show Comments', $customLang),
|
||||
'ui.panel.no.resolved' => t('moinframe.loop.ui.panel.no.resolved', 'No resolved comments yet.', $customLang),
|
||||
|
||||
// Time formatting
|
||||
'ui.time.just_now' => t('moinframe.loop.ui.time.just_now', 'just now', $customLang),
|
||||
'ui.time.minute_ago' => t('moinframe.loop.ui.time.minute_ago', 'a minute ago', $customLang),
|
||||
'ui.time.minutes_ago' => t('moinframe.loop.ui.time.minutes_ago', '{count} minutes ago', $customLang),
|
||||
'ui.time.hour_ago' => t('moinframe.loop.ui.time.hour_ago', 'an hour ago', $customLang),
|
||||
'ui.time.hours_ago' => t('moinframe.loop.ui.time.hours_ago', '{count} hours ago', $customLang),
|
||||
'ui.time.yesterday' => t('moinframe.loop.ui.time.yesterday', 'yesterday', $customLang),
|
||||
'ui.time.days_ago' => t('moinframe.loop.ui.time.days_ago', '{count} days ago', $customLang)
|
||||
];
|
||||
}
|
||||
|
||||
if (Options::enabled() && (Options::public() || kirby()->user() !== null)): ?>
|
||||
<kirby-loop
|
||||
theme="<?= Options::theme() ?>"
|
||||
csrf-token="<?= csrf() ?>"
|
||||
position="<?= Options::position() ?>"
|
||||
language="<?= kirby()->language() ? kirby()->language()->code() : '' ?>"
|
||||
apiBase="<?= kirby()->site()->url() ?>"
|
||||
pageId="<?= $page->id() ?>"
|
||||
authenticated="<?= kirby()->user() !== null ? 'true' : 'false' ?>"
|
||||
welcome-enabled="<?= Options::welcomeDialogEnabled() ? 'true' : 'false' ?>"
|
||||
welcome-headline="<?= esc(Options::welcomeDialogHeadline()) ?>"
|
||||
welcome-text="<?= esc(Options::welcomeDialogText()) ?>"
|
||||
translations="<?= esc(json_encode(getTranslations())) ?>"></kirby-loop>
|
||||
<script type="module" src="<?= Kirby::plugin('moinframe/loop')->asset('loop.js') ?>"></script>
|
||||
<?php endif ?>
|
||||
199
site/plugins/loop/src/App.php
Normal file
199
site/plugins/loop/src/App.php
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
<?php
|
||||
|
||||
namespace Moinframe\Loop;
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\App as KirbyApp;
|
||||
use Moinframe\Loop\Models\Comment;
|
||||
use Moinframe\Loop\Models\Reply;
|
||||
|
||||
class App
|
||||
{
|
||||
private static ?Database $instance = null;
|
||||
|
||||
/**
|
||||
* Gets the major version of Kirby
|
||||
* @return int Major version number
|
||||
*/
|
||||
public static function getKirbyMajorVersion(): int
|
||||
{
|
||||
$version = KirbyApp::version() ?? '0.0.0';
|
||||
$parts = explode('.', $version);
|
||||
return (int) ($parts[0] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the loop component HTML
|
||||
* @return string Component HTML
|
||||
*/
|
||||
public static function render(): string
|
||||
{
|
||||
$user = kirby()->user();
|
||||
|
||||
if (null === $user) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the database instance (singleton)
|
||||
* @return Database
|
||||
*/
|
||||
protected static function db(): Database
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new Database();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts array data to Comment objects
|
||||
* @param array<mixed> $commentsData
|
||||
* @return Comment[]
|
||||
*/
|
||||
private static function convertToCommentObjects(array $commentsData): array
|
||||
{
|
||||
$comments = [];
|
||||
foreach ($commentsData as $commentData) {
|
||||
$comment = Comment::fromArray($commentData);
|
||||
$comments[] = $comment;
|
||||
}
|
||||
return $comments;
|
||||
}
|
||||
/**
|
||||
* Get comments by kirby page
|
||||
* @param Page $page
|
||||
* @param string $lang Language code
|
||||
* @return Comment[]
|
||||
*/
|
||||
public static function getCommentsByPage(\Kirby\Cms\Page $page, string $lang = ''): array
|
||||
{
|
||||
try {
|
||||
// @phpstan-ignore method.notFound
|
||||
$pageUuid = $page->content()->uuid()->value();
|
||||
$db = self::db();
|
||||
|
||||
// Use optimized query that filters at database level
|
||||
$commentsData = $db::getCommentsByPage($pageUuid, $lang)->toArray();
|
||||
|
||||
return self::convertToCommentObjects($commentsData);
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves all comments organized in a nested structure
|
||||
* @return Comment[] array of Comment objects with nested replies
|
||||
* @remarks Top-level comments have replies as children
|
||||
*/
|
||||
public static function getComments(): array
|
||||
{
|
||||
try {
|
||||
$db = self::db();
|
||||
// Use optimized query that fetches comments with replies in 2 queries instead of N+1
|
||||
$commentsData = $db::getCommentsWithReplies()->toArray();
|
||||
|
||||
return self::convertToCommentObjects($commentsData);
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Comment from array data
|
||||
* @param array<mixed> $data
|
||||
* @return Comment|null The created comment or null if validation fails
|
||||
*/
|
||||
public static function createComment(array $data): ?Comment
|
||||
{
|
||||
try {
|
||||
return Comment::fromArray($data);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
error_log(tt('moinframe.loop.comment.creation.failed', ['error' => $e->getMessage()]));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Reply from array data
|
||||
* @param array<mixed> $data
|
||||
* @return Reply|null The created reply or null if validation fails
|
||||
*/
|
||||
public static function createReply(array $data): ?Reply
|
||||
{
|
||||
try {
|
||||
return Reply::fromArray($data);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
error_log(tt('moinframe.loop.reply.creation.failed', ['error' => $e->getMessage()]));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new comment to the database
|
||||
* @param Comment $comment Comment to add
|
||||
* @return Comment|null The added comment or null on failure
|
||||
*/
|
||||
public static function addComment(Comment $comment): ?Comment
|
||||
{
|
||||
try {
|
||||
if (!$comment->isValid()) {
|
||||
error_log(tt('moinframe.loop.comment.validation.error', ['errors' => implode(', ', $comment->validate())]));
|
||||
return null;
|
||||
}
|
||||
|
||||
$comment = self::db()::addComment($comment);
|
||||
return $comment;
|
||||
} catch (\Exception $e) {
|
||||
error_log(tt('moinframe.loop.comment.add.failed', ['error' => $e->getMessage()]));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new reply to the database
|
||||
* @param Reply $reply Reply to add
|
||||
* @return Reply|null The added reply or null on failure
|
||||
*/
|
||||
public static function addReply(Reply $reply): ?Reply
|
||||
{
|
||||
try {
|
||||
if (!$reply->isValid()) {
|
||||
error_log(tt('moinframe.loop.reply.validation.error', ['errors' => implode(', ', $reply->validate())]));
|
||||
return null;
|
||||
}
|
||||
|
||||
$reply = self::db()::addReply($reply);
|
||||
return $reply;
|
||||
} catch (\Exception $e) {
|
||||
error_log(tt('moinframe.loop.reply.add.failed', ['error' => $e->getMessage()]));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static function resolveComment(string $commentId): bool
|
||||
{
|
||||
try {
|
||||
$success = self::db()::updateCommentStatus($commentId, 'RESOLVED');
|
||||
return $success;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static function unresolveComment(string $commentId): bool
|
||||
{
|
||||
try {
|
||||
$success = self::db()::updateCommentStatus($commentId, 'OPEN');
|
||||
return $success;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
356
site/plugins/loop/src/Database.php
Normal file
356
site/plugins/loop/src/Database.php
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
<?php
|
||||
|
||||
namespace Moinframe\Loop;
|
||||
|
||||
use Kirby\Database\Db;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\Collection;
|
||||
use Moinframe\Loop\Models\Comment;
|
||||
use Moinframe\Loop\Models\Reply;
|
||||
|
||||
class Database
|
||||
{
|
||||
/**
|
||||
* Initializes the database connection if needed
|
||||
*/
|
||||
protected static function initializeDatabase(): void
|
||||
{
|
||||
// Get path to database file
|
||||
$dbPath = Options::databasePath();
|
||||
|
||||
// Make sure database directory exists
|
||||
$dir = dirname($dbPath);
|
||||
if (is_dir($dir) === false) {
|
||||
Dir::make($dir);
|
||||
}
|
||||
|
||||
// Create empty database file if it doesn't exist
|
||||
if (F::exists($dbPath) === false) {
|
||||
self::createEmptyDatabase($dbPath);
|
||||
}
|
||||
|
||||
// Configure Kirby's Db connection if not already done
|
||||
if (Db::connection() === null) {
|
||||
Db::connect([
|
||||
'type' => 'sqlite',
|
||||
'database' => $dbPath
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty database with schema
|
||||
* @param string $path Database file path
|
||||
*/
|
||||
protected static function createEmptyDatabase(string $path): void
|
||||
{
|
||||
$db = new \SQLite3($path);
|
||||
$db->exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
page TEXT NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
selector TEXT NOT NULL,
|
||||
selectorOffsetX REAL NOT NULL,
|
||||
selectorOffsetY REAL NOT NULL,
|
||||
pagePositionX REAL NOT NULL,
|
||||
pagePositionY REAL NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
lang TEXT NOT NULL DEFAULT ""
|
||||
)');
|
||||
|
||||
// Create indexes for better performance
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_comments_page_lang ON comments(page, lang)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_comments_timestamp ON comments(timestamp)');
|
||||
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author TEXT NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
parentId INTEGER NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
FOREIGN KEY (parentId) REFERENCES comments(id) ON DELETE CASCADE
|
||||
)');
|
||||
|
||||
// Create indexes for replies
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_replies_parent ON replies(parentId)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_replies_timestamp ON replies(timestamp)');
|
||||
|
||||
$db->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a query builder for replies table
|
||||
* @return \Kirby\Database\Query
|
||||
*/
|
||||
protected static function tableReplies(): \Kirby\Database\Query
|
||||
{
|
||||
self::initializeDatabase();
|
||||
return Db::table('replies');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a query builder for comments table
|
||||
* @return \Kirby\Database\Query
|
||||
*/
|
||||
protected static function tableComments(): \Kirby\Database\Query
|
||||
{
|
||||
self::initializeDatabase();
|
||||
return Db::table('comments');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Retrieves all comments from the database
|
||||
* @return Collection Array of comments
|
||||
*/
|
||||
public static function getComments(): Collection
|
||||
{
|
||||
try {
|
||||
$comments = self::tableComments()
|
||||
->select('*')
|
||||
->order('timestamp DESC')
|
||||
->all();
|
||||
return $comments;
|
||||
} catch (\Exception $e) {
|
||||
return new Collection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves comments with their replies in a single optimized query
|
||||
* @param string|null $status Filter by status (optional)
|
||||
* @return Collection Array of comments with nested replies
|
||||
*/
|
||||
public static function getCommentsWithReplies(?string $status = null): Collection
|
||||
{
|
||||
try {
|
||||
// Build the main comments query
|
||||
$commentsQuery = self::tableComments()->select('*');
|
||||
|
||||
if ($status !== null) {
|
||||
$commentsQuery = $commentsQuery->where('status', '!=', $status);
|
||||
}
|
||||
|
||||
$comments = $commentsQuery->order('timestamp DESC')->all();
|
||||
|
||||
if ($comments->count() === 0) {
|
||||
return new Collection();
|
||||
}
|
||||
|
||||
// Get all comment IDs for efficient reply lookup
|
||||
$commentIds = $comments->pluck('id');
|
||||
|
||||
// Single query to get all replies for these comments
|
||||
$replies = self::tableReplies()
|
||||
->select('*')
|
||||
->where('parentId', 'in', $commentIds)
|
||||
->order('timestamp ASC')
|
||||
->all();
|
||||
|
||||
// Group replies by parentId for efficient lookup
|
||||
$repliesByParent = [];
|
||||
foreach ($replies as $reply) {
|
||||
$parentId = $reply->parentId;
|
||||
if (!isset($repliesByParent[$parentId])) {
|
||||
$repliesByParent[$parentId] = [];
|
||||
}
|
||||
$repliesByParent[$parentId][] = $reply->toArray();
|
||||
}
|
||||
|
||||
// Add replies to their parent comments
|
||||
$result = [];
|
||||
foreach ($comments as $comment) {
|
||||
$commentArray = $comment->toArray();
|
||||
$commentArray['replies'] = $repliesByParent[$comment->id] ?? [];
|
||||
$result[] = $commentArray;
|
||||
}
|
||||
|
||||
return new Collection($result);
|
||||
} catch (\Exception $e) {
|
||||
return new Collection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves comments for a specific page with their replies
|
||||
* @param string $pageUuid Page UUID to filter by
|
||||
* @param string $lang Language to filter by
|
||||
* @param string|null $status Status to exclude (optional)
|
||||
* @return Collection Array of comments with nested replies
|
||||
*/
|
||||
public static function getCommentsByPage(string $pageUuid, string $lang = '', ?string $status = null): Collection
|
||||
{
|
||||
try {
|
||||
// Build the main comments query with page filter
|
||||
$commentsQuery = self::tableComments()
|
||||
->select('*')
|
||||
->where('page', '=', $pageUuid);
|
||||
|
||||
// Add language filter only if language is specified
|
||||
if ($lang !== '') {
|
||||
$commentsQuery = $commentsQuery->where('lang', '=', $lang);
|
||||
}
|
||||
|
||||
if ($status !== null) {
|
||||
$commentsQuery = $commentsQuery->where('status', '!=', $status);
|
||||
}
|
||||
|
||||
$comments = $commentsQuery->order('timestamp DESC')->all();
|
||||
|
||||
if ($comments->count() === 0) {
|
||||
return new Collection();
|
||||
}
|
||||
|
||||
// Get all comment IDs for efficient reply lookup
|
||||
$commentIds = $comments->pluck('id');
|
||||
|
||||
// Single query to get all replies for these comments
|
||||
$replies = self::tableReplies()
|
||||
->select('*')
|
||||
->where('parentId', 'in', $commentIds)
|
||||
->order('timestamp ASC')
|
||||
->all();
|
||||
|
||||
// Group replies by parentId for efficient lookup
|
||||
$repliesByParent = [];
|
||||
foreach ($replies as $reply) {
|
||||
$parentId = $reply->parentId();
|
||||
if (!isset($repliesByParent[$parentId])) {
|
||||
$repliesByParent[$parentId] = [];
|
||||
}
|
||||
$repliesByParent[$parentId][] = $reply->toArray();
|
||||
}
|
||||
|
||||
// Add replies to their parent comments
|
||||
$result = [];
|
||||
foreach ($comments as $comment) {
|
||||
$commentArray = $comment->toArray();
|
||||
$commentArray['replies'] = $repliesByParent[$comment->id] ?? [];
|
||||
$result[] = $commentArray;
|
||||
}
|
||||
|
||||
return new Collection($result);
|
||||
} catch (\Exception $e) {
|
||||
return new Collection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all replies from the database
|
||||
* @return Collection Array of comments
|
||||
*/
|
||||
public static function getReplies(): Collection
|
||||
{
|
||||
try {
|
||||
$replies = self::tableReplies()
|
||||
->select('*')
|
||||
->order('timestamp DESC')
|
||||
->all();
|
||||
return $replies;
|
||||
} catch (\Exception $e) {
|
||||
return new Collection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new comment to the database
|
||||
* @param Comment $comment Comment data
|
||||
* @return Comment|null The added comment or null on failure
|
||||
*/
|
||||
public static function addComment(Comment $comment): ?Comment
|
||||
{
|
||||
try {
|
||||
$data = $comment->toArray();
|
||||
// Remove id field for insertion to allow auto-increment
|
||||
unset($data['id']);
|
||||
|
||||
$id = self::tableComments()->insert($data);
|
||||
|
||||
if (null !== $id) {
|
||||
$comment->id = $id;
|
||||
return $comment;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new reply to the database
|
||||
* @param Reply $reply Reply data
|
||||
* @return Reply|null The added reply or null on failure
|
||||
*/
|
||||
public static function addReply(Reply $reply): ?Reply
|
||||
{
|
||||
try {
|
||||
$data = $reply->toArray();
|
||||
// Remove id field for insertion to allow auto-increment
|
||||
unset($data['id']);
|
||||
|
||||
$id = self::tableReplies()->insert($data);
|
||||
|
||||
if (null !== $id) {
|
||||
$reply->id = $id;
|
||||
return $reply;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Updates a comment in the database
|
||||
// * @param string $id Comment ID
|
||||
// * @param array $data Updated comment data
|
||||
// * @return bool Success status
|
||||
// */
|
||||
// public static function updateComment(string $id, array $data): bool
|
||||
// {
|
||||
// try {
|
||||
// $updateData = [];
|
||||
|
||||
// foreach ($data as $key => $value) {
|
||||
// if (in_array($key, ['comment', 'selector', 'posX', 'posY'])) {
|
||||
// $updateData[$key] = $value;
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (empty($updateData)) {
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// return self::table()->update($updateData, ['id' => $id]);
|
||||
// } catch (\Exception $e) {
|
||||
// return false;
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Updates a comment's status
|
||||
* @param string $id Comment ID
|
||||
* @param string $status New status
|
||||
* @return bool Success status
|
||||
*/
|
||||
public static function updateCommentStatus(string $id, string $status): bool
|
||||
{
|
||||
try {
|
||||
return self::tableComments()->update(
|
||||
['status' => $status],
|
||||
['id' => $id]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
site/plugins/loop/src/Enums/CommentStatus.php
Normal file
9
site/plugins/loop/src/Enums/CommentStatus.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace Moinframe\Loop\Enums;
|
||||
|
||||
enum CommentStatus: string
|
||||
{
|
||||
case OPEN = 'OPEN';
|
||||
case RESOLVED = 'RESOLVED';
|
||||
}
|
||||
49
site/plugins/loop/src/Middleware.php
Normal file
49
site/plugins/loop/src/Middleware.php
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace Moinframe\Loop;
|
||||
|
||||
use Kirby\Http\Response;
|
||||
|
||||
class Middleware
|
||||
{
|
||||
/**
|
||||
* Authentication middleware
|
||||
* @param callable $next The next action to execute
|
||||
* @return callable Middleware function
|
||||
*/
|
||||
public static function auth(callable $next): callable
|
||||
{
|
||||
return function () use ($next) {
|
||||
|
||||
// Check if loop is enabled
|
||||
if (!Options::enabled()) {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Loop is disabled',
|
||||
'code' => 'DISABLED'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$csrfToken = kirby()->request()->header('X-CSRF-Token');
|
||||
if (csrf($csrfToken) !== true) {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => t('moinframe.loop.csrf.invalid'),
|
||||
'code' => 'CSRF_INVALID'
|
||||
], 403);
|
||||
}
|
||||
|
||||
|
||||
if (Options::public() === false && kirby()->user() === null) {
|
||||
return Response::json([
|
||||
'status' => 'error',
|
||||
'message' => 'Unauthorized',
|
||||
'code' => 'UNAUTHORIZED'
|
||||
], 401);
|
||||
}
|
||||
|
||||
|
||||
return $next(...func_get_args());
|
||||
};
|
||||
}
|
||||
}
|
||||
359
site/plugins/loop/src/Models/Comment.php
Normal file
359
site/plugins/loop/src/Models/Comment.php
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
<?php
|
||||
|
||||
namespace Moinframe\Loop\Models;
|
||||
|
||||
use Moinframe\Loop\Enums\CommentStatus;
|
||||
|
||||
/**
|
||||
* Comment model
|
||||
* @property int $id
|
||||
* @property string $author
|
||||
* @property string $url
|
||||
* @property string $page
|
||||
* @property string $comment
|
||||
* @property string $selector
|
||||
* @property string $lang
|
||||
* @property float $selectorOffsetX
|
||||
* @property float $selectorOffsetY
|
||||
* @property float $pagePositionX
|
||||
* @property float $pagePositionY
|
||||
* @property CommentStatus $status
|
||||
* @property int $timestamp
|
||||
* @property Reply[] $replies
|
||||
*
|
||||
*/
|
||||
class Comment
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
public int $id = 0,
|
||||
public string $author = "",
|
||||
public string $url = "",
|
||||
public string $page = "",
|
||||
public string $comment = "",
|
||||
public string $selector = "",
|
||||
public float $selectorOffsetX = 0,
|
||||
public float $selectorOffsetY = 0,
|
||||
public float $pagePositionX = 0,
|
||||
public float $pagePositionY = 0,
|
||||
public CommentStatus $status = CommentStatus::OPEN,
|
||||
public int $timestamp = 0,
|
||||
public string $lang = "",
|
||||
/** @var Reply[] */
|
||||
public array $replies = []
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a Comment instance from an array
|
||||
*
|
||||
* @param array{id?: int, author?: string, url?: string, page?: string, selector?: string, selectorOffsetX?: float, selectorOffsetY?: float, pagePositionX?: float, pagePositionY?: float, status?: CommentStatus, comment?: string, parentId?: int, timestamp?: int} $data
|
||||
* @return self
|
||||
* @throws \InvalidArgumentException If validation fails
|
||||
*/
|
||||
|
||||
public static function fromArray($data): self
|
||||
{
|
||||
|
||||
$errors = self::validateData($data);
|
||||
if (count($errors) > 0) {
|
||||
throw new \InvalidArgumentException(tt('moinframe.loop.comment.validation.failed', ['errors' => implode(', ', $errors)]));
|
||||
}
|
||||
|
||||
$data = static::transformNumbers($data);
|
||||
|
||||
// Convert reply arrays to Reply objects
|
||||
$replies = [];
|
||||
if (isset($data['replies']) && is_array($data['replies'])) {
|
||||
foreach ($data['replies'] as $replyData) {
|
||||
$replies[] = Reply::fromArray($replyData);
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
id: $data['id'] ?? 0,
|
||||
author: strip_tags($data['author'] ?? ''),
|
||||
url: $data['url'] ?? '',
|
||||
page: $data['page'] ?? '',
|
||||
selector: $data['selector'] ?? '',
|
||||
selectorOffsetX: $data['selectorOffsetX'] ?? 0,
|
||||
selectorOffsetY: $data['selectorOffsetY'] ?? 0,
|
||||
pagePositionX: $data['pagePositionX'] ?? 0,
|
||||
pagePositionY: $data['pagePositionY'] ?? 0,
|
||||
status: isset($data['status']) ? CommentStatus::from($data['status']) : CommentStatus::OPEN,
|
||||
comment: strip_tags($data['comment'] ?? ''),
|
||||
timestamp: $data['timestamp'] ?? 0,
|
||||
lang: $data['lang'] ?? '',
|
||||
replies: $replies
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Transforms numeric fields in comment data
|
||||
*
|
||||
* @param array<mixed> $item
|
||||
* @return array<mixed>
|
||||
*/
|
||||
protected static function transformNumbers(array $item): array
|
||||
{
|
||||
// Create a new array instead of modifying the input
|
||||
$result = $item;
|
||||
|
||||
// Handle each numeric field explicitly
|
||||
if (isset($result['id'])) {
|
||||
$result['id'] = (int)$result['id'];
|
||||
}
|
||||
|
||||
if (isset($result['selectorOffsetX'])) {
|
||||
$result['selectorOffsetX'] = (float)$result['selectorOffsetX'];
|
||||
}
|
||||
|
||||
if (isset($result['selectorOffsetY'])) {
|
||||
$result['selectorOffsetY'] = (float)$result['selectorOffsetY'];
|
||||
}
|
||||
|
||||
if (isset($result['pagePositionX'])) {
|
||||
$result['pagePositionX'] = (float)$result['pagePositionX'];
|
||||
}
|
||||
|
||||
if (isset($result['pagePositionY'])) {
|
||||
$result['pagePositionY'] = (float)$result['pagePositionY'];
|
||||
}
|
||||
|
||||
if (isset($result['timestamp'])) {
|
||||
$result['timestamp'] = (int)$result['timestamp'];
|
||||
}
|
||||
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the comment data using Kirby validators
|
||||
* @return array<string> Array of validation errors (empty if valid)
|
||||
*/
|
||||
/** @phpstan-ignore-next-line */
|
||||
public function validate(): array
|
||||
{
|
||||
$rules = [
|
||||
'author' => ['required', 'maxLength' => 255],
|
||||
'comment' => ['required', 'maxLength' => 5000],
|
||||
'page' => ['required', 'maxLength' => 255],
|
||||
'selector' => ['required', 'maxLength' => 1000],
|
||||
'url' => ['maxLength' => 2048],
|
||||
'selectorOffsetX' => ['min' => 0],
|
||||
'selectorOffsetY' => ['min' => 0],
|
||||
'pagePositionX' => ['min' => 0],
|
||||
'pagePositionY' => ['min' => 0],
|
||||
'timestamp' => ['min' => 0]
|
||||
];
|
||||
|
||||
$messages = [
|
||||
'author' => [
|
||||
t('moinframe.loop.author.required'),
|
||||
t('moinframe.loop.author.max.length')
|
||||
],
|
||||
'comment' => [
|
||||
t('moinframe.loop.comment.required'),
|
||||
t('moinframe.loop.comment.max.length')
|
||||
],
|
||||
'page' => [
|
||||
t('moinframe.loop.page.required'),
|
||||
t('moinframe.loop.page.max.length')
|
||||
],
|
||||
'selector' => [
|
||||
t('moinframe.loop.selector.required'),
|
||||
t('moinframe.loop.selector.max.length')
|
||||
],
|
||||
'url' => t('moinframe.loop.url.max.length'),
|
||||
'selectorOffsetX' => t('moinframe.loop.selector.offset.x.min'),
|
||||
'selectorOffsetY' => t('moinframe.loop.selector.offset.y.min'),
|
||||
'pagePositionX' => t('moinframe.loop.page.position.x.min'),
|
||||
'pagePositionY' => t('moinframe.loop.page.position.y.min'),
|
||||
'timestamp' => t('moinframe.loop.timestamp.min')
|
||||
];
|
||||
|
||||
$data = $this->toArray();
|
||||
|
||||
// Custom URL validation if provided
|
||||
if (($data['url'] ?? '') !== '' && filter_var($data['url'], FILTER_VALIDATE_URL) === false) {
|
||||
return [t('moinframe.loop.url.format.invalid')];
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$invalid = invalid($data, $rules, $messages) ?: [];
|
||||
|
||||
// Convert validation errors to flat array of strings
|
||||
$errors = [];
|
||||
foreach ($invalid as $field => $fieldErrors) {
|
||||
if (is_array($fieldErrors)) {
|
||||
// Multiple validation rules failed for this field
|
||||
foreach ($fieldErrors as $error) {
|
||||
$errors[] = (string) $error;
|
||||
}
|
||||
} else {
|
||||
// Single validation rule failed
|
||||
$errors[] = (string) $fieldErrors;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate replies
|
||||
foreach ($this->replies as $index => $reply) {
|
||||
$replyErrors = $reply->validate();
|
||||
foreach ($replyErrors as $replyError) {
|
||||
$errors[] = tt('moinframe.loop.reply.index.error', ['index' => $index, 'error' => $replyError]);
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the comment is valid
|
||||
* @return bool
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return count($this->validate()) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates data before creating Comment instance using Kirby validators
|
||||
* @param array<mixed> $data
|
||||
* @return array<string> Array of validation errors
|
||||
*/
|
||||
/** @phpstan-ignore-next-line */
|
||||
public static function validateData(array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'author' => ['required', 'maxLength' => 255],
|
||||
'comment' => ['required', 'maxLength' => 5000],
|
||||
'page' => ['required', 'maxLength' => 255],
|
||||
'selector' => ['required', 'maxLength' => 1000],
|
||||
'url' => ['maxLength' => 2048],
|
||||
'selectorOffsetX' => ['min' => 0],
|
||||
'selectorOffsetY' => ['min' => 0],
|
||||
'pagePositionX' => ['min' => 0],
|
||||
'pagePositionY' => ['min' => 0],
|
||||
'timestamp' => ['min' => 0]
|
||||
];
|
||||
|
||||
$messages = [
|
||||
'author' => [
|
||||
t('moinframe.loop.author.required'),
|
||||
t('moinframe.loop.author.max.length')
|
||||
],
|
||||
'comment' => [
|
||||
t('moinframe.loop.comment.required'),
|
||||
t('moinframe.loop.comment.max.length')
|
||||
],
|
||||
'page' => [
|
||||
t('moinframe.loop.page.required'),
|
||||
t('moinframe.loop.page.max.length')
|
||||
],
|
||||
'selector' => [
|
||||
t('moinframe.loop.selector.required'),
|
||||
t('moinframe.loop.selector.max.length')
|
||||
],
|
||||
'url' => t('moinframe.loop.url.max.length'),
|
||||
'selectorOffsetX' => t('moinframe.loop.selector.offset.x.min'),
|
||||
'selectorOffsetY' => t('moinframe.loop.selector.offset.y.min'),
|
||||
'pagePositionX' => t('moinframe.loop.page.position.x.min'),
|
||||
'pagePositionY' => t('moinframe.loop.page.position.y.min'),
|
||||
'timestamp' => t('moinframe.loop.timestamp.min')
|
||||
];
|
||||
|
||||
// Custom URL validation if provided
|
||||
if (($data['url'] ?? '') !== '' && filter_var($data['url'], FILTER_VALIDATE_URL) === false) {
|
||||
return [t('moinframe.loop.url.format.invalid')];
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$invalid = invalid($data, $rules, $messages) ?: [];
|
||||
|
||||
// Convert validation errors to flat array of strings
|
||||
$errors = [];
|
||||
foreach ($invalid as $field => $fieldErrors) {
|
||||
if (is_array($fieldErrors)) {
|
||||
// Multiple validation rules failed for this field
|
||||
foreach ($fieldErrors as $error) {
|
||||
$errors[] = (string) $error;
|
||||
}
|
||||
} else {
|
||||
// Single validation rule failed
|
||||
$errors[] = (string) $fieldErrors;
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the author string to a display name
|
||||
* If author starts with 'user://', attempts to resolve Kirby user
|
||||
* Returns user's name or email prefix, otherwise returns the stored string
|
||||
* @return string Resolved author display name
|
||||
*/
|
||||
public function resolveAuthor(): string
|
||||
{
|
||||
// Check if author is a Kirby user reference
|
||||
if (str_starts_with($this->author, 'user://')) {
|
||||
$userId = substr($this->author, 7); // Remove 'user://' prefix
|
||||
|
||||
try {
|
||||
$user = kirby()->user($userId);
|
||||
if ($user !== null && $user->exists()) {
|
||||
// Return user's name if available
|
||||
if ($user->name()->isNotEmpty()) {
|
||||
return $user->name()->value();
|
||||
}
|
||||
|
||||
// Fallback to email prefix (everything before @)
|
||||
$email = $user->email();
|
||||
if ($email !== null && str_contains($email, '@')) {
|
||||
return explode('@', $email)[0];
|
||||
}
|
||||
|
||||
// Final fallback to email
|
||||
return $email !== null ? $email : $this->author;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
// If user resolution fails, fall back to original string
|
||||
}
|
||||
}
|
||||
|
||||
// Return the original author string if not a user reference or resolution failed
|
||||
return $this->author;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Comment instance to array
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
// Convert Reply objects to arrays
|
||||
$replies = [];
|
||||
foreach ($this->replies as $reply) {
|
||||
$replies[] = $reply->toArray();
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id ?? null,
|
||||
'author' => $this->resolveAuthor(),
|
||||
'url' => $this->url,
|
||||
'page' => $this->page,
|
||||
'comment' => $this->comment,
|
||||
'selector' => $this->selector,
|
||||
'selectorOffsetX' => $this->selectorOffsetX,
|
||||
'selectorOffsetY' => $this->selectorOffsetY,
|
||||
'pagePositionX' => $this->pagePositionX,
|
||||
'pagePositionY' => $this->pagePositionY,
|
||||
'status' => $this->status->value,
|
||||
'replies' => $replies,
|
||||
'timestamp' => $this->timestamp,
|
||||
'lang' => $this->lang
|
||||
];
|
||||
}
|
||||
}
|
||||
198
site/plugins/loop/src/Models/Reply.php
Normal file
198
site/plugins/loop/src/Models/Reply.php
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
|
||||
namespace Moinframe\Loop\Models;
|
||||
|
||||
class Reply
|
||||
{
|
||||
public function __construct(
|
||||
public int $id = 0,
|
||||
public string $author = '',
|
||||
public string $comment = '',
|
||||
public int $parentId = 0,
|
||||
public int $timestamp = 0
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a Reply instance from an array
|
||||
*
|
||||
* @param array{id?: int, author?: string, comment?: string, parentId?: int, timestamp?: int} $data
|
||||
* @return self
|
||||
* @throws \InvalidArgumentException If validation fails
|
||||
*/
|
||||
|
||||
public static function fromArray($data): self
|
||||
{
|
||||
$errors = self::validateData($data);
|
||||
if (count($errors) > 0) {
|
||||
throw new \InvalidArgumentException(tt('moinframe.loop.reply.validation.failed', ['errors' => implode(', ', $errors)]));
|
||||
}
|
||||
|
||||
$data = static::transformNumbers($data);
|
||||
|
||||
return new self(
|
||||
id: $data['id'] ?? 0,
|
||||
author: strip_tags($data['author'] ?? ''),
|
||||
comment: strip_tags($data['comment'] ?? ''),
|
||||
parentId: $data['parentId'] ?? 0,
|
||||
timestamp: $data['timestamp'] ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms numeric fields in reply data
|
||||
*
|
||||
* @param array<mixed> $item
|
||||
* @return array<mixed>
|
||||
*/
|
||||
protected static function transformNumbers(array $item): array
|
||||
{
|
||||
$result = $item;
|
||||
|
||||
if (isset($result['id'])) {
|
||||
$result['id'] = (int)$result['id'];
|
||||
}
|
||||
|
||||
if (isset($result['parentId'])) {
|
||||
$result['parentId'] = (int)$result['parentId'];
|
||||
}
|
||||
|
||||
if (isset($result['timestamp'])) {
|
||||
$result['timestamp'] = (int)$result['timestamp'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the reply data using Kirby validators
|
||||
* @return array<string> Array of validation errors (empty if valid)
|
||||
*/
|
||||
public function validate(): array
|
||||
{
|
||||
$rules = [
|
||||
'author' => ['required', 'maxLength' => 255],
|
||||
'comment' => ['required', 'maxLength' => 5000],
|
||||
'parentId' => ['required', 'min' => 1],
|
||||
'timestamp' => ['min' => 0]
|
||||
];
|
||||
|
||||
$messages = [
|
||||
'author' => [
|
||||
t('moinframe.loop.author.required'),
|
||||
t('moinframe.loop.author.max.length')
|
||||
],
|
||||
'comment' => [
|
||||
t('moinframe.loop.comment.required'),
|
||||
t('moinframe.loop.comment.max.length')
|
||||
],
|
||||
'parentId' => [
|
||||
t('moinframe.loop.parent.id.required'),
|
||||
t('moinframe.loop.parent.id.required')
|
||||
],
|
||||
'timestamp' => t('moinframe.loop.timestamp.min')
|
||||
];
|
||||
|
||||
$data = $this->toArray();
|
||||
/** @phpstan-ignore-next-line */
|
||||
$invalid = invalid($data, $rules, $messages) ?: [];
|
||||
return array_map('strval', array_values($invalid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the reply is valid
|
||||
* @return bool
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return count($this->validate()) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates data before creating Reply instance using Kirby validators
|
||||
* @param array<mixed> $data
|
||||
* @return array<string> Array of validation errors
|
||||
*/
|
||||
public static function validateData(array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'author' => ['required', 'maxLength' => 255],
|
||||
'comment' => ['required', 'maxLength' => 5000],
|
||||
'parentId' => ['required', 'min' => 1],
|
||||
'timestamp' => ['min' => 0]
|
||||
];
|
||||
|
||||
$messages = [
|
||||
'author' => [
|
||||
t('moinframe.loop.author.required'),
|
||||
t('moinframe.loop.author.max.length')
|
||||
],
|
||||
'comment' => [
|
||||
t('moinframe.loop.comment.required'),
|
||||
t('moinframe.loop.comment.max.length')
|
||||
],
|
||||
'parentId' => [
|
||||
t('moinframe.loop.parent.id.required'),
|
||||
t('moinframe.loop.parent.id.required')
|
||||
],
|
||||
'timestamp' => t('moinframe.loop.timestamp.min')
|
||||
];
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$invalid = invalid($data, $rules, $messages) ?: [];
|
||||
return array_map('strval', array_values($invalid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the author string to a display name
|
||||
* If author starts with 'user://', attempts to resolve Kirby user
|
||||
* Returns user's name or email prefix, otherwise returns the stored string
|
||||
* @return string Resolved author display name
|
||||
*/
|
||||
public function resolveAuthor(): string
|
||||
{
|
||||
// Check if author is a Kirby user reference
|
||||
if (str_starts_with($this->author, 'user://')) {
|
||||
$userId = substr($this->author, 7); // Remove 'user://' prefix
|
||||
|
||||
try {
|
||||
$user = kirby()->user($userId);
|
||||
if ($user !== null && $user->exists()) {
|
||||
// Return user's name if available
|
||||
if ($user->name()->isNotEmpty()) {
|
||||
return $user->name()->value();
|
||||
}
|
||||
|
||||
// Fallback to email prefix (everything before @)
|
||||
$email = $user->email();
|
||||
if ($email !== null && str_contains($email, '@')) {
|
||||
return explode('@', $email)[0];
|
||||
}
|
||||
|
||||
// Final fallback to email
|
||||
return $email !== null ? $email : $this->author;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
// If user resolution fails, fall back to original string
|
||||
}
|
||||
}
|
||||
|
||||
// Return the original author string if not a user reference or resolution failed
|
||||
return $this->author;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Reply instance to array
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'author' => $this->resolveAuthor(),
|
||||
'comment' => $this->comment,
|
||||
'parentId' => $this->parentId,
|
||||
'timestamp' => $this->timestamp
|
||||
];
|
||||
}
|
||||
}
|
||||
114
site/plugins/loop/src/Options.php
Normal file
114
site/plugins/loop/src/Options.php
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
namespace Moinframe\Loop;
|
||||
|
||||
class Options
|
||||
{
|
||||
|
||||
/**
|
||||
* Check if loop should be public
|
||||
* @return bool
|
||||
*/
|
||||
public static function public(): bool
|
||||
{
|
||||
return option('moinframe.loop.public', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the database file
|
||||
* @return string
|
||||
*/
|
||||
public static function databasePath(): string
|
||||
{
|
||||
return option('moinframe.loop.database', kirby()->root('logs') . '/loop/comments.sqlite');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get header position (top or bottom)
|
||||
* @return string
|
||||
*/
|
||||
public static function position(): string
|
||||
{
|
||||
return option('moinframe.loop.position', 'top');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-injection is enabled
|
||||
* @return bool
|
||||
*/
|
||||
public static function autoInject(): bool
|
||||
{
|
||||
return option('moinframe.loop.auto-inject', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if loop is enabled for the current page
|
||||
* @return bool
|
||||
*/
|
||||
public static function enabled(): bool
|
||||
{
|
||||
$enabledOption = option('moinframe.loop.enabled', true);
|
||||
|
||||
// If it's a boolean, return it directly
|
||||
if (is_bool($enabledOption)) {
|
||||
return $enabledOption;
|
||||
}
|
||||
|
||||
// If it's a callable, execute it with the current page
|
||||
if (is_callable($enabledOption)) {
|
||||
$page = kirby()->site()->page();
|
||||
return (bool) $enabledOption($page);
|
||||
}
|
||||
|
||||
// Default to enabled if invalid configuration
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if welcome dialog is enabled
|
||||
* @return bool
|
||||
*/
|
||||
public static function welcomeDialogEnabled(): bool
|
||||
{
|
||||
return option('moinframe.loop.welcome.enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get welcome dialog headline
|
||||
* @return string
|
||||
*/
|
||||
public static function welcomeDialogHeadline(): string
|
||||
{
|
||||
$customLang = self::language();
|
||||
return option('moinframe.loop.welcome.headline', $customLang !== null ? t('moinframe.loop.welcome.headline', '', $customLang) : t('moinframe.loop.welcome.headline'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get welcome dialog text
|
||||
* @return string
|
||||
*/
|
||||
public static function welcomeDialogText(): string
|
||||
{
|
||||
$customLang = self::language();
|
||||
$translatedPosition = t('moinframe.loop.ui.header.position.' . self::position(), self::position(), $customLang);
|
||||
return option('moinframe.loop.welcome.text', $customLang !== null ? tt('moinframe.loop.welcome.text', '', ['position' => $translatedPosition], $customLang) : tt('moinframe.loop.welcome.text', ['position' => $translatedPosition]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom language setting for loop
|
||||
* @return string|null
|
||||
*/
|
||||
public static function language(): ?string
|
||||
{
|
||||
return option('moinframe.loop.language', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a theme
|
||||
* @return string
|
||||
*/
|
||||
public static function theme(): string
|
||||
{
|
||||
return option('moinframe.loop.theme', 'default');
|
||||
}
|
||||
}
|
||||
312
site/plugins/loop/src/Routes.php
Normal file
312
site/plugins/loop/src/Routes.php
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<?php
|
||||
|
||||
namespace Moinframe\Loop;
|
||||
|
||||
use Moinframe\Loop\App;
|
||||
use Kirby\Http\Response;
|
||||
use Moinframe\Loop\Models\Comment;
|
||||
use Moinframe\Loop\Models\Reply;
|
||||
|
||||
class Routes
|
||||
{
|
||||
/**
|
||||
* Standard error codes
|
||||
*/
|
||||
public const ERROR_CSRF_INVALID = 'CSRF_INVALID';
|
||||
public const ERROR_PAGE_NOT_FOUND = 'PAGE_NOT_FOUND';
|
||||
public const ERROR_FIELD_REQUIRED = 'FIELD_REQUIRED';
|
||||
public const ERROR_UNAUTHORIZED = 'UNAUTHORIZED';
|
||||
public const ERROR_INVALID_SELECTOR = 'INVALID_SELECTOR';
|
||||
public const ERROR_INVALID_NAME = 'INVALID_NAME';
|
||||
|
||||
/**
|
||||
* Creates a consistent error response
|
||||
* @param string $message Human-readable error message
|
||||
* @param string|null $code Optional error code
|
||||
* @return array<string, mixed> Error response array
|
||||
*/
|
||||
private static function errorResponse(string $message, ?string $code = null): array
|
||||
{
|
||||
$response = [
|
||||
'status' => 'error',
|
||||
'message' => $message
|
||||
];
|
||||
|
||||
if ($code !== null) {
|
||||
$response['code'] = $code;
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
/**
|
||||
* Registers routes and returns route definitions
|
||||
* @return array<mixed> Route definitions array
|
||||
*/
|
||||
public static function register(): array
|
||||
{
|
||||
|
||||
return [
|
||||
[
|
||||
'pattern' => 'loop/comments/(:all)',
|
||||
'method' => 'GET',
|
||||
'language' => '*',
|
||||
'action' => Middleware::auth(function ($language = null, $pageId = null) {
|
||||
|
||||
// Handle both multilingual and non-multilingual cases
|
||||
if ($pageId === null && $language !== null) {
|
||||
// Non-multilingual: only pageId was passed as first argument
|
||||
$pageId = $language;
|
||||
$language = null;
|
||||
}
|
||||
$onPage = null;
|
||||
|
||||
if ($pageId === 'home'):
|
||||
$onPage = kirby()->site()->homePage();
|
||||
else:
|
||||
$onPage = page($pageId);
|
||||
endif;
|
||||
|
||||
// If not found, check if it's a draft and validate access
|
||||
if (null === $onPage) {
|
||||
$draftPage = kirby()->page($pageId);
|
||||
if ($draftPage !== null && $draftPage->isDraft() && (
|
||||
(App::getKirbyMajorVersion() >= 5 && $draftPage->renderVersionFromRequest() !== null) ||
|
||||
// @phpstan-ignore method.notFound
|
||||
(App::getKirbyMajorVersion() < 5 && $draftPage->isVerified(get('token')) === true)
|
||||
)) {
|
||||
$onPage = $draftPage;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $onPage) {
|
||||
return Response::json(self::errorResponse(
|
||||
tt('moinframe.loop.page.not.found', ['pageId' => $pageId]),
|
||||
self::ERROR_PAGE_NOT_FOUND
|
||||
), 400);
|
||||
}
|
||||
|
||||
$comments = App::getCommentsByPage($onPage, $language ?? '');
|
||||
|
||||
// Convert Comment objects to arrays to ensure resolved authors are included
|
||||
$commentsArray = array_map(function ($comment) {
|
||||
return $comment->toArray();
|
||||
}, $comments);
|
||||
|
||||
return Response::json([
|
||||
'status' => 'ok',
|
||||
'comments' => $commentsArray
|
||||
], 200);
|
||||
})
|
||||
],
|
||||
[
|
||||
'pattern' => 'loop/comment/new',
|
||||
'method' => 'POST',
|
||||
'language' => '*',
|
||||
'action' => Middleware::auth(function ($language = null) {
|
||||
|
||||
$data = kirby()->request()->data();
|
||||
|
||||
// Sanitize input data
|
||||
if (isset($data['comment'])) {
|
||||
$data['comment'] = htmlspecialchars(strip_tags($data['comment']), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
if (isset($data['selector'])) {
|
||||
// Validate selector but don't HTML encode it as it needs to remain a valid CSS selector
|
||||
$data['selector'] = trim($data['selector']);
|
||||
// Basic validation - selector should not contain script tags or javascript
|
||||
if (preg_match('/<script|javascript:|data:/i', $data['selector']) === 1) {
|
||||
return Response::json(self::errorResponse(
|
||||
'Invalid selector format',
|
||||
self::ERROR_INVALID_SELECTOR
|
||||
), 400);
|
||||
}
|
||||
}
|
||||
|
||||
$required = ['comment', 'url', 'selector', 'selectorOffsetX', 'selectorOffsetY', 'pagePositionX', 'pagePositionY', 'pageId'];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field])) {
|
||||
return Response::json(self::errorResponse(
|
||||
tt('moinframe.loop.field.required', ['field' => $field]),
|
||||
self::ERROR_FIELD_REQUIRED
|
||||
), 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Find page using page ID from data
|
||||
$pageId = $data['pageId'] ?? null;
|
||||
|
||||
if ($pageId === null) {
|
||||
return Response::json(self::errorResponse(
|
||||
tt('moinframe.loop.field.required', ['field' => 'pageId']),
|
||||
self::ERROR_FIELD_REQUIRED
|
||||
), 400);
|
||||
}
|
||||
|
||||
$page = ($pageId === 'home') ? kirby()->site()->homePage() : page($pageId);
|
||||
|
||||
// If not found, check if it's a draft and validate access
|
||||
if (null === $page) {
|
||||
$draftPage = kirby()->page($pageId);
|
||||
if ($draftPage !== null && $draftPage->isDraft() && (
|
||||
(App::getKirbyMajorVersion() >= 5 && $draftPage->renderVersionFromRequest() !== null) ||
|
||||
// @phpstan-ignore method.notFound
|
||||
(App::getKirbyMajorVersion() < 5 && $draftPage->isVerified(get('token')) === true)
|
||||
)) {
|
||||
$page = $draftPage;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $page) {
|
||||
return Response::json(self::errorResponse(
|
||||
tt('moinframe.loop.page.path.not.found', ['path' => $pageId]),
|
||||
self::ERROR_PAGE_NOT_FOUND
|
||||
), 404);
|
||||
}
|
||||
|
||||
$comment = [
|
||||
'author' => ($user = kirby()->user()) !== null ? (string) $user->uuid() : (kirby()->session()->data()->get('loop.guest.name') ?? "guest"),
|
||||
'url' => $data['url'],
|
||||
// @phpstan-ignore method.notFound
|
||||
'page' => $page->content()->uuid(),
|
||||
'comment' => $data['comment'],
|
||||
'selector' => $data['selector'],
|
||||
'selectorOffsetX' => $data['selectorOffsetX'],
|
||||
'selectorOffsetY' => $data['selectorOffsetY'],
|
||||
'pagePositionX' => $data['pagePositionX'],
|
||||
'pagePositionY' => $data['pagePositionY'],
|
||||
'timestamp' => time(),
|
||||
'lang' => $language ?? '',
|
||||
'replies' => []
|
||||
];
|
||||
|
||||
$comment = Comment::fromArray($comment);
|
||||
$result = App::addComment($comment);
|
||||
return Response::json([
|
||||
'status' => 'ok',
|
||||
'comment' => $result !== null ? $result->toArray() : null
|
||||
], 201);
|
||||
})
|
||||
],
|
||||
[
|
||||
'pattern' => 'loop/comment/resolve',
|
||||
'method' => 'POST',
|
||||
'language' => '*',
|
||||
'action' => Middleware::auth(function ($language = null) {
|
||||
|
||||
|
||||
$data = kirby()->request()->data();
|
||||
|
||||
$required = ['id'];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field])) {
|
||||
return Response::json(self::errorResponse(
|
||||
tt('moinframe.loop.field.required', ['field' => $field]),
|
||||
self::ERROR_FIELD_REQUIRED
|
||||
), 400);
|
||||
}
|
||||
}
|
||||
|
||||
$success = App::resolveComment($data['id']);
|
||||
|
||||
return Response::json([
|
||||
'status' => 'ok',
|
||||
'success' => $success
|
||||
], 200);
|
||||
})
|
||||
],
|
||||
[
|
||||
'pattern' => 'loop/comment/reply',
|
||||
'method' => 'POST',
|
||||
'language' => '*',
|
||||
'action' => Middleware::auth(function ($language = null) {
|
||||
|
||||
|
||||
$data = kirby()->request()->data();
|
||||
|
||||
// Sanitize input data
|
||||
if (isset($data['comment'])) {
|
||||
$data['comment'] = htmlspecialchars(strip_tags($data['comment']), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
$required = ['comment', 'parentId'];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field])) {
|
||||
return Response::json(self::errorResponse(
|
||||
tt('moinframe.loop.field.required', ['field' => $field]),
|
||||
self::ERROR_FIELD_REQUIRED
|
||||
), 400);
|
||||
}
|
||||
}
|
||||
|
||||
$reply = Reply::fromArray([
|
||||
'author' => ($user = kirby()->user()) !== null ? (string) $user->uuid() : (kirby()->session()->data()->get('loop.guest.name') ?? "guest"),
|
||||
'comment' => $data['comment'],
|
||||
'parentId' => (int) $data['parentId'],
|
||||
'timestamp' => time()
|
||||
]);
|
||||
|
||||
$result = App::addReply($reply);
|
||||
|
||||
return Response::json([
|
||||
'status' => 'ok',
|
||||
'reply' => $result !== null ? $result->toArray() : null
|
||||
], 201);
|
||||
})
|
||||
],
|
||||
[
|
||||
'pattern' => 'loop/comment/unresolve',
|
||||
'method' => 'POST',
|
||||
'language' => '*',
|
||||
'action' => Middleware::auth(function ($language = null) {
|
||||
|
||||
|
||||
$data = kirby()->request()->data();
|
||||
|
||||
$required = ['id'];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field])) {
|
||||
return Response::json(self::errorResponse(
|
||||
tt('moinframe.loop.field.required', ['field' => $field]),
|
||||
self::ERROR_FIELD_REQUIRED
|
||||
), 400);
|
||||
}
|
||||
}
|
||||
|
||||
$success = App::unresolveComment($data['id']);
|
||||
|
||||
return Response::json([
|
||||
'status' => 'ok',
|
||||
'success' => $success
|
||||
], 200);
|
||||
})
|
||||
],
|
||||
[
|
||||
'pattern' => 'loop/guest/name',
|
||||
'method' => 'POST',
|
||||
'language' => '*',
|
||||
'action' => Middleware::auth(function ($language = null) {
|
||||
$data = kirby()->request()->data();
|
||||
|
||||
if (!isset($data['name']) || trim($data['name']) === '') {
|
||||
return Response::json(self::errorResponse(
|
||||
'Name is required',
|
||||
self::ERROR_INVALID_NAME
|
||||
), 400);
|
||||
}
|
||||
|
||||
$name = trim($data['name']);
|
||||
kirby()->session()->data()->set('loop.guest.name', $name);
|
||||
|
||||
return Response::json([
|
||||
'status' => 'ok',
|
||||
'name' => $name
|
||||
], 200);
|
||||
})
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue