Новый компоент счетчика

This commit is contained in:
Web-serfer 2026-05-03 17:16:37 +05:00
parent 8c2d4007fa
commit 9d10fda3f3
9 changed files with 349 additions and 5 deletions

View file

@ -0,0 +1,103 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"help": "",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text791980464",
"max": 0,
"min": 0,
"name": "visitor_hash",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text2783163181",
"max": 0,
"min": 0,
"name": "ip",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text3293145029",
"max": 0,
"min": 0,
"name": "user_agent",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_2651661972",
"indexes": [],
"listRule": null,
"name": "site_visitors",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2651661972");
return app.delete(collection);
})

View file

@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2651661972")
// update collection data
unmarshal({
"createRule": "",
"deleteRule": "",
"listRule": "",
"updateRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2651661972")
// update collection data
unmarshal({
"createRule": null,
"deleteRule": null,
"listRule": null,
"updateRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})

BIN
bun.lockb Normal file

Binary file not shown.

View file

@ -0,0 +1,77 @@
---
import { Icon } from 'astro-icon/components';
interface Props {
today?: number;
total?: number;
}
const { today = 0, total = 0 } = Astro.props;
---
<div class="visitor-counters">
<div class="counter-item">
<Icon name="users-group" />
<span class="counter-value">{today} сегодня</span>
</div>
<div class="counter-item">
<Icon name="users-total" />
<span class="counter-value">{total} всего</span>
</div>
</div>
<script>
(function() {
const root = document.querySelector('.visitor-counters');
if (!root) return;
const valueEls = root.querySelectorAll('.counter-value');
if (valueEls.length < 2) return;
const todayStr = new Date().toDateString();
const lastVisit = localStorage.getItem('site_visited');
const isRepeat = lastVisit === todayStr;
const apiUrl = isRepeat ? '/api/visitors?repeat=true' : '/api/visitors';
fetch(apiUrl).then(r => r.json()).then(d => {
if (typeof d.todayVisitors === 'number') {
if (d.isNewVisitor) {
localStorage.setItem('site_visited', todayStr);
}
if (valueEls[0]) valueEls[0].textContent = d.todayVisitors + ' сегодня';
if (valueEls[1]) valueEls[1].textContent = d.totalVisitors + ' всего';
}
}).catch(() => {});
})();
</script>
<style>
.visitor-counters {
display: flex;
gap: 1rem;
align-items: center;
}
.counter-item {
display: flex;
align-items: center;
gap: 0.4rem;
color: #8c9bb0;
font-size: 0.75rem;
}
.counter-item :global(svg) {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.counter-value {
font-variant-numeric: tabular-nums;
}
.counter-item:hover {
color: #bf9b58;
}
</style>

View file

@ -1,6 +1,7 @@
---
import { CONTACT_CONSTANTS } from "@constants/constants.ts";
import SocialIcons from "@components/base/SocialIcons.astro";
import VisitorCounter from "@components/base/VisitorCounter.astro";
const currentYear = new Date().getFullYear();
@ -147,11 +148,14 @@ const menu = [
<div
class="pt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-4"
>
<div class="flex flex-col md:flex-row gap-4 md:gap-8 items-center">
<p class="text-[10px] uppercase tracking-[0.1em] text-gray-600">
&copy; {currentYear} ADVOKAT086. Все права защищены.
</p>
<VisitorCounter />
</div>
<div class="flex gap-8">
<div class="flex flex-col md:flex-row gap-3 md:gap-8 items-center justify-center">
<a
href="/privacy-policy"
class="text-[10px] uppercase tracking-[0.1em] text-gray-600 hover:text-white transition-colors"

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<line x1="19" y1="8" x2="19" y2="14"></line>
<line x1="22" y1="11" x2="16" y2="11"></line>
</svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>

After

Width:  |  Height:  |  Size: 383 B

View file

@ -0,0 +1,117 @@
import type { APIRoute } from 'astro';
import crypto from 'crypto';
const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || 'http://localhost:8090';
function getClientIp(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
return forwarded.split(',')[0].trim();
}
const realIp = request.headers.get('x-real-ip');
if (realIp) {
return realIp;
}
const host = request.headers.get('host') || 'localhost';
return host.includes('localhost') || host.includes('127.0.0.1') ? 'localhost' : 'unknown';
}
function generateVisitorHash(ip: string, userAgent: string): string {
const stableIP = ip.split(':').pop() || ip;
const uaParts = userAgent.split(' ');
const stableUA = uaParts.slice(0, 2).join(' ');
return crypto.createHash('sha256').update(stableIP + stableUA).digest('hex').slice(0, 32);
}
async function pbRequest(method: string, path: string, body?: object) {
const url = `${POCKETBASE_URL}${path}`;
const options: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Origin': 'http://127.0.0.1:4321',
},
};
if (body) options.body = JSON.stringify(body);
const res = await fetch(url, options);
if (!res.ok) {
const err = await res.text();
throw new Error(`PB ${method} ${path}: ${res.status} - ${err}`);
}
return res.json();
}
function jsonResponse(data: object, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }
});
}
export const GET: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const isRepeatVisit = url.searchParams.get('repeat') === 'true';
const ip = getClientIp(request);
const userAgent = request.headers.get('user-agent') || 'unknown';
const visitorHash = generateVisitorHash(ip, userAgent);
const now = new Date();
const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0));
const todayStartStr = todayStart.toISOString().replace('T', ' ').replace('Z', '');
const filterTodayVisitor = `visitor_hash="${visitorHash}" && created >= "${todayStartStr}"`;
let existingVisitor;
try {
const res = await pbRequest('GET', `/api/collections/site_visitors/records?filter=${encodeURIComponent(filterTodayVisitor)}&perPage=1`);
existingVisitor = { totalItems: res.totalItems || 0 };
} catch {
existingVisitor = { totalItems: 0 };
}
let isNewVisitor = false;
if (isRepeatVisit) {
isNewVisitor = false;
}
else if (existingVisitor.totalItems === 0) {
isNewVisitor = true;
try {
await pbRequest('POST', '/api/collections/site_visitors/records', {
visitor_hash: visitorHash,
ip: ip,
user_agent: userAgent,
});
} catch {
// ignore
}
}
const filterToday = `created >= "${todayStartStr}"`;
let todayCount = 0;
let totalCount = 0;
try {
const resToday = await pbRequest('GET', `/api/collections/site_visitors/records?filter=${encodeURIComponent(filterToday)}&perPage=1`);
todayCount = resToday.totalItems || 0;
} catch {
todayCount = 0;
}
try {
const resTotal = await pbRequest('GET', `/api/collections/site_visitors/records?perPage=1`);
totalCount = resTotal.totalItems || 0;
} catch {
totalCount = 0;
}
return jsonResponse({ todayVisitors: todayCount, totalVisitors: totalCount, isNewVisitor }, 200);
} catch (error) {
console.error('[Visitors] Error:', error);
return jsonResponse({ error: 'Внутренняя ошибка сервера' }, 500);
}
};

View file

@ -21,5 +21,8 @@
"concurrently": "^9.2.1",
"maildev": "^2.2.1"
},
"private": true
"private": true,
"dependencies": {
"astro-icon": "^1.1.5"
}
}