feat: Implement multi-sheet management, enhance AI command parsing with detailed hierarchy and format extraction rules, and add activity logging.

This commit is contained in:
DJP 2025-11-19 21:59:18 -05:00
parent 27284570ed
commit 0b363dbfa8
12 changed files with 997 additions and 64 deletions

96
api.php
View file

@ -4,6 +4,15 @@ require_once 'config.php';
require_once 'sheet_helpers.php';
$dataFile = 'data.json';
$logFile = 'activity.log';
// Helper to log activity
function logActivity($message, $type = 'INFO') {
global $logFile, $CURRENT_USER;
$timestamp = date('Y-m-d H:i:s');
$logEntry = "[$timestamp] [$type] [$CURRENT_USER] $message\n";
file_put_contents($logFile, $logEntry, FILE_APPEND);
}
// Helper to read data
function getData() {
@ -113,9 +122,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
if ($action === 'command') {
$data = getData(); // Load existing data first!
$sheetId = $input['sheet_id'] ?? '';
if (empty($sheetId)) {
echo json_encode(['success' => false, 'message' => 'Please create or select a sheet first.']);
exit;
}
$data = loadSheetData($CURRENT_USER, $sheetId) ?? [];
$command = trim($input['command']);
// Log the incoming command
logActivity("Command received: $command", 'COMMAND');
$commandLower = strtolower($command);
// Pre-processing: Common speech-to-text corrections
@ -175,7 +193,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- Status (Enum: Booked, To-do, In Progress, Done) - Default to 'Booked'
- Category (Enum: 'Digital', 'DOOH', 'Online - Banner - Celtra', 'OOH', 'Print', 'Video')
- Media (Enum: 'POS', 'OOH', 'Social', 'Community management', 'Online advertising', 'Banner', 'Rich media', 'Landing page', 'Static Image', 'Video', 'Push notifications', '.com', 'Print', 'Digital', 'Direct mail', 'Packaging', 'TV', 'Cinema', 'Radio', 'VOD')
- Sub-media (String)
- Sub-media (Dependent on Media):
* POS: Wobbler, Display, Sticker, Poster, Shelf Talker
* OOH: Billboard, City Light, Bus Stop, Digital Screen, Transit
* Social: Instagram, Facebook, TikTok, LinkedIn, Twitter/X, Pinterest, Snapchat, YouTube
* Community management: Post, Reply, Story, Moderation
* Online advertising: Display, Native, Programmatic
* Banner: Standard, Animated, HTML5, Static
* Rich media: Expandable, Interstitial, Video
* Landing page: Microsite, Product Page, Campaign Page
* Static Image: JPEG, PNG, Hero Image
* Video: TVC, OLV, Social Video, Bumper, Long Form
* Push notifications: App Push, Web Push
* .com: Homepage, Product Page, Blog
* Print: Magazine, Newspaper, Flyer, Poster, Brochure
* Digital: Banner, Email, Website, App
* Direct mail: Letter, Postcard, Catalog
* Packaging: Box, Label, Bag
* TV: Spot, Sponsorship
* Cinema: Pre-roll, Spot
* Radio: Spot, Sponsorship
* VOD: Pre-roll, Mid-roll
- Format (String) - Extract sizes/dimensions here! e.g., '300x250', 'A4', '10x15cm', 'Full Page', '1080p'.
- Supply date (YYYY-MM-DD)
- Live date (YYYY-MM-DD)
@ -213,19 +251,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
1. **FORMAT EXTRACTION (Ultra Complex Logic)**:
- Look for ANY size or format indication.
- Pixels: '300x250', '300 by 250', '1920x1080'. Normalize to 'WxH'.
- Print: 'A4', 'A3', 'Letter', 'Full Page', 'Half Page'.
- Dimensions: '10x15cm', '5 inches', '4x6\"'. Keep units.
- If user says \"social media sizes\", INFER standard sizes (e.g., 1080x1080 for Instagram) if possible, or ASK if unsure (unless YOLO is ON).
- **STRICT RULE**: ALWAYS use 'x' as the separator for dimensions. NEVER use 'by'.
- **Pixel Dimensions**: Convert '300 by 250' -> '300x250'. Convert '1920 by 1080' -> '1920x1080'.
- **Physical Dimensions**: Convert '30 by 30 cm' -> '30x30cm'. Convert '10 x 15 cm' -> '10x15cm'. Keep the unit attached or separated by space, but use 'x' for the numbers.
- **Print**: 'A4', 'A3', 'Letter', 'Full Page', 'Half Page'.
- **Social**: If user says \"social media sizes\", INFER standard sizes (e.g., 1080x1080 for Instagram) if possible, or ASK if unsure (unless YOLO is ON).
- **Examples**:
* \"300 by 300\" -> \"300x300\"
* \"30x30 cm\" -> \"30x30cm\"
* \"30 by 30 cm\" -> \"30x30cm\"
2. **YOLO MODE**:
- If YOLO MODE is TRUE: NEVER ask questions. GUESS. Make the best assumption and DO IT.
- If YOLO MODE is FALSE: If the request is ambiguous (e.g., \"next Friday\" when it's unclear, or missing critical category info), return operation 'question'.
2. **YOLO MODE (HIGHEST PRIORITY)**:
- If YOLO MODE is TRUE: **YOU ARE FORBIDDEN FROM ASKING QUESTIONS.**
- You MUST GUESS any missing information.
- Example: If user says \"banners\" and nothing else, create 1 banner with default settings.
- Example: If user says \"2027\" in YOLO mode, assume it's the date for the previous request and EXECUTE.
- NEVER return 'question' operation when YOLO is TRUE.
3. **CLARIFICATION RECOVERY (CRITICAL)**:
- **CONTEXT MERGING**: The user's current input is likely an ANSWER to your previous question.
- **DO NOT** treat the input (e.g., \"2027\", \"300x300\") as a standalone command.
- **COMBINE** it with the previous user messages in the history to form a complete request.
- **Example Flow**:
1. User: \"Create ads\" (Missing format)
2. AI: \"What format?\"
3. User: \"300x250\"
-> **INTERNAL THOUGHT**: \"User said 300x250. Previous was 'Create ads'. Combined: 'Create ads 300x250'.\"
-> **ACTION**: Execute the creation. DO NOT ask again.
- If the user provides *some* missing info but not *all*, AND YOLO is FALSE, you *can* ask for the remaining info, but acknowledge what you received.
- **BUT**: If the user seems frustrated or repeats info, JUST EXECUTE with best guesses.
4. **CONTEXT IS KING**: Use 'CURRENT DATA' to resolve references like \"the French ones\".
3. **CONTEXT IS KING**: Use 'CURRENT DATA' to resolve references like \"the French ones\".
4. **PRECISE TARGETING**: Use `target_ids` for updates.
5. **PRECISE TARGETING**: Use `target_ids` for updates.
5. **INFER FIELDS**:
- \"OOH\" -> Category='OOH', Media='OOH'.
@ -283,6 +341,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Check for API Level Errors (e.g. Invalid Key)
if (isset($responseObj['error'])) {
$errorMsg = $responseObj['error']['message'] ?? 'Unknown Error';
logActivity("Gemini API Error: $errorMsg", 'ERROR');
echo json_encode([
'success' => false,
'message' => "Gemini API Error: " . ($responseObj['error']['message'] ?? 'Unknown Error'),
@ -362,7 +422,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data[] = $item;
$count++;
}
saveData($data);
updateSheet($CURRENT_USER, $sheetId, $data);
logActivity("Created $count items via AI", 'SUCCESS');
echo json_encode(array_merge(['success' => true, 'message' => "Created $count items.", 'count' => $count], $debugInfo));
} elseif ($llmAction['operation'] === 'update') {
@ -388,7 +449,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$count++;
}
}
saveData($data);
updateSheet($CURRENT_USER, $sheetId, $data);
logActivity("Updated $count items via AI", 'SUCCESS');
echo json_encode(array_merge(['success' => true, 'message' => "Updated $count items.", 'count' => $count], $debugInfo));
} elseif ($llmAction['operation'] === 'batch_update') {
@ -407,10 +469,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
}
saveData($data);
updateSheet($CURRENT_USER, $sheetId, $data);
logActivity("Batch updated $count items via AI", 'SUCCESS');
echo json_encode(array_merge(['success' => true, 'message' => "Batch updated $count items.", 'count' => $count], $debugInfo));
} elseif ($llmAction['operation'] === 'question') {
logActivity("AI asked question: " . $llmAction['text'], 'QUESTION');
echo json_encode(array_merge(['success' => true, 'question' => $llmAction['text']], $debugInfo));
} else {
echo json_encode(array_merge(['success' => false, 'message' => 'Unknown operation: ' . $llmAction['operation']], $debugInfo));

View file

@ -1,6 +1,6 @@
<?php
// API Configuration
$GEMINI_API_KEY = 'AIzaSyBDMbLOvVqYMXvvFCOoLZOXCEb3YtKhsL0';
$GEMINI_API_KEY = 'AIzaSyC2DrDCeNIhI531JcXCF9uolTMU_KBcjDY';
// User Configuration (hardcoded for now, will be SSO later)
$CURRENT_USER = 'daveporter@oliver.agency';

View file

@ -1 +0,0 @@
[]

View file

@ -98,6 +98,29 @@
<div class="spinner"></div>
</div>
<!-- AI Question Modal -->
<div id="aiQuestionModal" class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h3>🤖 AI Needs Clarification</h3>
<button class="close-modal-btn" onclick="closeAiModal()">×</button>
</div>
<div class="modal-body">
<p id="aiQuestionText" class="ai-question-text"></p>
<div class="modal-input-group">
<input type="text" id="aiModalInput" placeholder="Type your answer..." autocomplete="off">
<button id="aiModalMicBtn" class="btn btn-icon" title="Speak">🎤</button>
</div>
<div class="modal-actions">
<button id="aiModalSendBtn" class="btn btn-primary">Send Answer</button>
<button id="aiModalYoloBtn" class="btn btn-secondary">🚀 Do what you think (YOLO)</button>
</div>
</div>
</div>
</div>
<script src="script.js"></script>
</div> <!-- /main-content -->
</div> <!-- /app-container -->

266
script.js
View file

@ -94,6 +94,32 @@ document.addEventListener('DOMContentLoaded', () => {
const categoryOptions = ['Digital', 'DOOH', 'Online - Banner - Celtra', 'OOH', 'Print', 'Video'];
const mediaOptions = ['POS', 'OOH', 'Social', 'Community management', 'Online advertising', 'Banner', 'Rich media', 'Landing page', 'Static Image', 'Video', 'Push notifications', '.com', 'Print', 'Digital', 'Direct mail', 'Packaging', 'TV', 'Cinema', 'Radio', 'VOD'];
const subMediaMap = {
'POS': ['Wobbler', 'Display', 'Sticker', 'Poster', 'Shelf Talker'],
'OOH': ['Billboard', 'City Light', 'Bus Stop', 'Digital Screen', 'Transit'],
'Social': ['Instagram', 'Facebook', 'TikTok', 'LinkedIn', 'Twitter/X', 'Pinterest', 'Snapchat', 'YouTube'],
'Community management': ['Post', 'Reply', 'Story', 'Moderation'],
'Online advertising': ['Display', 'Native', 'Programmatic'],
'Banner': ['Standard', 'Animated', 'HTML5', 'Static'],
'Rich media': ['Expandable', 'Interstitial', 'Video'],
'Landing page': ['Microsite', 'Product Page', 'Campaign Page'],
'Static Image': ['JPEG', 'PNG', 'Hero Image'],
'Video': ['TVC', 'OLV', 'Social Video', 'Bumper', 'Long Form'],
'Push notifications': ['App Push', 'Web Push'],
'.com': ['Homepage', 'Product Page', 'Blog'],
'Print': ['Magazine', 'Newspaper', 'Flyer', 'Poster', 'Brochure'],
'Digital': ['Banner', 'Email', 'Website', 'App'],
'Direct mail': ['Letter', 'Postcard', 'Catalog'],
'Packaging': ['Box', 'Label', 'Bag'],
'TV': ['Spot', 'Sponsorship'],
'Cinema': ['Pre-roll', 'Spot'],
'Radio': ['Spot', 'Sponsorship'],
'VOD': ['Pre-roll', 'Mid-roll']
};
// Flatten unique sub-media options for the source
const allSubMediaOptions = [...new Set(Object.values(subMediaMap).flat())].sort();
// --- Sheet Management ---
let activeSheetId = localStorage.getItem('activeSheetId') || null;
let sheets = [];
@ -120,17 +146,43 @@ document.addEventListener('DOMContentLoaded', () => {
sheetList.innerHTML = sheets.map(sheet => `
<div class="sheet-item ${sheet.id === activeSheetId ? 'active' : ''}" data-id="${sheet.id}">
<div class="sheet-item-name">${sheet.name}</div>
<div class="sheet-item-meta">${sheet.itemCount} items ${new Date(sheet.modified).toLocaleDateString()}</div>
<div class="sheet-item-content">
<div class="sheet-item-name">${sheet.name}</div>
<div class="sheet-item-meta">${sheet.itemCount} items ${new Date(sheet.modified).toLocaleDateString()}</div>
</div>
<div class="sheet-item-actions">
<button class="sheet-action-btn" data-action="rename" title="Rename"></button>
<button class="sheet-action-btn" data-action="duplicate" title="Duplicate">📋</button>
<button class="sheet-action-btn sheet-action-delete" data-action="delete" title="Delete">🗑</button>
</div>
</div>
`).join('');
// Add click handlers
document.querySelectorAll('.sheet-item').forEach(item => {
item.addEventListener('click', () => loadSheet(item.dataset.id));
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
showSheetContextMenu(e, item.dataset.id);
const sheetId = item.dataset.id;
// Click on the card itself (but not on action buttons)
item.addEventListener('click', (e) => {
if (!e.target.closest('.sheet-item-actions')) {
loadSheet(sheetId);
}
});
// Action button handlers
item.querySelectorAll('.sheet-action-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent card click
const action = btn.dataset.action;
if (action === 'rename') {
renameSheet(sheetId);
} else if (action === 'duplicate') {
duplicateSheet(sheetId);
} else if (action === 'delete') {
deleteSheet(sheetId);
}
});
});
});
}
@ -189,7 +241,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
async function deleteSheet(sheetId) {
if (!confirm('Are you sure you want to delete this sheet?')) return;
if (!confirm('Are you sure you want to delete this sheet? This cannot be undone.')) return;
try {
const response = await fetch('api.php?action=delete_sheet', {
@ -202,7 +254,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (activeSheetId === sheetId) {
activeSheetId = null;
localStorage.removeItem('activeSheetId');
initSpreadsheet([]);
initSpreadsheet([]); // Clear grid
}
await loadSheetList();
}
@ -253,9 +305,23 @@ document.addEventListener('DOMContentLoaded', () => {
async function loadData() {
showLoading(true);
try {
// Cache buster
const response = await fetch(`api.php?action=load&t=${Date.now()}`);
const rawData = await response.json();
let rawData;
// If there's an active sheet, load from that sheet
if (activeSheetId) {
const response = await fetch(`api.php?action=load_sheet&id=${activeSheetId}&t=${Date.now()}`);
const result = await response.json();
if (result.success) {
rawData = result.data;
} else {
console.error('Failed to load sheet:', result.message);
rawData = [];
}
} else {
// Fallback: load from data.json (legacy)
const response = await fetch(`api.php?action=load&t=${Date.now()}`);
rawData = await response.json();
}
if (!rawData) {
initSpreadsheet([]);
@ -290,7 +356,21 @@ document.addEventListener('DOMContentLoaded', () => {
{ type: 'dropdown', title: 'Status', width: 100, name: 'Status', source: statusOptions },
{ type: 'dropdown', title: 'Category', width: 120, name: 'Category', source: categoryOptions },
{ type: 'dropdown', title: 'Media', width: 120, name: 'Media', source: mediaOptions },
{ type: 'text', title: 'Sub-media', width: 150, name: 'Sub-media' },
{
type: 'dropdown',
title: 'Sub-media',
width: 150,
name: 'Sub-media',
source: allSubMediaOptions,
filter: function (instance, cell, c, r, source) {
const jexcel = instance.jexcel || instance;
const mediaValue = jexcel.getValueFromCoords(4, r); // Media is at index 4
if (subMediaMap[mediaValue]) {
return subMediaMap[mediaValue];
}
return source;
}
},
{ type: 'text', title: 'Format', width: 120, name: 'Format' },
{ type: 'calendar', title: 'Supply date', width: 100, name: 'Supply date', options: { format: 'YYYY-MM-DD' } },
{ type: 'calendar', title: 'Live date', width: 100, name: 'Live date', options: { format: 'YYYY-MM-DD' } },
@ -351,17 +431,36 @@ document.addEventListener('DOMContentLoaded', () => {
// showLoading(true); // Don't block UI for auto-save
try {
const data = dataOverride || spreadsheet.getJson();
const response = await fetch('api.php?action=save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: data })
});
const result = await response.json();
if (result.success) {
console.log('Saved.');
updateStats(data.length);
// If there's an active sheet, save to that sheet
if (activeSheetId) {
const response = await fetch('api.php?action=update_sheet', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: activeSheetId, data: data })
});
const result = await response.json();
if (result.success) {
console.log('Sheet saved.');
updateStats(data.length);
await loadSheetList(); // Refresh sheet list to update item counts
} else {
console.error('Save failed:', result.message);
}
} else {
console.error('Save failed:', result.message);
// Fallback: save to data.json (legacy behavior)
const response = await fetch('api.php?action=save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: data })
});
const result = await response.json();
if (result.success) {
console.log('Saved to data.json.');
updateStats(data.length);
} else {
console.error('Save failed:', result.message);
}
}
} catch (error) {
console.error('Error saving data:', error);
@ -381,7 +480,8 @@ document.addEventListener('DOMContentLoaded', () => {
const yoloMode = forceYolo || document.getElementById('yoloToggle').checked;
const aiOutput = document.getElementById('aiOutput');
aiOutput.textContent = `> Sending command: "${command}" (YOLO: ${yoloMode})...\n`;
const timestamp = new Date().toLocaleTimeString();
aiOutput.textContent += `\n[${timestamp}] Sending command: "${command}" (YOLO: ${yoloMode})...\n`;
// Clear input box
if (!overrideCommand) {
@ -399,7 +499,8 @@ document.addEventListener('DOMContentLoaded', () => {
body: JSON.stringify({
command: command,
yolo_mode: yoloMode,
history: historyText
history: historyText,
sheet_id: activeSheetId // Pass active sheet ID
})
});
@ -425,22 +526,14 @@ document.addEventListener('DOMContentLoaded', () => {
conversationHistory.push({ role: 'User', text: command });
if (result.question) {
// AI has a question
// AI has a question - Show Modal
conversationHistory.push({ role: 'AI', text: result.question });
aiOutput.innerHTML += `<div style="color: #ff9800; font-weight: bold; margin: 10px 0;">
🤖 AI Question: ${result.question}
</div>`;
// Log to activity log for history
aiOutput.textContent += `\n[${new Date().toLocaleTimeString()}] 🤖 AI Question: ${result.question}\n`;
aiOutput.scrollTop = aiOutput.scrollHeight;
// Add "Do what you think" button
const yoloBtn = document.createElement('button');
yoloBtn.className = 'btn btn-primary';
yoloBtn.style.marginTop = '5px';
yoloBtn.innerText = '🚀 Do what you think (YOLO)';
yoloBtn.onclick = () => sendCommand(lastCommandText, true);
aiOutput.appendChild(yoloBtn);
aiOutput.innerHTML += '\nOr type your clarification above.\n';
showAiQuestionModal(result.question);
} else {
// Success - clear history for fresh start
@ -448,13 +541,13 @@ document.addEventListener('DOMContentLoaded', () => {
await loadData(); // Reload grid
commandInput.value = '';
aiOutput.textContent += `> Success! Processed ${result.count || 0} items.\n`;
aiOutput.textContent += ` Success! Processed ${result.count || 0} items.\n`;
if (result.debug_llm) {
aiOutput.textContent += `\n--- AI Response ---\n${result.debug_llm}\n`;
}
}
} else {
aiOutput.textContent += `> Error: ${result.message}\n`;
aiOutput.textContent += ` Error: ${result.message}\n`;
if (result.message && result.message.includes('Debug:')) {
aiOutput.textContent += `\n--- Debug Info ---\n${result.message}\n`;
}
@ -466,7 +559,7 @@ document.addEventListener('DOMContentLoaded', () => {
aiOutput.scrollTop = aiOutput.scrollHeight;
} catch (error) {
console.error('Error sending command:', error);
aiOutput.textContent += `> Critical Error: ${error.message}\n`;
aiOutput.textContent += ` Critical Error: ${error.message}\n`;
aiOutput.scrollTop = aiOutput.scrollHeight;
} finally {
showLoading(false);
@ -665,4 +758,97 @@ document.addEventListener('DOMContentLoaded', () => {
}
sendCommand();
}
// --- AI Question Modal Logic ---
const aiModal = document.getElementById('aiQuestionModal');
const aiModalInput = document.getElementById('aiModalInput');
const aiModalMicBtn = document.getElementById('aiModalMicBtn');
const aiModalSendBtn = document.getElementById('aiModalSendBtn');
const aiModalYoloBtn = document.getElementById('aiModalYoloBtn');
window.showAiQuestionModal = function (question) {
document.getElementById('aiQuestionText').textContent = question;
aiModal.classList.add('active');
aiModalInput.value = '';
aiModalInput.focus();
};
window.closeAiModal = function () {
aiModal.classList.remove('active');
if (recognition) recognition.stop();
};
// Modal Send
aiModalSendBtn.addEventListener('click', () => {
const answer = aiModalInput.value.trim();
if (answer) {
closeAiModal();
sendCommand(answer);
}
});
// Modal Enter Key
aiModalInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
aiModalSendBtn.click();
}
});
// Modal YOLO
aiModalYoloBtn.addEventListener('click', () => {
closeAiModal();
sendCommand(lastCommandText, true); // Resend last command with YOLO forced
});
// Modal Mic
if (recognition) {
aiModalMicBtn.addEventListener('click', () => {
if (aiModalMicBtn.classList.contains('listening')) {
recognition.stop();
} else {
// Temporarily override onresult for modal
recognition.onresult = (event) => {
let finalTranscript = '';
for (let i = 0; i < event.results.length; ++i) {
if (event.results[i].isFinal) {
finalTranscript += event.results[i][0].transcript;
}
}
aiModalInput.value = finalTranscript;
};
recognition.onend = () => {
aiModalMicBtn.classList.remove('listening');
// Restore original onresult
restoreMainMicHandler();
};
recognition.start();
aiModalMicBtn.classList.add('listening');
}
});
} else {
aiModalMicBtn.style.display = 'none';
}
function restoreMainMicHandler() {
recognition.onresult = (event) => {
let finalTranscript = '';
let interimTranscript = '';
for (let i = 0; i < event.results.length; ++i) {
if (event.results[i].isFinal) {
finalTranscript += event.results[i][0].transcript;
} else {
interimTranscript += event.results[i][0].transcript;
}
}
commandInput.value = finalTranscript + interimTranscript;
};
recognition.onend = () => {
micBtn.classList.remove('listening');
commandInput.placeholder = "Type or speak a command...";
};
}
});

View file

@ -0,0 +1,44 @@
[
{
"Number": "DEL-001",
"Title": "Video Ad",
"Status": "Booked",
"Category": "Video",
"Media": "OOH",
"Sub-media": "Social Video",
"Format": "",
"Supply date": "",
"Live date": "2025-11-28",
"Language": "JA",
"Country": "JP",
"Quantity": 1
},
{
"Number": "DEL-002",
"Title": "Video Ad",
"Status": "Booked",
"Category": "Video",
"Media": "Video",
"Sub-media": "",
"Format": "",
"Supply date": "",
"Live date": "2025-11-28",
"Language": "JA",
"Country": "JP",
"Quantity": 1
},
{
"Number": "DEL-003",
"Title": "Video Ad",
"Status": "Booked",
"Category": "Video",
"Media": "Video",
"Sub-media": "",
"Format": "",
"Supply date": "",
"Live date": "2025-11-28",
"Language": "JA",
"Country": "JP",
"Quantity": 1
}
]

View file

@ -0,0 +1,142 @@
[
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-001"
},
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-002"
},
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-003"
},
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-004"
},
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-005"
},
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-006"
},
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-007"
},
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-008"
},
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-009"
},
{
"Title": "Digital Deliverable",
"Status": "Booked",
"Category": "Digital",
"Media": "Digital",
"Sub-media": "TBD",
"Format": "TBD",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-010"
}
]

View file

@ -0,0 +1,72 @@
[
{
"Number": "DEL-001",
"Title": "Social Media Banner",
"Status": "Booked",
"Category": "Digital",
"Media": "Social",
"Sub-media": "Banner",
"Format": "1080x1080",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "EN",
"Country": "GB",
"Quantity": 1
},
{
"Number": "DEL-002",
"Title": "Social Media Banner",
"Status": "Booked",
"Category": "Digital",
"Media": "Social",
"Sub-media": "Banner",
"Format": "1080x1080",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "EN",
"Country": "GB",
"Quantity": 1
},
{
"Number": "DEL-003",
"Title": "Social Media Banner",
"Status": "Booked",
"Category": "Digital",
"Media": "Social",
"Sub-media": "Banner",
"Format": "1080x1080",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "EN",
"Country": "GB",
"Quantity": 1
},
{
"Number": "DEL-004",
"Title": "Social Media Banner",
"Status": "Booked",
"Category": "Digital",
"Media": "Social",
"Sub-media": "Banner",
"Format": "1080x1080",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "EN",
"Country": "GB",
"Quantity": 1
},
{
"Number": "DEL-005",
"Title": "Social Media Banner",
"Status": "Booked",
"Category": "Digital",
"Media": "Social",
"Sub-media": "Banner",
"Format": "1080x1080",
"Supply date": "2025-11-20",
"Live date": "2025-11-20",
"Language": "EN",
"Country": "GB",
"Quantity": 1
}
]

View file

@ -0,0 +1,210 @@
[
{
"Title": "Christmas campaign men",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "50x70cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-001"
},
{
"Title": "Christmas campaign women",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "50x70cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-002"
},
{
"Title": "Christmas campaign men",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "50x70cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "GB",
"Country": "GB",
"Quantity": 1,
"Number": "DEL-003"
},
{
"Title": "Christmas campaign women",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "50x70cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "GB",
"Country": "GB",
"Quantity": 1,
"Number": "DEL-004"
},
{
"Title": "Christmas campaign men",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "50x70cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "DE",
"Country": "DE",
"Quantity": 1,
"Number": "DEL-005"
},
{
"Title": "Christmas campaign women",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "50x70cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "DE",
"Country": "DE",
"Quantity": 1,
"Number": "DEL-006"
},
{
"Title": "Christmas campaign men",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "50x70cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "ES",
"Country": "ES",
"Quantity": 1,
"Number": "DEL-007"
},
{
"Title": "Christmas campaign women",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "50x70cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "ES",
"Country": "ES",
"Quantity": 1,
"Number": "DEL-008"
},
{
"Title": "Christmas campaign men",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "100x160cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-009"
},
{
"Title": "Christmas campaign women",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "100x160cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "FR",
"Country": "FR",
"Quantity": 1,
"Number": "DEL-010"
},
{
"Title": "Christmas campaign men",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "100x160cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "GB",
"Country": "GB",
"Quantity": 1,
"Number": "DEL-011"
},
{
"Title": "Christmas campaign women",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "100x160cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "GB",
"Country": "GB",
"Quantity": 1,
"Number": "DEL-012"
},
{
"Title": "Christmas campaign men",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "100x160cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "DE",
"Country": "DE",
"Quantity": 1,
"Number": "DEL-013"
},
{
"Title": "Christmas campaign women",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "100x160cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "DE",
"Country": "DE",
"Quantity": 1,
"Number": "DEL-014"
},
{
"Title": "Christmas campaign men",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "100x160cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "ES",
"Country": "ES",
"Quantity": 1,
"Number": "DEL-015"
},
{
"Title": "Christmas campaign women",
"Status": "Booked",
"Category": "Print",
"Media": "Print",
"Format": "100x160cm",
"Supply date": "2025-12-01",
"Live date": "2020-12-25",
"Language": "ES",
"Country": "ES",
"Quantity": 1,
"Number": "DEL-016"
}
]

View file

@ -1,11 +1,35 @@
{
"daveporter@oliver.agency": [
{
"id": "1763598776100",
"name": "HM 1",
"created": "2025-11-20T00:32:56+00:00",
"modified": "2025-11-20T00:32:56+00:00",
"itemCount": 0,
"id": "1763606241496",
"name": "1234568",
"created": "2025-11-20T02:37:21+00:00",
"modified": "2025-11-20T02:43:21+00:00",
"itemCount": 3,
"user": "daveporter@oliver.agency"
},
{
"id": "1763606271640",
"name": "1212",
"created": "2025-11-20T02:37:51+00:00",
"modified": "2025-11-20T02:37:51+00:00",
"itemCount": 10,
"user": "daveporter@oliver.agency"
},
{
"id": "1763606295286",
"name": "asdsaasd",
"created": "2025-11-20T02:38:15+00:00",
"modified": "2025-11-20T02:38:26+00:00",
"itemCount": 5,
"user": "daveporter@oliver.agency"
},
{
"id": "1763606320579",
"name": "aaasdasd",
"created": "2025-11-20T02:38:40+00:00",
"modified": "2025-11-20T02:39:11+00:00",
"itemCount": 16,
"user": "daveporter@oliver.agency"
}
]

170
style.css
View file

@ -70,6 +70,10 @@ body {
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.sheet-item:hover {
@ -84,6 +88,11 @@ body {
border-color: var(--accent-color);
}
.sheet-item-content {
flex: 1;
min-width: 0;
}
.sheet-item-name {
font-weight: 600;
margin-bottom: 4px;
@ -99,6 +108,54 @@ body {
opacity: 0.8;
}
.sheet-item-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.sheet-item:hover .sheet-item-actions {
opacity: 1;
}
.sheet-item.active .sheet-item-actions {
opacity: 1;
}
.sheet-action-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.sheet-action-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.sheet-item.active .sheet-action-btn {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(0, 0, 0, 0.3);
}
.sheet-item.active .sheet-action-btn:hover {
background: rgba(0, 0, 0, 0.3);
}
.sheet-action-delete:hover {
background: var(--danger-color) !important;
border-color: var(--danger-color) !important;
}
.loading-sheets {
text-align: center;
padding: 20px;
@ -663,4 +720,117 @@ input:checked+.slider:before {
display: inline-block;
width: 20px;
transition: transform 0.3s ease;
}
/* AI Question Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.modal-overlay.active {
opacity: 1;
pointer-events: all;
}
.modal-content {
background: #1e293b;
border: 1px solid var(--accent-color);
border-radius: 12px;
width: 90%;
max-width: 500px;
padding: 0;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5), 0 10px 10px -5px rgba(0, 0, 0, 0.4);
transform: translateY(20px);
transition: transform 0.3s ease;
}
.modal-overlay.active .modal-content {
transform: translateY(0);
}
.modal-header {
padding: 15px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(0, 0, 0, 0.2);
border-radius: 12px 12px 0 0;
}
.modal-header h3 {
margin: 0;
color: var(--accent-color);
font-size: 1.1rem;
}
.close-modal-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
line-height: 1;
}
.close-modal-btn:hover {
color: white;
}
.modal-body {
padding: 20px;
}
.ai-question-text {
font-size: 1.1rem;
line-height: 1.5;
margin-top: 0;
margin-bottom: 20px;
color: white;
}
.modal-input-group {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
#aiModalInput {
flex: 1;
padding: 12px;
background: #0f172a;
border: 1px solid var(--border-color);
border-radius: 8px;
color: white;
font-size: 1rem;
}
#aiModalInput:focus {
outline: none;
border-color: var(--accent-color);
}
.modal-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.modal-actions .btn {
width: 100%;
justify-content: center;
}