Download index.html from jhaspr/MBWA: direct link, hf CLI and curl.
- Browser
- Download file 36.7 kB
-
https://huggingface.co/spaces/jhaspr/MBWA/resolve/main/index.html
- Command line
-
hf download hf://spaces/jhaspr/MBWA/index.html
-
curl -L -o index.html https://huggingface.co/spaces/jhaspr/MBWA/resolve/main/index.html
36.7 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>MBWA (Management By Wandering Around)</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/feather-icons"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script> | |
| <style> | |
| #imageModal img { | |
| cursor: zoom-out; | |
| } | |
| .task-card { | |
| transition: all 0.2s ease; | |
| } | |
| .task-card:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); | |
| } | |
| .comment-box { | |
| max-height: 200px; | |
| overflow-y: auto; | |
| scrollbar-width: thin; | |
| } | |
| .comment-box::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .comment-box::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| } | |
| .comment-box::-webkit-scrollbar-thumb { | |
| background: #888; | |
| border-radius: 2px; | |
| } | |
| .comment-box::-webkit-scrollbar-thumb:hover { | |
| background: #555; | |
| } | |
| </style> | |
| </head> | |
| <body class="min-h-screen bg-gray-50"> | |
| <div class="max-w-5xl mx-auto p-6"> | |
| <header class="mb-6"> | |
| <h1 class="text-2xl font-bold text-gray-900">MBWA (Management By Wandering Around)</h1> | |
| <p class="text-gray-600 mt-1">Track tasks, observations and improvements from your management walks.</p> | |
| </header> | |
| <section class="bg-white border rounded-2xl p-6 shadow-sm mb-8"> | |
| <h2 class="text-lg font-semibold text-gray-900 mb-4">Create Task</h2> | |
| <form id="taskForm" class="grid grid-cols-1 md:grid-cols-3 gap-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Task</label> | |
| <input | |
| type="text" | |
| id="taskTitle" | |
| placeholder="What needs to be done?" | |
| class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| /> | |
| <p id="titleError" class="text-sm text-red-600 mt-1 hidden"></p> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Location</label> | |
| <select | |
| id="taskLocation" | |
| class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| > | |
| <option value="">Select location</option> | |
| <option value="Berthaphil I">Berthaphil I</option> | |
| <option value="Berthaphil II South">Berthaphil II South</option> | |
| <option value="Berthaphil II North">Berthaphil II North</option> | |
| <option value="Berthaphil Office Park">Berthaphil Office Park</option> | |
| <option value="Clark Center">Clark Center</option> | |
| <option value="Berthaphil IV">Berthaphil IV</option> | |
| <option value="Berthaphil VI">Berthaphil VI</option> | |
| <option value="Berthaphil VIII">Berthaphil VIII</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Assigned to</label> | |
| <input | |
| type="text" | |
| id="assignedTo" | |
| placeholder="Name" | |
| class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| /> | |
| <p id="assigneeError" class="text-sm text-red-600 mt-1 hidden"></p> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Date reported</label> | |
| <input | |
| type="date" | |
| id="dateReported" | |
| class="w-full rounded-lg border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| /> | |
| <p id="dateError" class="text-sm text-red-600 mt-1 hidden"></p> | |
| </div> | |
| <div class="md:col-span-2"> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Image (optional)</label> | |
| <div class="flex items-center gap-4"> | |
| <label class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 hover:bg-gray-100 cursor-pointer text-sm font-medium text-gray-700"> | |
| <input type="file" id="taskImage" accept="image/*" class="hidden"> | |
| Upload image | |
| </label> | |
| <div id="imagePreview" class="bg-gray-200 border-2 border-dashed rounded-xl w-16 h-16"></div> | |
| <button | |
| type="button" | |
| id="removeImageBtn" | |
| class="text-sm text-gray-600 hover:text-gray-800 underline hidden" | |
| > | |
| Remove | |
| </button> | |
| </div> | |
| </div> | |
| <div class="md:col-span-3"> | |
| <button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"> | |
| Add Task | |
| </button> | |
| </div> | |
| </form> | |
| </section> | |
| <section> | |
| <div class="flex items-center justify-between mb-4"> | |
| <h2 class="text-lg font-semibold text-gray-900">Tasks</h2> | |
| <div class="flex items-center gap-4"> | |
| <div class="flex items-center gap-2"> | |
| <label class="text-sm text-gray-700">Status:</label> | |
| <select | |
| id="statusFilter" | |
| class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| > | |
| <option value="All">All</option> | |
| <option value="Pending">Pending</option> | |
| <option value="In-Progress">In-Progress</option> | |
| <option value="Completed">Completed</option> | |
| </select> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <label class="text-sm text-gray-700">Assigned:</label> | |
| <select | |
| id="assigneeFilter" | |
| class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| > | |
| <option value="All">All</option> | |
| </select> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <label class="text-sm text-gray-700">Location:</label> | |
| <select | |
| id="locationFilter" | |
| class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| > | |
| <option value="All">All</option> | |
| <option value="Berthaphil I">Berthaphil I</option> | |
| <option value="Berthaphil II South">Berthaphil II South</option> | |
| <option value="Berthaphil II North">Berthaphil II North</option> | |
| <option value="Berthaphil Office Park">Berthaphil Office Park</option> | |
| <option value="Clark Center">Clark Center</option> | |
| <option value="Berthaphil IV">Berthaphil IV</option> | |
| <option value="Berthaphil VI">Berthaphil VI</option> | |
| <option value="Berthaphil VIII">Berthaphil VIII</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="emptyState" class="bg-white border rounded-2xl p-6 text-center text-gray-600 hidden"> | |
| No tasks yet. Create your first task above. | |
| </div> | |
| <div id="tasksContainer" class="grid grid-cols-1 md:grid-cols-2 gap-4"></div> | |
| </section> | |
| </div> | |
| <!-- Image Modal --> | |
| <div id="imageModal" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 hidden"> | |
| <div class="relative max-w-4xl max-h-screen p-4"> | |
| <button onclick="document.getElementById('imageModal').classList.add('hidden')" class="absolute top-4 right-4 text-white text-2xl z-10">×</button> | |
| <img id="modalImage" class="max-w-full max-h-screen" src="" alt="Enlarged view"> | |
| </div> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/lz-string@1.4.4/libs/lz-string.min.js"></script> | |
| <script> | |
| // Show image modal | |
| function showImageModal(src) { | |
| const modal = document.getElementById('imageModal'); | |
| const modalImg = document.getElementById('modalImage'); | |
| modalImg.src = src; | |
| modal.classList.remove('hidden'); | |
| } | |
| // Close modal when clicking outside image | |
| document.getElementById('imageModal').addEventListener('click', function(e) { | |
| if (e.target === this) { | |
| this.classList.add('hidden'); | |
| } | |
| }); | |
| // Status options and badge classes | |
| const STATUS_OPTIONS = ["Pending", "In-Progress", "Completed"]; | |
| function statusBadgeClasses(status) { | |
| switch (status) { | |
| case "Pending": return "bg-gray-100 text-gray-700 border-gray-200"; | |
| case "In-Progress": return "bg-blue-100 text-blue-700 border-blue-200"; | |
| case "Completed": return "bg-green-100 text-green-700 border-green-200"; | |
| default: return "bg-gray-100 text-gray-700 border-gray-200"; | |
| } | |
| } | |
| // Format date from YYYY-MM-DD to locale string | |
| function formatDate(dateStr) { | |
| if (!dateStr) return ""; | |
| const [y, m, d] = dateStr.split("-").map(Number); | |
| const dt = new Date(y || 0, (m || 1) - 1, d || 1); | |
| return dt.toLocaleDateString(); | |
| } | |
| // Task storage | |
| let tasks = JSON.parse(localStorage.getItem("crm_tasks_v1")) || []; | |
| // Handle initial image upload | |
| function handleInitialImageUpload(taskId, input) { | |
| const file = input.files?.[0]; | |
| if (!file) return; | |
| if (!file.type.startsWith("image/")) { | |
| alert("Please select an image file"); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| const task = tasks.find(t => t.id === taskId); | |
| if (task) { | |
| task.imageDataUrl = event.target.result; | |
| saveTasks(); | |
| renderTasks(); | |
| } | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| // Handle completed image upload | |
| function handleCompletedImageUpload(taskId, input) { | |
| const file = input.files?.[0]; | |
| if (!file) return; | |
| if (!file.type.startsWith("image/")) { | |
| alert("Please select an image file"); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| const task = tasks.find(t => t.id === taskId); | |
| if (task) { | |
| task.completedImageDataUrl = event.target.result; | |
| // Add a comment about the photo upload | |
| const newComment = { | |
| id: "c_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), | |
| text: "Uploaded completed task photo", | |
| createdAt: new Date().toISOString() | |
| }; | |
| task.comments.unshift(newComment); | |
| saveTasks(); | |
| renderTasks(); | |
| } | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| // DOM elements | |
| const taskForm = document.getElementById("taskForm"); | |
| const taskTitle = document.getElementById("taskTitle"); | |
| const assignedTo = document.getElementById("assignedTo"); | |
| const dateReported = document.getElementById("dateReported"); | |
| const taskImage = document.getElementById("taskImage"); | |
| const imagePreview = document.getElementById("imagePreview"); | |
| const removeImageBtn = document.getElementById("removeImageBtn"); | |
| const statusFilter = document.getElementById("statusFilter"); | |
| const assigneeFilter = document.getElementById("assigneeFilter"); | |
| const tasksContainer = document.getElementById("tasksContainer"); | |
| const emptyState = document.getElementById("emptyState"); | |
| // Error elements | |
| const titleError = document.getElementById("titleError"); | |
| const assigneeError = document.getElementById("assigneeError"); | |
| const dateError = document.getElementById("dateError"); | |
| // Initialize | |
| function init() { | |
| renderTasks(); | |
| setupEventListeners(); | |
| feather.replace(); | |
| } | |
| // Set up event listeners | |
| function setupEventListeners() { | |
| taskForm.addEventListener("submit", handleAddTask); | |
| taskImage.addEventListener("change", handleFileChange); | |
| removeImageBtn.addEventListener("click", () => { | |
| taskImage.value = ""; | |
| imagePreview.innerHTML = ""; | |
| imagePreview.className = "bg-gray-200 border-2 border-dashed rounded-xl w-16 h-16"; | |
| removeImageBtn.classList.add("hidden"); | |
| }); | |
| statusFilter.addEventListener("change", renderTasks); | |
| assigneeFilter.addEventListener("change", renderTasks); | |
| } | |
| // Handle file upload | |
| function handleFileChange(e) { | |
| const file = e.target.files?.[0]; | |
| if (!file) { | |
| removeImageBtn.click(); | |
| return; | |
| } | |
| if (!file.type.startsWith("image/")) { | |
| alert("Please select an image file"); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| imagePreview.innerHTML = `<img src="${event.target.result}" class="w-full h-full rounded-xl object-cover border" />`; | |
| removeImageBtn.classList.remove("hidden"); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| // Reset form | |
| function resetForm() { | |
| taskForm.reset(); | |
| titleError.classList.add("hidden"); | |
| assigneeError.classList.add("hidden"); | |
| dateError.classList.add("hidden"); | |
| document.getElementById('taskLocation').value = ''; | |
| imagePreview.innerHTML = ""; | |
| imagePreview.className = "bg-gray-200 border-2 border-dashed rounded-xl w-16 h-16"; | |
| removeImageBtn.classList.add("hidden"); | |
| } | |
| // Add new task | |
| function handleAddTask(e) { | |
| e.preventDefault(); | |
| // Validate form | |
| let isValid = true; | |
| if (!taskTitle.value.trim()) { | |
| titleError.textContent = "Task is required"; | |
| titleError.classList.remove("hidden"); | |
| isValid = false; | |
| } else { | |
| titleError.classList.add("hidden"); | |
| } | |
| if (!assignedTo.value.trim()) { | |
| assigneeError.textContent = "Assigned person is required"; | |
| assigneeError.classList.remove("hidden"); | |
| isValid = false; | |
| } else { | |
| assigneeError.classList.add("hidden"); | |
| } | |
| if (!dateReported.value) { | |
| dateError.textContent = "Date reported is required"; | |
| dateError.classList.remove("hidden"); | |
| isValid = false; | |
| } else { | |
| dateError.classList.add("hidden"); | |
| } | |
| if (!isValid) return; | |
| // Create new task | |
| const newTask = { | |
| id: "t_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), | |
| title: taskTitle.value.trim(), | |
| location: document.getElementById('taskLocation').value.trim(), | |
| assignedTo: assignedTo.value.trim(), | |
| dateReported: dateReported.value, | |
| status: "Pending", | |
| imageDataUrl: imagePreview.querySelector("img")?.src || undefined, | |
| completedImageDataUrl: undefined, | |
| comments: [], | |
| createdAt: new Date().toISOString() | |
| }; | |
| tasks.unshift(newTask); | |
| saveTasks(); | |
| resetForm(); | |
| updateAssigneeFilter(); | |
| renderTasks(); | |
| } | |
| // Update task status | |
| function updateTaskStatus(id, status) { | |
| const task = tasks.find(t => t.id === id); | |
| if (task) { | |
| task.status = status; | |
| saveTasks(); | |
| renderTasks(); | |
| } | |
| } | |
| // Add comment to task | |
| function addComment(id, commentInput) { | |
| const text = commentInput.value.trim(); | |
| const commentImageInput = document.getElementById(`commentImage-${id}`); | |
| const file = commentImageInput.files?.[0]; | |
| if (!text && !file) return; | |
| const task = tasks.find(t => t.id === id); | |
| if (task) { | |
| const reader = new FileReader(); | |
| if (file) { | |
| reader.onload = (event) => { | |
| const newComment = { | |
| id: "c_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), | |
| text: text || "Added photo update", | |
| imageDataUrl: event.target.result, | |
| createdAt: new Date().toISOString() | |
| }; | |
| task.comments.unshift(newComment); | |
| saveTasks(); | |
| renderTasks(); | |
| commentInput.value = ""; | |
| commentImageInput.value = ""; | |
| }; | |
| reader.readAsDataURL(file); | |
| } else { | |
| const newComment = { | |
| id: "c_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), | |
| text, | |
| createdAt: new Date().toISOString() | |
| }; | |
| task.comments.unshift(newComment); | |
| saveTasks(); | |
| renderTasks(); | |
| commentInput.value = ""; | |
| } | |
| } | |
| } | |
| // Toggle comments visibility | |
| function toggleComments(id) { | |
| const commentsDiv = document.getElementById(`comments-${id}`); | |
| const toggleBtn = document.getElementById(`toggleComments-${id}`); | |
| if (commentsDiv.classList.contains("hidden")) { | |
| commentsDiv.classList.remove("hidden"); | |
| toggleBtn.textContent = "Hide updates"; | |
| } else { | |
| commentsDiv.classList.add("hidden"); | |
| toggleBtn.textContent = `Show updates (${tasks.find(t => t.id === id).comments.length})`; | |
| } | |
| } | |
| // Save tasks to localStorage | |
| function saveTasks() { | |
| localStorage.setItem("crm_tasks_v1", JSON.stringify(tasks)); | |
| } | |
| // Update assignee filter options | |
| function updateAssigneeFilter() { | |
| const assignees = [...new Set(tasks.map(t => t.assignedTo.trim()))].filter(Boolean); | |
| const assigneeSelect = document.getElementById("assigneeFilter"); | |
| // Keep current selection | |
| const currentValue = assigneeSelect.value; | |
| // Clear and rebuild options | |
| assigneeSelect.innerHTML = '<option value="All">All</option>'; | |
| assignees.forEach(assignee => { | |
| const option = document.createElement("option"); | |
| option.value = assignee; | |
| option.textContent = assignee; | |
| assigneeSelect.appendChild(option); | |
| }); | |
| // Restore selection if still valid | |
| if (assignees.includes(currentValue)) { | |
| assigneeSelect.value = currentValue; | |
| } | |
| } | |
| // Render tasks based on filters | |
| function renderTasks() { | |
| const statusFilterValue = statusFilter.value; | |
| const assigneeFilterValue = assigneeFilter.value; | |
| const locationFilterValue = document.getElementById('locationFilter').value; | |
| const locationOrder = [ | |
| "Berthaphil I", | |
| "Berthaphil II South", | |
| "Berthaphil II North", | |
| "Berthaphil Office Park", | |
| "Clark Center", | |
| "Berthaphil IV", | |
| "Berthaphil VI", | |
| "Berthaphil VIII" | |
| ]; | |
| const filteredTasks = tasks | |
| .filter(task => { | |
| const statusMatch = statusFilterValue === "All" || task.status === statusFilterValue; | |
| const assigneeMatch = assigneeFilterValue === "All" || task.assignedTo.trim() === assigneeFilterValue; | |
| const locationMatch = locationFilterValue === "All" || task.location === locationFilterValue; | |
| return statusMatch && assigneeMatch && locationMatch; | |
| }) | |
| .sort((a, b) => { | |
| const aIndex = locationOrder.indexOf(a.location); | |
| const bIndex = locationOrder.indexOf(b.location); | |
| // If both locations are in our ordered list, sort by their position | |
| if (aIndex !== -1 && bIndex !== -1) { | |
| return aIndex - bIndex; | |
| } | |
| // If only one location is in our ordered list, it comes first | |
| if (aIndex !== -1) return -1; | |
| if (bIndex !== -1) return 1; | |
| // If neither location is in our ordered list, sort alphabetically | |
| return (a.location || '').localeCompare(b.location || ''); | |
| }); | |
| if (filteredTasks.length === 0) { | |
| emptyState.classList.remove("hidden"); | |
| tasksContainer.classList.add("hidden"); | |
| return; | |
| } | |
| emptyState.classList.add("hidden"); | |
| tasksContainer.classList.remove("hidden"); | |
| tasksContainer.innerHTML = filteredTasks.map(task => ` | |
| <div class="bg-white border rounded-2xl p-4 shadow-sm task-card"> | |
| <div class="flex items-start justify-between"> | |
| <h3 class="text-base font-semibold text-gray-900 cursor-pointer hover:underline" onclick="document.getElementById('taskDetails-${task.id}').classList.toggle('hidden')">${task.title}</h3> | |
| <div class="flex items-center gap-2"> | |
| <span class="ml-2 inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${statusBadgeClasses(task.status)}"> | |
| ${task.status} | |
| </span> | |
| <button data-task-id="${task.id}" class="delete-task-btn text-red-500 hover:text-red-700" title="Delete task" onclick="deleteTask('${task.id}')"> | |
| <i data-feather="trash-2" class="w-4 h-4"></i> | |
| </button> | |
| ${task.status === 'Completed' ? | |
| `<button data-task-id="${task.id}" class="text-green-600 hover:text-green-800" title="Mark as incomplete" onclick="updateTaskStatus('${task.id}', 'Pending')"> | |
| <i data-feather="rotate-ccw" class="w-4 h-4"></i> | |
| </button>` : '' | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| <div id="taskDetails-${task.id}" class="hidden mt-2 p-3 bg-gray-50 rounded-lg"> | |
| <div class="grid grid-cols-2 gap-2 text-sm"> | |
| <div> | |
| <span class="font-medium">Assigned To:</span> ${task.assignedTo} | |
| </div> | |
| <div> | |
| <span class="font-medium">Location:</span> ${task.location || 'Not specified'} | |
| </div> | |
| <div> | |
| <span class="font-medium">Status:</span> ${task.status} | |
| </div> | |
| <div> | |
| <span class="font-medium">Date Reported:</span> ${formatDate(task.dateReported)} | |
| </div> | |
| <div class="col-span-2"> | |
| <span class="font-medium">Created:</span> ${new Date(task.createdAt).toLocaleString()} | |
| </div> | |
| </div> | |
| <div class="mt-4 flex items-center gap-3"> | |
| <div class="flex flex-col gap-1"> | |
| ${task.imageDataUrl ? | |
| `<img src="${task.imageDataUrl}" alt="Attachment" class="w-16 h-16 rounded-xl object-cover border cursor-pointer" onclick="showImageModal('${task.imageDataUrl}')" />` : | |
| `<label class="flex items-center justify-center w-16 h-16 rounded-xl border border-gray-300 bg-gray-50 hover:bg-gray-100 cursor-pointer text-xs font-medium text-gray-700"> | |
| <input type="file" id="initialImage-${task.id}" accept="image/*" class="hidden" onchange="handleInitialImageUpload('${task.id}', this)"> | |
| Upload | |
| </label>` | |
| } | |
| <span class="text-xs text-gray-500 text-center">Before Task</span> | |
| </div> | |
| ${task.completedImageDataUrl ? | |
| `<img src="${task.completedImageDataUrl}" alt="Completed Photo" class="w-16 h-16 rounded-xl object-cover border cursor-pointer" onclick="showImageModal('${task.completedImageDataUrl}')" />` : | |
| task.status === 'Completed' ? ` | |
| <div class="flex flex-col gap-1"> | |
| <label class="flex items-center justify-center w-16 h-16 rounded-xl border border-gray-300 bg-gray-50 hover:bg-gray-100 cursor-pointer text-xs font-medium text-gray-700"> | |
| <input type="file" id="completedImage-${task.id}" accept="image/*" class="hidden" onchange="handleCompletedImageUpload('${task.id}', this)"> | |
| Upload | |
| </label> | |
| <span class="text-xs text-gray-500 text-center">Completed</span> | |
| </div> | |
| ` : '' | |
| } | |
| <div class="flex-1"> | |
| <label class="block text-sm text-gray-700 mb-1">Progress</label> | |
| <select | |
| onchange="updateTaskStatus('${task.id}', this.value)" | |
| class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| > | |
| ${STATUS_OPTIONS.map(s => ` | |
| <option value="${s}" ${task.status === s ? 'selected' : ''}>${s}</option> | |
| `).join('')} | |
| </select> | |
| </div> | |
| </div> | |
| <div class="mt-4"> | |
| <button | |
| type="button" | |
| id="toggleComments-${task.id}" | |
| onclick="toggleComments('${task.id}')" | |
| class="text-sm text-blue-700 hover:text-blue-800 underline" | |
| > | |
| Show updates (${task.comments.length}) | |
| </button> | |
| <div id="comments-${task.id}" class="mt-3 hidden"> | |
| <div class="space-y-2 comment-box"> | |
| ${task.comments.length === 0 ? | |
| `<div class="text-sm text-gray-600">No updates yet.</div>` : | |
| task.comments.map(c => ` | |
| <div class="border rounded-lg p-2"> | |
| <div class="text-xs text-gray-500">${new Date(c.createdAt).toLocaleString()}</div> | |
| <div class="text-sm text-gray-800 mt-1">${c.text}</div> | |
| ${c.imageDataUrl ? ` | |
| <div class="mt-2"> | |
| <img src="${c.imageDataUrl}" alt="Update photo" class="w-full max-h-40 object-contain rounded cursor-pointer" onclick="showImageModal('${c.imageDataUrl}')" /> | |
| <div class="text-xs text-gray-500 mt-1">Photo After Completion</div> | |
| </div> | |
| ` : ''} | |
| </div> | |
| `).join('') | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-3"> | |
| <label class="block text-sm font-medium text-gray-700 mb-1">Add update</label> | |
| <div class="flex flex-col gap-2"> | |
| <div class="flex items-start gap-2"> | |
| <textarea | |
| id="commentInput-${task.id}" | |
| placeholder="Write a quick update..." | |
| rows="2" | |
| class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" | |
| ></textarea> | |
| <button | |
| onclick="addComment('${task.id}', document.getElementById('commentInput-${task.id}'))" | |
| class="self-stretch px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm" | |
| > | |
| Post | |
| </button> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <label class="inline-flex items-center justify-center px-3 py-1 border border-gray-300 rounded-lg bg-gray-50 hover:bg-gray-100 cursor-pointer text-xs font-medium text-gray-700"> | |
| <input type="file" id="commentImage-${task.id}" accept="image/*" class="hidden"> | |
| Add Photo | |
| </label> | |
| <span class="text-xs text-gray-500">Photo After Completion</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-4"> | |
| </div> | |
| `).join(''); | |
| } | |
| // Encode tasks for URL sharing | |
| function encodeTasksForURL() { | |
| const compressed = LZString.compressToEncodedURIComponent(JSON.stringify(tasks)); | |
| const url = new URL(window.location.href); | |
| url.searchParams.set('tasks', compressed); | |
| window.history.replaceState({}, '', url); | |
| return url.toString(); | |
| } | |
| // Decode tasks from URL | |
| function decodeTasksFromURL() { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const compressed = urlParams.get('tasks'); | |
| if (compressed) { | |
| try { | |
| const decompressed = LZString.decompressFromEncodedURIComponent(compressed); | |
| return JSON.parse(decompressed); | |
| } catch (e) { | |
| console.error('Error parsing URL tasks:', e); | |
| } | |
| } | |
| return null; | |
| } | |
| // Share button functionality | |
| function setupShareButton() { | |
| const shareBtn = document.createElement('button'); | |
| shareBtn.className = 'fixed bottom-6 right-6 bg-blue-600 text-white p-3 rounded-full shadow-lg hover:bg-blue-700 transition-colors'; | |
| shareBtn.innerHTML = '<i data-feather="share-2"></i>'; | |
| shareBtn.title = 'Share current tasks'; | |
| shareBtn.onclick = () => { | |
| const shareUrl = encodeTasksForURL(); | |
| navigator.clipboard.writeText(shareUrl).then(() => { | |
| alert('Shareable link copied to clipboard!'); | |
| }).catch(() => { | |
| prompt('Copy this link to share:', shareUrl); | |
| }); | |
| }; | |
| document.body.appendChild(shareBtn); | |
| feather.replace(); | |
| } | |
| // Delete task function | |
| function deleteTask(taskId) { | |
| if (confirm('Are you sure you want to delete this task?')) { | |
| tasks = tasks.filter(task => task.id !== taskId); | |
| saveTasks(); | |
| updateAssigneeFilter(); | |
| renderTasks(); | |
| } | |
| return false; // Prevent event bubbling | |
| } | |
| // Initialize the app | |
| document.addEventListener("DOMContentLoaded", () => { | |
| // Add LZString compression library | |
| const script = document.createElement('script'); | |
| script.src = 'https://cdn.jsdelivr.net/npm/lz-string@1.4.4/libs/lz-string.min.js'; | |
| script.onload = () => { | |
| // Try to load from URL first | |
| const urlTasks = decodeTasksFromURL(); | |
| if (urlTasks) { | |
| tasks = urlTasks; | |
| saveTasks(); | |
| } | |
| init(); | |
| updateAssigneeFilter(); | |
| setupShareButton(); | |
| // Initialize location filter | |
| document.getElementById('locationFilter').addEventListener('change', renderTasks); | |
| }; | |
| document.head.appendChild(script); | |
| }); | |
| // Close modal with ESC key | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key === 'Escape') { | |
| document.getElementById('imageModal').classList.add('hidden'); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |