A community based topic aggregation platform built on atproto

feat(web): add landing page with distinctive design

- Add landing page template with custom typography (Fraunces + Plus Jakarta Sans)
- Implement brand lockup with mascot + logo side-by-side layout
- Add ambient gradient background with noise texture overlay
- Include floating decorative blur elements with drift animation
- Add staggered fade-in animations for hero content
- Implement responsive design with mobile-first approach
- Add delete account and success page templates
- Include static assets (mascot, logo, app icon)
- Add web routes and handlers for serving pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+1423
+39
internal/api/routes/web.go
··· 1 + package routes 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + 8 + "Coves/internal/atproto/oauth" 9 + "Coves/internal/core/users" 10 + "Coves/internal/web" 11 + ) 12 + 13 + // RegisterWebRoutes registers all web page routes for the Coves frontend. 14 + // This includes the landing page, account deletion flow, and static assets. 15 + func RegisterWebRoutes(r chi.Router, oauthClient *oauth.OAuthClient, userService users.UserService) { 16 + // Initialize templates 17 + templates, err := web.NewTemplates() 18 + if err != nil { 19 + panic("failed to load web templates: " + err.Error()) 20 + } 21 + 22 + // Create handlers 23 + handlers := web.NewHandlers(templates, oauthClient, userService) 24 + 25 + // Landing page 26 + r.Get("/", handlers.LandingHandler) 27 + 28 + // Account deletion flow 29 + r.Get("/delete-account", handlers.DeleteAccountPageHandler) 30 + r.Post("/delete-account", handlers.DeleteAccountSubmitHandler) 31 + r.Get("/delete-account/success", handlers.DeleteAccountSuccessHandler) 32 + 33 + // Static files (images, etc.) 34 + r.Get("/static/*", func(w http.ResponseWriter, r *http.Request) { 35 + // Serve from project's static directory 36 + fs := http.StripPrefix("/static/", http.FileServer(http.Dir("static"))) 37 + fs.ServeHTTP(w, r) 38 + }) 39 + }
+175
internal/web/handlers.go
··· 1 + package web 2 + 3 + import ( 4 + "log" 5 + "log/slog" 6 + "net/http" 7 + 8 + "Coves/internal/atproto/oauth" 9 + "Coves/internal/core/users" 10 + ) 11 + 12 + // Handlers provides HTTP handlers for the Coves web interface. 13 + // This includes the landing page, static files, and account management. 14 + type Handlers struct { 15 + templates *Templates 16 + oauthClient *oauth.OAuthClient 17 + userService users.UserService 18 + } 19 + 20 + // NewHandlers creates a new Handlers instance with the provided dependencies. 21 + func NewHandlers(templates *Templates, oauthClient *oauth.OAuthClient, userService users.UserService) *Handlers { 22 + return &Handlers{ 23 + templates: templates, 24 + oauthClient: oauthClient, 25 + userService: userService, 26 + } 27 + } 28 + 29 + // LandingPageData holds data for the landing page template. 30 + type LandingPageData struct { 31 + // Title is the page title 32 + Title string 33 + // Description is the meta description for SEO 34 + Description string 35 + // AppStoreURL is the URL for the iOS App Store listing 36 + AppStoreURL string 37 + // PlayStoreURL is the URL for the Google Play Store listing 38 + PlayStoreURL string 39 + } 40 + 41 + // LandingHandler handles GET / requests and renders the landing page. 42 + func (h *Handlers) LandingHandler(w http.ResponseWriter, r *http.Request) { 43 + // Only handle exact root path - let other routes handle their own paths 44 + if r.URL.Path != "/" { 45 + http.NotFound(w, r) 46 + return 47 + } 48 + 49 + data := LandingPageData{ 50 + Title: "Coves - Community-Driven Forums on atProto", 51 + Description: "Coves is a forum-like social app built on the AT Protocol. Join communities, share content, and own your data.", 52 + // App store URLs - update these when apps are published 53 + AppStoreURL: "https://apps.apple.com/app/coves", 54 + PlayStoreURL: "https://play.google.com/store/apps/details?id=social.coves.app", 55 + } 56 + 57 + if err := h.templates.Render(w, "landing.html", data); err != nil { 58 + log.Printf("Failed to render landing page: %v", err) 59 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 60 + } 61 + } 62 + 63 + // DeleteAccountPageData contains the data for the delete account template 64 + type DeleteAccountPageData struct { 65 + LoggedIn bool 66 + Handle string 67 + DID string 68 + } 69 + 70 + // DeleteAccountPageHandler renders the delete account page 71 + // GET /delete-account 72 + func (h *Handlers) DeleteAccountPageHandler(w http.ResponseWriter, r *http.Request) { 73 + data := DeleteAccountPageData{ 74 + LoggedIn: false, 75 + } 76 + 77 + // Check for session cookie 78 + cookie, err := r.Cookie("coves_session") 79 + if err == nil && cookie.Value != "" { 80 + // Try to unseal the session 81 + sealed, err := h.oauthClient.UnsealSession(cookie.Value) 82 + if err == nil && sealed != nil { 83 + // Session is valid, get user info 84 + user, err := h.userService.GetUserByDID(r.Context(), sealed.DID) 85 + if err == nil && user != nil { 86 + data.LoggedIn = true 87 + data.Handle = user.Handle 88 + data.DID = user.DID 89 + } else { 90 + slog.Warn("delete account: failed to get user by DID", 91 + "did", sealed.DID, "error", err) 92 + } 93 + } else { 94 + slog.Debug("delete account: invalid or expired session", "error", err) 95 + } 96 + } 97 + 98 + if err := h.templates.Render(w, "delete_account.html", data); err != nil { 99 + slog.Error("failed to render delete account template", "error", err) 100 + http.Error(w, "Internal server error", http.StatusInternalServerError) 101 + } 102 + } 103 + 104 + // DeleteAccountSubmitHandler processes the account deletion request 105 + // POST /delete-account 106 + func (h *Handlers) DeleteAccountSubmitHandler(w http.ResponseWriter, r *http.Request) { 107 + ctx := r.Context() 108 + 109 + // Verify session 110 + cookie, err := r.Cookie("coves_session") 111 + if err != nil || cookie.Value == "" { 112 + slog.Warn("delete account submit: no session cookie") 113 + http.Redirect(w, r, "/delete-account", http.StatusFound) 114 + return 115 + } 116 + 117 + // Unseal the session 118 + sealed, err := h.oauthClient.UnsealSession(cookie.Value) 119 + if err != nil || sealed == nil { 120 + slog.Warn("delete account submit: invalid session", "error", err) 121 + h.clearSessionCookie(w) 122 + http.Redirect(w, r, "/delete-account", http.StatusFound) 123 + return 124 + } 125 + 126 + // Parse form to check confirmation checkbox 127 + if err := r.ParseForm(); err != nil { 128 + slog.Error("delete account submit: failed to parse form", "error", err) 129 + http.Error(w, "Bad request", http.StatusBadRequest) 130 + return 131 + } 132 + 133 + // Verify confirmation checkbox was checked 134 + if r.FormValue("confirm") != "true" { 135 + slog.Warn("delete account submit: confirmation not checked", "did", sealed.DID) 136 + http.Redirect(w, r, "/delete-account", http.StatusFound) 137 + return 138 + } 139 + 140 + // Delete the user's account 141 + err = h.userService.DeleteAccount(ctx, sealed.DID) 142 + if err != nil { 143 + slog.Error("delete account submit: failed to delete account", 144 + "did", sealed.DID, "error", err) 145 + http.Error(w, "Failed to delete account", http.StatusInternalServerError) 146 + return 147 + } 148 + 149 + slog.Info("account deleted successfully via web", "did", sealed.DID) 150 + 151 + // Clear the session cookie 152 + h.clearSessionCookie(w) 153 + 154 + // Redirect to success page 155 + http.Redirect(w, r, "/delete-account/success", http.StatusFound) 156 + } 157 + 158 + // DeleteAccountSuccessHandler renders the deletion success page 159 + // GET /delete-account/success 160 + func (h *Handlers) DeleteAccountSuccessHandler(w http.ResponseWriter, r *http.Request) { 161 + if err := h.templates.Render(w, "delete_success.html", nil); err != nil { 162 + slog.Error("failed to render delete success template", "error", err) 163 + http.Error(w, "Internal server error", http.StatusInternalServerError) 164 + } 165 + } 166 + 167 + // clearSessionCookie clears the session cookie 168 + func (h *Handlers) clearSessionCookie(w http.ResponseWriter) { 169 + http.SetCookie(w, &http.Cookie{ 170 + Name: "coves_session", 171 + Value: "", 172 + Path: "/", 173 + MaxAge: -1, 174 + }) 175 + }
+58
internal/web/templates.go
··· 1 + // Package web provides HTTP handlers and templates for the Coves web interface. 2 + // This includes the landing page and static file serving for the coves.social website. 3 + package web 4 + 5 + import ( 6 + "embed" 7 + "fmt" 8 + "html/template" 9 + "net/http" 10 + "path/filepath" 11 + ) 12 + 13 + //go:embed templates/*.html 14 + var templatesFS embed.FS 15 + 16 + // Templates holds the parsed HTML templates for the web interface. 17 + type Templates struct { 18 + templates *template.Template 19 + } 20 + 21 + // NewTemplates creates a new Templates instance by parsing all embedded templates. 22 + func NewTemplates() (*Templates, error) { 23 + tmpl, err := template.ParseFS(templatesFS, "templates/*.html") 24 + if err != nil { 25 + return nil, fmt.Errorf("failed to parse templates: %w", err) 26 + } 27 + return &Templates{templates: tmpl}, nil 28 + } 29 + 30 + // Render renders a named template with the provided data to the response writer. 31 + // Returns an error if the template doesn't exist or rendering fails. 32 + func (t *Templates) Render(w http.ResponseWriter, name string, data interface{}) error { 33 + // Set content type before writing 34 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 35 + 36 + // Check if template exists 37 + tmpl := t.templates.Lookup(name) 38 + if tmpl == nil { 39 + return fmt.Errorf("template %q not found", name) 40 + } 41 + 42 + // Execute template 43 + if err := tmpl.Execute(w, data); err != nil { 44 + return fmt.Errorf("failed to execute template %q: %w", name, err) 45 + } 46 + 47 + return nil 48 + } 49 + 50 + // ProjectStaticFileServer returns an http.Handler that serves static files from the project root. 51 + // This is used for files that live outside the web package (e.g., /static/images/). 52 + func ProjectStaticFileServer(staticDir string) http.Handler { 53 + absPath, err := filepath.Abs(staticDir) 54 + if err != nil { 55 + panic(fmt.Sprintf("failed to get absolute path for static directory: %v", err)) 56 + } 57 + return http.StripPrefix("/static/", http.FileServer(http.Dir(absPath))) 58 + }
+335
internal/web/templates/delete_account.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>Delete Your Account - Coves</title> 7 + <!-- Favicon --> 8 + <link rel="icon" type="image/png" href="/static/images/lil_dude.png"> 9 + <style> 10 + * { box-sizing: border-box; margin: 0; padding: 0; } 11 + body { 12 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 13 + background: #0B0F14; 14 + color: #e4e6e7; 15 + min-height: 100vh; 16 + display: flex; 17 + justify-content: center; 18 + align-items: center; 19 + padding: 24px; 20 + } 21 + .card { 22 + background: #1A1F26; 23 + border-radius: 16px; 24 + padding: 32px; 25 + max-width: 480px; 26 + width: 100%; 27 + } 28 + h1 { 29 + font-size: 24px; 30 + font-weight: 600; 31 + margin-bottom: 16px; 32 + color: #e4e6e7; 33 + } 34 + .subtitle { 35 + font-size: 16px; 36 + color: #B6C2D2; 37 + margin-bottom: 24px; 38 + line-height: 1.5; 39 + } 40 + .handle-display { 41 + font-size: 16px; 42 + color: #7CB9E8; 43 + background: #0B0F14; 44 + padding: 12px 16px; 45 + border-radius: 8px; 46 + margin-bottom: 24px; 47 + display: inline-block; 48 + } 49 + .warning-list { 50 + background: rgba(255, 107, 53, 0.1); 51 + border: 1px solid rgba(255, 107, 53, 0.3); 52 + border-radius: 8px; 53 + padding: 16px; 54 + margin-bottom: 24px; 55 + } 56 + .warning-list h3 { 57 + color: #FF6B35; 58 + font-size: 14px; 59 + font-weight: 600; 60 + margin-bottom: 12px; 61 + } 62 + .warning-list ul { 63 + list-style: disc; 64 + padding-left: 20px; 65 + } 66 + .warning-list li { 67 + color: #B6C2D2; 68 + font-size: 14px; 69 + margin-bottom: 8px; 70 + line-height: 1.4; 71 + } 72 + .info-box { 73 + background: rgba(124, 185, 232, 0.1); 74 + border: 1px solid rgba(124, 185, 232, 0.3); 75 + border-radius: 8px; 76 + padding: 16px; 77 + margin-bottom: 24px; 78 + } 79 + .info-box p { 80 + color: #B6C2D2; 81 + font-size: 14px; 82 + line-height: 1.5; 83 + } 84 + .checkbox-container { 85 + display: flex; 86 + align-items: flex-start; 87 + gap: 12px; 88 + margin-bottom: 24px; 89 + } 90 + .checkbox-container input[type="checkbox"] { 91 + width: 20px; 92 + height: 20px; 93 + margin-top: 2px; 94 + accent-color: #FF6B35; 95 + cursor: pointer; 96 + } 97 + .checkbox-container label { 98 + color: #e4e6e7; 99 + font-size: 14px; 100 + cursor: pointer; 101 + line-height: 1.4; 102 + } 103 + .btn { 104 + display: inline-block; 105 + padding: 14px 28px; 106 + border-radius: 8px; 107 + font-size: 16px; 108 + font-weight: 600; 109 + text-decoration: none; 110 + cursor: pointer; 111 + border: none; 112 + transition: all 0.2s ease; 113 + } 114 + .btn-primary { 115 + background: #FF6B35; 116 + color: white; 117 + } 118 + .btn-primary:hover:not(:disabled) { 119 + background: #e55a2b; 120 + } 121 + .btn-primary:disabled { 122 + background: #4A4F56; 123 + color: #6B7280; 124 + cursor: not-allowed; 125 + } 126 + .btn-secondary { 127 + background: transparent; 128 + color: #B6C2D2; 129 + border: 1px solid #2A2F36; 130 + } 131 + .btn-secondary:hover { 132 + background: #2A2F36; 133 + } 134 + .btn-danger { 135 + background: #DC2626; 136 + color: white; 137 + } 138 + .btn-danger:hover:not(:disabled) { 139 + background: #B91C1C; 140 + } 141 + .btn-danger:disabled { 142 + background: #4A4F56; 143 + color: #6B7280; 144 + cursor: not-allowed; 145 + } 146 + .button-row { 147 + display: flex; 148 + gap: 12px; 149 + flex-wrap: wrap; 150 + } 151 + .divider { 152 + height: 1px; 153 + background: #2A2F36; 154 + margin: 24px 0; 155 + } 156 + .bluesky-icon { 157 + width: 20px; 158 + height: 20px; 159 + margin-right: 8px; 160 + vertical-align: middle; 161 + } 162 + </style> 163 + </head> 164 + <body> 165 + <div class="card"> 166 + <h1>Delete Your Account</h1> 167 + 168 + {{if .LoggedIn}} 169 + <!-- Logged in state --> 170 + <p class="subtitle">You are signed in as:</p> 171 + <div class="handle-display">@{{.Handle}}</div> 172 + 173 + <div class="warning-list"> 174 + <h3>This will permanently delete:</h3> 175 + <ul> 176 + <li>All your posts on Coves</li> 177 + <li>All your comments</li> 178 + <li>All your votes</li> 179 + <li>Your community subscriptions and memberships</li> 180 + <li>Your profile data on Coves</li> 181 + </ul> 182 + </div> 183 + 184 + <div class="info-box"> 185 + <p>Your atProto identity (DID and handle) will be preserved. You can still use your Bluesky account and other atProto apps. Only your Coves-specific data will be removed.</p> 186 + </div> 187 + 188 + <form method="POST" action="/delete-account" id="delete-form"> 189 + <div class="checkbox-container"> 190 + <input type="checkbox" id="confirm-checkbox" name="confirm" value="true" onchange="toggleDeleteButton()"> 191 + <label for="confirm-checkbox">I understand this action cannot be undone and I want to permanently delete my Coves account</label> 192 + </div> 193 + 194 + <div class="button-row"> 195 + <button type="submit" class="btn btn-danger" id="delete-btn" disabled>Delete My Account</button> 196 + <a href="/" class="btn btn-secondary">Cancel</a> 197 + </div> 198 + </form> 199 + 200 + <script> 201 + function toggleDeleteButton() { 202 + var checkbox = document.getElementById('confirm-checkbox'); 203 + var button = document.getElementById('delete-btn'); 204 + button.disabled = !checkbox.checked; 205 + } 206 + </script> 207 + 208 + {{else}} 209 + <!-- Not logged in state --> 210 + <p class="subtitle">To delete your Coves account, you'll need to sign in first to verify your identity.</p> 211 + 212 + <div class="info-box"> 213 + <p style="margin-bottom: 12px;">When you delete your account:</p> 214 + <ul style="list-style: disc; padding-left: 20px; margin: 0;"> 215 + <li style="margin-bottom: 8px; line-height: 1.4;">All your Coves data will be permanently removed (posts, comments, votes, subscriptions)</li> 216 + <li style="margin-bottom: 8px; line-height: 1.4;">Your atProto identity (DID and handle) will be preserved</li> 217 + <li style="line-height: 1.4;">You can still use your Bluesky account and other atProto apps</li> 218 + </ul> 219 + </div> 220 + 221 + <div class="divider"></div> 222 + 223 + <div class="handle-input-container" style="margin-bottom: 24px;"> 224 + <label for="handle-input" style="display: block; font-size: 16px; font-weight: 600; color: #e4e6e7; margin-bottom: 12px;">Enter your atproto handle</label> 225 + <div style="position: relative;"> 226 + <span style="position: absolute; left: 16px; top: 50%; transform: translateY(-50%); color: #5A6B7F; font-size: 16px;">@</span> 227 + <input 228 + type="text" 229 + id="handle-input" 230 + placeholder="alice.bsky.social" 231 + style="width: 100%; padding: 14px 16px 14px 36px; background: #1A2028; border: 2px solid #2A3441; border-radius: 12px; color: #e4e6e7; font-size: 16px; outline: none; transition: border-color 0.2s;" 232 + onfocus="this.style.borderColor='#FF6B35'" 233 + onblur="this.style.borderColor='#2A3441'" 234 + onkeyup="validateHandle()" 235 + > 236 + </div> 237 + <p id="handle-error" style="color: #FF6B35; font-size: 12px; margin-top: 8px; display: none;">Please enter your handle to continue</p> 238 + </div> 239 + 240 + <button type="button" id="sign-in-btn" class="btn btn-primary" onclick="signIn()" disabled style="width: 100%; display: flex; align-items: center; justify-content: center; border-radius: 9999px;"> 241 + Sign In 242 + </button> 243 + 244 + <p style="color: #5A6B7F; font-size: 13px; text-align: center; margin-top: 16px; line-height: 1.5;"> 245 + You'll be redirected to your atproto provider to authorize this action. 246 + </p> 247 + 248 + <p style="text-align: center; margin-top: 24px;"> 249 + <a href="#" onclick="showHandleHelp(); return false;" style="color: #FF6B35; font-size: 14px; text-decoration: underline;">What is a handle?</a> 250 + </p> 251 + 252 + <!-- Handle Help Modal --> 253 + <div id="help-modal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.7); z-index: 1000; justify-content: center; align-items: center; padding: 24px;"> 254 + <div style="background: #1A1F26; border-radius: 16px; padding: 32px; max-width: 400px; width: 100%; text-align: center;"> 255 + <div style="width: 48px; height: 48px; background: rgba(124, 185, 232, 0.15); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px;"> 256 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#7CB9E8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 257 + <circle cx="12" cy="12" r="10"></circle> 258 + <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path> 259 + <line x1="12" y1="17" x2="12.01" y2="17"></line> 260 + </svg> 261 + </div> 262 + <h3 style="color: #e4e6e7; font-size: 18px; font-weight: 600; margin-bottom: 12px;">What is a handle?</h3> 263 + <p style="color: #B6C2D2; font-size: 14px; line-height: 1.6; margin-bottom: 16px;"> 264 + Your handle is your unique identifier on the atproto network, like <span style="color: #7CB9E8;">alice.bsky.social</span>. 265 + </p> 266 + <p style="color: #B6C2D2; font-size: 14px; line-height: 1.6; margin-bottom: 24px;"> 267 + If you don't have one yet, you can create an account at <a href="https://bsky.app" target="_blank" style="color: #FF6B35; text-decoration: underline;">bsky.app</a>. 268 + </p> 269 + <button type="button" onclick="closeHelpModal()" style="width: 100%; padding: 12px 20px; background: #FF6B35; border: none; border-radius: 8px; color: white; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.2s;" onmouseover="this.style.background='#e55a2b'" onmouseout="this.style.background='#FF6B35'"> 270 + Got it 271 + </button> 272 + </div> 273 + </div> 274 + 275 + <script> 276 + function validateHandle() { 277 + var input = document.getElementById('handle-input'); 278 + var button = document.getElementById('sign-in-btn'); 279 + var error = document.getElementById('handle-error'); 280 + var handle = input.value.trim(); 281 + 282 + if (handle.length > 0) { 283 + button.disabled = false; 284 + error.style.display = 'none'; 285 + } else { 286 + button.disabled = true; 287 + } 288 + } 289 + 290 + function signIn() { 291 + var input = document.getElementById('handle-input'); 292 + var error = document.getElementById('handle-error'); 293 + var handle = input.value.trim(); 294 + 295 + if (!handle) { 296 + error.style.display = 'block'; 297 + return; 298 + } 299 + 300 + window.location.href = '/oauth/login?handle=' + encodeURIComponent(handle) + '&redirect=/delete-account'; 301 + } 302 + 303 + function showHandleHelp() { 304 + document.getElementById('help-modal').style.display = 'flex'; 305 + } 306 + 307 + function closeHelpModal() { 308 + document.getElementById('help-modal').style.display = 'none'; 309 + } 310 + 311 + // Close modal on backdrop click 312 + document.getElementById('help-modal').addEventListener('click', function(e) { 313 + if (e.target === this) { 314 + closeHelpModal(); 315 + } 316 + }); 317 + 318 + // Close modal on Escape key 319 + document.addEventListener('keydown', function(e) { 320 + if (e.key === 'Escape') { 321 + closeHelpModal(); 322 + } 323 + }); 324 + 325 + // Allow Enter key to submit 326 + document.getElementById('handle-input').addEventListener('keypress', function(e) { 327 + if (e.key === 'Enter' && !document.getElementById('sign-in-btn').disabled) { 328 + signIn(); 329 + } 330 + }); 331 + </script> 332 + {{end}} 333 + </div> 334 + </body> 335 + </html>
+114
internal/web/templates/delete_success.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>Account Deleted - Coves</title> 7 + <!-- Favicon --> 8 + <link rel="icon" type="image/png" href="/static/images/lil_dude.png"> 9 + <style> 10 + * { box-sizing: border-box; margin: 0; padding: 0; } 11 + body { 12 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 13 + background: #0B0F14; 14 + color: #e4e6e7; 15 + min-height: 100vh; 16 + display: flex; 17 + justify-content: center; 18 + align-items: center; 19 + padding: 24px; 20 + } 21 + .card { 22 + background: #1A1F26; 23 + border-radius: 16px; 24 + padding: 32px; 25 + max-width: 480px; 26 + width: 100%; 27 + text-align: center; 28 + } 29 + .checkmark { 30 + width: 64px; 31 + height: 64px; 32 + margin: 0 auto 24px; 33 + background: #22C55E; 34 + border-radius: 50%; 35 + display: flex; 36 + align-items: center; 37 + justify-content: center; 38 + animation: scale-in 0.3s ease-out; 39 + } 40 + .checkmark svg { 41 + width: 32px; 42 + height: 32px; 43 + stroke: white; 44 + stroke-width: 3; 45 + fill: none; 46 + } 47 + @keyframes scale-in { 48 + 0% { transform: scale(0); } 49 + 50% { transform: scale(1.1); } 50 + 100% { transform: scale(1); } 51 + } 52 + h1 { 53 + font-size: 24px; 54 + font-weight: 600; 55 + margin-bottom: 16px; 56 + color: #e4e6e7; 57 + } 58 + .message { 59 + font-size: 16px; 60 + color: #B6C2D2; 61 + margin-bottom: 24px; 62 + line-height: 1.5; 63 + } 64 + .info-box { 65 + background: rgba(124, 185, 232, 0.1); 66 + border: 1px solid rgba(124, 185, 232, 0.3); 67 + border-radius: 8px; 68 + padding: 16px; 69 + margin-bottom: 24px; 70 + text-align: left; 71 + } 72 + .info-box p { 73 + color: #B6C2D2; 74 + font-size: 14px; 75 + line-height: 1.5; 76 + } 77 + .btn { 78 + display: inline-block; 79 + padding: 14px 28px; 80 + border-radius: 8px; 81 + font-size: 16px; 82 + font-weight: 600; 83 + text-decoration: none; 84 + cursor: pointer; 85 + border: none; 86 + transition: all 0.2s ease; 87 + } 88 + .btn-primary { 89 + background: #FF6B35; 90 + color: white; 91 + } 92 + .btn-primary:hover { 93 + background: #e55a2b; 94 + } 95 + </style> 96 + </head> 97 + <body> 98 + <div class="card"> 99 + <div class="checkmark"> 100 + <svg viewBox="0 0 24 24"> 101 + <polyline points="20 6 9 17 4 12"></polyline> 102 + </svg> 103 + </div> 104 + <h1>Account Deleted</h1> 105 + <p class="message">Your Coves account has been successfully deleted.</p> 106 + 107 + <div class="info-box"> 108 + <p>Your atProto identity has been preserved. You can continue using your Bluesky account and other atProto applications. If you ever want to return to Coves, you can create a new account using the same identity.</p> 109 + </div> 110 + 111 + <a href="/" class="btn btn-primary">Return to Homepage</a> 112 + </div> 113 + </body> 114 + </html>
+554
internal/web/templates/landing.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>{{.Title}}</title> 7 + <meta name="description" content="{{.Description}}"> 8 + 9 + <!-- Open Graph / Social --> 10 + <meta property="og:type" content="website"> 11 + <meta property="og:title" content="{{.Title}}"> 12 + <meta property="og:description" content="{{.Description}}"> 13 + <meta property="og:image" content="/static/images/app-icon.png"> 14 + 15 + <!-- Twitter Card --> 16 + <meta name="twitter:card" content="summary"> 17 + <meta name="twitter:title" content="{{.Title}}"> 18 + <meta name="twitter:description" content="{{.Description}}"> 19 + 20 + <!-- Favicon --> 21 + <link rel="icon" type="image/png" href="/static/images/lil_dude.png"> 22 + 23 + <!-- Fonts: Fraunces for display (warm, quirky serifs), Plus Jakarta Sans for body --> 24 + <link rel="preconnect" href="https://fonts.googleapis.com"> 25 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 26 + <link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,400&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet"> 27 + 28 + <style> 29 + :root { 30 + --color-deep: #080B10; 31 + --color-surface: #0E1218; 32 + --color-elevated: #161B24; 33 + --color-border: #252D3A; 34 + --color-text: #E8ECF2; 35 + --color-text-muted: #8A96A8; 36 + --color-accent: #FF6B35; 37 + --color-accent-glow: rgba(255, 107, 53, 0.15); 38 + --color-warm: #FF8F66; 39 + --color-coral: #E85A4F; 40 + --font-display: 'Fraunces', Georgia, serif; 41 + --font-body: 'Plus Jakarta Sans', system-ui, sans-serif; 42 + } 43 + 44 + * { 45 + box-sizing: border-box; 46 + margin: 0; 47 + padding: 0; 48 + } 49 + 50 + html { 51 + scroll-behavior: smooth; 52 + } 53 + 54 + body { 55 + font-family: var(--font-body); 56 + background: var(--color-deep); 57 + color: var(--color-text); 58 + min-height: 100vh; 59 + display: flex; 60 + flex-direction: column; 61 + overflow-x: hidden; 62 + position: relative; 63 + } 64 + 65 + /* Animated gradient background */ 66 + body::before { 67 + content: ''; 68 + position: fixed; 69 + top: 0; 70 + left: 0; 71 + right: 0; 72 + bottom: 0; 73 + background: 74 + radial-gradient(ellipse 80% 60% at 10% 20%, rgba(255, 107, 53, 0.08) 0%, transparent 50%), 75 + radial-gradient(ellipse 60% 80% at 90% 80%, rgba(232, 90, 79, 0.06) 0%, transparent 50%), 76 + radial-gradient(ellipse 100% 100% at 50% 100%, rgba(255, 143, 102, 0.04) 0%, transparent 40%); 77 + pointer-events: none; 78 + z-index: 0; 79 + } 80 + 81 + /* Subtle noise texture overlay */ 82 + body::after { 83 + content: ''; 84 + position: fixed; 85 + top: 0; 86 + left: 0; 87 + right: 0; 88 + bottom: 0; 89 + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); 90 + opacity: 0.025; 91 + pointer-events: none; 92 + z-index: 1; 93 + } 94 + 95 + /* Main content */ 96 + main { 97 + flex: 1; 98 + display: flex; 99 + flex-direction: column; 100 + justify-content: center; 101 + align-items: center; 102 + padding: 60px 24px 40px; 103 + position: relative; 104 + z-index: 2; 105 + } 106 + 107 + /* Floating decorative elements */ 108 + .decor { 109 + position: absolute; 110 + border-radius: 50%; 111 + filter: blur(60px); 112 + opacity: 0.4; 113 + animation: drift 20s ease-in-out infinite; 114 + pointer-events: none; 115 + } 116 + 117 + .decor-1 { 118 + width: 300px; 119 + height: 300px; 120 + background: var(--color-accent); 121 + top: 10%; 122 + left: -100px; 123 + animation-delay: 0s; 124 + } 125 + 126 + .decor-2 { 127 + width: 200px; 128 + height: 200px; 129 + background: var(--color-coral); 130 + bottom: 20%; 131 + right: -50px; 132 + animation-delay: -7s; 133 + } 134 + 135 + @keyframes drift { 136 + 0%, 100% { transform: translate(0, 0) scale(1); } 137 + 33% { transform: translate(30px, -20px) scale(1.05); } 138 + 66% { transform: translate(-20px, 30px) scale(0.95); } 139 + } 140 + 141 + /* Hero section */ 142 + .hero { 143 + text-align: center; 144 + max-width: 680px; 145 + position: relative; 146 + } 147 + 148 + /* Brand lockup: mascot + logo side by side */ 149 + .brand-lockup { 150 + display: flex; 151 + align-items: center; 152 + justify-content: center; 153 + margin-bottom: 36px; 154 + position: relative; 155 + } 156 + 157 + /* Mascot with glow effect */ 158 + .mascot-container { 159 + position: relative; 160 + display: flex; 161 + align-items: center; 162 + justify-content: center; 163 + margin-right: -20px; /* Overlap into logo space */ 164 + z-index: 2; 165 + } 166 + 167 + .mascot-glow { 168 + position: absolute; 169 + top: 50%; 170 + left: 50%; 171 + width: 140px; 172 + height: 140px; 173 + background: radial-gradient(circle, var(--color-accent-glow) 0%, transparent 70%); 174 + transform: translate(-50%, -50%); 175 + animation: pulse-glow 4s ease-in-out infinite; 176 + } 177 + 178 + @keyframes pulse-glow { 179 + 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; } 180 + 50% { transform: translate(-50%, -50%) scale(1.2); opacity: 0.3; } 181 + } 182 + 183 + .mascot { 184 + width: 100px; 185 + height: 100px; 186 + position: relative; 187 + z-index: 1; 188 + animation: float 4s ease-in-out infinite; 189 + filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.4)); 190 + } 191 + 192 + @keyframes float { 193 + 0%, 100% { transform: translateY(0) rotate(-3deg); } 194 + 50% { transform: translateY(-8px) rotate(3deg); } 195 + } 196 + 197 + .logo { 198 + height: 72px; 199 + position: relative; 200 + z-index: 1; 201 + opacity: 0; 202 + animation: fade-in-scale 0.6s ease-out 0.15s forwards; 203 + } 204 + 205 + @keyframes fade-in-scale { 206 + from { 207 + opacity: 0; 208 + transform: scale(0.9); 209 + } 210 + to { 211 + opacity: 1; 212 + transform: scale(1); 213 + } 214 + } 215 + 216 + @keyframes fade-up { 217 + from { 218 + opacity: 0; 219 + transform: translateY(20px); 220 + } 221 + to { 222 + opacity: 1; 223 + transform: translateY(0); 224 + } 225 + } 226 + 227 + .tagline { 228 + font-family: var(--font-display); 229 + font-size: clamp(1.75rem, 5vw, 2.5rem); 230 + font-weight: 600; 231 + color: var(--color-text); 232 + margin-bottom: 20px; 233 + line-height: 1.2; 234 + letter-spacing: -0.02em; 235 + opacity: 0; 236 + animation: fade-up 0.8s ease-out 0.35s forwards; 237 + } 238 + 239 + .tagline .highlight { 240 + color: var(--color-accent); 241 + font-style: italic; 242 + } 243 + 244 + .description { 245 + font-size: 1.125rem; 246 + color: var(--color-text-muted); 247 + line-height: 1.7; 248 + margin-bottom: 48px; 249 + max-width: 520px; 250 + margin-left: auto; 251 + margin-right: auto; 252 + opacity: 0; 253 + animation: fade-up 0.8s ease-out 0.5s forwards; 254 + } 255 + 256 + /* App store buttons */ 257 + .app-buttons { 258 + display: flex; 259 + flex-wrap: wrap; 260 + gap: 16px; 261 + justify-content: center; 262 + margin-bottom: 48px; 263 + opacity: 0; 264 + animation: fade-up 0.8s ease-out 0.65s forwards; 265 + } 266 + 267 + .app-button { 268 + display: inline-flex; 269 + align-items: center; 270 + background: var(--color-elevated); 271 + border: 1px solid var(--color-border); 272 + border-radius: 14px; 273 + padding: 14px 28px; 274 + text-decoration: none; 275 + color: var(--color-text); 276 + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); 277 + position: relative; 278 + overflow: hidden; 279 + } 280 + 281 + .app-button::before { 282 + content: ''; 283 + position: absolute; 284 + top: 0; 285 + left: 0; 286 + right: 0; 287 + bottom: 0; 288 + background: linear-gradient(135deg, var(--color-accent-glow) 0%, transparent 50%); 289 + opacity: 0; 290 + transition: opacity 0.3s ease; 291 + } 292 + 293 + .app-button:hover { 294 + border-color: var(--color-accent); 295 + transform: translateY(-3px); 296 + box-shadow: 297 + 0 10px 40px -10px rgba(255, 107, 53, 0.3), 298 + 0 0 0 1px var(--color-accent); 299 + } 300 + 301 + .app-button:hover::before { 302 + opacity: 1; 303 + } 304 + 305 + .app-button svg { 306 + width: 26px; 307 + height: 26px; 308 + margin-right: 14px; 309 + position: relative; 310 + z-index: 1; 311 + transition: transform 0.3s ease; 312 + } 313 + 314 + .app-button:hover svg { 315 + transform: scale(1.1); 316 + } 317 + 318 + .app-button-text { 319 + text-align: left; 320 + position: relative; 321 + z-index: 1; 322 + } 323 + 324 + .app-button-label { 325 + font-size: 0.7rem; 326 + text-transform: uppercase; 327 + letter-spacing: 0.08em; 328 + color: var(--color-text-muted); 329 + display: block; 330 + margin-bottom: 2px; 331 + } 332 + 333 + .app-button-store { 334 + font-size: 1.1rem; 335 + font-weight: 600; 336 + letter-spacing: -0.01em; 337 + } 338 + 339 + /* Built on AT Protocol badge */ 340 + .built-on { 341 + display: inline-flex; 342 + align-items: center; 343 + gap: 8px; 344 + padding: 10px 20px; 345 + background: var(--color-surface); 346 + border: 1px solid var(--color-border); 347 + border-radius: 100px; 348 + color: var(--color-text-muted); 349 + font-size: 0.875rem; 350 + opacity: 0; 351 + animation: fade-up 0.8s ease-out 0.8s forwards; 352 + } 353 + 354 + .built-on svg { 355 + width: 16px; 356 + height: 16px; 357 + color: var(--color-accent); 358 + } 359 + 360 + .built-on a { 361 + color: var(--color-text); 362 + text-decoration: none; 363 + font-weight: 500; 364 + transition: color 0.2s ease; 365 + } 366 + 367 + .built-on a:hover { 368 + color: var(--color-accent); 369 + } 370 + 371 + /* Footer */ 372 + footer { 373 + background: linear-gradient(to top, var(--color-surface) 0%, transparent 100%); 374 + padding: 40px 24px 32px; 375 + text-align: center; 376 + position: relative; 377 + z-index: 2; 378 + } 379 + 380 + .footer-links { 381 + display: flex; 382 + flex-wrap: wrap; 383 + justify-content: center; 384 + gap: 32px; 385 + } 386 + 387 + .footer-links a { 388 + color: var(--color-text-muted); 389 + text-decoration: none; 390 + font-size: 0.875rem; 391 + transition: color 0.2s ease; 392 + position: relative; 393 + } 394 + 395 + .footer-links a::after { 396 + content: ''; 397 + position: absolute; 398 + bottom: -4px; 399 + left: 0; 400 + width: 0; 401 + height: 1px; 402 + background: var(--color-accent); 403 + transition: width 0.3s ease; 404 + } 405 + 406 + .footer-links a:hover { 407 + color: var(--color-text); 408 + } 409 + 410 + .footer-links a:hover::after { 411 + width: 100%; 412 + } 413 + 414 + /* Responsive */ 415 + @media (max-width: 640px) { 416 + main { 417 + padding: 40px 20px 32px; 418 + } 419 + 420 + .brand-lockup { 421 + flex-direction: column; 422 + margin-bottom: 28px; 423 + } 424 + 425 + .mascot-container { 426 + margin-right: 0; 427 + margin-bottom: -16px; /* Overlap vertically on mobile */ 428 + } 429 + 430 + .mascot { 431 + width: 80px; 432 + height: 80px; 433 + } 434 + 435 + .mascot-glow { 436 + width: 110px; 437 + height: 110px; 438 + } 439 + 440 + .logo { 441 + height: 56px; 442 + } 443 + 444 + .description { 445 + font-size: 1rem; 446 + margin-bottom: 36px; 447 + } 448 + 449 + .app-buttons { 450 + flex-direction: column; 451 + align-items: center; 452 + gap: 12px; 453 + } 454 + 455 + .app-button { 456 + width: 100%; 457 + max-width: 280px; 458 + justify-content: center; 459 + padding: 12px 24px; 460 + } 461 + 462 + .footer-links { 463 + gap: 24px; 464 + } 465 + 466 + .decor-1 { 467 + width: 200px; 468 + height: 200px; 469 + left: -80px; 470 + } 471 + 472 + .decor-2 { 473 + width: 150px; 474 + height: 150px; 475 + } 476 + } 477 + 478 + /* Reduce motion for accessibility */ 479 + @media (prefers-reduced-motion: reduce) { 480 + *, *::before, *::after { 481 + animation-duration: 0.01ms !important; 482 + animation-iteration-count: 1 !important; 483 + transition-duration: 0.01ms !important; 484 + } 485 + } 486 + </style> 487 + </head> 488 + <body> 489 + <!-- Floating ambient decorations --> 490 + <div class="decor decor-1" aria-hidden="true"></div> 491 + <div class="decor decor-2" aria-hidden="true"></div> 492 + 493 + <main> 494 + <section class="hero"> 495 + <!-- Brand lockup: mascot + logo integrated --> 496 + <div class="brand-lockup"> 497 + <div class="mascot-container"> 498 + <div class="mascot-glow" aria-hidden="true"></div> 499 + <img src="/static/images/lil_dude.png" alt="Lil Dude - Coves Mascot" class="mascot"> 500 + </div> 501 + <img src="/static/images/coves_bubble.svg" alt="Coves" class="logo"> 502 + </div> 503 + 504 + <h1 class="tagline"> 505 + Community-Driven Forums,<br> 506 + <span class="highlight">Truly Yours</span> 507 + </h1> 508 + 509 + <p class="description"> 510 + Join topic-based communities, share what you love, and own your data. 511 + Built on the AT Protocol—the same foundation as Bluesky—Coves brings forums to the decentralized web. 512 + </p> 513 + 514 + <div class="app-buttons"> 515 + <a href="{{.AppStoreURL}}" class="app-button" aria-label="Download on the App Store"> 516 + <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 517 + <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/> 518 + </svg> 519 + <span class="app-button-text"> 520 + <span class="app-button-label">Download on the</span> 521 + <span class="app-button-store">App Store</span> 522 + </span> 523 + </a> 524 + 525 + <a href="{{.PlayStoreURL}}" class="app-button" aria-label="Get it on Google Play"> 526 + <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 527 + <path d="M3,20.5V3.5C3,2.91 3.34,2.39 3.84,2.15L13.69,12L3.84,21.85C3.34,21.6 3,21.09 3,20.5M16.81,15.12L6.05,21.34L14.54,12.85L16.81,15.12M20.16,10.81C20.5,11.08 20.75,11.5 20.75,12C20.75,12.5 20.5,12.92 20.16,13.19L17.89,14.5L15.39,12L17.89,9.5L20.16,10.81M6.05,2.66L16.81,8.88L14.54,11.15L6.05,2.66Z"/> 528 + </svg> 529 + <span class="app-button-text"> 530 + <span class="app-button-label">Get it on</span> 531 + <span class="app-button-store">Google Play</span> 532 + </span> 533 + </a> 534 + </div> 535 + 536 + <div class="built-on"> 537 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 538 + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> 539 + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> 540 + </svg> 541 + <span>Built on <a href="https://atproto.com" target="_blank" rel="noopener">AT Protocol</a></span> 542 + </div> 543 + </section> 544 + </main> 545 + 546 + <footer> 547 + <nav class="footer-links"> 548 + <a href="/privacy">Privacy Policy</a> 549 + <a href="/terms">Terms of Service</a> 550 + <a href="/delete-account">Delete Account</a> 551 + </nav> 552 + </footer> 553 + </body> 554 + </html>
+134
internal/web/templates_test.go
··· 1 + package web 2 + 3 + import ( 4 + "bytes" 5 + "net/http/httptest" 6 + "testing" 7 + ) 8 + 9 + func TestNewTemplates(t *testing.T) { 10 + templates, err := NewTemplates() 11 + if err != nil { 12 + t.Fatalf("NewTemplates() error = %v", err) 13 + } 14 + if templates == nil { 15 + t.Fatal("NewTemplates() returned nil") 16 + } 17 + } 18 + 19 + func TestTemplatesRender_LandingPage(t *testing.T) { 20 + templates, err := NewTemplates() 21 + if err != nil { 22 + t.Fatalf("NewTemplates() error = %v", err) 23 + } 24 + 25 + data := LandingPageData{ 26 + Title: "Test Title", 27 + Description: "Test Description", 28 + AppStoreURL: "https://example.com/appstore", 29 + PlayStoreURL: "https://example.com/playstore", 30 + } 31 + 32 + w := httptest.NewRecorder() 33 + err = templates.Render(w, "landing.html", data) 34 + if err != nil { 35 + t.Fatalf("Render() error = %v", err) 36 + } 37 + 38 + body := w.Body.String() 39 + 40 + // Check that key elements are present 41 + if !bytes.Contains([]byte(body), []byte("Test Title")) { 42 + t.Error("Rendered output does not contain title") 43 + } 44 + if !bytes.Contains([]byte(body), []byte("Test Description")) { 45 + t.Error("Rendered output does not contain description") 46 + } 47 + if !bytes.Contains([]byte(body), []byte("https://example.com/appstore")) { 48 + t.Error("Rendered output does not contain App Store URL") 49 + } 50 + if !bytes.Contains([]byte(body), []byte("https://example.com/playstore")) { 51 + t.Error("Rendered output does not contain Play Store URL") 52 + } 53 + if !bytes.Contains([]byte(body), []byte("/static/images/lil_dude.png")) { 54 + t.Error("Rendered output does not contain mascot image path") 55 + } 56 + } 57 + 58 + func TestTemplatesRender_DeleteAccount(t *testing.T) { 59 + templates, err := NewTemplates() 60 + if err != nil { 61 + t.Fatalf("NewTemplates() error = %v", err) 62 + } 63 + 64 + // Test logged out state 65 + data := DeleteAccountPageData{ 66 + LoggedIn: false, 67 + } 68 + 69 + w := httptest.NewRecorder() 70 + err = templates.Render(w, "delete_account.html", data) 71 + if err != nil { 72 + t.Fatalf("Render() error = %v", err) 73 + } 74 + 75 + body := w.Body.String() 76 + if !bytes.Contains([]byte(body), []byte("Sign in with Bluesky")) { 77 + t.Error("Logged out state does not show sign in button") 78 + } 79 + 80 + // Test logged in state 81 + dataLoggedIn := DeleteAccountPageData{ 82 + LoggedIn: true, 83 + Handle: "testuser.bsky.social", 84 + DID: "did:plc:test123", 85 + } 86 + 87 + w2 := httptest.NewRecorder() 88 + err = templates.Render(w2, "delete_account.html", dataLoggedIn) 89 + if err != nil { 90 + t.Fatalf("Render() error = %v", err) 91 + } 92 + 93 + body2 := w2.Body.String() 94 + if !bytes.Contains([]byte(body2), []byte("@testuser.bsky.social")) { 95 + t.Error("Logged in state does not show user handle") 96 + } 97 + if !bytes.Contains([]byte(body2), []byte("Delete My Account")) { 98 + t.Error("Logged in state does not show delete button") 99 + } 100 + } 101 + 102 + func TestTemplatesRender_DeleteSuccess(t *testing.T) { 103 + templates, err := NewTemplates() 104 + if err != nil { 105 + t.Fatalf("NewTemplates() error = %v", err) 106 + } 107 + 108 + w := httptest.NewRecorder() 109 + err = templates.Render(w, "delete_success.html", nil) 110 + if err != nil { 111 + t.Fatalf("Render() error = %v", err) 112 + } 113 + 114 + body := w.Body.String() 115 + if !bytes.Contains([]byte(body), []byte("Account Deleted")) { 116 + t.Error("Success page does not contain confirmation message") 117 + } 118 + if !bytes.Contains([]byte(body), []byte("Return to Homepage")) { 119 + t.Error("Success page does not contain return link") 120 + } 121 + } 122 + 123 + func TestTemplatesRender_NotFound(t *testing.T) { 124 + templates, err := NewTemplates() 125 + if err != nil { 126 + t.Fatalf("NewTemplates() error = %v", err) 127 + } 128 + 129 + w := httptest.NewRecorder() 130 + err = templates.Render(w, "nonexistent.html", nil) 131 + if err == nil { 132 + t.Fatal("Render() should return error for nonexistent template") 133 + } 134 + }
static/images/app-icon.png

This is a binary file and will not be displayed.

+14
static/images/coves_bubble.svg
··· 1 + <svg width="1096" height="362" viewBox="0 0 1096 362" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <path d="M851.184 35.2918C887.459 -1.98182 938.123 -6.86801 986.765 7.37694C997.048 10.3869 1010.63 16.8788 1018.91 23.839C1026.28 30.0334 1030.64 42.4002 1037.45 49.5248C1061.81 77.4918 1098.5 101.792 1083.41 144.277C1077.62 160.586 1071.3 168.903 1055.58 176.668C1073.47 193.146 1088.36 211.726 1093.46 235.787C1104.39 287.43 1079.56 338.131 1028.11 354.446C981.985 369.084 929.116 363.027 892.069 331.195C883.143 361.338 865.612 356.724 839.269 356.699L795.495 356.668L754.334 356.743C747.054 356.749 734.441 357.145 727.776 355.883C715.164 352.186 708.776 340.473 700.717 330.862C689.247 317.19 678.745 302.589 666.245 289.79C659.801 304.039 646.145 338.106 635.982 347.139C625.141 356.781 608.326 360.491 594.155 359.687C584.105 359.116 572.636 356.36 564.401 350.296C554.558 343.046 537.379 318.734 528.445 307.831C520.226 297.8 511.364 288.233 502.738 278.554C494.119 298.829 487.782 310.769 471.551 326.694C447.692 350.108 415.666 361.106 382.459 360.447C354.807 359.901 327.931 352.205 305.812 335.137C298.244 329.293 292.271 323.059 285.643 316.267C282.706 326.826 276.078 337.365 266.423 342.632C203.182 377.1 116.464 360.61 74.6014 300.581C58.9416 278.127 35.5772 259.741 21.7459 235.806C-17.8863 167.214 -3.69572 65.5036 67.192 22.6991C102.55 1.27985 145.059 -4.97858 185.095 5.34247C210.538 11.8024 224.448 19.7324 237.682 41.9733C264.93 14.0785 293.017 2.30679 331.729 0.340135C366.798 -1.5512 401.788 12.2506 426.72 36.7457C439.42 12.68 464.121 0.915763 490.873 1.09027C502.002 0.252258 527.31 9.15903 532.888 19.5334C541.751 36.0011 548.465 43.1095 561.983 57.0519C565.13 46.4986 570.268 30.6479 576.574 21.7468C595.908 -5.56676 638.551 -3.42871 663.325 14.9115C667.514 11.5708 670.234 9.52311 675.202 7.38257C679.072 5.71094 683.18 4.67457 687.369 4.31238C694.611 3.64824 703.996 3.88052 711.477 3.86545L789.773 3.78572C822.309 3.7807 837.535 1.61065 851.184 35.2918Z" fill="black"/> 3 + <path d="M248.008 241.819C199.438 175.623 215.062 46.8986 305.582 24.8613C352.394 13.4652 397.577 27.1413 424.865 67.9315C435.376 83.6421 439.75 98.1197 444.27 116.13C452.311 154.989 447.885 202.484 425.758 236.026C410.331 259.386 386.101 275.499 358.6 280.681C317.716 288.785 273.622 275.606 248.008 241.819Z" fill="#FE6446"/> 4 + <path d="M444.27 116C452.311 154.89 447.885 202.422 425.757 235.99C410.329 259.368 386.098 275.493 358.597 280.679C317.711 288.789 273.616 275.6 248 241.787C252.018 240.249 257.457 241.944 261.795 242.65C267.939 243.69 274.138 244.37 280.361 244.692C351.103 248.422 397.904 206.902 428.303 147.051C431.815 140.132 439.538 119.642 444.27 116Z" fill="#FF6B35"/> 5 + <path d="M333.207 92C357.907 93.0363 365.169 112.606 367.931 134.067C370.989 157.83 369.349 205.681 337.506 209C329.577 208.806 323.603 208.424 316.729 203.333C307.154 196.239 302.789 180.785 301.218 169.307C298.493 149.389 299.618 120.36 312.13 103.416C317.546 96.0807 324.501 93.0075 333.207 92Z" fill="black"/> 6 + <path d="M670.243 236.583L670.356 71.9859C670.363 67.6078 670.382 63.1196 670.407 58.7446C670.502 41.2932 672.25 25.3433 694.132 25.1885C730.378 24.9311 766.587 25.1004 802.833 25.0104C807.289 24.9991 811.827 24.916 816.252 25.4994C839.175 30.0386 837.799 76.9833 825.226 89.6827C818.984 95.9884 797.784 94.4957 789.744 94.524L746.214 94.6801L746.251 115.676C756.823 115.729 767.395 115.712 777.966 115.626C784.656 115.609 793.063 115.659 799.596 116.519C823.699 119.696 826.142 178.259 801.685 182.375C795.544 183.407 784.164 183.211 777.26 183.186L746.7 183.205L746.687 208.812C767.811 209.397 791.138 208.699 812.61 208.944C834.34 209.68 836.335 231.587 835.962 248.496C835.767 257.489 833.135 268.955 826.053 274.871C819.571 280.289 802.865 278.823 794.42 278.81L718.368 278.646C706.262 278.684 683.756 281.258 675.355 271.567C668.463 263.612 670.161 246.81 670.243 236.583Z" fill="#FE6446"/> 7 + <path d="M812.61 209C834.34 209.736 836.335 231.624 835.962 248.521C835.767 257.506 833.135 268.963 826.053 274.874C819.571 280.288 802.865 278.823 794.42 278.81L718.368 278.646C706.262 278.684 683.756 281.256 675.355 271.573C668.463 263.624 670.161 246.835 670.243 236.617C678.139 236.812 688.313 239.019 696.726 239.554C728.908 241.61 759.954 236.762 789.371 223.324C794.591 220.935 810.899 213.597 812.61 209Z" fill="#FF6B35"/> 8 + <path d="M846 236.551C846.391 221.828 851.361 206.682 863.298 197.284C880.35 183.852 895.449 201.45 911.506 206.493C915.764 207.829 919.682 209.184 924.142 209.688C932.835 211.705 959.531 208.112 946.171 194.561C942.297 190.634 930.605 185.768 925.434 183.676C904.407 175.173 882.492 166.532 866.781 149.432C840.331 120.625 842.787 70.2558 871.865 44.0833C899.336 19.5547 940.489 16.9497 974.801 25.2797C995.966 30.3107 1015.31 39.3725 1013.02 64.9159C1011.49 82.3323 998.933 109.511 977.094 102.971C961.969 98.442 946.101 88.1927 929.44 92.1182C925.912 92.949 923.525 94.6829 923.071 98.483C924.319 102.967 935.229 108.895 939.462 110.66C961.44 119.815 980.564 126.788 999.588 141.78C1031.78 168.971 1034.56 218.916 1007.86 250.884C991.909 269.976 965.944 280.042 941.547 281.712C916.753 283.105 878.479 279.853 859.014 262.866C850.485 255.416 847.165 247.481 846 236.551Z" fill="#FE6446"/> 9 + <path d="M999.588 142C1031.78 169.148 1034.56 219.015 1007.86 250.933C991.909 269.995 965.944 280.044 941.547 281.712C916.753 283.103 878.479 279.856 859.014 262.896C850.485 255.458 847.165 247.535 846 236.623C913.66 252.67 972.949 219.719 995.872 153.604C997.05 150.225 997.843 146.443 999.122 143.158L999.588 142Z" fill="#FF6B35"/> 10 + <path d="M555.303 159.129C556.832 148.192 563.237 128.092 566.502 117.076C573.466 93.4463 580.708 69.9014 588.232 46.4457C591.258 37.2511 594.385 30.5293 603.98 25.6924C623.943 14.4935 668.241 29.93 664.812 56.3428C663.113 69.436 652.543 94.1086 647.724 107.117L613.109 201.611C608.384 214.674 603.495 227.673 598.456 240.616C595.707 247.679 591.277 259.81 587.27 266.049C577.763 280.847 550.792 282.482 535.63 277.382C519.405 271.923 514.983 254.496 509.837 240.012C506.763 229.824 499.85 212.667 495.98 202.165L459.744 104.767C455.375 92.8331 450.462 80.9808 446.518 68.9624C443.635 60.2181 442.572 53.1071 446.908 44.4772C458.352 21.707 506.363 12.0828 519.363 37.2951C524.552 47.3582 529.232 66.4713 532.723 78.5683C540.619 105.319 548.143 132.174 555.303 159.129Z" fill="#FE6446"/> 11 + <path d="M510 240.217C523.458 235.58 533.376 234.492 547.522 227.233C585.953 207.499 611.201 167.949 631.785 131.374C636.111 123.691 639.868 112.64 647 108L612.607 202.012C607.913 215.007 603.056 227.94 598.049 240.817C595.317 247.844 590.916 259.913 586.934 266.12C577.489 280.843 550.691 282.47 535.626 277.395C519.506 271.964 515.113 254.626 510 240.217Z" fill="#FF6B35"/> 12 + <path d="M58.1592 248.089C49.8246 239.347 42.7202 229.51 37.0411 218.848C4.27592 157.249 22.0929 67.7222 86.9317 35.1588C118.762 19.5068 153.373 16.7459 187.287 27.3041C212.208 35.0624 225.684 49.9768 217.407 76.9327C214.424 86.647 207.503 99.3056 197.994 103.921C186.279 109.607 173.678 101.172 162.756 97.4856C80.0324 71.5959 75.4357 225.92 160.165 204.709C171.49 201.875 184.337 193.033 196.068 196.648C206.006 199.709 212.364 210.642 216.706 219.427C225.885 240.273 224.72 262.385 200.404 272.266C156.506 290.114 91.8953 284.188 58.1592 248.089Z" fill="#FE6446"/> 13 + <path d="M216.7 219C225.888 239.988 224.722 262.251 200.383 272.199C156.442 290.169 91.7689 284.203 58 247.857C64.2969 245.498 79.3874 250.837 87.1261 252.055C126.087 258.154 161.392 250.786 196.005 232.195C203.078 228.397 209.711 223.191 216.7 219Z" fill="#FF6B35"/> 14 + </svg>
static/images/lil_dude.png

This is a binary file and will not be displayed.