Initial commit: Aplikasi Foto Dokumentasi Nasabah Koperasi
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Build files
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
195
README.md
Normal file
195
README.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 📸 Aplikasi Dokumentasi Nasabah Koperasi
|
||||
|
||||
Aplikasi web untuk mengambil foto dokumentasi nasabah saat penandatanganan surat perjanjian menggunakan webcam USB.
|
||||
|
||||
## ✨ Fitur Utama
|
||||
|
||||
### 1. Pengambilan Foto dengan Webcam
|
||||
- ✅ Akses webcam USB secara otomatis
|
||||
- ✅ Live preview sebelum mengambil foto
|
||||
- ✅ Preview hasil foto sebelum menyimpan
|
||||
- ✅ Opsi untuk mengambil ulang foto
|
||||
|
||||
### 2. Input Data Nasabah
|
||||
- ✅ Nama lengkap nasabah
|
||||
- ✅ Nomor anggota koperasi
|
||||
- ✅ Jenis perjanjian/dokumen
|
||||
- ✅ Tanggal penandatanganan (auto-fill hari ini)
|
||||
- ✅ Catatan tambahan (opsional)
|
||||
|
||||
### 3. Penyimpanan Data
|
||||
- ✅ Menggunakan IndexedDB (database lokal browser)
|
||||
- ✅ Foto tersimpan dalam format base64
|
||||
- ✅ Data tersimpan secara permanen di browser
|
||||
- ✅ Kapasitas besar (ratusan foto)
|
||||
|
||||
### 4. Galeri & Pencarian
|
||||
- ✅ Tampilan grid semua dokumentasi
|
||||
- ✅ Pencarian berdasarkan nama/nomor anggota
|
||||
- ✅ Thumbnail foto dengan info singkat
|
||||
- ✅ Sorting otomatis (terbaru di atas)
|
||||
|
||||
### 5. Detail & Pencetakan
|
||||
- ✅ Lihat detail lengkap dokumentasi
|
||||
- ✅ Cetak dokumentasi (format A4)
|
||||
- ✅ Hapus dokumentasi yang tidak diperlukan
|
||||
|
||||
### 6. Export Data
|
||||
- ✅ Export data ke file CSV
|
||||
- ✅ Berisi semua informasi kecuali foto
|
||||
- ✅ Untuk keperluan backup atau analisis
|
||||
|
||||
## 🚀 Cara Menggunakan
|
||||
|
||||
### Instalasi
|
||||
|
||||
1. **Download atau Clone Repository**
|
||||
```bash
|
||||
# Download semua file ke folder C:\MyApp\webcam
|
||||
```
|
||||
|
||||
2. **Buka Aplikasi**
|
||||
- Buka file `index.html` menggunakan browser modern (Chrome, Edge, atau Firefox)
|
||||
- Atau double-click file `index.html`
|
||||
|
||||
### Penggunaan
|
||||
|
||||
1. **Mengambil Foto Dokumentasi**
|
||||
- Isi form data nasabah (nama, nomor anggota, jenis perjanjian, tanggal)
|
||||
- Klik tombol "Aktifkan Webcam"
|
||||
- Izinkan akses webcam saat browser meminta
|
||||
- Posisikan nasabah di depan webcam
|
||||
- Klik "Ambil Foto"
|
||||
- Preview hasil foto
|
||||
- Jika sudah sesuai, klik "Simpan Dokumentasi"
|
||||
- Jika ingin ambil ulang, klik "Ambil Ulang"
|
||||
|
||||
2. **Melihat Galeri**
|
||||
- Scroll ke bawah untuk melihat galeri dokumentasi
|
||||
- Gunakan kotak pencarian untuk mencari nama/nomor anggota
|
||||
- Klik pada card dokumentasi untuk melihat detail
|
||||
|
||||
3. **Mencetak Dokumentasi**
|
||||
- Klik card dokumentasi yang ingin dicetak
|
||||
- Klik tombol "Cetak Dokumentasi"
|
||||
- Pilih printer atau "Save as PDF"
|
||||
|
||||
4. **Export Data**
|
||||
- Klik tombol "Export Data" di galeri
|
||||
- File CSV akan otomatis terdownload
|
||||
- Buka dengan Excel atau Google Sheets
|
||||
|
||||
## 🔧 Persyaratan Sistem
|
||||
|
||||
- **Browser Modern:**
|
||||
- Google Chrome 60+ (Direkomendasikan)
|
||||
- Microsoft Edge 79+
|
||||
- Mozilla Firefox 60+
|
||||
|
||||
- **Webcam USB:**
|
||||
- Webcam USB yang terhubung dan berfungsi
|
||||
- Driver webcam sudah terinstal
|
||||
|
||||
- **Koneksi Internet:**
|
||||
- Hanya diperlukan untuk load font (Google Fonts)
|
||||
- Aplikasi tetap berfungsi offline setelah font ter-cache
|
||||
|
||||
## 💾 Penyimpanan Data
|
||||
|
||||
### IndexedDB
|
||||
- Data disimpan di IndexedDB browser
|
||||
- Lokasi penyimpanan: Browser's Application Data
|
||||
- Data bersifat permanen sampai dihapus manual
|
||||
- Foto tersimpan dalam format base64 (JPEG dengan kualitas 90%)
|
||||
|
||||
### Kapasitas
|
||||
- Chrome/Edge: Hingga 60% dari disk space yang tersedia
|
||||
- Firefox: Hingga 10% dari free disk space
|
||||
- Estimasi: Bisa menyimpan ratusan hingga ribuan foto
|
||||
|
||||
### Backup Data
|
||||
- **Metode 1:** Export ke CSV (tanpa foto)
|
||||
- **Metode 2:** Browser backup
|
||||
- Chrome: `chrome://settings/cookies` > Site Data > klik domain
|
||||
- Export IndexedDB menggunakan Developer Tools
|
||||
|
||||
## 🎨 Kustomisasi
|
||||
|
||||
### Mengubah Logo Koperasi
|
||||
Edit file `style.css` dan tambahkan logo di header:
|
||||
```css
|
||||
.app-header::before {
|
||||
content: url('logo-koperasi.png');
|
||||
}
|
||||
```
|
||||
|
||||
### Menambah Jenis Perjanjian
|
||||
Edit file `index.html` di bagian `<select id="jenisPerjanjian">`:
|
||||
```html
|
||||
<option value="Jenis Baru">Jenis Perjanjian Baru</option>
|
||||
```
|
||||
|
||||
### Mengubah Warna Tema
|
||||
Edit file `style.css` di bagian `:root`:
|
||||
```css
|
||||
:root {
|
||||
--primary-color: #2563eb; /* Ubah ke warna yang diinginkan */
|
||||
}
|
||||
```
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
### Webcam tidak terdeteksi
|
||||
- ✅ Pastikan webcam terhubung dengan baik
|
||||
- ✅ Cek apakah driver webcam sudah terinstal
|
||||
- ✅ Pastikan tidak ada aplikasi lain yang menggunakan webcam
|
||||
- ✅ Berikan izin akses webcam saat browser meminta
|
||||
- ✅ Coba restart browser
|
||||
|
||||
### Data hilang
|
||||
- ✅ Jangan clear browser data/cache
|
||||
- ✅ Gunakan browser yang sama untuk akses data
|
||||
- ✅ Backup data secara berkala dengan export CSV
|
||||
|
||||
### Foto tidak jelas
|
||||
- ✅ Pastikan pencahayaan cukup
|
||||
- ✅ Gunakan webcam dengan resolusi tinggi
|
||||
- ✅ Bersihkan lensa webcam
|
||||
|
||||
### Tidak bisa print
|
||||
- ✅ Pastikan browser sudah update
|
||||
- ✅ Coba "Print to PDF" terlebih dahulu
|
||||
- ✅ Cek printer sudah terhubung
|
||||
|
||||
## 📱 Kompatibilitas
|
||||
|
||||
### Desktop Browsers
|
||||
- ✅ Chrome 60+ (Windows, macOS, Linux)
|
||||
- ✅ Edge 79+ (Windows, macOS)
|
||||
- ✅ Firefox 60+ (Windows, macOS, Linux)
|
||||
- ⚠️ Safari 11+ (terbatas, webcam mungkin bermasalah)
|
||||
|
||||
### Mobile Browsers
|
||||
- ⚠️ Terbatas - Webcam USB tidak didukung di mobile
|
||||
- ✅ Bisa menggunakan kamera built-in device
|
||||
|
||||
## 🔒 Keamanan & Privasi
|
||||
|
||||
- ✅ Semua data tersimpan lokal di komputer
|
||||
- ✅ Tidak ada data yang dikirim ke server
|
||||
- ✅ Tidak memerlukan koneksi internet (kecuali font)
|
||||
- ✅ Akses webcam hanya saat aplikasi digunakan
|
||||
- ⚠️ Jangan gunakan di komputer publik/shared
|
||||
|
||||
## 📄 Lisensi
|
||||
|
||||
Aplikasi ini bebas digunakan untuk keperluan internal Koperasi.
|
||||
|
||||
## 🤝 Dukungan
|
||||
|
||||
Untuk pertanyaan atau masalah, silakan hubungi IT Support Koperasi.
|
||||
|
||||
---
|
||||
|
||||
**Versi:** 1.0.0
|
||||
**Terakhir diupdate:** Januari 2026
|
||||
649
app.js
Normal file
649
app.js
Normal file
@@ -0,0 +1,649 @@
|
||||
// ============================================
|
||||
// APLIKASI DOKUMENTASI NASABAH KOPERASI
|
||||
// ============================================
|
||||
|
||||
// Global Variables
|
||||
let stream = null;
|
||||
let capturedImage = null;
|
||||
let db = null;
|
||||
let currentDetailId = null;
|
||||
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
// Webcam
|
||||
webcam: document.getElementById('webcam'),
|
||||
canvas: document.getElementById('canvas'),
|
||||
preview: document.getElementById('preview'),
|
||||
previewImage: document.getElementById('previewImage'),
|
||||
|
||||
// Buttons
|
||||
startBtn: document.getElementById('startBtn'),
|
||||
captureBtn: document.getElementById('captureBtn'),
|
||||
retakeBtn: document.getElementById('retakeBtn'),
|
||||
saveBtn: document.getElementById('saveBtn'),
|
||||
printBtn: document.getElementById('printBtn'),
|
||||
deleteBtn: document.getElementById('deleteBtn'),
|
||||
exportBtn: document.getElementById('exportBtn'),
|
||||
|
||||
// Form
|
||||
form: document.getElementById('nasabahForm'),
|
||||
nama: document.getElementById('nama'),
|
||||
noAnggota: document.getElementById('noAnggota'),
|
||||
jenisPerjanjian: document.getElementById('jenisPerjanjian'),
|
||||
tanggal: document.getElementById('tanggal'),
|
||||
catatan: document.getElementById('catatan'),
|
||||
|
||||
// Gallery
|
||||
gallery: document.getElementById('gallery'),
|
||||
emptyState: document.getElementById('emptyState'),
|
||||
searchInput: document.getElementById('searchInput'),
|
||||
galleryStats: document.getElementById('galleryStats'),
|
||||
|
||||
// Modal
|
||||
modal: document.getElementById('detailModal'),
|
||||
modalBody: document.getElementById('modalBody'),
|
||||
closeModal: document.querySelector('.close-modal'),
|
||||
|
||||
// Status
|
||||
webcamStatus: document.getElementById('webcamStatus'),
|
||||
toast: document.getElementById('toast')
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// INDEXEDDB SETUP
|
||||
// ============================================
|
||||
|
||||
function initDatabase() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('KoperasiDB', 1);
|
||||
|
||||
request.onerror = () => {
|
||||
showToast('Gagal membuka database', 'error');
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
db = request.result;
|
||||
console.log('Database berhasil dibuka');
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
db = event.target.result;
|
||||
|
||||
if (!db.objectStoreNames.contains('dokumentasi')) {
|
||||
const objectStore = db.createObjectStore('dokumentasi', {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true
|
||||
});
|
||||
|
||||
// Create indexes for searching
|
||||
objectStore.createIndex('nama', 'nama', { unique: false });
|
||||
objectStore.createIndex('noAnggota', 'noAnggota', { unique: false });
|
||||
objectStore.createIndex('tanggal', 'tanggal', { unique: false });
|
||||
objectStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
|
||||
console.log('Object store "dokumentasi" berhasil dibuat');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DATABASE OPERATIONS
|
||||
// ============================================
|
||||
|
||||
function saveToDatabase(data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['dokumentasi'], 'readwrite');
|
||||
const objectStore = transaction.objectStore('dokumentasi');
|
||||
|
||||
const request = objectStore.add(data);
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('Data berhasil disimpan dengan ID:', request.result);
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Gagal menyimpan data:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getAllData() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['dokumentasi'], 'readonly');
|
||||
const objectStore = transaction.objectStore('dokumentasi');
|
||||
const request = objectStore.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getDataById(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['dokumentasi'], 'readonly');
|
||||
const objectStore = transaction.objectStore('dokumentasi');
|
||||
const request = objectStore.get(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function deleteData(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['dokumentasi'], 'readwrite');
|
||||
const objectStore = transaction.objectStore('dokumentasi');
|
||||
const request = objectStore.delete(id);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WEBCAM FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
async function startWebcam() {
|
||||
try {
|
||||
showStatus('Meminta akses webcam...', 'info');
|
||||
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user'
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
|
||||
elements.webcam.srcObject = stream;
|
||||
|
||||
elements.startBtn.style.display = 'none';
|
||||
elements.captureBtn.style.display = 'inline-flex';
|
||||
|
||||
showStatus('Webcam aktif. Posisikan nasabah dan klik "Ambil Foto"', 'success');
|
||||
showToast('✅ Webcam berhasil diaktifkan', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error accessing webcam:', error);
|
||||
showStatus('Gagal mengakses webcam. Pastikan webcam terhubung dan izin diberikan.', 'error');
|
||||
showToast('❌ Gagal mengakses webcam', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function capturePhoto() {
|
||||
const canvas = elements.canvas;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
// Set canvas size to match video
|
||||
canvas.width = elements.webcam.videoWidth;
|
||||
canvas.height = elements.webcam.videoHeight;
|
||||
|
||||
// Draw video frame to canvas
|
||||
context.drawImage(elements.webcam, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Get image data
|
||||
capturedImage = canvas.toDataURL('image/jpeg', 0.9);
|
||||
|
||||
// Show preview
|
||||
elements.previewImage.src = capturedImage;
|
||||
elements.webcam.style.display = 'none';
|
||||
elements.preview.style.display = 'block';
|
||||
|
||||
// Update buttons
|
||||
elements.captureBtn.style.display = 'none';
|
||||
elements.retakeBtn.style.display = 'inline-flex';
|
||||
elements.saveBtn.style.display = 'inline-flex';
|
||||
|
||||
showStatus('Foto berhasil diambil. Periksa hasilnya dan klik "Simpan" jika sudah sesuai.', 'success');
|
||||
showToast('📸 Foto berhasil diambil', 'success');
|
||||
}
|
||||
|
||||
function retakePhoto() {
|
||||
capturedImage = null;
|
||||
|
||||
elements.webcam.style.display = 'block';
|
||||
elements.preview.style.display = 'none';
|
||||
|
||||
elements.retakeBtn.style.display = 'none';
|
||||
elements.saveBtn.style.display = 'none';
|
||||
elements.captureBtn.style.display = 'inline-flex';
|
||||
|
||||
showStatus('Webcam aktif. Ambil foto ulang.', 'info');
|
||||
}
|
||||
|
||||
function stopWebcam() {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
|
||||
elements.webcam.srcObject = null;
|
||||
elements.webcam.style.display = 'block';
|
||||
elements.preview.style.display = 'none';
|
||||
|
||||
elements.startBtn.style.display = 'inline-flex';
|
||||
elements.captureBtn.style.display = 'none';
|
||||
elements.retakeBtn.style.display = 'none';
|
||||
elements.saveBtn.style.display = 'none';
|
||||
|
||||
capturedImage = null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FORM FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
async function saveDocumentation() {
|
||||
// Validate form
|
||||
if (!elements.form.checkValidity()) {
|
||||
elements.form.reportValidity();
|
||||
showToast('⚠️ Lengkapi semua data yang wajib diisi', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate photo
|
||||
if (!capturedImage) {
|
||||
showToast('⚠️ Ambil foto terlebih dahulu', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data
|
||||
const data = {
|
||||
nama: elements.nama.value.trim(),
|
||||
noAnggota: elements.noAnggota.value.trim(),
|
||||
jenisPerjanjian: elements.jenisPerjanjian.value,
|
||||
tanggal: elements.tanggal.value,
|
||||
catatan: elements.catatan.value.trim(),
|
||||
foto: capturedImage,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
await saveToDatabase(data);
|
||||
|
||||
showToast('✅ Dokumentasi berhasil disimpan', 'success');
|
||||
|
||||
// Reset form and webcam
|
||||
elements.form.reset();
|
||||
setDefaultDate();
|
||||
stopWebcam();
|
||||
showStatus('', '');
|
||||
|
||||
// Reload gallery
|
||||
await loadGallery();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving documentation:', error);
|
||||
showToast('❌ Gagal menyimpan dokumentasi', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function setDefaultDate() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
elements.tanggal.value = today;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GALLERY FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
async function loadGallery(searchTerm = '') {
|
||||
try {
|
||||
const allData = await getAllData();
|
||||
|
||||
// Filter by search term
|
||||
let filteredData = allData;
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filteredData = allData.filter(item =>
|
||||
item.nama.toLowerCase().includes(term) ||
|
||||
item.noAnggota.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
filteredData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
// Update stats
|
||||
updateGalleryStats(filteredData.length, allData.length);
|
||||
|
||||
// Show empty state if no data
|
||||
if (filteredData.length === 0) {
|
||||
elements.gallery.innerHTML = '';
|
||||
elements.emptyState.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.emptyState.style.display = 'none';
|
||||
|
||||
// Render gallery items
|
||||
elements.gallery.innerHTML = filteredData.map(item => `
|
||||
<div class="gallery-item" data-id="${item.id}" onclick="showDetail(${item.id})">
|
||||
<img src="${item.foto}" alt="${item.nama}" class="gallery-item-image">
|
||||
<div class="gallery-item-info">
|
||||
<h3>${escapeHtml(item.nama)}</h3>
|
||||
<p><strong>No. Anggota:</strong> ${escapeHtml(item.noAnggota)}</p>
|
||||
<p><strong>Perjanjian:</strong> ${escapeHtml(item.jenisPerjanjian)}</p>
|
||||
<p class="gallery-item-date">📅 ${formatDate(item.tanggal)} • 🕐 ${formatTime(item.timestamp)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading gallery:', error);
|
||||
showToast('❌ Gagal memuat galeri', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateGalleryStats(shown, total) {
|
||||
if (total === 0) {
|
||||
elements.galleryStats.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let statsText = `📊 Total: ${total} dokumentasi`;
|
||||
if (shown < total) {
|
||||
statsText += ` • Ditampilkan: ${shown}`;
|
||||
}
|
||||
|
||||
elements.galleryStats.innerHTML = statsText;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DETAIL & PRINT FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
async function showDetail(id) {
|
||||
try {
|
||||
const data = await getDataById(id);
|
||||
if (!data) {
|
||||
showToast('❌ Data tidak ditemukan', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentDetailId = id;
|
||||
|
||||
elements.modalBody.innerHTML = `
|
||||
<div class="detail-container">
|
||||
<div>
|
||||
<img src="${data.foto}" alt="${escapeHtml(data.nama)}" class="detail-image">
|
||||
</div>
|
||||
<div class="detail-info">
|
||||
<h2>📋 Detail Dokumentasi</h2>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Nama Lengkap</div>
|
||||
<div class="detail-value">${escapeHtml(data.nama)}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Nomor Anggota</div>
|
||||
<div class="detail-value">${escapeHtml(data.noAnggota)}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Jenis Perjanjian</div>
|
||||
<div class="detail-value">${escapeHtml(data.jenisPerjanjian)}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Tanggal Penandatanganan</div>
|
||||
<div class="detail-value">${formatDate(data.tanggal)}</div>
|
||||
</div>
|
||||
|
||||
${data.catatan ? `
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Catatan</div>
|
||||
<div class="detail-value">${escapeHtml(data.catatan)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">Waktu Pengambilan Foto</div>
|
||||
<div class="detail-value">${formatDateTime(data.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
elements.modal.classList.add('show');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing detail:', error);
|
||||
showToast('❌ Gagal memuat detail', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
elements.modal.classList.remove('show');
|
||||
currentDetailId = null;
|
||||
}
|
||||
|
||||
function printDetail() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
async function deleteDetail() {
|
||||
if (!currentDetailId) return;
|
||||
|
||||
if (!confirm('Apakah Anda yakin ingin menghapus dokumentasi ini?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteData(currentDetailId);
|
||||
showToast('✅ Dokumentasi berhasil dihapus', 'success');
|
||||
closeModal();
|
||||
await loadGallery();
|
||||
} catch (error) {
|
||||
console.error('Error deleting data:', error);
|
||||
showToast('❌ Gagal menghapus dokumentasi', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EXPORT FUNCTION
|
||||
// ============================================
|
||||
|
||||
async function exportData() {
|
||||
try {
|
||||
const allData = await getAllData();
|
||||
|
||||
if (allData.length === 0) {
|
||||
showToast('⚠️ Tidak ada data untuk diekspor', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create CSV data (without photos for file size)
|
||||
const csvData = convertToCSV(allData);
|
||||
|
||||
// Download CSV
|
||||
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `dokumentasi_nasabah_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showToast('✅ Data berhasil diekspor', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting data:', error);
|
||||
showToast('❌ Gagal mengekspor data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function convertToCSV(data) {
|
||||
const headers = ['ID', 'Nama', 'No. Anggota', 'Jenis Perjanjian', 'Tanggal', 'Catatan', 'Waktu Input'];
|
||||
const rows = data.map(item => [
|
||||
item.id,
|
||||
item.nama,
|
||||
item.noAnggota,
|
||||
item.jenisPerjanjian,
|
||||
formatDate(item.tanggal),
|
||||
item.catatan || '',
|
||||
formatDateTime(item.timestamp)
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
return csvContent;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITY FUNCTIONS
|
||||
// ============================================
|
||||
|
||||
function showStatus(message, type) {
|
||||
if (!message) {
|
||||
elements.webcamStatus.innerHTML = '';
|
||||
elements.webcamStatus.className = 'status-message';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.webcamStatus.innerHTML = message;
|
||||
elements.webcamStatus.className = `status-message ${type}`;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
elements.toast.textContent = message;
|
||||
elements.toast.className = `toast ${type} show`;
|
||||
|
||||
setTimeout(() => {
|
||||
elements.toast.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||||
return new Date(dateString).toLocaleDateString('id-ID', options);
|
||||
}
|
||||
|
||||
function formatTime(isoString) {
|
||||
return new Date(isoString).toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(isoString) {
|
||||
const date = new Date(isoString);
|
||||
return `${date.toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})} pukul ${date.toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EVENT LISTENERS
|
||||
// ============================================
|
||||
|
||||
function setupEventListeners() {
|
||||
// Webcam controls
|
||||
elements.startBtn.addEventListener('click', startWebcam);
|
||||
elements.captureBtn.addEventListener('click', capturePhoto);
|
||||
elements.retakeBtn.addEventListener('click', retakePhoto);
|
||||
elements.saveBtn.addEventListener('click', saveDocumentation);
|
||||
|
||||
// Modal
|
||||
elements.closeModal.addEventListener('click', closeModal);
|
||||
elements.modal.addEventListener('click', (e) => {
|
||||
if (e.target === elements.modal) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
elements.printBtn.addEventListener('click', printDetail);
|
||||
elements.deleteBtn.addEventListener('click', deleteDetail);
|
||||
|
||||
// Search
|
||||
elements.searchInput.addEventListener('input', (e) => {
|
||||
loadGallery(e.target.value);
|
||||
});
|
||||
|
||||
// Export
|
||||
elements.exportBtn.addEventListener('click', exportData);
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INITIALIZATION
|
||||
// ============================================
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
// Initialize database
|
||||
await initDatabase();
|
||||
|
||||
// Set default date
|
||||
setDefaultDate();
|
||||
|
||||
// Setup event listeners
|
||||
setupEventListeners();
|
||||
|
||||
// Load gallery
|
||||
await loadGallery();
|
||||
|
||||
console.log('✅ Aplikasi berhasil diinisialisasi');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing app:', error);
|
||||
showToast('❌ Gagal menginisialisasi aplikasi', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Start the app when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Make showDetail function globally accessible
|
||||
window.showDetail = showDetail;
|
||||
146
index.html
Normal file
146
index.html
Normal file
@@ -0,0 +1,146 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dokumentasi Nasabah Koperasi</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<h1>📸 Dokumentasi Nasabah Koperasi</h1>
|
||||
<p>Sistem Pengambilan Foto Penandatanganan Perjanjian</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Section 1: Form & Webcam -->
|
||||
<div class="capture-section">
|
||||
<div class="card form-card">
|
||||
<h2>📋 Data Nasabah</h2>
|
||||
<form id="nasabahForm">
|
||||
<div class="form-group">
|
||||
<label for="nama">Nama Lengkap <span class="required">*</span></label>
|
||||
<input type="text" id="nama" name="nama" required placeholder="Masukkan nama lengkap nasabah">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="noAnggota">Nomor Anggota <span class="required">*</span></label>
|
||||
<input type="text" id="noAnggota" name="noAnggota" required placeholder="Contoh: KOP-2026-001">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="jenisPerjanjian">Jenis Perjanjian <span class="required">*</span></label>
|
||||
<select id="jenisPerjanjian" name="jenisPerjanjian" required>
|
||||
<option value="">-- Pilih Jenis Perjanjian --</option>
|
||||
<option value="Pinjaman Tunai">Pinjaman Tunai</option>
|
||||
<option value="Simpan Pinjam">Simpan Pinjam</option>
|
||||
<option value="Kredit Usaha">Kredit Usaha</option>
|
||||
<option value="Tabungan">Tabungan</option>
|
||||
<option value="Deposito">Deposito</option>
|
||||
<option value="Anggota Baru">Pendaftaran Anggota Baru</option>
|
||||
<option value="Lainnya">Lainnya</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tanggal">Tanggal Penandatanganan <span class="required">*</span></label>
|
||||
<input type="date" id="tanggal" name="tanggal" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="catatan">Catatan Tambahan</label>
|
||||
<textarea id="catatan" name="catatan" rows="3" placeholder="Catatan atau keterangan tambahan (opsional)"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card webcam-card">
|
||||
<h2>📷 Webcam</h2>
|
||||
<div class="webcam-container">
|
||||
<video id="webcam" autoplay playsinline></video>
|
||||
<canvas id="canvas" style="display: none;"></canvas>
|
||||
<div id="preview" class="preview-container" style="display: none;">
|
||||
<img id="previewImage" alt="Preview">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="webcam-controls">
|
||||
<button id="startBtn" class="btn btn-primary">
|
||||
<span class="btn-icon">🎥</span> Aktifkan Webcam
|
||||
</button>
|
||||
<button id="captureBtn" class="btn btn-success" style="display: none;">
|
||||
<span class="btn-icon">📸</span> Ambil Foto
|
||||
</button>
|
||||
<button id="retakeBtn" class="btn btn-warning" style="display: none;">
|
||||
<span class="btn-icon">🔄</span> Ambil Ulang
|
||||
</button>
|
||||
<button id="saveBtn" class="btn btn-primary" style="display: none;">
|
||||
<span class="btn-icon">💾</span> Simpan Dokumentasi
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="webcamStatus" class="status-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Gallery -->
|
||||
<div class="gallery-section">
|
||||
<div class="card gallery-card">
|
||||
<div class="gallery-header">
|
||||
<h2>🗂️ Galeri Dokumentasi</h2>
|
||||
<div class="gallery-controls">
|
||||
<input type="text" id="searchInput" placeholder="🔍 Cari nama atau nomor anggota...">
|
||||
<button id="exportBtn" class="btn btn-secondary">
|
||||
<span class="btn-icon">📥</span> Export Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="galleryStats" class="gallery-stats"></div>
|
||||
|
||||
<div id="gallery" class="gallery-grid">
|
||||
<!-- Gallery items will be inserted here -->
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<h3>Belum Ada Dokumentasi</h3>
|
||||
<p>Mulai dengan mengambil foto dokumentasi nasabah di atas</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Modal for Detail & Print -->
|
||||
<div id="detailModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close-modal">×</span>
|
||||
<div id="modalBody" class="modal-body">
|
||||
<!-- Detail content will be inserted here -->
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="printBtn" class="btn btn-primary">
|
||||
<span class="btn-icon">🖨️</span> Cetak Dokumentasi
|
||||
</button>
|
||||
<button id="deleteBtn" class="btn btn-danger">
|
||||
<span class="btn-icon">🗑️</span> Hapus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification Toast -->
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
650
style.css
Normal file
650
style.css
Normal file
@@ -0,0 +1,650 @@
|
||||
/* Reset & Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Color Palette - Professional Blue Theme */
|
||||
--primary-color: #2563eb;
|
||||
--primary-dark: #1e40af;
|
||||
--primary-light: #3b82f6;
|
||||
--secondary-color: #64748b;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--danger-color: #ef4444;
|
||||
|
||||
/* Neutral Colors */
|
||||
--bg-color: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
|
||||
/* Gradients */
|
||||
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-blue: linear-gradient(135deg, #2563eb 0%, #1e40af 100%);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.5rem;
|
||||
--spacing-sm: 0.75rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.9) 100%);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
background: var(--gradient-blue);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Capture Section */
|
||||
.capture-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Form Styles */
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Webcam Styles */
|
||||
.webcam-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
background: #000;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
#webcam,
|
||||
.preview-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.webcam-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
margin-top: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-message.info {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.status-message.success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
/* Gallery Section */
|
||||
.gallery-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.gallery-controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#searchInput {
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
min-width: 300px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#searchInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.gallery-stats {
|
||||
background: var(--bg-color);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.gallery-item-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
object-fit: cover;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.gallery-item-info {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.gallery-item-info h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.gallery-item-info p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.gallery-item-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl) 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(5px);
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius-xl);
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.close-modal {
|
||||
position: absolute;
|
||||
right: var(--spacing-lg);
|
||||
top: var(--spacing-lg);
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.close-modal:hover {
|
||||
color: var(--text-primary);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
border-top: 2px solid var(--border-color);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Detail View */
|
||||
.detail-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
width: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.detail-info h2 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 1rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Toast Notification */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: var(--spacing-xl);
|
||||
right: var(--spacing-xl);
|
||||
background: var(--text-primary);
|
||||
color: white;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
z-index: 2000;
|
||||
animation: slideInRight 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
background: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-header,
|
||||
.webcam-controls,
|
||||
.gallery-section,
|
||||
.modal-actions,
|
||||
.close-modal {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: block !important;
|
||||
position: static;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-image {
|
||||
max-width: 100%;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: none;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 1024px) {
|
||||
.capture-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.gallery-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#searchInput {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.webcam-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user