diff --git a/Strategi b/Strategi
new file mode 100644
index 0000000..e69de29
diff --git a/admin.php b/admin.php
new file mode 100644
index 0000000..c0b1e08
--- /dev/null
+++ b/admin.php
@@ -0,0 +1,171 @@
+ '',
+ 'category' => '',
+ 'excerpt' => '',
+ 'content' => '',
+ 'cta_text' => 'Kunjungi apknusa.com',
+ 'cta_url' => APKNUSA_URL,
+ 'featured' => 0,
+];
+$errors = [];
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $result = create_post($_POST);
+ if (!empty($result['success'])) {
+ header('Location: admin.php?status=created&slug=' . rawurlencode((string)$result['slug']), true, 303);
+ exit;
+ }
+
+ $errors = $result['errors'] ?? [];
+ $formData = array_merge($formData, $result['input'] ?? []);
+}
+
+$posts = fetch_posts();
+$latestSlug = trim((string)($_GET['slug'] ?? ''));
+$status = trim((string)($_GET['status'] ?? ''));
+
+render_page_start([
+ 'title' => 'Kelola Konten Blog',
+ 'description' => 'Halaman admin sederhana untuk menambah artikel blog yang mengarahkan pembaca ke apknusa.com.',
+ 'canonical' => canonical_for('admin.php'),
+ 'robots' => 'noindex,nofollow',
+ 'keywords' => 'admin blog, tambah artikel apknusa',
+ 'body_class' => 'admin-page',
+]);
+?>
+
+
+
+
+
+
+
Artikel berhasil ditambahkan dan sitemap.xml sudah diperbarui.
+
+
+
+
+
+
+
+
+
+
+
Tambah artikel baru
+
Workflow admin sederhana
+
Tulis artikel, tentukan CTA, lalu artikel otomatis masuk ke blog publik dan sitemap.
+
+
+ Tautan CTA dibatasi ke apknusa.com agar microsite tetap fokus sebagai website pendukung backlink.
+
+
+
+
+
+
+
+
+
Status konten
+
Artikel yang sudah tayang
+
Daftar ini memperlihatkan konten yang aktif di blog dan ikut masuk ke sitemap.
+
+
+
+
+
+
+
+
+
+
+ Artikel
+ Kategori
+ CTA
+ Tayang
+ Aksi
+
+
+
+
+
+
+ = e((string)$post['title']) ?>
+ = e((string)$post['slug']) ?>
+
+ = e((string)$post['category']) ?>
+
+ = e((string)$post['cta_text']) ?>
+ = e((string)$post['cta_url']) ?>
+
+
+ = e((string)format_article_date((string)$post['published_at'])) ?>
+ Unggulan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/css/custom.css b/assets/css/custom.css
index 789132e..a2b41f3 100644
--- a/assets/css/custom.css
+++ b/assets/css/custom.css
@@ -1,403 +1,1108 @@
+:root {
+ --bg: #edf4fb;
+ --bg-deep: #e6effa;
+ --surface: rgba(255, 255, 255, 0.86);
+ --surface-solid: #ffffff;
+ --surface-muted: rgba(248, 250, 252, 0.88);
+ --line: rgba(148, 163, 184, 0.18);
+ --line-strong: rgba(100, 116, 139, 0.28);
+ --text: #0f172a;
+ --muted: #475569;
+ --muted-soft: #64748b;
+ --accent: #0f172a;
+ --accent-strong: #111c35;
+ --accent-alt: #0ea5e9;
+ --accent-mint: #14b8a6;
+ --accent-soft: rgba(14, 165, 233, 0.08);
+ --accent-soft-2: rgba(20, 184, 166, 0.1);
+ --shadow-sm: 0 10px 26px rgba(15, 23, 42, 0.06);
+ --shadow-md: 0 24px 64px rgba(15, 23, 42, 0.1);
+ --shadow-lg: 0 32px 96px rgba(15, 23, 42, 0.14);
+ --radius-sm: 12px;
+ --radius-md: 18px;
+ --radius-lg: 28px;
+ --content-width: 1200px;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
body {
- background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
- background-size: 400% 400%;
- animation: gradient 15s ease infinite;
- color: #212529;
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
- font-size: 14px;
+ position: relative;
margin: 0;
min-height: 100vh;
+ background:
+ radial-gradient(circle at top left, rgba(14, 165, 233, 0.14), transparent 32%),
+ radial-gradient(circle at top right, rgba(20, 184, 166, 0.11), transparent 28%),
+ linear-gradient(180deg, #f8fbff 0%, var(--bg) 45%, var(--bg-deep) 100%);
+ color: var(--text);
+ font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ font-size: 15px;
+ line-height: 1.68;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
}
-.main-wrapper {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 100vh;
- width: 100%;
- padding: 20px;
- box-sizing: border-box;
- position: relative;
- z-index: 1;
-}
-
-@keyframes gradient {
- 0% {
- background-position: 0% 50%;
- }
- 50% {
- background-position: 100% 50%;
- }
- 100% {
- background-position: 0% 50%;
- }
-}
-
-.chat-container {
- width: 100%;
- max-width: 600px;
- background: rgba(255, 255, 255, 0.85);
- border: 1px solid rgba(255, 255, 255, 0.3);
- border-radius: 20px;
- display: flex;
- flex-direction: column;
- height: 85vh;
- box-shadow: 0 20px 40px rgba(0,0,0,0.2);
- backdrop-filter: blur(15px);
- -webkit-backdrop-filter: blur(15px);
- overflow: hidden;
-}
-
-.chat-header {
- padding: 1.5rem;
- border-bottom: 1px solid rgba(0, 0, 0, 0.05);
- background: rgba(255, 255, 255, 0.5);
- font-weight: 700;
- font-size: 1.1rem;
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-
-.chat-messages {
- flex: 1;
- overflow-y: auto;
- padding: 1.5rem;
- display: flex;
- flex-direction: column;
- gap: 1.25rem;
-}
-
-/* Custom Scrollbar */
-::-webkit-scrollbar {
- width: 6px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.3);
- border-radius: 10px;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.5);
-}
-
-.message {
- max-width: 85%;
- padding: 0.85rem 1.1rem;
- border-radius: 16px;
- line-height: 1.5;
- font-size: 0.95rem;
- box-shadow: 0 4px 15px rgba(0,0,0,0.05);
- animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(20px) scale(0.95); }
- to { opacity: 1; transform: translateY(0) scale(1); }
-}
-
-.message.visitor {
- align-self: flex-end;
- background: linear-gradient(135deg, #212529 0%, #343a40 100%);
- color: #fff;
- border-bottom-right-radius: 4px;
-}
-
-.message.bot {
- align-self: flex-start;
- background: #ffffff;
- color: #212529;
- border-bottom-left-radius: 4px;
-}
-
-.chat-input-area {
- padding: 1.25rem;
- background: rgba(255, 255, 255, 0.5);
- border-top: 1px solid rgba(0, 0, 0, 0.05);
-}
-
-.chat-input-area form {
- display: flex;
- gap: 0.75rem;
-}
-
-.chat-input-area input {
- flex: 1;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- padding: 0.75rem 1rem;
- outline: none;
- background: rgba(255, 255, 255, 0.9);
- transition: all 0.3s ease;
-}
-
-.chat-input-area input:focus {
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
-}
-
-.chat-input-area button {
- background: #212529;
- color: #fff;
- border: none;
- padding: 0.75rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
- transition: all 0.3s ease;
-}
-
-.chat-input-area button:hover {
- background: #000;
- transform: translateY(-2px);
- box-shadow: 0 5px 15px rgba(0,0,0,0.2);
-}
-
-/* Background Animations */
-.bg-animations {
+body::before,
+body::after {
+ content: '';
position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 0;
- overflow: hidden;
+ z-index: -1;
pointer-events: none;
+ filter: blur(72px);
+ opacity: 0.85;
}
-.blob {
- position: absolute;
- width: 500px;
- height: 500px;
- background: rgba(255, 255, 255, 0.2);
+body::before {
+ top: 3rem;
+ right: 3rem;
+ width: 16rem;
+ height: 16rem;
border-radius: 50%;
- filter: blur(80px);
- animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
+ background: rgba(14, 165, 233, 0.12);
}
-.blob-1 {
- top: -10%;
- left: -10%;
- background: rgba(238, 119, 82, 0.4);
+body::after {
+ left: -2rem;
+ bottom: 10%;
+ width: 18rem;
+ height: 18rem;
+ border-radius: 50%;
+ background: rgba(20, 184, 166, 0.1);
}
-.blob-2 {
- bottom: -10%;
- right: -10%;
- background: rgba(35, 166, 213, 0.4);
- animation-delay: -7s;
- width: 600px;
- height: 600px;
+::selection {
+ background: rgba(14, 165, 233, 0.18);
+ color: var(--text);
}
-.blob-3 {
- top: 40%;
- left: 30%;
- background: rgba(231, 60, 126, 0.3);
- animation-delay: -14s;
- width: 450px;
- height: 450px;
+img {
+ max-width: 100%;
+ height: auto;
}
-@keyframes move {
- 0% { transform: translate(0, 0) rotate(0deg) scale(1); }
- 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
- 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
- 100% { transform: translate(0, 0) rotate(360deg) scale(1); }
+main {
+ min-height: calc(100vh - 220px);
}
-.header-link {
- font-size: 14px;
+.page-wrap {
+ padding-top: 0.35rem;
+}
+
+.py-lg-6 {
+ padding-top: 4.75rem !important;
+ padding-bottom: 4.75rem !important;
+}
+
+.container {
+ max-width: var(--content-width);
+}
+
+.skip-link {
+ position: absolute;
+ left: -999px;
+ top: 0;
+ z-index: 1000;
+ padding: 0.75rem 1rem;
+ background: var(--accent);
color: #fff;
- text-decoration: none;
- background: rgba(0, 0, 0, 0.2);
- padding: 0.5rem 1rem;
- border-radius: 8px;
- transition: all 0.3s ease;
+ border-radius: 0 0 var(--radius-sm) var(--radius-sm);
}
-.header-link:hover {
- background: rgba(0, 0, 0, 0.4);
+.skip-link:focus {
+ left: 1rem;
+}
+
+.site-header {
+ background: rgba(248, 250, 252, 0.72);
+ border-color: rgba(148, 163, 184, 0.16) !important;
+ backdrop-filter: blur(18px);
+ box-shadow: 0 10px 32px rgba(15, 23, 42, 0.05);
+}
+
+.navbar {
+ padding-top: 0.95rem;
+ padding-bottom: 0.95rem;
+}
+
+.navbar-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.85rem;
+ color: var(--text);
text-decoration: none;
}
-/* Admin Styles */
-.admin-container {
- max-width: 900px;
- margin: 3rem auto;
- padding: 2.5rem;
- background: rgba(255, 255, 255, 0.85);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-radius: 24px;
- box-shadow: 0 20px 50px rgba(0,0,0,0.15);
- border: 1px solid rgba(255, 255, 255, 0.4);
+.navbar-brand:hover,
+.navbar-brand:focus {
+ color: var(--text);
+}
+
+.brand-mark {
position: relative;
- z-index: 1;
+ width: 2.35rem;
+ height: 2.35rem;
+ border-radius: 16px;
+ background: linear-gradient(135deg, var(--accent-alt), var(--accent-mint));
+ box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.5), 0 12px 32px rgba(14, 165, 233, 0.28);
}
-.admin-container h1 {
- margin-top: 0;
- color: #212529;
+.brand-mark::before,
+.brand-mark::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ border-radius: 999px;
+}
+
+.brand-mark::before {
+ width: 1rem;
+ height: 1rem;
+ background: rgba(255, 255, 255, 0.92);
+}
+
+.brand-mark::after {
+ width: 0.34rem;
+ height: 0.34rem;
+ background: var(--accent);
+}
+
+.brand-copy {
+ display: inline-flex;
+ flex-direction: column;
+ line-height: 1.05;
+}
+
+.brand-title {
+ font-size: 1rem;
+ font-weight: 800;
+ letter-spacing: -0.03em;
+}
+
+.brand-subtitle {
+ color: var(--muted-soft);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.navbar-toggler {
+ border-color: rgba(100, 116, 139, 0.22);
+ border-radius: 14px;
+ padding: 0.45rem 0.68rem;
+ background: rgba(255, 255, 255, 0.7);
+}
+
+.navbar-toggler:focus {
+ box-shadow: 0 0 0 0.2rem rgba(14, 165, 233, 0.14);
+}
+
+.navbar-nav .nav-link {
+ color: var(--muted);
+ font-weight: 600;
+ padding: 0.65rem 0.95rem;
+ border-radius: 999px;
+ transition: color 0.18s ease, background-color 0.18s ease, transform 0.18s ease;
+}
+
+.navbar-nav .nav-link.active,
+.navbar-nav .nav-link:hover,
+.navbar-nav .nav-link:focus {
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.72);
+ transform: translateY(-1px);
+}
+
+.nav-actions .btn {
+ min-width: 118px;
+}
+
+.hero-section {
+ padding-top: 3.15rem;
+ padding-bottom: 2.4rem;
+}
+
+.hero-panel,
+.panel-card,
+.content-card,
+.metric-card,
+.article-shell,
+.table-card,
+.empty-state,
+.cta-panel,
+.list-shell,
+.list-row,
+.related-item,
+.spotlight-banner {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ box-shadow: var(--shadow-sm);
+ backdrop-filter: blur(18px);
+}
+
+.hero-panel {
+ position: relative;
+ overflow: hidden;
+ border-radius: 34px;
+ padding: 2.75rem;
+ border-color: rgba(148, 163, 184, 0.2);
+ background:
+ linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(248, 250, 252, 0.84)),
+ radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 32%);
+ box-shadow: var(--shadow-md);
+}
+
+.hero-panel::before,
+.hero-panel::after {
+ content: '';
+ position: absolute;
+ pointer-events: none;
+ border-radius: 999px;
+}
+
+.hero-panel::before {
+ top: -4rem;
+ right: -3rem;
+ width: 14rem;
+ height: 14rem;
+ background: rgba(14, 165, 233, 0.09);
+}
+
+.hero-panel::after {
+ bottom: -5rem;
+ left: -4rem;
+ width: 16rem;
+ height: 16rem;
+ background: rgba(20, 184, 166, 0.08);
+}
+
+.hero-title,
+.section-title,
+.article-title {
+ letter-spacing: -0.045em;
+ color: var(--text);
+}
+
+.hero-title {
+ font-size: clamp(2.4rem, 4vw, 4rem);
+ font-weight: 800;
+ line-height: 1.02;
+ max-width: 11ch;
+}
+
+.hero-copy,
+.section-copy,
+.card-copy,
+.metric-copy,
+.article-lead,
+.footer-copy,
+.list-copy {
+ color: var(--muted);
+}
+
+.hero-copy {
+ max-width: 60ch;
+ font-size: 1.02rem;
+}
+
+.eyebrow,
+.section-kicker,
+.card-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--muted-soft);
+}
+
+.eyebrow::before,
+.section-kicker::before,
+.card-label::before {
+ content: '';
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 999px;
+ background: linear-gradient(135deg, var(--accent-alt), var(--accent-mint));
+ box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.08);
+}
+
+.hero-chip-row,
+.meta-inline {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.8rem;
+}
+
+.meta-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.72rem 0.95rem;
+ border-radius: 999px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ background: rgba(255, 255, 255, 0.62);
+ color: var(--muted);
+ font-size: 0.92rem;
+ font-weight: 600;
+ box-shadow: 0 10px 24px rgba(15, 23, 42, 0.04);
+}
+
+.meta-pill strong,
+.meta-inline strong {
+ color: var(--text);
font-weight: 800;
}
-.table {
- width: 100%;
- border-collapse: separate;
- border-spacing: 0 8px;
- margin-top: 1.5rem;
+.hero-visual {
+ position: relative;
+ min-height: 360px;
+ padding: 0.5rem;
}
-.table th {
- background: transparent;
- border: none;
+.hero-glow {
+ position: absolute;
+ border-radius: 999px;
+ filter: blur(8px);
+ opacity: 0.85;
+}
+
+.hero-glow-a {
+ top: 1rem;
+ right: 0.25rem;
+ width: 9rem;
+ height: 9rem;
+ background: rgba(14, 165, 233, 0.24);
+}
+
+.hero-glow-b {
+ bottom: 0.75rem;
+ left: 0;
+ width: 8.5rem;
+ height: 8.5rem;
+ background: rgba(20, 184, 166, 0.22);
+}
+
+.visual-browser {
+ position: relative;
+ z-index: 1;
+ width: min(100%, 360px);
+ margin: 0 auto;
padding: 1rem;
- color: #6c757d;
- font-weight: 600;
+ border-radius: 28px;
+ background: linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(17, 28, 53, 0.92));
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ color: #e2e8f0;
+ box-shadow: 0 28px 64px rgba(15, 23, 42, 0.28);
+}
+
+.visual-bar {
+ display: flex;
+ gap: 0.45rem;
+ margin-bottom: 1rem;
+}
+
+.visual-bar span {
+ width: 0.72rem;
+ height: 0.72rem;
+ border-radius: 999px;
+ background: rgba(226, 232, 240, 0.26);
+}
+
+.visual-bar span:first-child {
+ background: rgba(248, 113, 113, 0.92);
+}
+
+.visual-bar span:nth-child(2) {
+ background: rgba(250, 204, 21, 0.92);
+}
+
+.visual-bar span:last-child {
+ background: rgba(34, 197, 94, 0.92);
+}
+
+.visual-badge {
+ display: inline-flex;
+ padding: 0.42rem 0.72rem;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: #bfdbfe;
+ font-size: 0.76rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
text-transform: uppercase;
- font-size: 0.75rem;
- letter-spacing: 1px;
}
-.table td {
- background: #fff;
+.visual-card {
+ margin-top: 0.95rem;
padding: 1rem;
- border: none;
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.08);
}
-.table tr td:first-child { border-radius: 12px 0 0 12px; }
-.table tr td:last-child { border-radius: 0 12px 12px 0; }
-
-.form-group {
- margin-bottom: 1.25rem;
-}
-
-.form-group label {
+.visual-card strong {
display: block;
- margin-bottom: 0.5rem;
- font-weight: 600;
+ font-size: 1rem;
+ margin-bottom: 0.35rem;
+ color: #f8fafc;
+}
+
+.visual-card p,
+.visual-stats span {
+ margin: 0;
+ color: rgba(226, 232, 240, 0.78);
font-size: 0.9rem;
}
-.form-control {
- width: 100%;
- padding: 0.75rem 1rem;
- border: 1px solid rgba(0, 0, 0, 0.1);
- border-radius: 12px;
- background: #fff;
- transition: all 0.3s ease;
- box-sizing: border-box;
+.visual-card.is-accent {
+ background: linear-gradient(135deg, rgba(14, 165, 233, 0.18), rgba(20, 184, 166, 0.16));
+ border-color: rgba(125, 211, 252, 0.22);
}
-.form-control:focus {
- outline: none;
- border-color: #23a6d5;
- box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
+.visual-stats {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.85rem;
+ margin-top: 0.95rem;
}
-.header-container {
+.visual-stats div {
+ padding: 0.95rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.visual-stats strong {
+ display: block;
+ margin-top: 0.25rem;
+ font-size: 0.98rem;
+ color: #f8fafc;
+}
+
+.spotlight-banner {
+ display: grid;
+ grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
+ gap: 1.5rem;
+ padding: 1.65rem 1.75rem;
+ border-radius: 26px;
+}
+
+.spotlight-pills {
display: flex;
- justify-content: space-between;
+ flex-wrap: wrap;
+ align-content: center;
+ gap: 0.75rem;
+}
+
+.spotlight-pill {
+ display: inline-flex;
align-items: center;
-}
-
-.header-links {
- display: flex;
- gap: 1rem;
-}
-
-.admin-card {
- background: rgba(255, 255, 255, 0.6);
- padding: 2rem;
- border-radius: 20px;
- border: 1px solid rgba(255, 255, 255, 0.5);
- margin-bottom: 2.5rem;
- box-shadow: 0 10px 30px rgba(0,0,0,0.05);
-}
-
-.admin-card h3 {
- margin-top: 0;
- margin-bottom: 1.5rem;
+ justify-content: center;
+ padding: 0.78rem 0.98rem;
+ border-radius: 16px;
+ background: linear-gradient(135deg, rgba(14, 165, 233, 0.09), rgba(20, 184, 166, 0.11));
+ border: 1px solid rgba(125, 211, 252, 0.28);
+ color: var(--text);
+ font-size: 0.92rem;
font-weight: 700;
}
-.btn-delete {
- background: #dc3545;
- color: white;
- border: none;
- padding: 0.25rem 0.5rem;
- border-radius: 4px;
- cursor: pointer;
+.metric-card,
+.panel-card,
+.content-card,
+.article-shell,
+.table-card,
+.empty-state,
+.cta-panel,
+.blog-hero-card,
+.blog-side-card {
+ border-radius: var(--radius-lg);
+ padding: 1.6rem;
}
-.btn-add {
- background: #212529;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 4px;
- cursor: pointer;
- margin-top: 1rem;
+.metric-card {
+ position: relative;
+ min-height: 100%;
+ overflow: hidden;
}
-.btn-save {
- background: #0088cc;
- color: white;
- border: none;
- padding: 0.8rem 1.5rem;
- border-radius: 12px;
- cursor: pointer;
- font-weight: 600;
+.metric-card::before {
+ content: '';
+ position: absolute;
+ inset: 0 auto auto 0;
width: 100%;
- transition: all 0.3s ease;
+ height: 0.28rem;
+ background: linear-gradient(90deg, var(--accent-alt), var(--accent-mint));
}
-.webhook-url {
- font-size: 0.85em;
- color: #555;
- margin-top: 0.5rem;
+.metric-label {
+ color: var(--muted-soft);
+ font-size: 0.78rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ margin-bottom: 0.85rem;
}
-.history-table-container {
- overflow-x: auto;
- background: rgba(255, 255, 255, 0.4);
+.metric-value {
+ font-size: 1.22rem;
+ font-weight: 800;
+ margin-bottom: 0.6rem;
+}
+
+.feature-list {
+ list-style: none;
+ padding: 0;
+ margin: 1rem 0 0;
+}
+
+.feature-list li {
+ position: relative;
+ padding-left: 1.35rem;
+ margin-bottom: 0.85rem;
+ color: var(--muted);
+}
+
+.feature-list li::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0.58rem;
+ width: 0.55rem;
+ height: 0.55rem;
+ border-radius: 999px;
+ background: linear-gradient(135deg, var(--accent-alt), var(--accent-mint));
+}
+
+.compact-list li:last-child,
+.feature-list li:last-child {
+ margin-bottom: 0;
+}
+
+.section-block {
+ padding: 1.35rem 0 3.9rem;
+}
+
+.section-title {
+ font-size: clamp(1.65rem, 2.7vw, 2.55rem);
+ font-weight: 800;
+ line-height: 1.08;
+}
+
+.content-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.82rem;
+ transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
+}
+
+.content-card:hover,
+.content-card:focus-within,
+.list-row:hover,
+.list-row:focus-within,
+.related-item:hover,
+.related-item:focus-within {
+ transform: translateY(-2px);
+ border-color: rgba(14, 165, 233, 0.26);
+ box-shadow: var(--shadow-md);
+}
+
+.card-title,
+.list-title,
+.related-item h3 a,
+.card-title a,
+.list-title a,
+.article-title {
+ color: var(--text);
+ text-decoration: none;
+}
+
+.card-title a:hover,
+.list-title a:hover,
+.related-item h3 a:hover,
+.card-title a:focus,
+.list-title a:focus,
+.related-item h3 a:focus {
+ color: var(--text);
+ text-decoration: underline;
+ text-decoration-color: rgba(14, 165, 233, 0.58);
+ text-underline-offset: 0.18em;
+}
+
+.card-title {
+ font-size: 1.18rem;
+ line-height: 1.28;
+ margin-bottom: 0;
+}
+
+.card-copy {
+ margin-bottom: 0;
+}
+
+.card-footer-actions {
+ padding-top: 0.35rem;
+}
+
+.tag-badge,
+.tag-chip {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid rgba(125, 211, 252, 0.28);
+ border-radius: 999px;
+ padding: 0.42rem 0.78rem;
+ background: linear-gradient(135deg, rgba(14, 165, 233, 0.08), rgba(255, 255, 255, 0.7));
+ color: var(--text);
+ font-size: 0.82rem;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.tag-chip:hover,
+.tag-chip:focus,
+.tag-badge:hover,
+.tag-badge:focus {
+ color: var(--text);
+ border-color: rgba(14, 165, 233, 0.42);
+}
+
+.tag-chip.is-active {
+ background: linear-gradient(135deg, var(--accent), var(--accent-strong));
+ color: #fff;
+ border-color: transparent;
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
+}
+
+.tag-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.7rem;
+}
+
+.muted-meta {
+ color: var(--muted-soft);
+ font-size: 0.88rem;
+}
+
+.list-shell {
+ display: grid;
+ gap: 1rem;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ backdrop-filter: none;
+}
+
+.list-row {
+ border-radius: 22px;
+ padding: 1.3rem 1.4rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+}
+
+.list-title {
+ font-size: 1.06rem;
+ font-weight: 800;
+}
+
+.list-copy {
+ max-width: 60ch;
+ margin-bottom: 0;
+}
+
+.article-shell {
+ border-radius: 30px;
+ padding: 2.1rem;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.86));
+ box-shadow: var(--shadow-md);
+}
+
+.article-title {
+ font-size: clamp(2rem, 3vw, 3.2rem);
+ font-weight: 800;
+ line-height: 1.12;
+}
+
+.article-lead {
+ font-size: 1.05rem;
+ margin-bottom: 1.65rem;
+}
+
+.article-body p {
+ margin-bottom: 1.18rem;
+ color: #243042;
+}
+
+.article-body a {
+ color: #0369a1;
+}
+
+.article-body a:hover,
+.article-body a:focus {
+ color: #075985;
+}
+
+.cta-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ background: linear-gradient(135deg, rgba(14, 165, 233, 0.1), rgba(20, 184, 166, 0.08));
+ border-color: rgba(125, 211, 252, 0.22);
+}
+
+.flow-list li {
+ margin-bottom: 0.58rem;
+ color: var(--muted);
+}
+
+.related-item {
+ border-radius: 20px;
padding: 1rem;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.3);
+ background: rgba(255, 255, 255, 0.72);
}
-.history-table {
- width: 100%;
-}
-
-.history-table-time {
- width: 15%;
- white-space: nowrap;
- font-size: 0.85em;
- color: #555;
-}
-
-.history-table-user {
- width: 35%;
- background: rgba(255, 255, 255, 0.3);
- border-radius: 8px;
- padding: 8px;
-}
-
-.history-table-ai {
- width: 50%;
- background: rgba(255, 255, 255, 0.5);
- border-radius: 8px;
- padding: 8px;
-}
-
-.no-messages {
+.empty-state {
text-align: center;
- color: #777;
-}
\ No newline at end of file
+ border-style: dashed;
+ border-color: rgba(125, 211, 252, 0.34);
+ padding: 3rem 1.5rem;
+}
+
+.blog-hero-card {
+ background:
+ linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(244, 249, 255, 0.86));
+}
+
+.blog-side-card {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(240, 249, 255, 0.85));
+}
+
+.table-card {
+ overflow: hidden;
+}
+
+.admin-table thead th {
+ background: rgba(248, 250, 252, 0.88);
+ color: var(--muted-soft);
+ font-size: 0.8rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ border-bottom: 1px solid var(--line);
+ padding: 1rem;
+}
+
+.admin-table tbody td {
+ padding: 1rem;
+ border-color: var(--line);
+}
+
+.form-control,
+.form-select {
+ border: 1px solid rgba(100, 116, 139, 0.22);
+ border-radius: 14px;
+ padding: 0.82rem 0.95rem;
+ font-size: 0.95rem;
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.96);
+}
+
+.form-control:focus,
+.form-select:focus,
+.form-check-input:focus,
+.btn:focus {
+ box-shadow: 0 0 0 0.2rem rgba(14, 165, 233, 0.12);
+ border-color: rgba(14, 165, 233, 0.38);
+}
+
+.form-check-input {
+ border-radius: 6px;
+ border-color: rgba(100, 116, 139, 0.28);
+}
+
+.slug-preview {
+ color: var(--text);
+ font-weight: 700;
+}
+
+.toast {
+ border-radius: 18px;
+ box-shadow: var(--shadow-lg);
+}
+
+.btn {
+ border-radius: 14px;
+ font-weight: 700;
+ padding: 0.76rem 1.05rem;
+ transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
+}
+
+.btn:hover,
+.btn:focus-visible {
+ transform: translateY(-1px);
+}
+
+.btn-dark {
+ background: linear-gradient(135deg, var(--accent), var(--accent-strong));
+ border-color: transparent;
+ color: #fff;
+ box-shadow: 0 14px 30px rgba(15, 23, 42, 0.14);
+}
+
+.btn-dark:hover,
+.btn-dark:focus-visible {
+ background: linear-gradient(135deg, #0b1324, #16223f);
+ border-color: transparent;
+ color: #fff;
+}
+
+.btn-outline-dark {
+ border-color: rgba(100, 116, 139, 0.24);
+ color: var(--text);
+ background: rgba(255, 255, 255, 0.76);
+}
+
+.btn-outline-dark:hover,
+.btn-outline-dark:focus-visible {
+ color: var(--text);
+ border-color: rgba(14, 165, 233, 0.36);
+ background: rgba(255, 255, 255, 0.96);
+}
+
+.btn-light {
+ background: linear-gradient(135deg, rgba(14, 165, 233, 0.08), rgba(255, 255, 255, 0.92));
+ border-color: rgba(125, 211, 252, 0.24);
+ color: var(--text);
+}
+
+.btn-light:hover,
+.btn-light:focus-visible {
+ background: linear-gradient(135deg, rgba(14, 165, 233, 0.14), rgba(255, 255, 255, 1));
+ border-color: rgba(14, 165, 233, 0.34);
+ color: var(--text);
+}
+
+.btn-cta {
+ min-width: 198px;
+}
+
+.article-meta {
+ border-color: rgba(148, 163, 184, 0.18) !important;
+}
+
+.sticky-card {
+ position: sticky;
+ top: 6rem;
+}
+
+.site-footer {
+ background: rgba(248, 250, 252, 0.76);
+ border-color: rgba(148, 163, 184, 0.14) !important;
+ backdrop-filter: blur(18px);
+}
+
+.footer-title {
+ font-weight: 800;
+ margin-bottom: 0.35rem;
+}
+
+.footer-copy {
+ max-width: 58ch;
+ font-size: 0.94rem;
+}
+
+.footer-links a {
+ color: var(--muted);
+ text-decoration: none;
+ font-weight: 600;
+}
+
+.footer-links a:hover,
+.footer-links a:focus {
+ color: var(--text);
+ text-decoration: underline;
+ text-decoration-color: rgba(14, 165, 233, 0.58);
+ text-underline-offset: 0.18em;
+}
+
+@media (max-width: 991.98px) {
+ .hero-panel {
+ padding: 2rem;
+ }
+
+ .hero-title {
+ max-width: 12ch;
+ }
+
+ .hero-visual {
+ min-height: 320px;
+ }
+
+ .spotlight-banner {
+ grid-template-columns: 1fr;
+ }
+
+ .sticky-card {
+ position: static;
+ }
+
+ .nav-actions {
+ margin-top: 1rem;
+ flex-wrap: wrap;
+ }
+}
+
+@media (max-width: 767.98px) {
+ body {
+ font-size: 14px;
+ }
+
+ .hero-panel,
+ .panel-card,
+ .content-card,
+ .metric-card,
+ .article-shell,
+ .table-card,
+ .empty-state,
+ .cta-panel,
+ .blog-hero-card,
+ .blog-side-card,
+ .spotlight-banner {
+ padding: 1.25rem;
+ border-radius: 22px;
+ }
+
+ .hero-title {
+ max-width: none;
+ font-size: clamp(2.1rem, 9vw, 3rem);
+ }
+
+ .hero-chip-row,
+ .meta-inline,
+ .spotlight-pills {
+ gap: 0.6rem;
+ }
+
+ .meta-pill,
+ .spotlight-pill {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .visual-browser {
+ width: 100%;
+ }
+
+ .visual-stats {
+ grid-template-columns: 1fr;
+ }
+
+ .list-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .list-row-actions {
+ width: 100%;
+ }
+
+ .list-row-actions .btn,
+ .nav-actions .btn,
+ .btn-cta {
+ width: 100%;
+ }
+
+ .brand-subtitle {
+ display: none;
+ }
+}
+
+
+/* preview-embed-home */
+.hero-visual.has-preview {
+ min-height: auto;
+ padding: 0.25rem;
+}
+
+.preview-browser {
+ width: min(100%, 420px);
+ padding: 0.95rem;
+}
+
+.preview-bar {
+ align-items: center;
+ margin-bottom: 0.85rem;
+}
+
+.preview-url {
+ margin-left: auto;
+ max-width: calc(100% - 3.3rem);
+ padding: 0.34rem 0.72rem;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ color: rgba(226, 232, 240, 0.82);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.preview-screen {
+ overflow: hidden;
+ border-radius: 22px;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ background: #ffffff;
+ aspect-ratio: 4 / 5;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45);
+}
+
+.preview-screen iframe {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: #ffffff;
+}
+
+.preview-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.85rem;
+ margin-top: 0.9rem;
+}
+
+.preview-footer .btn {
+ white-space: nowrap;
+}
+
+.compact-spotlight {
+ align-items: center;
+}
+
+@media (max-width: 767.98px) {
+ .preview-footer {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .preview-footer .btn {
+ width: 100%;
+ }
+}
diff --git a/assets/js/main.js b/assets/js/main.js
index d349598..aeda74c 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -1,39 +1,34 @@
document.addEventListener('DOMContentLoaded', () => {
- const chatForm = document.getElementById('chat-form');
- const chatInput = document.getElementById('chat-input');
- const chatMessages = document.getElementById('chat-messages');
-
- const appendMessage = (text, sender) => {
- const msgDiv = document.createElement('div');
- msgDiv.classList.add('message', sender);
- msgDiv.textContent = text;
- chatMessages.appendChild(msgDiv);
- chatMessages.scrollTop = chatMessages.scrollHeight;
+ const slugify = (value) => {
+ return value
+ .toLowerCase()
+ .trim()
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
+ .replace(/^-+|-+$/g, '') || 'slug-artikel-otomatis';
};
- chatForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- const message = chatInput.value.trim();
- if (!message) return;
+ const titleInput = document.querySelector('[data-slug-source]');
+ const slugTarget = document.querySelector('[data-slug-target]');
- appendMessage(message, 'visitor');
- chatInput.value = '';
+ if (titleInput && slugTarget) {
+ const updateSlug = () => {
+ slugTarget.textContent = slugify(titleInput.value);
+ };
- try {
- const response = await fetch('api/chat.php', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message })
+ titleInput.addEventListener('input', updateSlug);
+ updateSlug();
+ }
+
+ if (window.bootstrap) {
+ document.querySelectorAll('.toast').forEach((toastElement) => {
+ const toast = new window.bootstrap.Toast(toastElement, {
+ delay: Number.parseInt(toastElement.dataset.bsDelay || '5000', 10),
+ autohide: true,
});
- const data = await response.json();
-
- // Artificial delay for realism
- setTimeout(() => {
- appendMessage(data.reply, 'bot');
- }, 500);
- } catch (error) {
- console.error('Error:', error);
- appendMessage("Sorry, something went wrong. Please try again.", 'bot');
- }
- });
+
+ if (toastElement.dataset.autoshow !== 'false') {
+ toast.show();
+ }
+ });
+ }
});
diff --git a/blog.php b/blog.php
new file mode 100644
index 0000000..408e87c
--- /dev/null
+++ b/blog.php
@@ -0,0 +1,104 @@
+ $selectedCategory !== '' ? 'Blog: ' . $selectedCategory : 'Blog Backlink untuk apknusa.com',
+ 'description' => $blogDescription,
+ 'canonical' => $selectedCategory !== '' ? canonical_for('blog.php?category=' . rawurlencode($selectedCategory)) : canonical_for('blog.php'),
+ 'keywords' => 'blog apknusa, artikel android, backlink apknusa, tips apk',
+ 'body_class' => 'blog-page',
+]);
+?>
+
+
+
+
+
+
+
Blog publik
+
Artikel singkat yang membangun konteks sebelum pengunjung lanjut ke apknusa.com
+
= e($blogDescription) ?> Setiap artikel di halaman ini tetap informatif, tetapi ujung perjalanannya adalah CTA yang membawa pembaca ke brand utama.
+
+ = e((string)count($posts)) ?> artikel tampil
+ = e((string)count($categories)) ?> kategori
+ Contextual CTA
+
+
+
+
+
+
Tujuan akhir
+
Baca singkat, klik ke sumber utama.
+
Jika pengunjung sudah tertarik, setiap artikel di bawah sudah menyiapkan jalur ke apknusa.com dengan konteks yang lebih rapi.
+
Buka apknusa.com
+
+
+
+
+
+
+
+
+
Filter topik
+
Pilih kategori untuk melihat kelompok artikel yang paling relevan dengan intent pembaca.
+
+
+
+
+
+
+
+ Belum ada artikel pada kategori ini
+ Silakan pilih kategori lain atau kembali ke semua artikel yang tersedia.
+
+
+
+
+
+
+
+
+ = e((string)$post['category']) ?>
+ = e((string)reading_time_minutes((string)$post['content'])) ?> menit baca
+
+
+ = e((string)$post['excerpt']) ?>
+
+
Dipublikasikan = e((string)format_article_date((string)$post['published_at'])) ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/db/config.php b/db/config.php
index f135f1f..7644cf3 100644
--- a/db/config.php
+++ b/db/config.php
@@ -1,21 +1,247 @@
$host !== '' ? $host : 'localhost',
+ 'DB_PORT' => $port !== '' ? $port : '3306',
+ 'DB_NAME' => $name,
+ 'DB_USER' => $user,
+ 'DB_PASS' => $password,
+ ];
}
-function db() {
- static $pdo;
- if (!$pdo) {
- $pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
- ]);
- }
- return $pdo;
+function db_env_settings(): array
+{
+ $settings = [];
+
+ foreach (['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASS'] as $key) {
+ $value = getenv($key);
+ if ($value !== false) {
+ $settings[$key] = $value;
+ }
+ }
+
+ return db_normalize_settings($settings);
+}
+
+function db_local_settings(): array
+{
+ static $settings = null;
+
+ if (is_array($settings)) {
+ return $settings;
+ }
+
+ if (!is_file(DB_INSTALLER_CONFIG_FILE)) {
+ $settings = db_normalize_settings([]);
+ return $settings;
+ }
+
+ $loaded = include DB_INSTALLER_CONFIG_FILE;
+ $settings = is_array($loaded) ? db_normalize_settings($loaded) : db_normalize_settings([]);
+
+ return $settings;
+}
+
+function db_settings_complete(array $settings): bool
+{
+ return $settings['DB_HOST'] !== ''
+ && $settings['DB_NAME'] !== ''
+ && $settings['DB_USER'] !== '';
+}
+
+function db_resolved_settings(): array
+{
+ static $settings = null;
+
+ if (is_array($settings)) {
+ return $settings;
+ }
+
+ $env = db_env_settings();
+ $local = db_local_settings();
+ $envComplete = db_settings_complete($env);
+ $localComplete = db_settings_complete($local);
+
+ if ($envComplete && $localComplete) {
+ $error = null;
+ if (db_test_connection($env, true, $error)) {
+ $env['__source'] = 'env';
+ $settings = $env;
+ return $settings;
+ }
+
+ $local['__source'] = 'file';
+ $settings = $local;
+ return $settings;
+ }
+
+ if ($envComplete) {
+ $env['__source'] = 'env';
+ $settings = $env;
+ return $settings;
+ }
+
+ if ($localComplete) {
+ $local['__source'] = 'file';
+ $settings = $local;
+ return $settings;
+ }
+
+ $settings = db_normalize_settings([]);
+ $settings['__source'] = 'missing';
+
+ return $settings;
+}
+
+$resolvedDbSettings = db_resolved_settings();
+define('DB_HOST', $resolvedDbSettings['DB_HOST']);
+define('DB_PORT', $resolvedDbSettings['DB_PORT']);
+define('DB_NAME', $resolvedDbSettings['DB_NAME']);
+define('DB_USER', $resolvedDbSettings['DB_USER']);
+define('DB_PASS', $resolvedDbSettings['DB_PASS']);
+unset($resolvedDbSettings);
+
+function db_config_source(): string
+{
+ return db_resolved_settings()['__source'] ?? 'missing';
+}
+
+function db_has_required_config(): bool
+{
+ return db_settings_complete(db_resolved_settings());
+}
+
+function db_build_dsn(array $settings, bool $withDatabase = true): string
+{
+ $settings = db_normalize_settings($settings);
+ $dsn = 'mysql:host=' . $settings['DB_HOST'] . ';port=' . $settings['DB_PORT'] . ';charset=utf8mb4';
+
+ if ($withDatabase && $settings['DB_NAME'] !== '') {
+ $dsn .= ';dbname=' . $settings['DB_NAME'];
+ }
+
+ return $dsn;
+}
+
+function db_create_pdo(array $settings, bool $withDatabase = true): PDO
+{
+ $settings = db_normalize_settings($settings);
+
+ return new PDO(
+ db_build_dsn($settings, $withDatabase),
+ $settings['DB_USER'],
+ $settings['DB_PASS'],
+ [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_TIMEOUT => 5,
+ ]
+ );
+}
+
+function db_test_connection(array $settings, bool $withDatabase = true, ?string &$error = null): bool
+{
+ try {
+ db_create_pdo($settings, $withDatabase)->query('SELECT 1');
+ $error = null;
+ return true;
+ } catch (Throwable $exception) {
+ $error = $exception->getMessage();
+ return false;
+ }
+}
+
+function db_connection_error(?array $settings = null): ?string
+{
+ $settings = $settings !== null ? db_normalize_settings($settings) : db_resolved_settings();
+
+ if (!db_settings_complete($settings)) {
+ return 'Database belum dikonfigurasi lengkap.';
+ }
+
+ $error = null;
+ db_test_connection($settings, true, $error);
+
+ return $error;
+}
+
+function db(): PDO
+{
+ static $pdo = null;
+
+ if ($pdo instanceof PDO) {
+ return $pdo;
+ }
+
+ if (!db_has_required_config()) {
+ throw new RuntimeException('Database belum dikonfigurasi.');
+ }
+
+ $pdo = db_create_pdo(db_resolved_settings(), true);
+ return $pdo;
+}
+
+function db_error_is_missing_database(string $message): bool
+{
+ return stripos($message, 'Unknown database') !== false
+ || str_contains($message, '[1049]');
+}
+
+function db_quote_identifier(string $identifier): string
+{
+ $identifier = trim($identifier);
+ if ($identifier === '' || preg_match('/[[:cntrl:]]/', $identifier)) {
+ throw new RuntimeException('Nama database tidak valid.');
+ }
+
+ return '`' . str_replace('`', '``', $identifier) . '`';
+}
+
+function db_create_database_if_missing(array $settings): void
+{
+ $settings = db_normalize_settings($settings);
+
+ if ($settings['DB_NAME'] === '') {
+ throw new RuntimeException('Nama database wajib diisi.');
+ }
+
+ $pdo = db_create_pdo($settings, false);
+ $pdo->exec(
+ 'CREATE DATABASE IF NOT EXISTS ' . db_quote_identifier($settings['DB_NAME']) .
+ ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
+ );
+}
+
+function db_save_local_settings(array $settings): void
+{
+ $settings = db_normalize_settings($settings);
+ $payload = " $settings['DB_HOST'],
+ 'DB_PORT' => $settings['DB_PORT'],
+ 'DB_NAME' => $settings['DB_NAME'],
+ 'DB_USER' => $settings['DB_USER'],
+ 'DB_PASS' => $settings['DB_PASS'],
+ ], true) . ";
+";
+
+ $bytes = file_put_contents(DB_INSTALLER_CONFIG_FILE, $payload, LOCK_EX);
+ if ($bytes === false) {
+ throw new RuntimeException('Gagal menyimpan file konfigurasi database. Pastikan folder db bisa ditulis.');
+ }
}
diff --git a/db/migrations/20260517_create_backlink_posts.sql b/db/migrations/20260517_create_backlink_posts.sql
new file mode 100644
index 0000000..1c3be6f
--- /dev/null
+++ b/db/migrations/20260517_create_backlink_posts.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS backlink_posts (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT,
+ title VARCHAR(180) NOT NULL,
+ slug VARCHAR(190) NOT NULL,
+ category VARCHAR(80) NOT NULL DEFAULT 'Artikel',
+ excerpt TEXT NOT NULL,
+ content MEDIUMTEXT NOT NULL,
+ cta_text VARCHAR(120) NOT NULL DEFAULT 'Kunjungi apknusa.com',
+ cta_url VARCHAR(255) NOT NULL DEFAULT 'https://apknusa.com',
+ featured TINYINT(1) NOT NULL DEFAULT 0,
+ published_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (id),
+ UNIQUE KEY uniq_backlink_posts_slug (slug),
+ KEY idx_backlink_posts_category (category),
+ KEY idx_backlink_posts_published_at (published_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/healthz.php b/healthz.php
new file mode 100644
index 0000000..fe27cdb
--- /dev/null
+++ b/healthz.php
@@ -0,0 +1,41 @@
+ 'setup_required',
+ 'php_version' => PHP_VERSION,
+ 'time_utc' => gmdate('c'),
+ 'db' => 'setup_required',
+ 'message' => $status['message'],
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ exit;
+}
+
+try {
+ init_blog_storage();
+ $count = post_count();
+
+ http_response_code(200);
+ echo json_encode([
+ 'status' => 'ok',
+ 'php_version' => PHP_VERSION,
+ 'time_utc' => gmdate('c'),
+ 'posts' => $count,
+ 'db' => 'ok',
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+} catch (Throwable $exception) {
+ http_response_code(503);
+ echo json_encode([
+ 'status' => 'error',
+ 'php_version' => PHP_VERSION,
+ 'time_utc' => gmdate('c'),
+ 'db' => 'error',
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+}
diff --git a/includes/layout.php b/includes/layout.php
new file mode 100644
index 0000000..03884b1
--- /dev/null
+++ b/includes/layout.php
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+ = e($pageTitle) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Lewati ke konten utama
+
+
+
+
+
+
+
+ false,
+ 'source' => db_config_source(),
+ 'message' => '',
+ 'details' => '',
+ ];
+
+ if (!db_has_required_config()) {
+ $status['message'] = 'Koneksi database belum diatur.';
+ $status['details'] = 'Masukkan host, port, nama database, username, dan password di installer.';
+ return $status;
+ }
+
+ $error = db_connection_error();
+ if ($error !== null) {
+ $status['message'] = 'Aplikasi belum bisa terhubung ke database.';
+ $status['details'] = $error;
+ return $status;
+ }
+
+ $status['ready'] = true;
+ $status['message'] = 'Koneksi database siap.';
+
+ return $status;
+}
+
+function ensure_site_installed(): void
+{
+ if (current_script_name() === 'install.php') {
+ return;
+ }
+
+ $status = site_installation_status();
+ if ($status['ready']) {
+ return;
+ }
+
+ $nextPath = safe_install_next_path((string)($_SERVER['REQUEST_URI'] ?? '/'));
+ header('Location: install.php?next=' . rawurlencode($nextPath));
+ exit;
+}
+
+function post_url(array $post): string
+{
+ return 'post.php?slug=' . rawurlencode((string)$post['slug']);
+}
+
+function word_count_indonesian(string $text): int
+{
+ if (preg_match_all('/[\p{L}\p{N}]+/u', strip_tags($text), $matches) !== false) {
+ return count($matches[0]);
+ }
+
+ return 0;
+}
+
+function reading_time_minutes(string $text): int
+{
+ return max(1, (int)ceil(word_count_indonesian($text) / 180));
+}
+
+function excerpt_text(string $text, int $limit = 160): string
+{
+ $plain = trim(preg_replace('/\s+/u', ' ', strip_tags($text)) ?? '');
+ if (text_length($plain) <= $limit) {
+ return $plain;
+ }
+
+ return rtrim(text_substr($plain, 0, $limit - 1)) . '…';
+}
+
+function slugify(string $text): string
+{
+ $text = trim(text_lower($text));
+ $text = preg_replace('/[^\p{L}\p{N}]+/u', '-', $text) ?? '';
+ $text = trim($text, '-');
+
+ return $text !== '' ? $text : 'artikel';
+}
+
+function allowed_apknusa_hosts(): array
+{
+ return ['apknusa.com', 'www.apknusa.com'];
+}
+
+function validate_apknusa_url(string $url): bool
+{
+ if (filter_var($url, FILTER_VALIDATE_URL) === false) {
+ return false;
+ }
+
+ $host = strtolower((string)parse_url($url, PHP_URL_HOST));
+ $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
+
+ return in_array($host, allowed_apknusa_hosts(), true) && in_array($scheme, ['http', 'https'], true);
+}
+
+function init_blog_storage(): void
+{
+ static $initialized = false;
+
+ if ($initialized) {
+ return;
+ }
+
+ $migrationFile = project_root_path('db/migrations/20260517_create_backlink_posts.sql');
+ $sql = is_file($migrationFile) ? file_get_contents($migrationFile) : false;
+
+ if ($sql === false || trim($sql) === '') {
+ throw new RuntimeException('Blog migration file not found.');
+ }
+
+ db()->exec($sql);
+ $initialized = true;
+ seed_default_posts();
+}
+
+function boot_site(): void
+{
+ ensure_site_installed();
+ init_blog_storage();
+ refresh_seo_artifacts();
+}
+
+function unique_slug(string $title): string
+{
+ init_blog_storage();
+
+ $baseSlug = slugify($title);
+ $slug = $baseSlug;
+ $suffix = 2;
+
+ $statement = db()->prepare('SELECT COUNT(*) FROM backlink_posts WHERE slug = :slug');
+
+ while (true) {
+ $statement->bindValue(':slug', $slug);
+ $statement->execute();
+ if ((int)$statement->fetchColumn() === 0) {
+ return $slug;
+ }
+
+ $slug = $baseSlug . '-' . $suffix;
+ $suffix++;
+ }
+}
+
+function seed_default_posts(): void
+{
+ $count = (int)db()->query('SELECT COUNT(*) FROM backlink_posts')->fetchColumn();
+ if ($count > 0) {
+ return;
+ }
+
+ $posts = [
+ [
+ 'title' => 'Cara Memilih Sumber APK yang Aman Sebelum Instal',
+ 'category' => 'Keamanan APK',
+ 'excerpt' => 'Checklist sederhana untuk menilai file APK, versi, izin aplikasi, dan reputasi sumber sebelum dipasang di Android.',
+ 'content' => "Saat mencari file APK, hal pertama yang perlu diperiksa adalah reputasi sumber informasinya. Pengunjung biasanya ingin jawaban cepat, tetapi keputusan instalasi tetap perlu didukung informasi versi, pembaruan terakhir, dan ringkasan fungsi aplikasi. Artikel yang rapi akan membantu pembaca merasa aman sebelum melanjutkan.\n\nLangkah berikutnya adalah membandingkan izin aplikasi dengan fungsi utamanya. Jika aplikasi sederhana meminta akses yang berlebihan, pembaca perlu waspada. Konten seperti ini biasanya punya performa SEO yang baik karena menjawab pertanyaan praktis yang sering dicari pengguna Android.\n\nWebsite pendukung seperti microsite ini bisa menjadi titik awal untuk edukasi singkat. Setelah pembaca memahami dasar keamanannya, mereka bisa diarahkan ke sumber utama untuk melihat referensi yang lebih lengkap. Itu sebabnya tombol ajakan bertindak di halaman ini selalu mengarah ke apknusa.com.\n\nUntuk referensi yang lebih lengkap tentang aplikasi Android, pembaruan, dan tips penggunaan, lanjutkan ke apknusa.com agar pembaca mendapat halaman utama yang menjadi tujuan akhir.",
+ 'cta_text' => 'Lihat referensi lengkap di apknusa.com',
+ 'cta_url' => APKNUSA_URL,
+ 'featured' => 1,
+ 'published_at' => date('Y-m-d H:i:s', strtotime('-18 days')),
+ ],
+ [
+ 'title' => 'Panduan Update Aplikasi Android Tanpa Kehilangan Data',
+ 'category' => 'Update Android',
+ 'excerpt' => 'Urutan aman saat memperbarui aplikasi Android supaya data tetap aman dan prosesnya tidak membingungkan.',
+ 'content' => "Banyak pengguna menunda update aplikasi karena khawatir data mereka hilang. Padahal, dengan langkah yang benar, pembaruan bisa dilakukan tanpa membuat pengalaman penggunaan terganggu. Konten edukatif seperti ini sangat cocok untuk menarik trafik organik dari pencarian yang berniat tinggi.\n\nSebelum update, pembaca perlu memastikan ruang penyimpanan cukup dan mengetahui versi aplikasi yang sedang digunakan. Setelah itu, mereka sebaiknya membaca catatan perubahan agar tahu apakah pembaruan membawa perbaikan keamanan, fitur baru, atau sekadar penyesuaian ringan.\n\nMicrosite pendukung berguna untuk menjelaskan proses tersebut secara singkat, lalu memberi jalur yang jelas menuju website utama. Model seperti ini membuat konten tetap fokus, cepat dimuat, dan memiliki tujuan konversi yang tegas.\n\nJika ingin melihat referensi aplikasi Android yang lebih lengkap dan pembahasan lanjutan, pengunjung bisa langsung menuju apknusa.com dari tombol CTA di bawah artikel ini.",
+ 'cta_text' => 'Buka panduan lanjutan di apknusa.com',
+ 'cta_url' => APKNUSA_URL,
+ 'featured' => 1,
+ 'published_at' => date('Y-m-d H:i:s', strtotime('-14 days')),
+ ],
+ [
+ 'title' => 'Kenapa Review Aplikasi Penting Sebelum Download',
+ 'category' => 'Review Aplikasi',
+ 'excerpt' => 'Review singkat membantu pembaca menilai kualitas aplikasi, manfaat, dan potensi risiko sebelum mengunduh.',
+ 'content' => "Sebelum pengguna menekan tombol download, mereka biasanya ingin tahu satu hal: apakah aplikasi ini memang layak dipakai. Di situlah peran review aplikasi menjadi penting. Review yang ringkas tetapi informatif membantu pembaca memahami fungsi utama, kelebihan, dan keterbatasan aplikasi.\n\nDari sisi SEO, artikel review punya peluang baik karena menjawab kebutuhan pencarian yang spesifik. Orang yang mengetik nama aplikasi biasanya sedang berada di tahap evaluasi. Jika artikel bisa membantu mereka mengambil keputusan, peluang klik ke website utama akan jauh lebih besar.\n\nMicrosite backlink sebaiknya tidak mencoba menampung semua informasi sekaligus. Tugasnya adalah membuka konteks, memberi jawaban awal, lalu membawa pembaca ke sumber utama yang lebih lengkap dan lebih kuat secara brand. Struktur seperti ini terasa alami bagi pengunjung dan jelas bagi mesin pencari.\n\nUntuk melihat referensi lain seputar aplikasi Android dan sumber informasi yang lebih lengkap, arahkan pembaca ke apknusa.com sebagai tujuan utama perjalanan mereka.",
+ 'cta_text' => 'Lanjut ke apknusa.com',
+ 'cta_url' => APKNUSA_URL,
+ 'featured' => 1,
+ 'published_at' => date('Y-m-d H:i:s', strtotime('-10 days')),
+ ],
+ [
+ 'title' => 'Tips Menyimpan File APK Supaya Mudah Dicari Lagi',
+ 'category' => 'Tips Android',
+ 'excerpt' => 'Strategi folder, penamaan file, dan kebiasaan kecil agar file APK tetap rapi dan mudah ditemukan saat dibutuhkan.',
+ 'content' => "File APK sering tersimpan di berbagai folder unduhan sehingga mudah terlupakan. Dengan membuat struktur folder yang jelas, pengguna bisa lebih cepat menemukan file lama saat perlu memasang ulang aplikasi atau membandingkan versi tertentu.\n\nGunakan nama file yang konsisten, misalnya menggabungkan nama aplikasi dan versi. Kebiasaan kecil seperti ini terlihat sederhana, tetapi sangat membantu saat jumlah file unduhan mulai bertambah. Artikel semacam ini cocok untuk blog pendukung karena informatif dan mudah dibaca.\n\nSetelah pembaca merasa terbantu, langkah berikutnya adalah membawa mereka ke halaman utama yang menyediakan referensi lebih luas. CTA yang jelas membuat alur pengguna lebih halus dan membantu microsite menjalankan fungsi backlink dengan baik.\n\nUntuk informasi tambahan seputar aplikasi Android dan referensi lain yang lebih lengkap, arahkan pembaca ke apknusa.com agar mereka bisa melanjutkan eksplorasi dari sumber utama.",
+ 'cta_text' => 'Kunjungi apknusa.com untuk info lain',
+ 'cta_url' => APKNUSA_URL,
+ 'featured' => 0,
+ 'published_at' => date('Y-m-d H:i:s', strtotime('-6 days')),
+ ],
+ [
+ 'title' => 'Perbedaan APK, XAPK, dan File Instalasi Lain yang Perlu Dipahami',
+ 'category' => 'Panduan Instalasi',
+ 'excerpt' => 'Penjelasan singkat tentang format file instalasi Android agar pembaca tahu apa yang mereka buka dan instal.',
+ 'content' => "Bagi banyak pengguna, semua file instalasi Android terlihat mirip. Padahal, format seperti APK dan XAPK memiliki perbedaan cara penggunaan dan kebutuhan file pendukung. Menjelaskan perbedaan ini secara ringkas membantu pembaca memahami konteks sebelum melakukan tindakan.\n\nKonten edukatif yang sederhana sering kali efektif sebagai pintu masuk trafik organik. Pengunjung datang karena pertanyaan dasar, lalu diarahkan secara elegan ke sumber utama untuk membaca informasi yang lebih luas dan lebih terstruktur.\n\nMicrosite backlink yang baik tidak terasa seperti halaman iklan. Ia terasa seperti halaman bantuan yang memang menjawab kebutuhan awal pengguna. Dari situ, tautan ke website utama terasa relevan, bukan dipaksakan.\n\nJika pembaca ingin melihat pembahasan lanjutan seputar aplikasi Android, format file, dan referensi lain, tombol CTA di bawah akan membawa mereka ke apknusa.com.",
+ 'cta_text' => 'Baca referensi utama di apknusa.com',
+ 'cta_url' => APKNUSA_URL,
+ 'featured' => 0,
+ 'published_at' => date('Y-m-d H:i:s', strtotime('-2 days')),
+ ],
+ ];
+
+ $statement = db()->prepare(
+ 'INSERT INTO backlink_posts (title, slug, category, excerpt, content, cta_text, cta_url, featured, published_at)
+ VALUES (:title, :slug, :category, :excerpt, :content, :cta_text, :cta_url, :featured, :published_at)'
+ );
+
+ foreach ($posts as $post) {
+ $statement->execute([
+ ':title' => $post['title'],
+ ':slug' => unique_slug($post['title']),
+ ':category' => $post['category'],
+ ':excerpt' => $post['excerpt'],
+ ':content' => $post['content'],
+ ':cta_text' => $post['cta_text'],
+ ':cta_url' => $post['cta_url'],
+ ':featured' => $post['featured'],
+ ':published_at' => $post['published_at'],
+ ]);
+ }
+}
+
+function post_count(): int
+{
+ init_blog_storage();
+ return (int)db()->query('SELECT COUNT(*) FROM backlink_posts')->fetchColumn();
+}
+
+function fetch_categories(): array
+{
+ init_blog_storage();
+
+ $rows = db()->query('SELECT DISTINCT category FROM backlink_posts ORDER BY category ASC')->fetchAll();
+ return array_values(array_filter(array_map(static fn(array $row): string => (string)$row['category'], $rows)));
+}
+
+function fetch_posts(?string $category = null, int $limit = 0): array
+{
+ init_blog_storage();
+
+ $sql = 'SELECT * FROM backlink_posts';
+ $params = [];
+
+ if ($category !== null && $category !== '') {
+ $sql .= ' WHERE category = :category';
+ $params[':category'] = $category;
+ }
+
+ $sql .= ' ORDER BY published_at DESC, id DESC';
+
+ if ($limit > 0) {
+ $sql .= ' LIMIT ' . (int)$limit;
+ }
+
+ $statement = db()->prepare($sql);
+ $statement->execute($params);
+
+ return $statement->fetchAll();
+}
+
+function fetch_featured_posts(int $limit = 3): array
+{
+ init_blog_storage();
+
+ $statement = db()->prepare(
+ 'SELECT * FROM backlink_posts WHERE featured = 1 ORDER BY published_at DESC, id DESC LIMIT ' . (int)$limit
+ );
+ $statement->execute();
+ $posts = $statement->fetchAll();
+
+ if ($posts !== []) {
+ return $posts;
+ }
+
+ return fetch_posts(null, $limit);
+}
+
+function fetch_post_by_slug(string $slug): ?array
+{
+ init_blog_storage();
+
+ $statement = db()->prepare('SELECT * FROM backlink_posts WHERE slug = :slug LIMIT 1');
+ $statement->execute([':slug' => $slug]);
+ $post = $statement->fetch();
+
+ return $post !== false ? $post : null;
+}
+
+function fetch_related_posts(int $excludeId, string $category, int $limit = 3): array
+{
+ init_blog_storage();
+
+ $statement = db()->prepare(
+ 'SELECT * FROM backlink_posts
+ WHERE id <> :id AND category = :category
+ ORDER BY published_at DESC, id DESC
+ LIMIT ' . (int)$limit
+ );
+ $statement->execute([
+ ':id' => $excludeId,
+ ':category' => $category,
+ ]);
+
+ $posts = $statement->fetchAll();
+ if (count($posts) >= $limit) {
+ return $posts;
+ }
+
+ $fallback = db()->prepare(
+ 'SELECT * FROM backlink_posts
+ WHERE id <> :id
+ ORDER BY published_at DESC, id DESC
+ LIMIT ' . (int)$limit
+ );
+ $fallback->execute([':id' => $excludeId]);
+
+ return $fallback->fetchAll();
+}
+
+function create_post(array $input): array
+{
+ init_blog_storage();
+
+ $data = [
+ 'title' => trim((string)($input['title'] ?? '')),
+ 'category' => trim((string)($input['category'] ?? '')),
+ 'excerpt' => trim((string)($input['excerpt'] ?? '')),
+ 'content' => trim((string)($input['content'] ?? '')),
+ 'cta_text' => trim((string)($input['cta_text'] ?? '')),
+ 'cta_url' => trim((string)($input['cta_url'] ?? APKNUSA_URL)),
+ 'featured' => !empty($input['featured']) ? 1 : 0,
+ ];
+
+ if ($data['category'] === '') {
+ $data['category'] = 'Artikel';
+ }
+ if ($data['cta_text'] === '') {
+ $data['cta_text'] = 'Kunjungi apknusa.com';
+ }
+ if ($data['cta_url'] === '') {
+ $data['cta_url'] = APKNUSA_URL;
+ }
+
+ $errors = [];
+
+ if (text_length($data['title']) < 8) {
+ $errors['title'] = 'Judul minimal 8 karakter.';
+ }
+ if (text_length($data['excerpt']) < 24) {
+ $errors['excerpt'] = 'Ringkasan minimal 24 karakter.';
+ }
+ if (text_length($data['content']) < 140) {
+ $errors['content'] = 'Konten minimal 140 karakter supaya artikel terasa lengkap.';
+ }
+ if (text_length($data['cta_text']) < 4) {
+ $errors['cta_text'] = 'CTA minimal 4 karakter.';
+ }
+ if (!validate_apknusa_url($data['cta_url'])) {
+ $errors['cta_url'] = 'Tautan CTA harus mengarah ke apknusa.com.';
+ }
+
+ if ($errors !== []) {
+ return [
+ 'success' => false,
+ 'errors' => $errors,
+ 'input' => $data,
+ ];
+ }
+
+ $slug = unique_slug($data['title']);
+
+ $statement = db()->prepare(
+ 'INSERT INTO backlink_posts (title, slug, category, excerpt, content, cta_text, cta_url, featured, published_at)
+ VALUES (:title, :slug, :category, :excerpt, :content, :cta_text, :cta_url, :featured, NOW())'
+ );
+
+ $statement->execute([
+ ':title' => $data['title'],
+ ':slug' => $slug,
+ ':category' => $data['category'],
+ ':excerpt' => $data['excerpt'],
+ ':content' => $data['content'],
+ ':cta_text' => $data['cta_text'],
+ ':cta_url' => $data['cta_url'],
+ ':featured' => $data['featured'],
+ ]);
+
+ refresh_seo_artifacts();
+
+ return [
+ 'success' => true,
+ 'slug' => $slug,
+ 'input' => $data,
+ ];
+}
+
+function render_post_content(string $content): string
+{
+ $paragraphs = preg_split('/\R{2,}/', trim($content)) ?: [];
+ $html = [];
+
+ foreach ($paragraphs as $paragraph) {
+ $paragraph = trim($paragraph);
+ if ($paragraph === '') {
+ continue;
+ }
+
+ $html[] = '' . nl2br(e($paragraph)) . '
';
+ }
+
+ return implode("\n", $html);
+}
+
+function format_article_date(string $dateTime): string
+{
+ $timestamp = strtotime($dateTime);
+ if ($timestamp === false) {
+ return $dateTime;
+ }
+
+ $months = [
+ 1 => 'Januari',
+ 2 => 'Februari',
+ 3 => 'Maret',
+ 4 => 'April',
+ 5 => 'Mei',
+ 6 => 'Juni',
+ 7 => 'Juli',
+ 8 => 'Agustus',
+ 9 => 'September',
+ 10 => 'Oktober',
+ 11 => 'November',
+ 12 => 'Desember',
+ ];
+
+ $monthNumber = (int)date('n', $timestamp);
+
+ return date('j ', $timestamp) . $months[$monthNumber] . date(' Y', $timestamp);
+}
+
+function maybe_write_file(string $path, string $content): void
+{
+ $existing = is_file($path) ? file_get_contents($path) : false;
+ if ($existing === $content) {
+ return;
+ }
+
+ $directory = dirname($path);
+ if (!is_dir($directory) || !is_writable($directory)) {
+ return;
+ }
+
+ @file_put_contents($path, $content, LOCK_EX);
+}
+
+function refresh_seo_artifacts(): void
+{
+ init_blog_storage();
+
+ $baseUrl = site_base_url();
+ $posts = fetch_posts();
+ $today = gmdate('c');
+
+ $entries = [
+ [
+ 'loc' => $baseUrl . '/',
+ 'lastmod' => $today,
+ 'changefreq' => 'daily',
+ 'priority' => '1.0',
+ ],
+ [
+ 'loc' => $baseUrl . '/blog.php',
+ 'lastmod' => $today,
+ 'changefreq' => 'daily',
+ 'priority' => '0.8',
+ ],
+ ];
+
+ foreach ($posts as $post) {
+ $entries[] = [
+ 'loc' => $baseUrl . '/' . post_url($post),
+ 'lastmod' => gmdate('c', strtotime((string)($post['updated_at'] ?? $post['published_at'] ?? $today))),
+ 'changefreq' => 'weekly',
+ 'priority' => '0.7',
+ ];
+ }
+
+ $sitemap = [
+ '',
+ '',
+ ];
+
+ foreach ($entries as $entry) {
+ $sitemap[] = ' ';
+ $sitemap[] = ' ' . xml_escape($entry['loc']) . ' ';
+ $sitemap[] = ' ' . xml_escape($entry['lastmod']) . ' ';
+ $sitemap[] = ' ' . xml_escape($entry['changefreq']) . ' ';
+ $sitemap[] = ' ' . xml_escape($entry['priority']) . ' ';
+ $sitemap[] = ' ';
+ }
+
+ $sitemap[] = ' ';
+
+ $robots = implode("\n", [
+ 'User-agent: *',
+ 'Allow: /',
+ 'Disallow: /admin.php',
+ 'Sitemap: ' . $baseUrl . '/sitemap.xml',
+ '',
+ ]);
+
+ maybe_write_file(project_root_path('sitemap.xml'), implode("\n", $sitemap) . "\n");
+ maybe_write_file(project_root_path('robots.txt'), $robots);
+}
diff --git a/index.php b/index.php
index 7205f3d..fcbb995 100644
--- a/index.php
+++ b/index.php
@@ -1,150 +1,208 @@
'Backlink Microsite untuk apknusa.com',
+ 'description' => 'Artikel singkat, live preview, dan tombol cepat menuju apknusa.com.',
+ 'canonical' => $homeUrl,
+ 'keywords' => 'apknusa, backlink, preview website, blog aplikasi android, microsite seo',
+ 'body_class' => 'home-page',
+ 'json_ld' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'WebSite',
+ 'name' => project_name(),
+ 'url' => $homeUrl,
+ 'description' => $heroDescription,
+ 'publisher' => [
+ '@type' => 'Organization',
+ 'name' => project_name(),
+ ],
+ ],
+]);
?>
-
-
-
-
-
- New Style
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Analyzing your requirements and generating your website…
-
-
Loading…
+
+
+
+
+
+
+
Backlink microsite
+
Lihat apknusa.com, baca cepat, lalu klik lanjut.
+
= e($heroDescription) ?>
+
+
+ = e((string)$totalPosts) ?> artikel
+ = e((string)$categoryCount) ?> topik
+ Preview live
+
+
+
+
+
+
+
+
+
+
+
+
https://apknusa.com
+
+
+
+
+
+
+
+
+
-
= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.
-
This page will update automatically as the plan is implemented.
-
Runtime: PHP = htmlspecialchars($phpVersion) ?> — UTC = htmlspecialchars($now) ?>
-
-
- Page updated: = htmlspecialchars($now) ?> (UTC)
-
-
-
+
+
+
+
+
+
+
Preview cepat
+
Website utama langsung tampil di landing ini.
+
Kalau iframe tidak muncul di browser, pakai tombol buka penuh.
+
+
+ apknusa.com
+ Artikel ringkas
+ CTA langsung
+
+
+
+
+
+
+
+
+
+
Artikel unggulan
+
Pilih artikel singkat yang paling relevan.
+
Setelah itu, lanjutkan ke apknusa.com dari tombol CTA.
+
+
Lihat semua artikel
+
+
+
+
+
+
+ = e((string)$post['category']) ?>
+ = e((string)reading_time_minutes((string)$post['content'])) ?> menit baca
+
+
+ = e((string)$post['excerpt']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Topik siap pakai
+
Kategori yang sudah siap jadi pintu masuk.
+
Pilih topik yang paling dekat dengan intent pengunjung.
+
+
+
+
+
+
+
+
Quick links
+
Semua jalur utama ada di sini.
+
Tanpa banyak penjelasan tambahan.
+
+
+
+
+
+
+
+
+
+
+ Artikel terbaru
+
Update konten terbaru
+
+
+
+ Belum ada artikel terbaru
+ Sementara, buka blog atau langsung ke apknusa.com.
+
+
+
+
+
+
+
+
+ = e((string)$post['category']) ?>
+ = e((string)format_article_date((string)$post['published_at'])) ?>
+
+
+
= e((string)$post['excerpt']) ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/install.php b/install.php
new file mode 100644
index 0000000..ee22f28
--- /dev/null
+++ b/install.php
@@ -0,0 +1,213 @@
+ 'environment server',
+ 'file' => 'file installer lokal',
+ default => 'belum ada',
+ };
+}
+
+$status = site_installation_status();
+$nextPath = safe_install_next_path((string)($_REQUEST['next'] ?? '/'));
+$formData = [
+ 'db_host' => DB_HOST !== '' ? DB_HOST : 'localhost',
+ 'db_port' => DB_PORT !== '' ? DB_PORT : '3306',
+ 'db_name' => DB_NAME,
+ 'db_user' => DB_USER,
+ 'db_pass' => DB_PASS,
+];
+$errors = [];
+$flash = null;
+
+if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
+ $formData = [
+ 'db_host' => trim((string)($_POST['db_host'] ?? 'localhost')),
+ 'db_port' => trim((string)($_POST['db_port'] ?? '3306')),
+ 'db_name' => trim((string)($_POST['db_name'] ?? '')),
+ 'db_user' => trim((string)($_POST['db_user'] ?? '')),
+ 'db_pass' => (string)($_POST['db_pass'] ?? ''),
+ ];
+ $nextPath = safe_install_next_path((string)($_POST['next'] ?? '/'));
+
+ if ($formData['db_host'] === '') {
+ $formData['db_host'] = 'localhost';
+ }
+ if ($formData['db_port'] === '') {
+ $formData['db_port'] = '3306';
+ }
+
+ if ($formData['db_name'] === '') {
+ $errors['db_name'] = 'Nama database wajib diisi.';
+ }
+ if ($formData['db_user'] === '') {
+ $errors['db_user'] = 'Username database wajib diisi.';
+ }
+ if (!ctype_digit($formData['db_port']) || (int)$formData['db_port'] < 1 || (int)$formData['db_port'] > 65535) {
+ $errors['db_port'] = 'Port harus berupa angka 1 sampai 65535.';
+ }
+
+ if ($errors === []) {
+ $settings = [
+ 'DB_HOST' => $formData['db_host'],
+ 'DB_PORT' => $formData['db_port'],
+ 'DB_NAME' => $formData['db_name'],
+ 'DB_USER' => $formData['db_user'],
+ 'DB_PASS' => $formData['db_pass'],
+ ];
+
+ try {
+ $connectionError = db_connection_error($settings);
+ if ($connectionError !== null && db_error_is_missing_database($connectionError)) {
+ db_create_database_if_missing($settings);
+ $connectionError = db_connection_error($settings);
+ }
+
+ if ($connectionError !== null) {
+ throw new RuntimeException($connectionError);
+ }
+
+ db_save_local_settings($settings);
+ header('Location: ' . $nextPath);
+ exit;
+ } catch (Throwable $exception) {
+ $flash = [
+ 'type' => 'danger',
+ 'title' => 'Koneksi database gagal.',
+ 'message' => $exception->getMessage(),
+ ];
+ }
+ } else {
+ $flash = [
+ 'type' => 'warning',
+ 'title' => 'Form belum lengkap.',
+ 'message' => 'Periksa lagi field yang diberi tanda merah.',
+ ];
+ }
+}
+
+render_page_start([
+ 'title' => 'Installer Database',
+ 'description' => 'Form instalasi cepat untuk menghubungkan aplikasi ke database MySQL.',
+ 'canonical' => canonical_for('install.php'),
+ 'robots' => 'noindex,nofollow',
+ 'keywords' => 'installer database, setup mysql, setup php app',
+ 'body_class' => 'install-page',
+]);
+?>
+
+
+
+
+
+
Auto installer
+
Pindah hosting? Tinggal isi data database, lalu website jalan lagi.
+
Kalau script ini dipindah ke server baru, halaman ini akan muncul otomatis saat koneksi database belum siap. Anda hanya perlu memasukkan host, port, nama database, username, dan password MySQL.
+
+
+
+
Yang perlu disiapkan
+
+ Host database, seringnya localhost.
+ Port MySQL, default 3306.
+ Nama database dari hosting/cPanel.
+ Username dan password database.
+
+
Jika database yang Anda tulis belum ada, installer akan mencoba membuatnya otomatis jika akun database punya izin.
+
+
+
+
+
+
+
+
+ Status saat ini
+
+ Aplikasi sudah terhubung ke database.
+ Sumber konfigurasi aktif: = e(installer_source_label((string)$status['source'])) ?> .
+ Kalau Anda hanya ingin memakai website, tidak perlu mengisi ulang form di kanan.
+
+
+
= e((string)$status['message']) ?>
+
+
= e((string)$status['details']) ?>
+
+
+ Begitu data database benar, aplikasi akan membuat tabel blog otomatis saat Anda masuk ke halaman utama.
+
+
+
+
+
+
+
+
+
Form koneksi
+
Isi data MySQL / MariaDB
+
Data ini biasanya ada di menu Database pada panel hosting atau PHPMyAdmin.
+
+
Apa itu PHPMyAdmin?
+
+
+
+
+
= e((string)$flash['title']) ?>
+
= e((string)$flash['message']) ?>
+
+
+
+
+
+
+
+
+
+
+
Host database
+
+
= e($errors['db_host']) ?>
+
+
+
Port
+
+
= e($errors['db_port']) ?>
+
+
+
Nama database
+
+
= e($errors['db_name']) ?>
+
+
+
Username database
+
+
= e($errors['db_user']) ?>
+
+
+ Password database
+
+
+
+
+ Setelah berhasil disimpan, website akan memakai file konfigurasi lokal di folder db/. Jadi kalau script dipindah ke server lain, cukup buka web dan isi form ini lagi bila koneksi lama tidak cocok.
+
+
+
Simpan & masuk ke website
+
Kembali
+
+
+
+
+
+
+
+
+
diff --git a/post.php b/post.php
new file mode 100644
index 0000000..6d6438b
--- /dev/null
+++ b/post.php
@@ -0,0 +1,126 @@
+ 'Artikel tidak ditemukan',
+ 'description' => 'Halaman artikel yang diminta tidak ditemukan.',
+ 'canonical' => current_url(),
+ 'robots' => 'noindex,follow',
+ ]);
+ ?>
+
+
+
+ Artikel tidak ditemukan
+ Slug yang diminta tidak tersedia. Silakan kembali ke blog untuk melihat artikel yang tersedia.
+
+
+
+
+ (string)$post['title'],
+ 'description' => $description,
+ 'canonical' => $canonicalUrl,
+ 'keywords' => 'artikel apknusa, blog android, backlink apknusa, ' . (string)$post['category'],
+ 'json_ld' => [
+ '@context' => 'https://schema.org',
+ '@type' => 'BlogPosting',
+ 'headline' => (string)$post['title'],
+ 'description' => $description,
+ 'datePublished' => date(DATE_ATOM, strtotime((string)$post['published_at'])),
+ 'dateModified' => date(DATE_ATOM, strtotime((string)$post['updated_at'])),
+ 'mainEntityOfPage' => $canonicalUrl,
+ 'author' => [
+ '@type' => 'Organization',
+ 'name' => project_name(),
+ ],
+ 'publisher' => [
+ '@type' => 'Organization',
+ 'name' => project_name(),
+ ],
+ ],
+]);
+?>
+
+
+
+
+
+
+
= e((string)$post['category']) ?>
+
= e((string)format_article_date((string)$post['published_at'])) ?>
+
= e((string)reading_time_minutes((string)$post['content'])) ?> menit baca
+
+ = e((string)$post['title']) ?>
+ = e((string)$post['excerpt']) ?>
+
+ = render_post_content((string)$post['content']) ?>
+
+
+
+
Langkah berikutnya
+
Lanjutkan ke website utama
+
Setelah membaca ringkasan ini, arahkan pengunjung ke apknusa.com untuk melihat referensi atau konten utama yang lebih lengkap.
+
+
= e((string)$post['cta_text']) ?>
+
+
+
+
+
+
+
+
diff --git a/robots.txt b/robots.txt
new file mode 100644
index 0000000..95ce749
--- /dev/null
+++ b/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+Disallow: /admin.php
+Sitemap: https://baclink-apknusa-microsite-ad42.dev.flatlogic.app/sitemap.xml
diff --git a/sitemap.xml b/sitemap.xml
new file mode 100644
index 0000000..a5dd743
--- /dev/null
+++ b/sitemap.xml
@@ -0,0 +1,51 @@
+
+
+
+ https://baclink-apknusa-microsite-ad42.dev.flatlogic.app/
+ 2026-05-17T14:40:34+00:00
+ daily
+ 1.0
+
+
+ https://baclink-apknusa-microsite-ad42.dev.flatlogic.app/blog.php
+ 2026-05-17T14:40:34+00:00
+ daily
+ 0.8
+
+
+ https://baclink-apknusa-microsite-ad42.dev.flatlogic.app/post.php?slug=checklist-link-seo-ke-apknusa-com
+ 2026-05-17T13:29:20+00:00
+ weekly
+ 0.7
+
+
+ https://baclink-apknusa-microsite-ad42.dev.flatlogic.app/post.php?slug=perbedaan-apk-xapk-dan-file-instalasi-lain-yang-perlu-dipahami
+ 2026-05-17T13:29:11+00:00
+ weekly
+ 0.7
+
+
+ https://baclink-apknusa-microsite-ad42.dev.flatlogic.app/post.php?slug=tips-menyimpan-file-apk-supaya-mudah-dicari-lagi
+ 2026-05-17T13:29:11+00:00
+ weekly
+ 0.7
+
+
+ https://baclink-apknusa-microsite-ad42.dev.flatlogic.app/post.php?slug=kenapa-review-aplikasi-penting-sebelum-download
+ 2026-05-17T13:29:11+00:00
+ weekly
+ 0.7
+
+
+ https://baclink-apknusa-microsite-ad42.dev.flatlogic.app/post.php?slug=panduan-update-aplikasi-android-tanpa-kehilangan-data
+ 2026-05-17T13:29:11+00:00
+ weekly
+ 0.7
+
+
+ https://baclink-apknusa-microsite-ad42.dev.flatlogic.app/post.php?slug=cara-memilih-sumber-apk-yang-aman-sebelum-instal
+ 2026-05-17T13:29:11+00:00
+ weekly
+ 0.7
+
+