Compare commits

...

2 Commits

Author SHA1 Message Date
ce88ac52f6 Add phone number field to citizen data
Adds phone column to tb_pdd table and updates all related forms (add, edit, view) to handle phone numbers for citizens.
2026-01-25 00:20:46 +08:00
5d69f28fad Secure Gemini API key with environment variables and cleanup unused dependencies 2026-01-22 22:22:58 +08:00
13 changed files with 464 additions and 350 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Google Gemini API Configuration
# Get your API key from: https://makersuite.google.com/app/apikey
GEMINI_API_KEY=your_gemini_api_key_here
# Database Configuration (if needed in future)
# DB_HOST=localhost
# DB_NAME=sidak_db
# DB_USER=root
# DB_PASSWORD=

5
.gitignore vendored
View File

@@ -17,3 +17,8 @@ vendor/
# Uploads # Uploads
foto/ foto/
# API Keys and Environment Variables
admin/api/config_api.php
.env
.env.*.local

115
README_API_SECURITY.md Normal file
View File

@@ -0,0 +1,115 @@
# 🔐 Keamanan API Key SIDAK
## **⚠️ PENTING: API Key Terdeteksi di Repository Git**
File `admin/api/config_api.php` sebelumnya mengandung kunci API Gemini yang terekspos di repository Git. Kunci ini telah diamankan dengan sistem environment variables.
## **🛡️ Langkah-Langkah Pengamanan yang Telah Dilakukan:**
1. **API Key dihapus dari Git tracking:**
```bash
git rm --cached admin/api/config_api.php
```
2. **File konfigurasi dimodifikasi untuk menggunakan environment variables:**
- `admin/api/config_api.php` sekarang membaca dari file `.env`
- Gunakan template `.env.example` untuk membuat `.env`
3. **File sensitif ditambahkan ke `.gitignore`:**
```
admin/api/config_api.php
.env
.env.*.local
```
## **🚀 Deployment Instructions:**
### **1. Untuk Development Lokal:**
```bash
# Salin template .env
cp .env.example .env
# Edit .env dengan API key Anda
nano .env # atau editor favorit Anda
```
### **2. Untuk Production Server:**
```bash
# Buat file .env di server
cat > /var/www/sidak/.env << 'EOF'
# Google Gemini API Configuration
GEMINI_API_KEY=your_actual_production_key_here
EOF
# Pastikan permission aman
chmod 600 /var/www/sidak/.env
chown www-data:www-data /var/www/sidak/.env
```
### **3. Get New API Key (jika perlu):**
1. Kunjungi [Google AI Studio](https://makersuite.google.com/app/apikey)
2. Login dengan akun Google
3. Create API Key → Copy key baru
4. Update file `.env` di server
## **📁 Struktur File yang Aman:**
```
sidak/
├── .env # ⚠️ JANGAN commit (sudah di .gitignore)
├── .env.example # ✅ Template aman untuk commit
├── .gitignore # ✅ Menyertakan .env dan config_api.php
├── admin/
│ └── api/
│ ├── config_api.php # ✅ Membaca dari environment variables
│ └── ocr_helper.php # ✅ Menggunakan GEMINI_API_KEY dari config
└── README_API_SECURITY.md # ✅ Dokumentasi ini
```
## **🔧 Testing Configuration:**
Untuk memastikan konfigurasi bekerja:
```php
<?php
// Test script: test_api_config.php
require_once 'admin/api/config_api.php';
echo "GEMINI_API_KEY: " . (defined('GEMINI_API_KEY') ? '✅ Set' : '❌ Not set') . "\n";
echo "GEMINI_API_URL: " . GEMINI_API_URL . "\n";
if (empty(GEMINI_API_KEY)) {
echo "❌ ERROR: API key tidak terdeteksi.\n";
echo "Pastikan file .env ada dan berisi GEMINI_API_KEY=your_key\n";
} else {
echo "✅ Konfigurasi API siap digunakan.\n";
}
?>
```
## **🔄 Jika Terjadi Masalah:**
### **Masalah: "API key not set"**
**Solusi:**
1. Pastikan file `.env` ada di root directory
2. Pastikan permission file `.env` dapat dibaca oleh PHP
3. Restart web server jika perlu: `sudo service apache2 restart`
### **Masalah: "403 Forbidden" dari Gemini API**
**Solusi:**
1. Periksa apakah API key valid di [Google AI Studio](https://makersuite.google.com/app/apikey)
2. Pastikan billing enabled di Google Cloud Console
3. Cek quota usage di Google Cloud Console
## **📞 Support:**
Jika menemukan masalah keamanan:
1. **Segera putar API key** di Google AI Studio
2. Update file `.env` di semua environment
3. Hubungi developer: [wartana@example.com]
---
**⚠️ REMINDER:** JANGAN pernah commit file `.env` atau `admin/api/config_api.php` ke repository Git. Selalu gunakan `.env.example` sebagai template.
**Terakhir diperbarui:** 22 Januari 2026

View File

@@ -1,5 +0,0 @@
<?php
// Google Gemini API Configuration
define('GEMINI_API_KEY', 'AIzaSyDp9crq4QWN15xBXbDY2FBXdUoRg1LgM1M');
define('GEMINI_API_URL', 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent');
?>

View File

@@ -130,10 +130,16 @@ $return_to = isset($_GET['return_to']) ? mysqli_real_escape_string($koneksi, tri
<div class="col-sm-6"> <div class="col-sm-6">
<input type="text" class="form-control" id="kewarganegaraan" name="kewarganegaraan" placeholder="WNI/WNA" required> <input type="text" class="form-control" id="kewarganegaraan" name="kewarganegaraan" placeholder="WNI/WNA" required>
</div> </div>
</div> </div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">No. Telepon</label>
<div class="col-sm-6">
<input type="text" class="form-control" id="phone" name="phone" placeholder="Nomor Telepon">
</div>
</div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<input type="submit" name="Simpan" value="Simpan" class="btn btn-info"> <input type="submit" name="Simpan" value="Simpan" class="btn btn-info">
@@ -412,8 +418,10 @@ window.addEventListener('load', function() {
$kabupaten = mysqli_real_escape_string($koneksi, trim($_POST['kabupaten'])); $kabupaten = mysqli_real_escape_string($koneksi, trim($_POST['kabupaten']));
$provinsi = mysqli_real_escape_string($koneksi, trim($_POST['provinsi'])); $provinsi = mysqli_real_escape_string($koneksi, trim($_POST['provinsi']));
$kewarganegaraan = mysqli_real_escape_string($koneksi, trim($_POST['kewarganegaraan'])); $kewarganegaraan = mysqli_real_escape_string($koneksi, trim($_POST['kewarganegaraan']));
$phone = mysqli_real_escape_string($koneksi, trim($_POST['phone']));
$sql_simpan = "INSERT INTO tb_pdd (nik, nama, tempat_lh, tgl_lh, jekel, desa, rt, rw, agama, kawin, pekerjaan, foto_ktp, status, kecamatan, kabupaten, provinsi, kewarganegaraan) VALUES (
$sql_simpan = "INSERT INTO tb_pdd (nik, nama, tempat_lh, tgl_lh, jekel, desa, rt, rw, agama, kawin, pekerjaan, foto_ktp, status, kecamatan, kabupaten, provinsi, kewarganegaraan, phone) VALUES (
'$nik', '$nik',
'$nama', '$nama',
'$tempat_lh', '$tempat_lh',
@@ -430,7 +438,7 @@ window.addEventListener('load', function() {
'$kecamatan', '$kecamatan',
'$kabupaten', '$kabupaten',
'$provinsi', '$provinsi',
'$kewarganegaraan')"; '$kewarganegaraan', '$phone')";
$query_simpan = mysqli_query($koneksi, $sql_simpan); $query_simpan = mysqli_query($koneksi, $sql_simpan);
if ($query_simpan) { if ($query_simpan) {

View File

@@ -178,9 +178,16 @@
<input type="text" class="form-control" id="kewarganegaraan" name="kewarganegaraan" value="<?php echo $data_cek['kewarganegaraan']; ?>" <input type="text" class="form-control" id="kewarganegaraan" name="kewarganegaraan" value="<?php echo $data_cek['kewarganegaraan']; ?>"
required> required>
</div> </div>
</div> </div>
<div class="form-group row">
<label class="col-sm-2 col-form-label">No. Telepon</label>
<div class="col-sm-6">
<input type="text" class="form-control" id="phone" name="phone" value="<?php echo isset($data_cek['phone']) ? $data_cek['phone'] : ''; ?>" placeholder="Nomor Telepon">
</div>
</div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<input type="submit" name="Ubah" value="Simpan" class="btn btn-success"> <input type="submit" name="Ubah" value="Simpan" class="btn btn-success">
@@ -382,6 +389,7 @@
$kabupaten = mysqli_real_escape_string($koneksi, $_POST['kabupaten']); $kabupaten = mysqli_real_escape_string($koneksi, $_POST['kabupaten']);
$provinsi = mysqli_real_escape_string($koneksi, $_POST['provinsi']); $provinsi = mysqli_real_escape_string($koneksi, $_POST['provinsi']);
$kewarganegaraan = mysqli_real_escape_string($koneksi, $_POST['kewarganegaraan']); $kewarganegaraan = mysqli_real_escape_string($koneksi, $_POST['kewarganegaraan']);
$phone = mysqli_real_escape_string($koneksi, $_POST['phone']);
if($has_new_photo){ if($has_new_photo){
$foto= $data_cek['foto_ktp']; $foto= $data_cek['foto_ktp'];
@@ -403,8 +411,9 @@
kecamatan='$kecamatan', kecamatan='$kecamatan',
kabupaten='$kabupaten', kabupaten='$kabupaten',
provinsi='$provinsi', provinsi='$provinsi',
kewarganegaraan='$kewarganegaraan', kewarganegaraan='$kewarganegaraan',
foto_ktp='$nama_file' phone='$phone',
foto_ktp='$nama_file'
WHERE id_pend='$id_pend'"; WHERE id_pend='$id_pend'";
$query_ubah = mysqli_query($koneksi, $sql_ubah); $query_ubah = mysqli_query($koneksi, $sql_ubah);
@@ -424,7 +433,8 @@
kecamatan='$kecamatan', kecamatan='$kecamatan',
kabupaten='$kabupaten', kabupaten='$kabupaten',
provinsi='$provinsi', provinsi='$provinsi',
kewarganegaraan='$kewarganegaraan' kewarganegaraan='$kewarganegaraan',
phone='$phone'
WHERE id_pend='$id_pend'"; WHERE id_pend='$id_pend'";
$query_ubah = mysqli_query($koneksi, $sql_ubah); $query_ubah = mysqli_query($koneksi, $sql_ubah);
} }

View File

@@ -134,9 +134,17 @@
<td>: <td>:
<?php echo $data_cek['kewarganegaraan']; ?> <?php echo $data_cek['kewarganegaraan']; ?>
</td> </td>
</tr> </tr>
<tr>
<td style="width: 150px">
<b>No. Telepon</b>
</td>
<td>:
<?php echo isset($data_cek['phone']) ? $data_cek['phone'] : ''; ?>
</td>
</tr>
</tbody> </tbody>
</table> </table>
<div class="card-footer"> <div class="card-footer">

View File

@@ -3,51 +3,33 @@
<div class="modal fade" id="modalScanner" tabindex="-1" role="dialog" aria-labelledby="modalScannerLabel" aria-hidden="true" data-backdrop="static"> <div class="modal fade" id="modalScanner" tabindex="-1" role="dialog" aria-labelledby="modalScannerLabel" aria-hidden="true" data-backdrop="static">
<div class="modal-dialog modal-xl" role="document"> <div class="modal-dialog modal-xl" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="modalScannerLabel"><i class="fas fa-expand"></i> Smart Scanner</h5> <h5 class="modal-title" id="modalScannerLabel"><i class="fas fa-expand"></i> Smart Scanner</h5>
<div class="ml-auto mr-3"> <button type="button" class="close" data-dismiss="modal" aria-label="Close">
<div class="btn-group btn-group-sm" role="group" id="scannerModeToggle"> <span aria-hidden="true">&times;</span>
<button type="button" class="btn btn-outline-primary active" data-mode="smart"> </button>
<i class="fas fa-magic"></i> Smart Scan </div>
</button>
<button type="button" class="btn btn-outline-secondary" data-mode="manual">
<i class="fas fa-crop-alt"></i> Manual Crop
</button>
</div>
</div>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body text-center bg-dark p-0" style="position: relative; overflow: hidden; height: 80vh;"> <div class="modal-body text-center bg-dark p-0" style="position: relative; overflow: hidden; height: 80vh;">
<!-- Smart Scan Mode (Default) --> <div id="scanner-container" style="position: relative; margin: auto; display: inline-block;">
<div id="scanner-container" class="scanner-mode" data-mode="smart" style="display: block; position: relative; margin: auto; display: inline-block;"> <canvas id="canvas-image" style="position: absolute; left: 0; top: 0; z-index: 1;"></canvas>
<canvas id="canvas-image" style="position: absolute; left: 0; top: 0; z-index: 1;"></canvas> <canvas id="canvas-overlay" style="position: absolute; left: 0; top: 0; z-index: 2; cursor: crosshair;"></canvas>
<canvas id="canvas-overlay" style="position: absolute; left: 0; top: 0; z-index: 2; cursor: crosshair;"></canvas> </div>
</div>
<div id="scanner-loading" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); color: white; display: none;">
<!-- Manual Crop Mode (Cropper.js) --> <i class="fas fa-spinner fa-spin fa-3x"></i><br>Detecting Document...
<div id="crop-container" class="scanner-mode" data-mode="manual" style="display: none; width: 100%; height: 100%;"> </div>
<img id="crop-image" style="max-width: 100%; max-height: 100%;">
</div> <!-- Mobile Help Tips -->
<div id="mobile-help" class="d-none d-md-none d-lg-none" style="position: absolute; bottom: 10px; left: 0; right: 0; text-align: center; color: white; background: rgba(0,0,0,0.7); padding: 5px; font-size: 12px;">
<div id="scanner-loading" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); color: white; display: none;"> <span>📍 Sentuh & geser titik biru untuk atur sudut dokumen</span>
<i class="fas fa-spinner fa-spin fa-3x"></i><br>Detecting Document... </div>
</div>
<!-- Mobile Help Tips -->
<div id="mobile-help" class="d-none d-md-none d-lg-none" style="position: absolute; bottom: 10px; left: 0; right: 0; text-align: center; color: white; background: rgba(0,0,0,0.7); padding: 5px; font-size: 12px;">
<span id="help-text-smart">📍 Sentuh & geser titik biru untuk atur sudut (untuk mobile, gunakan Manual Crop)</span>
<span id="help-text-manual" style="display: none;">📍 Pinch untuk zoom, geser untuk pindah area crop</span>
</div>
</div> </div>
<div class="modal-footer justify-content-between"> <div class="modal-footer justify-content-between">
<div> <div>
<button type="button" class="btn btn-secondary" id="btnScanRotateLeft" title="Putar Kiri (-90°)"><i class="fas fa-undo"></i></button> <button type="button" class="btn btn-secondary" id="btnScanRotateLeft" title="Putar Kiri (-90°)"><i class="fas fa-undo"></i></button>
<button type="button" class="btn btn-secondary" id="btnScanRotateRight" title="Putar Kanan (+90°)"><i class="fas fa-redo"></i></button> <button type="button" class="btn btn-secondary" id="btnScanRotateRight" title="Putar Kanan (+90°)"><i class="fas fa-redo"></i></button>
<button type="button" class="btn btn-warning" id="btnScanReset"><i class="fas fa-sync"></i> Reset Sudut</button> <button type="button" class="btn btn-warning" id="btnScanReset"><i class="fas fa-sync"></i> Reset Sudut</button>
<button type="button" class="btn btn-info d-none" id="btnCropReset"><i class="fas fa-crop"></i> Reset Crop</button> </div>
</div>
<div> <div>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button> <button type="button" class="btn btn-secondary" data-dismiss="modal">Batal</button>
<button type="button" class="btn btn-primary" id="btnScanSave"><i class="fas fa-check"></i> Simpan Hasil Scan</button> <button type="button" class="btn btn-primary" id="btnScanSave"><i class="fas fa-check"></i> Simpan Hasil Scan</button>
@@ -179,12 +161,7 @@
touch-action: manipulation; touch-action: manipulation;
} }
/* Mode toggle buttons */
#scannerModeToggle .btn {
min-height: 36px !important;
min-width: 90px !important;
font-size: 14px !important;
}
/* Help text */ /* Help text */
#mobile-help { #mobile-help {
@@ -193,43 +170,187 @@
} }
} }
/* Cropper.js customizations for mobile */ /* Larger hit area for corner points for touch devices */
.cropper-point { #canvas-overlay {
width: 30px !important; touch-action: pinch-zoom;
height: 30px !important; }
}
.cropper-line {
background-color: rgba(0, 123, 255, 0.5) !important;
}
/* Larger hit area for corner points in smart scan */
.scanner-mode[data-mode="smart"] canvas {
touch-action: pinch-zoom;
}
</style> </style>
<script> <script>
window.addEventListener('load', function() { window.addEventListener('load', function() {
// Function to load scanner libraries dynamically
function loadScannerLibs() {
return new Promise(function(resolve, reject) {
// Check if both libraries are already loaded AND OpenCV runtime is ready
if (typeof cv !== 'undefined' && cv.Mat && typeof jscanify !== 'undefined') {
resolve();
return;
}
// Load OpenCV first (jscanify depends on it)
function loadOpenCV() {
return new Promise(function(resolveOpenCV, rejectOpenCV) {
if (typeof cv !== 'undefined') {
// OpenCV already loaded, check if runtime ready
if (cv.Mat) {
resolveOpenCV();
} else if (cv.onRuntimeInitialized) {
// Wait for runtime initialization
var existingCallback = cv.onRuntimeInitialized;
cv.onRuntimeInitialized = function() {
if (typeof existingCallback === 'function') {
existingCallback();
}
resolveOpenCV();
};
} else {
// OpenCV loaded but no runtime callback mechanism
// Poll for cv.Mat
var checkInterval = setInterval(function() {
if (cv.Mat) {
clearInterval(checkInterval);
resolveOpenCV();
}
}, 100);
// Timeout after 10 seconds
setTimeout(function() {
clearInterval(checkInterval);
rejectOpenCV(new Error('OpenCV runtime initialization timeout'));
}, 10000);
}
} else {
// Load OpenCV script
var opencvScript = document.createElement('script');
opencvScript.src = '../../plugins/vendor/opencv/opencv.js';
opencvScript.onload = function() {
console.log('OpenCV.js loaded dynamically');
// Wait for runtime initialization
if (cv.onRuntimeInitialized) {
var existingCallback = cv.onRuntimeInitialized;
cv.onRuntimeInitialized = function() {
if (typeof existingCallback === 'function') {
existingCallback();
}
resolveOpenCV();
};
} else {
// Fallback: wait a bit then resolve
setTimeout(resolveOpenCV, 5000);
}
};
opencvScript.onerror = function() {
console.error('Failed to load OpenCV.js');
rejectOpenCV(new Error('Failed to load OpenCV.js'));
};
document.head.appendChild(opencvScript);
}
});
}
// Load jscanify after OpenCV is ready
function loadJscanify() {
return new Promise(function(resolveJscanify, rejectJscanify) {
if (typeof jscanify !== 'undefined') {
resolveJscanify();
return;
}
var jscanifyScript = document.createElement('script');
jscanifyScript.src = '../../plugins/vendor/jscanify/jscanify.min.js';
jscanifyScript.onload = function() {
console.log('jscanify loaded dynamically');
resolveJscanify();
};
jscanifyScript.onerror = function() {
console.error('Failed to load jscanify');
rejectJscanify(new Error('Failed to load jscanify'));
};
document.head.appendChild(jscanifyScript);
});
}
// Load all libraries in sequence
loadOpenCV()
.then(function() {
console.log('OpenCV ready, loading jscanify...');
return loadJscanify();
})
.then(function() {
console.log('All scanner libraries loaded');
resolve();
})
.catch(function(error) {
console.error('Failed to load scanner libraries:', error);
reject(error);
});
});
}
// Load libraries and then initialize scanner
loadScannerLibs().then(function() {
// Give OpenCV a moment to initialize runtime
setTimeout(function() {
// Scanner Variables // Scanner Variables
var scannerModal = $('#modalScanner'); var scannerModal = $('#modalScanner');
var canvasImage = document.getElementById('canvas-image'); var canvasImage = document.getElementById('canvas-image');
var canvasOverlay = document.getElementById('canvas-overlay'); var canvasOverlay = document.getElementById('canvas-overlay');
var ctxImg = canvasImage.getContext('2d'); var ctxImg = canvasImage.getContext('2d');
var ctxOver = canvasOverlay.getContext('2d'); var ctxOver = canvasOverlay.getContext('2d');
const IS_TOUCH_DEVICE = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
var scanner = null; var scanner = null;
try {
scanner = new jscanify(); function initScanner() {
} catch (e) { // Check if OpenCV is loaded
console.error('Failed to initialize jscanify:', e); if (typeof cv === 'undefined') {
// scanner remains null console.warn('OpenCV not loaded yet');
} return false;
}
// Check if OpenCV runtime is already initialized
if (cv.Mat) {
try {
scanner = new jscanify();
console.log('Scanner initialized successfully (runtime ready)');
return true;
} catch (e) {
console.error('Failed to initialize jscanify:', e);
return false;
}
}
// Runtime not initialized yet, set up callback
if (cv.onRuntimeInitialized) {
// Save any existing callback
var existingCallback = cv.onRuntimeInitialized;
cv.onRuntimeInitialized = function() {
// Call existing callback first if it's a function
if (typeof existingCallback === 'function') {
existingCallback();
}
// Now initialize scanner
try {
scanner = new jscanify();
console.log('Scanner initialized after runtime ready (callback)');
} catch (e) {
console.error('Failed to initialize jscanify after runtime:', e);
}
};
console.log('Waiting for OpenCV runtime initialization...');
return false;
}
// Should not reach here
console.warn('OpenCV loaded but no runtime initialization mechanism');
return false;
}
// Try to initialize scanner immediately
initScanner();
var originalImg = new Image(); var originalImg = new Image();
// Cropper.js instance // Mode is always smart scan now
var cropper = null; var currentMode = 'smart';
var currentMode = IS_TOUCH_DEVICE ? 'manual' : 'smart'; // Default to manual for touch devices
// Corner Points (tl, tr, bl, br) // Corner Points (tl, tr, bl, br)
var corners = []; var corners = [];
@@ -239,48 +360,17 @@ window.addEventListener('load', function() {
var isTouchInteraction = false; var isTouchInteraction = false;
var touchOffset = null; var touchOffset = null;
// Config // Config - Extra large circles for easy mobile touch, thin lines
const POINT_RADIUS = IS_TOUCH_DEVICE ? 25 : 15; // Larger for touch devices const POINT_RADIUS = 50; // Extra large circle radius for easy mobile touch
const POINT_COLOR = '#007bff'; const POINT_COLOR = 'rgba(0, 123, 255, 0.15)'; // Transparent blue (15% opacity)
const LINE_COLOR = '#00ff00'; const POINT_OUTLINE_COLOR = '#007bff'; // Blue outline
const LINE_WIDTH = IS_TOUCH_DEVICE ? 5 : 3; const POINT_OUTLINE_WIDTH = 2; // Thin outline
const TOUCH_RADIUS_MULTIPLIER = 2.5; // Larger hit area for touch devices const LINE_COLOR = 'rgba(0, 255, 0, 0.8)'; // Semi-transparent green lines
const LINE_WIDTH = 1.5; // Very thin lines
const TOUCH_RADIUS_MULTIPLIER = 3.5; // Extra large hit area for touch devices
const TOUCH_SLOP = 5; // pixels threshold before dragging starts const TOUCH_SLOP = 5; // pixels threshold before dragging starts
const IS_TOUCH_DEVICE = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// --- Mode Toggle Handler --- // Mode is always smart scan (no toggle needed)
$('#scannerModeToggle button').on('click', function() {
var mode = $(this).data('mode');
if (mode === currentMode) return;
// Update UI
$('#scannerModeToggle button').removeClass('active btn-primary').addClass('btn-outline-secondary');
$(this).removeClass('btn-outline-secondary').addClass('active btn-primary');
// Switch modes
$('.scanner-mode').hide();
$('.scanner-mode[data-mode="' + mode + '"]').show();
// Update help text
$('#help-text-smart, #help-text-manual').hide();
$('#help-text-' + mode).show();
// Show/hide appropriate buttons
if (mode === 'smart') {
$('#btnScanReset').removeClass('d-none');
$('#btnCropReset').addClass('d-none');
} else {
$('#btnScanReset').addClass('d-none');
$('#btnCropReset').removeClass('d-none');
// Initialize cropper if not already
if (typeof Cropper !== 'undefined' && !cropper && originalImg.src) {
initCropper();
}
}
currentMode = mode;
});
// --- Public Function to Open Scanner --- // --- Public Function to Open Scanner ---
window.openScanner = function(file) { window.openScanner = function(file) {
@@ -300,7 +390,7 @@ window.addEventListener('load', function() {
function checkReady() { function checkReady() {
if (imgLoaded && modalShown) { if (imgLoaded && modalShown) {
initScanner(); setupScannerCanvas();
} }
} }
@@ -335,9 +425,8 @@ window.addEventListener('load', function() {
var preventTouch = function(e) { var preventTouch = function(e) {
// Allow touch on canvas elements and buttons // Allow touch on canvas elements and buttons
var target = e.target; var target = e.target;
var isCanvas = target.id === 'canvas-overlay' || target.id === 'canvas-image' || var isCanvas = target.id === 'canvas-overlay' || target.id === 'canvas-image' ||
target.id === 'crop-image' || target.closest('#crop-container') || target.closest('#scanner-container');
target.closest('#scanner-container');
var isButton = target.tagName === 'BUTTON' || target.closest('button'); var isButton = target.tagName === 'BUTTON' || target.closest('button');
if (!isCanvas && !isButton) { if (!isCanvas && !isButton) {
@@ -369,8 +458,7 @@ window.addEventListener('load', function() {
document.body.style.overflow = ''; document.body.style.overflow = '';
document.body.style.position = ''; document.body.style.position = '';
document.body.style.width = ''; document.body.style.width = '';
// Cleanup cropper
destroyCropper();
}); });
scannerModal.modal('show'); scannerModal.modal('show');
@@ -433,104 +521,33 @@ window.addEventListener('load', function() {
drawOverlay(); drawOverlay();
} }
function initScanner() { function setupScannerCanvas() {
// Resize Canvas to fit screen but keep aspect ratio // Resize Canvas to fit screen but keep aspect ratio
var maxWidth = $('#modalScanner .modal-body').width() - 20; var maxWidth = $('#modalScanner .modal-body').width() - 20;
var maxHeight = $('#modalScanner .modal-body').height() - 20; var maxHeight = $('#modalScanner .modal-body').height() - 20;
var scale = Math.min(maxWidth / originalImg.width, maxHeight / originalImg.height);
var w = originalImg.width * scale;
var h = originalImg.height * scale;
canvasImage.width = w;
canvasImage.height = h;
canvasOverlay.width = w;
canvasOverlay.height = h;
// Resize container
$('#scanner-container').css({ width: w, height: h, marginTop: '10px' });
// Draw Image
ctxImg.drawImage(originalImg, 0, 0, w, h);
detectDocument(scale, w, h, false); var scale = Math.min(maxWidth / originalImg.width, maxHeight / originalImg.height);
$('#scanner-loading').hide(); var w = originalImg.width * scale;
var h = originalImg.height * scale;
// Prepare image for cropper canvasImage.width = w;
var cropImage = document.getElementById('crop-image'); canvasImage.height = h;
cropImage.src = originalImg.src; canvasOverlay.width = w;
canvasOverlay.height = h;
// Initialize cropper if in manual mode // Resize container
if (currentMode === 'manual' && typeof Cropper !== 'undefined') { $('#scanner-container').css({ width: w, height: h, marginTop: '10px' });
initCropper();
} // Draw Image
} ctxImg.drawImage(originalImg, 0, 0, w, h);
detectDocument(scale, w, h, false);
$('#scanner-loading').hide();
// No cropper needed - smart scan only
}
function initCropper() {
if (typeof Cropper === 'undefined') {
console.error('Cropper.js not loaded');
alert('Cropper library not loaded. Please refresh the page.');
return;
}
var cropImage = document.getElementById('crop-image');
if (!cropImage.src) {
console.warn('Crop image not loaded yet');
return;
}
// Destroy existing cropper
if (cropper) {
cropper.destroy();
cropper = null;
}
// Initialize new cropper with mobile-friendly options
cropper = new Cropper(cropImage, {
viewMode: 1,
dragMode: 'crop',
initialAspectRatio: 16 / 9,
aspectRatio: NaN, // Free aspect ratio
autoCrop: true,
autoCropArea: 0.8,
responsive: true,
restore: true,
checkCrossOrigin: false,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
minCanvasWidth: 100,
minCanvasHeight: 100,
minContainerWidth: 100,
minContainerHeight: 100,
minCropBoxWidth: 50,
minCropBoxHeight: 50,
// Mobile touch settings
touchDragZoom: true,
wheelZoomRatio: 0.1,
ready: function() {
// Adjust for mobile
if (IS_TOUCH_DEVICE) {
// Make handles larger for touch
var points = this.cropper.cropBox.querySelectorAll('.cropper-point');
points.forEach(function(point) {
point.style.width = '30px';
point.style.height = '30px';
});
}
}
});
}
function destroyCropper() {
if (cropper) {
cropper.destroy();
cropper = null;
}
}
function defaultCorners(w, h) { function defaultCorners(w, h) {
// Default 20% margin // Default 20% margin
@@ -549,7 +566,7 @@ window.addEventListener('load', function() {
if (corners.length < 4) return; if (corners.length < 4) return;
// Draw Lines // Draw thin cropping lines
ctxOver.beginPath(); ctxOver.beginPath();
ctxOver.lineWidth = LINE_WIDTH; ctxOver.lineWidth = LINE_WIDTH;
ctxOver.strokeStyle = LINE_COLOR; ctxOver.strokeStyle = LINE_COLOR;
@@ -560,14 +577,19 @@ window.addEventListener('load', function() {
ctxOver.closePath(); ctxOver.closePath();
ctxOver.stroke(); ctxOver.stroke();
// Draw Points // Draw transparent circles with thin outline
ctxOver.fillStyle = POINT_COLOR;
corners.forEach(p => { corners.forEach(p => {
// Draw transparent inner circle
ctxOver.beginPath(); ctxOver.beginPath();
ctxOver.arc(p.x, p.y, POINT_RADIUS, 0, Math.PI * 2); ctxOver.arc(p.x, p.y, POINT_RADIUS, 0, Math.PI * 2);
ctxOver.fillStyle = POINT_COLOR;
ctxOver.fill(); ctxOver.fill();
ctxOver.strokeStyle = 'white';
ctxOver.lineWidth = 2; // Draw thin blue outline
ctxOver.beginPath();
ctxOver.arc(p.x, p.y, POINT_RADIUS, 0, Math.PI * 2);
ctxOver.lineWidth = POINT_OUTLINE_WIDTH;
ctxOver.strokeStyle = POINT_OUTLINE_COLOR;
ctxOver.stroke(); ctxOver.stroke();
}); });
} }
@@ -707,22 +729,10 @@ window.addEventListener('load', function() {
// Update originalImg // Update originalImg
var rotatedUrl = offCanvas.toDataURL(); var rotatedUrl = offCanvas.toDataURL();
originalImg.onload = function() { originalImg.onload = function() {
// Re-init scanner for both modes // Re-init scanner
if (currentMode === 'smart') { setupScannerCanvas();
initScanner(); }
} else {
// Update cropper image
var cropImage = document.getElementById('crop-image');
cropImage.src = rotatedUrl;
// Reinitialize cropper
if (cropper) {
cropper.destroy();
}
initCropper();
}
}
originalImg.src = rotatedUrl; originalImg.src = rotatedUrl;
} }
@@ -730,77 +740,56 @@ window.addEventListener('load', function() {
$('#btnScanRotateRight').click(function() { rotateImage(90); }); $('#btnScanRotateRight').click(function() { rotateImage(90); });
// --- Reset Button --- // --- Reset Button ---
$('#btnScanReset').click(function() { $('#btnScanReset').click(function() {
initScanner(); setupScannerCanvas();
}); });
// --- Crop Reset Button ---
$('#btnCropReset').click(function() {
if (cropper) {
cropper.reset();
}
});
// --- Save / Extract Button --- // --- Save / Extract Button ---
$('#btnScanSave').click(function() { $('#btnScanSave').click(function() {
try { try {
var base64; var base64;
if (currentMode === 'smart') { // Smart Scan Mode - Use jscanify
// Smart Scan Mode - Use jscanify // 1. Get raw points relative to Original Image
// 1. Get raw points relative to Original Image var scaleX = originalImg.width / canvasImage.width;
var scaleX = originalImg.width / canvasImage.width; var scaleY = originalImg.height / canvasImage.height;
var scaleY = originalImg.height / canvasImage.height;
var tl = { x: corners[0].x * scaleX, y: corners[0].y * scaleY };
var tl = { x: corners[0].x * scaleX, y: corners[0].y * scaleY }; var tr = { x: corners[1].x * scaleX, y: corners[1].y * scaleY };
var tr = { x: corners[1].x * scaleX, y: corners[1].y * scaleY }; var br = { x: corners[2].x * scaleX, y: corners[2].y * scaleY };
var br = { x: corners[2].x * scaleX, y: corners[2].y * scaleY }; var bl = { x: corners[3].x * scaleX, y: corners[3].y * scaleY };
var bl = { x: corners[3].x * scaleX, y: corners[3].y * scaleY };
// 2. Calculate dimensions of the crop area
// 2. Calculate dimensions of the crop area var widthTop = Math.hypot(tr.x - tl.x, tr.y - tl.y);
var widthTop = Math.hypot(tr.x - tl.x, tr.y - tl.y); var widthBottom = Math.hypot(br.x - bl.x, br.y - bl.y);
var widthBottom = Math.hypot(br.x - bl.x, br.y - bl.y); var outputWidth = Math.max(widthTop, widthBottom);
var outputWidth = Math.max(widthTop, widthBottom);
var heightLeft = Math.hypot(bl.x - tl.x, bl.y - tl.y);
var heightLeft = Math.hypot(bl.x - tl.x, bl.y - tl.y); var heightRight = Math.hypot(br.x - tr.x, br.y - tr.y);
var heightRight = Math.hypot(br.x - tr.x, br.y - tr.y); var outputHeight = Math.max(heightLeft, heightRight);
var outputHeight = Math.max(heightLeft, heightRight);
var extractPoints = { var extractPoints = {
topLeftCorner: tl, topLeftCorner: tl,
topRightCorner: tr, topRightCorner: tr,
bottomRightCorner: br, bottomRightCorner: br,
bottomLeftCorner: bl bottomLeftCorner: bl
}; };
// 3. Extract with dynamic dimensions // 3. Extract with dynamic dimensions
var resultCanvas = null; var resultCanvas = null;
if (scanner && scanner.extractPaper) { if (!scanner || !scanner.extractPaper) {
resultCanvas = scanner.extractPaper(originalImg, outputWidth, outputHeight, extractPoints); // Try to initialize scanner if not ready
} else { initScanner();
alert("Scanner library not loaded. Please refresh the page."); }
return; if (scanner && scanner.extractPaper) {
} resultCanvas = scanner.extractPaper(originalImg, outputWidth, outputHeight, extractPoints);
base64 = resultCanvas.toDataURL('image/jpeg'); } else {
alert("Scanner library not loaded. Please refresh the page.");
} else { return;
// Manual Crop Mode - Use cropper.js }
if (!cropper) { base64 = resultCanvas.toDataURL('image/jpeg');
alert("Cropper not initialized. Please try again.");
return;
}
// Get cropped canvas
var canvas = cropper.getCroppedCanvas({
width: originalImg.width,
height: originalImg.height,
fillColor: '#fff',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high'
});
base64 = canvas.toDataURL('image/jpeg');
}
if (window.handleScannerResult) { if (window.handleScannerResult) {
window.handleScannerResult(base64); window.handleScannerResult(base64);
@@ -819,14 +808,7 @@ window.addEventListener('load', function() {
$('#mobile-help').removeClass('d-none'); $('#mobile-help').removeClass('d-none');
} }
// Set initial mode based on device // Mode is always smart scan now
setTimeout(function() { }, 500); }); });
var initialMode = IS_TOUCH_DEVICE ? 'manual' : 'smart';
var button = $('#scannerModeToggle button[data-mode="' + initialMode + '"]');
if (button.length) {
button.click();
}
}, 100);
});
</script> </script>

View File

@@ -44,7 +44,7 @@
<link rel="stylesheet" href="plugins/select2/css/select2.min.css"> <link rel="stylesheet" href="plugins/select2/css/select2.min.css">
<link rel="stylesheet" href="plugins/select2-bootstrap4-theme/select2-bootstrap4.min.css"> <link rel="stylesheet" href="plugins/select2-bootstrap4-theme/select2-bootstrap4.min.css">
<!-- Cropper.js --> <!-- Cropper.js -->
<link href="plugins/vendor/cropperjs/css/cropper.min.css" rel="stylesheet">
<!-- Google Font: Source Sans Pro --> <!-- Google Font: Source Sans Pro -->
<link href="plugins/vendor/google-fonts/source-sans-pro/css/fonts-local.css" rel="stylesheet"> <link href="plugins/vendor/google-fonts/source-sans-pro/css/fonts-local.css" rel="stylesheet">
<!-- Modern CSS --> <!-- Modern CSS -->
@@ -730,10 +730,9 @@
<script src="plugins/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- Select2 --> <!-- Select2 -->
<script src="plugins/select2/js/select2.full.min.js"></script> <script src="plugins/select2/js/select2.full.min.js"></script>
<!-- Cropper.js -->
<script src="plugins/vendor/cropperjs/js/cropper.min.js"></script>
<!-- OpenCV.js (Required for jscanify) --> <!-- OpenCV.js (Required for jscanify) -->
<script src="plugins/vendor/opencv/opencv.js" async></script> <script src="plugins/vendor/opencv/opencv.js"></script>
<!-- jscanify --> <!-- jscanify -->
<script src="plugins/vendor/jscanify/jscanify.min.js"></script> <script src="plugins/vendor/jscanify/jscanify.min.js"></script>
<!-- DataTables --> <!-- DataTables -->

View File

@@ -1,9 +0,0 @@
/*!
* Cropper.js v1.5.13
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2022-11-20T05:30:43.444Z
*/.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{-webkit-backface-visibility:hidden;backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}

File diff suppressed because one or more lines are too long

View File

@@ -31,7 +31,8 @@ CREATE TABLE IF NOT EXISTS `tb_pdd` (
`kecamatan` varchar(50) DEFAULT NULL, `kecamatan` varchar(50) DEFAULT NULL,
`kabupaten` varchar(50) DEFAULT NULL, `kabupaten` varchar(50) DEFAULT NULL,
`provinsi` varchar(50) DEFAULT NULL, `provinsi` varchar(50) DEFAULT NULL,
`kewarganegaraan` varchar(20) DEFAULT 'WNI', `kewarganegaraan` varchar(20) DEFAULT 'WNI',
`phone` varchar(20) DEFAULT NULL,
PRIMARY KEY (`id_pend`), PRIMARY KEY (`id_pend`),
UNIQUE KEY `nik` (`nik`) UNIQUE KEY `nik` (`nik`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -31,7 +31,8 @@ CREATE TABLE IF NOT EXISTS `tb_pdd` (
`kecamatan` varchar(50) DEFAULT NULL, `kecamatan` varchar(50) DEFAULT NULL,
`kabupaten` varchar(50) DEFAULT NULL, `kabupaten` varchar(50) DEFAULT NULL,
`provinsi` varchar(50) DEFAULT NULL, `provinsi` varchar(50) DEFAULT NULL,
`kewarganegaraan` varchar(20) DEFAULT 'WNI', `kewarganegaraan` varchar(20) DEFAULT 'WNI',
`phone` varchar(20) DEFAULT NULL,
PRIMARY KEY (`id_pend`), PRIMARY KEY (`id_pend`),
UNIQUE KEY `nik` (`nik`) UNIQUE KEY `nik` (`nik`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;