commit d476c063581a31536b9cffedfdc4b8c96d88d60f Author: Wartana Date: Sat Feb 28 17:18:46 2026 +0800 feat: Implement a web-based Bluetooth label printer application with live preview and CPCL generation capabilities. diff --git a/app.js b/app.js new file mode 100644 index 0000000..719b8fb --- /dev/null +++ b/app.js @@ -0,0 +1,318 @@ +// DOM Elements +const connectBtn = document.getElementById('connectBtn'); +const printBtn = document.getElementById('printBtn'); +const statusText = document.getElementById('statusText'); +const statusDot = document.getElementById('statusDot'); +const labelForm = document.getElementById('labelForm'); +const labelType = document.getElementById('labelType'); + +// Inputs +const itemNameLabel = document.getElementById('itemNameLabel'); +const itemName = document.getElementById('itemName'); +const itemPriceLabel = document.getElementById('itemPriceLabel'); +const itemPrice = document.getElementById('itemPrice'); +const itemNotesLabel = document.getElementById('itemNotesLabel'); +const itemNotes = document.getElementById('itemNotes'); + +// Preview Elements +const previewName = document.getElementById('previewName'); +const previewPrice = document.getElementById('previewPrice'); +const previewNotes = document.getElementById('previewNotes'); + +// Bluetooth State +let printDevice = null; +let printCharacteristic = null; + +// Live Preview Updater +const labelPreviewContainer = document.getElementById('labelPreview'); + +const updatePreview = () => { + const isCable = labelType.value === 'cable'; + const repeats = isCable ? 3 : 1; + + labelPreviewContainer.innerHTML = ''; // Clear current + + // Toggle Labels & Placeholders + if (isCable) { + itemNameLabel.textContent = "Source / Near-End"; + itemName.placeholder = "e.g. SW-CORE-01 P12"; + itemPriceLabel.textContent = "Destination / Far-End"; + itemPrice.placeholder = "e.g. SRV-WEB ETH0"; + itemNotesLabel.textContent = "Cable Info (Optional)"; + itemNotes.placeholder = "e.g. CAT6 / VLAN 20"; + } else { + itemNameLabel.textContent = "Item Name"; + itemName.placeholder = "e.g. Premium Coffee Beans"; + itemPriceLabel.textContent = "Price"; + itemPrice.placeholder = "e.g. Rp 50.000"; + itemNotesLabel.textContent = "Additional Notes (Optional)"; + itemNotes.placeholder = "e.g. 500g, Exp: 24/12/26"; + } + + let blockHtml = ''; + + if (isCable) { + const nameVal = itemName.value || 'SW-CORE-01 P12'; + const priceVal = itemPrice.value || 'SRV-WEB ETH0'; + const notesVal = itemNotes.value || 'CAT6'; + + blockHtml = ` +
[SRC] ${nameVal.toUpperCase()}
+
[DST] ${priceVal.toUpperCase()}
+
(${notesVal})
+
--------------------
+ `; + } else { + const nameVal = itemName.value || 'ITEM NAME'; + const priceVal = itemPrice.value || 'Rp 0'; + const notesVal = itemNotes.value || 'Notes here'; + + blockHtml = ` +
LABEL STORE
+
${nameVal.toUpperCase()}
+
====================
+
${priceVal}
+
====================
+ `; + if (notesVal) { + blockHtml += `
${notesVal}
`; + } + } + + const blockDiv = document.createElement('div'); + blockDiv.innerHTML = blockHtml; + labelPreviewContainer.appendChild(blockDiv); +}; + +labelType.addEventListener('change', updatePreview); +itemName.addEventListener('input', updatePreview); +itemPrice.addEventListener('input', updatePreview); +itemNotes.addEventListener('input', updatePreview); + +// Handle BT Connection +connectBtn.addEventListener('click', async () => { + try { + if (!navigator.bluetooth) { + alert('Web Bluetooth API is not available on your browser. Please use Chrome on Android or Desktop.'); + return; + } + + connectBtn.textContent = 'Connecting...'; + + const device = await navigator.bluetooth.requestDevice({ + acceptAllDevices: true, + optionalServices: [ + '000018f0-0000-1000-8000-00805f9b34fb', // standard SPP + 'e7810a71-73ae-499d-8c15-faa9aef0c3f2', + '49535343-fe7d-4ae5-8fa9-9fafd205e455', // iOS serial / generic clone + '0000ff00-0000-1000-8000-00805f9b34fb' // generic custom + ] + }); + + printDevice = device; + device.addEventListener('gattserverdisconnected', onDisconnected); + + statusText.textContent = 'Pairing...'; + const server = await device.gatt.connect(); + + statusText.textContent = 'Finding Services...'; + const services = await server.getPrimaryServices(); + + // Find the characteristic that supports WRITE without response (or write) + let foundChar = null; + for (const service of services) { + const characteristics = await service.getCharacteristics(); + for (const char of characteristics) { + if (char.properties.write || char.properties.writeWithoutResponse) { + foundChar = char; + break; + } + } + if (foundChar) break; + } + + if (!foundChar) { + throw new Error('Writable characteristic not found on this device'); + } + + printCharacteristic = foundChar; + + // UI Update on Success + statusDot.className = 'dot connected'; + statusText.textContent = `Connected: ${device.name || 'Printer'}`; + connectBtn.style.display = 'none'; + printBtn.disabled = false; + + } catch (error) { + console.error('Connection Error:', error); + alert(`Connection Failed: ${error.message}`); + resetUI(); + } +}); + +function onDisconnected() { + console.log('Device Disconnected'); + printDevice = null; + printCharacteristic = null; + resetUI(); +} + +function resetUI() { + statusDot.className = 'dot disconnected'; + statusText.textContent = 'Disconnected'; + connectBtn.style.display = 'inline-flex'; + connectBtn.innerHTML = ' Connect to Printer'; + printBtn.disabled = true; +} + +// Convert string to bytes +function encodeToBytes(str) { + const arr = []; + for (let i = 0; i < str.length; i++) { + let code = str.charCodeAt(i); + if (code > 255) code = 63; // '?' + arr.push(code); + } + return arr; +} + +labelForm.addEventListener('submit', async (e) => { + e.preventDefault(); + console.log("Submit triggered. Characteristic:", printCharacteristic); + + if (!printCharacteristic) { + alert("Printer is not fully connected. Please ensure you click 'Connect to Printer' first and the indicator is Green (Connected)."); + return; + } + + printBtn.disabled = true; + printBtn.textContent = 'Printing...'; + + // Formatting the Print Job using CPCL (Comtec Printer Control Language) + // CPCL uses text based commands, ending with PRINT + + let cpcl = []; + + // Initialize CPCL label format + // Format: ! offset <200|x|y> + // 200 dpi, 200 dpi, height ~ 800 dots (100mm) + cpcl.push("! 0 200 200 800 1\r\n"); + + // PAGE-WIDTH 384 (48mm printable area on 200dpi is ~384 dots) + cpcl.push("PAGE-WIDTH 384\r\n"); + + let currentY = 20; + + const isCable = labelType.value === 'cable'; + + // Print Single Block Logic + const generateTextBlock = (startY) => { + let y = startY; + + if (isCable) { + // --- Source --- + cpcl.push(`TEXT 7 1 20 ${y} [SRC] ${itemName.value.toUpperCase()}\r\n`); + y += 50; + + // --- Destination --- + cpcl.push(`TEXT 7 1 20 ${y} [DST] ${itemPrice.value.toUpperCase()}\r\n`); + y += 50; + + // --- Cable Info --- + if (itemNotes.value) { + cpcl.push(`TEXT 7 0 20 ${y} (${itemNotes.value})\r\n`); + y += 50; + } + + // --- Divider --- + cpcl.push(`LINE 20 ${y} 360 ${y} 2\r\n`); + } else { + // --- Header --- + cpcl.push(`TEXT 7 0 20 ${y} ITEM NAME\r\n`); + y += 40; + cpcl.push(`TEXT 7 0 20 ${y} ${itemName.value.toUpperCase()}\r\n`); + y += 50; + + // --- Divider --- + cpcl.push(`LINE 20 ${y} 360 ${y} 2\r\n`); + y += 20; + + // --- Price --- + cpcl.push(`TEXT 7 1 20 ${y} ${itemPrice.value}\r\n`); + y += 60; + + // --- Divider --- + cpcl.push(`LINE 20 ${y} 360 ${y} 2\r\n`); + y += 20; + + // --- Notes --- + if (itemNotes.value) { + cpcl.push(`TEXT 7 0 20 ${y} ${itemNotes.value}\r\n`); + y += 40; + } + } + }; + + if (labelType.value === 'cable') { + // Cable Wrap Style: Print 1 block at the very top. + // The bottom 80% of the label will be blank because it overlaps/wraps around the cable. + generateTextBlock(20); + } else { + // Standard Label Style: Single block at top + generateTextBlock(20); + } + + // Finish rendering and print + cpcl.push("FORM\r\n"); + cpcl.push("PRINT\r\n"); + + // Combine to single string and encode + const printString = cpcl.join(""); + console.log("Sending CPCL Command:\n", printString); + const printData = encodeToBytes(printString); + + // Recursive Write Queue + const MAX_CHUNK = 20; // 20-byte MTU limit for maximum standard BLE compatibility + let chunks = []; + + for (let i = 0; i < printData.length; i += MAX_CHUNK) { + chunks.push(new Uint8Array(printData.slice(i, i + MAX_CHUNK))); + } + + const writeNextChunk = async () => { + if (chunks.length === 0) { + console.log("Print job completed successfully."); + printBtn.disabled = false; + printBtn.innerHTML = ' Print via Bluetooth'; + return; + } + + const chunk = chunks.shift(); + + try { + console.log("Writing chunk...", chunk.length, "bytes. Char props:", Object.keys(printCharacteristic.properties).filter(k => printCharacteristic.properties[k]).join(',')); + + // Prioritize writeWithoutResponse for cheap clones - they often stall on writeValue + if (printCharacteristic.properties && printCharacteristic.properties.writeWithoutResponse) { + await printCharacteristic.writeValueWithoutResponse(chunk); + } else if (printCharacteristic.properties && printCharacteristic.properties.write) { + await printCharacteristic.writeValue(chunk); + } else { + await printCharacteristic.writeValue(chunk); + } + + setTimeout(writeNextChunk, 100); // Wait 100ms before next 20 byte chunk to ensure buffer digests + } catch (error) { + console.error("Critical Write Error:", error); + alert(`Printing failed: ${error.message}`); + printBtn.disabled = false; + printBtn.innerHTML = ' Retry Print'; + } + }; + + // Kick off printing + writeNextChunk(); +}); + +// Initialize the preview on load +updatePreview(); diff --git a/btlabel.oncloud.my.id.conf b/btlabel.oncloud.my.id.conf new file mode 100644 index 0000000..4106613 --- /dev/null +++ b/btlabel.oncloud.my.id.conf @@ -0,0 +1,10 @@ +server { + listen 80; + server_name btlabel.oncloud.my.id; + + location / { + root /home/wartana/myApp/btlabel; + index index.html; + try_files $uri $uri/ =404; + } +} diff --git a/index.css b/index.css new file mode 100644 index 0000000..7666e78 --- /dev/null +++ b/index.css @@ -0,0 +1,313 @@ +:root { + --bg-color: #f0f4f8; + --card-bg: rgba(255, 255, 255, 0.85); + --primary: #4F46E5; + --primary-hover: #4338CA; + --text: #1F2937; + --text-muted: #6B7280; + --border: rgba(229, 231, 235, 0.5); + --success: #10B981; + --danger: #EF4444; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Inter', sans-serif; + color: var(--text); + background-color: var(--bg-color); + min-height: 100vh; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 40px 20px; + overflow-x: hidden; + overflow-y: auto; +} + +.app-background { + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle at 50% 50%, #e0c3fc 0%, #8ec5fc 100%); + z-index: -1; + animation: drift 20s ease-in-out infinite alternate; +} + +@keyframes drift { + 0% { + transform: translate(0, 0) scale(1); + } + + 100% { + transform: translate(-2%, 2%) scale(1.05); + } +} + +.container { + background: var(--card-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 24px; + padding: 32px; + width: 100%; + max-width: 420px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(40px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.app-header { + text-align: center; + margin-bottom: 24px; +} + +.icon-container { + background: linear-gradient(135deg, var(--primary), #8B5CF6); + width: 64px; + height: 64px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + color: white; + margin: 0 auto 16px; + box-shadow: 0 10px 15px -3px rgba(79, 70, 229, 0.3); +} + +h1 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 4px; +} + +.app-header p { + color: var(--text-muted); + font-size: 0.9rem; +} + +.device-section { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(255, 255, 255, 0.5); + padding: 16px; + border-radius: 12px; + margin-bottom: 24px; + border: 1px solid var(--border); +} + +.status-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + font-weight: 500; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.disconnected { + background-color: var(--danger); + box-shadow: 0 0 8px rgba(239, 68, 68, 0.5); +} + +.connected { + background-color: var(--success); + box-shadow: 0 0 8px rgba(16, 185, 129, 0.5); +} + +button { + cursor: pointer; + font-family: inherit; + border: none; + outline: none; + border-radius: 8px; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s ease; +} + +.primary-btn { + background: var(--primary); + color: white; + padding: 8px 16px; + font-size: 0.9rem; +} + +.primary-btn:hover { + background: var(--primary-hover); + transform: translateY(-1px); +} + +.action-btn { + width: 100%; + padding: 14px; + background: linear-gradient(135deg, var(--success), #059669); + color: white; + font-size: 1.1rem; + border-radius: 12px; + margin-top: 16px; + box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.3); +} + +.action-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(16, 185, 129, 0.4); +} + +.action-btn:disabled { + background: #D1D5DB; + cursor: not-allowed; + box-shadow: none; + transform: none; +} + +h2 { + font-size: 1.1rem; + margin-bottom: 16px; + border-bottom: 2px solid var(--primary); + padding-bottom: 8px; + display: inline-block; +} + +.form-group { + margin-bottom: 16px; +} + +.form-row { + display: flex; + gap: 16px; +} + +.form-row .form-group { + flex: 1; +} + +label { + display: block; + font-size: 0.85rem; + color: var(--text-muted); + margin-bottom: 6px; + font-weight: 500; +} + +input, +select { + width: 100%; + padding: 10px 12px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + font-family: inherit; + font-size: 1rem; + background: rgba(255, 255, 255, 0.8); + transition: all 0.2s; + appearance: none; + -webkit-appearance: none; +} + +select { + background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%231F2937%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E"); + background-repeat: no-repeat; + background-position: right 12px top 50%; + background-size: 12px auto; +} + +input:focus, +select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); + background: white; +} + +.preview-container { + background: #fff; + border: 1px dashed var(--primary); + border-radius: 8px; + padding: 16px; + margin-top: 24px; + margin-bottom: 16px; + text-align: center; +} + +.preview-container label { + margin-bottom: 12px; +} + +.label-preview { + /* Simulating 58mm printer aspect/width constraints (approx 384 dots width = 48 chars) */ + width: 250px; + margin: 0 auto; + font-family: monospace; + color: black; + text-align: center; + padding: 10px; + background-color: white; + border: 1px solid #eee; +} + +.preview-name { + font-weight: bold; + font-size: 1.2rem; + margin-bottom: 8px; + text-transform: uppercase; +} + +.preview-price { + font-size: 1.1rem; + margin-bottom: 8px; + font-weight: bold; +} + +.preview-notes { + font-size: 0.85rem; + color: #444; +} + +.brand-header { + font-size: 1.1rem; + font-weight: bold; + margin-bottom: 12px; +} + +.divider { + font-size: 0.8rem; + color: #000; + margin: 4px 0; + letter-spacing: -1px; +} + +footer { + text-align: center; + margin-top: 24px; + font-size: 0.8rem; + color: var(--text-muted); +} + +/* Base CSS End */ \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..11ef992 --- /dev/null +++ b/index.html @@ -0,0 +1,105 @@ + + + + + + + Bluetooth Label Printer App + + + + + + +
+ +
+
+
+ + + + + +
+

Smart Label Printer

+

58mm Thermal Bluetooth Print

+
+ +
+
+
+ Disconnected +
+ +
+ +
+

Design Label

+
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
LABEL STORE
+
Item Name
+
====================
+
Rp 0
+
====================
+
Notes here
+
+
+ + +
+
+ +
+

Built with Vanilla JS & Web Bluetooth API

+
+
+ + + + + \ No newline at end of file diff --git a/label.app.oncloud.my.id.conf b/label.app.oncloud.my.id.conf new file mode 100644 index 0000000..0ac9c78 --- /dev/null +++ b/label.app.oncloud.my.id.conf @@ -0,0 +1,13 @@ +server { + listen 80; + server_name label.app.oncloud.my.id; + root /home/wartana/myApp/label; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + # Enable CORS if needed + add_header Access-Control-Allow-Origin *; +}