Commit f5b02dc

bryfry <bryon@fryer.io>
2025-07-22 11:11:37
Task 1.9: Implement static file serving
- Configure http.FileServer with /static/ route in main.go using http.StripPrefix - Create ui/static directory structure with css/, js/, img/ folders - Move all inline CSS from templates to external main.css file - Update base template to reference external CSS and JavaScript files - Create main.js with form validation and UX enhancements for submit form - Add favicon.svg with buylater.email "B" branding - Remove all inline <style> blocks from templates for cleaner separation - Include proper favicon link in base template head section - Update project_plan.md status to Completed See: docs/todo/task_1.9.md
1 parent 33ab906
Changed files (8)
cmd/web/main.go
@@ -10,6 +10,16 @@ func main() {
 	// URL patterns and their corresponding handlers.
 	mux := http.NewServeMux()
 
+	// Create a file server which serves files out of the "./ui/static" directory.
+	// Note that the path given to the http.Dir function is relative to the project
+	// directory root.
+	fileServer := http.FileServer(http.Dir("./ui/static/"))
+
+	// Use the mux.Handle() function to register the file server as the handler for
+	// all URL paths that start with "/static/". For matching paths, we strip the
+	// "/static" prefix before the request reaches the file server.
+	mux.Handle("GET /static/", http.StripPrefix("/static", fileServer))
+
 	// Register handlers for buylater.email routes
 	mux.HandleFunc("GET /{$}", home)                         // Exact match for home page
 	mux.HandleFunc("GET /submit", submitForm)                // Display email submission form
docs/project_plan.md
@@ -16,7 +16,7 @@ Phase 1 lays the groundwork by methodically working through the Let's Go book, c
 | 1.6  | Custom Response Headers and Status Codes | Completed | Small  | 2.6     | [task_1.6.md](todo/task_1.6.md) |
 | 1.7  | Project Structure and Organization       | Completed | Medium | 2.7     | [task_1.7.md](todo/task_1.7.md) |
 | 1.8  | HTML Templating and Inheritance         | Completed | Medium | 2.8     | [task_1.8.md](todo/task_1.8.md) |
-| 1.9  | Serving Static Files                    | In Progress | Small  | 2.9     | [task_1.9.md](todo/task_1.9.md) |
+| 1.9  | Serving Static Files                    | Completed | Small  | 2.9     | [task_1.9.md](todo/task_1.9.md) |
 
 ## Status Legend
 - **Completed** - Implemented and verified
ui/html/layouts/base.tmpl
@@ -5,95 +5,8 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>{{.Title}} - buylater.email</title>
-    <style>
-        * {
-            box-sizing: border-box;
-            margin: 0;
-            padding: 0;
-        }
-
-        body {
-            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
-            line-height: 1.6;
-            color: #333;
-            background-color: #f8f9fa;
-        }
-
-        .container {
-            max-width: 800px;
-            margin: 0 auto;
-            padding: 0 20px;
-        }
-
-        header {
-            background-color: #fff;
-            border-bottom: 1px solid #e9ecef;
-            padding: 1rem 0;
-        }
-
-        .header-content {
-            display: flex;
-            justify-content: space-between;
-            align-items: center;
-        }
-
-        .logo {
-            font-size: 1.5rem;
-            font-weight: 600;
-            color: #2c3e50;
-            text-decoration: none;
-        }
-
-        nav a {
-            text-decoration: none;
-            color: #6c757d;
-            margin-left: 1.5rem;
-            font-weight: 500;
-        }
-
-        nav a:hover {
-            color: #2c3e50;
-        }
-
-        main {
-            padding: 2rem 0;
-            min-height: calc(100vh - 160px);
-        }
-
-        footer {
-            background-color: #fff;
-            border-top: 1px solid #e9ecef;
-            padding: 1.5rem 0;
-            margin-top: 2rem;
-            color: #6c757d;
-            font-size: 0.9rem;
-        }
-
-        .footer-content {
-            text-align: center;
-        }
-
-        .tagline {
-            font-style: italic;
-            margin-top: 0.5rem;
-        }
-
-        @media (max-width: 768px) {
-            .container {
-                padding: 0 15px;
-            }
-            
-            .header-content {
-                flex-direction: column;
-                gap: 1rem;
-            }
-            
-            nav a {
-                margin-left: 1rem;
-                margin-right: 1rem;
-            }
-        }
-    </style>
+    <link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg">
+    <link rel="stylesheet" href="/static/css/main.css">
 </head>
 <body>
     <header>
@@ -123,6 +36,8 @@
             </div>
         </div>
     </footer>
+    
+    <script src="/static/js/main.js"></script>
 </body>
 </html>
 {{end}}
\ No newline at end of file
ui/html/pages/home.tmpl
@@ -37,121 +37,4 @@
     </blockquote>
 </div>
 
-<style>
-.hero {
-    text-align: center;
-    padding: 3rem 0;
-}
-
-.hero h1 {
-    font-size: 2.5rem;
-    font-weight: 700;
-    color: #2c3e50;
-    margin-bottom: 1rem;
-}
-
-.hero-subtitle {
-    font-size: 1.2rem;
-    color: #6c757d;
-    margin-bottom: 2rem;
-    max-width: 600px;
-    margin-left: auto;
-    margin-right: auto;
-}
-
-.cta-section {
-    margin: 2rem 0;
-}
-
-.cta-button {
-    display: inline-block;
-    background-color: #2c3e50;
-    color: white;
-    padding: 1rem 2rem;
-    text-decoration: none;
-    border-radius: 8px;
-    font-weight: 600;
-    font-size: 1.1rem;
-    transition: background-color 0.2s;
-}
-
-.cta-button:hover {
-    background-color: #34495e;
-}
-
-.cta-description {
-    margin-top: 0.5rem;
-    color: #6c757d;
-    font-size: 0.9rem;
-}
-
-.features {
-    margin: 4rem 0;
-}
-
-.feature-grid {
-    display: grid;
-    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
-    gap: 2rem;
-    margin-top: 2rem;
-}
-
-.feature {
-    background: white;
-    padding: 1.5rem;
-    border-radius: 8px;
-    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
-}
-
-.feature h3 {
-    color: #2c3e50;
-    margin-bottom: 0.5rem;
-}
-
-.philosophy {
-    background: white;
-    padding: 2rem;
-    border-radius: 8px;
-    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
-    margin: 3rem 0;
-}
-
-.philosophy h2 {
-    color: #2c3e50;
-    margin-bottom: 1rem;
-}
-
-.philosophy p {
-    margin-bottom: 1.5rem;
-    line-height: 1.7;
-}
-
-.philosophy blockquote {
-    font-style: italic;
-    font-size: 1.1rem;
-    color: #6c757d;
-    border-left: 4px solid #2c3e50;
-    padding-left: 1rem;
-    margin: 1rem 0;
-}
-
-@media (max-width: 768px) {
-    .hero h1 {
-        font-size: 2rem;
-    }
-    
-    .hero-subtitle {
-        font-size: 1.1rem;
-    }
-    
-    .feature-grid {
-        grid-template-columns: 1fr;
-        gap: 1rem;
-    }
-    
-    .philosophy {
-        padding: 1.5rem;
-    }
-}
-</style>
 {{end}}
\ No newline at end of file
ui/html/pages/submit.tmpl
@@ -47,120 +47,4 @@
     </div>
 </div>
 
-<style>
-.submit-page {
-    max-width: 600px;
-    margin: 0 auto;
-}
-
-.submit-header {
-    text-align: center;
-    margin-bottom: 3rem;
-}
-
-.submit-header h1 {
-    color: #2c3e50;
-    margin-bottom: 0.5rem;
-}
-
-.submit-header p {
-    color: #6c757d;
-    font-size: 1.1rem;
-}
-
-.submit-form-container {
-    display: grid;
-    gap: 3rem;
-}
-
-.submit-form {
-    background: white;
-    padding: 2rem;
-    border-radius: 8px;
-    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
-}
-
-.form-group {
-    margin-bottom: 1.5rem;
-}
-
-.form-group label {
-    display: block;
-    font-weight: 600;
-    color: #2c3e50;
-    margin-bottom: 0.5rem;
-}
-
-.form-group input,
-.form-group select {
-    width: 100%;
-    padding: 0.75rem;
-    border: 2px solid #e9ecef;
-    border-radius: 4px;
-    font-size: 1rem;
-    transition: border-color 0.2s;
-}
-
-.form-group input:focus,
-.form-group select:focus {
-    outline: none;
-    border-color: #2c3e50;
-}
-
-.form-group small {
-    display: block;
-    margin-top: 0.25rem;
-    color: #6c757d;
-    font-size: 0.9rem;
-}
-
-.submit-button {
-    width: 100%;
-    background-color: #2c3e50;
-    color: white;
-    padding: 1rem 2rem;
-    border: none;
-    border-radius: 8px;
-    font-weight: 600;
-    font-size: 1.1rem;
-    cursor: pointer;
-    transition: background-color 0.2s;
-}
-
-.submit-button:hover {
-    background-color: #34495e;
-}
-
-.submit-info {
-    background: white;
-    padding: 2rem;
-    border-radius: 8px;
-    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
-}
-
-.submit-info h3 {
-    color: #2c3e50;
-    margin-bottom: 1rem;
-}
-
-.submit-info ol {
-    padding-left: 1.2rem;
-}
-
-.submit-info li {
-    margin-bottom: 0.5rem;
-    line-height: 1.6;
-}
-
-@media (max-width: 768px) {
-    .submit-form,
-    .submit-info {
-        padding: 1.5rem;
-    }
-    
-    .submit-form-container {
-        gap: 2rem;
-    }
-}
-</style>
 {{end}}
\ No newline at end of file
ui/static/css/main.css
@@ -0,0 +1,321 @@
+/* Reset and Base Styles */
+* {
+    box-sizing: border-box;
+    margin: 0;
+    padding: 0;
+}
+
+body {
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+    line-height: 1.6;
+    color: #333;
+    background-color: #f8f9fa;
+}
+
+.container {
+    max-width: 800px;
+    margin: 0 auto;
+    padding: 0 20px;
+}
+
+/* Header Styles */
+header {
+    background-color: #fff;
+    border-bottom: 1px solid #e9ecef;
+    padding: 1rem 0;
+}
+
+.header-content {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+.logo {
+    font-size: 1.5rem;
+    font-weight: 600;
+    color: #2c3e50;
+    text-decoration: none;
+}
+
+nav a {
+    text-decoration: none;
+    color: #6c757d;
+    margin-left: 1.5rem;
+    font-weight: 500;
+}
+
+nav a:hover {
+    color: #2c3e50;
+}
+
+/* Main Content */
+main {
+    padding: 2rem 0;
+    min-height: calc(100vh - 160px);
+}
+
+/* Footer */
+footer {
+    background-color: #fff;
+    border-top: 1px solid #e9ecef;
+    padding: 1.5rem 0;
+    margin-top: 2rem;
+    color: #6c757d;
+    font-size: 0.9rem;
+}
+
+.footer-content {
+    text-align: center;
+}
+
+.tagline {
+    font-style: italic;
+    margin-top: 0.5rem;
+}
+
+/* Home Page Styles */
+.hero {
+    text-align: center;
+    padding: 3rem 0;
+}
+
+.hero h1 {
+    font-size: 2.5rem;
+    font-weight: 700;
+    color: #2c3e50;
+    margin-bottom: 1rem;
+}
+
+.hero-subtitle {
+    font-size: 1.2rem;
+    color: #6c757d;
+    margin-bottom: 2rem;
+    max-width: 600px;
+    margin-left: auto;
+    margin-right: auto;
+}
+
+.cta-section {
+    margin: 2rem 0;
+}
+
+.cta-button {
+    display: inline-block;
+    background-color: #2c3e50;
+    color: white;
+    padding: 1rem 2rem;
+    text-decoration: none;
+    border-radius: 8px;
+    font-weight: 600;
+    font-size: 1.1rem;
+    transition: background-color 0.2s;
+}
+
+.cta-button:hover {
+    background-color: #34495e;
+}
+
+.cta-description {
+    margin-top: 0.5rem;
+    color: #6c757d;
+    font-size: 0.9rem;
+}
+
+.features {
+    margin: 4rem 0;
+}
+
+.feature-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+    gap: 2rem;
+    margin-top: 2rem;
+}
+
+.feature {
+    background: white;
+    padding: 1.5rem;
+    border-radius: 8px;
+    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.feature h3 {
+    color: #2c3e50;
+    margin-bottom: 0.5rem;
+}
+
+.philosophy {
+    background: white;
+    padding: 2rem;
+    border-radius: 8px;
+    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+    margin: 3rem 0;
+}
+
+.philosophy h2 {
+    color: #2c3e50;
+    margin-bottom: 1rem;
+}
+
+.philosophy p {
+    margin-bottom: 1.5rem;
+    line-height: 1.7;
+}
+
+.philosophy blockquote {
+    font-style: italic;
+    font-size: 1.1rem;
+    color: #6c757d;
+    border-left: 4px solid #2c3e50;
+    padding-left: 1rem;
+    margin: 1rem 0;
+}
+
+/* Submit Page Styles */
+.submit-page {
+    max-width: 600px;
+    margin: 0 auto;
+}
+
+.submit-header {
+    text-align: center;
+    margin-bottom: 3rem;
+}
+
+.submit-header h1 {
+    color: #2c3e50;
+    margin-bottom: 0.5rem;
+}
+
+.submit-header p {
+    color: #6c757d;
+    font-size: 1.1rem;
+}
+
+.submit-form-container {
+    display: grid;
+    gap: 3rem;
+}
+
+.submit-form {
+    background: white;
+    padding: 2rem;
+    border-radius: 8px;
+    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.form-group {
+    margin-bottom: 1.5rem;
+}
+
+.form-group label {
+    display: block;
+    font-weight: 600;
+    color: #2c3e50;
+    margin-bottom: 0.5rem;
+}
+
+.form-group input,
+.form-group select {
+    width: 100%;
+    padding: 0.75rem;
+    border: 2px solid #e9ecef;
+    border-radius: 4px;
+    font-size: 1rem;
+    transition: border-color 0.2s;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+    outline: none;
+    border-color: #2c3e50;
+}
+
+.form-group small {
+    display: block;
+    margin-top: 0.25rem;
+    color: #6c757d;
+    font-size: 0.9rem;
+}
+
+.submit-button {
+    width: 100%;
+    background-color: #2c3e50;
+    color: white;
+    padding: 1rem 2rem;
+    border: none;
+    border-radius: 8px;
+    font-weight: 600;
+    font-size: 1.1rem;
+    cursor: pointer;
+    transition: background-color 0.2s;
+}
+
+.submit-button:hover {
+    background-color: #34495e;
+}
+
+.submit-info {
+    background: white;
+    padding: 2rem;
+    border-radius: 8px;
+    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.submit-info h3 {
+    color: #2c3e50;
+    margin-bottom: 1rem;
+}
+
+.submit-info ol {
+    padding-left: 1.2rem;
+}
+
+.submit-info li {
+    margin-bottom: 0.5rem;
+    line-height: 1.6;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+    .container {
+        padding: 0 15px;
+    }
+    
+    .header-content {
+        flex-direction: column;
+        gap: 1rem;
+    }
+    
+    nav a {
+        margin-left: 1rem;
+        margin-right: 1rem;
+    }
+
+    .hero h1 {
+        font-size: 2rem;
+    }
+    
+    .hero-subtitle {
+        font-size: 1.1rem;
+    }
+    
+    .feature-grid {
+        grid-template-columns: 1fr;
+        gap: 1rem;
+    }
+    
+    .philosophy {
+        padding: 1.5rem;
+    }
+
+    .submit-form,
+    .submit-info {
+        padding: 1.5rem;
+    }
+    
+    .submit-form-container {
+        gap: 2rem;
+    }
+}
\ No newline at end of file
ui/static/img/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+    <rect width="32" height="32" fill="#2c3e50"/>
+    <text x="16" y="20" font-family="Arial, sans-serif" font-size="16" font-weight="bold" text-anchor="middle" fill="white">B</text>
+</svg>
\ No newline at end of file
ui/static/js/main.js
@@ -0,0 +1,163 @@
+// Main JavaScript for buylater.email
+// Provides form validation and UX enhancements
+
+document.addEventListener('DOMContentLoaded', function() {
+    // Form validation for submit page
+    const submitForm = document.querySelector('.submit-form');
+    
+    if (submitForm) {
+        setupFormValidation(submitForm);
+    }
+
+    // Add smooth scrolling for anchor links
+    document.querySelectorAll('a[href^="#"]').forEach(anchor => {
+        anchor.addEventListener('click', function (e) {
+            e.preventDefault();
+            const target = document.querySelector(this.getAttribute('href'));
+            if (target) {
+                target.scrollIntoView({
+                    behavior: 'smooth'
+                });
+            }
+        });
+    });
+});
+
+function setupFormValidation(form) {
+    const emailInput = form.querySelector('#email');
+    const urlInput = form.querySelector('#url');
+    const submitButton = form.querySelector('.submit-button');
+    
+    // Real-time validation for email
+    if (emailInput) {
+        emailInput.addEventListener('blur', function() {
+            validateEmail(this);
+        });
+        
+        emailInput.addEventListener('input', function() {
+            clearValidationError(this);
+        });
+    }
+    
+    // Real-time validation for URL
+    if (urlInput) {
+        urlInput.addEventListener('blur', function() {
+            validateUrl(this);
+        });
+        
+        urlInput.addEventListener('input', function() {
+            clearValidationError(this);
+        });
+    }
+    
+    // Form submission validation
+    form.addEventListener('submit', function(e) {
+        let isValid = true;
+        
+        if (emailInput && !validateEmail(emailInput)) {
+            isValid = false;
+        }
+        
+        if (urlInput && !validateUrl(urlInput)) {
+            isValid = false;
+        }
+        
+        if (!isValid) {
+            e.preventDefault();
+            showFormError('Please fix the errors above before submitting.');
+        } else {
+            // Show loading state
+            submitButton.textContent = 'Scheduling...';
+            submitButton.disabled = true;
+        }
+    });
+}
+
+function validateEmail(input) {
+    const email = input.value.trim();
+    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+    
+    if (!email) {
+        showFieldError(input, 'Email address is required.');
+        return false;
+    }
+    
+    if (!emailRegex.test(email)) {
+        showFieldError(input, 'Please enter a valid email address.');
+        return false;
+    }
+    
+    clearValidationError(input);
+    return true;
+}
+
+function validateUrl(input) {
+    const url = input.value.trim();
+    
+    if (!url) {
+        showFieldError(input, 'Product URL is required.');
+        return false;
+    }
+    
+    try {
+        const urlObj = new URL(url);
+        if (!['http:', 'https:'].includes(urlObj.protocol)) {
+            throw new Error('Invalid protocol');
+        }
+    } catch (e) {
+        showFieldError(input, 'Please enter a valid URL (including http:// or https://).');
+        return false;
+    }
+    
+    clearValidationError(input);
+    return true;
+}
+
+function showFieldError(input, message) {
+    clearValidationError(input);
+    
+    const errorDiv = document.createElement('div');
+    errorDiv.className = 'field-error';
+    errorDiv.textContent = message;
+    errorDiv.style.color = '#dc3545';
+    errorDiv.style.fontSize = '0.875rem';
+    errorDiv.style.marginTop = '0.25rem';
+    
+    input.style.borderColor = '#dc3545';
+    input.parentNode.appendChild(errorDiv);
+}
+
+function clearValidationError(input) {
+    const existingError = input.parentNode.querySelector('.field-error');
+    if (existingError) {
+        existingError.remove();
+    }
+    
+    const existingFormError = document.querySelector('.form-error');
+    if (existingFormError) {
+        existingFormError.remove();
+    }
+    
+    input.style.borderColor = '#e9ecef';
+}
+
+function showFormError(message) {
+    const existingError = document.querySelector('.form-error');
+    if (existingError) {
+        existingError.remove();
+    }
+    
+    const errorDiv = document.createElement('div');
+    errorDiv.className = 'form-error';
+    errorDiv.textContent = message;
+    errorDiv.style.color = '#dc3545';
+    errorDiv.style.backgroundColor = '#f8d7da';
+    errorDiv.style.border = '1px solid #f5c6cb';
+    errorDiv.style.borderRadius = '4px';
+    errorDiv.style.padding = '0.75rem';
+    errorDiv.style.marginBottom = '1rem';
+    errorDiv.style.fontSize = '0.875rem';
+    
+    const form = document.querySelector('.submit-form');
+    form.insertBefore(errorDiv, form.firstChild);
+}
\ No newline at end of file