diff --git a/.htaccess b/.htaccess
new file mode 100644
index 0000000..426c10b
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,5 @@
+RewriteEngine On
+RewriteBase /
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteRule ^(.*)$ public/index.php [QSA,L]
diff --git a/README.md b/README.md
index cdc16c5..9e182c3 100644
--- a/README.md
+++ b/README.md
@@ -1,28 +1,42 @@
-# Decrypt
-### [+] Created By HTR-TECH (@***tahmid.rayat***)
-### [+] Disclaimer :
-***Decrypter is a tool to decrypt Encrypted Bash Scripts into a Readable Format.This Tool is created for Educational Purpose only.I am not responsible for any misuse of this tool.***
-
-
-
-### [+] Installation
-```apt update```
-
-```apt install git python2 -y```
-
-```git clone https://github.com/hax0rtahm1d/decrypt```
-
-```cd decrypt```
-
-```python2 dec.py```
-
-### Or, Use Single Command
-
-```
-apt update && apt install git python2 -y && git clone https://github.com/hax0rtahm1d/decrypt && cd decrypt && python2 dec.py
-```
-
-## [+] Find Me on :
-[](https://github.com/htr-tech)
-[](https://www.instagram.com/tahmid.rayat)
-[](https://m.me/tahmid.rayat.official)
+# Tin tức PHP + MySQL
+
+Website báo tin tức (frontend + admin) viết thuần PHP 8.x, chạy tốt trên shared hosting Apache + MySQL. Không dùng `.env`; mọi cấu hình nằm trong `config.php`.
+
+## Tính năng chính
+- 3 theme giao diện (A/B/C) đổi nhanh trong trang Admin → Cài đặt.
+- Bài viết hỗ trợ text/ảnh/iframe Telegram embed.
+- Interstitial Shopee hợp lệ: nút chính đi Shopee (target `_blank`), nút phụ bỏ qua đọc bài; có tần suất hiển thị theo session/giờ/số bài.
+- Tracking pageview, click Shopee (lọc bot/UA), thống kê ngày/tháng, export CSV.
+- Admin: đăng nhập (bcrypt), chống brute-force, CSRF token, CRUD bài viết, cài đặt hệ thống, upload logo/banner.
+- Telegram bot: gửi khi publish bài hoặc khi có click Shopee (tùy chọn).
+- Setup wizard: nhập thông số DB → tạo bảng + admin → sinh `config.php`.
+
+## Cấu trúc thư mục
+- `public/`: Front controller `index.php`, asset CSS/JS.
+- `app/Core`: Router, DB, bảo mật, view.
+- `app/Controllers`: Controller frontend & admin.
+- `app/Models`: Model truy vấn PDO.
+- `app/Views`: Template PHP (frontend/admin + partials).
+- `database/schema.sql`: Schema MySQL.
+- `setup.php`: Trang cài đặt nhanh.
+- `docs/DEPLOYMENT.md`: Hướng dẫn deploy Apache/shared hosting.
+
+## Cài đặt nhanh (local hoặc shared hosting)
+1. Upload toàn bộ mã nguồn lên hosting (document root chứa `.htaccess` và thư mục `public`).
+2. Truy cập `https://domain/setup.php`, nhập thông tin DB, base URL, tài khoản admin.
+3. Sau khi thấy thông báo thành công, xóa `setup.php` trên hosting.
+4. Đăng nhập Admin tại `https://domain/admin`.
+
+## Chạy thủ công (không dùng setup wizard)
+1. Copy `config.example.php` → `config.php`, điền thông tin DB/base URL.
+2. Import `database/schema.sql` vào MySQL.
+3. Tạo tài khoản admin: `INSERT INTO users (name,email,password) VALUES ('Admin','admin@example.com', PASSWORD_HASH_HERE);` (dùng `password_hash` PHP).
+4. Đặt DocumentRoot trỏ vào thư mục gốc repo, bật mod_rewrite (đã có `.htaccess`).
+
+## Ghi chú bảo mật
+- Luôn thay `security.csrf_key` trong `config.php`.
+- Upload logo/banner chỉ chấp nhận PNG/JPG/WEBP < 2MB.
+- Luôn xóa `setup.php` sau khi cài đặt.
+
+## License
+MIT
diff --git a/app/Controllers/Admin/ArticleController.php b/app/Controllers/Admin/ArticleController.php
new file mode 100644
index 0000000..74735d7
--- /dev/null
+++ b/app/Controllers/Admin/ArticleController.php
@@ -0,0 +1,118 @@
+requireAuth();
+ $page = max(1, (int)($_GET['page'] ?? 1));
+ $data = Article::paginate($page, 15, null, null, true);
+ View::render('admin/articles/index', ['articles' => $data['items'], 'pagination' => $data]);
+ }
+
+ public function create(): void
+ {
+ $this->requireAuth();
+ $categories = Category::all();
+ View::render('admin/articles/form', ['article' => null, 'categories' => $categories]);
+ }
+
+ public function store(): void
+ {
+ $this->requireAuth();
+ verify_csrf();
+ $data = $this->articleDataFromRequest(false);
+ $id = Article::create($data);
+ if ($data['include_id_tail']) {
+ $data['slug'] = rtrim($data['slug'], '-') . '-' . $id;
+ Article::update($id, $data);
+ }
+ $this->maybeNotifyTelegram($data['title']);
+ redirect('admin/articles');
+ }
+
+ public function edit(array $params): void
+ {
+ $this->requireAuth();
+ $id = (int)$params['id'];
+ $article = Article::findById($id);
+ $categories = Category::all();
+ View::render('admin/articles/form', ['article' => $article, 'categories' => $categories]);
+ }
+
+ public function update(array $params): void
+ {
+ $this->requireAuth();
+ verify_csrf();
+ $id = (int)$params['id'];
+ $data = $this->articleDataFromRequest(true, $id);
+ Article::update($id, $data);
+ redirect('admin/articles');
+ }
+
+ public function delete(array $params): void
+ {
+ $this->requireAuth();
+ verify_csrf();
+ Article::delete((int)$params['id']);
+ redirect('admin/articles');
+ }
+
+ public function copy(array $params): void
+ {
+ $this->requireAuth();
+ verify_csrf();
+ Article::copy((int)$params['id']);
+ redirect('admin/articles');
+ }
+
+ private function articleDataFromRequest(bool $hasId = false, int $id = 0): array
+ {
+ $title = $_POST['title'] ?? '';
+ $slug = $_POST['slug'] ?: slugify($title);
+ $includeIdTail = !empty($_POST['include_id_tail']);
+ $slugWithTail = $slug;
+ if ($includeIdTail && $hasId) {
+ $slugWithTail .= '-' . $id;
+ }
+ return [
+ 'title' => $title,
+ 'slug' => $slugWithTail,
+ 'meta_title' => $_POST['meta_title'] ?? $title,
+ 'meta_description' => $_POST['meta_description'] ?? '',
+ 'keywords' => $_POST['keywords'] ?? '',
+ 'og_image' => $_POST['og_image'] ?? '',
+ 'content' => $_POST['content'] ?? '',
+ 'category_id' => (int)($_POST['category_id'] ?? 0),
+ 'tags' => $_POST['tags'] ?? '',
+ 'status' => $_POST['status'] ?? 'draft',
+ 'include_id_tail' => $includeIdTail,
+ 'published_at' => $_POST['published_at'] ?: date('Y-m-d H:i:s'),
+ ];
+ }
+
+ private function maybeNotifyTelegram(string $title): void
+ {
+ $settings = Setting::getAll();
+ if (empty($settings['telegram_enabled'])) {
+ return;
+ }
+ $token = $settings['telegram_token'] ?? '';
+ $chatId = $settings['telegram_chat_id'] ?? '';
+ if (!$token || !$chatId) {
+ return;
+ }
+ $url = "https://api.telegram.org/bot{$token}/sendMessage";
+ $payload = http_build_query([
+ 'chat_id' => $chatId,
+ 'text' => 'Bài mới: ' . $title,
+ ]);
+ @file_get_contents($url . '?' . $payload);
+ }
+}
diff --git a/app/Controllers/Admin/AuthController.php b/app/Controllers/Admin/AuthController.php
new file mode 100644
index 0000000..733745b
--- /dev/null
+++ b/app/Controllers/Admin/AuthController.php
@@ -0,0 +1,45 @@
+ null]);
+ }
+
+ public function login(): void
+ {
+ verify_csrf();
+ $email = $_POST['email'] ?? '';
+ $password = $_POST['password'] ?? '';
+ $_SESSION['login_attempts'] = $_SESSION['login_attempts'] ?? 0;
+ $_SESSION['login_locked_until'] = $_SESSION['login_locked_until'] ?? 0;
+ if (time() < $_SESSION['login_locked_until']) {
+ View::render('admin/login', ['error' => 'Tạm khóa đăng nhập, thử lại sau ít phút.']);
+ return;
+ }
+ $user = User::findByEmail($email);
+ if ($user && password_verify($password, $user['password'])) {
+ $_SESSION['admin_id'] = $user['id'];
+ $_SESSION['admin_name'] = $user['name'];
+ $_SESSION['login_attempts'] = 0;
+ $_SESSION['login_locked_until'] = 0;
+ redirect('admin');
+ }
+ $_SESSION['login_attempts']++;
+ if ($_SESSION['login_attempts'] >= 5) {
+ $_SESSION['login_locked_until'] = time() + 300;
+ }
+ View::render('admin/login', ['error' => 'Sai email hoặc mật khẩu']);
+ }
+
+ public function logout(): void
+ {
+ session_destroy();
+ redirect('admin/login');
+ }
+}
diff --git a/app/Controllers/Admin/BaseAdminController.php b/app/Controllers/Admin/BaseAdminController.php
new file mode 100644
index 0000000..4cb6eb3
--- /dev/null
+++ b/app/Controllers/Admin/BaseAdminController.php
@@ -0,0 +1,12 @@
+requireAuth();
+ $stats = Stat::totals();
+ $daily = Stat::daily();
+ $byArticle = Stat::byArticle();
+ $settings = Setting::getAll();
+ View::render('admin/dashboard', [
+ 'stats' => $stats,
+ 'daily' => $daily,
+ 'byArticle' => $byArticle,
+ 'settings' => $settings,
+ ]);
+ }
+
+ public function resetStats(): void
+ {
+ $this->requireAuth();
+ verify_csrf();
+ Stat::reset();
+ $logs = Setting::get('reset_logs', []);
+ $logs[] = [
+ 'by' => $_SESSION['admin_name'] ?? 'unknown',
+ 'at' => date('Y-m-d H:i:s'),
+ ];
+ Setting::set('reset_logs', $logs);
+ $_SESSION['flash'] = 'Đã reset thống kê.';
+ redirect('admin');
+ }
+}
diff --git a/app/Controllers/Admin/ReportController.php b/app/Controllers/Admin/ReportController.php
new file mode 100644
index 0000000..f1b045f
--- /dev/null
+++ b/app/Controllers/Admin/ReportController.php
@@ -0,0 +1,21 @@
+requireAuth();
+ $rows = Stat::byArticle();
+ header('Content-Type: text/csv');
+ header('Content-Disposition: attachment; filename="article_stats.csv"');
+ $out = fopen('php://output', 'w');
+ fputcsv($out, ['ID', 'Tiêu đề', 'View', 'Click']);
+ foreach ($rows as $row) {
+ fputcsv($out, [$row['id'], $row['title'], $row['views'] ?? 0, $row['clicks'] ?? 0]);
+ }
+ fclose($out);
+ }
+}
diff --git a/app/Controllers/Admin/SettingsController.php b/app/Controllers/Admin/SettingsController.php
new file mode 100644
index 0000000..afbaeae
--- /dev/null
+++ b/app/Controllers/Admin/SettingsController.php
@@ -0,0 +1,69 @@
+requireAuth();
+ $settings = Setting::getAll();
+ View::render('admin/settings', ['settings' => $settings]);
+ }
+
+ public function save(): void
+ {
+ $this->requireAuth();
+ verify_csrf();
+ $data = [
+ 'site_name' => $_POST['site_name'] ?? 'Tin tức',
+ 'site_description' => $_POST['site_description'] ?? '',
+ 'footer_text' => $_POST['footer_text'] ?? '',
+ 'theme' => $_POST['theme'] ?? 'theme-a',
+ 'shopee_link' => $_POST['shopee_link'] ?? '',
+ 'interstitial_enabled' => !empty($_POST['interstitial_enabled']),
+ 'interstitial_frequency' => (int)($_POST['interstitial_frequency'] ?? 1),
+ 'interstitial_unit' => $_POST['interstitial_unit'] ?? 'session',
+ 'bot_list' => array_filter(array_map('trim', explode("\n", $_POST['bot_list'] ?? 'bot'))),
+ 'view_cooldown_seconds' => (int)($_POST['view_cooldown_seconds'] ?? 120),
+ 'click_cooldown_seconds' => (int)($_POST['click_cooldown_seconds'] ?? 300),
+ 'telegram_enabled' => !empty($_POST['telegram_enabled']),
+ 'telegram_click_enabled' => !empty($_POST['telegram_click_enabled']),
+ 'telegram_token' => $_POST['telegram_token'] ?? '',
+ 'telegram_chat_id' => $_POST['telegram_chat_id'] ?? '',
+ 'logo' => $this->handleUpload('logo', $_POST['current_logo'] ?? ''),
+ 'banner' => $this->handleUpload('banner', $_POST['current_banner'] ?? ''),
+ ];
+ foreach ($data as $key => $value) {
+ Setting::set($key, $value);
+ }
+ $_SESSION['flash'] = 'Đã lưu cài đặt';
+ redirect('admin/settings');
+ }
+
+ private function handleUpload(string $field, string $current): string
+ {
+ if (!isset($_FILES[$field]) || $_FILES[$field]['error'] !== UPLOAD_ERR_OK) {
+ return $current;
+ }
+ $file = $_FILES[$field];
+ if ($file['size'] > 2 * 1024 * 1024) {
+ return $current;
+ }
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mime = finfo_file($finfo, $file['tmp_name']);
+ finfo_close($finfo);
+ $allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp'];
+ if (!isset($allowed[$mime])) {
+ return $current;
+ }
+ $ext = $allowed[$mime];
+ $name = Security::sanitizeFilename($field . '-' . time() . '.' . $ext);
+ $path = __DIR__ . '/../../../public/uploads/' . $name;
+ move_uploaded_file($file['tmp_name'], $path);
+ return 'uploads/' . $name;
+ }
+}
diff --git a/app/Controllers/Frontend/ArticleController.php b/app/Controllers/Frontend/ArticleController.php
new file mode 100644
index 0000000..11dffe1
--- /dev/null
+++ b/app/Controllers/Frontend/ArticleController.php
@@ -0,0 +1,68 @@
+ $article,
+ 'settings' => $settings,
+ 'related' => $related,
+ 'categories' => \App\Models\Category::all(),
+ ]);
+ }
+
+ public function clickShopee(): void
+ {
+ verify_csrf();
+ $articleId = (int)($_POST['article_id'] ?? 0);
+ $settings = Setting::getAll();
+ $botList = $settings['bot_list'] ?? ['bot', 'crawler'];
+ $success = false;
+ if ($articleId && !is_bot($botList)) {
+ $success = Stat::trackClick($articleId, client_fingerprint(), $settings);
+ if (!empty($settings['telegram_click_enabled'])) {
+ $this->notifyTelegram('Click Shopee mới', 'Bài #' . $articleId . ' vừa nhận click Shopee.');
+ }
+ }
+ header('Content-Type: application/json');
+ echo json_encode(['ok' => $success]);
+ }
+
+ private function notifyTelegram(string $title, string $message): void
+ {
+ $settings = Setting::getAll();
+ $token = $settings['telegram_token'] ?? '';
+ $chatId = $settings['telegram_chat_id'] ?? '';
+ if (!$token || !$chatId) {
+ return;
+ }
+ $url = "https://api.telegram.org/bot{$token}/sendMessage";
+ $payload = http_build_query([
+ 'chat_id' => $chatId,
+ 'text' => $title . "\n" . $message,
+ ]);
+ @file_get_contents($url . '?' . $payload);
+ }
+}
diff --git a/app/Controllers/Frontend/HomeController.php b/app/Controllers/Frontend/HomeController.php
new file mode 100644
index 0000000..8d63566
--- /dev/null
+++ b/app/Controllers/Frontend/HomeController.php
@@ -0,0 +1,29 @@
+ $data['items'],
+ 'pagination' => $data,
+ 'categories' => $categories,
+ 'settings' => $settings,
+ 'search' => $search,
+ 'categoryId' => $categoryId,
+ ]);
+ }
+}
diff --git a/app/Core/Config.php b/app/Core/Config.php
new file mode 100644
index 0000000..443a165
--- /dev/null
+++ b/app/Core/Config.php
@@ -0,0 +1,25 @@
+ PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ];
+ try {
+ self::$instance = new PDO($dsn, Config::get('db.user'), Config::get('db.pass'), $options);
+ } catch (PDOException $e) {
+ if (Config::get('app.debug')) {
+ throw $e;
+ }
+ die('Database connection failed.');
+ }
+ }
+ return self::$instance;
+ }
+}
diff --git a/app/Core/Router.php b/app/Core/Router.php
new file mode 100644
index 0000000..052e1bc
--- /dev/null
+++ b/app/Core/Router.php
@@ -0,0 +1,32 @@
+routes['GET'][$path] = $handler;
+ }
+
+ public function post(string $path, callable $handler): void
+ {
+ $this->routes['POST'][$path] = $handler;
+ }
+
+ public function dispatch(string $uri, string $method)
+ {
+ $path = parse_url($uri, PHP_URL_PATH);
+ $path = rtrim($path, '/') ?: '/';
+ foreach ($this->routes[$method] ?? [] as $route => $handler) {
+ $pattern = '#^' . preg_replace('#\{([^/]+)\}#', '(?P<$1>[^/]+)', $route) . '$#';
+ if (preg_match($pattern, $path, $matches)) {
+ return call_user_func($handler, $matches);
+ }
+ }
+ http_response_code(404);
+ echo 'Page not found';
+ return null;
+ }
+}
diff --git a/app/Core/Security.php b/app/Core/Security.php
new file mode 100644
index 0000000..7c63e24
--- /dev/null
+++ b/app/Core/Security.php
@@ -0,0 +1,35 @@
+';
+}
+
+function verify_csrf(): void
+{
+ $token = $_POST['csrf_token'] ?? '';
+ if (!Security::verifyCsrf($token)) {
+ http_response_code(403);
+ exit('Invalid CSRF token');
+ }
+}
+
+function slugify(string $text): string
+{
+ $text = preg_replace('~[^\pL\d]+~u', '-', $text);
+ $text = trim($text, '-');
+ $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
+ $text = strtolower($text);
+ $text = preg_replace('~[^-a-z0-9]+~', '', $text);
+ return $text ?: 'n-a';
+}
+
+function is_bot(array $botList): bool
+{
+ $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
+ if (!$ua) {
+ return true;
+ }
+ foreach ($botList as $bot) {
+ if ($bot && stripos($ua, $bot) !== false) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function client_fingerprint(): string
+{
+ $ua = $_SERVER['HTTP_USER_AGENT'] ?? 'unknown';
+ $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
+ return hash('sha256', $ua . '|' . $ip);
+}
+
+function human_date(string $date): string
+{
+ return date('d/m/Y H:i', strtotime($date));
+}
diff --git a/app/Models/Article.php b/app/Models/Article.php
new file mode 100644
index 0000000..73fec0e
--- /dev/null
+++ b/app/Models/Article.php
@@ -0,0 +1,137 @@
+prepare($sql);
+ foreach ($params as $k => $v) {
+ $stmt->bindValue($k, $v);
+ }
+ $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
+ $stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
+ $stmt->execute();
+ $items = $stmt->fetchAll();
+
+ $countSql = "SELECT COUNT(*) FROM articles a $where";
+ $countStmt = Database::getInstance()->prepare($countSql);
+ foreach ($params as $k => $v) {
+ $countStmt->bindValue($k, $v);
+ }
+ $countStmt->execute();
+ $total = (int) $countStmt->fetchColumn();
+
+ return ['items' => $items, 'total' => $total, 'page' => $page, 'perPage' => $perPage];
+ }
+
+ public static function findBySlug(string $slug): ?array
+ {
+ $sql = 'SELECT a.*, c.name as category_name FROM articles a LEFT JOIN categories c ON c.id = a.category_id WHERE a.slug = :slug';
+ $stmt = Database::getInstance()->prepare($sql);
+ $stmt->execute([':slug' => $slug]);
+ $article = $stmt->fetch();
+ return $article ?: null;
+ }
+
+ public static function findById(int $id): ?array
+ {
+ $stmt = Database::getInstance()->prepare('SELECT * FROM articles WHERE id = :id');
+ $stmt->execute([':id' => $id]);
+ $article = $stmt->fetch();
+ return $article ?: null;
+ }
+
+ public static function related(int $categoryId, int $excludeId, int $limit = 4): array
+ {
+ if ($categoryId > 0) {
+ $stmt = Database::getInstance()->prepare('SELECT id, title, slug FROM articles WHERE status = "published" AND category_id = :cat AND id <> :id ORDER BY published_at DESC LIMIT :limit');
+ $stmt->bindValue(':cat', $categoryId, PDO::PARAM_INT);
+ } else {
+ $stmt = Database::getInstance()->prepare('SELECT id, title, slug FROM articles WHERE status = "published" AND id <> :id ORDER BY published_at DESC LIMIT :limit');
+ }
+ $stmt->bindValue(':id', $excludeId, PDO::PARAM_INT);
+ $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll();
+ }
+
+ public static function create(array $data): int
+ {
+ $sql = 'INSERT INTO articles (title, slug, meta_title, meta_description, keywords, og_image, content, category_id, tags, status, include_id_tail, published_at, created_at) VALUES (:title, :slug, :meta_title, :meta_description, :keywords, :og_image, :content, :category_id, :tags, :status, :include_id_tail, :published_at, NOW())';
+ $stmt = Database::getInstance()->prepare($sql);
+ $stmt->execute([
+ ':title' => $data['title'],
+ ':slug' => $data['slug'],
+ ':meta_title' => $data['meta_title'],
+ ':meta_description' => $data['meta_description'],
+ ':keywords' => $data['keywords'],
+ ':og_image' => $data['og_image'],
+ ':content' => $data['content'],
+ ':category_id' => $data['category_id'],
+ ':tags' => $data['tags'],
+ ':status' => $data['status'],
+ ':include_id_tail' => $data['include_id_tail'],
+ ':published_at' => $data['published_at'],
+ ]);
+ return (int) Database::getInstance()->lastInsertId();
+ }
+
+ public static function update(int $id, array $data): void
+ {
+ $sql = 'UPDATE articles SET title=:title, slug=:slug, meta_title=:meta_title, meta_description=:meta_description, keywords=:keywords, og_image=:og_image, content=:content, category_id=:category_id, tags=:tags, status=:status, include_id_tail=:include_id_tail, published_at=:published_at WHERE id=:id';
+ $stmt = Database::getInstance()->prepare($sql);
+ $stmt->execute([
+ ':title' => $data['title'],
+ ':slug' => $data['slug'],
+ ':meta_title' => $data['meta_title'],
+ ':meta_description' => $data['meta_description'],
+ ':keywords' => $data['keywords'],
+ ':og_image' => $data['og_image'],
+ ':content' => $data['content'],
+ ':category_id' => $data['category_id'],
+ ':tags' => $data['tags'],
+ ':status' => $data['status'],
+ ':include_id_tail' => $data['include_id_tail'],
+ ':published_at' => $data['published_at'],
+ ':id' => $id,
+ ]);
+ }
+
+ public static function delete(int $id): void
+ {
+ $stmt = Database::getInstance()->prepare('DELETE FROM articles WHERE id = :id');
+ $stmt->execute([':id' => $id]);
+ }
+
+ public static function copy(int $id): ?int
+ {
+ $article = self::findById($id);
+ if (!$article) {
+ return null;
+ }
+ unset($article['id']);
+ $article['slug'] = $article['slug'] . '-copy-' . rand(100, 999);
+ $article['status'] = 'draft';
+ return self::create($article);
+ }
+}
diff --git a/app/Models/Category.php b/app/Models/Category.php
new file mode 100644
index 0000000..53d5e20
--- /dev/null
+++ b/app/Models/Category.php
@@ -0,0 +1,28 @@
+query('SELECT * FROM categories ORDER BY name');
+ return $stmt->fetchAll();
+ }
+
+ public static function find(int $id): ?array
+ {
+ $stmt = Database::getInstance()->prepare('SELECT * FROM categories WHERE id = :id');
+ $stmt->execute([':id' => $id]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+ }
+
+ public static function create(string $name, string $slug): void
+ {
+ $stmt = Database::getInstance()->prepare('INSERT INTO categories (name, slug) VALUES (:name, :slug)');
+ $stmt->execute([':name' => $name, ':slug' => $slug]);
+ }
+}
diff --git a/app/Models/Setting.php b/app/Models/Setting.php
new file mode 100644
index 0000000..45f5332
--- /dev/null
+++ b/app/Models/Setting.php
@@ -0,0 +1,34 @@
+prepare('SELECT value FROM settings WHERE `key` = :key LIMIT 1');
+ $stmt->execute([':key' => $key]);
+ $value = $stmt->fetchColumn();
+ return $value !== false ? json_decode($value, true) : $default;
+ }
+
+ public static function set(string $key, $value): void
+ {
+ $stmt = Database::getInstance()->prepare('INSERT INTO settings (`key`, `value`) VALUES (:key, :value) ON DUPLICATE KEY UPDATE value=:value');
+ $stmt->execute([
+ ':key' => $key,
+ ':value' => json_encode($value),
+ ]);
+ }
+
+ public static function getAll(): array
+ {
+ $stmt = Database::getInstance()->query('SELECT `key`, `value` FROM settings');
+ $data = [];
+ foreach ($stmt->fetchAll() as $row) {
+ $data[$row['key']] = json_decode($row['value'], true);
+ }
+ return $data;
+ }
+}
diff --git a/app/Models/Stat.php b/app/Models/Stat.php
new file mode 100644
index 0000000..fa34f59
--- /dev/null
+++ b/app/Models/Stat.php
@@ -0,0 +1,97 @@
+prepare('SELECT occurred_at FROM interactions WHERE fingerprint = :fp AND type = :type ORDER BY occurred_at DESC LIMIT 1');
+ $stmt->execute([':fp' => $fingerprint, ':type' => $type]);
+ $time = $stmt->fetchColumn();
+ if (!$time) {
+ return false;
+ }
+ return (time() - strtotime($time)) < $seconds;
+ }
+
+ private static function insertInteraction(string $fingerprint, string $type, int $articleId): void
+ {
+ $stmt = Database::getInstance()->prepare('INSERT INTO interactions (fingerprint, type, article_id, occurred_at) VALUES (:fp, :type, :article_id, NOW())');
+ $stmt->execute([':fp' => $fingerprint, ':type' => $type, ':article_id' => $articleId]);
+ }
+
+ private static function incrementDaily(string $column): void
+ {
+ $today = date('Y-m-d');
+ $sql = "INSERT INTO stats_daily (`date`, views, clicks) VALUES (:date, :views, :clicks) ON DUPLICATE KEY UPDATE $column = $column + 1";
+ $stmt = Database::getInstance()->prepare($sql);
+ $stmt->execute([
+ ':date' => $today,
+ ':views' => $column === 'views' ? 1 : 0,
+ ':clicks' => $column === 'clicks' ? 1 : 0,
+ ]);
+ }
+
+ private static function incrementArticle(int $articleId, string $column): void
+ {
+ $sql = "INSERT INTO article_stats (article_id, views, clicks) VALUES (:id, :views, :clicks) ON DUPLICATE KEY UPDATE $column = $column + 1";
+ $stmt = Database::getInstance()->prepare($sql);
+ $stmt->execute([
+ ':id' => $articleId,
+ ':views' => $column === 'views' ? 1 : 0,
+ ':clicks' => $column === 'clicks' ? 1 : 0,
+ ]);
+ }
+
+ public static function daily(): array
+ {
+ $stmt = Database::getInstance()->query('SELECT * FROM stats_daily ORDER BY date DESC LIMIT 30');
+ return $stmt->fetchAll();
+ }
+
+ public static function totals(): array
+ {
+ $stmt = Database::getInstance()->query('SELECT SUM(views) as views, SUM(clicks) as clicks FROM stats_daily');
+ $totals = $stmt->fetch();
+ return $totals ?: ['views' => 0, 'clicks' => 0];
+ }
+
+ public static function byArticle(): array
+ {
+ $stmt = Database::getInstance()->query('SELECT a.id, a.title, s.views, s.clicks FROM articles a LEFT JOIN article_stats s ON s.article_id = a.id ORDER BY (s.views IS NULL), s.views DESC');
+ return $stmt->fetchAll();
+ }
+
+ public static function reset(): void
+ {
+ Database::getInstance()->exec('TRUNCATE TABLE stats_daily');
+ Database::getInstance()->exec('TRUNCATE TABLE article_stats');
+ Database::getInstance()->exec('TRUNCATE TABLE interactions');
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
new file mode 100644
index 0000000..9a47297
--- /dev/null
+++ b/app/Models/User.php
@@ -0,0 +1,26 @@
+prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
+ $stmt->execute([':email' => $email]);
+ $user = $stmt->fetch();
+ return $user ?: null;
+ }
+
+ public static function create(string $name, string $email, string $password): void
+ {
+ $hash = password_hash($password, PASSWORD_DEFAULT);
+ $stmt = Database::getInstance()->prepare('INSERT INTO users (name, email, password) VALUES (:name, :email, :password)');
+ $stmt->execute([
+ ':name' => $name,
+ ':email' => $email,
+ ':password' => $hash,
+ ]);
+ }
+}
diff --git a/app/Views/admin/articles/form.php b/app/Views/admin/articles/form.php
new file mode 100644
index 0000000..638e741
--- /dev/null
+++ b/app/Views/admin/articles/form.php
@@ -0,0 +1,30 @@
+
+
+
diff --git a/app/Views/admin/articles/index.php b/app/Views/admin/articles/index.php
new file mode 100644
index 0000000..bb8d80b
--- /dev/null
+++ b/app/Views/admin/articles/index.php
@@ -0,0 +1,25 @@
+
+
+
+ | Tiêu đề | Slug | Trạng thái | Ngày | Hành động |
+
+
+ | = App\Core\Security::escape($article['title']) ?> |
+ = App\Core\Security::escape($article['slug']) ?> |
+ = $article['status'] ?> |
+ = human_date($article['published_at']) ?> |
+
+ Sửa
+
+
+ |
+
+
+
+
diff --git a/app/Views/admin/dashboard.php b/app/Views/admin/dashboard.php
new file mode 100644
index 0000000..4a70c74
--- /dev/null
+++ b/app/Views/admin/dashboard.php
@@ -0,0 +1,38 @@
+
+
+ Pageviews: = $stats['views'] ?? 0 ?>
+ Click Shopee: = $stats['clicks'] ?? 0 ?>
+ CTR: = ($stats['views'] ?? 0) ? round(($stats['clicks'] / $stats['views']) * 100, 2) : 0 ?>%
+
+
+ Thống kê theo ngày
+
+ | Ngày | View | Click |
+
+ | = $row['date'] ?> | = $row['views'] ?> | = $row['clicks'] ?> |
+
+
+
+
+ Theo bài
+
+ | ID | Tiêu đề | View | Click |
+
+ | = $row['id'] ?> | = App\Core\Security::escape($row['title']) ?> | = $row['views'] ?? 0 ?> | = $row['clicks'] ?? 0 ?> |
+
+
+
+
+ Nhật ký reset
+
+
+ - = App\Core\Security::escape($log['by']) ?> lúc = $log['at'] ?>
+
+
+
+
+Export CSV
+
diff --git a/app/Views/admin/layout_bottom.php b/app/Views/admin/layout_bottom.php
new file mode 100644
index 0000000..16fb04b
--- /dev/null
+++ b/app/Views/admin/layout_bottom.php
@@ -0,0 +1,4 @@
+
+
+