toadstoolTally/index.html
chris 6f97d817ae Add drag-to-reorder, PDF/EPUB pattern viewing, and account security improvements
- Let projects and parts be reordered via touch/mouse-friendly drag handles
- Support PDF and EPUB pattern files end-to-end (upload, page-flip viewer, EPUB content extraction)
- Move password hashing to bcrypt with automatic migration from legacy SHA-256, and add email-based password reset
- Externalize docker-compose credentials into .env instead of hardcoding them
- Polish Pattern Composer UI (collapsible spec cards, tag input, file attach/view controls)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 11:39:23 -04:00

482 lines
29 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Toadstool Cottage Counter</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="icon" type="image/png" sizes="32x32" href="assets/icons/appicon-32x32.png">
<link rel="icon" type="image/png" sizes="128x128" href="assets/icons/appicon-128x128.png">
<link rel="apple-touch-icon" href="assets/icons/appicon-256x256.png">
<link rel="manifest" href="assets/site.webmanifest">
<meta name="theme-color" content="#e4e8d5">
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<div class="brand">
<img class="brand-icon" src="assets/icons/appicon-128x128.png" alt="Toadstool Cottage Counter icon">
<h1 id="appTitle">Toadstool Cottage Counter</h1>
</div>
<div class="header-controls">
<button class="sync-banner" id="syncBanner" onclick="openAuthModal()" title="Sign in to sync">Local-only mode</button>
<button class="header-btn hidden" id="installBtn" title="Install app"><i class="fa-solid fa-download"></i></button>
<button class="header-btn" id="settingsBtn" onclick="openSettingsModal()" title="Settings"><i class="fa-solid fa-gear"></i></button>
<button class="header-btn" id="focusBtn" onclick="toggleFocusMode()" title="Focus Mode (Keeps Screen On)"><i class="fa-solid fa-eye"></i></button>
<button class="header-btn" id="saveLoadBtn" onclick="openSaveModal()" title="Save/Load"><i class="fa-solid fa-floppy-disk"></i></button>
<button class="header-btn" id="authBtn" onclick="openAuthModal()" title="Sign in to sync"><i class="fa-solid fa-user"></i></button>
</div>
</header>
<input type="file" id="importFile" accept="application/json" class="hidden-input" />
<div class="container" id="app"></div>
<button class="fab" onclick="openModal('addProject')">+</button>
<button class="fab fab-ref" id="refFab" onclick="openReferenceViewer()" title="Open pattern (Esc to return to counter)" style="display:none"><i class="fa-solid fa-book-open"></i></button>
<button class="fab fab-pattern" id="patternFab" onclick="openPatternComposer()" title="Open Pattern Composer"><i class="fa-solid fa-swatchbook"></i></button>
<div class="reading-pill" id="readingPill" style="display:none" onclick="openLastReadPattern()">
<i class="fa-solid fa-book-open-reader"></i>
<span id="readingPillLabel">Continue reading</span>
</div>
<div class="modal-overlay" id="modalOverlay">
<div class="modal-content">
<h3 class="modal-title" id="modalTitle">Title</h3>
<input type="text" class="modal-input" id="modalInput" autocomplete="off">
<div class="pattern-picker" id="patternPicker">
<label for="patternSelect">Pattern (optional)</label>
<select id="patternSelect">
<option value="">No pattern</option>
</select>
</div>
<div class="modal-actions">
<button class="modal-btn btn-cancel" onclick="closeModal()">Cancel</button>
<button class="modal-btn btn-save" onclick="saveModal()">Save</button>
</div>
</div>
</div>
<div class="color-overlay" id="colorOverlay">
<div class="color-modal">
<h3 class="color-title">Pick a color</h3>
<div class="color-grid" id="colorGrid"></div>
<div class="color-custom">
<label for="customColorInput">Custom:</label>
<input type="color" id="customColorInput" />
</div>
<button class="modal-btn btn-cancel" onclick="closeColorPicker()">Close</button>
</div>
</div>
<div class="save-overlay" id="saveOverlay">
<div class="save-modal">
<h3 class="color-title">Save or Load</h3>
<p class="save-subtext">Choose projects to include:</p>
<div class="save-list" id="saveList"></div>
<div class="save-actions">
<button class="modal-btn btn-cancel" onclick="closeSaveModal()">Cancel</button>
<button class="modal-btn btn-save" onclick="exportSelected()">Save</button>
<button class="modal-btn btn-save" onclick="triggerImport()">Load</button>
</div>
<div class="import-selection hidden" id="importSelection">
<p class="save-subtext">Imported file projects:</p>
<div class="save-list" id="importList"></div>
<div class="save-actions">
<button class="modal-btn btn-cancel" onclick="cancelImportSelection()">Cancel</button>
<button class="modal-btn btn-save" onclick="applyImportSelection()">Add Selected</button>
</div>
</div>
</div>
</div>
<div class="settings-overlay" id="settingsOverlay">
<div class="settings-modal">
<div class="settings-head">
<h3 class="color-title">Settings</h3>
<button class="pattern-close" onclick="closeSettingsModal()">&times;</button>
</div>
<div class="settings-body">
<label class="settings-toggle">
<span>Dark mode</span>
<input type="checkbox" id="settingDarkMode" onchange="setDarkMode(this.checked)">
</label>
<p class="settings-note">Use a darker theme that is easier on the eyes at night.</p>
<label class="settings-toggle">
<span>Animations</span>
<input type="checkbox" id="settingAnimations" onchange="setAnimations(this.checked)">
</label>
<p class="settings-note">Toggle ambient motion like fireflies and micro-animations.</p>
</div>
</div>
</div>
<div class="auth-overlay" id="authOverlay">
<div class="auth-modal">
<div class="auth-modal-head">
<div>
<h3 class="color-title">Account</h3>
<p class="auth-subtext">Sign in to sync across devices.</p>
</div>
<button class="pattern-close" onclick="closeAuthModal()">&times;</button>
</div>
<div class="auth-status">
<span id="authStatusBadge" class="status-pill">Signed out</span>
<span id="authLastSync" class="status-subtext">Status: unknown</span>
</div>
<div class="auth-tabs">
<button class="auth-tab active" data-mode="login" onclick="setAuthMode('login')">Login</button>
<button class="auth-tab" data-mode="signup" onclick="setAuthMode('signup')">Sign up</button>
<button class="auth-tab" data-mode="reset" onclick="setAuthMode('reset')">Reset</button>
<button class="auth-tab" id="profileTabBtn" data-mode="profile" onclick="setAuthMode('profile')" style="display:none;">Profile</button>
<button class="auth-tab" id="adminTabBtn" data-mode="admin" onclick="setAuthMode('admin')" style="display:none;">Admin</button>
</div>
<div class="auth-content">
<div id="loginContent" class="auth-tab-content active">
<form class="auth-form" onsubmit="return submitAuth(event, 'login')">
<label class="field-label" for="loginEmail">Email</label>
<input id="loginEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
<label class="field-label" for="loginPassword">Password</label>
<input id="loginPassword" type="password" placeholder="••••••••" autocomplete="current-password" required>
<div class="auth-actions">
<button type="button" class="modal-btn btn-secondary" onclick="setAuthMode('reset')">Forgot password?</button>
<button type="button" class="modal-btn btn-cancel" onclick="closeAuthModal()">Cancel</button>
<button type="submit" class="modal-btn btn-save">Login</button>
</div>
</form>
</div>
<div id="signupContent" class="auth-tab-content">
<form class="auth-form" onsubmit="return submitAuth(event, 'signup')">
<label class="field-label" for="signupEmail">Email</label>
<input id="signupEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
<label class="field-label" for="signupPassword">Password</label>
<input id="signupPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
<label class="field-label" for="signupConfirmPassword">Confirm Password</label>
<input id="signupConfirmPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
<div class="auth-actions">
<button type="button" class="modal-btn btn-cancel" onclick="closeAuthModal()">Cancel</button>
<button type="submit" class="modal-btn btn-save">Sign Up</button>
</div>
</form>
</div>
<div id="resetContent" class="auth-tab-content">
<form class="auth-form" onsubmit="return requestPasswordReset(event)">
<label class="field-label" for="resetEmail">Email</label>
<input id="resetEmail" type="email" placeholder="you@example.com" autocomplete="email" required>
<div class="auth-actions">
<button type="button" class="modal-btn btn-cancel" onclick="setAuthMode('login')">Back to login</button>
<button type="submit" class="modal-btn btn-save">Send reset</button>
</div>
</form>
<hr class="auth-divider">
<form id="resetConfirmForm" class="auth-form" onsubmit="return confirmPasswordReset(event)">
<label class="field-label" for="resetToken">Reset Token</label>
<input id="resetToken" type="text" placeholder="Paste your reset token" autocomplete="one-time-code" required>
<label class="field-label" for="resetPassword">New Password</label>
<input id="resetPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
<label class="field-label" for="resetConfirmPassword">Confirm New Password</label>
<input id="resetConfirmPassword" type="password" placeholder="••••••••" autocomplete="new-password" required>
<div class="auth-actions">
<button type="submit" class="modal-btn btn-save">Update password</button>
</div>
</form>
</div>
<div id="adminContent" class="auth-tab-content">
<div id="adminPanel" class="admin-panel">
<h4>Pending Approvals</h4>
<div class="admin-actions">
<button type="button" class="modal-btn btn-secondary" onclick="fetchPendingUsers()">Refresh Pending</button>
</div>
<div id="pendingList" class="admin-list"></div>
<h4 style="margin-top:16px;">User Management</h4>
<div class="admin-actions">
<button type="button" class="modal-btn btn-secondary" onclick="fetchAllUsers()">Load All Users</button>
</div>
<div id="allUsersList" class="admin-list"></div>
<h4 style="margin-top:16px;">System Data</h4>
<div class="admin-actions">
<button type="button" class="modal-btn btn-secondary" onclick="downloadBackup()">Backup Data</button>
<label class="modal-btn btn-secondary">
Restore
<input type="file" id="restoreInput" accept="application/json,application/sql,.sql" style="display:none;" onchange="uploadRestore(event)">
</label>
</div>
</div>
</div>
</div>
<form class="auth-profile" onsubmit="return saveProfile(event)">
<h4 class="auth-section-title">Profile Settings</h4>
<div class="auth-profile-body">
<label class="field-label" for="authDisplayName">Display Name</label>
<input id="authDisplayName" type="text" placeholder="Your name">
<label class="field-label" for="authNote">Profile Note</label>
<textarea id="authNote" rows="2" placeholder="Add a note for your patterns..."></textarea>
</div>
<div class="auth-sync-section">
<p class="auth-hint">Sync your projects to the cloud to access them anywhere.</p>
<button type="button" class="modal-btn btn-secondary" onclick="autoSync()"><i class="fa-solid fa-rotate"></i> Sync Now</button>
</div>
<div class="auth-actions modal-footer">
<button type="button" class="modal-btn btn-cancel danger-text" onclick="logoutAuth()">Log Out</button>
<button type="submit" class="modal-btn btn-save">Save Changes</button>
</div>
</form>
</div>
</div>
<div class="pattern-overlay" id="patternOverlay">
<div class="pattern-sheet">
<div class="pattern-sheet-header">
<div class="header-main">
<div class="pattern-title-group">
<h2 class="pattern-sheet-title">Pattern Composer</h2>
<span id="patternHeaderTitle" class="pattern-header-subtitle"></span>
</div>
<div class="pattern-modes">
<button class="pattern-mode" data-mode="crochet" onclick="setPatternMode('crochet')">Crochet</button>
<button class="pattern-mode" data-mode="knit" onclick="setPatternMode('knit')">Knit</button>
</div>
</div>
<div class="header-actions">
<span class="pattern-save-indicator" id="patternSaveIndicator">Saved</span>
<button class="icon-action primary-action" onclick="savePatternDraft()" title="Save to Shelf"><i class="fa-solid fa-bookmark"></i> Save</button>
<button class="icon-action" onclick="exportPatternPDF()" title="Export PDF"><i class="fa-solid fa-file-pdf"></i></button>
<button class="icon-action" onclick="sharePattern()" title="Share Link"><i class="fa-solid fa-link"></i></button>
<button class="icon-action danger" onclick="clearPatternOutput()" title="Clear Draft"><i class="fa-solid fa-eraser"></i></button>
<button class="icon-action" id="composerViewFileBtn" onclick="openPatternFileViewer()" title="View attached file" style="display:none"><i class="fa-solid fa-book-open"></i></button>
<button class="icon-action" id="composerAttachBtn" onclick="composerAttachFile()" title="Attach source file"><i class="fa-solid fa-paperclip"></i></button>
<button class="pattern-close" onclick="closePatternComposer()">&times;</button>
</div>
</div>
<div class="pattern-nav">
<button class="nav-item active" data-tab="info" onclick="showPatternTab('info')">Specs <span class="tab-dot" id="tabDot-info"></span></button>
<button class="nav-item" data-tab="steps" onclick="showPatternTab('steps')">Draft <span class="tab-dot" id="tabDot-steps"></span></button>
<button class="nav-item" data-tab="view" onclick="showPatternTab('view')">Read <span class="tab-dot" id="tabDot-view"></span></button>
<button class="nav-item" data-tab="library" onclick="showPatternTab('library')">Shelf <span class="tab-dot" id="tabDot-library"></span></button>
</div>
<div class="pattern-body">
<div class="pattern-section active" data-section="info">
<div class="card-grid">
<div class="input-card collapsible-card" id="specCard-basic">
<div class="card-head-row" onclick="toggleSpecsCard('specCard-basic')">
<h4 class="card-head">Basic Details</h4>
<i class="fa-solid fa-chevron-down card-toggle-icon"></i>
</div>
<div class="card-content">
<div class="form-row">
<label class="field-label" for="patternTitle">Title</label>
<input id="patternTitle" type="text" placeholder="e.g., Baby Fox Plush">
</div>
<div class="form-row">
<label class="field-label" for="patternDesigner">Designer</label>
<input id="patternDesigner" type="text" placeholder="Your name or shop">
</div>
<div class="form-row">
<label class="field-label" for="patternTagInput">Tags</label>
<div class="tag-input-wrap">
<div id="patternTagList" class="tag-pill-list"></div>
<input id="patternTagInput" type="text" placeholder="Add tag, press Enter" autocomplete="off">
</div>
</div>
</div>
</div>
<div class="input-card collapsible-card" id="specCard-yarns">
<div class="card-head-row" onclick="toggleSpecsCard('specCard-yarns')">
<h4 class="card-head">Yarn & Tools</h4>
<i class="fa-solid fa-chevron-down card-toggle-icon"></i>
</div>
<div class="card-content">
<label class="field-label">Yarns</label>
<div id="yarnList" class="yarn-list-container">
<!-- Populated by JS -->
</div>
<label class="field-label" style="margin-top: 10px;">Hooks / Needles</label>
<div id="hookList" class="hook-list-container">
<!-- Populated by JS -->
</div>
<div class="field-group-inline" style="margin-top: 10px;">
<div>
<label class="field-label" for="patternGaugeSts">Sts / 4in</label>
<input id="patternGaugeSts" type="text" placeholder="e.g., 16 sc">
</div>
<div>
<label class="field-label" for="patternGaugeRows">Rows / 4in</label>
<input id="patternGaugeRows" type="text" placeholder="e.g., 18 rows">
</div>
</div>
<label class="field-label" for="patternGauge">Gauge Notes</label>
<textarea id="patternGauge" rows="2" placeholder="Magic ring start; or any extra gauge notes"></textarea>
</div>
</div>
<div class="input-card collapsible-card full-width" id="specCard-materials">
<div class="card-head-row" onclick="toggleSpecsCard('specCard-materials')">
<h4 class="card-head">Materials & Notes</h4>
<i class="fa-solid fa-chevron-down card-toggle-icon"></i>
</div>
<div class="card-content">
<label class="field-label" for="patternMaterials">Materials (one per line)</label>
<textarea id="patternMaterials" rows="3" placeholder="• 1 skein Worsted Weight Yarn (Main Color)&#10;• 4.0mm Hook&#10;• Tapestry Needle&#10;• Polyester Fiberfill"></textarea>
<label class="field-label" for="patternNotes">Notes / Finishing</label>
<textarea id="patternNotes" rows="2" placeholder="Assembly, finishing, safety warnings, credits..."></textarea>
<label class="field-label" style="margin-top: 10px;">Finished Photos</label>
<div id="finishedPhotoList" class="finished-photo-list"></div>
<button class="secondary small" type="button" onclick="addFinishedPhoto()">Add Photo</button>
</div>
</div>
<div class="input-card collapsible-card full-width" id="specCard-abbrev">
<div class="card-head-row" onclick="toggleSpecsCard('specCard-abbrev')">
<h4 class="card-head">Abbreviations</h4>
<div class="card-head-actions" onclick="event.stopPropagation()">
<button class="secondary small" onclick="openAbbrevModal()">Edit / Add</button>
</div>
<i class="fa-solid fa-chevron-down card-toggle-icon"></i>
</div>
<div class="card-content">
<div id="abbrevSummary" class="selected-abbrev">
<!-- Selected pills will appear here -->
<span class="text-muted" style="font-size: 0.9rem;">No stitches selected.</span>
</div>
<textarea id="patternAbbrev" rows="3" placeholder="Selected abbreviations will appear here..." readonly></textarea>
<label class="field-label" for="patternStitches">Special Stitches / Notes</label>
<textarea id="patternStitches" rows="2" placeholder="Magic ring: ...&#10;Invisible decrease: ..."></textarea>
</div>
</div>
</div>
<div class="pattern-footer-actions">
<button class="secondary" onclick="importPatternJSON()">Import JSON</button>
</div>
</div>
<div class="pattern-section" data-section="steps">
<div class="pattern-steps-head">
<div>
<h4>Pattern Steps</h4>
<p class="muted small">Build steps and rows in order. Collapse steps to stay focused.</p>
</div>
</div>
<div id="patternSteps"></div>
</div>
<div class="pattern-section" data-section="library">
<div class="shelf-header">
<div class="shelf-header-text">
<h4>My Pattern Shelf</h4>
<p class="muted small">Your composed and imported patterns, all in one place.</p>
</div>
<div class="shelf-header-actions">
<button class="shelf-btn shelf-btn--upload" onclick="uploadToLibrary()" title="Upload a PDF, EPUB, or image">
<i class="fa-solid fa-upload"></i> Upload
</button>
<button class="shelf-btn shelf-btn--save" onclick="savePatternDraft()" title="Save current composer draft">
<i class="fa-solid fa-floppy-disk"></i> Save Draft
</button>
</div>
</div>
<div class="pattern-library-controls">
<input id="patternSearch" type="text" placeholder="Search patterns, tags..." oninput="renderPatternLibrary()">
<select id="patternSort" onchange="renderPatternLibrary()">
<option value="recent">Most recent</option>
<option value="name">Name AZ</option>
</select>
</div>
<div class="shelf-filter-bar" id="shelfFilterBar">
<button class="shelf-filter-chip active" data-filter="all" onclick="setShelfFilter('all')">All</button>
<button class="shelf-filter-chip" data-filter="crochet" onclick="setShelfFilter('crochet')">Crochet</button>
<button class="shelf-filter-chip" data-filter="knit" onclick="setShelfFilter('knit')">Knitting</button>
<button class="shelf-filter-chip" data-filter="pdf" onclick="setShelfFilter('pdf')">PDF</button>
<button class="shelf-filter-chip" data-filter="epub" onclick="setShelfFilter('epub')">EPUB</button>
<div id="shelfTagFilters" class="shelf-tag-filters"></div>
</div>
<div id="patternLibrary" class="pattern-library"></div>
</div>
<div class="pattern-section" data-section="view">
<div id="patternView" class="pattern-view"></div>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="abbrevModal">
<div class="modal-content" style="width: min(800px, 94vw); max-height: 85vh; display: flex; flex-direction: column;">
<h3 class="modal-title">Select Abbreviations</h3>
<div class="abbrev-controls" style="margin-bottom: 12px;">
<input type="text" id="abbrevSearch" placeholder="Search stitches..." oninput="filterAbbrev()">
<button class="secondary small" onclick="loadDefaultAbbrev()">Reset to Defaults</button>
</div>
<div id="patternAbbrevList" class="abbrev-grid" style="overflow-y: auto; flex: 1;"></div>
<div class="modal-actions" style="margin-top: 12px; border-top: 1px solid var(--border); padding-top: 12px;">
<button class="modal-btn btn-save" onclick="closeAbbrevModal()">Done</button>
</div>
</div>
</div>
<!-- Pattern File Viewer -->
<div class="modal-overlay" id="patternFileViewerOverlay">
<div class="viewer-shell">
<div class="viewer-header">
<button class="viewer-back-btn" onclick="closePatternFileViewer()" title="Back to counter">
<i class="fa-solid fa-arrow-left"></i> Counter
</button>
<span id="viewerPatternName" class="viewer-title"></span>
<button class="viewer-close" onclick="closePatternFileViewer()" title="Close">&times;</button>
</div>
<div class="viewer-stage" id="viewerStage">
<div id="viewerBook" class="viewer-book"></div>
<div class="viewer-loading" id="viewerLoading">
<div class="import-spinner"></div>
<p>Loading…</p>
</div>
</div>
<div class="viewer-counter-bar" id="viewerCounterBar" style="display:none">
<div class="viewer-counter-info">
<span id="viewerCounterProject" class="viewer-counter-project"></span>
<i class="fa-solid fa-chevron-right viewer-counter-sep"></i>
<span id="viewerCounterPart" class="viewer-counter-part"></span>
</div>
<div class="viewer-counter-btns">
<button class="viewer-count-btn" id="viewerCountMinus" onclick="viewerCountChange(-1)"></button>
<span id="viewerCountDisplay" class="viewer-count-display">0</span>
<button class="viewer-count-btn" id="viewerCountPlus" onclick="viewerCountChange(1)">+</button>
</div>
<button class="viewer-goto-counter" onclick="closePatternFileViewer()" title="Back to counter">
<i class="fa-solid fa-list-check"></i>
</button>
</div>
<div class="viewer-controls">
<button class="viewer-nav-btn" id="viewerPrevBtn" onclick="viewerNavigate(-1)">&#8249;</button>
<span id="viewerPageInfo" class="viewer-page-info" onclick="viewerJumpToPage()" title="Tap to jump to page" style="cursor:pointer"></span>
<button class="viewer-nav-btn" id="viewerNextBtn" onclick="viewerNavigate(1)">&#8250;</button>
<div class="viewer-zoom-controls">
<button class="viewer-zoom-btn" onclick="viewerZoomChange(-0.25)" title="Zoom out"></button>
<span id="viewerZoomDisplay" class="viewer-zoom-display">100%</span>
<button class="viewer-zoom-btn" onclick="viewerZoomChange(0.25)" title="Zoom in">+</button>
<button class="viewer-zoom-btn viewer-zoom-reset" onclick="viewerZoomReset()" title="Reset zoom"></button>
</div>
</div>
</div>
</div>
<script src="assets/app.js?v=9"></script>
<footer class="footer-bg" aria-hidden="true"></footer>
</body>
</html>