diff --git a/PhotoShotListPlanner/cobie1818/README.md b/PhotoShotListPlanner/cobie1818/README.md new file mode 100644 index 000000000..0a14ea9fa --- /dev/null +++ b/PhotoShotListPlanner/cobie1818/README.md @@ -0,0 +1,80 @@ +# Photo Shot List Planner + +A responsive photography planning app built with HTML, CSS, and vanilla +JavaScript. Photographers can organize shot ideas before a trip or photo +session and track which shots they have completed. + +## Features + +- Add shots with a title, category, and priority. +- Reject empty and whitespace-only titles. +- Mark shots as completed or pending. +- Filter by all, pending, or completed shots. +- Delete individual shots. +- Display a completed-shot counter. +- Save the list in localStorage between page visits. +- Adapt the layout to desktop and mobile screens. + +## Technologies + +- HTML5 for page structure and form controls +- CSS3 for styling and responsive layouts +- Vanilla JavaScript for interactions and list management +- Browser localStorage for persistence + +No framework, package installation, API key, or database is required. +VS Code's Live Server extension is used for local development. + +## Run Locally + +1. Clone or download the repository. +2. Open the repository folder in VS Code. +3. Install the Live Server extension if it is not already installed. +4. Navigate to PhotoShotListPlanner/cobie1818. +5. Right-click index.html and select "Open with Live Server." + +## How to Use + +1. Enter a shot idea. +2. Select its category and priority. +3. Click "Add shot." +4. Use a shot's checkbox to toggle its completion status. +5. Use "Show shots" to filter the list. +6. Click "Delete" to remove an unwanted shot. + +## Data Storage + +The list is saved in the current browser on the current device. +It does not synchronize between browsers or devices. Clearing browser +site data removes the saved list. + +The app displays a message if stored data cannot be loaded or changes +cannot be saved. User-entered titles are displayed as text rather +than interpreted as HTML. + +## Manual Testing + +The following checks were performed in Microsoft Edge: + +- Adding shots with different categories and priorities +- Completing shots and returning them to pending +- Updating the completion counter +- Filtering completed and pending shots +- Retaining the list and completion status after refresh +- Rejecting empty and whitespace-only titles +- Adding and deleting a temporary shot +- Inspecting the responsive layout at a 375-pixel viewport width + +No automated tests are included with this contribution. + +## Screenshot + +![Photo Shot List Planner with sample shots](screenshot.png) + +## Related Issue + +https://github.com/thinkswell/javascript-mini-projects/issues/1245 + +## Author + +Jacobie Jackson (cobie1818) \ No newline at end of file diff --git a/PhotoShotListPlanner/cobie1818/index.html b/PhotoShotListPlanner/cobie1818/index.html new file mode 100644 index 000000000..84e285782 --- /dev/null +++ b/PhotoShotListPlanner/cobie1818/index.html @@ -0,0 +1,86 @@ + + + + + + Photo Shot List Planner + + + + +
+
+

PLAN YOUR NEXT PHOTO SESSION

+

Photo Shot List Planner

+

Keep your ideas organized and capture every shot that matters.

+
+ +
+

Add a shot

+ +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ +

+
+ +
+
+

Your shot list

+

0 of 0 shots completed

+
+ +
+ + +
+ +

Your list is empty. Add your first shot above.

+ +
+ + +
+ + \ No newline at end of file diff --git a/PhotoShotListPlanner/cobie1818/screenshot.png b/PhotoShotListPlanner/cobie1818/screenshot.png new file mode 100644 index 000000000..0a033367c Binary files /dev/null and b/PhotoShotListPlanner/cobie1818/screenshot.png differ diff --git a/PhotoShotListPlanner/cobie1818/script.js b/PhotoShotListPlanner/cobie1818/script.js new file mode 100644 index 000000000..c2e151d21 --- /dev/null +++ b/PhotoShotListPlanner/cobie1818/script.js @@ -0,0 +1,233 @@ +"use strict"; + +const STORAGE_KEY = "photo-shot-list-planner-cobie1818"; + +const form = document.getElementById("shot-form"); +const titleInput = document.getElementById("shot-title"); +const categoryInput = document.getElementById("shot-category"); +const priorityInput = document.getElementById("shot-priority"); +const filterInput = document.getElementById("shot-filter"); +const shotList = document.getElementById("shot-list"); +const shotCount = document.getElementById("shot-count"); +const emptyMessage = document.getElementById("empty-message"); +const feedback = document.getElementById("feedback"); + +const categories = Array.from(categoryInput.options, option => option.value); +const priorities = Array.from(priorityInput.options, option => option.value); + +let storageWarning = ""; +let shots = loadShots(); + +function showFeedback(message) { + feedback.textContent = [message, storageWarning].filter(Boolean).join(" "); +} + +function loadShots() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + + if (saved === null) { + return []; + } + + const parsed = JSON.parse(saved); + const ids = new Set(); + + // Validate stored data before using it in the interface. + if (!Array.isArray(parsed) || !parsed.every(shot => { + const valid = + shot !== null && + typeof shot === "object" && + typeof shot.id === "string" && + shot.id.length > 0 && + !ids.has(shot.id) && + typeof shot.title === "string" && + shot.title.trim().length > 0 && + shot.title.length <= 120 && + categories.includes(shot.category) && + priorities.includes(shot.priority) && + typeof shot.completed === "boolean"; + + if (valid) { + ids.add(shot.id); + } + + return valid; + })) { + throw new Error("Invalid saved shot list"); + } + + return parsed; + } catch { + storageWarning = + "The saved list could not be loaded. An empty list is shown."; + return []; + } +} + +function saveShots() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(shots)); + storageWarning = ""; + } catch { + storageWarning = + "Changes could not be saved. Keep this page open to retain this list."; + } +} + +function createId() { + let id; + + do { + id = `shot-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + } while (shots.some(shot => shot.id === id)); + + return id; +} + +function createBadge(text, extraClass = "") { + const badge = document.createElement("span"); + badge.className = `badge ${extraClass}`.trim(); + badge.textContent = text; + return badge; +} + +function createShotCard(shot) { + const card = document.createElement("li"); + card.className = shot.completed ? "shot-card completed" : "shot-card"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "shot-checkbox"; + checkbox.checked = shot.completed; + checkbox.id = shot.id; + + const details = document.createElement("div"); + details.className = "shot-details"; + + // textContent displays user input as text, never as HTML. + const title = document.createElement("label"); + title.className = "shot-title"; + title.htmlFor = checkbox.id; + title.textContent = shot.title; + + const meta = document.createElement("div"); + meta.className = "shot-meta"; + meta.append( + createBadge(shot.category), + createBadge( + `${shot.priority} priority`, + `priority-${shot.priority.toLowerCase()}` + ) + ); + + details.append(title, meta); + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "delete-button"; + deleteButton.textContent = "Delete"; + deleteButton.setAttribute("aria-label", `Delete shot: ${shot.title}`); + + checkbox.addEventListener("change", () => { + shot.completed = checkbox.checked; + saveShots(); + renderShots(); + + // Restore keyboard focus after rebuilding the list. + const updatedCheckbox = document.getElementById(shot.id); + + if (updatedCheckbox) { + updatedCheckbox.focus(); + } else { + filterInput.focus(); + } + + showFeedback( + `"${shot.title}" marked ${shot.completed ? "completed" : "pending"}.` + ); + }); + + deleteButton.addEventListener("click", () => { + shots = shots.filter(item => item.id !== shot.id); + saveShots(); + renderShots(); + filterInput.focus(); + showFeedback(`Deleted "${shot.title}".`); + }); + + card.append(checkbox, details, deleteButton); + return card; +} + +function renderShots() { + const selectedFilter = filterInput.value; + const visibleShots = shots.filter(shot => { + if (selectedFilter === "pending") { + return !shot.completed; + } + + if (selectedFilter === "completed") { + return shot.completed; + } + + return true; + }); + + shotList.replaceChildren(); + + visibleShots.forEach(shot => { + shotList.appendChild(createShotCard(shot)); + }); + + const completedCount = shots.filter(shot => shot.completed).length; + shotCount.textContent = + `${completedCount} of ${shots.length} shots completed`; + + emptyMessage.hidden = visibleShots.length > 0; + + if (shots.length === 0) { + emptyMessage.textContent = + "Your list is empty. Add your first shot above."; + } else { + emptyMessage.textContent = `No ${selectedFilter} shots to show.`; + } +} + +titleInput.addEventListener("input", () => { + titleInput.setCustomValidity(""); +}); + +form.addEventListener("submit", event => { + event.preventDefault(); + + const title = titleInput.value.trim(); + + if (!title) { + titleInput.setCustomValidity("Enter a shot idea, not just spaces."); + titleInput.reportValidity(); + return; + } + + shots.push({ + id: createId(), + title, + category: categoryInput.value, + priority: priorityInput.value, + completed: false + }); + + saveShots(); + form.reset(); + + // Show the new shot even if the completed filter was selected. + filterInput.value = "all"; + renderShots(); + titleInput.focus(); + showFeedback(`Added "${title}".`); +}); + +filterInput.addEventListener("change", renderShots); + +renderShots(); +showFeedback(""); \ No newline at end of file diff --git a/PhotoShotListPlanner/cobie1818/style.css b/PhotoShotListPlanner/cobie1818/style.css new file mode 100644 index 000000000..301e78d4e --- /dev/null +++ b/PhotoShotListPlanner/cobie1818/style.css @@ -0,0 +1,274 @@ +:root { + color-scheme: light; + font-family: Arial, Helvetica, sans-serif; + color: #20312d; + background: #f3f5f1; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + line-height: 1.6; +} + +.container { + width: min(900px, 92%); + margin: 0 auto; + padding: 48px 0; +} + +header { + margin-bottom: 32px; +} + +.eyebrow { + color: #406650; + font-size: 0.8rem; + font-weight: bold; + letter-spacing: 0.12em; +} + +h1 { + margin: 8px 0; + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1.2; +} + +h2 { + margin: 0 0 20px; + font-size: 1.3rem; +} + +header > p:last-child, +footer, +#shot-count { + color: #53635b; +} + +.panel { + margin-bottom: 24px; + padding: 28px; + background: #fff; + border: 1px solid #dce3da; + border-radius: 16px; + box-shadow: 0 8px 24px rgb(32 49 45 / 5%); +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 18px; +} + +label { + font-weight: bold; +} + +input, +select, +button { + font: inherit; +} + +input[type="text"], +select { + width: 100%; + padding: 12px; + color: #20312d; + background: #fff; + border: 1px solid #86978b; + border-radius: 8px; +} + +input::placeholder { + color: #657369; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +button { + min-height: 44px; + padding: 10px 18px; + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; +} + +.primary-button { + color: #fff; + background: #315d46; + font-weight: bold; +} + +.primary-button:hover { + background: #244734; +} + +:focus-visible { + outline: 3px solid #2868b2; + outline-offset: 3px; +} + +#feedback { + margin: 14px 0 0; + color: #315d46; +} + +#feedback:empty { + display: none; +} + +.list-header { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px 20px; + margin-bottom: 18px; +} + +.list-header h2, +#shot-count { + margin: 0; +} + +.filter-field { + max-width: 240px; +} + +#empty-message { + padding: 24px 12px; + color: #53635b; + text-align: center; + background: #f5f7f3; + border-radius: 8px; +} + +.shot-list { + display: grid; + gap: 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.shot-card { + display: flex; + align-items: center; + gap: 14px; + padding: 18px; + border: 1px solid #dce3da; + border-radius: 10px; +} + +.shot-checkbox { + flex-shrink: 0; + width: 22px; + height: 22px; + margin: 0; + accent-color: #315d46; + cursor: pointer; +} + +.shot-details { + flex: 1; + min-width: 0; +} + +.shot-title { + display: block; + margin: 0 0 6px; + overflow-wrap: anywhere; + font-weight: bold; +} + +.shot-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 0.85rem; +} + +.badge { + padding: 3px 10px; + color: #384b40; + background: #edf1eb; + border-radius: 20px; +} + +.priority-high { + color: #842b21; + background: #fde8e4; +} + +.priority-medium { + color: #705008; + background: #fff2cc; +} + +.priority-low { + color: #24573c; + background: #e5f2e9; +} + +.shot-card.completed { + background: #f5f7f3; +} + +.completed .shot-title { + color: #59665d; + text-decoration: line-through; +} + +.delete-button { + flex-shrink: 0; + color: #842b21; + background: #fff; + border-color: #d9ada7; +} + +.delete-button:hover { + background: #fde8e4; +} + +footer { + text-align: center; + font-size: 0.85rem; +} + +@media (max-width: 600px) { + .container { + padding: 28px 0; + } + + .panel { + padding: 20px; + } + + .form-row { + grid-template-columns: 1fr; + gap: 0; + } + + .primary-button, + .filter-field { + width: 100%; + max-width: none; + } + + .shot-card { + flex-wrap: wrap; + } + + .delete-button { + margin-left: auto; + } +} \ No newline at end of file