feat: add extension implementation and docs
Add manifest.json, sidepanel components, and scripts. Include project assets and documentation files. Remove placeholder blank file.
This commit is contained in:
464
sidepanel/grid_ui.js
Normal file
464
sidepanel/grid_ui.js
Normal file
@@ -0,0 +1,464 @@
|
||||
|
||||
// Grid UI Logic
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Determine context
|
||||
const tabBtns = document.querySelectorAll('.tab-btn');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
// Remove active class from all
|
||||
tabBtns.forEach(b => b.classList.remove('active'));
|
||||
tabContents.forEach(c => c.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button and target content
|
||||
btn.classList.add('active');
|
||||
const targetId = `tab-${btn.dataset.tab}`;
|
||||
const targetContent = document.getElementById(targetId);
|
||||
if (targetContent) {
|
||||
targetContent.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
initializeGridUI();
|
||||
});
|
||||
|
||||
let gridImages = []; // Stores Image objects
|
||||
let currentTemplateId = 1;
|
||||
// Offsets for panning: map of slotIndex -> { x: float, y: float } (range -1.0 to 1.0)
|
||||
let gridOffsets = {};
|
||||
|
||||
function initializeGridUI() {
|
||||
const gridUploadInput = document.getElementById('gridImageInput');
|
||||
const gridDropZone = document.getElementById('gridDropZone');
|
||||
|
||||
if (!gridUploadInput) return; // Not in tab mode or elements missing
|
||||
|
||||
// File Upload Handlers
|
||||
gridDropZone.addEventListener('click', () => gridUploadInput.click());
|
||||
gridUploadInput.addEventListener('change', (e) => handleGridFiles(e.target.files));
|
||||
|
||||
gridDropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
gridDropZone.classList.add('drag-over');
|
||||
});
|
||||
gridDropZone.addEventListener('dragleave', () => gridDropZone.classList.remove('drag-over'));
|
||||
gridDropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
gridDropZone.classList.remove('drag-over');
|
||||
handleGridFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
// Template Selection
|
||||
renderTemplateOptions();
|
||||
|
||||
// Generate Button
|
||||
document.getElementById('gridGenerateBtn').addEventListener('click', generateGrid);
|
||||
|
||||
// Download Button
|
||||
document.getElementById('gridDownloadBtn').addEventListener('click', downloadGrid);
|
||||
|
||||
// Fake Number Controls
|
||||
const fakeNumberToggle = document.getElementById('fakeNumberToggle');
|
||||
const fakeNumberInput = document.getElementById('fakeNumberInput');
|
||||
const fakeNumberContainer = document.getElementById('fakeNumberInputContainer');
|
||||
|
||||
fakeNumberToggle.addEventListener('change', (e) => {
|
||||
fakeNumberContainer.style.display = e.target.checked ? 'block' : 'none';
|
||||
updateGridPreview();
|
||||
});
|
||||
|
||||
fakeNumberInput.addEventListener('input', () => {
|
||||
updateGridPreview();
|
||||
});
|
||||
|
||||
// Initialize Canvas Interaction
|
||||
initCanvasInteraction();
|
||||
}
|
||||
|
||||
function renderTemplateOptions() {
|
||||
const container = document.getElementById('gridTemplateList');
|
||||
const templates = TemplateManager.getTemplates();
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
templates.forEach(t => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'template-btn';
|
||||
if (t.id === currentTemplateId) btn.classList.add('active');
|
||||
|
||||
// Create Dynamic Preview
|
||||
const canvasW = t.canvasSize[0];
|
||||
const canvasH = t.canvasSize[1];
|
||||
|
||||
let slotsHtml = '';
|
||||
t.slots.forEach(slot => {
|
||||
const left = (slot.x / canvasW) * 100;
|
||||
const top = (slot.y / canvasH) * 100;
|
||||
const width = (slot.w / canvasW) * 100;
|
||||
const height = (slot.h / canvasH) * 100;
|
||||
|
||||
slotsHtml += `<div class="preview-slot" style="left: ${left}%; top: ${top}%; width: ${width}%; height: ${height}%;"></div>`;
|
||||
});
|
||||
|
||||
btn.innerHTML = `
|
||||
<div class="template-preview">
|
||||
${slotsHtml}
|
||||
</div>
|
||||
<span>${t.expectedCount} ภาพ</span>
|
||||
`;
|
||||
|
||||
btn.onclick = () => {
|
||||
document.querySelectorAll('.template-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentTemplateId = t.id;
|
||||
gridOffsets = {};
|
||||
updateGridPreview();
|
||||
};
|
||||
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function handleGridFiles(fileList) {
|
||||
if (!fileList.length) return;
|
||||
|
||||
// Load images
|
||||
Array.from(fileList).forEach(file => {
|
||||
if (!file.type.startsWith('image/')) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
gridImages.push(img);
|
||||
renderGridImagesList();
|
||||
updateGridPreview();
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Drag & Drop Reordering State
|
||||
let draggedItemIndex = null;
|
||||
|
||||
function renderGridImagesList() {
|
||||
const list = document.getElementById('gridImagesList');
|
||||
list.innerHTML = '';
|
||||
|
||||
gridImages.forEach((img, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'grid-thumb-item';
|
||||
div.draggable = true; // Enable drag
|
||||
|
||||
// Drag Events
|
||||
div.addEventListener('dragstart', (e) => {
|
||||
draggedItemIndex = index;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
div.style.opacity = '0.5';
|
||||
});
|
||||
|
||||
div.addEventListener('dragend', () => {
|
||||
div.style.opacity = '1';
|
||||
draggedItemIndex = null;
|
||||
document.querySelectorAll('.grid-thumb-item').forEach(item => item.classList.remove('drag-over-target'));
|
||||
});
|
||||
|
||||
div.addEventListener('dragover', (e) => {
|
||||
e.preventDefault(); // Allow drop
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (draggedItemIndex !== index) {
|
||||
div.classList.add('drag-over-target');
|
||||
}
|
||||
});
|
||||
|
||||
div.addEventListener('dragleave', () => {
|
||||
div.classList.remove('drag-over-target');
|
||||
});
|
||||
|
||||
div.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (draggedItemIndex !== null && draggedItemIndex !== index) {
|
||||
// Reorder array
|
||||
const movedItem = gridImages[draggedItemIndex];
|
||||
gridImages.splice(draggedItemIndex, 1);
|
||||
gridImages.splice(index, 0, movedItem);
|
||||
|
||||
// Also reorder offsets if we want to be fancy, but resetting is safer/simpler for now
|
||||
// or we could map them. Let's just reset offsets for simplicity as image moved slots
|
||||
gridOffsets = {};
|
||||
|
||||
renderGridImagesList();
|
||||
updateGridPreview();
|
||||
}
|
||||
});
|
||||
|
||||
const thumb = img.cloneNode();
|
||||
div.appendChild(thumb);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'remove-btn';
|
||||
removeBtn.innerHTML = '×';
|
||||
removeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
gridImages.splice(index, 1);
|
||||
gridOffsets = {};
|
||||
renderGridImagesList();
|
||||
updateGridPreview();
|
||||
};
|
||||
div.appendChild(removeBtn);
|
||||
|
||||
list.appendChild(div);
|
||||
});
|
||||
|
||||
// Update count hint
|
||||
const template = TemplateManager.getTemplateById(currentTemplateId);
|
||||
const countSpan = document.getElementById('gridImageCount');
|
||||
if (countSpan) countSpan.textContent = `${gridImages.length} / ${template.expectedCount}`;
|
||||
}
|
||||
|
||||
function updateGridPreview() {
|
||||
const previewContainer = document.getElementById('gridCanvasPreview');
|
||||
|
||||
// If no images, clear the preview
|
||||
if (gridImages.length === 0) {
|
||||
previewContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const template = TemplateManager.getTemplateById(currentTemplateId);
|
||||
|
||||
// Get fake number if enabled
|
||||
let fakeNumber = null;
|
||||
const fakeNumberToggle = document.getElementById('fakeNumberToggle');
|
||||
if (fakeNumberToggle && fakeNumberToggle.checked) {
|
||||
fakeNumber = document.getElementById('fakeNumberInput').value || "";
|
||||
}
|
||||
|
||||
// Pass the offsets and fakeNumber
|
||||
const canvas = GridGenerator.generate(gridImages, template, gridOffsets, fakeNumber);
|
||||
|
||||
previewContainer.innerHTML = '';
|
||||
|
||||
// Scale for display
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = 'auto';
|
||||
|
||||
// Add class for cursor interaction
|
||||
canvas.classList.add('interactive-canvas');
|
||||
|
||||
previewContainer.appendChild(canvas);
|
||||
}
|
||||
|
||||
// Canvas Interaction (Panning)
|
||||
let isDragging = false;
|
||||
let startX, startY;
|
||||
let activeSlotIndex = -1;
|
||||
let initialOffset = { x: 0, y: 0 };
|
||||
|
||||
function initCanvasInteraction() {
|
||||
const container = document.getElementById('gridCanvasPreview');
|
||||
|
||||
container.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('mousemove', onMouseMove); // Document level for smooth drag outside
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
function onMouseDown(e) {
|
||||
if (e.target.tagName !== 'CANVAS') return;
|
||||
|
||||
e.preventDefault();
|
||||
const canvas = e.target;
|
||||
// Get mouse pos relative to canvas
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
|
||||
const mouseX = (e.clientX - rect.left) * scaleX;
|
||||
const mouseY = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
// Find clicked slot
|
||||
const template = TemplateManager.getTemplateById(currentTemplateId);
|
||||
const count = Math.min(gridImages.length, template.slots.length);
|
||||
|
||||
activeSlotIndex = -1;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const slot = template.slots[i];
|
||||
// Simple hit test (ignoring borders for simplicity, or include them)
|
||||
if (mouseX >= slot.x && mouseX <= slot.x + slot.w &&
|
||||
mouseY >= slot.y && mouseY <= slot.y + slot.h) {
|
||||
activeSlotIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSlotIndex !== -1) {
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
initialOffset = { ... (gridOffsets[activeSlotIndex] || { x: 0, y: 0 }) };
|
||||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseMove(e) {
|
||||
if (!isDragging || activeSlotIndex === -1) return;
|
||||
e.preventDefault();
|
||||
|
||||
const canvas = document.querySelector('#gridCanvasPreview canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
// Calculate Delta
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
|
||||
// Convert delta to offset range (-1 to 1) manually?
|
||||
// This is tricky because the sensitivity depends on how much "extra" image there is.
|
||||
// If the image fits perfectly, maxOffset is 0, so panning does nothing (correct).
|
||||
// If we move 100px, how much logic offset is that?
|
||||
// We need to know the max pan capabilities of the current image in the current slot.
|
||||
// Ideally, we move pixel-for-pixel.
|
||||
|
||||
// Let's retrieve image and slot info
|
||||
const template = TemplateManager.getTemplateById(currentTemplateId);
|
||||
const slot = template.slots[activeSlotIndex];
|
||||
const img = gridImages[activeSlotIndex];
|
||||
if (!img || !slot) return;
|
||||
|
||||
const border = 5; // Hardcoded border width from generator
|
||||
const actualW = slot.w - (border * 2);
|
||||
const actualH = slot.h - (border * 2);
|
||||
const slotAspect = actualW / actualH;
|
||||
const imgAspect = img.naturalWidth / img.naturalHeight;
|
||||
|
||||
let maxOffsetX_px = 0;
|
||||
let maxOffsetY_px = 0;
|
||||
|
||||
// Calculate how the image is scaled in the slot
|
||||
// Logic duplicated from GridGenerator... ideally we refactor shared math but let's recompute.
|
||||
if (imgAspect > slotAspect) {
|
||||
// Image is wider - fit height
|
||||
// rendered height = actualH
|
||||
// rendered width = actualH * imgAspect
|
||||
const renderedW = actualH * imgAspect;
|
||||
maxOffsetX_px = (renderedW - actualW) / 2;
|
||||
} else {
|
||||
// Image is taller - fit width
|
||||
// rendered width = actualW
|
||||
// rendered height = actualW / imgAspect
|
||||
const renderedH = actualW / imgAspect;
|
||||
maxOffsetY_px = (renderedH - actualH) / 2;
|
||||
}
|
||||
|
||||
// Map pixels to -1..1 range
|
||||
// delta 100px means we want to shift offset.
|
||||
// current_pixel_offset = initial_pixel_offset + delta
|
||||
// new_normalized = current_pixel_offset / max_offset_px
|
||||
|
||||
// Since offset 1.0 = max_px, offset 0.0 = 0px
|
||||
// initial_pixel_val = initialOffset.x * maxOffsetX_px;
|
||||
// new_pixel_val = initial_pixel_val - deltaX (Mouse move right -> pan image right -> seeing left part?
|
||||
// Wait, usually panning: move mouse right = image moves right.
|
||||
// In our generator:
|
||||
// sourceX = center + (maxOffset * offset.x)
|
||||
// If offset.x is positive, sourceX increases -> we crop from the right -> image appears to move LEFT?
|
||||
// Let's verify:
|
||||
// Center = 500. MaxOffset = 100.
|
||||
// Offset 0 -> sourceX = 500.
|
||||
// Offset 1 -> sourceX = 600. Viewport shows 600+. Image shifted LEFT relative to frame.
|
||||
// So to move image RIGHT (mouse right), we need to DECREASE sourceX.
|
||||
// So we need to DECREASE offset.
|
||||
|
||||
// X Axis
|
||||
let newOffsetX = initialOffset.x;
|
||||
if (maxOffsetX_px > 0) {
|
||||
// Sensitivity factor? Pixel to pixel mapping
|
||||
// To move image by deltaX pixels visually:
|
||||
// shift sourceX by -deltaX * (sourceScale / destScale) ?
|
||||
// Actually we are mapping screen pixels to canvas pixels.
|
||||
// Canvas is scaled via CSS.
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const screenToCanvasRatio = canvas.width / rect.width;
|
||||
|
||||
// We want the image to move with the mouse pointer exactly.
|
||||
// The image is drawn at 'actualW' size (destination).
|
||||
// If we move mouse 10px right, we want image to shift 10px right.
|
||||
// Drawing: ctx.drawImage(img, srcX, ...)
|
||||
// If we change srcX by -10, the image drawn shifts right by 10 (if scale is 1).
|
||||
|
||||
// Calculate scale between Source Image and Destination Canvas Slot
|
||||
let scale = 1;
|
||||
if (imgAspect > slotAspect) {
|
||||
scale = img.naturalHeight / actualH; // Source / Dest
|
||||
} else {
|
||||
scale = img.naturalWidth / actualW;
|
||||
}
|
||||
|
||||
const moveX_src = -deltaX * screenToCanvasRatio * scale;
|
||||
const moveY_src = -deltaY * screenToCanvasRatio * scale;
|
||||
|
||||
// Convert src movement to offset change
|
||||
// offset = src_shift / max_offset
|
||||
if (maxOffsetX_px > 0) {
|
||||
// maxOffsetX_px is in Destination pixels... wait.
|
||||
// maxOffsetX defined above was: (renderedW - actualW) / 2
|
||||
// renderedW IS destination size.
|
||||
// So maxOffsetX_px is in Desintation pixels (Canvas coords).
|
||||
const moveX_dest = deltaX * screenToCanvasRatio;
|
||||
// Move right = positive delta.
|
||||
// We want image to move right = shift left side in view = sourceX decreases.
|
||||
// offset factor = move_dest / max_offset_dest
|
||||
|
||||
// BUT, verify direction again.
|
||||
// offset 1 => shift LEFT.
|
||||
// we want shift RIGHT => decrease offset.
|
||||
// So minus sign.
|
||||
newOffsetX = initialOffset.x - (moveX_dest / maxOffsetX_px);
|
||||
}
|
||||
}
|
||||
|
||||
// Y Axis
|
||||
let newOffsetY = initialOffset.y;
|
||||
if (maxOffsetY_px > 0) {
|
||||
const moveY_dest = deltaY * (canvas.height / canvas.getBoundingClientRect().height);
|
||||
newOffsetY = initialOffset.y - (moveY_dest / maxOffsetY_px);
|
||||
}
|
||||
|
||||
// Clamp
|
||||
newOffsetX = Math.max(-1, Math.min(1, newOffsetX));
|
||||
newOffsetY = Math.max(-1, Math.min(1, newOffsetY));
|
||||
|
||||
// Update State
|
||||
gridOffsets[activeSlotIndex] = { x: newOffsetX, y: newOffsetY };
|
||||
|
||||
// Redraw (Throttle?)
|
||||
// For now direct redraw
|
||||
updateGridPreview();
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging = false;
|
||||
activeSlotIndex = -1;
|
||||
const canvas = document.querySelector('#gridCanvasPreview canvas');
|
||||
if (canvas) canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
function generateGrid() {
|
||||
updateGridPreview(); // Force refresh
|
||||
}
|
||||
|
||||
function downloadGrid() {
|
||||
const previewContainer = document.getElementById('gridCanvasPreview');
|
||||
const canvas = previewContainer.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `autogrid-${Date.now()}.png`;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.click();
|
||||
}
|
||||
177
sidepanel/panel-compact.css
Normal file
177
sidepanel/panel-compact.css
Normal file
@@ -0,0 +1,177 @@
|
||||
/* Compact CSS - Overrides for smaller UI */
|
||||
|
||||
.container {
|
||||
padding: 10px 10px 250px 10px !important;
|
||||
/* Ensure bottom padding for fixed footer */
|
||||
}
|
||||
|
||||
/* ... existing styles ... */
|
||||
|
||||
/* Buttons */
|
||||
.action-buttons {
|
||||
gap: 8px !important;
|
||||
padding: 12px 16px !important;
|
||||
/* Adjust padding for compact fixed footer */
|
||||
background: rgba(15, 23, 42, 0.95) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border-top: 1px solid var(--border) !important;
|
||||
margin: 0 !important;
|
||||
/* Reset margin for fixed position */
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
position: fixed !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 12px 10px !important;
|
||||
margin-bottom: 10px !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px !important;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 10px !important;
|
||||
margin-bottom: 8px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 13px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
/* Grids */
|
||||
.style-grid,
|
||||
.ratio-grid,
|
||||
.mood-grid,
|
||||
.position-grid,
|
||||
.font-type-grid {
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.style-card {
|
||||
padding: 8px 6px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.style-icon {
|
||||
font-size: 24px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.style-name {
|
||||
font-size: 12px !important;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.style-desc {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
.ratio-btn,
|
||||
.position-btn,
|
||||
.font-type-btn,
|
||||
.image-position-btn {
|
||||
padding: 8px 4px !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
.ratio-btn span,
|
||||
.position-btn span,
|
||||
.font-type-btn span,
|
||||
.image-position-btn span {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.mood-btn {
|
||||
padding: 6px 4px !important;
|
||||
}
|
||||
|
||||
.mood-icon {
|
||||
font-size: 20px !important;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
.mood-name {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 12px !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.text-input,
|
||||
.custom-prompt {
|
||||
padding: 8px 10px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.headline-textarea {
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
.custom-prompt {
|
||||
min-height: 60px !important;
|
||||
}
|
||||
|
||||
.checkbox-style {
|
||||
padding: 6px 8px !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.checkbox-style span {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.checkbox-style input[type="checkbox"] {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
|
||||
.color-picker-wrapper {
|
||||
padding: 6px 8px !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.color-picker-wrapper input[type="color"] {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.color-label {
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.action-buttons {
|
||||
gap: 8px !important;
|
||||
margin-top: 10px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 14px !important;
|
||||
font-size: 13px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 8px 12px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
1138
sidepanel/panel.css
Normal file
1138
sidepanel/panel.css
Normal file
File diff suppressed because it is too large
Load Diff
314
sidepanel/panel.html
Normal file
314
sidepanel/panel.html
Normal file
@@ -0,0 +1,314 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="th">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Auto Cover Generator</title>
|
||||
<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=Sarabun:wght@300;400;600;700;800&family=Prompt:wght@400;600;700&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="panel.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<nav class="tabs">
|
||||
<button class="tab-btn active" data-tab="ai-cover">AI Cover (Gemini)</button>
|
||||
<button class="tab-btn" data-tab="grid-creator">Grid Creator</button>
|
||||
</nav>
|
||||
|
||||
<!-- TAB 1: AI Cover (Original Content Restored) -->
|
||||
<div id="tab-ai-cover" class="tab-content active">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-gradient"></div>
|
||||
<h1 class="title">🎨 Auto Cover Generator</h1>
|
||||
<p class="subtitle">สร้างหน้าปกสุดเจ๋งด้วย Gemini AI</p>
|
||||
</header>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<section class="section" style="padding-bottom: 10px;">
|
||||
<h2 class="section-title" style="margin-bottom: 10px;">⚙️ การตั้งค่า</h2>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="autoDownloadToggle">
|
||||
<span>ดาวน์โหลดรูปภาพอัตโนมัติ (Auto Download)</span>
|
||||
</label>
|
||||
<p style="font-size: 11px; color: var(--text-secondary); margin-left: 28px; margin-top: 4px;">
|
||||
เมื่อเปิดใช้งาน รูปที่ Gen เสร็จจะถูกดาวน์โหลดลงเครื่องทันที
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Image Upload Section -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">🖼️ เพิ่มรูปภาพ (สูงสุด 10 รูป)</h2>
|
||||
<div class="upload-area" id="dropZone">
|
||||
<div class="upload-placeholder" id="uploadPlaceholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
<p>คลิกหรือลากรูปมาวางที่นี่</p>
|
||||
<span class="upload-hint">รองรับ JPG, PNG (Max 10)</span>
|
||||
</div>
|
||||
<input type="file" id="imageInput" multiple accept="image/*" style="display: none;">
|
||||
<div class="image-preview-grid" id="imagePreviewGrid" style="display: none;"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Style Selector -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">🎭 เลือกสไตล์ปก</h2>
|
||||
<div class="style-grid">
|
||||
<button class="style-card active" data-style="breaking-news">
|
||||
<div class="style-icon">🚨</div>
|
||||
<div class="style-name">ข่าวด่วน</div>
|
||||
<div class="style-desc">Breaking News</div>
|
||||
</button>
|
||||
<button class="style-card" data-style="political">
|
||||
<div class="style-icon">🏛️</div>
|
||||
<div class="style-name">ข่าวการเมือง</div>
|
||||
<div class="style-desc">Political</div>
|
||||
</button>
|
||||
<button class="style-card" data-style="movie-poster">
|
||||
<div class="style-icon">🎬</div>
|
||||
<div class="style-name">โปสเตอร์ภาพยนต์</div>
|
||||
<div class="style-desc">Movie Poster</div>
|
||||
</button>
|
||||
<button class="style-card" data-style="drama">
|
||||
<div class="style-icon">🎭</div>
|
||||
<div class="style-name">ดราม่า</div>
|
||||
<div class="style-desc">Drama</div>
|
||||
</button>
|
||||
<button class="style-card" data-style="action">
|
||||
<div class="style-icon">💥</div>
|
||||
<div class="style-name">แอคชั่น</div>
|
||||
<div class="style-desc">Action</div>
|
||||
</button>
|
||||
<button class="style-card" data-style="original-image">
|
||||
<div class="style-icon">🖼️</div>
|
||||
<div class="style-name">ภาพต้นแบบเหมือนเดิม</div>
|
||||
<div class="style-desc">ไม่แก้ไขภาพ</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Aspect Ratio Selector -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">📐 ขนาดหน้าปก</h2>
|
||||
<div class="ratio-grid">
|
||||
<button class="ratio-btn" data-ratio="1:1">
|
||||
<div class="ratio-box square"></div>
|
||||
<span>1:1</span>
|
||||
</button>
|
||||
<button class="ratio-btn" data-ratio="4:5">
|
||||
<div class="ratio-box portrait-45"></div>
|
||||
<span>4:5</span>
|
||||
</button>
|
||||
<button class="ratio-btn" data-ratio="3:4">
|
||||
<div class="ratio-box portrait-34"></div>
|
||||
<span>3:4</span>
|
||||
</button>
|
||||
<button class="ratio-btn" data-ratio="4:3">
|
||||
<div class="ratio-box landscape-43"></div>
|
||||
<span>4:3</span>
|
||||
</button>
|
||||
<button class="ratio-btn" data-ratio="2:3">
|
||||
<div class="ratio-box portrait-23"></div>
|
||||
<span>2:3</span>
|
||||
</button>
|
||||
<button class="ratio-btn" data-ratio="9:16">
|
||||
<div class="ratio-box portrait-916"></div>
|
||||
<span>9:16</span>
|
||||
</button>
|
||||
<button class="ratio-btn active" data-ratio="16:9">
|
||||
<div class="ratio-box landscape-169"></div>
|
||||
<span>16:9</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Emotion/Mood Selector -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">😊 อารมณ์ของตัวแบบ</h2>
|
||||
<div class="emotion-grid">
|
||||
<button class="emotion-btn" data-emotion="none">🚫 ไม่ใส่</button>
|
||||
<button class="emotion-btn" data-emotion="happy">😊 ยิ้มแย้ม</button>
|
||||
<button class="emotion-btn active" data-emotion="serious">😐 จริงจัง</button>
|
||||
<button class="emotion-btn" data-emotion="sad">😢 เศร้า</button>
|
||||
<button class="emotion-btn" data-emotion="angry">😠 โกรธ</button>
|
||||
<button class="emotion-btn" data-emotion="surprised">😲 ตกใจ</button>
|
||||
<button class="emotion-btn" data-emotion="confident">😎 มั่นใจ</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Text Customization -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">✍️ ปรับแต่งตัวหนังสือ</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="includeText" checked>
|
||||
<span>ใส่ข้อความในหน้าปก</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="textOptions">
|
||||
<div class="form-group">
|
||||
<label for="headlineText">หัวข้อข่าว / ข้อความ</label>
|
||||
<textarea id="headlineText" placeholder="พิมพ์หัวข้อที่ต้องการ..." class="text-input" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>ตำแหน่งตัวหนังสือ</label>
|
||||
<div class="position-grid">
|
||||
<button class="position-btn" data-position="top-left">บนซ้าย</button>
|
||||
<button class="position-btn" data-position="top">บน</button>
|
||||
<button class="position-btn" data-position="top-right">บนขวา</button>
|
||||
<button class="position-btn" data-position="center-left">ซ้าย</button>
|
||||
<button class="position-btn active" data-position="center">กลาง</button>
|
||||
<button class="position-btn" data-position="center-right">ขวา</button>
|
||||
<button class="position-btn" data-position="bottom-left">ล่างซ้าย</button>
|
||||
<button class="position-btn" data-position="bottom">ล่าง</button>
|
||||
<button class="position-btn" data-position="bottom-right">ล่างขวา</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>สไตล์ฟอนต์ (เลือกได้มากกว่า 1)</label>
|
||||
<div class="font-style-grid" id="fontStyleGrid">
|
||||
<button class="font-style-btn" data-font-style="3d">3D</button>
|
||||
<button class="font-style-btn active" data-font-style="thai-news-loop">ข่าวไทยมีหัว</button>
|
||||
<button class="font-style-btn active" data-font-style="bold">ตัวหนา</button>
|
||||
<button class="font-style-btn" data-font-style="thin">ตัวบาง</button>
|
||||
<button class="font-style-btn" data-font-style="italic">ตัวเอียง</button>
|
||||
<button class="font-style-btn" data-font-style="thai-news">ฟอนต์ข่าวไทย</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Preset Management -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">💾 บันทึกการตั้งค่า (Presets)</h2>
|
||||
<div class="preset-controls">
|
||||
<input type="text" id="presetName" class="text-input" placeholder="ตั้งชื่อ Preset..."
|
||||
style="margin-bottom: 8px;">
|
||||
<button class="btn btn-secondary btn-full" id="savePresetBtn">บันทึกการตั้งค่า</button>
|
||||
</div>
|
||||
<div id="presetList" class="preset-list">
|
||||
<!-- Presets will be loaded here -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Custom Prompt -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">🎨 Prompt เพิ่มเติม (ถ้ามี)</h2>
|
||||
<textarea id="customPrompt" class="custom-prompt"
|
||||
placeholder="เพิ่มรายละเอียดเพิ่มเติมที่ต้องการ... เช่น: เพิ่มเอฟเฟกต์แสง, เปลี่ยนบรรยากาศ, ฯลฯ"
|
||||
rows="4"></textarea>
|
||||
</section>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary btn-full" id="generateBtn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M8 12h8"></path>
|
||||
<path d="M12 8v8"></path>
|
||||
</svg>
|
||||
สร้างหน้าปก
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- History Section -->
|
||||
<div class="history-section section">
|
||||
<div class="history-header">
|
||||
<h2 class="section-title">📜 ประวัติการสร้าง</h2>
|
||||
<button class="btn-small" id="exportHistoryBtn">ส่งออก Log</button>
|
||||
</div>
|
||||
<div class="history-stats">
|
||||
<span id="historyCount">ทั้งหมด: 0</span>
|
||||
</div>
|
||||
<div class="history-list" id="historyList">
|
||||
<p style="text-align: center; color: var(--text-secondary); padding: 20px;">ยังไม่มีประวัติ</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB 2: Grid Creator (Original Content Restored) -->
|
||||
<div id="tab-grid-creator" class="tab-content">
|
||||
<header class="header">
|
||||
<div class="header-gradient"></div>
|
||||
<h1 class="title">🧩 Grid Creator</h1>
|
||||
<p class="subtitle">รวมภาพหลายช่องในสไตล์คุณ</p>
|
||||
</header>
|
||||
|
||||
<section class="section">
|
||||
<div class="upload-area" id="gridDropZone">
|
||||
<div class="upload-placeholder">
|
||||
<p>+ เพิ่มรูปภาพ</p>
|
||||
<span class="upload-hint">ลาก หรือ คลิก</span>
|
||||
</div>
|
||||
<input type="file" id="gridImageInput" multiple accept="image/*" style="display: none;">
|
||||
</div>
|
||||
<div id="gridImagesList" class="grid-images-list"></div>
|
||||
<p class="section-status"><small id="gridImageCount">0 ภาพ</small></p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-title">Layout</h2>
|
||||
<div id="gridTemplateList" class="template-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- Fake Number Settings -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">🔢 Fake Number Overlay</h2>
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="fakeNumberToggle">
|
||||
<span>แสดงตัวเลขมุมขวาล่าง</span>
|
||||
</label>
|
||||
<div id="fakeNumberInputContainer" style="margin-top: 8px; display: none;">
|
||||
<input type="text" id="fakeNumberInput" class="text-input" value="+5" placeholder="e.g. +5">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="preview-section">
|
||||
<div id="gridCanvasPreview" class="canvas-preview-container">
|
||||
<!-- Canvas will be here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary btn-full" id="gridGenerateBtn">Generate Grid</button>
|
||||
<button class="btn btn-secondary btn-full" id="gridDownloadBtn" style="margin-top: 8px;">Download</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div> <!-- Container End -->
|
||||
|
||||
|
||||
<div class="status-message" id="statusMessage" style="display: none;"></div>
|
||||
|
||||
<script src="../scripts/grid_templates.js"></script>
|
||||
<script src="../scripts/grid_generator.js"></script>
|
||||
<script src="grid_ui.js"></script>
|
||||
|
||||
<script src="panel.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
778
sidepanel/panel.js
Normal file
778
sidepanel/panel.js
Normal file
@@ -0,0 +1,778 @@
|
||||
// State management
|
||||
const state = {
|
||||
selectedStyle: 'breaking-news',
|
||||
selectedRatio: '16:9',
|
||||
selectedEmotion: 'serious', // happy, serious, sad, angry, surprised, confident
|
||||
includeText: true,
|
||||
headlineText: '',
|
||||
fontStyles: ['thai-news', 'bold'], // Default multiple
|
||||
textPosition: 'center', // top, center, bottom, etc.
|
||||
customPrompt: '',
|
||||
selectedImages: [], // Array of { id, file, base64 }
|
||||
generatedImageUrl: null,
|
||||
generationHistory: [], // Array to store generation logs with timestamps
|
||||
presets: [], // Saved presets
|
||||
autoDownload: false // Auto download setting
|
||||
};
|
||||
|
||||
// Style templates
|
||||
const styleTemplates = {
|
||||
'breaking-news': {
|
||||
name: 'ข่าวด่วน',
|
||||
prompt: 'Breaking news cover design, urgent dramatic style, bold red theme, high impact typography, professional news broadcast aesthetic, dramatic lighting, sense of urgency'
|
||||
},
|
||||
'political': {
|
||||
name: 'ข่าวการเมือง',
|
||||
prompt: 'Political news cover, professional authoritative design, blue and white color scheme, formal typography, government/political aesthetic, serious tone, clean layout'
|
||||
},
|
||||
'movie-poster': {
|
||||
name: 'โปสเตอร์ภาพยนต์',
|
||||
prompt: 'Cinematic movie poster style, dramatic composition, theatrical lighting, bold title treatment, Hollywood blockbuster aesthetic, epic scale, professional film poster design'
|
||||
},
|
||||
'drama': {
|
||||
name: 'ดราม่า',
|
||||
prompt: 'Drama series poster, emotional atmosphere, rich warm colors, character-focused composition, TV drama aesthetic, compelling visual storytelling, dramatic mood'
|
||||
},
|
||||
'action': {
|
||||
name: 'แอคชั่น',
|
||||
prompt: 'Action movie poster, dynamic explosive composition, high energy, intense colors, motion and movement, adrenaline-pumping design, bold graphics, powerful visual impact'
|
||||
},
|
||||
'original-image': {
|
||||
name: 'ภาพต้นแบบเหมือนเดิม',
|
||||
prompt: 'Use the original uploaded image exactly as provided, preserve all original details, colors, composition, and quality. Do not modify, enhance, or alter the image in any way. Keep the image completely unchanged from the original'
|
||||
}
|
||||
};
|
||||
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
styleCards: document.querySelectorAll('.style-card'),
|
||||
ratioButtons: document.querySelectorAll('.ratio-btn'),
|
||||
emotionButtons: document.querySelectorAll('.emotion-btn'),
|
||||
includeTextCheckbox: document.getElementById('includeText'),
|
||||
textOptions: document.getElementById('textOptions'),
|
||||
headlineInput: document.getElementById('headlineText'),
|
||||
positionButtons: document.querySelectorAll('.position-btn'),
|
||||
fontStyleButtons: document.querySelectorAll('.font-style-btn'),
|
||||
customPromptInput: document.getElementById('customPrompt'),
|
||||
generateBtn: document.getElementById('generateBtn'),
|
||||
statusMessage: document.getElementById('statusMessage'),
|
||||
historyList: document.getElementById('historyList'),
|
||||
historyCount: document.getElementById('historyCount'),
|
||||
exportHistoryBtn: document.getElementById('exportHistoryBtn'),
|
||||
presetNameInput: document.getElementById('presetName'),
|
||||
savePresetBtn: document.getElementById('savePresetBtn'),
|
||||
presetList: document.getElementById('presetList'),
|
||||
// New Image Handling Elements
|
||||
dropZone: document.getElementById('dropZone'),
|
||||
imageInput: document.getElementById('imageInput'),
|
||||
imagePreviewGrid: document.getElementById('imagePreviewGrid'),
|
||||
uploadPlaceholder: document.getElementById('uploadPlaceholder'),
|
||||
autoDownloadToggle: document.getElementById('autoDownloadToggle')
|
||||
};
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
setupEventListeners();
|
||||
loadSavedState();
|
||||
updateUI();
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
function setupEventListeners() {
|
||||
// Style selection
|
||||
elements.styleCards.forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const style = card.dataset.style;
|
||||
selectStyle(style);
|
||||
});
|
||||
});
|
||||
|
||||
// Ratio selection
|
||||
elements.ratioButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const ratio = btn.dataset.ratio;
|
||||
selectRatio(ratio);
|
||||
});
|
||||
});
|
||||
|
||||
// Emotion selection
|
||||
elements.emotionButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const emotion = btn.dataset.emotion;
|
||||
selectEmotion(emotion);
|
||||
});
|
||||
});
|
||||
|
||||
// Text options
|
||||
elements.includeTextCheckbox.addEventListener('change', (e) => {
|
||||
state.includeText = e.target.checked;
|
||||
elements.textOptions.style.display = state.includeText ? 'block' : 'none';
|
||||
saveState();
|
||||
});
|
||||
|
||||
elements.headlineInput.addEventListener('input', (e) => {
|
||||
state.headlineText = e.target.value;
|
||||
saveState();
|
||||
});
|
||||
|
||||
// Position selection
|
||||
elements.positionButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const position = btn.dataset.position;
|
||||
selectPosition(position);
|
||||
});
|
||||
});
|
||||
|
||||
// Font style selection (Multi-select)
|
||||
elements.fontStyleButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const style = btn.dataset.fontStyle;
|
||||
const index = state.fontStyles.indexOf(style);
|
||||
|
||||
if (index === -1) {
|
||||
state.fontStyles.push(style);
|
||||
} else {
|
||||
state.fontStyles.splice(index, 1);
|
||||
}
|
||||
updateUI();
|
||||
saveState();
|
||||
});
|
||||
});
|
||||
|
||||
// Custom prompt
|
||||
elements.customPromptInput.addEventListener('input', (e) => {
|
||||
state.customPrompt = e.target.value;
|
||||
saveState();
|
||||
});
|
||||
|
||||
// Preset management
|
||||
elements.savePresetBtn.addEventListener('click', savePreset);
|
||||
|
||||
elements.generateBtn.addEventListener('click', generateCover);
|
||||
elements.exportHistoryBtn.addEventListener('click', exportHistory);
|
||||
|
||||
// Image Upload Handlers
|
||||
elements.dropZone.addEventListener('click', () => {
|
||||
elements.imageInput.click();
|
||||
});
|
||||
|
||||
elements.dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
elements.dropZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
elements.dropZone.addEventListener('dragleave', () => {
|
||||
elements.dropZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
elements.dropZone.addEventListener('drop', handleDrop);
|
||||
elements.imageInput.addEventListener('change', handleImageSelect);
|
||||
|
||||
// Auto Download Toggle
|
||||
if (elements.autoDownloadToggle) {
|
||||
elements.autoDownloadToggle.addEventListener('change', (e) => {
|
||||
state.autoDownload = e.target.checked;
|
||||
|
||||
// Save specific setting for content script to digest easily
|
||||
chrome.storage.local.set({ autoDownload: state.autoDownload });
|
||||
|
||||
// Also save full state
|
||||
saveState();
|
||||
|
||||
showStatus(state.autoDownload ? 'เปิดใช้งาน Auto Download' : 'ปิดใช้งาน Auto Download', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
// --- Tab Switching Logic (Centralized) ---
|
||||
const tabBtns = document.querySelectorAll('.tab-btn');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
// Remove active class from all
|
||||
tabBtns.forEach(b => b.classList.remove('active'));
|
||||
tabContents.forEach(c => c.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button and target content
|
||||
btn.classList.add('active');
|
||||
const targetId = `tab-${btn.dataset.tab}`;
|
||||
const targetContent = document.getElementById(targetId);
|
||||
if (targetContent) {
|
||||
targetContent.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Selection handlers
|
||||
function selectStyle(style) {
|
||||
state.selectedStyle = style;
|
||||
elements.styleCards.forEach(card => {
|
||||
card.classList.toggle('active', card.dataset.style === style);
|
||||
});
|
||||
saveState();
|
||||
}
|
||||
|
||||
function selectRatio(ratio) {
|
||||
state.selectedRatio = ratio;
|
||||
elements.ratioButtons.forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.ratio === ratio);
|
||||
});
|
||||
saveState();
|
||||
}
|
||||
|
||||
function selectEmotion(emotion) {
|
||||
state.selectedEmotion = emotion;
|
||||
elements.emotionButtons.forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.emotion === emotion);
|
||||
});
|
||||
saveState();
|
||||
}
|
||||
|
||||
// Helper to select position
|
||||
function selectPosition(position) {
|
||||
state.textPosition = position;
|
||||
elements.positionButtons.forEach(btn => {
|
||||
if (btn.dataset.position === position) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
saveState();
|
||||
}
|
||||
|
||||
// Prompt generation (Structured JSON)
|
||||
function generatePrompt() {
|
||||
// Base style template
|
||||
const styleTemplate = styleTemplates[state.selectedStyle];
|
||||
|
||||
// Map emotions to detailed descriptions
|
||||
const emotionMap = {
|
||||
'none': null,
|
||||
'happy': 'Happy, cheerful, smiling expressions, conveying positivity',
|
||||
'serious': 'Serious, professional, focused expressions, conveying authority',
|
||||
'sad': 'Sad, melancholic, emotional expressions, conveying deep feeling',
|
||||
'angry': 'Angry, intense, fierce expressions, conveying power and aggression',
|
||||
'surprised': 'Surprised, shocked, amazed expressions, conveying sudden impact',
|
||||
'confident': 'Confident, powerful, strong expressions, conveying leadership'
|
||||
};
|
||||
|
||||
// Map text positions to detailed specs
|
||||
const positionMap = {
|
||||
'top': { region: 'Top', description: 'Placed prominently at the top of the layout' },
|
||||
'center': { region: 'Center', description: 'Placed prominently in the center, potentially overlaying the subject with blending' },
|
||||
'bottom': { region: 'Bottom', description: 'Placed at the bottom, anchoring the layout' },
|
||||
'top-left': { region: 'Top-Left', description: 'Aligned to the top-left corner' },
|
||||
'top-right': { region: 'Top-Right', description: 'Aligned to the top-right corner' },
|
||||
'center-left': { region: 'Center-Left', description: 'Aligned to the middle-left side' },
|
||||
'center-right': { region: 'Center-Right', description: 'Aligned to the middle-right side' },
|
||||
'bottom-left': { region: 'Bottom-Left', description: 'Aligned to the bottom-left corner' },
|
||||
'bottom-right': { region: 'Bottom-Right', description: 'Aligned to the bottom-right corner' }
|
||||
};
|
||||
|
||||
// Map font styles
|
||||
const fontStyleMap = {
|
||||
'3d': '3D effect typography with depth and shadows',
|
||||
'thai-news-loop': 'Traditional Thai formal serif font with loops (Thai Chatuchak, TH Sarabun, or similar government/formal style)',
|
||||
'bold': 'Extra Bold, heavy weight for maximum impact',
|
||||
'thin': 'Thin, minimalist, elegant line width',
|
||||
'italic': 'Italicized, slanted for dynamic movement',
|
||||
'thai-news': 'Standard Modern Thai News font (sans-serif or slab-serif)',
|
||||
'serif': 'Classic Serif font, elegant and readable',
|
||||
'sans-serif': 'Modern Sans-serif font, clean and geometric'
|
||||
};
|
||||
|
||||
// Build the JSON object
|
||||
const promptJson = {
|
||||
action: "generate_cover_image",
|
||||
context: "Professional magazine/news cover generation",
|
||||
visual_style: {
|
||||
style_name: styleTemplate.name,
|
||||
artistic_description: styleTemplate.prompt,
|
||||
quality_standards: ["High Resolution", "Professional Photography", "Magazine Standard", "Visually Stunning"]
|
||||
},
|
||||
composition: {
|
||||
aspect_ratio: state.selectedRatio,
|
||||
framing: "Optimized for cover layout, leaving space for text where specified"
|
||||
},
|
||||
subject_elements: {
|
||||
emotion_mood: emotionMap[state.selectedEmotion] || "Neutral or context-appropriate",
|
||||
focus: "Clear subject focus with professional lighting"
|
||||
},
|
||||
text_overlay_specification: {
|
||||
enabled: state.includeText && !!state.headlineText.trim(),
|
||||
content: state.headlineText ? state.headlineText.trim() : null,
|
||||
placement: state.includeText ? positionMap[state.textPosition] : null,
|
||||
orientation: "Strictly Horizontal (0 degrees). Text must be perfectly straight, no tilting, no perspective slant.",
|
||||
typography: {
|
||||
font_styles: state.fontStyles.map(s => fontStyleMap[s]).filter(Boolean),
|
||||
readability: "Must be highly legible against the background",
|
||||
integration: "Text should be seamlessly integrated but MUST remain perfectly horizontal."
|
||||
}
|
||||
},
|
||||
additional_instructions: state.customPrompt.trim() || null
|
||||
};
|
||||
|
||||
// Wrap in strict instructions
|
||||
const instruction = `
|
||||
You are an expert AI Art Director and Image Generator. Your task is to generate a cover image based EXACTLY on the following JSON specification.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Analyze the JSON object below.
|
||||
2. Generate an image that visually represents all parameters in 'visual_style', 'composition', and 'subject_elements'.
|
||||
3. IF 'text_overlay_specification.enabled' is true, you MUST attempt to render the text provided in 'content' clearly and legibly at the specified 'placement'.
|
||||
4. STRICTLY follow the 'typography.font_styles'. If 'Traditional Thai formal serif font with loops' is requested, ensure the Thai characters have the correct terminal loops and traditional structure.
|
||||
5. The 'additional_instructions' field (if present) overrides standard style settings if there is a conflict.
|
||||
|
||||
JSON SPECIFICATION:
|
||||
\`\`\`json
|
||||
${JSON.stringify(promptJson, null, 2)}
|
||||
\`\`\`
|
||||
`.trim();
|
||||
|
||||
return instruction;
|
||||
}
|
||||
|
||||
// Image Handling Functions
|
||||
async function handleImageSelect(e) {
|
||||
const files = Array.from(e.target.files);
|
||||
await processFiles(files);
|
||||
// Reset input
|
||||
e.target.value = '';
|
||||
}
|
||||
|
||||
async function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
elements.dropZone.classList.remove('drag-over');
|
||||
const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/'));
|
||||
await processFiles(files);
|
||||
}
|
||||
|
||||
async function processFiles(files) {
|
||||
const remainingSlots = 10 - state.selectedImages.length;
|
||||
if (remainingSlots <= 0) {
|
||||
showStatus('ครบ 10 รูปแล้ว ไม่สามารถเพิ่มได้อีก', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToProcess = files.slice(0, remainingSlots);
|
||||
|
||||
if (files.length > remainingSlots) {
|
||||
showStatus(`เพิ่มได้อีกเพียง ${remainingSlots} รูป`, 'info');
|
||||
}
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
try {
|
||||
const base64 = await readFileAsBase64(file);
|
||||
state.selectedImages.push({
|
||||
id: Date.now() + Math.random(),
|
||||
file: file,
|
||||
base64: base64
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateImageUI();
|
||||
}
|
||||
|
||||
function readFileAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function removeImage(id) {
|
||||
state.selectedImages = state.selectedImages.filter(img => img.id !== id);
|
||||
updateImageUI();
|
||||
}
|
||||
|
||||
function updateImageUI() {
|
||||
const hasImages = state.selectedImages.length > 0;
|
||||
|
||||
elements.uploadPlaceholder.style.display = hasImages ? 'none' : 'block';
|
||||
elements.imagePreviewGrid.style.display = hasImages ? 'grid' : 'none';
|
||||
|
||||
elements.imagePreviewGrid.innerHTML = state.selectedImages.map(img => `
|
||||
<div class="image-preview-item">
|
||||
<img src="${img.base64}" alt="Preview">
|
||||
<button class="remove-btn">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Re-attach event listeners for remove buttons (since inline onclick with CustomEvent is tricky in extensions)
|
||||
// Better way:
|
||||
const removeBtns = elements.imagePreviewGrid.querySelectorAll('.remove-btn');
|
||||
removeBtns.forEach((btn, index) => {
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation(); // Prevent triggering dropZone click
|
||||
removeImage(state.selectedImages[index].id);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Generate cover
|
||||
async function generateCover() {
|
||||
|
||||
const prompt = generatePrompt();
|
||||
|
||||
// Log generation attempt
|
||||
logGeneration(prompt);
|
||||
|
||||
// Show loading
|
||||
elements.generateBtn.classList.add('loading');
|
||||
elements.generateBtn.disabled = true;
|
||||
showStatus('กำลังสร้างหน้าปก...', 'info');
|
||||
|
||||
// Send to content script
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
action: 'generateCover',
|
||||
prompt: prompt,
|
||||
ratio: state.selectedRatio,
|
||||
images: state.selectedImages.map(img => img.base64) // Send array of base64 images
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
showStatus('กำลังรอ Gemini สร้างรูปภาพ...', 'info');
|
||||
} else {
|
||||
throw new Error(response.error || 'Failed to send to Gemini');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showStatus('เกิดข้อผิดพลาด: ' + error.message, 'error');
|
||||
elements.generateBtn.classList.remove('loading');
|
||||
elements.generateBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Download cover
|
||||
function downloadCover() {
|
||||
if (!state.generatedImageUrl) {
|
||||
showStatus('ยังไม่มีรูปภาพให้ดาวน์โหลด', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().getTime();
|
||||
const filename = `cover_${state.selectedStyle}_${state.selectedRatio.replace(':', 'x')}_${timestamp}.png`;
|
||||
|
||||
chrome.downloads.download({
|
||||
url: state.generatedImageUrl,
|
||||
filename: filename,
|
||||
saveAs: false
|
||||
}, (downloadId) => {
|
||||
if (downloadId) {
|
||||
showStatus('ดาวน์โหลดสำเร็จ!', 'success');
|
||||
} else {
|
||||
showStatus('เกิดข้อผิดพลาดในการดาวน์โหลด', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Log generation with timestamp
|
||||
function logGeneration(prompt) {
|
||||
const now = new Date();
|
||||
const logEntry = {
|
||||
timestamp: now.toISOString(),
|
||||
date: now.toLocaleDateString('th-TH', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}),
|
||||
settings: {
|
||||
style: state.selectedStyle,
|
||||
ratio: state.selectedRatio,
|
||||
emotion: state.selectedEmotion,
|
||||
fontStyles: state.fontStyles,
|
||||
textPosition: state.textPosition,
|
||||
headline: state.headlineText
|
||||
},
|
||||
prompt: prompt
|
||||
};
|
||||
|
||||
// Add to history (keep last 50 entries)
|
||||
state.generationHistory.unshift(logEntry);
|
||||
if (state.generationHistory.length > 50) {
|
||||
state.generationHistory = state.generationHistory.slice(0, 50);
|
||||
}
|
||||
|
||||
saveState();
|
||||
console.log('Generation logged:', logEntry);
|
||||
|
||||
// Update history display
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
// Presets Management
|
||||
function savePreset() {
|
||||
const name = elements.presetNameInput.value.trim();
|
||||
if (!name) {
|
||||
showStatus('กรุณาตั้งชื่อ Preset', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newPreset = {
|
||||
id: Date.now(),
|
||||
name: name,
|
||||
settings: {
|
||||
selectedStyle: state.selectedStyle,
|
||||
selectedRatio: state.selectedRatio,
|
||||
selectedEmotion: state.selectedEmotion,
|
||||
includeText: state.includeText,
|
||||
headlineText: state.headlineText,
|
||||
fontStyles: [...state.fontStyles],
|
||||
textPosition: state.textPosition,
|
||||
customPrompt: state.customPrompt
|
||||
}
|
||||
};
|
||||
|
||||
state.presets = state.presets || [];
|
||||
state.presets.push(newPreset);
|
||||
saveState();
|
||||
|
||||
elements.presetNameInput.value = '';
|
||||
renderPresets();
|
||||
showStatus('บันทึก Preset เรียบร้อย', 'success');
|
||||
}
|
||||
|
||||
function loadPreset(id) {
|
||||
const preset = state.presets.find(p => p.id === id);
|
||||
if (preset) {
|
||||
Object.assign(state, preset.settings);
|
||||
updateUI();
|
||||
saveState();
|
||||
showStatus(`โหลด Preset "${preset.name}" เรียบร้อย`, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
function deletePreset(id) {
|
||||
state.presets = state.presets.filter(p => p.id !== id);
|
||||
saveState();
|
||||
renderPresets();
|
||||
showStatus('ลบ Preset เรียบร้อย', 'info');
|
||||
}
|
||||
|
||||
function renderPresets() {
|
||||
const presets = state.presets || [];
|
||||
|
||||
if (presets.length === 0) {
|
||||
elements.presetList.innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 10px;">ยังไม่มี Preset ที่บันทึกไว้</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.presetList.innerHTML = '';
|
||||
presets.forEach(preset => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'preset-item';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = preset.name;
|
||||
nameSpan.className = 'preset-name';
|
||||
nameSpan.addEventListener('click', () => loadPreset(preset.id));
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.innerHTML = '🗑️';
|
||||
deleteBtn.className = 'preset-delete-btn';
|
||||
deleteBtn.title = 'ลบ Preset';
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
deletePreset(preset.id);
|
||||
});
|
||||
|
||||
item.appendChild(nameSpan);
|
||||
item.appendChild(deleteBtn);
|
||||
elements.presetList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// Message listener for content script responses
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
// Check if AI Cover tab is active
|
||||
const aiCoverTab = document.getElementById('tab-ai-cover');
|
||||
if (!aiCoverTab || !aiCoverTab.classList.contains('active')) {
|
||||
return; // Ignore messages if not on AI Cover tab
|
||||
}
|
||||
|
||||
if (message.action === 'imageGenerated') {
|
||||
state.generatedImageUrl = message.imageUrl;
|
||||
|
||||
// Update last log entry with success status
|
||||
if (state.generationHistory.length > 0) {
|
||||
state.generationHistory[0].success = true;
|
||||
state.generationHistory[0].imageUrl = message.imageUrl;
|
||||
saveState();
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
elements.generateBtn.classList.remove('loading');
|
||||
elements.generateBtn.disabled = false;
|
||||
showStatus('สร้างหน้าปกสำเร็จ!', 'success');
|
||||
} else if (message.action === 'generationError') {
|
||||
// Update last log entry with error status
|
||||
if (state.generationHistory.length > 0) {
|
||||
state.generationHistory[0].success = false;
|
||||
state.generationHistory[0].error = message.error;
|
||||
saveState();
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
elements.generateBtn.classList.remove('loading');
|
||||
elements.generateBtn.disabled = false;
|
||||
showStatus('เกิดข้อผิดพลาด: ' + message.error, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Status message
|
||||
function showStatus(message, type = 'info') {
|
||||
elements.statusMessage.textContent = message;
|
||||
elements.statusMessage.className = `status-message ${type}`;
|
||||
elements.statusMessage.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
elements.statusMessage.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// State persistence
|
||||
function saveState() {
|
||||
chrome.storage.local.set({ coverGeneratorState: state });
|
||||
}
|
||||
|
||||
function loadSavedState() {
|
||||
chrome.storage.local.get('coverGeneratorState', (result) => {
|
||||
if (result.coverGeneratorState) {
|
||||
// Ensure presets array exists
|
||||
if (!result.coverGeneratorState.presets) {
|
||||
result.coverGeneratorState.presets = [];
|
||||
}
|
||||
Object.assign(state, result.coverGeneratorState);
|
||||
updateUI();
|
||||
renderPresets(); // Render presets after loading
|
||||
}
|
||||
|
||||
// Check independent key as fallback or source of truth
|
||||
chrome.storage.local.get('autoDownload', (res) => {
|
||||
if (res.autoDownload !== undefined) {
|
||||
state.autoDownload = res.autoDownload;
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
// Set active style
|
||||
selectStyle(state.selectedStyle);
|
||||
|
||||
// Set active ratio
|
||||
selectRatio(state.selectedRatio);
|
||||
|
||||
// Set active emotion
|
||||
selectEmotion(state.selectedEmotion);
|
||||
|
||||
// Set active position
|
||||
selectPosition(state.textPosition);
|
||||
|
||||
// Set text options
|
||||
elements.includeTextCheckbox.checked = state.includeText;
|
||||
elements.textOptions.style.display = state.includeText ? 'block' : 'none';
|
||||
elements.headlineInput.value = state.headlineText;
|
||||
|
||||
// Set font styles (Multi-select)
|
||||
elements.fontStyleButtons.forEach(btn => {
|
||||
const style = btn.dataset.fontStyle;
|
||||
if (state.fontStyles.includes(style)) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Set custom prompt
|
||||
elements.customPromptInput.value = state.customPrompt;
|
||||
|
||||
// Render history
|
||||
renderHistory();
|
||||
|
||||
// Update image preview grid
|
||||
updateImageUI();
|
||||
|
||||
// Set Auto Download Toggle
|
||||
if (elements.autoDownloadToggle) {
|
||||
elements.autoDownloadToggle.checked = state.autoDownload;
|
||||
}
|
||||
}
|
||||
|
||||
// Render history list
|
||||
function renderHistory() {
|
||||
const history = state.generationHistory || [];
|
||||
|
||||
// Update stats
|
||||
// Update stats
|
||||
const successCount = history.filter(h => h.success === true).length;
|
||||
elements.historyCount.textContent = `ทั้งหมด: ${history.length}`;
|
||||
|
||||
// Render list
|
||||
if (history.length === 0) {
|
||||
elements.historyList.innerHTML = '<p style="text-align: center; color: var(--text-secondary); padding: 20px;">ยังไม่มีประวัติ</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.historyList.innerHTML = history.map(entry => {
|
||||
const statusClass = entry.success === true ? 'success' : entry.success === false ? 'error' : '';
|
||||
const statusIcon = entry.success === true ? '✅' : entry.success === false ? '❌' : '⏳';
|
||||
|
||||
return `
|
||||
<div class="history-item ${statusClass}">
|
||||
<div class="history-date">${statusIcon} ${entry.date}</div>
|
||||
<div class="history-headline">${entry.settings.headline || '(ไม่มีข้อความ)'}</div>
|
||||
<div class="history-settings">
|
||||
<span>📐 ${entry.settings.ratio}</span>
|
||||
<span>🎭 ${entry.settings.style}</span>
|
||||
<span>😊 ${entry.settings.emotion}</span>
|
||||
<span>🔤 ${Array.isArray(entry.settings.fontStyles) ? entry.settings.fontStyles.join(', ') : entry.settings.fontStyle}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Export history to JSON file
|
||||
function exportHistory() {
|
||||
const history = state.generationHistory || [];
|
||||
|
||||
if (history.length === 0) {
|
||||
showStatus('ไม่มีประวัติให้ส่งออก', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const dataStr = JSON.stringify(history, null, 2);
|
||||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const now = new Date();
|
||||
const filename = `cover-generation-log-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}.json`;
|
||||
|
||||
chrome.downloads.download({
|
||||
url: url,
|
||||
filename: filename,
|
||||
saveAs: true
|
||||
}, (downloadId) => {
|
||||
if (downloadId) {
|
||||
showStatus('ส่งออก Log สำเร็จ!', 'success');
|
||||
} else {
|
||||
showStatus('เกิดข้อผิดพลาดในการส่งออก', 'error');
|
||||
}
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
init();
|
||||
64
sidepanel/warning.html
Normal file
64
sidepanel/warning.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="th">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Warning</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: 500;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: 20px;
|
||||
padding: 10px 20px;
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
background-color: #357abd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="icon">⚠️</div>
|
||||
<h2>ใช้งานไม่ได้ในหน้านี้</h2>
|
||||
<p>Extension นี้ใช้งานได้เฉพาะบน<br>gemini.google.com เท่านั้น</p>
|
||||
<a href="https://gemini.google.com/" target="_blank" class="link">ไปที่ Gemini</a>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user