Lightweight, self-hosted emoji reaction widget for blogs.
- Emoji reactions with toggle support
- Visitor counter with deduplication
- Silent mode (no UI, only data collection)
- Analytics dashboard
- Fully customizable styling via CSS
- Self-hosted with SQLite storage
- Cookie-less & privacy-friendly
- ~3KB minified frontend
Frontend
- TypeScript
- esbuild
- Vitest
Backend
- PHP 8.4
- Symfony 8
- SQLite
- Node.js 20+
- PHP 8.4+
- Composer
make install # Install dependencies
make test # Run tests
make dev # Start dev server with file watchingOpens http://localhost:8000/demo.html
The project includes GitHub Actions workflows for CI/CD:
- Tests: Run automatically on push to
main - Deploy: Manual trigger via GitHub Actions UI
Required secrets:
DEPLOY_SSH_KEY- Private SSH keyDEPLOY_HOST- Server hostname/IPDEPLOY_USER- SSH usernameDEPLOY_PATH- Target directory on server
-
Build the project:
make build
-
Deploy these directories to your server:
bin/ # Symfony console config/ # Symfony config public/ # Document root (index.php, pop.min.js, pop.min.css) src/ # PHP source vendor/ # PHP dependencies -
Create
.env.localon the server:APP_ENV=prod APP_SECRET=<random-string> POP_ALLOWED_DOMAINS=https://your-blog.com POP_DATABASE_PATH=var/data.db
-
Ensure
var/directory exists and is writable by the web server.
services:
pop:
image: php:8.4-apache
ports:
- "8080:80"
volumes:
- ./:/var/www/html
- pop-data:/var/www/html/var
environment:
- APP_ENV=prod
- APP_SECRET=change-me-to-random-string
- POP_ALLOWED_DOMAINS=https://your-blog.com
- POP_DATABASE_PATH=var/data.db
command: >
bash -c "a2enmod rewrite &&
echo '<Directory /var/www/html/public>
AllowOverride All
Require all granted
</Directory>' > /etc/apache2/conf-available/pop.conf &&
a2enconf pop &&
sed -i 's|/var/www/html|/var/www/html/public|g' /etc/apache2/sites-available/000-default.conf &&
apache2-foreground"
volumes:
pop-data:<link rel="stylesheet" href="https://your-api.com/pop.min.css">
<div id="pop"></div>
<script src="https://your-api.com/pop.min.js"></script>
<script>
Pop.init({
el: '#pop',
endpoint: 'https://your-api.com/api',
pageId: 'unique-page-id', // optional, defaults to current URL
emojis: ['π', 'π₯', 'π‘', 'β€οΈ'],
trackVisits: true,
renderVisits: true,
renderReactions: true
});
</script><div id="pop"></div>
<script src="https://your-api.com/pop.min.js"></script>
<script>
Pop.init({
el: '#pop',
endpoint: 'https://your-api.com/api',
emojis: ['π', 'π₯', 'π‘', 'β€οΈ'],
trackVisits: true,
renderReactions: true
});
</script><div id="pop"></div>
<script src="https://your-api.com/pop.min.js"></script>
<script>
Pop.init({
el: '#pop',
endpoint: 'https://your-api.com/api',
trackVisits: true,
renderVisits: true
});
</script>For recording visits without displaying anything:
<script src="https://your-api.com/pop.min.js"></script>
<script>
Pop.init({
endpoint: 'https://your-api.com/api',
trackVisits: true
});
</script>| Option | Type | Default | Description |
|---|---|---|---|
endpoint |
string | required | API endpoint URL |
pageId |
string | current URL | Unique identifier for the page |
el |
string | - | CSS selector for container (required if rendering) |
emojis |
string[] | [] |
Array of emoji (required if renderReactions) |
trackVisits |
boolean | false |
Record visits to the server |
renderVisits |
boolean | false |
Show visitor counter (requires el) |
renderReactions |
boolean | false |
Show reaction buttons (requires el + emojis) |
onLoad |
function | - | Callback when Pop is loaded |
The onLoad callback is called after Pop has finished initializing. Use it to fetch additional data:
<script>
Pop.init({
el: '#pop',
endpoint: 'https://your-api.com/api',
emojis: ['π', 'π₯'],
renderReactions: true,
trackVisits: true,
onLoad: function() {
fetch('https://your-api.com/api/visits?pageId=my-page')
.then(res => res.json())
.then(data => {
console.log('Unique visitors:', data.uniqueVisitors);
console.log('Total visits:', data.totalVisits);
});
}
});
</script>Returns reaction counts and current user's reactions.
{
"pageId": "my-page",
"reactions": { "π": 5, "π₯": 3 },
"userReactions": ["π"]
}Toggles a reaction (adds if not present, removes if already added).
Request:
{ "pageId": "my-page", "emoji": "π" }Response:
{ "success": true, "action": "added", "count": 6 }Returns visitor counts for a page.
{
"pageId": "my-page",
"uniqueVisitors": 42,
"totalVisits": 128
}Records a visit. Deduplicates by fingerprint within a configurable window (default: 30 minutes).
Request:
{ "pageId": "my-page" }Response:
{ "success": true, "recorded": true, "uniqueVisitors": 43 }Returns aggregated statistics for all pages (used by analytics dashboard).
Query parameters:
pageIdFilter- filters pages containing the given stringgroup- group results by time period:day,week, ormonthlimit- number of periods to return (max: day=28, week=52, month=12)
Default response (no grouping):
{
"global": {
"uniqueVisitors": 1234,
"totalVisits": 5678,
"totalPages": 42
},
"pages": [
{ "pageId": "page-1", "uniqueVisitors": 100, "totalVisits": 250 },
{ "pageId": "page-2", "uniqueVisitors": 80, "totalVisits": 180 }
]
}Grouped response (?group=day&limit=7):
{
"day_0": {
"global": { "uniqueVisitors": 50, "totalVisits": 120, "totalPages": 5 },
"pages": [{ "pageId": "page-1", "uniqueVisitors": 30, "totalVisits": 80 }]
},
"day_1": {
"global": { "uniqueVisitors": 45, "totalVisits": 100, "totalPages": 4 },
"pages": [...]
}
}Where day_0 is today, day_1 is yesterday, etc. Same logic applies for week_0/week_1 and month_0/month_1.
| Variable | Description |
|---|---|
APP_SECRET |
Symfony secret key |
POP_ALLOWED_DOMAINS |
Comma-separated allowed origins |
POP_DATABASE_PATH |
Path to SQLite database file |
POP_VISIT_DEDUP_SECONDS |
Visit deduplication window in seconds (default: 1800) |
Access the analytics dashboard at /analytics.html. Enter your API endpoint to view visitor statistics across all pages.
MIT
