From bda02bcf5fda081716e9073a5f556f71b3fec978 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Tue, 28 Jul 2026 17:10:22 +0530 Subject: [PATCH] chore: automated code quality fixes () --- .github/AUTOMATION.md | 9 +- .github/pull_request_template.md | 10 + .github/scripts/auto-label.js | 18 +- .github/scripts/remind-issues.mjs | 2 +- .github/workflows/auto-label.yml | 2 +- .github/workflows/contributor-welcome.yml | 1 - .github/workflows/issue-reminder.yml | 2 +- BUG_FIX_PATCH.md | 61 +- CONTRIBUTING.md | 56 +- DESIGN.md | 208 +-- EMAIL_VERIFICATION_BUG_FIX.md | 92 +- IMPLEMENTATION_SUMMARY.md | 107 +- PULL_REQUEST_TEMPLATE.md | 28 +- QUICK_DEPLOYMENT_GUIDE.md | 69 +- QUICK_REFERENCE.md | 95 +- README.md | 58 +- connect.js | 146 +- controller/analytics.js | 93 +- controller/auth.js | 664 ++++---- controller/collaborationController.js | 127 +- controller/instagramController.js | 105 +- controller/instagramWebhookController.js | 202 ++- controller/suggestionController.js | 103 +- controller/url.js | 542 +++--- docs/EMAIL_VERIFICATION.md | 107 +- docs/analytics.md | 96 +- index.js | 988 ++++++----- middleware/auth.js | 131 +- middleware/csrf.js | 78 +- middleware/errorHandler.js | 14 +- middleware/rateLimiters.js | 113 +- middleware/validators.js | 145 +- model/analyticsSnapshot.js | 52 +- model/contributorSession.js | 88 +- model/creator.js | 108 +- model/engagementHistory.js | 40 +- model/invite.js | 20 +- model/post.js | 38 +- model/seedSuggestions.js | 22 +- model/suggestion.js | 148 +- model/suggestionData.js | 11 +- model/url.js | 223 +-- model/user.js | 439 ++--- performance.md | 3 +- public/css/dashboard.css | 10 +- public/css/my-links.css | 813 ++++----- public/css/settings.css | 1029 ++++++------ public/css/style.css | 1842 ++++++++++++++++++++- public/css/workspace-shell.css | 808 ++++----- public/js/pages/dashboard.js | 58 +- public/js/pages/file-upload.js | 313 ++-- public/js/pages/home.js | 130 +- public/js/pages/login.js | 61 +- public/js/pages/services-hub.js | 287 ++-- public/js/pages/settings.js | 851 +++++----- public/js/pages/suggestions.js | 76 +- public/js/site-3d.js | 158 +- public/site.webmanifest | 20 +- routes/analytics.js | 24 +- routes/auth.js | 218 +-- routes/collaboration.js | 21 +- routes/instagram.js | 17 +- routes/settings.js | 226 ++- routes/suggestionRoutes.js | 16 +- routes/url.js | 66 +- scratch.js | 12 +- scripts/addDocs.js | 130 +- scripts/replace-layout.js | 139 +- scripts/seedTestUser.js | 32 +- services.config.js | 158 +- services/dmQueueService.js | 160 +- tests/email-verification.test.js | 205 +-- utils/asyncHandler.js | 4 +- utils/dashboardHelper.js | 216 +-- utils/email.js | 34 +- utils/instagramProfileService.js | 621 +++---- utils/requestType.js | 4 +- utils/swaggerOptions.js | 77 +- utils/validators.js | 16 +- utils/viewModels.js | 20 +- vercel.json | 2 +- view/index.html | 273 +-- workers/analyticsRefreshWorker.js | 100 +- 83 files changed, 9017 insertions(+), 5894 deletions(-) diff --git a/.github/AUTOMATION.md b/.github/AUTOMATION.md index 9b609991..60d5210e 100644 --- a/.github/AUTOMATION.md +++ b/.github/AUTOMATION.md @@ -5,7 +5,9 @@ This repository uses **simple, lightweight GitHub Actions** to automate issue ma ## What's Automated ### 1. **Auto-Label Issues** (`.github/workflows/auto-label.yml`) + Runs when issues are opened or edited. + - Detects: bug, enhancement, documentation, question - Marks incomplete issues with `needs-info` label - No configuration needed - works out of the box @@ -13,7 +15,9 @@ Runs when issues are opened or edited. **Script**: `scripts/auto-label.js` ### 2. **Issue Reminders** (`.github/workflows/issue-reminder.yml`) + Runs every 12 hours. + - Finds open assigned issues inactive for 32+ hours - Comments reminder once (avoids spam) - Asks for blockers, progress, and ETA @@ -23,6 +27,7 @@ Runs every 12 hours. ## Local Testing ### Test Auto-Label + ```bash export GITHUB_TOKEN="your_token" export GITHUB_REPOSITORY_OWNER="your_username" @@ -33,6 +38,7 @@ node scripts/auto-label.js ``` ### Test Reminders + ```bash export GITHUB_TOKEN="your_token" export GITHUB_REPOSITORY_OWNER="your_username" @@ -71,11 +77,12 @@ scripts/ ✓ **Easy to debug** - Transparent logging ✓ **No spam** - Reminds only once per issue ✓ **Maintainable** - <100 lines per script -✓ **Zero config** - Works immediately +✓ **Zero config** - Works immediately ## Future Additions When you have more contributors, consider: + - Stale issue auto-closing (30+ days inactive) - PR greetings for first-time contributors - Automatic assignment rotation diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 656aff75..88c0168b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,12 +1,15 @@ ## 📝 Description + ## 🔗 Related Issues + Fixes # ## 📋 Type of Change + - [ ] 🐛 Bug fix (non-breaking change which fixes an issue) @@ -19,6 +22,7 @@ Fixes # - [ ] 🧪 Test update ## 🔄 Changes Made + - Change 1 @@ -28,6 +32,7 @@ Fixes # ## ✅ Testing ### How to Test + 1. @@ -35,17 +40,21 @@ Fixes # 3. ### Test Coverage + - [ ] Unit tests added/updated - [ ] Integration tests added/updated - [ ] E2E tests added/updated (if applicable) ## 📸 Screenshots (if applicable) + ## 🚀 Deployment Notes + ## ⚠️ Breaking Changes + - None @@ -63,6 +72,7 @@ Fixes # - [ ] I have linked related issues ## 📝 Additional Context + --- diff --git a/.github/scripts/auto-label.js b/.github/scripts/auto-label.js index f667ebef..0de4f942 100644 --- a/.github/scripts/auto-label.js +++ b/.github/scripts/auto-label.js @@ -32,7 +32,11 @@ async function autoLabel() { const labelsToAdd = []; // Detect issue type - if (content.includes("bug") || content.includes("error") || content.includes("broken")) { + if ( + content.includes("bug") || + content.includes("error") || + content.includes("broken") + ) { if (!currentLabels.includes("bug")) { labelsToAdd.push("bug"); } @@ -44,11 +48,19 @@ async function autoLabel() { if (!currentLabels.includes("enhancement")) { labelsToAdd.push("enhancement"); } - } else if (content.includes("doc") || content.includes("readme") || content.includes("guide")) { + } else if ( + content.includes("doc") || + content.includes("readme") || + content.includes("guide") + ) { if (!currentLabels.includes("documentation")) { labelsToAdd.push("documentation"); } - } else if (content.includes("?") || content.includes("how to") || content.includes("question")) { + } else if ( + content.includes("?") || + content.includes("how to") || + content.includes("question") + ) { if (!currentLabels.includes("question")) { labelsToAdd.push("question"); } diff --git a/.github/scripts/remind-issues.mjs b/.github/scripts/remind-issues.mjs index 68924e60..12ac606d 100644 --- a/.github/scripts/remind-issues.mjs +++ b/.github/scripts/remind-issues.mjs @@ -47,7 +47,7 @@ async function remindIssues() { const hasReminder = comments.data.some( (comment) => comment.user.login === "github-actions[bot]" && - comment.body.includes("Any progress update") + comment.body.includes("Any progress update"), ); if (hasReminder) { diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index c7ea7765..91bf802f 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -27,4 +27,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} \ No newline at end of file + GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} diff --git a/.github/workflows/contributor-welcome.yml b/.github/workflows/contributor-welcome.yml index c0fde119..18d46e99 100644 --- a/.github/workflows/contributor-welcome.yml +++ b/.github/workflows/contributor-welcome.yml @@ -9,7 +9,6 @@ permissions: issues: write pull-requests: write - jobs: welcome: runs-on: ubuntu-latest diff --git a/.github/workflows/issue-reminder.yml b/.github/workflows/issue-reminder.yml index b93f3db9..0c5df188 100644 --- a/.github/workflows/issue-reminder.yml +++ b/.github/workflows/issue-reminder.yml @@ -30,4 +30,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} GITHUB_REPOSITORY: ${{ github.repository }} - run: node .github/scripts/remind-issues.mjs \ No newline at end of file + run: node .github/scripts/remind-issues.mjs diff --git a/BUG_FIX_PATCH.md b/BUG_FIX_PATCH.md index ed9832b2..ef1db923 100644 --- a/BUG_FIX_PATCH.md +++ b/BUG_FIX_PATCH.md @@ -13,13 +13,14 @@ ## 1. controller/auth.js ### Change 1.1: Line 320-326 (resendVerificationEmail - missing email) + ```diff if (!email) { if (wantsHtml(req)) { -- return res.status(400).render("resend-verification", { -- error: "Email address is required." +- return res.status(400).render("resend-verification", { +- error: "Email address is required." - }); -+ return res.status(400).render("resend-verification", { ++ return res.status(400).render("resend-verification", { + error: "Email address is required.", + success: null + }); @@ -27,11 +28,12 @@ ``` ### Change 1.2: Line 330-338 (resendVerificationEmail - user not found) + ```diff if (!user) { // Don't reveal whether email exists if (wantsHtml(req)) { - return res.render("resend-verification", { + return res.render("resend-verification", { success: "If that email address is in our system, you'll receive a verification email shortly.", - error: null, + error: null @@ -40,10 +42,11 @@ ``` ### Change 1.3: Line 340-346 (resendVerificationEmail - already verified) + ```diff if (user.isVerified) { if (wantsHtml(req)) { - return res.render("resend-verification", { + return res.render("resend-verification", { success: "Your email is already verified. You can log in now.", - error: null, + error: null @@ -52,12 +55,13 @@ ``` ### Change 1.4: Line 368-374 (resendVerificationEmail - email send failure) + ```diff } catch (emailError) { console.error("Failed to send verification email:", emailError); if (wantsHtml(req)) { - return res.status(500).render("resend-verification", { -- error: "Failed to send verification email. Please try again later." + return res.status(500).render("resend-verification", { +- error: "Failed to send verification email. Please try again later." + error: "Failed to send verification email. Please try again later.", + success: null }); @@ -65,9 +69,10 @@ ``` ### Change 1.5: Line 380-386 (resendVerificationEmail - success) + ```diff if (wantsHtml(req)) { - return res.render("resend-verification", { + return res.render("resend-verification", { success: "Verification email sent! Please check your inbox.", - error: null, + error: null @@ -76,11 +81,12 @@ ``` ### Change 1.6: Line 247-253 (verifyEmail - no token) + ```diff if (!token) { if (wantsHtml(req)) { - return res.status(400).render("verify-email", { -- error: "Invalid verification link. Please request a new one." + return res.status(400).render("verify-email", { +- error: "Invalid verification link. Please request a new one." + error: "Invalid verification link. Please request a new one.", + success: null, + expiredToken: false, @@ -90,11 +96,12 @@ ``` ### Change 1.7: Line 262-268 (verifyEmail - user not found) + ```diff if (!user) { if (wantsHtml(req)) { - return res.status(400).render("verify-email", { -- error: "Invalid verification link. Please request a new one." + return res.status(400).render("verify-email", { +- error: "Invalid verification link. Please request a new one." + error: "Invalid verification link. Please request a new one.", + success: null, + expiredToken: false, @@ -104,10 +111,11 @@ ``` ### Change 1.8: Line 271-277 (verifyEmail - already verified) + ```diff if (user.isVerified) { if (wantsHtml(req)) { - return res.render("verify-email", { + return res.render("verify-email", { success: "Your email is already verified. You can log in now.", - error: null, + error: null, @@ -118,10 +126,11 @@ ``` ### Change 1.9: Line 280-288 (verifyEmail - token expired) + ```diff if (isVerificationTokenExpired(user.verificationTokenExpiry)) { if (wantsHtml(req)) { - return res.status(410).render("verify-email", { + return res.status(410).render("verify-email", { error: "Verification link has expired. Please request a new one.", - expiredToken: true, - userEmail: user.email, @@ -133,9 +142,10 @@ ``` ### Change 1.10: Line 309-315 (verifyEmail - success) + ```diff if (wantsHtml(req)) { - return res.render("verify-email", { + return res.render("verify-email", { success: "Your email has been verified successfully! You can now log in.", - error: null, + error: null, @@ -150,12 +160,14 @@ ## 2. view/resend-verification.ejs ### Change 2.1: Line 215 (error condition) + ```diff - <% if (error) { %> + <% if (typeof error !== 'undefined' && error) { %> ``` ### Change 2.2: Line 222 (success condition) + ```diff - <% if (success) { %> + <% if (typeof success !== 'undefined' && success) { %> @@ -166,12 +178,14 @@ ## 3. view/verify-email.ejs ### Change 3.1: Line 177 (success condition) + ```diff - <% if (success) { %> + <% if (typeof success !== 'undefined' && success) { %> ``` ### Change 3.2: Line 189 (error condition) + ```diff - <% } else if (error) { %> + <% } else if (typeof error !== 'undefined' && error) { %> @@ -181,13 +195,13 @@ ## 🎯 Summary of Changes -| Change Type | Count | Purpose | -|-------------|-------|---------| -| Added `success: null` to error renders | 4 | Prevent undefined variable errors | -| Added missing template variables | 6 | Ensure all variables are defined | -| Type-safe EJS checks | 4 | Use `typeof` to prevent ReferenceError | -| Formatting cleanup | 15 | Removed trailing commas, consistency | -| **Total Lines** | **+29** | | +| Change Type | Count | Purpose | +| -------------------------------------- | ------- | -------------------------------------- | +| Added `success: null` to error renders | 4 | Prevent undefined variable errors | +| Added missing template variables | 6 | Ensure all variables are defined | +| Type-safe EJS checks | 4 | Use `typeof` to prevent ReferenceError | +| Formatting cleanup | 15 | Removed trailing commas, consistency | +| **Total Lines** | **+29** | | --- @@ -206,9 +220,10 @@ ## 🧪 Testing All test cases pass: + - ✅ Initial page load - ✅ Resend with valid email -- ✅ Resend with unregistered email +- ✅ Resend with unregistered email - ✅ Resend with already verified email - ✅ Resend with no email - ✅ Email verification success diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5ac2a97..2a4fc0e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,17 +5,20 @@ Thank you for wanting to contribute to CreatorOS! We're excited to have you help ## 🚀 Quick Start 1. **Fork the repository** + ```bash git clone https://github.com/YOUR_USERNAME/CreatorOS.git cd CreatorOS ``` 2. **Create a feature branch** + ```bash git checkout -b feature/your-feature-name ``` 3. **Make your changes** and commit + ```bash git commit -m "feat: add amazing feature" ``` @@ -30,11 +33,13 @@ Thank you for wanting to contribute to CreatorOS! We're excited to have you help ## 📋 Before You Start ### Pick an Issue + - 🟢 **Good First Issues**: Perfect for newcomers - 🟡 **Help Wanted**: Medium complexity - 🔴 **Complex Issues**: For experienced contributors ### Ask Questions + - Comment on the issue to express interest - Ask clarifications before starting work - Check if someone is already working on it @@ -44,11 +49,13 @@ Thank you for wanting to contribute to CreatorOS! We're excited to have you help ## 💻 Development Setup ### Requirements + - **Node.js**: 18 or higher - **npm**: 9 or higher - **MongoDB**: Running locally (or use MongoDB Atlas) ### Installation + ```bash # Install dependencies npm install @@ -62,6 +69,7 @@ npm run dev ``` ### Running Tests + ```bash npm test ``` @@ -71,24 +79,28 @@ npm test ## 🎯 Types of Contributions ### 🐛 Bug Reports + - Use the **Bug Report** template - Include steps to reproduce - Provide environment details - Add screenshots if applicable ### ✨ Feature Requests + - Use the **Feature Request** template - Explain the problem and solution - Describe use cases - Link related issues ### 📚 Documentation + - Fix typos and clarity - Add examples - Improve guides - Update outdated content ### 💬 Questions + - Use the **Question** template - Share context - Help others in discussions @@ -98,6 +110,7 @@ npm test ## 📝 Code Style ### Commit Messages + Follow the conventional commits format: ``` @@ -111,6 +124,7 @@ footer (optional) **Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` **Examples**: + ``` feat(auth): add two-factor authentication fix(dashboard): resolve chart rendering issue @@ -118,12 +132,14 @@ docs: update API documentation ``` ### Code Formatting + - Use **2 spaces** for indentation - Follow existing code style - Run formatter: `npm run format` (if available) - Use meaningful variable names ### JavaScript Conventions + ```javascript // ✅ Good const getUserData = (userId) => { @@ -141,19 +157,22 @@ function get_user_data(x) { ## 🔍 Code Review Process ### What We Look For + ✅ **Functionality**: Does it work as intended? ✅ **Testing**: Is it covered by tests? ✅ **Documentation**: Are changes documented? ✅ **Performance**: Does it scale? -✅ **Security**: No vulnerabilities? +✅ **Security**: No vulnerabilities? ### Review Timeline + - Usually reviewed within **24-48 hours** - Complex changes may take longer - We'll provide constructive feedback - Please respond to comments ### Common Feedback + - "Can you add a test for this?" - "Please update the docs" - "This could be simplified" @@ -181,25 +200,32 @@ Before submitting, ensure: ```markdown ## Description + Clear description of what changed and why ## Related Issues + Fixes #123 ## Changes + - Change 1 - Change 2 - Change 3 ## Testing + Steps to verify changes: + 1. Step 1 2. Step 2 ## Screenshots (if applicable) + [Add screenshots] ## Checklist + - [ ] Tests pass - [ ] Docs updated - [ ] No breaking changes @@ -211,28 +237,35 @@ Steps to verify changes: ```markdown ## Description + Clear description of the bug ## Steps to Reproduce + 1. Step 1 2. Step 2 3. Step 3 ## Expected Behavior + What should happen ## Actual Behavior + What actually happens ## Environment -- OS: -- Node.js: + +- OS: +- Node.js: - Package version: ## Logs + [Paste relevant logs] ## Checklist + - [ ] Searched for existing issues - [ ] Provided all required info - [ ] Willing to help fix @@ -244,25 +277,32 @@ What actually happens ```markdown ## Description + Clear description of the feature ## Problem + What problem does this solve? ## Proposed Solution + How should this work? ## Use Cases + Real-world use cases: + 1. Use case 1 2. Use case 2 ## Complexity + - [ ] Simple - [ ] Medium - [ ] Complex ## Checklist + - [ ] Searched for existing requests - [ ] Clear problem statement - [ ] Willing to implement @@ -273,12 +313,14 @@ Real-world use cases: ## 🔄 After Approval ### Getting Your PR Merged + 1. All feedback addressed? ✅ 2. CI/CD passing? ✅ 3. At least one approval? ✅ 4. Ready to merge! ### Post-Merge + - Your changes will be deployed in the next release - You'll be credited as a contributor - Your code is now part of CreatorOS! 🎉 @@ -288,18 +330,21 @@ Real-world use cases: ## 📚 Resources ### Useful Docs + - [Project Architecture](./ARCHITECTURE.md) - [API Documentation](./API.md) - [Automation Guide](./ AUTOMATION.md) - [Deployment Guide](./DEPLOYMENT.md) ### Learning Resources + - [GitHub Guides](https://guides.github.com/) - [Node.js Documentation](https://nodejs.org/docs/) - [Express.js Guide](https://expressjs.com/) - [MongoDB Docs](https://docs.mongodb.com/) ### Community + - 💬 GitHub Issues - Ask questions - 📞 Discord - Chat with team - 🐦 Twitter - Latest updates @@ -309,19 +354,23 @@ Real-world use cases: ## 💡 Tips for Success ### For First-Time Contributors + - Start with "good first issues" - Ask questions in comments - Don't worry about being perfect - We're here to help! ### For Regular Contributors + - Help review other PRs - Mentor new contributors - Suggest improvements - Share knowledge ### Getting Help + Stuck? No problem! + - Comment on the issue - Ask in discussions - Reach out to maintainers @@ -332,6 +381,7 @@ Stuck? No problem! ## 🙏 Thank You! Every contribution matters, whether it's: + - 💻 Code - 📝 Documentation - 🐛 Bug reports diff --git a/DESIGN.md b/DESIGN.md index b24988cc..11b26794 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,15 +1,17 @@ # CreatorOS Complete Design System & Specification > **CRITICAL INSTRUCTION** -> When asked to "design", "build", or "redesign" ANY page or component for CreatorOS, you **MUST** strictly adhere to every single detail, variable, pixel offset, and structural paradigm in this document. Do not use generic components, default Bootstrap, or standard modern web styling. +> When asked to "design", "build", or "redesign" ANY page or component for CreatorOS, you **MUST** strictly adhere to every single detail, variable, pixel offset, and structural paradigm in this document. Do not use generic components, default Bootstrap, or standard modern web styling. > **You are building an exact, uncompromising, neo-brutalist interface.** --- ## 1. The Core Aesthetic Paradigm + The CreatorOS aesthetic is "Neo-Brutalism". It mimics a physical, punchy, high-contrast scrapbook. Elements feel like thick cardboard cutouts with raw, unapologetic styling. **Absolute Rules:** + 1. **Zero Border Radius:** Every element, button, input, and card MUST have `border-radius: 0;` or `rounded-none`. No exceptions. 2. **Hard Shadows Only:** Drop shadows are NEVER blurred. They are solid blocks of color. 3. **Thick Borders:** Every container uses a `2px` or `4px` solid black border. @@ -24,41 +26,43 @@ Every stylesheet MUST use these precise variables and colors. ```css :root { - /* 1. Base & Neutrals */ - --base-100: #F8F9FA; /* Main application background (off-white) */ - --base-600: #4B5563; /* Muted text */ - --base-700: #374151; /* Sub-headings and secondary text */ - --base-800: #1F2937; /* Darker text */ - --base-900: #111827; /* Almost black backgrounds */ - --black: #000000; /* Primary text, borders, default shadows */ - --white: #FFFFFF; /* High contrast card backgrounds */ - - /* 2. Vibrant Accent Colors */ - --accent-500: #4338CA; /* Primary Indigo. Used for main CTAs, highlights */ - --accent-600: #3730A3; /* Hover state for primary buttons */ - --warning-500: #F59E0B; /* Primary Orange/Yellow. Used for secondary CTAs, badges */ - --warning-600: #D97706; /* Hover state for warning elements */ - --success-500: #10B981; /* Green. Used for positive states and decorative blocks */ - --success-600: #059669; /* Hover state for success elements */ - --danger-500: #EF4444; /* Red. Used for destructive actions and hover warnings */ - - /* 3. Shadow System */ - --shadow-ink: var(--black); /* Default shadow color, can be swapped for white on dark bg */ - --shadow-sm: 3px 3px 0px 0px var(--shadow-ink); - --shadow-md: 4px 4px 0px 0px var(--shadow-ink); - --shadow-lg: 6px 6px 0px 0px var(--shadow-ink); - --shadow-xl: 8px 8px 0px 0px var(--shadow-ink); - --shadow-xxl: 9px 9px 0px 0px var(--shadow-ink); - - /* 4. Border System */ - --border-base: 2px solid var(--black); - --border-thick: 4px solid var(--black); + /* 1. Base & Neutrals */ + --base-100: #f8f9fa; /* Main application background (off-white) */ + --base-600: #4b5563; /* Muted text */ + --base-700: #374151; /* Sub-headings and secondary text */ + --base-800: #1f2937; /* Darker text */ + --base-900: #111827; /* Almost black backgrounds */ + --black: #000000; /* Primary text, borders, default shadows */ + --white: #ffffff; /* High contrast card backgrounds */ + + /* 2. Vibrant Accent Colors */ + --accent-500: #4338ca; /* Primary Indigo. Used for main CTAs, highlights */ + --accent-600: #3730a3; /* Hover state for primary buttons */ + --warning-500: #f59e0b; /* Primary Orange/Yellow. Used for secondary CTAs, badges */ + --warning-600: #d97706; /* Hover state for warning elements */ + --success-500: #10b981; /* Green. Used for positive states and decorative blocks */ + --success-600: #059669; /* Hover state for success elements */ + --danger-500: #ef4444; /* Red. Used for destructive actions and hover warnings */ + + /* 3. Shadow System */ + --shadow-ink: var( + --black + ); /* Default shadow color, can be swapped for white on dark bg */ + --shadow-sm: 3px 3px 0px 0px var(--shadow-ink); + --shadow-md: 4px 4px 0px 0px var(--shadow-ink); + --shadow-lg: 6px 6px 0px 0px var(--shadow-ink); + --shadow-xl: 8px 8px 0px 0px var(--shadow-ink); + --shadow-xxl: 9px 9px 0px 0px var(--shadow-ink); + + /* 4. Border System */ + --border-base: 2px solid var(--black); + --border-thick: 4px solid var(--black); } body { - background-color: var(--base-100); - color: var(--black); - /* For custom scrollbars, use 'scrollbar-hide' utility if needed, but no soft scrollbars */ + background-color: var(--base-100); + color: var(--black); + /* For custom scrollbars, use 'scrollbar-hide' utility if needed, but no soft scrollbars */ } ``` @@ -70,52 +74,55 @@ Import these exact Google Fonts: `` ### 3.1 Display & Headings (`.font-display`, `.font-serif`) -* **Font Family:** `'Petrona', serif` -* **Weights:** `600` (Semi-Bold), `700` (Bold) -* **Hero Titles (`h1`):** `text-4xl` up to `text-7xl` or `text-8xl`. -* **Line Height:** Extremely tight. `leading-[0.9]` or `leading-[0.98]`. -* **Tracking:** Tight. `tracking-tight`. -* **Usage:** Section titles, hero headers. + +- **Font Family:** `'Petrona', serif` +- **Weights:** `600` (Semi-Bold), `700` (Bold) +- **Hero Titles (`h1`):** `text-4xl` up to `text-7xl` or `text-8xl`. +- **Line Height:** Extremely tight. `leading-[0.9]` or `leading-[0.98]`. +- **Tracking:** Tight. `tracking-tight`. +- **Usage:** Section titles, hero headers. ### 3.2 Body & Paragraphs (`.font-sans`) -* **Font Family:** `'Source Sans 3', sans-serif` -* **Weights:** `400` (Regular), `500` (Medium), `600` (Semi-Bold). -* **Line Height:** Relaxed for readability. `leading-[1.65]` or `leading-relaxed`. -* **Opacity Variants:** When used inside colored cards, text should be `text-black/80` or `text-white/85`. -* **Usage:** Standard text, descriptions, subtext. + +- **Font Family:** `'Source Sans 3', sans-serif` +- **Weights:** `400` (Regular), `500` (Medium), `600` (Semi-Bold). +- **Line Height:** Relaxed for readability. `leading-[1.65]` or `leading-relaxed`. +- **Opacity Variants:** When used inside colored cards, text should be `text-black/80` or `text-white/85`. +- **Usage:** Standard text, descriptions, subtext. ### 3.3 Labels & Buttons (`.font-label`) -* **Font Family:** `'Azeret Mono', monospace` -* **Weights:** `400` (Regular), `600` (Bold) -* **Styling:** ALWAYS `uppercase`, `tracking-wide` (letter spacing), often `text-xs`, `text-sm`, or `text-base`. -* **Usage:** Navigation links, button text, badges, numbered steps (e.g., "01"). + +- **Font Family:** `'Azeret Mono', monospace` +- **Weights:** `400` (Regular), `600` (Bold) +- **Styling:** ALWAYS `uppercase`, `tracking-wide` (letter spacing), often `text-xs`, `text-sm`, or `text-base`. +- **Usage:** Navigation links, button text, badges, numbered steps (e.g., "01"). --- ## 4. Neo-Brutalist Interaction Physics -The interaction model is highly specific. When hovering, elements don't just change color; they move on the X/Y axes and their solid shadows expand. +The interaction model is highly specific. When hovering, elements don't just change color; they move on the X/Y axes and their solid shadows expand. ### Standard Hover Sequence 1. **Default State:** Element has `shadow-[4px_4px_0_var(--shadow-ink)]` and is static (or has a slight rotation like `rotate-[-1deg]`). - + Default State -2. **Hover State (`:hover`):** - * Move the element UP and LEFT: `translate-x-[-2px] translate-y-[-2px]` (sometimes up to `-3px`). - * Expand the shadow: `shadow-[6px_6px_0_var(--shadow-ink)]` or `shadow-[8px_8px_0_var(--shadow-ink)]`. - * Snap rotation to `0deg` (e.g., `hover:rotate-0`). - +2. **Hover State (`:hover`):** + - Move the element UP and LEFT: `translate-x-[-2px] translate-y-[-2px]` (sometimes up to `-3px`). + - Expand the shadow: `shadow-[6px_6px_0_var(--shadow-ink)]` or `shadow-[8px_8px_0_var(--shadow-ink)]`. + - Snap rotation to `0deg` (e.g., `hover:rotate-0`). + Hover State -3. **Active/Click State (`:active`):** - * Move the element DOWN and RIGHT past its origin: `translate-x-[2px] translate-y-[2px]`. - * Remove or shrink the shadow entirely: `shadow-none`. - +3. **Active/Click State (`:active`):** + - Move the element DOWN and RIGHT past its origin: `translate-x-[2px] translate-y-[2px]`. + - Remove or shrink the shadow entirely: `shadow-none`. + Pressed State -*Transition Timing:* `transition-all duration-150` or `duration-200`. No slow fades. +_Transition Timing:_ `transition-all duration-150` or `duration-200`. No slow fades. --- @@ -125,96 +132,101 @@ The interaction model is highly specific. When hovering, elements don't just cha Primary Button -* **Structure:** Thick rectangle (`h-13`, `px-6`, `py-3`). -* **Border:** `1px` or `2px` solid black. -* **Background:** `var(--accent-500)`. -* **Text:** `var(--white)`, `.font-label`, `uppercase`, `tracking-wide`, `font-medium`. -* **Default Rotation:** Often slightly off-kilter (`rotate-[-1deg]`). -* **Hover:** Snaps to `rotate-0`, moves `-1px` or `-2px`, shadow grows from `6px` to `8px`. Background shifts to `var(--accent-600)`. +- **Structure:** Thick rectangle (`h-13`, `px-6`, `py-3`). +- **Border:** `1px` or `2px` solid black. +- **Background:** `var(--accent-500)`. +- **Text:** `var(--white)`, `.font-label`, `uppercase`, `tracking-wide`, `font-medium`. +- **Default Rotation:** Often slightly off-kilter (`rotate-[-1deg]`). +- **Hover:** Snaps to `rotate-0`, moves `-1px` or `-2px`, shadow grows from `6px` to `8px`. Background shifts to `var(--accent-600)`. ### 5.2 Secondary/Ghost Buttons Secondary Button -* **Background:** `transparent` or `var(--white)`. -* **Text:** `var(--black)`. -* **Hover:** Background changes to `var(--black)` and text to `var(--white)`, or background changes to `var(--warning-500)` with black text. +- **Background:** `transparent` or `var(--white)`. +- **Text:** `var(--black)`. +- **Hover:** Background changes to `var(--black)` and text to `var(--white)`, or background changes to `var(--warning-500)` with black text. ### 5.3 Highlight Spans (In-text Badges) Highlight Spans Used inside `h1` or `h2` elements to draw extreme attention. -* **Style 1 (Standard):** `bg-accent-500 text-white px-2 inline-block rotate-1 shadow-[4px_4px_0_var(--shadow-ink)]`. -* **Style 2 (Dark):** `bg-black text-white px-2 inline-block -rotate-1`. -* **Style 3 (Warning):** `bg-warning-500 text-black px-3 inline-block rotate-[2deg] shadow-[4px_4px_0_var(--shadow-ink)]`. + +- **Style 1 (Standard):** `bg-accent-500 text-white px-2 inline-block rotate-1 shadow-[4px_4px_0_var(--shadow-ink)]`. +- **Style 2 (Dark):** `bg-black text-white px-2 inline-block -rotate-1`. +- **Style 3 (Warning):** `bg-warning-500 text-black px-3 inline-block rotate-[2deg] shadow-[4px_4px_0_var(--shadow-ink)]`. ### 5.4 Feature Cards & Sponsor Blocks Feature Cards -* **Border:** `2px border-black`. -* **Padding:** `p-6 md:p-8` or `p-8 sm:p-10`. -* **Shadow:** `shadow-[6px_6px_0px_0px_var(--shadow-ink)]`. -* **Hover Physics:** `hover:-translate-x-[3px] hover:-translate-y-[3px] hover:shadow-[9px_9px_0px_0px_var(--shadow-ink)] hover:rotate-[-1deg]`. -* **Colors:** Cycle backgrounds between `accent-500`, `warning-500`, `success-500`, and `base-900`. Note that inside `base-900` or `accent-500` cards, text MUST be `text-base-100` (white) for contrast. +- **Border:** `2px border-black`. +- **Padding:** `p-6 md:p-8` or `p-8 sm:p-10`. +- **Shadow:** `shadow-[6px_6px_0px_0px_var(--shadow-ink)]`. +- **Hover Physics:** `hover:-translate-x-[3px] hover:-translate-y-[3px] hover:shadow-[9px_9px_0px_0px_var(--shadow-ink)] hover:rotate-[-1deg]`. +- **Colors:** Cycle backgrounds between `accent-500`, `warning-500`, `success-500`, and `base-900`. Note that inside `base-900` or `accent-500` cards, text MUST be `text-base-100` (white) for contrast. ### 5.5 Step Number Badges (e.g., 01, 02) Step Number Badge -* **Structure:** Perfect square (`w-12 h-12`). -* **Style:** `border-2 border-black bg-warning-500 flex items-center justify-center shadow-[3px_3px_0px_0px_var(--shadow-ink)]`. -* **Text:** `.font-label text-base text-white font-bold`. +- **Structure:** Perfect square (`w-12 h-12`). +- **Style:** `border-2 border-black bg-warning-500 flex items-center justify-center shadow-[3px_3px_0px_0px_var(--shadow-ink)]`. +- **Text:** `.font-label text-base text-white font-bold`. ### 5.6 Navbar (Top Fixed) Navbar -* **Wrapper:** `fixed top-0 left-0 right-0 z-30 py-4 px-3 md:px-6 transition-transform duration-300`. (Hides on scroll down, shows on scroll up using translateY). -* **Inner Box:** `max-w-screen-xl mx-auto bg-base-100 border-2 border-black shadow-[6px_6px_0px_0px_var(--shadow-ink)] px-5 py-3 md:px-8 md:py-4`. -* **Nav Links:** `.font-sans text-base font-medium text-black px-3 py-2 border-2 border-transparent hover:border-black hover:text-accent-500 hover:bg-accent-500/10 transition-all`. -* **Logo Hover Interaction:** `group-hover:rotate-[-2deg] group-hover:shadow-[4px_4px_0_var(--shadow-ink)]`. +- **Wrapper:** `fixed top-0 left-0 right-0 z-30 py-4 px-3 md:px-6 transition-transform duration-300`. (Hides on scroll down, shows on scroll up using translateY). +- **Inner Box:** `max-w-screen-xl mx-auto bg-base-100 border-2 border-black shadow-[6px_6px_0px_0px_var(--shadow-ink)] px-5 py-3 md:px-8 md:py-4`. +- **Nav Links:** `.font-sans text-base font-medium text-black px-3 py-2 border-2 border-transparent hover:border-black hover:text-accent-500 hover:bg-accent-500/10 transition-all`. +- **Logo Hover Interaction:** `group-hover:rotate-[-2deg] group-hover:shadow-[4px_4px_0_var(--shadow-ink)]`. ### 5.7 Form Inputs Text Input -* **Inputs:** `border: var(--border-base); background: var(--white); padding: 1rem; font-family: 'Source Sans 3', sans-serif; border-radius: 0;` -* **Focus State:** `box-shadow: var(--shadow-sm); outline: 2px solid var(--accent-500); outline-offset: 2px; transform: translate(-1px, -1px);` +- **Inputs:** `border: var(--border-base); background: var(--white); padding: 1rem; font-family: 'Source Sans 3', sans-serif; border-radius: 0;` +- **Focus State:** `box-shadow: var(--shadow-sm); outline: 2px solid var(--accent-500); outline-offset: 2px; transform: translate(-1px, -1px);` --- ## 6. Section & Layout Blueprints ### 6.1 Hero Section -* **Grid:** 2-column layout (`grid-cols-1 lg:grid-cols-2`). Text on left, massive illustration/graphic on right. -* **Heading:** Multi-line `h1` using `.font-display`, featuring at least one rotated Highlight Span (see 5.3). -* **Buttons:** A row of buttons wrapped in `flex flex-wrap gap-4`. Primary CTA on the left, Secondary/GitHub button on the right. + +- **Grid:** 2-column layout (`grid-cols-1 lg:grid-cols-2`). Text on left, massive illustration/graphic on right. +- **Heading:** Multi-line `h1` using `.font-display`, featuring at least one rotated Highlight Span (see 5.3). +- **Buttons:** A row of buttons wrapped in `flex flex-wrap gap-4`. Primary CTA on the left, Secondary/GitHub button on the right. ### 6.2 "The Pain / The Problem" Section (About) -* **Structure:** Image on the left, vertical list of 3 steps on the right. -* **Title:** Heavy use of strikethrough for irony. Example: `The joy pain of manually editing...` -* **Step Items:** Flex container with the Step Number Badge (5.5) and a `.font-serif` title. + +- **Structure:** Image on the left, vertical list of 3 steps on the right. +- **Title:** Heavy use of strikethrough for irony. Example: `The joy pain of manually editing...` +- **Step Items:** Flex container with the Step Number Badge (5.5) and a `.font-serif` title. ### 6.3 Dark Mode / Inverse CTA Section -* **Container:** Massive block with `bg-black border-4 border-black p-6 md:p-10 lg:p-16 z-10`. -* **Typography:** `text-white`. -* **Shadows:** Inside this dark block, shadows use white ink: `shadow-[4px_4px_0_white]`. -* **Inverse Highlight:** `bg-warning-500 text-black px-4 inline-block origin-center shadow-[6px_6px_0_white]`. -* **Dividers:** Use `h-[2px] bg-white/30` with `OR` text in the center. + +- **Container:** Massive block with `bg-black border-4 border-black p-6 md:p-10 lg:p-16 z-10`. +- **Typography:** `text-white`. +- **Shadows:** Inside this dark block, shadows use white ink: `shadow-[4px_4px_0_white]`. +- **Inverse Highlight:** `bg-warning-500 text-black px-4 inline-block origin-center shadow-[6px_6px_0_white]`. +- **Dividers:** Use `h-[2px] bg-white/30` with `OR` text in the center. --- ## 7. Decorative Elements & Flair 1. **Floating Abstract Shapes:** Place absolute positioned blocks randomly behind main sections to break the grid. - * *Example:* `
` + - _Example:_ `
` 2. **Strikethrough Emphasis:** Do not just cross things out. Use thick lines: `line-through decoration-4` or `decoration-warning-500`. 3. **Icons:** SVG icons inside buttons should be thick stroke (`stroke-width="2"`) and scale to `w-5 h-5` or `w-6 h-6`. Sometimes icons are inverted (`invert` class) when placed on dark background buttons. --- ## AI PROMPT INJECTION SUMMARY + **If you are an AI generating code based on this file:** You are forbidden from using `border-radius`. Every element is a sharp square or rectangle. You are forbidden from using blurred shadows (`box-shadow: 0 4px 6px rgba(...)`). Every border must be solid black (`2px` or `4px`). Every shadow must be a sharp offset (`4px 4px 0px 0px #000`). You MUST incorporate tactile hover physics (`translate-x-[-2px] translate-y-[-2px]` with shadow expansion). Use `Petrona` for displays, `Source Sans 3` for body text, and `Azeret Mono` for uppercase labels. Use `bg-accent-500`, `bg-warning-500`, and `bg-success-500` aggressively. Follow these exact pixel values, transitions, and HTML structure rules flawlessly to output a perfect neo-brutalist UI. diff --git a/EMAIL_VERIFICATION_BUG_FIX.md b/EMAIL_VERIFICATION_BUG_FIX.md index 88796bff..6103b9d9 100644 --- a/EMAIL_VERIFICATION_BUG_FIX.md +++ b/EMAIL_VERIFICATION_BUG_FIX.md @@ -46,9 +46,9 @@ The `resendVerificationEmail()` function in `controller/auth.js` had multiple co res.render("resend-verification", { error: "Email address is required." }); ✅ AFTER (includes both): -res.render("resend-verification", { +res.render("resend-verification", { error: "Email address is required.", - success: null + success: null }); ``` @@ -60,17 +60,17 @@ The `verifyEmail()` function had the same inconsistency across multiple error pa ## 📊 Affected Code Paths -| Function | Route | Issue | Fix | -|----------|-------|-------|-----| -| `resendVerificationEmail()` | POST /resend-verification (no email) | Missing `success: null` | ✅ Added | +| Function | Route | Issue | Fix | +| --------------------------- | ------------------------------------------ | ----------------------- | ------------------------ | +| `resendVerificationEmail()` | POST /resend-verification (no email) | Missing `success: null` | ✅ Added | | `resendVerificationEmail()` | POST /resend-verification (user not found) | Missing `success: null` | ✅ Added (in error path) | -| `resendVerificationEmail()` | POST /resend-verification (email error) | Missing `success: null` | ✅ Added | -| `verifyEmail()` | POST /verify-email (no token) | Missing variables | ✅ Added all | -| `verifyEmail()` | POST /verify-email (user not found) | Missing variables | ✅ Added all | -| `verifyEmail()` | POST /verify-email (already verified) | Missing variables | ✅ Added all | -| `verifyEmail()` | POST /verify-email (expired token) | Missing variables | ✅ Added all | -| `resend-verification.ejs` | N/A | Direct variable check | ✅ Type-safe | -| `verify-email.ejs` | N/A | Direct variable check | ✅ Type-safe | +| `resendVerificationEmail()` | POST /resend-verification (email error) | Missing `success: null` | ✅ Added | +| `verifyEmail()` | POST /verify-email (no token) | Missing variables | ✅ Added all | +| `verifyEmail()` | POST /verify-email (user not found) | Missing variables | ✅ Added all | +| `verifyEmail()` | POST /verify-email (already verified) | Missing variables | ✅ Added all | +| `verifyEmail()` | POST /verify-email (expired token) | Missing variables | ✅ Added all | +| `resend-verification.ejs` | N/A | Direct variable check | ✅ Type-safe | +| `verify-email.ejs` | N/A | Direct variable check | ✅ Type-safe | --- @@ -79,55 +79,60 @@ The `verifyEmail()` function had the same inconsistency across multiple error pa ### Fix 1: Updated `controller/auth.js` - `resendVerificationEmail()` function **Lines 320-326** - No email error: + ```javascript ❌ BEFORE: -res.render("resend-verification", { - error: "Email address is required." +res.render("resend-verification", { + error: "Email address is required." }); ✅ AFTER: -res.render("resend-verification", { +res.render("resend-verification", { error: "Email address is required.", success: null }); ``` **Lines 330-338** - User not found: + ```javascript ✅ CONSISTENT (was already correct): -res.render("resend-verification", { +res.render("resend-verification", { success: "If that email address is in our system, you'll receive a verification email shortly.", error: null }); ``` **Lines 340-346** - Email already verified: + ```javascript ✅ NOW CONSISTENT: -res.render("resend-verification", { +res.render("resend-verification", { success: "Your email is already verified. You can log in now.", error: null }); ``` **Lines 368-374** - Email send failure: + ```javascript ❌ BEFORE: -res.render("resend-verification", { - error: "Failed to send verification email. Please try again later." +res.render("resend-verification", { + error: "Failed to send verification email. Please try again later." }); ✅ AFTER: -res.render("resend-verification", { +res.render("resend-verification", { error: "Failed to send verification email. Please try again later.", success: null }); ``` **Lines 380-386** - Success response: + ```javascript ✅ NOW CONSISTENT: -res.render("resend-verification", { +res.render("resend-verification", { success: "Verification email sent! Please check your inbox.", error: null }); @@ -136,9 +141,10 @@ res.render("resend-verification", { ### Fix 2: Updated `controller/auth.js` - `verifyEmail()` function **Lines 247-253** - No token error: + ```javascript ✅ NOW INCLUDES: -{ +{ error: "Invalid verification link. Please request a new one.", success: null, expiredToken: false, @@ -147,11 +153,13 @@ res.render("resend-verification", { ``` **Lines 262-268** - Token not found: + ```javascript ✅ NOW INCLUDES ALL VARIABLES ``` **Lines 271-277** - Already verified: + ```javascript ✅ NOW INCLUDES: { @@ -163,6 +171,7 @@ res.render("resend-verification", { ``` **Lines 280-288** - Token expired: + ```javascript ✅ NOW INCLUDES: { @@ -174,6 +183,7 @@ res.render("resend-verification", { ``` **Lines 309-315** - Success: + ```javascript ✅ NOW INCLUDES ALL VARIABLES: { @@ -187,6 +197,7 @@ res.render("resend-verification", { ### Fix 3: Updated `view/resend-verification.ejs` **Line 222** - Success condition: + ```ejs ❌ BEFORE: <% if (success) { %> @@ -196,6 +207,7 @@ res.render("resend-verification", { ``` **Line 215** - Error condition: + ```ejs ❌ BEFORE: <% if (error) { %> @@ -207,6 +219,7 @@ res.render("resend-verification", { ### Fix 4: Updated `view/verify-email.ejs` **Line 177** - Success condition: + ```ejs ❌ BEFORE: <% if (success) { %> @@ -216,6 +229,7 @@ res.render("resend-verification", { ``` **Line 189** - Error condition: + ```ejs ❌ BEFORE: <% } else if (error) { %> @@ -231,6 +245,7 @@ res.render("resend-verification", { ### Test Case 1: Initial Page Load ✅ **Steps:** + 1. Navigate to `http://localhost:3000/resend-verification` 2. Verify page loads without errors 3. Verify form is displayed @@ -242,12 +257,14 @@ res.render("resend-verification", { ### Test Case 2: Resend with Valid Email ✅ **Steps:** + 1. Register a new account with an unverified email 2. Navigate to `http://localhost:3000/resend-verification` 3. Enter the registered email 4. Click "Send Verification Email" **Expected Result:** + - Success message displays: "Verification email sent! Please check your inbox." - Email is sent successfully - Link to login is shown @@ -257,11 +274,13 @@ res.render("resend-verification", { ### Test Case 3: Resend with Unregistered Email ✅ **Steps:** + 1. Navigate to `http://localhost:3000/resend-verification` 2. Enter an email that doesn't exist in the system 3. Click "Send Verification Email" **Expected Result:** + - Generic success message displays (privacy protection) - No indication whether email exists or not - User is not enumerated @@ -271,12 +290,14 @@ res.render("resend-verification", { ### Test Case 4: Resend with Already Verified Email ✅ **Steps:** + 1. Register and verify an account 2. Navigate to `http://localhost:3000/resend-verification` 3. Enter the verified email 4. Click "Send Verification Email" **Expected Result:** + - Success message displays: "Your email is already verified. You can log in now." - No verification email sent @@ -285,11 +306,13 @@ res.render("resend-verification", { ### Test Case 5: Resend with No Email ✅ **Steps:** + 1. Navigate to `http://localhost:3000/resend-verification` 2. Leave email field empty 3. Click "Send Verification Email" **Expected Result:** + - Error message displays: "Email address is required." - Page reloads with form @@ -298,11 +321,13 @@ res.render("resend-verification", { ### Test Case 6: Email Verification Success ✅ **Steps:** + 1. Register a new account (incomplete, unverified) 2. Retrieve verification link from email 3. Click verification link in browser **Expected Result:** + - Verification page displays with success checkmark - Success message: "Your email has been verified successfully! You can now log in." - Login link is shown @@ -313,9 +338,11 @@ res.render("resend-verification", { ### Test Case 7: Verification with Invalid Token ✅ **Steps:** + 1. Navigate to `http://localhost:3000/verify-email?token=invalid_token_xyz` **Expected Result:** + - Verification page displays with error icon - Error message: "Invalid verification link. Please request a new one." - Signup link is shown @@ -325,10 +352,12 @@ res.render("resend-verification", { ### Test Case 8: Verification with Expired Token ✅ **Steps:** + 1. Create a test user with expired token 2. Click verification link with expired token **Expected Result:** + - Verification page displays with error icon - Error message: "Verification link has expired. Please request a new one." - "Resend Verification Email" button is shown with email pre-filled @@ -339,11 +368,13 @@ res.render("resend-verification", { ### Test Case 9: Already Verified Account ✅ **Steps:** + 1. Verify an account 2. Generate a new verification link (simulate getting old link) 3. Click the verification link **Expected Result:** + - Success message: "Your email is already verified. You can log in now." - No duplicate verification attempted - Login link is shown @@ -389,18 +420,19 @@ If issues arise, simply revert to previous code. The changes are minimal and don ## 📊 Files Modified -| File | Changes | Lines | -|------|---------|-------| -| `controller/auth.js` | Added `success: null` to 5 render calls; added missing variables to 5 render calls | +25 | -| `view/resend-verification.ejs` | Updated line 215 and 222 to use type-safe checks | +2 | -| `view/verify-email.ejs` | Updated line 177 and 189 to use type-safe checks | +2 | -| **Total** | | **+29 lines** | +| File | Changes | Lines | +| ------------------------------ | ---------------------------------------------------------------------------------- | ------------- | +| `controller/auth.js` | Added `success: null` to 5 render calls; added missing variables to 5 render calls | +25 | +| `view/resend-verification.ejs` | Updated line 215 and 222 to use type-safe checks | +2 | +| `view/verify-email.ejs` | Updated line 177 and 189 to use type-safe checks | +2 | +| **Total** | | **+29 lines** | --- ## ✨ Impact Assessment ### Positive Impact + - ✅ Fixes "success is not defined" error - ✅ Users can now resend verification emails - ✅ Users can complete registration flow @@ -409,9 +441,11 @@ If issues arise, simply revert to previous code. The changes are minimal and don - ✅ Template variables always defined ### Negative Impact + - ❌ None identified ### Risk Assessment + - **Risk Level:** Very Low - **Type:** Bug Fix - **Breaking Changes:** None @@ -435,7 +469,7 @@ If issues arise, simply revert to previous code. The changes are minimal and don **Fix Completed:** May 31, 2026 **Status:** Production Ready **Testing:** Complete -**Documentation:** Complete +**Documentation:** Complete All required fixes have been implemented and tested. The application is ready for production deployment. diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index f01cccc8..cbd5ead8 100644 --- a/IMPLEMENTATION_SUMMARY.md +++ b/IMPLEMENTATION_SUMMARY.md @@ -9,7 +9,9 @@ This document summarizes all changes made to implement email verification during ## 📋 Implementation Overview ### Objective + Implement a complete email verification workflow during user registration that: + - Requires users to verify their email before accessing the platform - Uses secure cryptographic tokens with 24-hour expiry - Prevents login for unverified accounts @@ -25,7 +27,9 @@ Implement a complete email verification workflow during user registration that: ### 1. **Database Layer** #### `model/user.js` - Added verification fields + **Changes:** + - Added `isVerified` (Boolean, default: false) - Added `verificationToken` (String, sparse unique index) - Added `verificationTokenExpiry` (Date, indexed) @@ -46,7 +50,9 @@ verificationTokenExpiry: { type: Date, index: true }, ### 2. **Email Service** #### `utils/email.js` - Added verification email function + **Changes:** + - Added `sendVerificationEmail()` function - Creates professional HTML email template - Includes verification link with token @@ -63,7 +69,9 @@ async function sendVerificationEmail({ to, verificationLink, userName }) ### 3. **Authentication Controller** #### `controller/auth.js` - Implemented verification logic + **Changes:** + - Added helper constants for token generation and expiry - Modified `signup()` to create unverified accounts - Added `verifyEmail()` endpoint handler @@ -74,6 +82,7 @@ async function sendVerificationEmail({ to, verificationLink, userName }) **Lines changed:** ~200 lines added/modified **New functions:** + ```javascript function generateVerificationToken() function getVerificationTokenExpiry() @@ -85,6 +94,7 @@ const resendVerificationEmail = asyncHandler(async (req, res, next) => { ... }) ``` **Key logic:** + - Generates 32-byte cryptographic token - Sets expiry to 24 hours - Sends email on signup @@ -97,7 +107,9 @@ const resendVerificationEmail = asyncHandler(async (req, res, next) => { ... }) ### 4. **Routes** #### `routes/auth.js` - Added verification routes + **Changes:** + - Imported new controller functions - Added GET /verify-email route (displays form) - Added POST /verify-email route (validates token) @@ -118,7 +130,9 @@ router.post("/resend-verification", resendVerificationEmail) ### 5. **Auth Middleware** #### `middleware/auth.js` - Added verification check + **Changes:** + - Modified `protect` middleware to check `isVerified` - Fetches user from database - Redirects unverified users to resend page @@ -129,7 +143,7 @@ router.post("/resend-verification", resendVerificationEmail) ```javascript // New logic in protect middleware: if (!user.isVerified) { - return res.status(403).redirect("/resend-verification"); + return res.status(403).redirect("/resend-verification"); } ``` @@ -138,7 +152,9 @@ if (!user.isVerified) { ### 6. **Frontend - Sign Up** #### `view/signup.ejs` - Added success message + **Changes:** + - Added `.auth-success` CSS class - Added success message display - Shows links to resend or login when signup succeeds @@ -148,11 +164,11 @@ if (!user.isVerified) { ```html <% if (typeof success !== 'undefined' && success) { %> - + <% } %> ``` @@ -161,7 +177,9 @@ if (!user.isVerified) { ### 7. **Frontend - Login** #### `view/login.ejs` - Added unverified user message + **Changes:** + - Added `.auth-unverified` CSS class for warning style - Added conditional display for unverified users - Shows resend verification link with email @@ -171,11 +189,11 @@ if (!user.isVerified) { ```html <% if (typeof unverifiedEmail !== 'undefined' && unverifiedEmail) { %> - + <% } %> ``` @@ -186,9 +204,11 @@ if (!user.isVerified) { ### 1. **Verification Confirmation Page** #### `view/verify-email.ejs` + **Purpose:** Display verification result (success/error) **Features:** + - Success page with checkmark - Error page with error message - Expired token option to resend @@ -202,9 +222,11 @@ if (!user.isVerified) { ### 2. **Resend Verification Page** #### `view/resend-verification.ejs` + **Purpose:** Allow users to request a new verification email **Features:** + - Email input form - Success/error message display - Privacy-friendly (no user enumeration) @@ -218,9 +240,11 @@ if (!user.isVerified) { ### 3. **Test Suite** #### `tests/email-verification.test.js` + **Purpose:** Comprehensive test coverage and manual testing guide **Content:** + - 12 test scenarios with descriptions - Manual testing checklist - Jest integration test examples @@ -241,9 +265,11 @@ if (!user.isVerified) { ### 4. **Documentation** #### `docs/EMAIL_VERIFICATION.md` + **Purpose:** Complete implementation guide **Sections:** + - Architecture overview - Database schema - API endpoint documentation @@ -265,17 +291,20 @@ if (!user.isVerified) { ### Verification Token Security ✅ **Cryptographically Secure** + - Uses `crypto.randomBytes(32)` - 256 bits of entropy - Hex-encoded to 64 characters - Cannot be predicted or brute-forced - Unique per user ✅ **Time-Limited** + - 24-hour expiry window - Server-side expiry validation - Expired tokens cannot be used ✅ **One-Time Use** + - Token deleted after successful verification - Cannot be reused - Unique constraint in database @@ -283,6 +312,7 @@ if (!user.isVerified) { ### Email Privacy ✅ **No User Enumeration** + - Resend endpoint returns success for unknown emails - Generic message prevents email address discovery - Prevents attacker reconnaissance @@ -290,6 +320,7 @@ if (!user.isVerified) { ### Login Protection ✅ **Verification Enforced** + - Middleware checks `isVerified` status - Cannot bypass with valid credentials - Redirects to resend page if unverified @@ -297,6 +328,7 @@ if (!user.isVerified) { ### OAuth Auto-Verification ✅ **Trusted OAuth Providers** + - Google OAuth users auto-verified - OAuth provider already verified email - No token delay for OAuth users @@ -309,29 +341,29 @@ if (!user.isVerified) { ```javascript // verificationToken - unique sparse index -db.users.createIndex({ verificationToken: 1 }, { unique: true, sparse: true }) +db.users.createIndex({ verificationToken: 1 }, { unique: true, sparse: true }); // verificationTokenExpiry - for cleanup queries -db.users.createIndex({ verificationTokenExpiry: 1 }) +db.users.createIndex({ verificationTokenExpiry: 1 }); ``` ### Query Patterns ```javascript // Find user by token -User.findOne({ verificationToken: token }) +User.findOne({ verificationToken: token }); // Find expired tokens (for cleanup) -User.find({ verificationTokenExpiry: { $lt: new Date() } }) +User.find({ verificationTokenExpiry: { $lt: new Date() } }); // Mark user verified User.updateOne( - { _id: userId }, - { - $set: { isVerified: true }, - $unset: { verificationToken: "", verificationTokenExpiry: "" } - } -) + { _id: userId }, + { + $set: { isVerified: true }, + $unset: { verificationToken: "", verificationTokenExpiry: "" }, + }, +); ``` --- @@ -339,6 +371,7 @@ User.updateOne( ## 🔗 API Flow ### Registration Flow + ``` POST /signup ├─ Validate input @@ -351,6 +384,7 @@ POST /signup ``` ### Email Verification Flow + ``` POST /verify-email?token=TOKEN ├─ Find user by token @@ -361,6 +395,7 @@ POST /verify-email?token=TOKEN ``` ### Login Flow + ``` POST /login ├─ Find user by email @@ -374,6 +409,7 @@ POST /login ``` ### Resend Flow + ``` POST /resend-verification ├─ Find user by email @@ -390,27 +426,32 @@ POST /resend-verification ## ✨ Features Implemented ### ✅ User Registration + - [x] Create unverified accounts - [x] Generate secure tokens - [x] Send verification emails ### ✅ Email Verification + - [x] Validate tokens - [x] Check token expiry - [x] Mark users as verified - [x] Clear tokens after use ### ✅ Login Protection + - [x] Check verification status - [x] Prevent unverified login - [x] Show helpful error message ### ✅ Resend Verification + - [x] Generate new tokens - [x] Send new emails - [x] Maintain privacy (no enumeration) ### ✅ Views & UX + - [x] Verification success page - [x] Resend verification form - [x] Updated signup page @@ -419,6 +460,7 @@ POST /resend-verification - [x] Responsive design ### ✅ Security + - [x] Cryptographic token generation - [x] Token expiry validation - [x] One-time token use @@ -426,6 +468,7 @@ POST /resend-verification - [x] Protected middleware check ### ✅ Email Delivery + - [x] Professional template - [x] HTML + plain text - [x] Personalized greeting @@ -433,11 +476,13 @@ POST /resend-verification - [x] Expiry information ### ✅ Google OAuth + - [x] Auto-verification for OAuth users - [x] No verification token for OAuth - [x] Immediate dashboard access ### ✅ Error Handling + - [x] Invalid token errors - [x] Expired token errors - [x] Already verified handling @@ -445,6 +490,7 @@ POST /resend-verification - [x] User-friendly messages ### ✅ Testing & Documentation + - [x] Comprehensive test suite - [x] Manual testing checklist - [x] Full documentation @@ -458,6 +504,7 @@ POST /resend-verification Before deploying to production: ### Configuration + - [ ] Set `APP_URL` environment variable - [ ] Configure email service credentials - [ ] Set `EMAIL_FROM` and `EMAIL_FROM_NAME` @@ -465,17 +512,20 @@ Before deploying to production: - [ ] Test email delivery ### Database + - [ ] Run migration for new fields - [ ] Create indexes on verification fields - [ ] Test database queries ### Security + - [ ] Verify HTTPS is enabled - [ ] Check email credentials not in logs - [ ] Review error messages for leaks - [ ] Test rate limiting (if implemented) ### Testing + - [ ] Run full test suite - [ ] Manual verification flow test - [ ] Test with real email address @@ -483,6 +533,7 @@ Before deploying to production: - [ ] Test mobile responsiveness ### Monitoring + - [ ] Set up error logging - [ ] Monitor email delivery - [ ] Track verification rates @@ -493,6 +544,7 @@ Before deploying to production: ## 📈 Metrics to Track ### User Metrics + - Registration rate - Email verification rate - Verification time (average) @@ -500,6 +552,7 @@ Before deploying to production: - Resend request frequency ### System Metrics + - Email send success rate - Email delivery time - Database query performance @@ -517,9 +570,9 @@ If migrating from a system without verification: ```javascript // Mark all existing users as verified (they already have access) db.users.updateMany( - { isVerified: { $exists: false } }, - { $set: { isVerified: true, verificationToken: null } } -) + { isVerified: { $exists: false } }, + { $set: { isVerified: true, verificationToken: null } }, +); // This way existing users are not locked out ``` @@ -529,17 +582,20 @@ db.users.updateMany( ## 📞 Support Resources ### Documentation + - [EMAIL_VERIFICATION.md](./EMAIL_VERIFICATION.md) - Full technical guide - [tests/email-verification.test.js](../tests/email-verification.test.js) - Test examples - [CONTRIBUTING.md](./CONTRIBUTING.md) - Development guidelines ### Files Changed + - Core: `model/user.js`, `controller/auth.js`, `utils/email.js` - Routes: `routes/auth.js` - Middleware: `middleware/auth.js` - Views: `view/signup.ejs`, `view/login.ejs`, `view/verify-email.ejs`, `view/resend-verification.ejs` ### Environment Setup + ```bash # Required in .env EMAIL_USER=your-email@gmail.com @@ -577,17 +633,20 @@ APP_URL=https:// ## 📋 Code Quality ### Testing Coverage + - ✅ 12 test scenarios documented - ✅ Manual testing checklist - ✅ Jest examples provided ### Documentation + - ✅ 600+ line guide - ✅ API endpoint documentation - ✅ Architecture diagrams - ✅ Troubleshooting section ### Code Standards + - ✅ Follows project conventions - ✅ Uses existing patterns - ✅ Consistent error handling diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index c4623bc9..ed26cc22 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -113,10 +113,10 @@ This PR implements a complete email verification system for user registration in ```javascript // Cryptographically secure, 256-bit entropy -crypto.randomBytes(32).toString("hex") // 64 hex characters +crypto.randomBytes(32).toString("hex"); // 64 hex characters // 24-hour expiry -new Date(Date.now() + 24 * 60 * 60 * 1000) +new Date(Date.now() + 24 * 60 * 60 * 1000); ``` ### User Registration Flow @@ -160,31 +160,37 @@ POST /login ## Security Features ✅ **Cryptographic Token Generation** + - 256-bit entropy using `crypto.randomBytes(32)` - Unique per user - Cannot be predicted or brute-forced ✅ **Time-Limited Tokens** + - 24-hour expiry window - Server-side expiry validation - Expired tokens cannot be used ✅ **One-Time Use** + - Token deleted after successful verification - Cannot be reused - Unique constraint prevents duplicates ✅ **Email Privacy** + - Resend endpoint doesn't reveal user existence - No user enumeration possible - Generic success messages ✅ **Protected Routes** + - Middleware checks `isVerified` status - Unverified users redirected - Cannot bypass with valid JWT ✅ **Secure Error Handling** + - No stack traces exposed - Generic error messages - Proper HTTP status codes @@ -197,13 +203,10 @@ POST /login ```javascript // Verify unique token for fast lookup -db.users.createIndex( - { verificationToken: 1 }, - { unique: true, sparse: true } -) +db.users.createIndex({ verificationToken: 1 }, { unique: true, sparse: true }); // Find expired tokens for cleanup -db.users.createIndex({ verificationTokenExpiry: 1 }) +db.users.createIndex({ verificationTokenExpiry: 1 }); ``` ### Migration for Existing Users @@ -212,8 +215,8 @@ db.users.createIndex({ verificationTokenExpiry: 1 }) // Mark existing users as verified (they already have access) db.users.updateMany( { isVerified: { $exists: false } }, - { $set: { isVerified: true, verificationToken: null } } -) + { $set: { isVerified: true, verificationToken: null } }, +); ``` --- @@ -255,6 +258,7 @@ EMAIL_FROM_NAME=CreatorOS Test file: `tests/email-verification.test.js` **Coverage:** + - User registration creates unverified account - Verification token is cryptographically secure - Verification email is sent after signup @@ -271,6 +275,7 @@ Test file: `tests/email-verification.test.js` ### Manual Testing Checklist See `tests/email-verification.test.js` for complete checklist including: + - Registration & email sending - Email verification - Login restrictions @@ -284,6 +289,7 @@ See `tests/email-verification.test.js` for complete checklist including: ## Breaking Changes **None.** This is a backward-compatible addition: + - Existing unverified users marked as verified on deploy - Google OAuth users auto-verified - Existing login flow preserved @@ -327,11 +333,13 @@ Before deploying: ## Performance Considerations ### Database Queries + - `User.findOne({ verificationToken })` - O(1) with index - `User.findOne({ email })` - O(1) with existing index - Token verification happens in ~2ms ### Email Sending + - Asynchronous (doesn't block signup) - Failures logged but don't prevent registration - Can be moved to queue service for scale @@ -339,6 +347,7 @@ Before deploying: ### Recommended Indexes Already created in migration: + - `verificationToken` (unique, sparse) - `verificationTokenExpiry` (for cleanup) @@ -380,6 +389,7 @@ System Metrics: ## Future Enhancements Possible improvements: + 1. Rate limiting on resend (3 requests/hour) 2. Verification reminders (12h, 24h, 48h) 3. SMS verification option diff --git a/QUICK_DEPLOYMENT_GUIDE.md b/QUICK_DEPLOYMENT_GUIDE.md index 2049b236..31794a85 100644 --- a/QUICK_DEPLOYMENT_GUIDE.md +++ b/QUICK_DEPLOYMENT_GUIDE.md @@ -21,11 +21,13 @@ The email verification system had a critical bug preventing users from accessing ## 📋 What Was Fixed ### Problem Scenario + ``` User clicks "Resend Verification" → Page throws error: "success is not defined" ``` ### Root Cause + ```javascript // Controller didn't pass 'success' variable res.render("resend-verification", { error: "..." }); // ❌ success is undefined @@ -35,9 +37,10 @@ res.render("resend-verification", { error: "..." }); // ❌ success is undefine ``` ### The Fix + ```javascript // Controller now passes all required variables -res.render("resend-verification", { +res.render("resend-verification", { error: "...", success: null // ✅ Now defined }); @@ -53,7 +56,7 @@ res.render("resend-verification", { ### Files Modified: 3 1. **controller/auth.js** - - Fixed 5 render calls in `resendVerificationEmail()` + - Fixed 5 render calls in `resendVerificationEmail()` - Fixed 5 render calls in `verifyEmail()` - Added missing variables: `success`, `error`, `expiredToken`, `userEmail` @@ -81,6 +84,7 @@ Formatting/cleanup: +5 lines All components tested and working: ### Resend Verification Flow + - [x] Initial page load (GET /resend-verification) - [x] Submit with valid email (POST /resend-verification) - [x] Submit with unregistered email (privacy-preserving response) @@ -91,6 +95,7 @@ All components tested and working: - [x] Error messages display correctly ### Email Verification Flow + - [x] Valid token verification - [x] Invalid token handling - [x] Expired token handling @@ -100,6 +105,7 @@ All components tested and working: - [x] Resend option for expired tokens ### Template Variable Safety + - [x] All variables type-checked before use - [x] No ReferenceError exceptions - [x] No undefined variable errors @@ -110,6 +116,7 @@ All components tested and working: ## 🚀 Deployment Readiness ### Pre-Deployment + - [x] Code reviewed for consistency - [x] All error paths tested - [x] All success paths tested @@ -119,11 +126,13 @@ All components tested and working: - [x] No configuration changes needed ### Deployment Steps + 1. ✅ Code is ready to merge 2. ✅ Run on staging first (recommended) 3. ✅ Deploy to production ### Rollback Plan + - Simple: Revert the 3 files if any issues arise - Risk: Minimal - only added variables and type checks @@ -132,36 +141,40 @@ All components tested and working: ## 📊 Impact Analysis ### What Changed -| Aspect | Status | -|--------|--------| -| User authentication flow | ✅ Unchanged | -| Database schema | ✅ No changes | -| Email sending logic | ✅ Unchanged | -| API endpoints | ✅ No changes | -| Error handling | ✅ Improved | -| User experience | ✅ Fixed | + +| Aspect | Status | +| ------------------------ | ------------- | +| User authentication flow | ✅ Unchanged | +| Database schema | ✅ No changes | +| Email sending logic | ✅ Unchanged | +| API endpoints | ✅ No changes | +| Error handling | ✅ Improved | +| User experience | ✅ Fixed | ### Performance Impact -| Metric | Change | -|--------|--------| -| Page load time | ✅ No impact | + +| Metric | Change | +| -------------------- | ------------ | +| Page load time | ✅ No impact | | Server response time | ✅ No impact | -| Database queries | ✅ No change | -| Memory usage | ✅ No impact | +| Database queries | ✅ No change | +| Memory usage | ✅ No impact | ### Security Impact -| Aspect | Status | -|--------|--------| -| Token security | ✅ No change | -| Email privacy | ✅ Maintained | -| User enumeration | ✅ Prevented | -| Password handling | ✅ No change | + +| Aspect | Status | +| ----------------- | ------------- | +| Token security | ✅ No change | +| Email privacy | ✅ Maintained | +| User enumeration | ✅ Prevented | +| Password handling | ✅ No change | --- ## 🧪 Test Results ### Functional Tests + ``` ✅ GET /resend-verification - Page loads, no errors ✅ POST /resend-verification - Form submits, email sends @@ -175,6 +188,7 @@ All components tested and working: ``` ### Template Variable Tests + ``` ✅ Variables always defined ✅ Type checks prevent errors @@ -212,12 +226,14 @@ Three comprehensive documents created: ## 🔄 Comparison: Before vs After ### Before Fix + ``` User Flow: Sign up → Verify email → Click resend → ❌ ERROR: "success is not defined" ``` ### After Fix + ``` User Flow: Sign up → Verify email → Click resend → ✅ Form displays @@ -254,16 +270,19 @@ Click link → ✅ Verified → ✅ Login works ## 📦 Deliverables ### Files Modified + - [x] controller/auth.js - [x] view/resend-verification.ejs - [x] view/verify-email.ejs ### Documentation Created + - [x] EMAIL_VERIFICATION_BUG_FIX.md (detailed guide) - [x] BUG_FIX_PATCH.md (diff format) - [x] QUICK_DEPLOYMENT_GUIDE.md (this file) ### Testing Completed + - [x] Unit tests (all paths) - [x] Integration tests (full flow) - [x] Error handling tests @@ -275,12 +294,14 @@ Click link → ✅ Verified → ✅ Login works ## 🎓 Lessons Learned 1. **Template Best Practice**: Always use type-safe checks in EJS + ```ejs ✅ <% if (typeof variable !== 'undefined' && variable) { %> ❌ <% if (variable) { %> ``` 2. **Controller Consistency**: Always pass all expected variables in render calls + ```javascript ✅ res.render("view", { error: null, success: null, ... }) ❌ res.render("view", { error: "..." }) @@ -293,16 +314,19 @@ Click link → ✅ Verified → ✅ Login works ## ✨ Next Steps ### Immediate + 1. Code review (should be quick - 29 lines) 2. Deploy to staging 3. Run smoke tests ### Short-term + 1. Deploy to production 2. Monitor email delivery rates 3. Monitor verification completion rates ### Optional Enhancements + 1. Add rate limiting to resend endpoint (3 requests/hour) 2. Add verification reminders (12h, 24h) 3. Add SMS verification alternative @@ -337,12 +361,14 @@ User Experience: ## 📞 Support & Rollback ### If Issues Arise + 1. Check error logs for specific issues 2. Review test cases above 3. Verify email configuration 4. Run database integrity check ### Quick Rollback + ```bash # Revert the 3 modified files git checkout controller/auth.js @@ -368,6 +394,7 @@ git push - [x] Ready for immediate deployment **Timeline:** + - Bug Fix: May 31, 2026 - Complete - Testing: May 31, 2026 - Complete - Documentation: May 31, 2026 - Complete diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index c96838c9..e731c2c9 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -80,16 +80,16 @@ All requirements implemented and tested. Ready for production deployment. ## 🔐 Security Features -| Feature | Implementation | Status | -|---------|----------------|--------| -| Token Generation | `crypto.randomBytes(32)` (256-bit entropy) | ✅ | -| Token Format | Hex-encoded 64 characters | ✅ | -| Token Uniqueness | Unique sparse index | ✅ | -| Token Expiry | 24 hours, server-side validation | ✅ | -| One-Time Use | Token cleared after verification | ✅ | -| Email Privacy | No user enumeration in resend | ✅ | -| Protected Routes | Middleware checks verification | ✅ | -| Error Handling | No sensitive data leakage | ✅ | +| Feature | Implementation | Status | +| ---------------- | ------------------------------------------ | ------ | +| Token Generation | `crypto.randomBytes(32)` (256-bit entropy) | ✅ | +| Token Format | Hex-encoded 64 characters | ✅ | +| Token Uniqueness | Unique sparse index | ✅ | +| Token Expiry | 24 hours, server-side validation | ✅ | +| One-Time Use | Token cleared after verification | ✅ | +| Email Privacy | No user enumeration in resend | ✅ | +| Protected Routes | Middleware checks verification | ✅ | +| Error Handling | No sensitive data leakage | ✅ | --- @@ -121,8 +121,8 @@ User.schema { // Mark existing users as verified db.users.updateMany( { isVerified: { $exists: false } }, - { $set: { isVerified: true, verificationToken: null } } -) + { $set: { isVerified: true, verificationToken: null } }, +); ``` --- @@ -131,19 +131,20 @@ db.users.updateMany( ### Endpoints -| Method | Route | Purpose | Status Code | -|--------|-------|---------|------------| -| GET | `/signup` | Show signup form | 200 | -| POST | `/signup` | Register user | 201/409/400 | -| GET | `/verify-email` | Show verification form | 200 | -| POST | `/verify-email?token=X` | Verify token | 200/400/410 | -| GET | `/resend-verification` | Show resend form | 200 | -| POST | `/resend-verification` | Resend email | 200/400/500 | -| POST | `/login` | Login user | 200/403/401 | +| Method | Route | Purpose | Status Code | +| ------ | ----------------------- | ---------------------- | ----------- | +| GET | `/signup` | Show signup form | 200 | +| POST | `/signup` | Register user | 201/409/400 | +| GET | `/verify-email` | Show verification form | 200 | +| POST | `/verify-email?token=X` | Verify token | 200/400/410 | +| GET | `/resend-verification` | Show resend form | 200 | +| POST | `/resend-verification` | Resend email | 200/400/500 | +| POST | `/login` | Login user | 200/403/401 | ### Response Examples **Signup Success:** + ```json { "success": true, @@ -153,6 +154,7 @@ db.users.updateMany( ``` **Verification Success:** + ```json { "success": true, @@ -161,6 +163,7 @@ db.users.updateMany( ``` **Unverified Login:** + ```json { "success": false, @@ -186,6 +189,7 @@ APP_URL=https:// ### Deployment Steps 1. **Configure Email Service** + ```bash # Gmail example: EMAIL_USER=creatorOS@gmail.com @@ -193,13 +197,20 @@ APP_URL=https:// ``` 2. **Database Migration** + ```javascript // Add indexes - db.users.createIndex({ verificationToken: 1 }, { unique: true, sparse: true }) - db.users.createIndex({ verificationTokenExpiry: 1 }) - + db.users.createIndex( + { verificationToken: 1 }, + { unique: true, sparse: true }, + ); + db.users.createIndex({ verificationTokenExpiry: 1 }); + // Mark existing users as verified - db.users.updateMany({}, { $set: { isVerified: true, verificationToken: null } }) + db.users.updateMany( + {}, + { $set: { isVerified: true, verificationToken: null } }, + ); ``` 3. **Test Configuration** @@ -218,18 +229,21 @@ APP_URL=https:// ## 📈 Key Metrics ### User Metrics + - Signup rate - Email verification rate - Resend request frequency - Average verification time ### System Metrics + - Email delivery rate - Database query performance - API response time - Error rate ### Quality Metrics + - Documentation completeness: 100% - Test coverage: 12 scenarios - Code review: Ready @@ -244,6 +258,7 @@ APP_URL=https:// Located: `tests/email-verification.test.js` Test scenarios: + 1. User registration creates unverified account 2. Verification token is cryptographically secure 3. Verification email is sent after signup @@ -293,36 +308,39 @@ See `tests/email-verification.test.js` for complete checklist. ## ✨ Features Summary -| Feature | Implemented | Tested | Documented | -|---------|------------|--------|------------| -| User registration | ✅ | ✅ | ✅ | -| Unverified accounts | ✅ | ✅ | ✅ | -| Token generation | ✅ | ✅ | ✅ | -| Email sending | ✅ | ✅ | ✅ | -| Email verification | ✅ | ✅ | ✅ | -| Login restrictions | ✅ | ✅ | ✅ | -| Resend verification | ✅ | ✅ | ✅ | -| Error handling | ✅ | ✅ | ✅ | -| Google OAuth | ✅ | ✅ | ✅ | -| Security | ✅ | ✅ | ✅ | +| Feature | Implemented | Tested | Documented | +| ------------------- | ----------- | ------ | ---------- | +| User registration | ✅ | ✅ | ✅ | +| Unverified accounts | ✅ | ✅ | ✅ | +| Token generation | ✅ | ✅ | ✅ | +| Email sending | ✅ | ✅ | ✅ | +| Email verification | ✅ | ✅ | ✅ | +| Login restrictions | ✅ | ✅ | ✅ | +| Resend verification | ✅ | ✅ | ✅ | +| Error handling | ✅ | ✅ | ✅ | +| Google OAuth | ✅ | ✅ | ✅ | +| Security | ✅ | ✅ | ✅ | --- ## 🎯 Next Steps ### Immediate + 1. Code review 2. Staging deployment 3. Integration testing 4. Email delivery verification ### Short-term + 1. Production deployment 2. Monitor verification metrics 3. Track user feedback 4. Watch for support issues ### Future Enhancements + 1. Rate limiting on resend 2. Verification reminders 3. SMS alternative @@ -333,13 +351,16 @@ See `tests/email-verification.test.js` for complete checklist. ## 📞 Support ### Documentation Links + - **Full Guide:** [docs/EMAIL_VERIFICATION.md](docs/EMAIL_VERIFICATION.md) - **Implementation:** [IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md) - **Tests:** [tests/email-verification.test.js](tests/email-verification.test.js) - **PR Template:** [PULL_REQUEST_TEMPLATE.md](PULL_REQUEST_TEMPLATE.md) ### Contact + For questions or issues: + 1. Check documentation first 2. Review test examples 3. Check server logs for errors diff --git a/README.md b/README.md index eddf198b..6716f899 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,14 @@ Every creator knows this chaos: -| Tool | What You Use It For | -|------|---------------------| -| Linktree / Beacons | Bio links | -| ManyChat | DM automation | -| Notion / Notes | Content ideas | -| Sheets / Notion | Brand deal tracking | -| Later / Buffer | Scheduling | -| Native dashboards | Analytics | +| Tool | What You Use It For | +| ------------------ | ------------------- | +| Linktree / Beacons | Bio links | +| ManyChat | DM automation | +| Notion / Notes | Content ideas | +| Sheets / Notion | Brand deal tracking | +| Later / Buffer | Scheduling | +| Native dashboards | Analytics | **That's 6+ apps, 6+ subscriptions, 6+ logins — just to run your creator business.** @@ -79,7 +79,9 @@ CreatorOS ends this. One platform. Everything connected. Zero context-switching. ### 🔗 Smart Bio System + Your link-in-bio, evolved. + - Advanced, fully branded bio pages - Product showcase with buy buttons - Per-link redirect tracking @@ -90,7 +92,9 @@ Your link-in-bio, evolved. ### 🤖 DM Automation + Turn every comment & DM into a conversion. + - Keyword-triggered auto-replies - Instant free resource delivery - Funnel followers → products & newsletters @@ -103,7 +107,9 @@ Turn every comment & DM into a conversion. ### 🤝 Creator CRM + Your brand deals, professionally managed. + - Full collaboration pipeline - Payment tracking & invoicing - Sponsor contact database @@ -114,7 +120,9 @@ Your brand deals, professionally managed. ### 📊 Analytics Dashboard + Know exactly what's working. + - Instagram engagement insights - Link click heatmaps - Follower growth curves @@ -127,7 +135,9 @@ Know exactly what's working. ### 🧠 Content OS + Never stare at a blank screen again. + - Idea bank with tagging & search - Script & caption storage - Visual post scheduler @@ -144,15 +154,15 @@ Never stare at a blank screen again.
-| Layer | Technology | -|-------|-----------| -| **Frontend** | ![Next.js](https://img.shields.io/badge/Next.js-000?logo=nextdotjs&logoColor=white&style=flat-square) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black&style=flat-square) ![TailwindCSS](https://img.shields.io/badge/Tailwind-06B6D4?logo=tailwindcss&logoColor=white&style=flat-square) | -| **Backend** | ![Node.js](https://img.shields.io/badge/Node.js-339933?logo=nodedotjs&logoColor=white&style=flat-square) ![Express](https://img.shields.io/badge/Express-000?logo=express&logoColor=white&style=flat-square) | -| **Database** | ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?logo=postgresql&logoColor=white&style=flat-square) ![MongoDB](https://img.shields.io/badge/MongoDB-47A248?logo=mongodb&logoColor=white&style=flat-square) | -| **Auth** | ![Clerk](https://img.shields.io/badge/Clerk-6C47FF?logo=clerk&logoColor=white&style=flat-square) ![Firebase](https://img.shields.io/badge/Firebase-FFCA28?logo=firebase&logoColor=black&style=flat-square) | -| **Automation** | ![Instagram](https://img.shields.io/badge/Instagram%20Graph%20API-E4405F?logo=instagram&logoColor=white&style=flat-square) | -| **AI** | ![OpenAI](https://img.shields.io/badge/OpenAI-412991?logo=openai&logoColor=white&style=flat-square) ![OpenRouter](https://img.shields.io/badge/OpenRouter-FF6B35?style=flat-square) | -| **Hosting** | ![Vercel](https://img.shields.io/badge/Vercel-000?logo=vercel&logoColor=white&style=flat-square) ![Railway](https://img.shields.io/badge/Railway-0B0D0E?logo=railway&logoColor=white&style=flat-square) ![Supabase](https://img.shields.io/badge/Supabase-3FCF8E?logo=supabase&logoColor=white&style=flat-square) | +| Layer | Technology | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Frontend** | ![Next.js](https://img.shields.io/badge/Next.js-000?logo=nextdotjs&logoColor=white&style=flat-square) ![React](https://img.shields.io/badge/React-61DAFB?logo=react&logoColor=black&style=flat-square) ![TailwindCSS](https://img.shields.io/badge/Tailwind-06B6D4?logo=tailwindcss&logoColor=white&style=flat-square) | +| **Backend** | ![Node.js](https://img.shields.io/badge/Node.js-339933?logo=nodedotjs&logoColor=white&style=flat-square) ![Express](https://img.shields.io/badge/Express-000?logo=express&logoColor=white&style=flat-square) | +| **Database** | ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?logo=postgresql&logoColor=white&style=flat-square) ![MongoDB](https://img.shields.io/badge/MongoDB-47A248?logo=mongodb&logoColor=white&style=flat-square) | +| **Auth** | ![Clerk](https://img.shields.io/badge/Clerk-6C47FF?logo=clerk&logoColor=white&style=flat-square) ![Firebase](https://img.shields.io/badge/Firebase-FFCA28?logo=firebase&logoColor=black&style=flat-square) | +| **Automation** | ![Instagram](https://img.shields.io/badge/Instagram%20Graph%20API-E4405F?logo=instagram&logoColor=white&style=flat-square) | +| **AI** | ![OpenAI](https://img.shields.io/badge/OpenAI-412991?logo=openai&logoColor=white&style=flat-square) ![OpenRouter](https://img.shields.io/badge/OpenRouter-FF6B35?style=flat-square) | +| **Hosting** | ![Vercel](https://img.shields.io/badge/Vercel-000?logo=vercel&logoColor=white&style=flat-square) ![Railway](https://img.shields.io/badge/Railway-0B0D0E?logo=railway&logoColor=white&style=flat-square) ![Supabase](https://img.shields.io/badge/Supabase-3FCF8E?logo=supabase&logoColor=white&style=flat-square) |
@@ -352,7 +362,6 @@ http://localhost:3000 > **Note**: In production, ensure your local development URL (`http://localhost:3000`) is replaced with your production domain (e.g., `https://your-domain.vercel.app`) in your environment configurations and OAuth settings. - ## 📁 Project Structure ``` @@ -379,9 +388,9 @@ CreatorOS/
-| 📸 Instagram Influencers | 🎬 YouTubers | 🎓 Coaches & Solopreneurs | -|:---:|:---:|:---:| -| 📦 Digital Product Sellers | ✍️ Indie Creators | 🏷️ Brand Deal Hunters | +| 📸 Instagram Influencers | 🎬 YouTubers | 🎓 Coaches & Solopreneurs | +| :------------------------: | :---------------: | :-----------------------: | +| 📦 Digital Product Sellers | ✍️ Indie Creators | 🏷️ Brand Deal Hunters |
@@ -498,7 +507,7 @@ Every contribution, no matter how small, helps us build something amazing for cr
-*If CreatorOS saves you time, give it a ⭐ — it means the world.* +_If CreatorOS saves you time, give it a ⭐ — it means the world._ @@ -507,12 +516,14 @@ Every contribution, no matter how small, helps us build something amazing for cr ## ✨ README Improvement Notes ### 📌 Formatting Enhancements Needed + - Improve heading hierarchy for better readability - Ensure consistent spacing between sections - Use proper Markdown formatting for code blocks and lists - Align all installation and usage steps properly ### 🚀 Suggested Structure Upgrade + - Introduction - Features - Tech Stack @@ -523,10 +534,11 @@ Every contribution, no matter how small, helps us build something amazing for cr - License ### 🛠️ Documentation Improvements + - Add badges (optional): build, license, contributors - Add screenshots for better UI understanding - Standardize code blocks for commands ### 🎯 Goal -Improve onboarding experience for new contributors and users by making README more structured, readable, and professional +Improve onboarding experience for new contributors and users by making README more structured, readable, and professional diff --git a/connect.js b/connect.js index 472aee1b..3f70d4a3 100644 --- a/connect.js +++ b/connect.js @@ -3,74 +3,100 @@ const mongoose = require("mongoose"); let connectionPromise = null; const connectDB = async () => { - if (process.env.USE_MOCK_DB === "true") return; - if (mongoose.connection.readyState === 1) return; - if (connectionPromise) return connectionPromise; + if (process.env.USE_MOCK_DB === "true") return; + if (mongoose.connection.readyState === 1) return; + if (connectionPromise) return connectionPromise; - const uri = process.env.MONGODB_URI; - const isPlaceholder = !uri || uri.includes("") || uri.includes("") || uri.includes("7udof89w.mongodb.net"); + const uri = process.env.MONGODB_URI; + const isPlaceholder = + !uri || + uri.includes("") || + uri.includes("") || + uri.includes("7udof89w.mongodb.net"); - // Add safe fallback for JWT_SECRET if missing or using standard placeholder - if (!process.env.JWT_SECRET || process.env.JWT_SECRET.includes("openssl rand")) { - if (process.env.NODE_ENV === 'production') { - console.error("\n❌ FATAL SECURITY ERROR: JWT_SECRET is not configured in production!"); - console.error("❌ Do not deploy without setting a strong JWT_SECRET environment variable."); - process.exit(1); - } else { - const crypto = require('crypto'); - const fs = require('fs'); - const path = require('path'); - - const newSecret = crypto.randomBytes(32).toString('hex'); - process.env.JWT_SECRET = newSecret; - - const envPath = path.join(process.cwd(), '.env.local'); - try { - fs.appendFileSync(envPath, `\n# Auto-generated by CreatorOS for local development\nJWT_SECRET=${newSecret}\n`); - console.warn("\n✅ JWT_SECRET was missing. A secure random secret was automatically generated and saved to your .env.local file.\n"); - } catch (err) { - console.warn("\n⚠️ JWT_SECRET missing. Auto-generated a temporary random secret, but failed to save to .env.local.\n"); - } - } + // Add safe fallback for JWT_SECRET if missing or using standard placeholder + if ( + !process.env.JWT_SECRET || + process.env.JWT_SECRET.includes("openssl rand") + ) { + if (process.env.NODE_ENV === "production") { + console.error( + "\n❌ FATAL SECURITY ERROR: JWT_SECRET is not configured in production!", + ); + console.error( + "❌ Do not deploy without setting a strong JWT_SECRET environment variable.", + ); + process.exit(1); + } else { + const crypto = require("crypto"); + const fs = require("fs"); + const path = require("path"); + + const newSecret = crypto.randomBytes(32).toString("hex"); + process.env.JWT_SECRET = newSecret; + + const envPath = path.join(process.cwd(), ".env.local"); + try { + fs.appendFileSync( + envPath, + `\n# Auto-generated by CreatorOS for local development\nJWT_SECRET=${newSecret}\n`, + ); + console.warn( + "\n✅ JWT_SECRET was missing. A secure random secret was automatically generated and saved to your .env.local file.\n", + ); + } catch (err) { + console.warn( + "\n⚠️ JWT_SECRET missing. Auto-generated a temporary random secret, but failed to save to .env.local.\n", + ); + } } + } - if (isPlaceholder) { - if (process.env.NODE_ENV === 'production') { - console.error("\n❌ FATAL ERROR: MongoDB URI is missing or invalid in production."); - console.error("❌ Do not deploy without a valid MONGODB_URI environment variable."); - process.exit(1); - } - console.log("\n=================================================="); - console.log("⚠️ MongoDB URI not configured or using placeholder."); - console.log("👉 CreatorOS is starting in MOCK DATABASE mode."); - console.log("👉 Test user credentials: test@local.com / Password123!"); - console.log("==================================================\n"); - process.env.USE_MOCK_DB = "true"; - return; + if (isPlaceholder) { + if (process.env.NODE_ENV === "production") { + console.error( + "\n❌ FATAL ERROR: MongoDB URI is missing or invalid in production.", + ); + console.error( + "❌ Do not deploy without a valid MONGODB_URI environment variable.", + ); + process.exit(1); } + console.log("\n=================================================="); + console.log("⚠️ MongoDB URI not configured or using placeholder."); + console.log("👉 CreatorOS is starting in MOCK DATABASE mode."); + console.log("👉 Test user credentials: test@local.com / Password123!"); + console.log("==================================================\n"); + process.env.USE_MOCK_DB = "true"; + return; + } - try { - connectionPromise = mongoose.connect(uri, { - serverSelectionTimeoutMS: 5000 // Slightly longer timeout for production - }); - await connectionPromise; + try { + connectionPromise = mongoose.connect(uri, { + serverSelectionTimeoutMS: 5000, // Slightly longer timeout for production + }); + await connectionPromise; - console.log("MongoDB Connected Successfully"); - } catch (error) { - connectionPromise = null; - if (process.env.NODE_ENV === 'production') { - console.error("\n❌ FATAL ERROR: MongoDB Connection Failed in Production."); - console.error("Error specifics:", error.message); - console.error("👉 Ensure your MONGODB_URI is correct and the deployment server's IP is whitelisted in your MongoDB cluster (e.g., MongoDB Atlas Network Access)."); - process.exit(1); - } - console.log("\n=================================================="); - console.log("MongoDB Connection Error:", error.message); - console.log("👉 CreatorOS is falling back to MOCK DATABASE mode."); - console.log("👉 Test user credentials: test@local.com / Password123!"); - console.log("==================================================\n"); - process.env.USE_MOCK_DB = "true"; + console.log("MongoDB Connected Successfully"); + } catch (error) { + connectionPromise = null; + if (process.env.NODE_ENV === "production") { + console.error( + "\n❌ FATAL ERROR: MongoDB Connection Failed in Production.", + ); + console.error("Error specifics:", error.message); + console.error( + "👉 Ensure your MONGODB_URI is correct and the deployment server's IP is whitelisted in your MongoDB cluster (e.g., MongoDB Atlas Network Access).", + ); + process.exit(1); } + console.log("\n=================================================="); + console.log("MongoDB Connection Error:", error.message); + console.log("👉 CreatorOS is falling back to MOCK DATABASE mode."); + console.log("👉 Test user credentials: test@local.com / Password123!"); + console.log("==================================================\n"); + process.env.USE_MOCK_DB = "true"; + } }; module.exports = connectDB; diff --git a/controller/analytics.js b/controller/analytics.js index 1a8ab8d6..3e536383 100644 --- a/controller/analytics.js +++ b/controller/analytics.js @@ -5,67 +5,76 @@ const asyncHandler = require("../utils/asyncHandler"); // GET /api/analytics/:creatorId/snapshots const getSnapshots = asyncHandler(async (req, res) => { - const snapshots = await AnalyticsSnapshot.find({ - creatorId: req.params.creatorId, - }).sort({ createdAt: -1 }); + const snapshots = await AnalyticsSnapshot.find({ + creatorId: req.params.creatorId, + }).sort({ createdAt: -1 }); - res.json({ success: true, data: snapshots }); + res.json({ success: true, data: snapshots }); }); // GET /api/analytics/:creatorId/snapshots/latest const getLatestSnapshot = asyncHandler(async (req, res) => { - const snapshot = await AnalyticsSnapshot.findOne( - { creatorId: req.params.creatorId }, - {}, - { sort: { createdAt: -1 } } - ); + const snapshot = await AnalyticsSnapshot.findOne( + { creatorId: req.params.creatorId }, + {}, + { sort: { createdAt: -1 } }, + ); - if (!snapshot) { - return res.status(404).json({ success: false, message: "No snapshot found" }); - } + if (!snapshot) { + return res + .status(404) + .json({ success: false, message: "No snapshot found" }); + } - res.json({ success: true, data: snapshot }); + res.json({ success: true, data: snapshot }); }); // GET /api/analytics/:creatorId/engagement-history const getEngagementHistory = asyncHandler(async (req, res) => { - const history = await EngagementHistory.find({ - creatorId: req.params.creatorId, - }).sort({ createdAt: -1 }); + const history = await EngagementHistory.find({ + creatorId: req.params.creatorId, + }).sort({ createdAt: -1 }); - res.json({ success: true, data: history }); + res.json({ success: true, data: history }); }); // POST /api/analytics/:creatorId/refresh const triggerRefresh = asyncHandler(async (req, res) => { - const creator = await Creator.findById(req.params.creatorId); - if (!creator) { - return res.status(404).json({ success: false, message: "Creator not found" }); - } + const creator = await Creator.findById(req.params.creatorId); + if (!creator) { + return res + .status(404) + .json({ success: false, message: "Creator not found" }); + } - // TODO: Replace with real API fetch - const mockData = { - followers: Math.floor(Math.random() * 10000), - following: Math.floor(Math.random() * 1000), - totalPosts: Math.floor(Math.random() * 500), - totalLikes: Math.floor(Math.random() * 50000), - totalComments: Math.floor(Math.random() * 5000), - totalViews: Math.floor(Math.random() * 100000), - engagementRate: parseFloat((Math.random() * 10).toFixed(2)), - }; + // TODO: Replace with real API fetch + const mockData = { + followers: Math.floor(Math.random() * 10000), + following: Math.floor(Math.random() * 1000), + totalPosts: Math.floor(Math.random() * 500), + totalLikes: Math.floor(Math.random() * 50000), + totalComments: Math.floor(Math.random() * 5000), + totalViews: Math.floor(Math.random() * 100000), + engagementRate: parseFloat((Math.random() * 10).toFixed(2)), + }; - const snapshot = await AnalyticsSnapshot.create({ - creatorId: creator._id, - platform: creator.platform, - ...mockData, - snapshotDate: new Date(), - }); + const snapshot = await AnalyticsSnapshot.create({ + creatorId: creator._id, + platform: creator.platform, + ...mockData, + snapshotDate: new Date(), + }); - await Creator.findByIdAndUpdate(creator._id, { - lastRefreshedAt: new Date(), - }); + await Creator.findByIdAndUpdate(creator._id, { + lastRefreshedAt: new Date(), + }); - res.json({ success: true, message: "Refresh successful", data: snapshot }); + res.json({ success: true, message: "Refresh successful", data: snapshot }); }); -module.exports = { getSnapshots, getLatestSnapshot, triggerRefresh, getEngagementHistory }; \ No newline at end of file +module.exports = { + getSnapshots, + getLatestSnapshot, + triggerRefresh, + getEngagementHistory, +}; diff --git a/controller/auth.js b/controller/auth.js index 5619f661..c5addbb0 100644 --- a/controller/auth.js +++ b/controller/auth.js @@ -10,7 +10,8 @@ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; const VERIFICATION_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours const GUEST_CONTRIBUTOR_ROLE = "guest_contributor"; const GENERIC_LOGIN_ERROR = "Invalid email or password"; -const GOOGLE_AUTH_CANCELLED_ERROR = "Google sign-in was cancelled or could not be completed."; +const GOOGLE_AUTH_CANCELLED_ERROR = + "Google sign-in was cancelled or could not be completed."; /** * @function getUserModel @@ -21,8 +22,8 @@ const GOOGLE_AUTH_CANCELLED_ERROR = "Google sign-in was cancelled or could not b * @returns {Promise|void} */ async function getUserModel() { - await connectDB(); - return require("../model/user"); + await connectDB(); + return require("../model/user"); } /** @@ -31,7 +32,7 @@ async function getUserModel() { * @returns {any} */ function generateVerificationToken() { - return crypto.randomBytes(32).toString("hex"); + return crypto.randomBytes(32).toString("hex"); } /** @@ -40,7 +41,7 @@ function generateVerificationToken() { * @returns {any} */ function getVerificationTokenExpiry() { - return new Date(Date.now() + VERIFICATION_TOKEN_EXPIRY_MS); + return new Date(Date.now() + VERIFICATION_TOKEN_EXPIRY_MS); } /** @@ -49,7 +50,7 @@ function getVerificationTokenExpiry() { * @returns {any} */ function isVerificationTokenExpired(expiryDate) { - return expiryDate < new Date(); + return expiryDate < new Date(); } /** * @function isGoogleAuthConfigured @@ -57,11 +58,11 @@ function isVerificationTokenExpired(expiryDate) { * @returns {any} */ function isGoogleAuthConfigured() { - return Boolean( - process.env.GOOGLE_CLIENT_ID && - process.env.GOOGLE_CLIENT_SECRET && - process.env.GOOGLE_CALLBACK_URL - ); + return Boolean( + process.env.GOOGLE_CLIENT_ID && + process.env.GOOGLE_CLIENT_SECRET && + process.env.GOOGLE_CALLBACK_URL, + ); } /** @@ -70,12 +71,12 @@ function isGoogleAuthConfigured() { * @returns {any} */ function serializeUser(user) { - return { - id: user._id.toString(), - name: user.name, - email: user.email, - role: user.role || "creator", - }; + return { + id: user._id.toString(), + name: user.name, + email: user.email, + role: user.role || "creator", + }; } /** @@ -84,15 +85,11 @@ function serializeUser(user) { * @returns {any} */ function createToken(user) { - const tokenUser = serializeUser(user); - - return jwt.sign( - tokenUser, - process.env.JWT_SECRET, - { - expiresIn: "7d", - } - ); + const tokenUser = serializeUser(user); + + return jwt.sign(tokenUser, process.env.JWT_SECRET, { + expiresIn: "7d", + }); } /** @@ -101,18 +98,18 @@ function createToken(user) { * @returns {any} */ function createContributorToken(session) { - return jwt.sign( - { - id: session.contributorId, - sessionId: session._id.toString(), - name: CONTRIBUTOR_NAME, - role: GUEST_CONTRIBUTOR_ROLE, - }, - process.env.JWT_SECRET, - { - expiresIn: "7d", - } - ); + return jwt.sign( + { + id: session.contributorId, + sessionId: session._id.toString(), + name: CONTRIBUTOR_NAME, + role: GUEST_CONTRIBUTOR_ROLE, + }, + process.env.JWT_SECRET, + { + expiresIn: "7d", + }, + ); } /** @@ -121,12 +118,12 @@ function createContributorToken(session) { * @returns {any} */ function setAuthCookie(res, token) { - res.cookie("token", token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: process.env.COOKIE_SAME_SITE || "lax", - maxAge: ONE_WEEK_MS, - }); + res.cookie("token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.COOKIE_SAME_SITE || "lax", + maxAge: ONE_WEEK_MS, + }); } /** @@ -135,10 +132,9 @@ function setAuthCookie(res, token) { * @returns {any} */ function redirectWithLoginError(res, error) { - const message = error === GOOGLE_AUTH_CANCELLED_ERROR - ? error - : GENERIC_LOGIN_ERROR; - return res.redirect(`/login?error=${encodeURIComponent(message)}`); + const message = + error === GOOGLE_AUTH_CANCELLED_ERROR ? error : GENERIC_LOGIN_ERROR; + return res.redirect(`/login?error=${encodeURIComponent(message)}`); } /** @@ -147,107 +143,115 @@ function redirectWithLoginError(res, error) { * @returns {any} */ function renderLoginError(req, res) { - if (wantsHtml(req)) { - return res.status(401).render("login", { - error: GENERIC_LOGIN_ERROR, - googleAuthConfigured: isGoogleAuthConfigured(), - }); - } + if (wantsHtml(req)) { + return res.status(401).render("login", { + error: GENERIC_LOGIN_ERROR, + googleAuthConfigured: isGoogleAuthConfigured(), + }); + } - return res.status(401).json({ success: false, message: GENERIC_LOGIN_ERROR }); + return res.status(401).json({ success: false, message: GENERIC_LOGIN_ERROR }); } const signup = asyncHandler(async (req, res, next) => { - const User = await getUserModel(); - const { sendVerificationEmail } = require("../utils/email"); - - const { name, email, password } = req.body || {}; - const normalizedEmail = email.toLowerCase().trim(); + const User = await getUserModel(); + const { sendVerificationEmail } = require("../utils/email"); - const existingUser = await User.findOne({ email: normalizedEmail }); - - if (existingUser) { - if (wantsHtml(req)) { - return res.status(409).render("signup", { error: "User already exists" }); - } - return res.status(409).json({ success: false, message: "User already exists" }); - } + const { name, email, password } = req.body || {}; + const normalizedEmail = email.toLowerCase().trim(); - const hashedPassword = await bcrypt.hash(password, 10); - const verificationToken = generateVerificationToken(); - const verificationTokenExpiry = getVerificationTokenExpiry(); - - const user = await User.create({ - name, - email: normalizedEmail, - password: hashedPassword, - authProvider: "local", - isVerified: false, - verificationToken, - verificationTokenExpiry, - }); - - // Send verification email - try { - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get("host")}`; - const verificationLink = `${baseUrl}/verify-email?token=${verificationToken}`; - - await sendVerificationEmail({ - to: normalizedEmail, - verificationLink, - userName: name, - }); - } catch (emailError) { - console.error("Failed to send verification email:", emailError); - // Don't fail the signup, but let user know to check email or resend - } + const existingUser = await User.findOne({ email: normalizedEmail }); + if (existingUser) { if (wantsHtml(req)) { - return res.render("signup", { - error: null, - success: "Sign up successful! Please check your email to verify your account." - }); + return res.status(409).render("signup", { error: "User already exists" }); } - return res.status(201).json({ - success: true, - message: "Sign up successful! Please check your email to verify your account.", - data: { id: user._id, email: user.email } + return res + .status(409) + .json({ success: false, message: "User already exists" }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + const verificationToken = generateVerificationToken(); + const verificationTokenExpiry = getVerificationTokenExpiry(); + + const user = await User.create({ + name, + email: normalizedEmail, + password: hashedPassword, + authProvider: "local", + isVerified: false, + verificationToken, + verificationTokenExpiry, + }); + + // Send verification email + try { + const baseUrl = + process.env.APP_URL || `${req.protocol}://${req.get("host")}`; + const verificationLink = `${baseUrl}/verify-email?token=${verificationToken}`; + + await sendVerificationEmail({ + to: normalizedEmail, + verificationLink, + userName: name, }); + } catch (emailError) { + console.error("Failed to send verification email:", emailError); + // Don't fail the signup, but let user know to check email or resend + } + + if (wantsHtml(req)) { + return res.render("signup", { + error: null, + success: + "Sign up successful! Please check your email to verify your account.", + }); + } + return res.status(201).json({ + success: true, + message: + "Sign up successful! Please check your email to verify your account.", + data: { id: user._id, email: user.email }, + }); }); const login = asyncHandler(async (req, res, next) => { - const User = await getUserModel(); - - const { email, password } = req.body || {}; - const normalizedEmail = (email && typeof email === 'string') ? email.toLowerCase().trim() : ""; - - // Allow login with any credentials: find user by email, or get the first user, or create a mock user - let user = null; - if (normalizedEmail) { - user = await User.findOne({ email: normalizedEmail }); - } - if (!user) { - user = await User.findOne({}); - } - if (!user) { - user = await User.create({ - name: "Test User", - email: normalizedEmail || "test@local.com", - password: await bcrypt.hash("Password123!", 10), - role: "creator", - isVerified: true - }); - } + const User = await getUserModel(); + + const { email, password } = req.body || {}; + const normalizedEmail = + email && typeof email === "string" ? email.toLowerCase().trim() : ""; + + // Allow login with any credentials: find user by email, or get the first user, or create a mock user + let user = null; + if (normalizedEmail) { + user = await User.findOne({ email: normalizedEmail }); + } + if (!user) { + user = await User.findOne({}); + } + if (!user) { + user = await User.create({ + name: "Test User", + email: normalizedEmail || "test@local.com", + password: await bcrypt.hash("Password123!", 10), + role: "creator", + isVerified: true, + }); + } - user.isVerified = true; - user.lastLoginAt = new Date(); - await user.save(); + user.isVerified = true; + user.lastLoginAt = new Date(); + await user.save(); - const token = createToken(user); - setAuthCookie(res, token); + const token = createToken(user); + setAuthCookie(res, token); - if (wantsHtml(req)) return res.redirect("/dashboard"); - return res.status(200).json({ success: true, token, user: serializeUser(user) }); + if (wantsHtml(req)) return res.redirect("/dashboard"); + return res + .status(200) + .json({ success: true, token, user: serializeUser(user) }); }); /** @@ -259,230 +263,236 @@ const login = asyncHandler(async (req, res, next) => { * @returns {Promise|void} */ const handleGoogleCallback = asyncHandler(async (req, res, next) => { - try { - if (!req.user) { - return redirectWithLoginError(res, GOOGLE_AUTH_CANCELLED_ERROR); - } - - const token = createToken(req.user); - setAuthCookie(res, token); - - return res.redirect("/dashboard?login=google"); - } catch (error) { - console.error("Google login error:", error); - return redirectWithLoginError(res, "Google sign-in failed. Please try again."); + try { + if (!req.user) { + return redirectWithLoginError(res, GOOGLE_AUTH_CANCELLED_ERROR); } -}); -const loginAsContributor = asyncHandler(async (req, res, next) => { - await connectDB(); - const ContributorSession = require("../model/contributorSession"); - const contributorId = crypto.randomUUID(); - const expiresAt = new Date(Date.now() + ONE_WEEK_MS); - const session = await ContributorSession.create({ - contributorId, - role: GUEST_CONTRIBUTOR_ROLE, - expiresAt, - }); - const user = { - id: contributorId, - name: CONTRIBUTOR_NAME, - role: GUEST_CONTRIBUTOR_ROLE, - }; - const token = createContributorToken(session); + const token = createToken(req.user); setAuthCookie(res, token); - if (wantsHtml(req)) return res.redirect("/dashboard"); - return res.status(200).json({ - success: true, - token, - user, - }); + return res.redirect("/dashboard?login=google"); + } catch (error) { + console.error("Google login error:", error); + return redirectWithLoginError( + res, + "Google sign-in failed. Please try again.", + ); + } +}); + +const loginAsContributor = asyncHandler(async (req, res, next) => { + await connectDB(); + const ContributorSession = require("../model/contributorSession"); + const contributorId = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + ONE_WEEK_MS); + const session = await ContributorSession.create({ + contributorId, + role: GUEST_CONTRIBUTOR_ROLE, + expiresAt, + }); + const user = { + id: contributorId, + name: CONTRIBUTOR_NAME, + role: GUEST_CONTRIBUTOR_ROLE, + }; + const token = createContributorToken(session); + setAuthCookie(res, token); + + if (wantsHtml(req)) return res.redirect("/dashboard"); + return res.status(200).json({ + success: true, + token, + user, + }); }); const verifyEmail = asyncHandler(async (req, res, next) => { - const User = await getUserModel(); - const { token } = req.query; - - if (!token) { - if (wantsHtml(req)) { - return res.status(400).render("verify-email", { - error: "Invalid verification link. Please request a new one.", - success: null, - expiredToken: false, - userEmail: null - }); - } - return res.status(400).json({ - success: false, - message: "Invalid verification link." - }); - } + const User = await getUserModel(); + const { token } = req.query; - const user = await User.findOne({ verificationToken: token }); - - if (!user) { - if (wantsHtml(req)) { - return res.status(400).render("verify-email", { - error: "Invalid verification link. Please request a new one.", - success: null, - expiredToken: false, - userEmail: null - }); - } - return res.status(400).json({ - success: false, - message: "Invalid verification link." - }); + if (!token) { + if (wantsHtml(req)) { + return res.status(400).render("verify-email", { + error: "Invalid verification link. Please request a new one.", + success: null, + expiredToken: false, + userEmail: null, + }); } + return res.status(400).json({ + success: false, + message: "Invalid verification link.", + }); + } - if (user.isVerified) { - if (wantsHtml(req)) { - return res.render("verify-email", { - success: "Your email is already verified. You can log in now.", - error: null, - expiredToken: false, - userEmail: null - }); - } - return res.status(200).json({ - success: true, - message: "Your email is already verified." - }); - } + const user = await User.findOne({ verificationToken: token }); - if (isVerificationTokenExpired(user.verificationTokenExpiry)) { - if (wantsHtml(req)) { - return res.status(410).render("verify-email", { - error: "Verification link has expired. Please request a new one.", - success: null, - expiredToken: true, - userEmail: user.email - }); - } - return res.status(410).json({ - success: false, - message: "Verification link has expired.", - userEmail: user.email, - }); + if (!user) { + if (wantsHtml(req)) { + return res.status(400).render("verify-email", { + error: "Invalid verification link. Please request a new one.", + success: null, + expiredToken: false, + userEmail: null, + }); } + return res.status(400).json({ + success: false, + message: "Invalid verification link.", + }); + } - user.isVerified = true; - user.verificationToken = null; - user.verificationTokenExpiry = null; - await user.save(); + if (user.isVerified) { + if (wantsHtml(req)) { + return res.render("verify-email", { + success: "Your email is already verified. You can log in now.", + error: null, + expiredToken: false, + userEmail: null, + }); + } + return res.status(200).json({ + success: true, + message: "Your email is already verified.", + }); + } + if (isVerificationTokenExpired(user.verificationTokenExpiry)) { if (wantsHtml(req)) { - return res.render("verify-email", { - success: "Your email has been verified successfully! You can now log in.", - error: null, - expiredToken: false, - userEmail: null - }); + return res.status(410).render("verify-email", { + error: "Verification link has expired. Please request a new one.", + success: null, + expiredToken: true, + userEmail: user.email, + }); } - return res.status(200).json({ - success: true, - message: "Email verified successfully!" + return res.status(410).json({ + success: false, + message: "Verification link has expired.", + userEmail: user.email, + }); + } + + user.isVerified = true; + user.verificationToken = null; + user.verificationTokenExpiry = null; + await user.save(); + + if (wantsHtml(req)) { + return res.render("verify-email", { + success: "Your email has been verified successfully! You can now log in.", + error: null, + expiredToken: false, + userEmail: null, }); + } + return res.status(200).json({ + success: true, + message: "Email verified successfully!", + }); }); const resendVerificationEmail = asyncHandler(async (req, res, next) => { - const User = await getUserModel(); - const { sendVerificationEmail } = require("../utils/email"); - const { email } = req.body || {}; - - if (!email) { - if (wantsHtml(req)) { - return res.status(400).render("resend-verification", { - error: "Email address is required.", - success: null - }); - } - return res.status(400).json({ - success: false, - message: "Email address is required." - }); - } + const User = await getUserModel(); + const { sendVerificationEmail } = require("../utils/email"); + const { email } = req.body || {}; - const normalizedEmail = email.toLowerCase().trim(); - const user = await User.findOne({ email: normalizedEmail }); - - if (!user) { - // Don't reveal whether email exists - if (wantsHtml(req)) { - return res.render("resend-verification", { - success: "If that email address is in our system, you'll receive a verification email shortly.", - error: null - }); - } - return res.status(200).json({ - success: true, - message: "If that email address is in our system, you'll receive a verification email shortly." - }); + if (!email) { + if (wantsHtml(req)) { + return res.status(400).render("resend-verification", { + error: "Email address is required.", + success: null, + }); } + return res.status(400).json({ + success: false, + message: "Email address is required.", + }); + } - if (user.isVerified) { - if (wantsHtml(req)) { - return res.render("resend-verification", { - success: "Your email is already verified. You can log in now.", - error: null - }); - } - return res.status(200).json({ - success: true, - message: "Your email is already verified." - }); - } + const normalizedEmail = email.toLowerCase().trim(); + const user = await User.findOne({ email: normalizedEmail }); - // Generate new verification token - const verificationToken = generateVerificationToken(); - const verificationTokenExpiry = getVerificationTokenExpiry(); - - user.verificationToken = verificationToken; - user.verificationTokenExpiry = verificationTokenExpiry; - await user.save(); - - // Send verification email - try { - const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get("host")}`; - const verificationLink = `${baseUrl}/verify-email?token=${verificationToken}`; - - await sendVerificationEmail({ - to: normalizedEmail, - verificationLink, - userName: user.name, - }); - } catch (emailError) { - console.error("Failed to send verification email:", emailError); - if (wantsHtml(req)) { - return res.status(500).render("resend-verification", { - error: "Failed to send verification email. Please try again later.", - success: null - }); - } - return res.status(500).json({ - success: false, - message: "Failed to send verification email. Please try again later." - }); + if (!user) { + // Don't reveal whether email exists + if (wantsHtml(req)) { + return res.render("resend-verification", { + success: + "If that email address is in our system, you'll receive a verification email shortly.", + error: null, + }); } + return res.status(200).json({ + success: true, + message: + "If that email address is in our system, you'll receive a verification email shortly.", + }); + } + if (user.isVerified) { if (wantsHtml(req)) { - return res.render("resend-verification", { - success: "Verification email sent! Please check your inbox.", - error: null - }); + return res.render("resend-verification", { + success: "Your email is already verified. You can log in now.", + error: null, + }); } - return res.status(200).json({ - success: true, - message: "Verification email sent successfully!" + return res.status(200).json({ + success: true, + message: "Your email is already verified.", + }); + } + + // Generate new verification token + const verificationToken = generateVerificationToken(); + const verificationTokenExpiry = getVerificationTokenExpiry(); + + user.verificationToken = verificationToken; + user.verificationTokenExpiry = verificationTokenExpiry; + await user.save(); + + // Send verification email + try { + const baseUrl = + process.env.APP_URL || `${req.protocol}://${req.get("host")}`; + const verificationLink = `${baseUrl}/verify-email?token=${verificationToken}`; + + await sendVerificationEmail({ + to: normalizedEmail, + verificationLink, + userName: user.name, + }); + } catch (emailError) { + console.error("Failed to send verification email:", emailError); + if (wantsHtml(req)) { + return res.status(500).render("resend-verification", { + error: "Failed to send verification email. Please try again later.", + success: null, + }); + } + return res.status(500).json({ + success: false, + message: "Failed to send verification email. Please try again later.", + }); + } + + if (wantsHtml(req)) { + return res.render("resend-verification", { + success: "Verification email sent! Please check your inbox.", + error: null, }); + } + return res.status(200).json({ + success: true, + message: "Verification email sent successfully!", + }); }); module.exports = { - signup, - login, - handleGoogleCallback, - loginAsContributor, - verifyEmail, - resendVerificationEmail, + signup, + login, + handleGoogleCallback, + loginAsContributor, + verifyEmail, + resendVerificationEmail, }; diff --git a/controller/collaborationController.js b/controller/collaborationController.js index 64dd99ed..cc2b0500 100644 --- a/controller/collaborationController.js +++ b/controller/collaborationController.js @@ -1,10 +1,10 @@ -const crypto = require('crypto'); -const Invite = require('../model/invite'); -const User = require('../model/user'); -const services = require('../services.config'); -const { sendInvitationEmail } = require('../utils/email'); -const asyncHandler = require('../utils/asyncHandler'); -const { getDashboardData } = require('../utils/dashboardHelper'); +const crypto = require("crypto"); +const Invite = require("../model/invite"); +const User = require("../model/user"); +const services = require("../services.config"); +const { sendInvitationEmail } = require("../utils/email"); +const asyncHandler = require("../utils/asyncHandler"); +const { getDashboardData } = require("../utils/dashboardHelper"); /** * @function buildAccountViewModel @@ -12,30 +12,31 @@ const { getDashboardData } = require('../utils/dashboardHelper'); * @returns {any} */ function buildAccountViewModel(userDoc, fallbackUser) { - const name = userDoc?.name || 'Creator'; - const initials = name - .split(' ') - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0].toUpperCase()) - .join('') || 'CR'; + const name = userDoc?.name || "Creator"; + const initials = + name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0].toUpperCase()) + .join("") || "CR"; return { id: fallbackUser.id, name, - email: userDoc?.email || '', + email: userDoc?.email || "", initials, }; } const getCreatorCrmPage = asyncHandler(async (req, res, next) => { - const userDoc = await User.findById(req.user.id).select('name email').lean(); + const userDoc = await User.findById(req.user.id).select("name email").lean(); const invites = await Invite.find({ inviter: req.user.id }) .sort({ createdAt: -1 }) .limit(12) .lean(); - res.render('creator-crm', { + res.render("creator-crm", { user: buildAccountViewModel(userDoc, req.user), invites, success: null, @@ -43,15 +44,23 @@ const getCreatorCrmPage = asyncHandler(async (req, res, next) => { }); }); -const { collaborationInviteSchema, collaborationAcceptSchema } = require('../middleware/validators'); +const { + collaborationInviteSchema, + collaborationAcceptSchema, +} = require("../middleware/validators"); const sendCollaboratorInvite = asyncHandler(async (req, res, next) => { const result = collaborationInviteSchema.safeParse(req.body); if (!result.success) { - const userDoc = await User.findById(req.user.id).select('name email').lean(); - const invites = await Invite.find({ inviter: req.user.id }).sort({ createdAt: -1 }).limit(12).lean(); - return res.status(400).render('creator-crm', { + const userDoc = await User.findById(req.user.id) + .select("name email") + .lean(); + const invites = await Invite.find({ inviter: req.user.id }) + .sort({ createdAt: -1 }) + .limit(12) + .lean(); + return res.status(400).render("creator-crm", { user: buildAccountViewModel(userDoc, req.user), invites, success: null, @@ -61,39 +70,45 @@ const sendCollaboratorInvite = asyncHandler(async (req, res, next) => { const { email, projectName, message } = result.data; - const token = crypto.randomBytes(22).toString('hex'); + const token = crypto.randomBytes(22).toString("hex"); const invite = await Invite.create({ inviter: req.user.id, email: email.trim().toLowerCase(), - projectName: projectName?.trim() || 'CreatorOS Collaboration', + projectName: projectName?.trim() || "CreatorOS Collaboration", message: message?.trim(), token, }); - const inviteUrl = `${process.env.APP_URL || `${req.protocol}://${req.get('host')}`}/invites/accept/${token}`; - const userDoc = await User.findById(req.user.id).select('name email').lean(); + const inviteUrl = `${process.env.APP_URL || `${req.protocol}://${req.get("host")}`}/invites/accept/${token}`; + const userDoc = await User.findById(req.user.id).select("name email").lean(); try { await sendInvitationEmail({ to: invite.email, - inviterName: userDoc?.name || 'CreatorOS', + inviterName: userDoc?.name || "CreatorOS", projectName: invite.projectName, inviteUrl, personalMessage: invite.message, }); } catch (emailError) { await Invite.findByIdAndDelete(invite._id); - const invites = await Invite.find({ inviter: req.user.id }).sort({ createdAt: -1 }).limit(12).lean(); - return res.status(500).render('creator-crm', { + const invites = await Invite.find({ inviter: req.user.id }) + .sort({ createdAt: -1 }) + .limit(12) + .lean(); + return res.status(500).render("creator-crm", { user: buildAccountViewModel(userDoc, req.user), invites, success: null, - error: 'Unable to send invite. Please try again later.', + error: "Unable to send invite. Please try again later.", }); } - const invites = await Invite.find({ inviter: req.user.id }).sort({ createdAt: -1 }).limit(12).lean(); - res.render('creator-crm', { + const invites = await Invite.find({ inviter: req.user.id }) + .sort({ createdAt: -1 }) + .limit(12) + .lean(); + res.render("creator-crm", { user: buildAccountViewModel(userDoc, req.user), invites, success: `Invite sent successfully to ${invite.email}.`, @@ -110,14 +125,14 @@ const sendCollaboratorInvite = asyncHandler(async (req, res, next) => { * @returns {Promise|void} */ const renderDashboard = async (req, res, options = {}) => { - const userDoc = await User.findById(req.user.id).select('name email').lean(); - + const userDoc = await User.findById(req.user.id).select("name email").lean(); + const [pending, accepted, expired] = await Promise.all([ - Invite.countDocuments({ inviter: req.user.id, status: 'pending' }), - Invite.countDocuments({ inviter: req.user.id, status: 'accepted' }), - Invite.countDocuments({ inviter: req.user.id, status: 'expired' }) + Invite.countDocuments({ inviter: req.user.id, status: "pending" }), + Invite.countDocuments({ inviter: req.user.id, status: "accepted" }), + Invite.countDocuments({ inviter: req.user.id, status: "expired" }), ]); - + const inviteSummary = { total: pending + accepted + expired, pending, @@ -127,7 +142,7 @@ const renderDashboard = async (req, res, options = {}) => { const dashboardData = await getDashboardData(userDoc); - return res.render('dashboard', { + return res.render("dashboard", { user: buildAccountViewModel(userDoc, req.user), services, inviteSummary, @@ -141,23 +156,23 @@ const acceptInvite = asyncHandler(async (req, res, next) => { const invite = await Invite.findOne({ token: req.params.token }); if (!invite) { - return res.status(404).render('invite-accept', { - status: 'missing', + return res.status(404).render("invite-accept", { + status: "missing", invite: null, }); } - if (invite.status === 'accepted') { - return res.render('invite-accept', { - status: 'accepted', + if (invite.status === "accepted") { + return res.render("invite-accept", { + status: "accepted", invite, }); } // Don't auto-accept anymore if unauthenticated. // Just render the pending state so they can copy the token and login. - res.render('invite-accept', { - status: 'pending', + res.render("invite-accept", { + status: "pending", invite, }); }); @@ -167,7 +182,9 @@ const acceptInviteFromDashboard = asyncHandler(async (req, res, next) => { const result = collaborationAcceptSchema.safeParse(req.body); if (!result.success) { - return renderDashboard(req, res, { inviteAcceptError: result.error.issues[0].message }); + return renderDashboard(req, res, { + inviteAcceptError: result.error.issues[0].message, + }); } const { inviteToken } = result.data; @@ -175,14 +192,18 @@ const acceptInviteFromDashboard = asyncHandler(async (req, res, next) => { const invite = await Invite.findOne({ token: inviteToken.trim() }); if (!invite) { - return renderDashboard(req, res, { inviteAcceptError: 'No invitation found for that token.' }); + return renderDashboard(req, res, { + inviteAcceptError: "No invitation found for that token.", + }); } - if (invite.status === 'accepted') { - return renderDashboard(req, res, { inviteAcceptMessage: 'This invitation has already been accepted.' }); + if (invite.status === "accepted") { + return renderDashboard(req, res, { + inviteAcceptMessage: "This invitation has already been accepted.", + }); } - invite.status = 'accepted'; + invite.status = "accepted"; invite.acceptedAt = new Date(); await invite.save(); @@ -190,7 +211,9 @@ const acceptInviteFromDashboard = asyncHandler(async (req, res, next) => { if (inviter) { if (!inviter.collaborators) inviter.collaborators = []; - const isAlreadyCollaborator = inviter.collaborators.some(id => id.toString() === req.user.id.toString()); + const isAlreadyCollaborator = inviter.collaborators.some( + (id) => id.toString() === req.user.id.toString(), + ); if (!isAlreadyCollaborator) { inviter.collaborators.push(req.user.id); await inviter.save(); @@ -202,7 +225,7 @@ const acceptInviteFromDashboard = asyncHandler(async (req, res, next) => { }); } catch (error) { return renderDashboard(req, res, { - inviteAcceptError: 'Unable to accept invite. Please try again later.', + inviteAcceptError: "Unable to accept invite. Please try again later.", }); } }); diff --git a/controller/instagramController.js b/controller/instagramController.js index 3a7e95cd..85e1004b 100644 --- a/controller/instagramController.js +++ b/controller/instagramController.js @@ -1,4 +1,8 @@ -const { fetchInstagramProfile, InstagramProfileError, validateUsername } = require('../utils/instagramProfileService'); +const { + fetchInstagramProfile, + InstagramProfileError, + validateUsername, +} = require("../utils/instagramProfileService"); const lookupTracker = new Map(); @@ -8,8 +12,8 @@ const lookupTracker = new Map(); * @returns {any} */ function getCooldownSeconds() { - const value = Number(process.env.INSTAGRAM_LOOKUP_COOLDOWN_SECONDS || 30); - return Number.isFinite(value) && value >= 0 ? value : 30; + const value = Number(process.env.INSTAGRAM_LOOKUP_COOLDOWN_SECONDS || 30); + return Number.isFinite(value) && value >= 0 ? value : 30; } /** @@ -18,7 +22,7 @@ function getCooldownSeconds() { * @returns {any} */ function getLookupKey(req) { - return req.user?.id || req.ip || 'anonymous'; + return req.user?.id || req.ip || "anonymous"; } /** @@ -27,27 +31,27 @@ function getLookupKey(req) { * @returns {any} */ function assertLookupAllowed(req) { - const cooldownSeconds = getCooldownSeconds(); + const cooldownSeconds = getCooldownSeconds(); - if (cooldownSeconds === 0) { - return; - } + if (cooldownSeconds === 0) { + return; + } - const lookupKey = getLookupKey(req); - const now = Date.now(); - const nextAllowedAt = lookupTracker.get(lookupKey) || 0; + const lookupKey = getLookupKey(req); + const now = Date.now(); + const nextAllowedAt = lookupTracker.get(lookupKey) || 0; - if (now < nextAllowedAt) { - const retryAfter = Math.ceil((nextAllowedAt - now) / 1000); - throw new InstagramProfileError( - 'RATE_LIMITED', - `Please wait ${retryAfter} seconds before fetching another Instagram profile.`, - 429, - { retryAfter } - ); - } + if (now < nextAllowedAt) { + const retryAfter = Math.ceil((nextAllowedAt - now) / 1000); + throw new InstagramProfileError( + "RATE_LIMITED", + `Please wait ${retryAfter} seconds before fetching another Instagram profile.`, + 429, + { retryAfter }, + ); + } - lookupTracker.set(lookupKey, now + cooldownSeconds * 1000); + lookupTracker.set(lookupKey, now + cooldownSeconds * 1000); } /** @@ -56,24 +60,25 @@ function assertLookupAllowed(req) { * @returns {any} */ function sendInstagramError(res, error) { - if (error instanceof InstagramProfileError) { - return res.status(error.statusCode).json({ - success: false, - error: { - code: error.code, - message: error.message, - details: error.details, - }, - }); - } - - return res.status(500).json({ - success: false, - error: { - code: 'TEMPORARY_FETCH_ERROR', - message: 'Unable to fetch Instagram profile right now. Please try again later.', - }, + if (error instanceof InstagramProfileError) { + return res.status(error.statusCode).json({ + success: false, + error: { + code: error.code, + message: error.message, + details: error.details, + }, }); + } + + return res.status(500).json({ + success: false, + error: { + code: "TEMPORARY_FETCH_ERROR", + message: + "Unable to fetch Instagram profile right now. Please try again later.", + }, + }); } /** @@ -85,22 +90,22 @@ function sendInstagramError(res, error) { * @returns {Promise|void} */ async function getInstagramProfile(req, res) { - try { - const username = validateUsername(req.query.username); + try { + const username = validateUsername(req.query.username); - assertLookupAllowed(req); + assertLookupAllowed(req); - const profile = await fetchInstagramProfile(username); + const profile = await fetchInstagramProfile(username); - return res.json({ - success: true, - data: profile, - }); - } catch (error) { - return sendInstagramError(res, error); - } + return res.json({ + success: true, + data: profile, + }); + } catch (error) { + return sendInstagramError(res, error); + } } module.exports = { - getInstagramProfile, + getInstagramProfile, }; diff --git a/controller/instagramWebhookController.js b/controller/instagramWebhookController.js index aca7011e..6e8c5a00 100644 --- a/controller/instagramWebhookController.js +++ b/controller/instagramWebhookController.js @@ -1,115 +1,133 @@ -const { dmQueue } = require('../services/dmQueueService'); -const asyncHandler = require('../utils/asyncHandler'); +const { dmQueue } = require("../services/dmQueueService"); +const asyncHandler = require("../utils/asyncHandler"); -const crypto = require('crypto'); +const crypto = require("crypto"); // Verify the webhook from Meta const verifyWebhook = (req, res) => { - // You should store your Verify Token in an environment variable - const VERIFY_TOKEN = process.env.INSTAGRAM_WEBHOOK_VERIFY_TOKEN || 'creatoros-verify-token'; - - const mode = req.query['hub.mode']; - const token = req.query['hub.verify_token']; - const challenge = req.query['hub.challenge']; - - if (mode && token) { - if (mode === 'subscribe' && token === VERIFY_TOKEN) { - console.log('WEBHOOK_VERIFIED'); - return res.status(200).send(challenge); - } else { - return res.sendStatus(403); - } + // You should store your Verify Token in an environment variable + const VERIFY_TOKEN = + process.env.INSTAGRAM_WEBHOOK_VERIFY_TOKEN || "creatoros-verify-token"; + + const mode = req.query["hub.mode"]; + const token = req.query["hub.verify_token"]; + const challenge = req.query["hub.challenge"]; + + if (mode && token) { + if (mode === "subscribe" && token === VERIFY_TOKEN) { + console.log("WEBHOOK_VERIFIED"); + return res.status(200).send(challenge); + } else { + return res.sendStatus(403); } - - return res.status(400).send('Missing hub variables'); + } + + return res.status(400).send("Missing hub variables"); }; const verifyWebhookSignature = (req, res, next) => { - const signature = req.headers['x-hub-signature-256']; - - if (!signature) { - console.warn('[Webhook] Missing X-Hub-Signature-256 header'); - return res.sendStatus(403); - } - - const APP_SECRET = process.env.INSTAGRAM_APP_SECRET; - - if (!APP_SECRET) { - console.error('[Webhook] INSTAGRAM_APP_SECRET is not configured'); - return res.sendStatus(500); - } - - const payload = req.rawBody; - - if (!payload) { - console.error('[Webhook] Raw body is missing. Ensure express.json({verify: ...}) is configured.'); - return res.sendStatus(500); - } + const signature = req.headers["x-hub-signature-256"]; - const expectedSignature = 'sha256=' + crypto.createHmac('sha256', APP_SECRET).update(payload).digest('hex'); + if (!signature) { + console.warn("[Webhook] Missing X-Hub-Signature-256 header"); + return res.sendStatus(403); + } - try { - if (crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { - return next(); - } - } catch (e) { - // catch error if buffer lengths don't match + const APP_SECRET = process.env.INSTAGRAM_APP_SECRET; + + if (!APP_SECRET) { + console.error("[Webhook] INSTAGRAM_APP_SECRET is not configured"); + return res.sendStatus(500); + } + + const payload = req.rawBody; + + if (!payload) { + console.error( + "[Webhook] Raw body is missing. Ensure express.json({verify: ...}) is configured.", + ); + return res.sendStatus(500); + } + + const expectedSignature = + "sha256=" + + crypto.createHmac("sha256", APP_SECRET).update(payload).digest("hex"); + + try { + if ( + crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature), + ) + ) { + return next(); } + } catch (e) { + // catch error if buffer lengths don't match + } - console.warn('[Webhook] Invalid signature'); - return res.sendStatus(403); + console.warn("[Webhook] Invalid signature"); + return res.sendStatus(403); }; // Handle incoming webhook events const handleWebhook = asyncHandler(async (req, res, next) => { - const body = req.body; - - // Check if it's a page or instagram event - if (body.object === 'instagram') { - if (body.entry && body.entry.length > 0) { - for (const entry of body.entry) { - if (entry.messaging && entry.messaging.length > 0) { - for (const webhookEvent of entry.messaging) { - const senderId = webhookEvent.sender.id; - const message = webhookEvent.message; - - if (message && message.text) { - console.log(`[Webhook] Received message from ${senderId}: ${message.text}`); - - // Enqueue the message for asynchronous processing instead of synchronous execution - // We set exponential backoff: 5 retries, starting with 2 seconds delay - try { - await dmQueue.add('process-dm', { - senderId: senderId, - message: message.text, - triggerKeyword: message.text.toLowerCase() - }, { - attempts: 5, - backoff: { - type: 'exponential', - delay: 2000 - } - }); - } catch (error) { - console.warn(`[Webhook] DM queue unavailable, skipping async processing: ${error.message}`); - } - } - } - } + const body = req.body; + + // Check if it's a page or instagram event + if (body.object === "instagram") { + if (body.entry && body.entry.length > 0) { + for (const entry of body.entry) { + if (entry.messaging && entry.messaging.length > 0) { + for (const webhookEvent of entry.messaging) { + const senderId = webhookEvent.sender.id; + const message = webhookEvent.message; + + if (message && message.text) { + console.log( + `[Webhook] Received message from ${senderId}: ${message.text}`, + ); + + // Enqueue the message for asynchronous processing instead of synchronous execution + // We set exponential backoff: 5 retries, starting with 2 seconds delay + try { + await dmQueue.add( + "process-dm", + { + senderId: senderId, + message: message.text, + triggerKeyword: message.text.toLowerCase(), + }, + { + attempts: 5, + backoff: { + type: "exponential", + delay: 2000, + }, + }, + ); + } catch (error) { + console.warn( + `[Webhook] DM queue unavailable, skipping async processing: ${error.message}`, + ); + } } + } } - - // Return a '200 OK' response to all requests immediately - // so Instagram doesn't think the webhook failed and resends it. - return res.status(200).send('EVENT_RECEIVED'); - } else { - // Return a '404 Not Found' if event is not from a supported object - return res.sendStatus(404); + } } + + // Return a '200 OK' response to all requests immediately + // so Instagram doesn't think the webhook failed and resends it. + return res.status(200).send("EVENT_RECEIVED"); + } else { + // Return a '404 Not Found' if event is not from a supported object + return res.sendStatus(404); + } }); module.exports = { - verifyWebhook, - verifyWebhookSignature, - handleWebhook + verifyWebhook, + verifyWebhookSignature, + handleWebhook, }; diff --git a/controller/suggestionController.js b/controller/suggestionController.js index 7b63e3b6..b3b0d61d 100644 --- a/controller/suggestionController.js +++ b/controller/suggestionController.js @@ -1,5 +1,5 @@ -const services = require('../services.config'); -const asyncHandler = require('../utils/asyncHandler'); +const services = require("../services.config"); +const asyncHandler = require("../utils/asyncHandler"); /** * @function generateAISuggestions @@ -13,38 +13,42 @@ async function generateAISuggestions(topic) { // If OpenAI API key is configured, use it for real AI generation if (process.env.OPENAI_API_KEY) { try { - const response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}` + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + }, + body: JSON.stringify({ + model: "gpt-3.5-turbo", + messages: [ + { + role: "system", + content: + 'You are an expert social media manager. Generate exactly 5 captions, 9 hashtags, and 5 song recommendations (with title and mood) for the given topic. Return ONLY a raw JSON object with keys: "captions" (array of strings), "hashtags" (array of strings), "songs" (array of objects with "title" and "mood").', + }, + { + role: "user", + content: `Topic: ${topic}`, + }, + ], + }), }, - body: JSON.stringify({ - model: 'gpt-3.5-turbo', - messages: [ - { - role: 'system', - content: 'You are an expert social media manager. Generate exactly 5 captions, 9 hashtags, and 5 song recommendations (with title and mood) for the given topic. Return ONLY a raw JSON object with keys: "captions" (array of strings), "hashtags" (array of strings), "songs" (array of objects with "title" and "mood").' - }, - { - role: 'user', - content: `Topic: ${topic}` - } - ] - }) - }); + ); const data = await response.json(); if (data.choices && data.choices[0]) { return JSON.parse(data.choices[0].message.content); } } catch (e) { - console.error('AI Generation Failed, falling back to mock generator:', e); + console.error("AI Generation Failed, falling back to mock generator:", e); } } // Fallback: Dynamic mock generator based on the topic - const words = topic.split(' ').filter(w => w.length > 2); - const mainWord = words.length > 0 ? words[0].toLowerCase() : 'vibes'; + const words = topic.split(" ").filter((w) => w.length > 2); + const mainWord = words.length > 0 ? words[0].toLowerCase() : "vibes"; const capWord = mainWord.charAt(0).toUpperCase() + mainWord.slice(1); return { @@ -53,39 +57,60 @@ async function generateAISuggestions(topic) { `Nothing beats good ${mainWord} and great company 🥂`, `${capWord} state of mind 🧠`, `Living for these ${mainWord} moments 🌟`, - `Just another day enjoying the ${mainWord} 📸` + `Just another day enjoying the ${mainWord} 📸`, ], hashtags: [ - `#${mainWord}`, `#${mainWord}vibes`, `#${mainWord}life`, - `#instadaily`, `#explore`, `#trending`, - `#${mainWord}goals`, `#foryou`, `#creator` + `#${mainWord}`, + `#${mainWord}vibes`, + `#${mainWord}life`, + `#instadaily`, + `#explore`, + `#trending`, + `#${mainWord}goals`, + `#foryou`, + `#creator`, ], songs: [ - { title: `${capWord} Dreams - The Creator`, mood: 'chill' }, - { title: `Summer ${capWord} - DJ Mix`, mood: 'upbeat' }, - { title: `Midnight ${capWord} - Lofi Boy`, mood: 'focus' }, - { title: `Golden ${capWord} - Sunset Bros`, mood: 'cinematic' }, - { title: `Pure ${capWord} - Acoustic`, mood: 'relaxing' } - ] + { title: `${capWord} Dreams - The Creator`, mood: "chill" }, + { title: `Summer ${capWord} - DJ Mix`, mood: "upbeat" }, + { title: `Midnight ${capWord} - Lofi Boy`, mood: "focus" }, + { title: `Golden ${capWord} - Sunset Bros`, mood: "cinematic" }, + { title: `Pure ${capWord} - Acoustic`, mood: "relaxing" }, + ], }; } exports.getPage = (req, res) => { // We no longer need static categories - res.render('suggestions', { categories: [], result: null, selected: null, services }); + res.render("suggestions", { + categories: [], + result: null, + selected: null, + services, + }); }; -const { suggestionSchema } = require('../middleware/validators'); +const { suggestionSchema } = require("../middleware/validators"); exports.getSuggestions = asyncHandler(async (req, res, next) => { const validationResult = suggestionSchema.safeParse(req.body); if (!validationResult.success) { - return res.render('suggestions', { categories: [], result: null, selected: null, services }); + return res.render("suggestions", { + categories: [], + result: null, + selected: null, + services, + }); } const { topic } = validationResult.data; const result = await generateAISuggestions(topic); - - res.render('suggestions', { categories: [], result, selected: topic, services }); + + res.render("suggestions", { + categories: [], + result, + selected: topic, + services, + }); }); diff --git a/controller/url.js b/controller/url.js index d3b476ee..266daf25 100644 --- a/controller/url.js +++ b/controller/url.js @@ -1,8 +1,8 @@ -const { nanoid } = require('nanoid'); -const QRCode = require('qrcode'); -const Url = require('../model/url'); -const { isValidUrl } = require('../utils/validators'); -const asyncHandler = require('../utils/asyncHandler'); +const { nanoid } = require("nanoid"); +const QRCode = require("qrcode"); +const Url = require("../model/url"); +const { isValidUrl } = require("../utils/validators"); +const asyncHandler = require("../utils/asyncHandler"); /** * @function deriveTitle @@ -10,17 +10,19 @@ const asyncHandler = require('../utils/asyncHandler'); * @returns {any} */ function deriveTitle(redirectUrl, fallback) { - if (fallback) return fallback; - try { - const parsed = new URL(redirectUrl); - const slug = parsed.pathname.split('/').filter(Boolean).pop(); - if (slug) { - return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); - } - return parsed.hostname.replace('www.', ''); - } catch (_) { - return 'Untitled Link'; + if (fallback) return fallback; + try { + const parsed = new URL(redirectUrl); + const slug = parsed.pathname.split("/").filter(Boolean).pop(); + if (slug) { + return slug + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); } + return parsed.hostname.replace("www.", ""); + } catch (_) { + return "Untitled Link"; + } } /** @@ -29,10 +31,10 @@ function deriveTitle(redirectUrl, fallback) { * @returns {any} */ function formatClicks(count) { - if (count >= 1000) { - return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k`; - } - return String(count); + if (count >= 1000) { + return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(count); } /** @@ -41,22 +43,23 @@ function formatClicks(count) { * @returns {any} */ function serializeLink(entry, hostBase) { - const linkedAt = entry.linkedAt || entry.createdAt?.[0]?.timeStamp || new Date(); - return { - shortId: entry.shortId, - redirectUrl: entry.redirectUrl, - title: entry.title || deriveTitle(entry.redirectUrl), - tag: entry.tag || 'active', - totalClicks: entry.totalClicks || 0, - clicksLabel: formatClicks(entry.totalClicks || 0), - shortUrl: `${hostBase}/u/${entry.shortId}`, - linkedAt: linkedAt, - linkedAtLabel: new Date(linkedAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }), - }; + const linkedAt = + entry.linkedAt || entry.createdAt?.[0]?.timeStamp || new Date(); + return { + shortId: entry.shortId, + redirectUrl: entry.redirectUrl, + title: entry.title || deriveTitle(entry.redirectUrl), + tag: entry.tag || "active", + totalClicks: entry.totalClicks || 0, + clicksLabel: formatClicks(entry.totalClicks || 0), + shortUrl: `${hostBase}/u/${entry.shortId}`, + linkedAt: linkedAt, + linkedAtLabel: new Date(linkedAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }), + }; } /** @@ -68,41 +71,43 @@ function serializeLink(entry, hostBase) { * @returns {Promise|void} */ async function handleGenerateShortUrl(req, res) { - const { redirectUrl, title, customSlug, tag } = req.body; - - let shortId = shortid(); - if (customSlug) { - const slug = String(customSlug).trim().toLowerCase(); - const existing = await Url.findOne({ shortId: slug }); - if (existing) { - return res.status(409).json({ error: 'That slug is already taken. Try another.' }); - } - shortId = slug; - } else { - const existing = await Url.findOne({ shortId }); - if (existing) { - shortId = shortid(); - } + const { redirectUrl, title, customSlug, tag } = req.body; + + let shortId = shortid(); + if (customSlug) { + const slug = String(customSlug).trim().toLowerCase(); + const existing = await Url.findOne({ shortId: slug }); + if (existing) { + return res + .status(409) + .json({ error: "That slug is already taken. Try another." }); } - - const allowedTags = ['active', 'social', 'campaign', 'general']; - const linkTag = allowedTags.includes(tag) ? tag : 'active'; - - const entry = await Url.create({ - shortId, - redirectUrl, - userId: req.user?.id || null, - title: deriveTitle(redirectUrl, title?.trim()), - tag: linkTag, - linkedAt: new Date(), - }); - - const hostBase = `${req.protocol}://${req.get('host')}`; - - return res.status(201).json({ - message: 'Link created successfully', - link: serializeLink(entry, hostBase), - }); + shortId = slug; + } else { + const existing = await Url.findOne({ shortId }); + if (existing) { + shortId = shortid(); + } + } + + const allowedTags = ["active", "social", "campaign", "general"]; + const linkTag = allowedTags.includes(tag) ? tag : "active"; + + const entry = await Url.create({ + shortId, + redirectUrl, + userId: req.user?.id || null, + title: deriveTitle(redirectUrl, title?.trim()), + tag: linkTag, + linkedAt: new Date(), + }); + + const hostBase = `${req.protocol}://${req.get("host")}`; + + return res.status(201).json({ + message: "Link created successfully", + link: serializeLink(entry, hostBase), + }); } /** @@ -114,32 +119,31 @@ async function handleGenerateShortUrl(req, res) { * @returns {Promise|void} */ async function handleListUserLinks(req, res) { - const hostBase = `${req.protocol}://${req.get('host')}`; - const userId = req.user?.id; - - const entries = await Url.listForUser(userId, 100); - - const links = entries.map((entry) => serializeLink(entry, hostBase)); - const totalClicks = links.reduce((sum, link) => sum + link.totalClicks, 0); - const topLink = links.reduce( - (best, link) => (link.totalClicks > (best?.totalClicks || 0) ? link : best), - null - ); - - return res.json({ - links, - stats: { - totalLinks: links.length, - totalClicks, - totalClicksLabel: formatClicks(totalClicks), - topLinkTitle: topLink?.title || '—', - topLinkClicks: topLink?.clicksLabel || '0', - }, - domain: hostBase.replace(/^https?:\/\//, ''), - }); + const hostBase = `${req.protocol}://${req.get("host")}`; + const userId = req.user?.id; + + const entries = await Url.listForUser(userId, 100); + + const links = entries.map((entry) => serializeLink(entry, hostBase)); + const totalClicks = links.reduce((sum, link) => sum + link.totalClicks, 0); + const topLink = links.reduce( + (best, link) => (link.totalClicks > (best?.totalClicks || 0) ? link : best), + null, + ); + + return res.json({ + links, + stats: { + totalLinks: links.length, + totalClicks, + totalClicksLabel: formatClicks(totalClicks), + topLinkTitle: topLink?.title || "—", + topLinkClicks: topLink?.clicksLabel || "0", + }, + domain: hostBase.replace(/^https?:\/\//, ""), + }); } - /** * Helper function to generate a Base64 Data URL for the QR code server-side. */ @@ -152,195 +156,225 @@ async function handleListUserLinks(req, res) { * @returns {Promise|void} */ const generateBase64QR = async (text, fg, bg) => { - return await QRCode.toDataURL(text, { - errorCorrectionLevel: "M", - margin: 2, - width: 300, - color: { dark: fg, light: bg } - }); + return await QRCode.toDataURL(text, { + errorCorrectionLevel: "M", + margin: 2, + width: 300, + color: { dark: fg, light: bg }, + }); }; // ── Render Home Page (Dashboard) ────────────────────────────────────────────── const handleRenderDashboard = asyncHandler(async (req, res) => { - const allUrls = await Url.find({}).sort({ createdAt: -1 }); - - return res.render("home", { - urls: allUrls, - id: null, - shortUrl: null, - qrCode: null, - campaignName: "", - error: null - }); + const allUrls = await Url.find({}).sort({ createdAt: -1 }); + + return res.render("home", { + urls: allUrls, + id: null, + shortUrl: null, + qrCode: null, + campaignName: "", + error: null, + }); }); // ── Shorten and Re-render Template ─────────────────────────────────────────── const handleGenerateShortUrlRender = asyncHandler(async (req, res) => { - const { redirectUrl, url, campaignName, qrFgColor, qrBgColor } = req.body; - const inputUrl = redirectUrl || url; - - if (!inputUrl || !isValidUrl(inputUrl)) { - const allUrls = await Url.find({}).sort({ createdAt: -1 }); - return res.status(400).render("home", { - urls: allUrls, - error: "A valid HTTP or HTTPS URL is required", - id: null, - shortUrl: null, - qrCode: null, - campaignName: "" - }); - } + const { redirectUrl, url, campaignName, qrFgColor, qrBgColor } = req.body; + const inputUrl = redirectUrl || url; - const shortId = nanoid(8); - const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get('host')}`; - const shortUrl = `${baseUrl}/u/${shortId}`; - - const fgColor = qrFgColor || "#1a1a1a"; - const bgColor = qrBgColor || "#ffffff"; - - // DB Record - await Url.create({ - shortId, - redirectUrl: inputUrl, - campaignName: campaignName || "Untitled Campaign", - qrFgColor: fgColor, - qrBgColor: bgColor, - qrGenerated: true, - }); - - const qrCodeDataUrl = await generateBase64QR(shortUrl, fgColor, bgColor); + if (!inputUrl || !isValidUrl(inputUrl)) { const allUrls = await Url.find({}).sort({ createdAt: -1 }); - - return res.render("home", { - urls: allUrls, - id: shortId, - shortUrl: shortUrl, - qrCode: qrCodeDataUrl, - campaignName: campaignName || "Untitled Campaign", - error: null + return res.status(400).render("home", { + urls: allUrls, + error: "A valid HTTP or HTTPS URL is required", + id: null, + shortUrl: null, + qrCode: null, + campaignName: "", }); + } + + const shortId = nanoid(8); + const baseUrl = + process.env.BASE_URL || `${req.protocol}://${req.get("host")}`; + const shortUrl = `${baseUrl}/u/${shortId}`; + + const fgColor = qrFgColor || "#1a1a1a"; + const bgColor = qrBgColor || "#ffffff"; + + // DB Record + await Url.create({ + shortId, + redirectUrl: inputUrl, + campaignName: campaignName || "Untitled Campaign", + qrFgColor: fgColor, + qrBgColor: bgColor, + qrGenerated: true, + }); + + const qrCodeDataUrl = await generateBase64QR(shortUrl, fgColor, bgColor); + const allUrls = await Url.find({}).sort({ createdAt: -1 }); + + return res.render("home", { + urls: allUrls, + id: shortId, + shortUrl: shortUrl, + qrCode: qrCodeDataUrl, + campaignName: campaignName || "Untitled Campaign", + error: null, + }); }); // ── Server-Generated SVG QR Code Route ─────────────────────────────────────── const handleGetQRCode = asyncHandler(async (req, res) => { - const { shortId } = req.params; - const entry = await Url.findOne({ shortId }); - - if (!entry) { - return res.status(404).json({ success: false, message: "Short URL not found", error: "Short URL not found" }); - } - - const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get('host')}`; - const shortUrl = `${baseUrl}/u/${shortId}`; - - const svgString = await QRCode.toString(shortUrl, { - type: "svg", - color: { - dark: entry.qrFgColor || "#1a1a1a", - light: entry.qrBgColor || "#ffffff", - }, - errorCorrectionLevel: "M", - margin: 2, - width: 256, - }); - - Url.findOneAndUpdate( - { shortId }, - { $set: { qrGenerated: true } } - ).catch((e) => console.error("[QR flag update error]", e)); - - res.set("Content-Type", "image/svg+xml"); - res.set("Cache-Control", "public, max-age=3600"); - return res.send(svgString); + const { shortId } = req.params; + const entry = await Url.findOne({ shortId }); + + if (!entry) { + return res + .status(404) + .json({ + success: false, + message: "Short URL not found", + error: "Short URL not found", + }); + } + + const baseUrl = + process.env.BASE_URL || `${req.protocol}://${req.get("host")}`; + const shortUrl = `${baseUrl}/u/${shortId}`; + + const svgString = await QRCode.toString(shortUrl, { + type: "svg", + color: { + dark: entry.qrFgColor || "#1a1a1a", + light: entry.qrBgColor || "#ffffff", + }, + errorCorrectionLevel: "M", + margin: 2, + width: 256, + }); + + Url.findOneAndUpdate({ shortId }, { $set: { qrGenerated: true } }).catch( + (e) => console.error("[QR flag update error]", e), + ); + + res.set("Content-Type", "image/svg+xml"); + res.set("Cache-Control", "public, max-age=3600"); + return res.send(svgString); }); // ── Server-Generated Download PNG Asset ────────────────────────────────────── const handleDownloadQRCode = asyncHandler(async (req, res) => { - const { shortId } = req.params; - const entry = await Url.findOne({ shortId }); - - if (!entry) { - return res.status(404).json({ success: false, message: "Short URL not found", error: "Short URL not found" }); - } - - const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get('host')}`; - const shortUrl = `${baseUrl}/u/${shortId}`; - - const pngBuffer = await QRCode.toBuffer(shortUrl, { - type: "png", - color: { - dark: entry.qrFgColor || "#1a1a1a", - light: entry.qrBgColor || "#ffffff", - }, - errorCorrectionLevel: "M", - margin: 2, - width: 512, - }); - - res.set("Content-Type", "image/png"); - res.set("Content-Disposition", `attachment; filename="qr-${shortId}.png"`); - res.set("Cache-Control", "public, max-age=3600"); - return res.send(pngBuffer); + const { shortId } = req.params; + const entry = await Url.findOne({ shortId }); + + if (!entry) { + return res + .status(404) + .json({ + success: false, + message: "Short URL not found", + error: "Short URL not found", + }); + } + + const baseUrl = + process.env.BASE_URL || `${req.protocol}://${req.get("host")}`; + const shortUrl = `${baseUrl}/u/${shortId}`; + + const pngBuffer = await QRCode.toBuffer(shortUrl, { + type: "png", + color: { + dark: entry.qrFgColor || "#1a1a1a", + light: entry.qrBgColor || "#ffffff", + }, + errorCorrectionLevel: "M", + margin: 2, + width: 512, + }); + + res.set("Content-Type", "image/png"); + res.set("Content-Disposition", `attachment; filename="qr-${shortId}.png"`); + res.set("Cache-Control", "public, max-age=3600"); + return res.send(pngBuffer); }); // ── Color API Endpoint Updater ──────────────────────────────────────────────── const handleUpdateQRColors = asyncHandler(async (req, res) => { - const { shortId } = req.params; - const { qrFgColor, qrBgColor } = req.body; - - const updated = await Url.findOneAndUpdate( - { shortId }, - { - $set: { - ...(qrFgColor && { qrFgColor }), - ...(qrBgColor && { qrBgColor }), - }, - }, - { new: true } - ); - - if (!updated) { - return res.status(404).json({ success: false, message: "Short URL not found", error: "Short URL not found" }); - } - - return res.json({ - success: true, - message: "QR colors updated", - qrFgColor: updated.qrFgColor, - qrBgColor: updated.qrBgColor, - }); + const { shortId } = req.params; + const { qrFgColor, qrBgColor } = req.body; + + const updated = await Url.findOneAndUpdate( + { shortId }, + { + $set: { + ...(qrFgColor && { qrFgColor }), + ...(qrBgColor && { qrBgColor }), + }, + }, + { new: true }, + ); + + if (!updated) { + return res + .status(404) + .json({ + success: false, + message: "Short URL not found", + error: "Short URL not found", + }); + } + + return res.json({ + success: true, + message: "QR colors updated", + qrFgColor: updated.qrFgColor, + qrBgColor: updated.qrBgColor, + }); }); // ── Get Click Metrics ──────────────────────────────────────────────────────── const handleGetAnalytics = asyncHandler(async (req, res) => { - const { shortId } = req.params; - const entry = await Url.findOne({ shortId }); - - if (!entry) { - return res.status(404).json({ success: false, message: "Short URL not found", error: "Short URL not found" }); - } - - const qrClicks = entry.visitHistory ? entry.visitHistory.filter((v) => v.source === "qr").length : 0; - const directClicks = entry.visitHistory ? entry.visitHistory.filter((v) => v.source === "direct").length : 0; - - return res.json({ - totalClicks: entry.totalClicks || 0, - qrClicks, - directClicks, - qrGenerated: entry.qrGenerated || false, - qrFgColor: entry.qrFgColor || "#1a1a1a", - qrBgColor: entry.qrBgColor || "#ffffff", - visitHistory: entry.visitHistory || [], - createdAt: entry.createdAt, - }); + const { shortId } = req.params; + const entry = await Url.findOne({ shortId }); + + if (!entry) { + return res + .status(404) + .json({ + success: false, + message: "Short URL not found", + error: "Short URL not found", + }); + } + + const qrClicks = entry.visitHistory + ? entry.visitHistory.filter((v) => v.source === "qr").length + : 0; + const directClicks = entry.visitHistory + ? entry.visitHistory.filter((v) => v.source === "direct").length + : 0; + + return res.json({ + totalClicks: entry.totalClicks || 0, + qrClicks, + directClicks, + qrGenerated: entry.qrGenerated || false, + qrFgColor: entry.qrFgColor || "#1a1a1a", + qrBgColor: entry.qrBgColor || "#ffffff", + visitHistory: entry.visitHistory || [], + createdAt: entry.createdAt, + }); }); module.exports = { - handleRenderDashboard, - handleGenerateShortUrl, - handleGetQRCode, - handleDownloadQRCode, - handleUpdateQRColors, - handleGetAnalytics, - handleListUserLinks, + handleRenderDashboard, + handleGenerateShortUrl, + handleGetQRCode, + handleDownloadQRCode, + handleUpdateQRColors, + handleGetAnalytics, + handleListUserLinks, }; diff --git a/docs/EMAIL_VERIFICATION.md b/docs/EMAIL_VERIFICATION.md index b4545675..33dbc350 100644 --- a/docs/EMAIL_VERIFICATION.md +++ b/docs/EMAIL_VERIFICATION.md @@ -6,7 +6,7 @@ This document describes the email verification feature implemented for CreatorOS **Status**: ✅ Fully Implemented **Version**: 1.0 -**Last Updated**: 2024 +**Last Updated**: 2024 --- @@ -116,13 +116,14 @@ Three new fields added to the User schema: **Field Descriptions:** -| Field | Type | Default | Purpose | -|-------|------|---------|---------| -| `isVerified` | Boolean | `false` | Whether user has verified their email | -| `verificationToken` | String | `null` | Unique verification token (hex-encoded 32 bytes) | -| `verificationTokenExpiry` | Date | `null` | When verification token expires (24 hours) | +| Field | Type | Default | Purpose | +| ------------------------- | ------- | ------- | ------------------------------------------------ | +| `isVerified` | Boolean | `false` | Whether user has verified their email | +| `verificationToken` | String | `null` | Unique verification token (hex-encoded 32 bytes) | +| `verificationTokenExpiry` | Date | `null` | When verification token expires (24 hours) | **Indexes:** + - `verificationToken`: Unique index (sparse) for fast lookup - `verificationTokenExpiry`: Index for cleanup queries @@ -137,6 +138,7 @@ Three new fields added to the User schema: Creates a new unverified user account and sends verification email. **Request:** + ```json { "name": "John Doe", @@ -146,6 +148,7 @@ Creates a new unverified user account and sends verification email. ``` **Response (Success):** + ```json { "success": true, @@ -158,11 +161,13 @@ Creates a new unverified user account and sends verification email. ``` **Response (HTML):** + ```html ``` **Status Codes:** + - `201`: User created successfully - `409`: Email already registered - `400`: Validation error @@ -176,14 +181,17 @@ Creates a new unverified user account and sends verification email. Validates verification token and marks user as verified. **Query Parameters:** + - `token` (required): Verification token from email link **Request:** + ``` POST /verify-email?token=a1b2c3d4e5f6... ``` **Response (Success):** + ```json { "success": true, @@ -192,20 +200,23 @@ POST /verify-email?token=a1b2c3d4e5f6... ``` **Response (HTML):** + ```html ``` **Status Codes:** + - `200`: Email verified successfully - `400`: Invalid or missing token - `410`: Token has expired **Errors:** -| Status | Message | Action | -|--------|---------|--------| -| 400 | Invalid verification link | Request resend from /resend-verification | -| 410 | Link expired | Resend verification email | + +| Status | Message | Action | +| ------ | ------------------------- | ---------------------------------------- | +| 400 | Invalid verification link | Request resend from /resend-verification | +| 410 | Link expired | Resend verification email | --- @@ -226,6 +237,7 @@ Displays the email verification confirmation page. Generates new verification token and resends email. **Request:** + ```json { "email": "john@example.com" @@ -233,6 +245,7 @@ Generates new verification token and resends email. ``` **Response (Success):** + ```json { "success": true, @@ -241,11 +254,13 @@ Generates new verification token and resends email. ``` **Response (HTML):** + ```html ``` **Status Codes:** + - `200`: Email sent (or generic success even if user doesn't exist) - `400`: Email field missing - `500`: Email service error @@ -261,6 +276,7 @@ Generates new verification token and resends email. Authenticates user - now includes verification check. **Request:** + ```json { "email": "john@example.com", @@ -269,6 +285,7 @@ Authenticates user - now includes verification check. ``` **Response (Unverified):** + ```json { "success": false, @@ -278,6 +295,7 @@ Authenticates user - now includes verification check. ``` **Status Codes:** + - `200`: Login successful - `403`: Email not verified - `401`: Invalid credentials @@ -388,9 +406,10 @@ Show: "Verification email sent!" ### Token Generation ✅ **Secure Token Generation** + ```javascript function generateVerificationToken() { - return crypto.randomBytes(32).toString("hex"); + return crypto.randomBytes(32).toString("hex"); } ``` @@ -402,6 +421,7 @@ function generateVerificationToken() { ### Token Storage ✅ **Secure Storage** + - Stored in plaintext in database (acceptable - tokens are one-time use) - Indexed for fast lookup - Deleted immediately after verification @@ -410,9 +430,10 @@ function generateVerificationToken() { ### Token Expiry ✅ **Time-Limited Tokens** + ```javascript function getVerificationTokenExpiry() { - return new Date(Date.now() + 24 * 60 * 60 * 1000); + return new Date(Date.now() + 24 * 60 * 60 * 1000); } ``` @@ -423,6 +444,7 @@ function getVerificationTokenExpiry() { ### Email Privacy ✅ **No User Enumeration** + - Resend endpoint returns success for unknown emails - Generic message: "If that email is in our system..." - Prevents attackers from discovering registered email addresses @@ -430,9 +452,10 @@ function getVerificationTokenExpiry() { ### Protected Routes ✅ **Verification Check in Middleware** + ```javascript if (!user.isVerified) { - return res.status(403).redirect("/resend-verification"); + return res.status(403).redirect("/resend-verification"); } ``` @@ -443,6 +466,7 @@ if (!user.isVerified) { ### Google OAuth Auto-Verification ✅ **Automatic Verification for OAuth Users** + - Google OAuth users marked as verified: `isVerified: true` - No token required - Rationale: OAuth provider verified the email @@ -450,6 +474,7 @@ if (!user.isVerified) { ### Email Validation ✅ **Input Validation** + - Email format validated with Zod schema - Email normalized (lowercase, trimmed) - Prevents injection and manipulation @@ -457,6 +482,7 @@ if (!user.isVerified) { ### No Data Leakage ✅ **Secure Error Handling** + - Generic error messages in responses - No stack traces exposed - Logging configured appropriately @@ -517,7 +543,7 @@ function createTransporter() { if (EMAIL_HOST) { transporterOptions.host = EMAIL_HOST; transporterOptions.port = Number(EMAIL_PORT); - transporterOptions.secure = EMAIL_SECURE === 'true'; + transporterOptions.secure = EMAIL_SECURE === "true"; } return nodemailer.createTransport(transporterOptions); @@ -541,6 +567,7 @@ Verification email includes: ### Email Service Providers Tested with: + - ✅ Gmail (with App Passwords) - ✅ SendGrid - ✅ Mailgun @@ -550,6 +577,7 @@ Tested with: ### Troubleshooting Email Issues **Emails not sending:** + 1. Verify credentials in `.env` 2. Check email service logs 3. Verify firewall/network allows SMTP @@ -616,18 +644,18 @@ npm test ### File Changes Summary -| File | Changes | -|------|---------| -| `model/user.js` | Added isVerified, verificationToken, verificationTokenExpiry fields | -| `controller/auth.js` | Modified signup, login; added verifyEmail, resendVerificationEmail | -| `routes/auth.js` | Added /verify-email, /resend-verification routes | -| `middleware/auth.js` | Added isVerified check in protect middleware | -| `utils/email.js` | Added sendVerificationEmail function | -| `view/signup.ejs` | Added success message display | -| `view/login.ejs` | Added unverified message display | -| `view/verify-email.ejs` | **NEW** - Verification confirmation page | -| `view/resend-verification.ejs` | **NEW** - Resend verification form | -| `tests/email-verification.test.js` | **NEW** - Comprehensive test suite | +| File | Changes | +| ---------------------------------- | ------------------------------------------------------------------- | +| `model/user.js` | Added isVerified, verificationToken, verificationTokenExpiry fields | +| `controller/auth.js` | Modified signup, login; added verifyEmail, resendVerificationEmail | +| `routes/auth.js` | Added /verify-email, /resend-verification routes | +| `middleware/auth.js` | Added isVerified check in protect middleware | +| `utils/email.js` | Added sendVerificationEmail function | +| `view/signup.ejs` | Added success message display | +| `view/login.ejs` | Added unverified message display | +| `view/verify-email.ejs` | **NEW** - Verification confirmation page | +| `view/resend-verification.ejs` | **NEW** - Resend verification form | +| `tests/email-verification.test.js` | **NEW** - Comprehensive test suite | ### Key Constants @@ -650,6 +678,7 @@ const tokenLength = 32; // bytes **Symptoms:** Signup succeeds but no email received **Solutions:** + - [ ] Verify EMAIL_USER and EMAIL_PASSWORD in .env - [ ] Check email service is configured correctly - [ ] Verify APP_URL is set correctly @@ -662,6 +691,7 @@ const tokenLength = 32; // bytes **Symptoms:** Fresh email address returns duplicate error **Solutions:** + - [ ] Check if email already in database - [ ] Verify email normalization (lowercase) - [ ] Check for duplicate indexes @@ -672,6 +702,7 @@ const tokenLength = 32; // bytes **Symptoms:** Token valid for less than 24 hours **Solutions:** + - [ ] Verify server time is correct - [ ] Check VERIFICATION_TOKEN_EXPIRY_MS constant - [ ] Verify database stores correct expiry time @@ -682,6 +713,7 @@ const tokenLength = 32; // bytes **Symptoms:** User can login despite isVerified=false **Solutions:** + - [ ] Verify protect middleware is applied - [ ] Check isVerified check in login controller - [ ] Verify middleware executes before login handler @@ -692,6 +724,7 @@ const tokenLength = 32; // bytes **Symptoms:** Verification fails immediately with "Invalid token" **Solutions:** + - [ ] Verify token is correctly stored in database - [ ] Check token in email matches database - [ ] Verify query parameter name (should be ?token=) @@ -718,19 +751,19 @@ const tokenLength = 32; // bytes Consider adding a cron job to clean up expired tokens: ```javascript -const cron = require('node-cron'); +const cron = require("node-cron"); // Daily cleanup at 2 AM -cron.schedule('0 2 * * *', async () => { - await User.updateMany( - { verificationTokenExpiry: { $lt: new Date() } }, - { - $set: { - verificationToken: null, - verificationTokenExpiry: null - } - } - ); +cron.schedule("0 2 * * *", async () => { + await User.updateMany( + { verificationTokenExpiry: { $lt: new Date() } }, + { + $set: { + verificationToken: null, + verificationTokenExpiry: null, + }, + }, + ); }); ``` diff --git a/docs/analytics.md b/docs/analytics.md index f24130fc..c60ad8a0 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -1,6 +1,7 @@ # Analytics Data Storage & Refresh System ## Overview + This module stores creator analytics history and periodically refreshes metrics using a scheduled worker. --- @@ -8,60 +9,64 @@ This module stores creator analytics history and periodically refreshes metrics ## Database Schema ### `creators` + Stores creator profile information linked to a user. -| Field | Type | Description | -|---|---|---| -| userId | ObjectId | Reference to User | -| username | String | Creator's username | -| platform | String | instagram / youtube / twitter / tiktok | -| profileUrl | String | Profile URL | -| avatar | String | Avatar URL | -| lastRefreshedAt | Date | Last time analytics were refreshed | +| Field | Type | Description | +| --------------- | -------- | -------------------------------------- | +| userId | ObjectId | Reference to User | +| username | String | Creator's username | +| platform | String | instagram / youtube / twitter / tiktok | +| profileUrl | String | Profile URL | +| avatar | String | Avatar URL | +| lastRefreshedAt | Date | Last time analytics were refreshed | ### `analytics_snapshots` + Stores periodic snapshots of a creator's analytics. -| Field | Type | Description | -|---|---|---| -| creatorId | ObjectId | Reference to Creator | -| platform | String | Platform name | -| followers | Number | Follower count | -| following | Number | Following count | -| totalPosts | Number | Total posts | -| totalLikes | Number | Total likes | -| totalComments | Number | Total comments | -| totalViews | Number | Total views | -| engagementRate | Number | Engagement rate % | -| snapshotDate | Date | When snapshot was taken | +| Field | Type | Description | +| -------------- | -------- | ----------------------- | +| creatorId | ObjectId | Reference to Creator | +| platform | String | Platform name | +| followers | Number | Follower count | +| following | Number | Following count | +| totalPosts | Number | Total posts | +| totalLikes | Number | Total likes | +| totalComments | Number | Total comments | +| totalViews | Number | Total views | +| engagementRate | Number | Engagement rate % | +| snapshotDate | Date | When snapshot was taken | ### `posts` + Stores individual post data for a creator. -| Field | Type | Description | -|---|---|---| -| creatorId | ObjectId | Reference to Creator | -| platform | String | Platform name | -| postId | String | Platform post ID | -| caption | String | Post caption | -| mediaUrl | String | Media URL | -| likes | Number | Like count | -| comments | Number | Comment count | -| views | Number | View count | -| postedAt | Date | When post was published | +| Field | Type | Description | +| --------- | -------- | ----------------------- | +| creatorId | ObjectId | Reference to Creator | +| platform | String | Platform name | +| postId | String | Platform post ID | +| caption | String | Post caption | +| mediaUrl | String | Media URL | +| likes | Number | Like count | +| comments | Number | Comment count | +| views | Number | View count | +| postedAt | Date | When post was published | ### `engagement_history` + Tracks growth deltas between snapshots for trend analysis. -| Field | Type | Description | -|---|---|---| -| creatorId | ObjectId | Reference to Creator | -| snapshotId | ObjectId | Reference to AnalyticsSnapshot | -| date | Date | Date of record | -| followersGrowth | Number | Change in followers | -| likesGrowth | Number | Change in likes | -| commentsGrowth | Number | Change in comments | -| engagementRateDelta | Number | Change in engagement rate | +| Field | Type | Description | +| ------------------- | -------- | ------------------------------ | +| creatorId | ObjectId | Reference to Creator | +| snapshotId | ObjectId | Reference to AnalyticsSnapshot | +| date | Date | Date of record | +| followersGrowth | Number | Change in followers | +| likesGrowth | Number | Change in likes | +| commentsGrowth | Number | Change in comments | +| engagementRateDelta | Number | Change in engagement rate | --- @@ -70,32 +75,41 @@ Tracks growth deltas between snapshots for trend analysis. All endpoints are protected and require authentication. ### Get All Snapshots + ``` GET /api/analytics/:creatorId/snapshots ``` + Returns all analytics snapshots for a creator, newest first. ### Get Latest Snapshot + ``` GET /api/analytics/:creatorId/snapshots/latest ``` + Returns the most recent analytics snapshot for a creator. ### Get Engagement History + ``` GET /api/analytics/:creatorId/engagement-history ``` + Returns engagement growth history for a creator. ### Trigger Manual Refresh + ``` POST /api/analytics/:creatorId/refresh ``` + Manually triggers an analytics refresh for a creator. --- ## Refresh Worker + The worker runs automatically every 6 hours using `node-cron`. - Fetches all creators from the database @@ -105,4 +119,4 @@ The worker runs automatically every 6 hours using `node-cron`. **Schedule:** `0 */6 * * *` (every 6 hours) -**Note:** Currently uses mock data. Replace the mock fetch in `workers/analyticsRefreshWorker.js` with real platform API calls (e.g. Instagram Graph API) when available. \ No newline at end of file +**Note:** Currently uses mock data. Replace the mock fetch in `workers/analyticsRefreshWorker.js` with real platform API calls (e.g. Instagram Graph API) when available. diff --git a/index.js b/index.js index 62469bca..bda08bc2 100644 --- a/index.js +++ b/index.js @@ -1,72 +1,79 @@ require("dotenv").config({ path: ".env.local" }); const cookieParser = require("cookie-parser"); -const express = require('express'); +const express = require("express"); const passport = require("passport"); -const path = require('path'); +const path = require("path"); // Validate required environment variables const requiredEnvVars = [ - { name: 'MONGODB_URI', description: 'MongoDB connection string' }, - { name: 'JWT_SECRET', description: 'Secret key for JWT token signing' }, + { name: "MONGODB_URI", description: "MongoDB connection string" }, + { name: "JWT_SECRET", description: "Secret key for JWT token signing" }, ]; const missingVars = requiredEnvVars.filter((v) => !process.env[v.name]); if (missingVars.length > 0) { - console.warn('\n⚠️ Missing environment variables for full production mode:'); - missingVars.forEach((v) => { - console.warn(` - ${v.name} (${v.description})`); - }); - console.warn('\n📋 The app will start in local mock mode.'); - console.warn(' To use a real database, copy .env.example to .env.local and fill in the values.\n'); + console.warn("\n⚠️ Missing environment variables for full production mode:"); + missingVars.forEach((v) => { + console.warn(` - ${v.name} (${v.description})`); + }); + console.warn("\n📋 The app will start in local mock mode."); + console.warn( + " To use a real database, copy .env.example to .env.local and fill in the values.\n", + ); } const app = express(); const connectDB = require("./connect"); const authRoutes = require("./routes/auth"); -const collaborationRoutes = require('./routes/collaboration'); +const collaborationRoutes = require("./routes/collaboration"); const analyticsRoutes = require("./routes/analytics"); -const instagramRoutes = require('./routes/instagram'); -const { acceptInvite, acceptInviteFromDashboard } = require('./controller/collaborationController'); +const instagramRoutes = require("./routes/instagram"); +const { + acceptInvite, + acceptInviteFromDashboard, +} = require("./controller/collaborationController"); connectDB(); require("./workers/analyticsRefreshWorker"); -const { generateCsrf, verifyCsrf } = require('./middleware/csrf'); +const { generateCsrf, verifyCsrf } = require("./middleware/csrf"); app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); -app.use(express.json({ +app.use( + express.json({ verify: (req, res, buf) => { - req.rawBody = buf; - } -})); + req.rawBody = buf; + }, + }), +); app.use(generateCsrf); app.use(verifyCsrf); app.use(passport.initialize()); app.set("view engine", "ejs"); -app.set('views', path.join(__dirname, 'view')); +app.set("views", path.join(__dirname, "view")); -const rateLimit = require('express-rate-limit'); +const rateLimit = require("express-rate-limit"); const loginLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 15, - message: 'Too many login attempts, please try again later.' + windowMs: 15 * 60 * 1000, + max: 15, + message: "Too many login attempts, please try again later.", }); -app.post('/login', loginLimiter); +app.post("/login", loginLimiter); const uploadLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, - max: 10, - message: { error: 'Upload limit reached, please try again later.' } + windowMs: 60 * 60 * 1000, + max: 10, + message: { error: "Upload limit reached, please try again later." }, }); const urlShortenerLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, - max: 30, - message: 'Too many URLs generated, please try again later.' + windowMs: 60 * 60 * 1000, + max: 30, + message: "Too many URLs generated, please try again later.", }); app.use("/", authRoutes); @@ -74,523 +81,752 @@ app.use("/", authRoutes); const protect = require("./middleware/auth"); const { preventContributorWrites } = require("./middleware/auth"); -const fs = require('fs'); -app.use(express.static(path.join(__dirname, 'public'))); -const shortid = require('shortid'); -const multer = require('multer'); -const services = require('./services.config'); -const User = require('./model/user'); -const Invite = require('./model/invite'); +const fs = require("fs"); +app.use(express.static(path.join(__dirname, "public"))); +const shortid = require("shortid"); +const multer = require("multer"); +const services = require("./services.config"); +const User = require("./model/user"); +const Invite = require("./model/invite"); const port = process.env.PORT || 3000; -const urlRoutes = require('./routes/url'); -const asyncHandler = require('./utils/asyncHandler'); - -const suggestionRoutes = require('./routes/suggestionRoutes'); -const { getDashboardData } = require('./utils/dashboardHelper'); - -app.use('/suggestions', protect, suggestionRoutes); -app.use('/services/creator-crm', protect, collaborationRoutes); -app.post('/dashboard/accept-invite', protect, preventContributorWrites, acceptInviteFromDashboard); -app.get('/invites/accept/:token', acceptInvite); -app.get('/services/bio-builder', (req, res) => { - res.render('bio-builder'); +const urlRoutes = require("./routes/url"); +const asyncHandler = require("./utils/asyncHandler"); + +const suggestionRoutes = require("./routes/suggestionRoutes"); +const { getDashboardData } = require("./utils/dashboardHelper"); + +app.use("/suggestions", protect, suggestionRoutes); +app.use("/services/creator-crm", protect, collaborationRoutes); +app.post( + "/dashboard/accept-invite", + protect, + preventContributorWrites, + acceptInviteFromDashboard, +); +app.get("/invites/accept/:token", acceptInvite); +app.get("/services/bio-builder", (req, res) => { + res.render("bio-builder"); }); +const Url = require("./model/url"); -const Url = require('./model/url'); - -app.use('/api/urls', urlRoutes); +app.use("/api/urls", urlRoutes); // API Documentation -const swaggerUi = require('swagger-ui-express'); -const swaggerSpec = require('./utils/swaggerOptions'); -app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { customCssUrl: 'https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.0.0/swagger-ui.min.css' })); +const swaggerUi = require("swagger-ui-express"); +const swaggerSpec = require("./utils/swaggerOptions"); +app.use( + "/api-docs", + swaggerUi.serve, + swaggerUi.setup(swaggerSpec, { + customCssUrl: + "https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.0.0/swagger-ui.min.css", + }), +); app.use("/api/analytics", protect, analyticsRoutes); -app.use('/api/instagram', instagramRoutes); +app.use("/api/instagram", instagramRoutes); -const settingsRoutes = require('./routes/settings'); -app.use('/api/settings', protect, settingsRoutes); +const settingsRoutes = require("./routes/settings"); +app.use("/api/settings", protect, settingsRoutes); const uploadDir = "/tmp"; const storage = multer.diskStorage({ - destination: function (req, file, cb) { cb(null, "/tmp"); }, - filename: function (req, file, cb) { - let sanitizedFilename = path.basename(file.originalname); - sanitizedFilename = sanitizedFilename.replace(/[/\\?%*:|"<>]/g, '-').replace(/^\.+/, ''); - cb(null, Date.now() + '-' + sanitizedFilename); - } + destination: function (req, file, cb) { + cb(null, "/tmp"); + }, + filename: function (req, file, cb) { + let sanitizedFilename = path.basename(file.originalname); + sanitizedFilename = sanitizedFilename + .replace(/[/\\?%*:|"<>]/g, "-") + .replace(/^\.+/, ""); + cb(null, Date.now() + "-" + sanitizedFilename); + }, +}); +const upload = multer({ + storage: storage, + limits: { fileSize: 50 * 1024 * 1024 }, }); -const upload = multer({ storage: storage, limits: { fileSize: 50 * 1024 * 1024 } }); // ── HELPERS ────────────────────────────────────────────────────────────────── function findServiceByKey(key) { - return services.find((service) => service.key === key); + return services.find((service) => service.key === key); } function buildShortenerViewModel(req, shortId = null, error = null) { - return { - service: findServiceByKey('url-shortener'), - services, - shortUrl: shortId ? `${req.protocol}://${req.get('host')}/u/${shortId}` : null, - error, - user: buildAccountViewModel(null, req.user) - }; + return { + service: findServiceByKey("url-shortener"), + services, + shortUrl: shortId + ? `${req.protocol}://${req.get("host")}/u/${shortId}` + : null, + error, + user: buildAccountViewModel(null, req.user), + }; } function buildAccountViewModel(userDoc, fallbackUser) { - const name = userDoc?.name || fallbackUser?.name || 'Creator'; - const initials = name - .split(' ') - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0].toUpperCase()) - .join('') || 'CR'; - - const passwordChangedAt = userDoc?.passwordChangedAt || userDoc?.updatedAt || null; - let passwordAgeDays = null; - if (passwordChangedAt) { - passwordAgeDays = Math.max( - 0, - Math.floor((Date.now() - new Date(passwordChangedAt).getTime()) / (1000 * 60 * 60 * 24)) - ); - } - - const sub = userDoc?.subscription || {}; - const nextInvoice = sub.nextInvoiceDate - ? new Date(sub.nextInvoiceDate) - : (() => { - const d = new Date(); - d.setMonth(d.getMonth() + 1); - d.setDate(24); - return d; - })(); - - return { - id: fallbackUser.id, - name, - email: userDoc?.email || fallbackUser?.email || '', - alias: userDoc?.alias || '', - bio: userDoc?.bio || '', - twoFactorEnabled: userDoc?.twoFactorEnabled || false, - preferences: userDoc?.preferences || { - appearanceMode: 'light', - interfaceDensity: 'tactile', - motionEffects: true, - soundCues: false, - autoSaveLinks: true + const name = userDoc?.name || fallbackUser?.name || "Creator"; + const initials = + name + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0].toUpperCase()) + .join("") || "CR"; + + const passwordChangedAt = + userDoc?.passwordChangedAt || userDoc?.updatedAt || null; + let passwordAgeDays = null; + if (passwordChangedAt) { + passwordAgeDays = Math.max( + 0, + Math.floor( + (Date.now() - new Date(passwordChangedAt).getTime()) / + (1000 * 60 * 60 * 24), + ), + ); + } + + const sub = userDoc?.subscription || {}; + const nextInvoice = sub.nextInvoiceDate + ? new Date(sub.nextInvoiceDate) + : (() => { + const d = new Date(); + d.setMonth(d.getMonth() + 1); + d.setDate(24); + return d; + })(); + + return { + id: fallbackUser.id, + name, + email: userDoc?.email || fallbackUser?.email || "", + alias: userDoc?.alias || "", + bio: userDoc?.bio || "", + twoFactorEnabled: userDoc?.twoFactorEnabled || false, + preferences: userDoc?.preferences || { + appearanceMode: "light", + interfaceDensity: "tactile", + motionEffects: true, + soundCues: false, + autoSaveLinks: true, + }, + passwordAgeDays, + billing: { + planName: sub.planName || "Pro Individual", + priceMonthly: sub.priceMonthly ?? 29, + nextInvoiceLabel: nextInvoice.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }), + estimatedTotal: `$${(sub.priceMonthly ?? 29).toFixed(2)} USD`, + cardBrand: sub.cardBrand || "VISA", + cardLast4: sub.cardLast4 || "4242", + invoices: [ + { + date: "Sep 24, 2023", + invoiceId: "#INV-88219", + amount: "$29.00", + status: "PAID", }, - passwordAgeDays, - billing: { - planName: sub.planName || 'Pro Individual', - priceMonthly: sub.priceMonthly ?? 29, - nextInvoiceLabel: nextInvoice.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }), - estimatedTotal: `$${(sub.priceMonthly ?? 29).toFixed(2)} USD`, - cardBrand: sub.cardBrand || 'VISA', - cardLast4: sub.cardLast4 || '4242', - invoices: [ - { date: 'Sep 24, 2023', invoiceId: '#INV-88219', amount: '$29.00', status: 'PAID' }, - { date: 'Aug 24, 2023', invoiceId: '#INV-87112', amount: '$29.00', status: 'PAID' }, - ], + { + date: "Aug 24, 2023", + invoiceId: "#INV-87112", + amount: "$29.00", + status: "PAID", }, - initials, - }; + ], + }, + initials, + }; } function buildAnalyticsViewModel() { - return { - isLoading: false, - isEmpty: false, - selectedRange: 'Last 30 days', - lastUpdated: '25 May 2026, 5:30 PM', - profile: { - name: 'Aarav Studio', - handle: '@aaravstudio', - category: 'Digital creator', - bio: 'Short-form creator sharing design systems, creator workflows, and behind-the-scenes builds.', - avatarInitials: 'AS', - followers: '128.4K', - following: '642', - totalPosts: '318', - growthLabel: '+8.6%', - }, - metrics: [ - { label: 'Followers', value: '128.4K', change: '+8.6%', tone: 'cyan' }, - { label: 'Engagement rate', value: '6.82%', change: '+1.2%', tone: 'green' }, - { label: 'Avg. likes', value: '8.7K', change: '+940', tone: 'blue' }, - { label: 'Avg. comments', value: '412', change: '+38', tone: 'orange' }, - { label: 'Posting frequency', value: '5.4/wk', change: 'Consistent', tone: 'violet' }, - { label: 'Best post', value: '14.9%', change: 'Engagement', tone: 'pink' }, - ], - charts: { - labels: ['Apr 26', 'May 1', 'May 6', 'May 11', 'May 16', 'May 21', 'May 25'], - followers: [113200, 115400, 118900, 120500, 123300, 126100, 128400], - engagement: [5.4, 5.9, 6.1, 5.7, 6.4, 6.6, 6.82], - posts: ['Launch reel', 'Carousel tips', 'Studio vlog', 'Template drop', 'AMA clip'], - postPerformance: [14900, 12100, 9800, 8700, 7600], - }, - topPosts: [ - { title: 'How I plan 30 days of content', type: 'Reel', likes: '14.2K', comments: '612', engagement: '14.9%', date: '24 May' }, - { title: 'Creator OS desk setup walkthrough', type: 'Carousel', likes: '11.8K', comments: '488', engagement: '12.4%', date: '22 May' }, - { title: '5 hooks that increased watch time', type: 'Reel', likes: '9.6K', comments: '371', engagement: '10.1%', date: '19 May' }, - { title: 'Behind the scenes: newsletter build', type: 'Post', likes: '7.4K', comments: '284', engagement: '8.7%', date: '16 May' }, - ], - timeline: [ - { title: 'Top post detected', detail: 'Planning reel crossed 14.9% engagement.', time: 'Today, 4:20 PM' }, - { title: 'Audience growth spike', detail: 'Followers increased by 2.3K over the last 48 hours.', time: 'Today, 11:10 AM' }, - { title: 'Weekly consistency check', detail: 'Posting cadence stayed above 5 posts per week.', time: 'Yesterday' }, - { title: 'Profile snapshot saved', detail: 'Mock analytics snapshot prepared for dashboard UI.', time: '24 May' }, - ], - }; + return { + isLoading: false, + isEmpty: false, + selectedRange: "Last 30 days", + lastUpdated: "25 May 2026, 5:30 PM", + profile: { + name: "Aarav Studio", + handle: "@aaravstudio", + category: "Digital creator", + bio: "Short-form creator sharing design systems, creator workflows, and behind-the-scenes builds.", + avatarInitials: "AS", + followers: "128.4K", + following: "642", + totalPosts: "318", + growthLabel: "+8.6%", + }, + metrics: [ + { label: "Followers", value: "128.4K", change: "+8.6%", tone: "cyan" }, + { + label: "Engagement rate", + value: "6.82%", + change: "+1.2%", + tone: "green", + }, + { label: "Avg. likes", value: "8.7K", change: "+940", tone: "blue" }, + { label: "Avg. comments", value: "412", change: "+38", tone: "orange" }, + { + label: "Posting frequency", + value: "5.4/wk", + change: "Consistent", + tone: "violet", + }, + { + label: "Best post", + value: "14.9%", + change: "Engagement", + tone: "pink", + }, + ], + charts: { + labels: [ + "Apr 26", + "May 1", + "May 6", + "May 11", + "May 16", + "May 21", + "May 25", + ], + followers: [113200, 115400, 118900, 120500, 123300, 126100, 128400], + engagement: [5.4, 5.9, 6.1, 5.7, 6.4, 6.6, 6.82], + posts: [ + "Launch reel", + "Carousel tips", + "Studio vlog", + "Template drop", + "AMA clip", + ], + postPerformance: [14900, 12100, 9800, 8700, 7600], + }, + topPosts: [ + { + title: "How I plan 30 days of content", + type: "Reel", + likes: "14.2K", + comments: "612", + engagement: "14.9%", + date: "24 May", + }, + { + title: "Creator OS desk setup walkthrough", + type: "Carousel", + likes: "11.8K", + comments: "488", + engagement: "12.4%", + date: "22 May", + }, + { + title: "5 hooks that increased watch time", + type: "Reel", + likes: "9.6K", + comments: "371", + engagement: "10.1%", + date: "19 May", + }, + { + title: "Behind the scenes: newsletter build", + type: "Post", + likes: "7.4K", + comments: "284", + engagement: "8.7%", + date: "16 May", + }, + ], + timeline: [ + { + title: "Top post detected", + detail: "Planning reel crossed 14.9% engagement.", + time: "Today, 4:20 PM", + }, + { + title: "Audience growth spike", + detail: "Followers increased by 2.3K over the last 48 hours.", + time: "Today, 11:10 AM", + }, + { + title: "Weekly consistency check", + detail: "Posting cadence stayed above 5 posts per week.", + time: "Yesterday", + }, + { + title: "Profile snapshot saved", + detail: "Mock analytics snapshot prepared for dashboard UI.", + time: "24 May", + }, + ], + }; } function isGuestContributor(user) { - return user?.role === 'guest_contributor'; + return user?.role === "guest_contributor"; } function buildEmptyInviteSummary() { - return { total: 0, pending: 0, accepted: 0, expired: 0 }; + return { total: 0, pending: 0, accepted: 0, expired: 0 }; } // ── ROUTES ─────────────────────────────────────────────────────────────────── // Home / services hub -app.get('/', (req, res) => { - res.render('services-hub', { services }); +app.get("/", (req, res) => { + res.render("services-hub", { services }); }); -app.get('/services', (req, res) => { - res.redirect('/'); +app.get("/services", (req, res) => { + res.redirect("/"); }); // Dashboard -app.get("/dashboard", protect, asyncHandler(async (req, res) => { +app.get( + "/dashboard", + protect, + asyncHandler(async (req, res) => { const userDoc = isGuestContributor(req.user) - ? null - : await User.findById(req.user.id) - .select('name email alias bio twoFactorEnabled preferences passwordChangedAt updatedAt subscription') - .lean(); + ? null + : await User.findById(req.user.id) + .select( + "name email alias bio twoFactorEnabled preferences passwordChangedAt updatedAt subscription", + ) + .lean(); const inviteSummary = isGuestContributor(req.user) - ? buildEmptyInviteSummary() - : await Promise.all([ - Invite.countDocuments({ inviter: req.user.id, status: 'pending' }), - Invite.countDocuments({ inviter: req.user.id, status: 'accepted' }), - Invite.countDocuments({ inviter: req.user.id, status: 'expired' }) + ? buildEmptyInviteSummary() + : await Promise.all([ + Invite.countDocuments({ inviter: req.user.id, status: "pending" }), + Invite.countDocuments({ inviter: req.user.id, status: "accepted" }), + Invite.countDocuments({ inviter: req.user.id, status: "expired" }), ]).then(([pending, accepted, expired]) => ({ - total: pending + accepted + expired, - pending, - accepted, - expired, + total: pending + accepted + expired, + pending, + accepted, + expired, })); const dashboardData = await getDashboardData(userDoc); res.render("dashboard", { - user: buildAccountViewModel(userDoc, req.user), - services, - inviteSummary, - dashboardData, - inviteAcceptMessage: null, - inviteAcceptError: null, + user: buildAccountViewModel(userDoc, req.user), + services, + inviteSummary, + dashboardData, + inviteAcceptMessage: null, + inviteAcceptError: null, }); -})); + }), +); // Profile -app.get("/profile", protect, asyncHandler(async (req, res) => { +app.get( + "/profile", + protect, + asyncHandler(async (req, res) => { const userDoc = isGuestContributor(req.user) - ? null - : await User.findById(req.user.id).select('name email').lean(); + ? null + : await User.findById(req.user.id).select("name email").lean(); res.render("profile", { user: buildAccountViewModel(userDoc, req.user) }); -})); + }), +); // Settings -app.get('/settings', protect, asyncHandler(async (req, res) => { +app.get( + "/settings", + protect, + asyncHandler(async (req, res) => { const userDoc = isGuestContributor(req.user) - ? null - : await User.findById(req.user.id) - .select('name email alias bio twoFactorEnabled preferences passwordChangedAt updatedAt subscription') - .lean(); - - res.render('settings', { - services, - user: buildAccountViewModel(userDoc, req.user), - isGuestContributor: isGuestContributor(req.user), + ? null + : await User.findById(req.user.id) + .select( + "name email alias bio twoFactorEnabled preferences passwordChangedAt updatedAt subscription", + ) + .lean(); + + res.render("settings", { + services, + user: buildAccountViewModel(userDoc, req.user), + isGuestContributor: isGuestContributor(req.user), }); -})); + }), +); // My Links -app.get('/my-links', protect, asyncHandler(async (req, res) => { +app.get( + "/my-links", + protect, + asyncHandler(async (req, res) => { const userDoc = isGuestContributor(req.user) - ? null - : await User.findById(req.user.id) - .select('name email alias bio twoFactorEnabled preferences passwordChangedAt updatedAt subscription') - .lean(); - - res.render('my-links', { - services, - user: buildAccountViewModel(userDoc, req.user), - isGuestContributor: isGuestContributor(req.user), - activeNav: 'my-links', - domain: req.get('host'), + ? null + : await User.findById(req.user.id) + .select( + "name email alias bio twoFactorEnabled preferences passwordChangedAt updatedAt subscription", + ) + .lean(); + + res.render("my-links", { + services, + user: buildAccountViewModel(userDoc, req.user), + isGuestContributor: isGuestContributor(req.user), + activeNav: "my-links", + domain: req.get("host"), }); -})); + }), +); // Analytics -app.get('/analytics', protect, asyncHandler(async (req, res) => { +app.get( + "/analytics", + protect, + asyncHandler(async (req, res) => { const userDoc = await User.findById(req.user.id) - .select('name email') - .lean(); + .select("name email") + .lean(); - return res.render('analytics', { - services, - user: buildAccountViewModel(userDoc, req.user), + return res.render("analytics", { + services, + user: buildAccountViewModel(userDoc, req.user), }); -})); + }), +); // Vault redirect to new File Upload page -app.get('/vault', protect, (req, res) => { - return res.redirect('/file-upload'); +app.get("/vault", protect, (req, res) => { + return res.redirect("/file-upload"); }); // File Upload page -app.get('/file-upload', protect, asyncHandler(async (req, res) => { +app.get( + "/file-upload", + protect, + asyncHandler(async (req, res) => { const userDoc = await User.findById(req.user.id) - .select('name email') - .lean(); + .select("name email") + .lean(); - return res.render('file-upload', { - services, - user: buildAccountViewModel(userDoc, req.user), + return res.render("file-upload", { + services, + user: buildAccountViewModel(userDoc, req.user), }); -})); + }), +); // ── BIO LINK ROUTES ── // Editor — creator configures their bio page -app.get('/bio', protect, asyncHandler(async (req, res) => { +app.get( + "/bio", + protect, + asyncHandler(async (req, res) => { const userDoc = await User.findById(req.user.id) - .select('name email alias bio') - .lean(); + .select("name email alias bio") + .lean(); - return res.render('bio-editor', { - services, - user: buildAccountViewModel(userDoc, req.user), + return res.render("bio-editor", { + services, + user: buildAccountViewModel(userDoc, req.user), }); -})); + }), +); // Save bio data -app.post('/bio/save', protect, asyncHandler(async (req, res) => { +app.post( + "/bio/save", + protect, + asyncHandler(async (req, res) => { // Wire to BioProfile model later return res.json({ success: true }); -})); + }), +); // Track link click -app.post('/bio/track/:linkId', asyncHandler(async (req, res) => { +app.post( + "/bio/track/:linkId", + asyncHandler(async (req, res) => { // Wire to analytics later return res.json({ tracked: true }); -})); + }), +); // Public profile — anyone can visit creatoros.com/@handle -app.get('/@:handle', asyncHandler(async (req, res) => { +app.get( + "/@:handle", + asyncHandler(async (req, res) => { const handle = req.params.handle; // Replace with DB lookup when BioProfile model is ready: // const bioProfile = await BioProfile.findOne({ handle }).lean(); const profile = { - name: 'Sudeepti Singh', - handle, - bio: 'AI/ML Enthusiast | Creator | B.Tech CS @ JUET', - tags: ['AI/ML', 'Creator', 'Open Source'], - avatarUrl: null, - initials: 'SS', - stats: { links: 6, views: '1.2K', clicks: '342' }, + name: "Sudeepti Singh", + handle, + bio: "AI/ML Enthusiast | Creator | B.Tech CS @ JUET", + tags: ["AI/ML", "Creator", "Open Source"], + avatarUrl: null, + initials: "SS", + stats: { links: 6, views: "1.2K", clicks: "342" }, }; const links = [ - { id: 1, type: 'instagram', icon: '📸', label: 'Instagram', url: 'https://instagram.com/', category: 'social' }, - { id: 2, type: 'youtube', icon: '🎥', label: 'YouTube', url: 'https://youtube.com/', category: 'social' }, - { id: 3, type: 'github', icon: '💻', label: 'GitHub', url: 'https://github.com/', category: 'work' }, - { id: 4, type: 'linkedin', icon: '💼', label: 'LinkedIn', url: 'https://linkedin.com/', category: 'work' }, - { id: 5, type: 'portfolio', icon: '🌐', label: 'Portfolio', url: 'https://portfolio.dev/', category: 'work' }, - { id: 6, type: 'email', icon: '📧', label: 'Contact Me', url: 'mailto:hello@example.com', category: 'other' }, + { + id: 1, + type: "instagram", + icon: "📸", + label: "Instagram", + url: "https://instagram.com/", + category: "social", + }, + { + id: 2, + type: "youtube", + icon: "🎥", + label: "YouTube", + url: "https://youtube.com/", + category: "social", + }, + { + id: 3, + type: "github", + icon: "💻", + label: "GitHub", + url: "https://github.com/", + category: "work", + }, + { + id: 4, + type: "linkedin", + icon: "💼", + label: "LinkedIn", + url: "https://linkedin.com/", + category: "work", + }, + { + id: 5, + type: "portfolio", + icon: "🌐", + label: "Portfolio", + url: "https://portfolio.dev/", + category: "work", + }, + { + id: 6, + type: "email", + icon: "📧", + label: "Contact Me", + url: "mailto:hello@example.com", + category: "other", + }, ]; - return res.render('bio-profile', { profile, links }); -})); + return res.render("bio-profile", { profile, links }); + }), +); // ── SERVICE PAGES ── -app.get('/services/:serviceKey', protect, asyncHandler(async (req, res) => { +app.get( + "/services/:serviceKey", + protect, + asyncHandler(async (req, res) => { const service = findServiceByKey(req.params.serviceKey); if (!service) { - return res.status(404).render('coming-soon', { - service: { - name: 'Unknown service', - description: 'This service does not exist in the current module registry.', - status: 'coming_soon', - }, - }); + return res.status(404).render("coming-soon", { + service: { + name: "Unknown service", + description: + "This service does not exist in the current module registry.", + status: "coming_soon", + }, + }); } - if (service.status !== 'available') { - return res.render('coming-soon', { service }); + if (service.status !== "available") { + return res.render("coming-soon", { service }); } - if (service.key === 'url-shortener') { - return res.render('home', buildShortenerViewModel(req)); + if (service.key === "url-shortener") { + return res.render("home", buildShortenerViewModel(req)); } - if (service.key === 'suggestion-tool') { - return res.redirect('/suggestions'); + if (service.key === "suggestion-tool") { + return res.redirect("/suggestions"); } - if (service.key === 'creator-crm') { - return res.redirect('/services/creator-crm'); + if (service.key === "creator-crm") { + return res.redirect("/services/creator-crm"); } - if (service.key === 'analytics-dashboard') { - const userDoc = await User.findById(req.user.id) - .select('name email') - .lean(); - - return res.render('analytics-dashboard', { - service, - services, - user: buildAccountViewModel(userDoc, req.user), - analytics: buildAnalyticsViewModel(), - }); + if (service.key === "analytics-dashboard") { + const userDoc = await User.findById(req.user.id) + .select("name email") + .lean(); + + return res.render("analytics-dashboard", { + service, + services, + user: buildAccountViewModel(userDoc, req.user), + analytics: buildAnalyticsViewModel(), + }); } - if (service.key === 'smart-bio') { - const userDoc = await User.findById(req.user.id) - .select('name email alias bio') - .lean(); + if (service.key === "smart-bio") { + const userDoc = await User.findById(req.user.id) + .select("name email alias bio") + .lean(); - return res.render('bio-editor', { - service, - services, - user: buildAccountViewModel(userDoc, req.user), - }); + return res.render("bio-editor", { + service, + services, + user: buildAccountViewModel(userDoc, req.user), + }); } - if (service.key === 'file-upload') { - return res.render('file-upload'); + if (service.key === "file-upload") { + return res.render("file-upload"); } - return res.render('coming-soon', { service }); -})); + return res.render("coming-soon", { service }); + }), +); // ── URL SHORTENER POST ── -const { isValidUrl } = require('./utils/validators'); +const { isValidUrl } = require("./utils/validators"); -app.post('/services/url-shortener/shorten', protect, preventContributorWrites, urlShortenerLimiter, async (req, res) => { +app.post( + "/services/url-shortener/shorten", + protect, + preventContributorWrites, + urlShortenerLimiter, + async (req, res) => { const { redirectUrl } = req.body; if (!redirectUrl || !isValidUrl(redirectUrl)) { - return res.render('home', buildShortenerViewModel(req, null, 'Please enter a valid HTTP or HTTPS URL.')); + return res.render( + "home", + buildShortenerViewModel( + req, + null, + "Please enter a valid HTTP or HTTPS URL.", + ), + ); } try { - const shortId = shortid(); - await Url.create({ shortId, redirectUrl }); - return res.render('home', buildShortenerViewModel(req, shortId)); + const shortId = shortid(); + await Url.create({ shortId, redirectUrl }); + return res.render("home", buildShortenerViewModel(req, shortId)); } catch (err) { - console.error('Error creating short URL:', err); - return res.render('home', buildShortenerViewModel(req, null, 'An unexpected error occurred.')); + console.error("Error creating short URL:", err); + return res.render( + "home", + buildShortenerViewModel(req, null, "An unexpected error occurred."), + ); } -}); + }, +); // ── FILE UPLOAD POST ── -app.post('/services/file-upload/upload', protect, preventContributorWrites, uploadLimiter, upload.single('file'), (req, res) => { +app.post( + "/services/file-upload/upload", + protect, + preventContributorWrites, + uploadLimiter, + upload.single("file"), + (req, res) => { if (!req.file) { - return res.status(400).json({ error: 'No file uploaded' }); + return res.status(400).json({ error: "No file uploaded" }); } return res.json({ - filename: req.file.originalname, - size: req.file.size, - mimetype: req.file.mimetype, - path: req.file.filename, + filename: req.file.originalname, + size: req.file.size, + mimetype: req.file.mimetype, + path: req.file.filename, }); -}); + }, +); // ── SHORT URL REDIRECT ── -app.get('/u/:shortId', asyncHandler(async (req, res) => { +app.get( + "/u/:shortId", + asyncHandler(async (req, res) => { const shortId = req.params.shortId; try { - const entry = await Url.findOneAndUpdate( - { shortId }, - { - $inc: { totalClicks: 1 }, - $push: { visitHistory: { timestamp: new Date(), source: 'direct' } }, - }, - { new: true } - ); - - if (!entry) return res.status(404).send('URL not found'); - return res.redirect(entry.redirectUrl); + const entry = await Url.findOneAndUpdate( + { shortId }, + { + $inc: { totalClicks: 1 }, + $push: { visitHistory: { timestamp: new Date(), source: "direct" } }, + }, + { new: true }, + ); + + if (!entry) return res.status(404).send("URL not found"); + return res.redirect(entry.redirectUrl); } catch (err) { - console.error('[redirect]', err); - return res.status(500).send('Server error'); + console.error("[redirect]", err); + return res.status(500).send("Server error"); } -})); + }), +); // ── SITEMAP ───────────────────────────────────────────── -app.get('/sitemap.xml', (req, res) => { - const baseUrl = 'https://titli-link-shortner.vercel.app'; - - const urls = [ - '/', - '/login', - '/signup', - '/services', - '/dashboard', - '/profile', - '/analytics', - '/vault', - '/bio', - '/settings', - '/suggestions', - '/my-links', - '/dm-automation', - '/services/creator-crm' - ]; - - const xml = ` +app.get("/sitemap.xml", (req, res) => { + const baseUrl = "https://titli-link-shortner.vercel.app"; + + const urls = [ + "/", + "/login", + "/signup", + "/services", + "/dashboard", + "/profile", + "/analytics", + "/vault", + "/bio", + "/settings", + "/suggestions", + "/my-links", + "/dm-automation", + "/services/creator-crm", + ]; + + const xml = ` -${urls.map(url => ` +${urls + .map( + (url) => ` ${baseUrl}${url} weekly - ${url === '/' ? '1.0' : '0.7'} + ${url === "/" ? "1.0" : "0.7"} -`).join('')} +`, + ) + .join("")} `; - res.header('Content-Type', 'application/xml'); - res.send(xml); + res.header("Content-Type", "application/xml"); + res.send(xml); }); // ── ERROR HANDLER — must be last ── -const errorHandler = require('./middleware/errorHandler'); +const errorHandler = require("./middleware/errorHandler"); app.use(errorHandler); if (require.main === module) { - app.listen(port, () => { - const url = process.env.APP_URL || `http://localhost:${port}`; - console.log(`Server is running on ${url}`); - }); + app.listen(port, () => { + const url = process.env.APP_URL || `http://localhost:${port}`; + console.log(`Server is running on ${url}`); + }); } -module.exports = app; \ No newline at end of file +module.exports = app; diff --git a/middleware/auth.js b/middleware/auth.js index d8d34091..d12665f2 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -11,61 +11,65 @@ const { wantsHtml } = require("../utils/requestType"); * @returns {Promise|void} */ const protect = async (req, res, next) => { - try { - const authHeader = req.headers.authorization || ""; - const bearerToken = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null; - const token = req.cookies.token || bearerToken; + try { + const authHeader = req.headers.authorization || ""; + const bearerToken = authHeader.startsWith("Bearer ") + ? authHeader.slice(7) + : null; + const token = req.cookies.token || bearerToken; - if (!token) { - if (wantsHtml(req)) return res.redirect("/login"); - return res.status(401).json({ - success: false, - message: "Authentication required", - error: "Authentication required", - }); - } - - const decoded = jwt.verify(token, process.env.JWT_SECRET); - - await connectDB(); - - if (decoded.role === "guest_contributor") { - const ContributorSession = require("../model/contributorSession"); - const session = await ContributorSession.findOne({ contributorId: decoded.id }); - - if (!session) { - if (wantsHtml(req)) return res.redirect("/login"); - return res.status(401).json({ - success: false, - message: "Invalid session", - error: "Invalid session", - }); - } - } else { - // For regular users, check if email is verified - const User = require("../model/user"); - const user = await User.findOne({ email: decoded.email }); + if (!token) { + if (wantsHtml(req)) return res.redirect("/login"); + return res.status(401).json({ + success: false, + message: "Authentication required", + error: "Authentication required", + }); + } - if (!user) { - return res.redirect("/login"); - } + const decoded = jwt.verify(token, process.env.JWT_SECRET); - if (!user.isVerified) { - return res.status(403).redirect("/resend-verification"); - } - } + await connectDB(); - req.user = decoded; + if (decoded.role === "guest_contributor") { + const ContributorSession = require("../model/contributorSession"); + const session = await ContributorSession.findOne({ + contributorId: decoded.id, + }); - next(); - } catch (error) { + if (!session) { if (wantsHtml(req)) return res.redirect("/login"); return res.status(401).json({ - success: false, - message: "Invalid or expired token", - error: "Invalid or expired token", + success: false, + message: "Invalid session", + error: "Invalid session", }); + } + } else { + // For regular users, check if email is verified + const User = require("../model/user"); + const user = await User.findOne({ email: decoded.email }); + + if (!user) { + return res.redirect("/login"); + } + + if (!user.isVerified) { + return res.status(403).redirect("/resend-verification"); + } } + + req.user = decoded; + + next(); + } catch (error) { + if (wantsHtml(req)) return res.redirect("/login"); + return res.status(401).json({ + success: false, + message: "Invalid or expired token", + error: "Invalid or expired token", + }); + } }; /** @@ -77,15 +81,15 @@ const protect = async (req, res, next) => { * @returns {Promise|void} */ const requireAdmin = (req, res, next) => { - if (req.user.role !== "admin") { - return res.status(403).json({ - success: false, - message: "Admin access required", - error: "Admin access required", - }); - } + if (req.user.role !== "admin") { + return res.status(403).json({ + success: false, + message: "Admin access required", + error: "Admin access required", + }); + } - return next(); + return next(); }; /** @@ -97,15 +101,18 @@ const requireAdmin = (req, res, next) => { * @returns {Promise|void} */ const preventContributorWrites = (req, res, next) => { - if (req.user.role === "contributor" || req.user.role === "guest_contributor") { - return res.status(403).json({ - success: false, - message: "Contributor accounts do not have permission to modify data.", - error: "Contributor accounts do not have permission to modify data.", - }); - } + if ( + req.user.role === "contributor" || + req.user.role === "guest_contributor" + ) { + return res.status(403).json({ + success: false, + message: "Contributor accounts do not have permission to modify data.", + error: "Contributor accounts do not have permission to modify data.", + }); + } - return next(); + return next(); }; module.exports = protect; diff --git a/middleware/csrf.js b/middleware/csrf.js index 79617ebd..fca1c4c5 100644 --- a/middleware/csrf.js +++ b/middleware/csrf.js @@ -1,55 +1,55 @@ -const crypto = require('crypto'); +const crypto = require("crypto"); /** * Middleware to generate a CSRF token and set it as an HttpOnly cookie. * It also exposes the token to views via res.locals.csrfToken. */ function generateCsrf(req, res, next) { - let token = req.cookies._csrf; - - if (!token) { - token = crypto.randomBytes(32).toString('hex'); - res.cookie('_csrf', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 24 * 60 * 60 * 1000 // 24 hours - }); - } - - // Always expose to views - res.locals.csrfToken = token; - next(); + let token = req.cookies._csrf; + + if (!token) { + token = crypto.randomBytes(32).toString("hex"); + res.cookie("_csrf", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 24 * 60 * 60 * 1000, // 24 hours + }); + } + + // Always expose to views + res.locals.csrfToken = token; + next(); } /** * Middleware to verify the CSRF token on state-changing requests. */ function verifyCsrf(req, res, next) { - const safeMethods = ['GET', 'HEAD', 'OPTIONS', 'TRACE']; - - if (safeMethods.includes(req.method)) { - return next(); - } - - const cookieToken = req.cookies._csrf; - const requestToken = - (req.body && req.body._csrf) || - req.headers['x-csrf-token'] || - req.headers['x-xsrf-token']; - - if (!cookieToken || !requestToken || cookieToken !== requestToken) { - return res.status(403).json({ - success: false, - message: 'Invalid CSRF token. Request blocked.', - error: 'CSRF token mismatch' - }); - } - - next(); + const safeMethods = ["GET", "HEAD", "OPTIONS", "TRACE"]; + + if (safeMethods.includes(req.method)) { + return next(); + } + + const cookieToken = req.cookies._csrf; + const requestToken = + (req.body && req.body._csrf) || + req.headers["x-csrf-token"] || + req.headers["x-xsrf-token"]; + + if (!cookieToken || !requestToken || cookieToken !== requestToken) { + return res.status(403).json({ + success: false, + message: "Invalid CSRF token. Request blocked.", + error: "CSRF token mismatch", + }); + } + + next(); } module.exports = { - generateCsrf, - verifyCsrf + generateCsrf, + verifyCsrf, }; diff --git a/middleware/errorHandler.js b/middleware/errorHandler.js index 9738f93d..2e92217a 100644 --- a/middleware/errorHandler.js +++ b/middleware/errorHandler.js @@ -1,4 +1,4 @@ -const { wantsHtml } = require('../utils/requestType'); +const { wantsHtml } = require("../utils/requestType"); /** * @function errorHandler @@ -11,14 +11,16 @@ function errorHandler(err, req, res, next) { const status = err.status && Number.isInteger(err.status) ? err.status : 500; const isClientError = status >= 400 && status < 500; - const isProduction = process.env.NODE_ENV === 'production'; + const isProduction = process.env.NODE_ENV === "production"; const message = isClientError - ? (err.message || 'Bad Request') - : (isProduction ? 'Internal server error' : (err.message || 'Internal server error')); + ? err.message || "Bad Request" + : isProduction + ? "Internal server error" + : err.message || "Internal server error"; if (wantsHtml(req)) { - return res.status(status).render('error', { - error: message + return res.status(status).render("error", { + error: message, }); } diff --git a/middleware/rateLimiters.js b/middleware/rateLimiters.js index 59b18fdf..b3ad7bab 100644 --- a/middleware/rateLimiters.js +++ b/middleware/rateLimiters.js @@ -1,78 +1,83 @@ -const rateLimit = require('express-rate-limit'); -const { wantsHtml } = require('../utils/requestType'); -const { buildShortenerViewModel } = require('../utils/viewModels'); +const rateLimit = require("express-rate-limit"); +const { wantsHtml } = require("../utils/requestType"); +const { buildShortenerViewModel } = require("../utils/viewModels"); const loginLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 15, - handler: (req, res) => { - const message = 'Too many login attempts, please try again later.'; - if (wantsHtml(req)) { - return res.status(429).render('login', { - error: message, - googleAuthConfigured: Boolean(process.env.GOOGLE_CLIENT_ID) - }); - } - return res.status(429).json({ success: false, message, error: message }); + windowMs: 15 * 60 * 1000, + max: 15, + handler: (req, res) => { + const message = "Too many login attempts, please try again later."; + if (wantsHtml(req)) { + return res.status(429).render("login", { + error: message, + googleAuthConfigured: Boolean(process.env.GOOGLE_CLIENT_ID), + }); } + return res.status(429).json({ success: false, message, error: message }); + }, }); const uploadLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, - max: 10, - handler: (req, res) => { - const message = 'Upload limit reached, please try again later.'; - return res.status(429).json({ success: false, message, error: message }); - } + windowMs: 60 * 60 * 1000, + max: 10, + handler: (req, res) => { + const message = "Upload limit reached, please try again later."; + return res.status(429).json({ success: false, message, error: message }); + }, }); const urlShortenerPageLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, - max: 30, - handler: (req, res) => { - const message = 'Too many URLs generated, please try again later.'; - return res.status(429).render('home', buildShortenerViewModel(req, null, message)); - } + windowMs: 60 * 60 * 1000, + max: 30, + handler: (req, res) => { + const message = "Too many URLs generated, please try again later."; + return res + .status(429) + .render("home", buildShortenerViewModel(req, null, message)); + }, }); const urlShortenerApiLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, - max: 30, - handler: (req, res) => { - const message = 'Too many URLs generated, please try again later.'; - return res.status(429).json({ success: false, message, error: message }); - } + windowMs: 60 * 60 * 1000, + max: 30, + handler: (req, res) => { + const message = "Too many URLs generated, please try again later."; + return res.status(429).json({ success: false, message, error: message }); + }, }); const signupLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour - max: 5, - handler: (req, res) => { - const message = 'Too many accounts created from this IP, please try again later.'; - if (wantsHtml(req)) { - return res.status(429).render('signup', { error: message }); - } - return res.status(429).json({ success: false, message, error: message }); + windowMs: 60 * 60 * 1000, // 1 hour + max: 5, + handler: (req, res) => { + const message = + "Too many accounts created from this IP, please try again later."; + if (wantsHtml(req)) { + return res.status(429).render("signup", { error: message }); } + return res.status(429).json({ success: false, message, error: message }); + }, }); const emailVerificationLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 5, - handler: (req, res) => { - const message = 'Too many requests. Please wait before trying again.'; - if (wantsHtml(req)) { - return res.status(429).render('resend-verification', { error: message, success: null }); - } - return res.status(429).json({ success: false, message, error: message }); + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, + handler: (req, res) => { + const message = "Too many requests. Please wait before trying again."; + if (wantsHtml(req)) { + return res + .status(429) + .render("resend-verification", { error: message, success: null }); } + return res.status(429).json({ success: false, message, error: message }); + }, }); module.exports = { - loginLimiter, - uploadLimiter, - urlShortenerPageLimiter, - urlShortenerApiLimiter, - signupLimiter, - emailVerificationLimiter + loginLimiter, + uploadLimiter, + urlShortenerPageLimiter, + urlShortenerApiLimiter, + signupLimiter, + emailVerificationLimiter, }; diff --git a/middleware/validators.js b/middleware/validators.js index 34f2362e..180277ce 100644 --- a/middleware/validators.js +++ b/middleware/validators.js @@ -1,74 +1,105 @@ -const { z } = require('zod'); -const { wantsHtml } = require('../utils/requestType'); +const { z } = require("zod"); +const { wantsHtml } = require("../utils/requestType"); const signupSchema = z.object({ - name: z.string().min(1, 'Name is required'), - email: z.string().email('Invalid email format'), - password: z.string().min(8, 'Password must be at least 8 characters'), + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email format"), + password: z.string().min(8, "Password must be at least 8 characters"), }); const loginSchema = z.object({ - email: z.string().email('Invalid email format'), - password: z.string().min(1, 'Password is required'), + email: z.string().email("Invalid email format"), + password: z.string().min(1, "Password is required"), }); const resendVerificationSchema = z.object({ - email: z.string().email('Invalid email format'), + email: z.string().email("Invalid email format"), }); const collaborationInviteSchema = z.object({ - email: z.string().email('Invalid email format'), + email: z.string().email("Invalid email format"), projectName: z.string().optional(), message: z.string().optional(), }); const collaborationAcceptSchema = z.object({ - inviteToken: z.string().min(1, 'Invite token is required'), + inviteToken: z.string().min(1, "Invite token is required"), }); -const urlShortenSchema = z.object({ - redirectUrl: z.string().url('A valid HTTP or HTTPS URL is required').optional(), - url: z.string().url('A valid HTTP or HTTPS URL is required').optional(), - title: z.string().optional(), - customSlug: z.string() - .regex(/^[a-z0-9-_]{3,32}$/, 'Custom slug must be 3–32 characters (letters, numbers, - or _).') - .optional() - .or(z.literal('')), - campaignName: z.string().optional(), - qrFgColor: z.string().regex(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, 'Invalid qrFgColor hex value').optional(), - qrBgColor: z.string().regex(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, 'Invalid qrBgColor hex value').optional(), - tag: z.enum(['active', 'social', 'campaign', 'general']).optional().or(z.literal('')), -}).refine(data => data.redirectUrl || data.url, { - message: "A valid HTTP or HTTPS URL is required", - path: ["redirectUrl"] -}); +const urlShortenSchema = z + .object({ + redirectUrl: z + .string() + .url("A valid HTTP or HTTPS URL is required") + .optional(), + url: z.string().url("A valid HTTP or HTTPS URL is required").optional(), + title: z.string().optional(), + customSlug: z + .string() + .regex( + /^[a-z0-9-_]{3,32}$/, + "Custom slug must be 3–32 characters (letters, numbers, - or _).", + ) + .optional() + .or(z.literal("")), + campaignName: z.string().optional(), + qrFgColor: z + .string() + .regex( + /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, + "Invalid qrFgColor hex value", + ) + .optional(), + qrBgColor: z + .string() + .regex( + /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, + "Invalid qrBgColor hex value", + ) + .optional(), + tag: z + .enum(["active", "social", "campaign", "general"]) + .optional() + .or(z.literal("")), + }) + .refine((data) => data.redirectUrl || data.url, { + message: "A valid HTTP or HTTPS URL is required", + path: ["redirectUrl"], + }); const urlQRColorsSchema = z.object({ - qrFgColor: z.string().regex(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, 'Invalid qrFgColor hex value').optional(), - qrBgColor: z.string().regex(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, 'Invalid qrBgColor hex value').optional(), + qrFgColor: z + .string() + .regex(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, "Invalid qrFgColor hex value") + .optional(), + qrBgColor: z + .string() + .regex(/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/, "Invalid qrBgColor hex value") + .optional(), }); const suggestionSchema = z.object({ - topic: z.string().min(1, 'Topic is required'), + topic: z.string().min(1, "Topic is required"), }); const objectIdParamSchema = z.object({ - creatorId: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid creatorId'), + creatorId: z.string().regex(/^[a-f\d]{24}$/i, "Invalid creatorId"), }); const shortIdParamSchema = z.object({ - shortId: z.string().min(1, 'Short ID is required'), + shortId: z.string().min(1, "Short ID is required"), }); -function validate(schema, source = 'body', viewName, buildLocals = () => ({})) { +function validate(schema, source = "body", viewName, buildLocals = () => ({})) { return (req, res, next) => { const result = schema.safeParse(req[source]); if (!result.success) { - const message = result.error.issues?.[0]?.message || "Invalid request data"; + const message = + result.error.issues?.[0]?.message || "Invalid request data"; if (wantsHtml(req) && viewName) { return res.status(400).render(viewName, { - ...buildLocals(req), - error: message, + ...buildLocals(req), + error: message, }); } return res.status(400).json({ success: false, message, error: message }); @@ -78,25 +109,29 @@ function validate(schema, source = 'body', viewName, buildLocals = () => ({})) { }; } -module.exports = { - signupSchema, - loginSchema, +module.exports = { + signupSchema, + loginSchema, + resendVerificationSchema, + collaborationInviteSchema, + collaborationAcceptSchema, + urlShortenSchema, + urlQRColorsSchema, + suggestionSchema, + objectIdParamSchema, + shortIdParamSchema, + validate, + signupValidator: validate(signupSchema, "body", "signup"), + loginValidator: validate(loginSchema, "body", "login", () => ({ + googleAuthConfigured: Boolean(process.env.GOOGLE_CLIENT_ID), + })), + resendVerificationValidator: validate( resendVerificationSchema, - collaborationInviteSchema, - collaborationAcceptSchema, - urlShortenSchema, - urlQRColorsSchema, - suggestionSchema, - objectIdParamSchema, - shortIdParamSchema, - validate, - signupValidator: validate(signupSchema, 'body', 'signup'), - loginValidator: validate(loginSchema, 'body', 'login', () => ({ - googleAuthConfigured: Boolean(process.env.GOOGLE_CLIENT_ID) - })), - resendVerificationValidator: validate(resendVerificationSchema, 'body', 'resend-verification'), - shortenUrlValidator: validate(urlShortenSchema, 'body'), - updateQrColorsValidator: validate(urlQRColorsSchema, 'body'), - inviteCollaboratorValidator: validate(collaborationInviteSchema, 'body'), - generateSuggestionValidator: validate(suggestionSchema, 'body') + "body", + "resend-verification", + ), + shortenUrlValidator: validate(urlShortenSchema, "body"), + updateQrColorsValidator: validate(urlQRColorsSchema, "body"), + inviteCollaboratorValidator: validate(collaborationInviteSchema, "body"), + generateSuggestionValidator: validate(suggestionSchema, "body"), }; diff --git a/model/analyticsSnapshot.js b/model/analyticsSnapshot.js index 0310755b..ba29e521 100644 --- a/model/analyticsSnapshot.js +++ b/model/analyticsSnapshot.js @@ -5,34 +5,34 @@ const mongoose = require("mongoose"); * @description Mongoose schema definition for analyticsSnapshot. */ const analyticsSnapshotSchema = new mongoose.Schema( - { - creatorId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Creator", - required: true, - }, - platform: { - type: String, - enum: ["instagram", "youtube", "twitter", "tiktok"], - required: true, - }, - followers: { type: Number, default: 0 }, - following: { type: Number, default: 0 }, - totalPosts: { type: Number, default: 0 }, - totalLikes: { type: Number, default: 0 }, - totalComments: { type: Number, default: 0 }, - totalViews: { type: Number, default: 0 }, - engagementRate: { type: Number, default: 0 }, - snapshotDate: { - type: Date, - default: Date.now, - }, + { + creatorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Creator", + required: true, }, - { timestamps: true } + platform: { + type: String, + enum: ["instagram", "youtube", "twitter", "tiktok"], + required: true, + }, + followers: { type: Number, default: 0 }, + following: { type: Number, default: 0 }, + totalPosts: { type: Number, default: 0 }, + totalLikes: { type: Number, default: 0 }, + totalComments: { type: Number, default: 0 }, + totalViews: { type: Number, default: 0 }, + engagementRate: { type: Number, default: 0 }, + snapshotDate: { + type: Date, + default: Date.now, + }, + }, + { timestamps: true }, ); const AnalyticsSnapshotModel = - mongoose.models.AnalyticsSnapshot || - mongoose.model("AnalyticsSnapshot", analyticsSnapshotSchema); + mongoose.models.AnalyticsSnapshot || + mongoose.model("AnalyticsSnapshot", analyticsSnapshotSchema); -module.exports = AnalyticsSnapshotModel; \ No newline at end of file +module.exports = AnalyticsSnapshotModel; diff --git a/model/contributorSession.js b/model/contributorSession.js index 5c86858b..11628faf 100644 --- a/model/contributorSession.js +++ b/model/contributorSession.js @@ -5,65 +5,65 @@ const mongoose = require("mongoose"); * @description Mongoose schema definition for contributorSession. */ const contributorSessionSchema = new mongoose.Schema( - { - contributorId: { - type: String, - required: true, - unique: true, - }, - role: { - type: String, - default: "guest_contributor", - }, - expiresAt: { - type: Date, - required: true, - index: { expires: 0 }, - }, + { + contributorId: { + type: String, + required: true, + unique: true, }, - { - timestamps: true, - } + role: { + type: String, + default: "guest_contributor", + }, + expiresAt: { + type: Date, + required: true, + index: { expires: 0 }, + }, + }, + { + timestamps: true, + }, ); const MongooseContributorSessionModel = - mongoose.models.ContributorSession || - mongoose.model("ContributorSession", contributorSessionSchema); + mongoose.models.ContributorSession || + mongoose.model("ContributorSession", contributorSessionSchema); const mockSessions = []; class MockContributorSessionModel { - constructor(data) { - this._id = data._id || new mongoose.Types.ObjectId().toString(); - this.contributorId = data.contributorId; - this.role = data.role || "guest_contributor"; - this.createdAt = data.createdAt || new Date(); - this.expiresAt = data.expiresAt; - } + constructor(data) { + this._id = data._id || new mongoose.Types.ObjectId().toString(); + this.contributorId = data.contributorId; + this.role = data.role || "guest_contributor"; + this.createdAt = data.createdAt || new Date(); + this.expiresAt = data.expiresAt; + } - static async create(data) { - const session = new MockContributorSessionModel(data); - mockSessions.push(session); - return session; - } + static async create(data) { + const session = new MockContributorSessionModel(data); + mockSessions.push(session); + return session; + } - static async findOne(query = {}) { - const now = new Date(); - const session = mockSessions.find((item) => { - return item.contributorId === query.contributorId && item.expiresAt > now; - }); + static async findOne(query = {}) { + const now = new Date(); + const session = mockSessions.find((item) => { + return item.contributorId === query.contributorId && item.expiresAt > now; + }); - return session || null; - } + return session || null; + } } function getActiveContributorSessionModel() { - return process.env.USE_MOCK_DB === "true" - ? MockContributorSessionModel - : MongooseContributorSessionModel; + return process.env.USE_MOCK_DB === "true" + ? MockContributorSessionModel + : MongooseContributorSessionModel; } module.exports = { - create: (...args) => getActiveContributorSessionModel().create(...args), - findOne: (...args) => getActiveContributorSessionModel().findOne(...args), + create: (...args) => getActiveContributorSessionModel().create(...args), + findOne: (...args) => getActiveContributorSessionModel().findOne(...args), }; diff --git a/model/creator.js b/model/creator.js index 8edb53f0..64989d04 100644 --- a/model/creator.js +++ b/model/creator.js @@ -5,69 +5,69 @@ const mongoose = require("mongoose"); * @description Mongoose schema definition for creator. */ const creatorSchema = new mongoose.Schema( - { - -bio: { - type: String, - trim: true, - default: "", -}, + { + bio: { + type: String, + trim: true, + default: "", + }, -theme: { - type: String, - enum: ["dark", "light"], - default: "dark", -}, + theme: { + type: String, + enum: ["dark", "light"], + default: "dark", + }, -accentColor: { - type: String, - default: "#8b5cf6", -}, + accentColor: { + type: String, + default: "#8b5cf6", + }, -links: [ - { + links: [ + { title: { - type: String, - required: true, - trim: true, + type: String, + required: true, + trim: true, }, url: { - type: String, - required: true, - trim: true, + type: String, + required: true, + trim: true, }, - }, -], + }, + ], - userId: { - type: mongoose.Schema.Types.ObjectId, - ref: "User", - required: true, - unique: true, - }, - username: { - type: String, - required: true, - trim: true, - }, - platform: { - type: String, - enum: ["instagram", "youtube", "twitter", "tiktok"], - required: true, - }, - profileUrl: { - type: String, - }, - avatar: { - type: String, - }, - lastRefreshedAt: { - type: Date, - }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + unique: true, + }, + username: { + type: String, + required: true, + trim: true, + }, + platform: { + type: String, + enum: ["instagram", "youtube", "twitter", "tiktok"], + required: true, + }, + profileUrl: { + type: String, + }, + avatar: { + type: String, + }, + lastRefreshedAt: { + type: Date, }, - { timestamps: true } + }, + { timestamps: true }, ); -const CreatorModel = mongoose.models.Creator || mongoose.model("Creator", creatorSchema); -module.exports = CreatorModel; \ No newline at end of file +const CreatorModel = + mongoose.models.Creator || mongoose.model("Creator", creatorSchema); +module.exports = CreatorModel; diff --git a/model/engagementHistory.js b/model/engagementHistory.js index fe169301..17dd2d62 100644 --- a/model/engagementHistory.js +++ b/model/engagementHistory.js @@ -5,28 +5,28 @@ const mongoose = require("mongoose"); * @description Mongoose schema definition for engagementHistory. */ const engagementHistorySchema = new mongoose.Schema( - { - creatorId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Creator", - required: true, - }, - snapshotId: { - type: mongoose.Schema.Types.ObjectId, - ref: "AnalyticsSnapshot", - required: true, - }, - date: { type: Date, default: Date.now }, - followersGrowth: { type: Number, default: 0 }, - likesGrowth: { type: Number, default: 0 }, - commentsGrowth: { type: Number, default: 0 }, - engagementRateDelta: { type: Number, default: 0 }, + { + creatorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Creator", + required: true, }, - { timestamps: true } + snapshotId: { + type: mongoose.Schema.Types.ObjectId, + ref: "AnalyticsSnapshot", + required: true, + }, + date: { type: Date, default: Date.now }, + followersGrowth: { type: Number, default: 0 }, + likesGrowth: { type: Number, default: 0 }, + commentsGrowth: { type: Number, default: 0 }, + engagementRateDelta: { type: Number, default: 0 }, + }, + { timestamps: true }, ); const EngagementHistoryModel = - mongoose.models.EngagementHistory || - mongoose.model("EngagementHistory", engagementHistorySchema); + mongoose.models.EngagementHistory || + mongoose.model("EngagementHistory", engagementHistorySchema); -module.exports = EngagementHistoryModel; \ No newline at end of file +module.exports = EngagementHistoryModel; diff --git a/model/invite.js b/model/invite.js index 1b42d72f..2137eb78 100644 --- a/model/invite.js +++ b/model/invite.js @@ -1,4 +1,4 @@ -const mongoose = require('mongoose'); +const mongoose = require("mongoose"); /** * @schema inviteSchema @@ -8,7 +8,7 @@ const inviteSchema = new mongoose.Schema( { inviter: { type: mongoose.Schema.Types.ObjectId, - ref: 'User', + ref: "User", required: true, }, email: { @@ -20,7 +20,7 @@ const inviteSchema = new mongoose.Schema( projectName: { type: String, trim: true, - default: 'CreatorOS Collaboration', + default: "CreatorOS Collaboration", }, token: { type: String, @@ -29,8 +29,8 @@ const inviteSchema = new mongoose.Schema( }, status: { type: String, - enum: ['pending', 'accepted', 'expired'], - default: 'pending', + enum: ["pending", "accepted", "expired"], + default: "pending", }, message: { type: String, @@ -42,10 +42,11 @@ const inviteSchema = new mongoose.Schema( }, { timestamps: true, - } + }, ); -const MongooseInviteModel = mongoose.models.Invite || mongoose.model('Invite', inviteSchema); +const MongooseInviteModel = + mongoose.models.Invite || mongoose.model("Invite", inviteSchema); const emptyInviteQuery = { sort() { @@ -71,7 +72,7 @@ const MockInviteModel = { return { _id: new mongoose.Types.ObjectId().toString(), ...data, - status: 'pending', + status: "pending", createdAt: new Date(), updatedAt: new Date(), }; @@ -87,7 +88,8 @@ function getActiveInviteModel() { const InviteModel = { countDocuments: (...args) => getActiveInviteModel().countDocuments(...args), findOne: (...args) => getActiveInviteModel().findOne(...args), - findByIdAndDelete: (...args) => getActiveInviteModel().findByIdAndDelete(...args), + findByIdAndDelete: (...args) => + getActiveInviteModel().findByIdAndDelete(...args), find: (...args) => getActiveInviteModel().find(...args), create: (...args) => getActiveInviteModel().create(...args), }; diff --git a/model/post.js b/model/post.js index 0b7936f4..8ff9340a 100644 --- a/model/post.js +++ b/model/post.js @@ -5,26 +5,26 @@ const mongoose = require("mongoose"); * @description Mongoose schema definition for post. */ const postSchema = new mongoose.Schema( - { - creatorId: { - type: mongoose.Schema.Types.ObjectId, - ref: "Creator", - required: true, - }, - platform: { - type: String, - enum: ["instagram", "youtube", "twitter", "tiktok"], - required: true, - }, - postId: { type: String, required: true }, - caption: { type: String }, - mediaUrl: { type: String }, - likes: { type: Number, default: 0 }, - comments: { type: Number, default: 0 }, - views: { type: Number, default: 0 }, - postedAt: { type: Date }, + { + creatorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Creator", + required: true, }, - { timestamps: true } + platform: { + type: String, + enum: ["instagram", "youtube", "twitter", "tiktok"], + required: true, + }, + postId: { type: String, required: true }, + caption: { type: String }, + mediaUrl: { type: String }, + likes: { type: Number, default: 0 }, + comments: { type: Number, default: 0 }, + views: { type: Number, default: 0 }, + postedAt: { type: Date }, + }, + { timestamps: true }, ); const PostModel = mongoose.models.Post || mongoose.model("Post", postSchema); diff --git a/model/seedSuggestions.js b/model/seedSuggestions.js index 021c2069..829d43f1 100644 --- a/model/seedSuggestions.js +++ b/model/seedSuggestions.js @@ -1,19 +1,25 @@ // scripts/seedSuggestions.js -const mongoose = require('mongoose'); -const Suggestion = require('../model/suggestionData'); -const suggestions = require('../model/suggestion'); +const mongoose = require("mongoose"); +const Suggestion = require("../model/suggestionData"); +const suggestions = require("../model/suggestion"); async function seed() { - await mongoose.connect('mongodb://localhost:27017/yourdb'); + await mongoose.connect("mongodb://localhost:27017/yourdb"); for (const [category, data] of Object.entries(suggestions)) { await Suggestion.updateOne( { category }, - { $set: { captions: data.captions, hashtags: data.hashtags, songs: data.songs } }, - { upsert: true } + { + $set: { + captions: data.captions, + hashtags: data.hashtags, + songs: data.songs, + }, + }, + { upsert: true }, ); } - console.log('Seeded!'); + console.log("Seeded!"); mongoose.disconnect(); } -seed(); \ No newline at end of file +seed(); diff --git a/model/suggestion.js b/model/suggestion.js index 648434dd..ee412315 100644 --- a/model/suggestion.js +++ b/model/suggestion.js @@ -5,154 +5,198 @@ const suggestions = { "Adventure awaits, go find it ✈️", "Collect moments, not things 📸", "Life is short and the world is wide 🗺️", - "Wander often, wonder always 🌄" + "Wander often, wonder always 🌄", ], hashtags: [ - "#travel", "#wanderlust", "#travelgram", - "#exploretheworld", "#adventure", "#travelphotography", - "#traveldiaries", "#instatravel", "#roamtheplanet" + "#travel", + "#wanderlust", + "#travelgram", + "#exploretheworld", + "#adventure", + "#travelphotography", + "#traveldiaries", + "#instatravel", + "#roamtheplanet", ], songs: [ { title: "Wake Me Up – Avicii", mood: "uplifting" }, { title: "A Sky Full of Stars – Coldplay", mood: "cinematic" }, { title: "Sunflower – Post Malone", mood: "chill" }, { title: "Good Life – OneRepublic", mood: "feel-good" }, - { title: "On Top of the World – Imagine Dragons", mood: "euphoric" } - ] + { title: "On Top of the World – Imagine Dragons", mood: "euphoric" }, + ], }, - + fitness: { captions: [ "Sweat now, shine later 💪", "Your only limit is you 🔥", "Train insane or remain the same", "The body achieves what the mind believes 🧠", - "Push yourself because no one else is going to do it for you 🏋️" + "Push yourself because no one else is going to do it for you 🏋️", ], hashtags: [ - "#fitness", "#gym", "#workout", - "#fitnessmotivation", "#grind", "#gains", - "#fitlife", "#nopainnogain", "#bodybuilding" + "#fitness", + "#gym", + "#workout", + "#fitnessmotivation", + "#grind", + "#gains", + "#fitlife", + "#nopainnogain", + "#bodybuilding", ], songs: [ { title: "Stronger – Kanye West", mood: "hype" }, { title: "Till I Collapse – Eminem", mood: "intense" }, { title: "Eye of the Tiger – Survivor", mood: "classic" }, { title: "Power – Kanye West", mood: "dominant" }, - { title: "Can't Hold Us – Macklemore", mood: "energetic" } - ] + { title: "Can't Hold Us – Macklemore", mood: "energetic" }, + ], }, - + fashion: { captions: [ "Style is a way to say who you are 👗", "Dress like you're already famous ✨", "Fashion fades, style is eternal", "Life is too short to wear boring clothes 🌈", - "Outfit of the day: confidence 💅" + "Outfit of the day: confidence 💅", ], hashtags: [ - "#fashion", "#ootd", "#style", - "#outfitoftheday", "#aesthetic", "#fashionista", - "#streetstyle", "#lookbook", "#fashionblogger" + "#fashion", + "#ootd", + "#style", + "#outfitoftheday", + "#aesthetic", + "#fashionista", + "#streetstyle", + "#lookbook", + "#fashionblogger", ], songs: [ { title: "Vogue – Madonna", mood: "iconic" }, { title: "7 rings – Ariana Grande", mood: "confident" }, { title: "Fancy – Iggy Azalea", mood: "glam" }, { title: "Boss Bitch – Doja Cat", mood: "fierce" }, - { title: "Formation – Beyoncé", mood: "powerful" } - ] + { title: "Formation – Beyoncé", mood: "powerful" }, + ], }, - + tech: { captions: [ "Building the future, one line at a time 💻", "Code is poetry 🧠", "Ship it. Iterate. Repeat 🚀", "In a world full of users, be a developer 👨‍💻", - "First, solve the problem. Then, write the code 🛠️" + "First, solve the problem. Then, write the code 🛠️", ], hashtags: [ - "#tech", "#coding", "#developer", - "#buildinpublic", "#100daysofcode", "#programming", - "#webdev", "#softwareengineer", "#opensource" + "#tech", + "#coding", + "#developer", + "#buildinpublic", + "#100daysofcode", + "#programming", + "#webdev", + "#softwareengineer", + "#opensource", ], songs: [ { title: "Harder Better Faster Stronger – Daft Punk", mood: "focus" }, { title: "Interstellar OST – Hans Zimmer", mood: "deep work" }, { title: "Lose Yourself – Eminem", mood: "grind" }, { title: "The Less I Know The Better – Tame Impala", mood: "flow state" }, - { title: "Levels – Avicii", mood: "productive" } - ] + { title: "Levels – Avicii", mood: "productive" }, + ], }, - + aesthetic: { captions: [ "Soft life, big dreams 🌸", "In my own world and it's beautiful 🕊️", "Living slowly, loving deeply", "She believed she could, so she did 🌙", - "Bloom where you are planted 🌷" + "Bloom where you are planted 🌷", ], hashtags: [ - "#aesthetic", "#softlife", "#vibes", - "#aestheticphotos", "#minimalvibes", "#moodboard", - "#dreamy", "#cottagecore", "#coquette" + "#aesthetic", + "#softlife", + "#vibes", + "#aestheticphotos", + "#minimalvibes", + "#moodboard", + "#dreamy", + "#cottagecore", + "#coquette", ], songs: [ { title: "Golden Hour – JVKE", mood: "dreamy" }, { title: "Sweater Weather – The Neighbourhood", mood: "cozy" }, { title: "Cruel Summer – Taylor Swift", mood: "nostalgic" }, { title: "Espresso – Sabrina Carpenter", mood: "girly pop" }, - { title: "Die With A Smile – Lady Gaga & Bruno Mars", mood: "romantic" } - ] + { title: "Die With A Smile – Lady Gaga & Bruno Mars", mood: "romantic" }, + ], }, - + food: { captions: [ "Good food, good mood 🍜", "First, we eat. Then, we do everything else 🍕", "Life is too short for bad coffee ☕", "Eating my feelings, one bite at a time 🍰", - "Food is my love language 🥘" + "Food is my love language 🥘", ], hashtags: [ - "#foodie", "#foodphotography", "#instafood", - "#foodblogger", "#yummy", "#foodstagram", - "#homecooking", "#foodlover", "#delicious" + "#foodie", + "#foodphotography", + "#instafood", + "#foodblogger", + "#yummy", + "#foodstagram", + "#homecooking", + "#foodlover", + "#delicious", ], songs: [ { title: "Happy – Pharrell Williams", mood: "upbeat" }, { title: "Sunday Morning – Maroon 5", mood: "brunch vibes" }, { title: "Peaches – Justin Bieber", mood: "fruity & fun" }, { title: "Good Day Sunshine – The Beatles", mood: "feel-good" }, - { title: "Butter – BTS", mood: "smooth" } - ] + { title: "Butter – BTS", mood: "smooth" }, + ], }, - + motivation: { captions: [ "Start where you are. Use what you have. Do what you can 🌟", "Every day is a second chance 💫", "Dream bigger. Do better. Become more 🚀", "The comeback is always stronger than the setback 💪", - "Small steps every day lead to big results 🎯" + "Small steps every day lead to big results 🎯", ], hashtags: [ - "#motivation", "#mindset", "#grindset", - "#success", "#hustle", "#inspire", - "#growth", "#selfdevelopment", "#dailymotivation" + "#motivation", + "#mindset", + "#grindset", + "#success", + "#hustle", + "#inspire", + "#growth", + "#selfdevelopment", + "#dailymotivation", ], songs: [ - { title: "Hall of Fame – The Script ft. will.i.am", mood: "inspirational" }, + { + title: "Hall of Fame – The Script ft. will.i.am", + mood: "inspirational", + }, { title: "Believer – Imagine Dragons", mood: "empowering" }, { title: "Rise – Katy Perry", mood: "triumphant" }, { title: "Not Afraid – Eminem", mood: "courageous" }, - { title: "Unstoppable – Sia", mood: "fearless" } - ] - - } + { title: "Unstoppable – Sia", mood: "fearless" }, + ], + }, }; -module.exports = suggestions; \ No newline at end of file +module.exports = suggestions; diff --git a/model/suggestionData.js b/model/suggestionData.js index c342eb3a..4e565b29 100644 --- a/model/suggestionData.js +++ b/model/suggestionData.js @@ -1,4 +1,4 @@ -const mongoose = require('mongoose'); +const mongoose = require("mongoose"); /** * @schema suggestionSchema @@ -11,11 +11,10 @@ const suggestionSchema = new mongoose.Schema({ songs: [ { title: String, - mood: String - } - ] + mood: String, + }, + ], }); module.exports = - mongoose.models.Suggestion || - mongoose.model('Suggestion', suggestionSchema); + mongoose.models.Suggestion || mongoose.model("Suggestion", suggestionSchema); diff --git a/model/url.js b/model/url.js index 76855b03..3c6f87b5 100644 --- a/model/url.js +++ b/model/url.js @@ -5,146 +5,147 @@ const mongoose = require("mongoose"); * @description Mongoose schema definition for url. */ const urlSchema = new mongoose.Schema({ - shortId: { - type: String, - required: true, - unique: true, - }, - redirectUrl: { - type: String, - required: true, - }, - campaignName: { - type: String, - default: "Untitled Campaign", - }, - totalClicks: { - type: Number, - default: 0, - }, - qrFgColor: { - type: String, - default: "#1a1a1a", - }, - qrBgColor: { - type: String, - default: "#ffffff", - }, - qrGenerated: { - type: Boolean, - default: false, - }, - createdAt: { + shortId: { + type: String, + required: true, + unique: true, + }, + redirectUrl: { + type: String, + required: true, + }, + campaignName: { + type: String, + default: "Untitled Campaign", + }, + totalClicks: { + type: Number, + default: 0, + }, + qrFgColor: { + type: String, + default: "#1a1a1a", + }, + qrBgColor: { + type: String, + default: "#ffffff", + }, + qrGenerated: { + type: Boolean, + default: false, + }, + createdAt: { + type: Date, + default: Date.now, + }, + visitHistory: [ + { + timestamp: { type: Date, default: Date.now, + }, + source: { + type: String, + enum: ["qr", "direct", "unknown"], + default: "unknown", + }, }, - visitHistory: [ - { - timestamp: { - type: Date, - default: Date.now, - }, - source: { - type: String, - enum: ["qr", "direct", "unknown"], - default: "unknown", - }, - }, - ], + ], }); -const MongooseUrlModel = mongoose.models.Url || mongoose.model("Url", urlSchema); +const MongooseUrlModel = + mongoose.models.Url || mongoose.model("Url", urlSchema); // In-Memory Database Fallback Implementation const mockUrls = []; class MockUrlModel { - constructor(data) { - this.shortId = data.shortId; - this.redirectUrl = data.redirectUrl; - this.campaignName = data.campaignName || "Untitled Campaign"; - this.totalClicks = data.totalClicks || 0; - this.qrFgColor = data.qrFgColor || "#1a1a1a"; - this.qrBgColor = data.qrBgColor || "#ffffff"; - this.qrGenerated = data.qrGenerated || false; - this.createdAt = data.createdAt || new Date(); - this.visitHistory = data.visitHistory || []; - } - - async save() { - const existing = mockUrls.find((u) => u.shortId === this.shortId); - if (existing) { - Object.assign(existing, this); - } else { - mockUrls.push(this); - } - return this; - } + constructor(data) { + this.shortId = data.shortId; + this.redirectUrl = data.redirectUrl; + this.campaignName = data.campaignName || "Untitled Campaign"; + this.totalClicks = data.totalClicks || 0; + this.qrFgColor = data.qrFgColor || "#1a1a1a"; + this.qrBgColor = data.qrBgColor || "#ffffff"; + this.qrGenerated = data.qrGenerated || false; + this.createdAt = data.createdAt || new Date(); + this.visitHistory = data.visitHistory || []; + } - static async create(data) { - const url = new MockUrlModel(data); - await url.save(); - return url; + async save() { + const existing = mockUrls.find((u) => u.shortId === this.shortId); + if (existing) { + Object.assign(existing, this); + } else { + mockUrls.push(this); } + return this; + } - static async findOne(query) { - const found = mockUrls.find((u) => u.shortId === query.shortId); - return found ? new MockUrlModel(found) : null; - } + static async create(data) { + const url = new MockUrlModel(data); + await url.save(); + return url; + } - static async findOneAndUpdate(query, update, opts = {}) { - const found = mockUrls.find((u) => u.shortId === query.shortId); - if (!found) { - return opts.upsert - ? MockUrlModel.create({ ...query, ...update.$set }) - : null; - } + static async findOne(query) { + const found = mockUrls.find((u) => u.shortId === query.shortId); + return found ? new MockUrlModel(found) : null; + } - if (update.$set) Object.assign(found, update.$set); - if (update.$push) { - const [key, val] = Object.entries(update.$push)[0]; - if (!found[key]) found[key] = []; - found[key].push(val); - } - if (update.$inc) { - const [key, val] = Object.entries(update.$inc)[0]; - found[key] = (found[key] || 0) + val; - } - return new MockUrlModel(found); + static async findOneAndUpdate(query, update, opts = {}) { + const found = mockUrls.find((u) => u.shortId === query.shortId); + if (!found) { + return opts.upsert + ? MockUrlModel.create({ ...query, ...update.$set }) + : null; } - static async find(query = {}) { - let results = mockUrls.filter((u) => - Object.entries(query).every(([k, v]) => u[k] === v) - ); - return { - sort: () => results.map((u) => new MockUrlModel(u)) - }; + if (update.$set) Object.assign(found, update.$set); + if (update.$push) { + const [key, val] = Object.entries(update.$push)[0]; + if (!found[key]) found[key] = []; + found[key].push(val); } - - static async findByIdAndDelete(id) { - const idx = mockUrls.findIndex((u) => u._id === id || u.shortId === id); - if (idx === -1) return null; - return mockUrls.splice(idx, 1)[0]; + if (update.$inc) { + const [key, val] = Object.entries(update.$inc)[0]; + found[key] = (found[key] || 0) + val; } + return new MockUrlModel(found); + } + + static async find(query = {}) { + let results = mockUrls.filter((u) => + Object.entries(query).every(([k, v]) => u[k] === v), + ); + return { + sort: () => results.map((u) => new MockUrlModel(u)), + }; + } + + static async findByIdAndDelete(id) { + const idx = mockUrls.findIndex((u) => u._id === id || u.shortId === id); + if (idx === -1) return null; + return mockUrls.splice(idx, 1)[0]; + } } function getActiveUrlModel() { - return process.env.USE_MOCK_DB === "true" - ? MockUrlModel - : MongooseUrlModel; + return process.env.USE_MOCK_DB === "true" ? MockUrlModel : MongooseUrlModel; } function UrlModel(data) { - const ActiveUrlModel = getActiveUrlModel(); - return new ActiveUrlModel(data); + const ActiveUrlModel = getActiveUrlModel(); + return new ActiveUrlModel(data); } UrlModel.findOne = (...args) => getActiveUrlModel().findOne(...args); UrlModel.create = (...args) => getActiveUrlModel().create(...args); UrlModel.findById = (...args) => getActiveUrlModel().findById(...args); -UrlModel.findOneAndUpdate = (...args) => getActiveUrlModel().findOneAndUpdate(...args); +UrlModel.findOneAndUpdate = (...args) => + getActiveUrlModel().findOneAndUpdate(...args); UrlModel.find = (...args) => getActiveUrlModel().find(...args); -UrlModel.findByIdAndDelete = (...args) => getActiveUrlModel().findByIdAndDelete(...args); +UrlModel.findByIdAndDelete = (...args) => + getActiveUrlModel().findByIdAndDelete(...args); -module.exports = UrlModel; \ No newline at end of file +module.exports = UrlModel; diff --git a/model/user.js b/model/user.js index 674612e5..81d10dff 100644 --- a/model/user.js +++ b/model/user.js @@ -6,250 +6,261 @@ const bcrypt = require("bcryptjs"); * @description Mongoose schema definition for user. */ const userSchema = new mongoose.Schema( - { - name: { + { + name: { + type: String, + required: true, + }, + + email: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + }, + + password: { + type: String, + required: function () { + return this.authProvider === "local"; + }, + }, + + authProvider: { + type: String, + enum: ["local", "google"], + default: "local", + }, + + role: { + type: String, + enum: ["creator", "contributor", "admin"], + default: "creator", + }, + + googleId: { + type: String, + sparse: true, + unique: true, + }, + + avatar: { + type: String, + }, + + alias: { + type: String, + }, + + bio: { + type: String, + }, + + twoFactorEnabled: { + type: Boolean, + default: false, + }, + + preferences: { + appearanceMode: { + type: String, + enum: ["light", "dark", "system"], + default: "light", + }, + interfaceDensity: { type: String, - required: true, - }, - - email: { - type: String, - required: true, - unique: true, - lowercase: true, - trim: true, - }, - - password: { - type: String, - required: function () { - return this.authProvider === "local"; - }, - }, - - authProvider: { - type: String, - enum: ["local", "google"], - default: "local", - }, - - role: { - type: String, - enum: ["creator", "contributor", "admin"], - default: "creator", - }, - - googleId: { - type: String, - sparse: true, - unique: true, - }, - - avatar: { - type: String, - }, - - alias: { - type: String, - }, - - bio: { - type: String, - }, - - twoFactorEnabled: { - type: Boolean, - default: false, - }, - - preferences: { - appearanceMode: { type: String, enum: ['light', 'dark', 'system'], default: 'light' }, - interfaceDensity: { type: String, enum: ['compact', 'tactile'], default: 'tactile' }, - motionEffects: { type: Boolean, default: true }, - soundCues: { type: Boolean, default: false }, - autoSaveLinks: { type: Boolean, default: true }, - }, - - passwordChangedAt: { - type: Date, - }, - - subscription: { - planName: { type: String, default: 'Pro Individual' }, - priceMonthly: { type: Number, default: 29 }, - nextInvoiceDate: { type: Date }, - cardBrand: { type: String, default: 'VISA' }, - cardLast4: { type: String, default: '4242' }, - }, - - lastLoginAt: { - type: Date, - }, - - collaborators: [{ - type: mongoose.Schema.Types.ObjectId, - ref: "User", - }], - - isVerified: { - type: Boolean, - default: false, - }, - - verificationToken: { - type: String, - sparse: true, - unique: true, - index: true, - }, - - verificationTokenExpiry: { - type: Date, - index: true, - }, + enum: ["compact", "tactile"], + default: "tactile", + }, + motionEffects: { type: Boolean, default: true }, + soundCues: { type: Boolean, default: false }, + autoSaveLinks: { type: Boolean, default: true }, }, - { - timestamps: true, - } + + passwordChangedAt: { + type: Date, + }, + + subscription: { + planName: { type: String, default: "Pro Individual" }, + priceMonthly: { type: Number, default: 29 }, + nextInvoiceDate: { type: Date }, + cardBrand: { type: String, default: "VISA" }, + cardLast4: { type: String, default: "4242" }, + }, + + lastLoginAt: { + type: Date, + }, + + collaborators: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, + ], + + isVerified: { + type: Boolean, + default: false, + }, + + verificationToken: { + type: String, + sparse: true, + unique: true, + index: true, + }, + + verificationTokenExpiry: { + type: Date, + index: true, + }, + }, + { + timestamps: true, + }, ); -const MongooseUserModel = mongoose.models.User || mongoose.model("User", userSchema); +const MongooseUserModel = + mongoose.models.User || mongoose.model("User", userSchema); // In-memory mock storage const mockUsers = []; class MockUserModel { - constructor(data) { - this._id = data._id || new mongoose.Types.ObjectId().toString(); - this.name = data.name; - this.email = data.email; - this.password = data.password; - this.role = data.role || "creator"; - this.authProvider = data.authProvider || "local"; - this.collaborators = data.collaborators || []; - - this.alias = data.alias || ""; - this.bio = data.bio || ""; - this.twoFactorEnabled = data.twoFactorEnabled || false; - this.preferences = data.preferences || { - appearanceMode: 'light', - interfaceDensity: 'tactile', - motionEffects: true, - soundCues: false, - autoSaveLinks: true - }; + constructor(data) { + this._id = data._id || new mongoose.Types.ObjectId().toString(); + this.name = data.name; + this.email = data.email; + this.password = data.password; + this.role = data.role || "creator"; + this.authProvider = data.authProvider || "local"; + this.collaborators = data.collaborators || []; - this.passwordChangedAt = data.passwordChangedAt || null; - this.subscription = data.subscription || { - planName: 'Pro Individual', - priceMonthly: 29, - cardBrand: 'VISA', - cardLast4: '4242', - }; - - this.isVerified = data.isVerified !== undefined ? data.isVerified : false; - this.verificationToken = data.verificationToken || null; - this.verificationTokenExpiry = data.verificationTokenExpiry || null; - - this.createdAt = new Date(); - this.updatedAt = new Date(); - this.lastLoginAt = data.lastLoginAt; - } + this.alias = data.alias || ""; + this.bio = data.bio || ""; + this.twoFactorEnabled = data.twoFactorEnabled || false; + this.preferences = data.preferences || { + appearanceMode: "light", + interfaceDensity: "tactile", + motionEffects: true, + soundCues: false, + autoSaveLinks: true, + }; - async save() { - const existing = mockUsers.find(u => u.email === this.email); - if (existing) { - Object.assign(existing, this); - } else { - mockUsers.push(this); - } - return this; - } + this.passwordChangedAt = data.passwordChangedAt || null; + this.subscription = data.subscription || { + planName: "Pro Individual", + priceMonthly: 29, + cardBrand: "VISA", + cardLast4: "4242", + }; + + this.isVerified = data.isVerified !== undefined ? data.isVerified : false; + this.verificationToken = data.verificationToken || null; + this.verificationTokenExpiry = data.verificationTokenExpiry || null; - async deleteOne() { - const idx = mockUsers.findIndex((u) => u._id === this._id || u.id === this._id); - if (idx !== -1) { - mockUsers.splice(idx, 1); - } - return this; + this.createdAt = new Date(); + this.updatedAt = new Date(); + this.lastLoginAt = data.lastLoginAt; + } + + async save() { + const existing = mockUsers.find((u) => u.email === this.email); + if (existing) { + Object.assign(existing, this); + } else { + mockUsers.push(this); } + return this; + } - static async findOne(query) { - let found = null; - if (query.email) { - found = mockUsers.find(u => u.email === query.email); - } - return found ? new MockUserModel(found) : null; + async deleteOne() { + const idx = mockUsers.findIndex( + (u) => u._id === this._id || u.id === this._id, + ); + if (idx !== -1) { + mockUsers.splice(idx, 1); } + return this; + } - static async create(data) { - const user = new MockUserModel(data); - await user.save(); - return user; + static async findOne(query) { + let found = null; + if (query.email) { + found = mockUsers.find((u) => u.email === query.email); } + return found ? new MockUserModel(found) : null; + } + + static async create(data) { + const user = new MockUserModel(data); + await user.save(); + return user; + } + + static findById(id) { + const found = mockUsers.find((u) => u._id === id || u.id === id); + const result = found ? new MockUserModel(found) : null; - static findById(id) { - const found = mockUsers.find(u => u._id === id || u.id === id); - const result = found ? new MockUserModel(found) : null; - + return { + select: function () { return { - select: function() { - return { - lean: function() { - return result; - }, - then: function(resolve) { - resolve(result); - } - }; - }, - lean: function() { - return result; - }, - then: function(resolve) { - resolve(result); - } + lean: function () { + return result; + }, + then: function (resolve) { + resolve(result); + }, }; - } + }, + lean: function () { + return result; + }, + then: function (resolve) { + resolve(result); + }, + }; + } } // Pre-seed the test user in Mock DB so login works immediately (async () => { - const hashed = await bcrypt.hash("Password123!", 10); - mockUsers.push({ - _id: "000000000000000000000001", - name: "Test User", - email: "test@local.com", - password: hashed, - role: "creator", - authProvider: "local", - alias: "@test_creator", - bio: "Test user bio goes here.", - twoFactorEnabled: false, - isVerified: true, - verificationToken: null, - verificationTokenExpiry: null, - preferences: { - appearanceMode: 'light', - interfaceDensity: 'tactile', - motionEffects: true, - soundCues: false, - autoSaveLinks: true - }, - createdAt: new Date(), - updatedAt: new Date() - }); + const hashed = await bcrypt.hash("Password123!", 10); + mockUsers.push({ + _id: "000000000000000000000001", + name: "Test User", + email: "test@local.com", + password: hashed, + role: "creator", + authProvider: "local", + alias: "@test_creator", + bio: "Test user bio goes here.", + twoFactorEnabled: false, + isVerified: true, + verificationToken: null, + verificationTokenExpiry: null, + preferences: { + appearanceMode: "light", + interfaceDensity: "tactile", + motionEffects: true, + soundCues: false, + autoSaveLinks: true, + }, + createdAt: new Date(), + updatedAt: new Date(), + }); })(); function getActiveUserModel() { - return process.env.USE_MOCK_DB === "true" - ? MockUserModel - : MongooseUserModel; + return process.env.USE_MOCK_DB === "true" ? MockUserModel : MongooseUserModel; } function UserModel(data) { - const ActiveUserModel = getActiveUserModel(); - return new ActiveUserModel(data); + const ActiveUserModel = getActiveUserModel(); + return new ActiveUserModel(data); } UserModel.findOne = (...args) => getActiveUserModel().findOne(...args); diff --git a/performance.md b/performance.md index 49d0a1c4..07fc3577 100644 --- a/performance.md +++ b/performance.md @@ -39,7 +39,6 @@ JavaScript execution is the biggest cost. When Scripting > Rendering, the browser is spending lots of time running JS rather than drawing the page. - Problems flagged: - _Render blockers_\- these files block the browser from rendering the page immediately, delaying FCP and LCP. Because Browser cannot render page until CSS + fonts are fetched. **Impact** - Fonts block first meaningful paint. Icon font CSS delays rendering entire layout. @@ -146,4 +145,4 @@ The score is 92/100 which is good but these are additional changes. (not mandato These are minor accessibility improvements and do not affect the website's core functionality. The site remains fully usable, but addressing these items would improve accessibility, user experience, and overall Lighthouse scores. -Also add meta descriptions to pages if not added. \ No newline at end of file +Also add meta descriptions to pages if not added. diff --git a/public/css/dashboard.css b/public/css/dashboard.css index bc5b31c0..9532e2a2 100644 --- a/public/css/dashboard.css +++ b/public/css/dashboard.css @@ -1,10 +1,12 @@ .stat-card-retro, .panel-retro { - transition: transform 0.2s ease, box-shadow 0.2s ease; + transition: + transform 0.2s ease, + box-shadow 0.2s ease; } .stat-card-retro:hover, .panel-retro:hover { - transform: translateY(-4px); - box-shadow: 6px 6px 0 rgba(0,0,0,0.12); -} \ No newline at end of file + transform: translateY(-4px); + box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.12); +} diff --git a/public/css/my-links.css b/public/css/my-links.css index e47f0692..78377de4 100644 --- a/public/css/my-links.css +++ b/public/css/my-links.css @@ -1,654 +1,659 @@ .sidebar-nav a, .workspace-sidebar a, .sidebar a { - transition: - background-color 0.2s ease, - color 0.2s ease, - transform 0.2s ease, - box-shadow 0.2s ease; + transition: + background-color 0.2s ease, + color 0.2s ease, + transform 0.2s ease, + box-shadow 0.2s ease; } .sidebar-nav a:hover, .workspace-sidebar a:hover, .sidebar a:hover { - transform: translateX(4px); - box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.12); + transform: translateX(4px); + box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.12); } .sidebar-nav a svg, .workspace-sidebar a svg, .sidebar a svg { - transition: transform 0.2s ease; + transition: transform 0.2s ease; } .sidebar-nav a:hover svg, .workspace-sidebar a:hover svg, .sidebar a:hover svg { - transform: scale(1.08); + transform: scale(1.08); } - - - :root { - --links-hero: #fef9c3; - --links-blue: #bfdbfe; - --links-dark: #1e293b; - --links-mono: 'IBM Plex Mono', monospace; + --links-hero: #fef9c3; + --links-blue: #bfdbfe; + --links-dark: #1e293b; + --links-mono: "IBM Plex Mono", monospace; } .links-page { - --ws-bg: #fcfbf7; + --ws-bg: #fcfbf7; } .links-hero { - background: var(--links-hero); - border: 3px solid var(--ws-border); - box-shadow: 8px 8px 0 var(--ws-border); - padding: 2rem 2.25rem 1.75rem; - position: relative; - margin-bottom: 2rem; + background: var(--links-hero); + border: 3px solid var(--ws-border); + box-shadow: 8px 8px 0 var(--ws-border); + padding: 2rem 2.25rem 1.75rem; + position: relative; + margin-bottom: 2rem; } .links-hero-badge { - position: absolute; - top: -14px; - right: 1.5rem; - background: var(--ws-yellow); - border: 2px solid var(--ws-border); - padding: 0.35rem 0.75rem; - font-size: 0.65rem; - font-weight: 800; - letter-spacing: 0.08em; - box-shadow: 3px 3px 0 var(--ws-border); - transform: rotate(2deg); + position: absolute; + top: -14px; + right: 1.5rem; + background: var(--ws-yellow); + border: 2px solid var(--ws-border); + padding: 0.35rem 0.75rem; + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.08em; + box-shadow: 3px 3px 0 var(--ws-border); + transform: rotate(2deg); } .links-hero h2 { - font-family: 'Playfair Display', serif; - font-size: clamp(1.6rem, 3vw, 2.2rem); - margin: 0 0 1.25rem; - max-width: 28ch; - line-height: 1.15; + font-family: "Playfair Display", serif; + font-size: clamp(1.6rem, 3vw, 2.2rem); + margin: 0 0 1.25rem; + max-width: 28ch; + line-height: 1.15; } .shorten-form { - display: flex; - gap: 0.75rem; - flex-wrap: wrap; - margin-bottom: 1rem; + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1rem; } .shorten-input-wrap { - flex: 1; - min-width: 240px; - display: flex; - align-items: center; - gap: 0.5rem; - background: #fff; - border: 2px solid var(--ws-border); - padding: 0.65rem 1rem; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04); + flex: 1; + min-width: 240px; + display: flex; + align-items: center; + gap: 0.5rem; + background: #fff; + border: 2px solid var(--ws-border); + padding: 0.65rem 1rem; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.04); } .shorten-input-wrap svg { - width: 18px; - height: 18px; - flex-shrink: 0; - opacity: 0.5; + width: 18px; + height: 18px; + flex-shrink: 0; + opacity: 0.5; } .shorten-input { - border: none; - outline: none; - font-family: var(--links-mono); - font-size: 0.85rem; - width: 100%; - background: transparent; - color: inherit; + border: none; + outline: none; + font-family: var(--links-mono); + font-size: 0.85rem; + width: 100%; + background: transparent; + color: inherit; } .btn-shorten { - background: var(--ws-teal); - border: 2px solid var(--ws-border); - padding: 0.85rem 1.5rem; - font-weight: 800; - font-family: inherit; - font-size: 0.85rem; - letter-spacing: 0.06em; - cursor: pointer; - box-shadow: 4px 4px 0 var(--ws-border); - white-space: nowrap; + background: var(--ws-teal); + border: 2px solid var(--ws-border); + padding: 0.85rem 1.5rem; + font-weight: 800; + font-family: inherit; + font-size: 0.85rem; + letter-spacing: 0.06em; + cursor: pointer; + box-shadow: 4px 4px 0 var(--ws-border); + white-space: nowrap; } .btn-shorten:active { - transform: translate(4px, 4px); - box-shadow: none; + transform: translate(4px, 4px); + box-shadow: none; } .btn-shorten:disabled { - opacity: 0.6; - cursor: not-allowed; + opacity: 0.6; + cursor: not-allowed; } .shorten-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 1rem; - font-size: 0.8rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + font-size: 0.8rem; } .domain-chip { - display: inline-flex; - align-items: center; - gap: 0.35rem; - background: #fff; - border: 2px solid var(--ws-border); - padding: 0.35rem 0.65rem; - font-family: var(--links-mono); - font-size: 0.75rem; - font-weight: 600; + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: #fff; + border: 2px solid var(--ws-border); + padding: 0.35rem 0.65rem; + font-family: var(--links-mono); + font-size: 0.75rem; + font-weight: 600; } .slug-toggle { - background: none; - border: none; - font-weight: 700; - text-decoration: underline; - cursor: pointer; - font-family: inherit; - font-size: 0.8rem; - padding: 0; + background: none; + border: none; + font-weight: 700; + text-decoration: underline; + cursor: pointer; + font-family: inherit; + font-size: 0.8rem; + padding: 0; } .slug-panel { - display: none; - margin-top: 1rem; - padding-top: 1rem; - border-top: 2px dashed var(--ws-border); - gap: 0.75rem; - flex-wrap: wrap; - align-items: flex-end; + display: none; + margin-top: 1rem; + padding-top: 1rem; + border-top: 2px dashed var(--ws-border); + gap: 0.75rem; + flex-wrap: wrap; + align-items: flex-end; } .slug-panel.open { - display: flex; + display: flex; } .slug-field { - flex: 1; - min-width: 160px; + flex: 1; + min-width: 160px; } .slug-field label { - display: block; - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - margin-bottom: 0.35rem; + display: block; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 0.35rem; } .slug-field input, .slug-field select { - width: 100%; - padding: 0.55rem 0.75rem; - border: 2px solid var(--ws-border); - background: #fff; - font-family: inherit; - font-size: 0.85rem; + width: 100%; + padding: 0.55rem 0.75rem; + border: 2px solid var(--ws-border); + background: #fff; + font-family: inherit; + font-size: 0.85rem; } .links-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 300px; - gap: 2rem; - align-items: start; + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 2rem; + align-items: start; } .links-main-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - margin-bottom: 1.25rem; - flex-wrap: wrap; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; } .links-main-header h3 { - font-family: 'Playfair Display', serif; - font-size: 1.5rem; - margin: 0; + font-family: "Playfair Display", serif; + font-size: 1.5rem; + margin: 0; } .links-toolbar { - display: flex; - gap: 0.5rem; - align-items: center; + display: flex; + gap: 0.5rem; + align-items: center; } .toolbar-btn { - background: #fff; - border: 2px solid var(--ws-border); - width: 36px; - height: 36px; - display: grid; - place-items: center; - cursor: pointer; - box-shadow: 2px 2px 0 var(--ws-border); + background: #fff; + border: 2px solid var(--ws-border); + width: 36px; + height: 36px; + display: grid; + place-items: center; + cursor: pointer; + box-shadow: 2px 2px 0 var(--ws-border); } .toolbar-btn.active { - background: var(--ws-teal); + background: var(--ws-teal); } .toolbar-btn svg { - width: 16px; - height: 16px; + width: 16px; + height: 16px; } .links-feed { - display: flex; - flex-direction: column; - gap: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; } .link-card { - background: #fff; - border: 2px solid var(--ws-border); - box-shadow: 5px 5px 0 var(--ws-border); - padding: 1.25rem 1.35rem; - display: grid; - grid-template-columns: 1fr auto; - gap: 1rem; - align-items: start; - transition: transform 0.12s ease; + background: #fff; + border: 2px solid var(--ws-border); + box-shadow: 5px 5px 0 var(--ws-border); + padding: 1.25rem 1.35rem; + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: start; + transition: transform 0.12s ease; } .link-card:hover { - transform: translate(-2px, -2px); - box-shadow: 7px 7px 0 var(--ws-border); + transform: translate(-2px, -2px); + box-shadow: 7px 7px 0 var(--ws-border); } .link-card-top { - display: flex; - align-items: center; - gap: 0.6rem; - margin-bottom: 0.5rem; - flex-wrap: wrap; + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; } .link-tag { - font-size: 0.65rem; - font-weight: 800; - letter-spacing: 0.06em; - text-transform: uppercase; - padding: 0.2rem 0.55rem; - border: 2px solid var(--ws-border); + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.2rem 0.55rem; + border: 2px solid var(--ws-border); } -.link-tag.active { background: var(--ws-teal); } -.link-tag.social { background: var(--links-blue); } -.link-tag.campaign { background: var(--ws-yellow); } -.link-tag.general { background: #e2e8f0; } +.link-tag.active { + background: var(--ws-teal); +} +.link-tag.social { + background: var(--links-blue); +} +.link-tag.campaign { + background: var(--ws-yellow); +} +.link-tag.general { + background: #e2e8f0; +} .link-title { - font-weight: 700; - font-size: 1.05rem; - margin: 0 0 0.35rem; + font-weight: 700; + font-size: 1.05rem; + margin: 0 0 0.35rem; } .link-short { - font-family: var(--links-mono); - font-size: 0.9rem; - font-weight: 600; - color: var(--ws-darkgreen); - margin: 0 0 0.35rem; - word-break: break-all; + font-family: var(--links-mono); + font-size: 0.9rem; + font-weight: 600; + color: var(--ws-darkgreen); + margin: 0 0 0.35rem; + word-break: break-all; } .link-dest { - font-size: 0.78rem; - color: #555; - margin: 0; - display: flex; - align-items: center; - gap: 0.35rem; - word-break: break-all; + font-size: 0.78rem; + color: #555; + margin: 0; + display: flex; + align-items: center; + gap: 0.35rem; + word-break: break-all; } .link-dest svg { - width: 14px; - height: 14px; - flex-shrink: 0; + width: 14px; + height: 14px; + flex-shrink: 0; } .link-clicks { - text-align: right; - min-width: 72px; + text-align: right; + min-width: 72px; } .link-clicks strong { - display: block; - font-family: 'Playfair Display', serif; - font-size: 2rem; - line-height: 1; + display: block; + font-family: "Playfair Display", serif; + font-size: 2rem; + line-height: 1; } .link-clicks span { - font-size: 0.65rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #666; + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #666; } .link-actions { - grid-column: 1 / -1; - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - padding-top: 0.75rem; - border-top: 2px solid #eee; + grid-column: 1 / -1; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + padding-top: 0.75rem; + border-top: 2px solid #eee; } .link-action-btn { - background: #fff; - border: 2px solid var(--ws-border); - padding: 0.4rem 0.75rem; - font-size: 0.75rem; - font-weight: 700; - cursor: pointer; - font-family: inherit; - box-shadow: 2px 2px 0 var(--ws-border); - text-decoration: none; - color: inherit; + background: #fff; + border: 2px solid var(--ws-border); + padding: 0.4rem 0.75rem; + font-size: 0.75rem; + font-weight: 700; + cursor: pointer; + font-family: inherit; + box-shadow: 2px 2px 0 var(--ws-border); + text-decoration: none; + color: inherit; } .link-action-btn:hover { - background: var(--ws-sidebar); + background: var(--ws-sidebar); } .links-sidebar-widgets { - display: flex; - flex-direction: column; - gap: 1.25rem; - position: sticky; - top: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; + position: sticky; + top: 1.5rem; } .widget { - border: 2px solid var(--ws-border); - box-shadow: 6px 6px 0 var(--ws-border); - padding: 1.35rem; + border: 2px solid var(--ws-border); + box-shadow: 6px 6px 0 var(--ws-border); + padding: 1.35rem; } .widget-dark { - background: var(--links-dark); - color: #f8fafc; + background: var(--links-dark); + color: #f8fafc; } .widget-light { - background: var(--links-blue); - color: #0f172a; + background: var(--links-blue); + color: #0f172a; } .widget-label { - font-size: 0.65rem; - font-weight: 800; - letter-spacing: 0.1em; - text-transform: uppercase; - margin-bottom: 0.75rem; - opacity: 0.85; + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 0.75rem; + opacity: 0.85; } .widget-dark .widget-label { - color: var(--ws-teal); + color: var(--ws-teal); } .widget-stat { - font-family: 'Playfair Display', serif; - font-size: 2.4rem; - line-height: 1; - margin: 0 0 0.35rem; + font-family: "Playfair Display", serif; + font-size: 2.4rem; + line-height: 1; + margin: 0 0 0.35rem; } .widget-stat span { - font-size: 0.9rem; - font-family: 'Space Grotesk', sans-serif; - font-weight: 700; + font-size: 0.9rem; + font-family: "Space Grotesk", sans-serif; + font-weight: 700; } .widget-desc { - font-size: 0.82rem; - margin: 0 0 1rem; - opacity: 0.9; - line-height: 1.5; + font-size: 0.82rem; + margin: 0 0 1rem; + opacity: 0.9; + line-height: 1.5; } .widget-btn { - display: block; - width: 100%; - text-align: center; - background: var(--ws-teal); - color: #000; - border: 2px solid var(--ws-border); - padding: 0.7rem; - font-weight: 800; - font-family: inherit; - cursor: pointer; - text-decoration: none; - box-shadow: 3px 3px 0 var(--ws-border); + display: block; + width: 100%; + text-align: center; + background: var(--ws-teal); + color: #000; + border: 2px solid var(--ws-border); + padding: 0.7rem; + font-weight: 800; + font-family: inherit; + cursor: pointer; + text-decoration: none; + box-shadow: 3px 3px 0 var(--ws-border); } .widget-link { - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-weight: 700; - font-size: 0.8rem; - color: inherit; - text-decoration: none; - margin-top: 0.75rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-weight: 700; + font-size: 0.8rem; + color: inherit; + text-decoration: none; + margin-top: 0.75rem; } .empty-state { - border: 2px dashed var(--ws-border); - padding: 3rem 2rem; - text-align: center; - background: #fff; + border: 2px dashed var(--ws-border); + padding: 3rem 2rem; + text-align: center; + background: #fff; } .empty-state h4 { - font-family: 'Playfair Display', serif; - margin: 0 0 0.5rem; - font-size: 1.4rem; + font-family: "Playfair Display", serif; + margin: 0 0 0.5rem; + font-size: 1.4rem; } .empty-state p { - margin: 0; - color: #555; - font-size: 0.9rem; + margin: 0; + color: #555; + font-size: 0.9rem; } .page-topbar { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - margin-bottom: 2rem; - flex-wrap: wrap; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; + flex-wrap: wrap; } .page-topbar h1 { - font-family: 'Playfair Display', serif; - font-size: clamp(1.75rem, 3vw, 2.25rem); - margin: 0; + font-family: "Playfair Display", serif; + font-size: clamp(1.75rem, 3vw, 2.25rem); + margin: 0; } .search-box { - flex: 1; - max-width: 360px; - min-width: 200px; - display: flex; - align-items: center; - gap: 0.5rem; - background: #fff; - border: 2px solid var(--ws-border); - padding: 0.55rem 0.85rem; - box-shadow: 3px 3px 0 var(--ws-border); + flex: 1; + max-width: 360px; + min-width: 200px; + display: flex; + align-items: center; + gap: 0.5rem; + background: #fff; + border: 2px solid var(--ws-border); + padding: 0.55rem 0.85rem; + box-shadow: 3px 3px 0 var(--ws-border); } .search-box input { - border: none; - outline: none; - width: 100%; - font-family: inherit; - font-size: 0.9rem; - background: transparent; + border: none; + outline: none; + width: 100%; + font-family: inherit; + font-size: 0.9rem; + background: transparent; } .search-box svg { - width: 16px; - height: 16px; - opacity: 0.45; + width: 16px; + height: 16px; + opacity: 0.45; } .topbar-actions { - display: flex; - align-items: center; - gap: 0.75rem; + display: flex; + align-items: center; + gap: 0.75rem; } .btn-new-link { - background: var(--ws-teal); - border: 2px solid var(--ws-border); - padding: 0.6rem 1rem; - font-weight: 700; - font-family: inherit; - cursor: pointer; - box-shadow: 3px 3px 0 var(--ws-border); - white-space: nowrap; + background: var(--ws-teal); + border: 2px solid var(--ws-border); + padding: 0.6rem 1rem; + font-weight: 700; + font-family: inherit; + cursor: pointer; + box-shadow: 3px 3px 0 var(--ws-border); + white-space: nowrap; } .page-footer { - margin-top: 3rem; - padding-top: 1.25rem; - border-top: 2px solid var(--ws-border); - display: flex; - justify-content: space-between; - flex-wrap: wrap; - gap: 0.75rem; - font-family: var(--links-mono); - font-size: 0.65rem; - letter-spacing: 0.04em; - text-transform: uppercase; - color: #555; + margin-top: 3rem; + padding-top: 1.25rem; + border-top: 2px solid var(--ws-border); + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + font-family: var(--links-mono); + font-size: 0.65rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #555; } .page-footer-links { - display: flex; - gap: 1rem; + display: flex; + gap: 1rem; } .page-footer-links a { - color: inherit; - text-decoration: none; - font-weight: 600; + color: inherit; + text-decoration: none; + font-weight: 600; } .page-footer-links a:hover { - text-decoration: underline; + text-decoration: underline; } .result-banner { - display: none; - margin-top: 1rem; - padding: 0.85rem 1rem; - background: #fff; - border: 2px solid var(--ws-teal); - box-shadow: 3px 3px 0 var(--ws-teal); - font-family: var(--links-mono); - font-size: 0.85rem; - word-break: break-all; + display: none; + margin-top: 1rem; + padding: 0.85rem 1rem; + background: #fff; + border: 2px solid var(--ws-teal); + box-shadow: 3px 3px 0 var(--ws-teal); + font-family: var(--links-mono); + font-size: 0.85rem; + word-break: break-all; } .result-banner.visible { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - flex-wrap: wrap; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; } @media (max-width: 1024px) { - .links-layout { - grid-template-columns: 1fr; - } + .links-layout { + grid-template-columns: 1fr; + } - .links-sidebar-widgets { - position: static; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - } + .links-sidebar-widgets { + position: static; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + } } @media (max-width: 640px) { - .link-card { - grid-template-columns: 1fr; - } + .link-card { + grid-template-columns: 1fr; + } - .link-clicks { - text-align: left; - } + .link-clicks { + text-align: left; + } } .empty-state p { - margin: 0; - color: #555; - font-size: 0.9rem; + margin: 0; + color: #555; + font-size: 0.9rem; } /* ===== Empty State CTA Enhancements ===== */ .btn-retro { - margin-top: 1rem; - padding: 0.75rem 1.25rem; - border: 2px solid var(--ws-border); - background: var(--ws-teal); - color: #000; - font-weight: 700; - font-family: inherit; - cursor: pointer; - box-shadow: 3px 3px 0 var(--ws-border); - transition: all 0.25s ease; - animation: ctaPulse 2.5s infinite; + margin-top: 1rem; + padding: 0.75rem 1.25rem; + border: 2px solid var(--ws-border); + background: var(--ws-teal); + color: #000; + font-weight: 700; + font-family: inherit; + cursor: pointer; + box-shadow: 3px 3px 0 var(--ws-border); + transition: all 0.25s ease; + animation: ctaPulse 2.5s infinite; } .btn-retro:hover { - transform: translateY(-2px); - box-shadow: 5px 5px 0 var(--ws-border); + transform: translateY(-2px); + box-shadow: 5px 5px 0 var(--ws-border); } .btn-retro:active { - transform: translateY(0); + transform: translateY(0); } @keyframes ctaPulse { - 0% { - box-shadow: 0 0 0 0 rgba(0, 255, 200, 0.25); - } - 70% { - box-shadow: 0 0 0 8px rgba(0, 255, 200, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(0, 255, 200, 0); - } + 0% { + box-shadow: 0 0 0 0 rgba(0, 255, 200, 0.25); + } + 70% { + box-shadow: 0 0 0 8px rgba(0, 255, 200, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(0, 255, 200, 0); + } } .empty-state p { - margin: 0; - color: #555; - font-size: 0.9rem; + margin: 0; + color: #555; + font-size: 0.9rem; } /* ===== Button Active / Pressed State Feedback ===== */ @@ -658,8 +663,8 @@ .widget-btn:active, .btn-new-link:active, .toolbar-btn:active { - transform: translate(2px, 2px); - box-shadow: none; + transform: translate(2px, 2px); + box-shadow: none; } .btn-shorten, @@ -667,7 +672,7 @@ .widget-btn, .btn-new-link, .toolbar-btn { - transition: - transform 0.12s ease, - box-shadow 0.12s ease; -} \ No newline at end of file + transition: + transform 0.12s ease, + box-shadow 0.12s ease; +} diff --git a/public/css/settings.css b/public/css/settings.css index b1043ef5..5808b592 100644 --- a/public/css/settings.css +++ b/public/css/settings.css @@ -1,42 +1,42 @@ :root { - --settings-bg: #fcfbf7; - --settings-sidebar: #f3efe6; - --settings-border: #000000; - --settings-teal: #1abc9c; - --settings-yellow: #f1c40f; - --settings-blue-badge: #00cec9; - --settings-darkgreen: #0b5345; - --settings-red-bg: #fadbd8; - --settings-red-text: #c0392b; - --settings-sidebar-width: 260px; + --settings-bg: #fcfbf7; + --settings-sidebar: #f3efe6; + --settings-border: #000000; + --settings-teal: #1abc9c; + --settings-yellow: #f1c40f; + --settings-blue-badge: #00cec9; + --settings-darkgreen: #0b5345; + --settings-red-bg: #fadbd8; + --settings-red-text: #c0392b; + --settings-sidebar-width: 260px; } * { - box-sizing: border-box; + box-sizing: border-box; } body.settings-page { - margin: 0; - font-family: 'Space Grotesk', sans-serif; - color: #000; - background-color: var(--settings-bg); - min-height: 100vh; + margin: 0; + font-family: "Space Grotesk", sans-serif; + color: #000; + background-color: var(--settings-bg); + min-height: 100vh; } body.settings-page.density-compact { - --section-pad: 1.5rem; - --card-gap: 2rem; + --section-pad: 1.5rem; + --card-gap: 2rem; } body.settings-page.density-tactile { - --section-pad: 2.5rem; - --card-gap: 3rem; + --section-pad: 2.5rem; + --card-gap: 3rem; } body.settings-page.appearance-dark { - --settings-bg: #1a1a1a; - --settings-sidebar: #2d2d2d; - color: #f5f5f5; + --settings-bg: #1a1a1a; + --settings-sidebar: #2d2d2d; + color: #f5f5f5; } body.settings-page.appearance-dark .section-card, @@ -45,167 +45,167 @@ body.settings-page.appearance-dark .sub-nav-item, body.settings-page.appearance-dark .form-input, body.settings-page.appearance-dark .form-textarea, body.settings-page.appearance-dark .setting-row { - background-color: #2d2d2d; - color: #f5f5f5; + background-color: #2d2d2d; + color: #f5f5f5; } body.settings-page.no-motion * { - transition: none !important; - animation: none !important; + transition: none !important; + animation: none !important; } body.settings-page { - --settings-bg: #f5f1ea; - --settings-surface: #fffaf0; - --settings-muted-surface: #f0ebe3; - --settings-card: #fffdf7; - --settings-text: #111111; - --settings-muted: #4b5563; - --settings-shadow: 3px 3px 0 var(--settings-border); - --settings-shadow-lg: 5px 5px 0 var(--settings-border); - --ws-bg: var(--settings-bg); - --ws-sidebar: var(--settings-muted-surface); - --ws-border: var(--settings-border); - color: var(--settings-text); + --settings-bg: #f5f1ea; + --settings-surface: #fffaf0; + --settings-muted-surface: #f0ebe3; + --settings-card: #fffdf7; + --settings-text: #111111; + --settings-muted: #4b5563; + --settings-shadow: 3px 3px 0 var(--settings-border); + --settings-shadow-lg: 5px 5px 0 var(--settings-border); + --ws-bg: var(--settings-bg); + --ws-sidebar: var(--settings-muted-surface); + --ws-border: var(--settings-border); + color: var(--settings-text); } body.settings-page.appearance-dark { - --settings-bg: #1a1a18; - --settings-surface: #252522; - --settings-muted-surface: #2c2c28; - --settings-card: #2c2c28; - --settings-text: #f5f1ea; - --settings-muted: #d1d5db; - --settings-border: #f5f1ea; - --ws-bg: var(--settings-bg); - --ws-sidebar: var(--settings-surface); - --ws-border: var(--settings-border); - color: var(--settings-text); + --settings-bg: #1a1a18; + --settings-surface: #252522; + --settings-muted-surface: #2c2c28; + --settings-card: #2c2c28; + --settings-text: #f5f1ea; + --settings-muted: #d1d5db; + --settings-border: #f5f1ea; + --ws-bg: var(--settings-bg); + --ws-sidebar: var(--settings-surface); + --ws-border: var(--settings-border); + color: var(--settings-text); } .settings-layout { - grid-template-columns: var(--ws-sidebar-width) minmax(0, 1fr); - background: var(--settings-bg); + grid-template-columns: var(--ws-sidebar-width) minmax(0, 1fr); + background: var(--settings-bg); } .settings-sidebar { - border-right: 3px solid var(--settings-border); - background-color: var(--settings-sidebar); - padding: 1.5rem 1rem; - display: flex; - flex-direction: column; - gap: 1.5rem; - position: sticky; - top: 0; - height: 100vh; + border-right: 3px solid var(--settings-border); + background-color: var(--settings-sidebar); + padding: 1.5rem 1rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + position: sticky; + top: 0; + height: 100vh; } .settings-brand { - padding: 0 0.5rem 1rem; - border-bottom: 2px solid var(--settings-border); - text-decoration: none; - color: inherit; + padding: 0 0.5rem 1rem; + border-bottom: 2px solid var(--settings-border); + text-decoration: none; + color: inherit; } .settings-brand h1 { - font-family: 'Playfair Display', serif; - font-size: 1.6rem; - margin: 0; - line-height: 1.1; + font-family: "Playfair Display", serif; + font-size: 1.6rem; + margin: 0; + line-height: 1.1; } .settings-brand span { - font-size: 0.65rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; } .settings-nav { - display: flex; - flex-direction: column; - gap: 0.5rem; - flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; } .settings-nav a { - display: flex; - align-items: center; - gap: 0.65rem; - padding: 0.75rem 0.85rem; - border: 2px solid transparent; - text-decoration: none; - color: inherit; - font-weight: 600; - font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.75rem 0.85rem; + border: 2px solid transparent; + text-decoration: none; + color: inherit; + font-weight: 600; + font-size: 0.9rem; } .settings-nav a:hover { - border-color: var(--settings-border); - background: #fff; + border-color: var(--settings-border); + background: #fff; } .settings-nav a.active { - background-color: var(--settings-teal); - border: 2px solid var(--settings-border); - box-shadow: 3px 3px 0 var(--settings-border); + background-color: var(--settings-teal); + border: 2px solid var(--settings-border); + box-shadow: 3px 3px 0 var(--settings-border); } .settings-sidebar .nav-icon { - width: 18px; - height: 18px; - min-width: 18px; - min-height: 18px; - max-width: 18px; - max-height: 18px; - stroke-width: 2; - flex-shrink: 0; + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + max-width: 18px; + max-height: 18px; + stroke-width: 2; + flex-shrink: 0; } .settings-sidebar-footer { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding-top: 1rem; - border-top: 2px solid var(--settings-border); + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 1rem; + border-top: 2px solid var(--settings-border); } .btn-upgrade { - display: block; - text-align: center; - padding: 0.85rem 1rem; - background: var(--settings-teal); - color: #000; - border: 2px solid var(--settings-border); - font-weight: 700; - text-decoration: none; - box-shadow: 4px 4px 0 var(--settings-border); - margin-bottom: 0.5rem; + display: block; + text-align: center; + padding: 0.85rem 1rem; + background: var(--settings-teal); + color: #000; + border: 2px solid var(--settings-border); + font-weight: 700; + text-decoration: none; + box-shadow: 4px 4px 0 var(--settings-border); + margin-bottom: 0.5rem; } .btn-upgrade:active { - transform: translate(4px, 4px); - box-shadow: none; + transform: translate(4px, 4px); + box-shadow: none; } .settings-sidebar-footer a { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.85rem; - color: inherit; - text-decoration: none; - font-weight: 600; - font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.85rem; + color: inherit; + text-decoration: none; + font-weight: 600; + font-size: 0.85rem; } .settings-main { - padding: 0; - overflow-y: auto; + padding: 0; + overflow-y: auto; } .settings-main .topbar { - position: relative; + position: relative; } body.settings-page .topbar, @@ -213,25 +213,25 @@ body.settings-page .menu-btn, body.settings-page .icon-btn, body.settings-page .search-input, body.settings-page .profile-chip { - background-color: var(--settings-card); - color: var(--settings-text); + background-color: var(--settings-card); + color: var(--settings-text); } body.settings-page .action-icon-btn, body.settings-page .brand-text { - color: var(--settings-text); + color: var(--settings-text); } body.settings-page .brand-version, body.settings-page .sidebar-label, body.settings-page .search-icon { - color: var(--settings-muted); - stroke: var(--settings-muted); + color: var(--settings-muted); + stroke: var(--settings-muted); } body.settings-page .nav-link, body.settings-page .service-link { - color: var(--settings-muted); + color: var(--settings-muted); } body.settings-page .nav-link:hover, @@ -240,8 +240,8 @@ body.settings-page .action-icon-btn:hover, body.settings-page .menu-btn:hover, body.settings-page .icon-btn:hover, body.settings-page .profile-chip:hover { - color: var(--settings-text); - background-color: rgba(26, 188, 156, 0.16); + color: var(--settings-text); + background-color: rgba(26, 188, 156, 0.16); } body.settings-page .nav-link.active, @@ -249,670 +249,673 @@ body.settings-page .service-link.active, body.settings-page .primary-action-btn, body.settings-page .avatar, body.settings-page .brand-mark { - color: #111; + color: #111; } .settings-content { - padding: 2.2rem 2.4rem 2.8rem; + padding: 2.2rem 2.4rem 2.8rem; } .settings-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - flex-wrap: wrap; - gap: 1rem; - border-bottom: 2px solid var(--settings-border); - padding-bottom: 1.5rem; - margin-bottom: 2rem; + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 1rem; + border-bottom: 2px solid var(--settings-border); + padding-bottom: 1.5rem; + margin-bottom: 2rem; } .settings-header h1 { - font-family: 'Space Grotesk', sans-serif; - font-size: 2.2rem; - line-height: 1.1; - margin: 0 0 0.5rem; + font-family: "Space Grotesk", sans-serif; + font-size: 2.2rem; + line-height: 1.1; + margin: 0 0 0.5rem; } .settings-header p { - margin: 0; - color: var(--settings-muted); - font-size: 0.95rem; - font-weight: 600; + margin: 0; + color: var(--settings-muted); + font-size: 0.95rem; + font-weight: 600; } body.appearance-dark .settings-header p { - color: var(--settings-muted); + color: var(--settings-muted); } .user-badge { - display: flex; - align-items: center; - gap: 1rem; - background-color: var(--settings-teal); - border: 2px solid var(--settings-border); - padding: 0.5rem 1rem; - box-shadow: 4px 4px 0 var(--settings-border); - flex-shrink: 0; - text-decoration: none; - color: inherit; - cursor: pointer; - transition: transform 0.1s ease; + display: flex; + align-items: center; + gap: 1rem; + background-color: var(--settings-teal); + border: 2px solid var(--settings-border); + padding: 0.5rem 1rem; + box-shadow: 4px 4px 0 var(--settings-border); + flex-shrink: 0; + text-decoration: none; + color: inherit; + cursor: pointer; + transition: transform 0.1s ease; } .user-badge:hover { - transform: translate(-1px, -1px); - box-shadow: 5px 5px 0 var(--settings-border); + transform: translate(-1px, -1px); + box-shadow: 5px 5px 0 var(--settings-border); } .user-badge:active { - transform: translate(2px, 2px); - box-shadow: 2px 2px 0 var(--settings-border); + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--settings-border); } .user-avatar { - width: 36px; - height: 36px; - background: #000; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - color: #fff; - font-weight: 700; - font-size: 0.8rem; + width: 36px; + height: 36px; + background: #000; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + font-size: 0.8rem; } .user-info { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .user-name { - font-weight: 700; - font-size: 0.9rem; + font-weight: 700; + font-size: 0.9rem; } .user-role { - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 1px; - font-weight: 700; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 1px; + font-weight: 700; } .content-layout { - display: grid; - grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); - gap: 1.5rem; - align-items: start; + display: grid; + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); + gap: 1.5rem; + align-items: start; } .sub-nav { - display: flex; - flex-direction: column; - gap: 0.65rem; - position: sticky; - top: 1rem; - padding: 1rem; - border: 2px solid var(--settings-border); - background: var(--settings-card); - box-shadow: var(--settings-shadow); + display: flex; + flex-direction: column; + gap: 0.65rem; + position: sticky; + top: 1rem; + padding: 1rem; + border: 2px solid var(--settings-border); + background: var(--settings-card); + box-shadow: var(--settings-shadow); } .sub-nav-item { - padding: 0.75rem 0.85rem; - border: 2px solid var(--settings-border); - background-color: transparent; - font-weight: 700; - font-size: 0.9rem; - cursor: pointer; - text-decoration: none; - color: var(--settings-muted); - transition: transform 0.15s ease, background-color 0.15s ease, color 0.15s ease; + padding: 0.75rem 0.85rem; + border: 2px solid var(--settings-border); + background-color: transparent; + font-weight: 700; + font-size: 0.9rem; + cursor: pointer; + text-decoration: none; + color: var(--settings-muted); + transition: + transform 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; } .sub-nav-item:hover { - background: rgba(26, 188, 156, 0.12); - color: var(--settings-text); - transform: translateX(4px); + background: rgba(26, 188, 156, 0.12); + color: var(--settings-text); + transform: translateX(4px); } .sub-nav-item.active { - color: #111; - background-color: var(--settings-teal); - box-shadow: var(--settings-shadow); + color: #111; + background-color: var(--settings-teal); + box-shadow: var(--settings-shadow); } .sub-nav-item:active { - transform: translate(3px, 3px); - box-shadow: none; + transform: translate(3px, 3px); + box-shadow: none; } .settings-sections { - display: flex; - flex-direction: column; - gap: 1.5rem; - max-width: 1040px; + display: flex; + flex-direction: column; + gap: 1.5rem; + max-width: 1040px; } .section-card { - background-color: var(--settings-card); - border: 2px solid var(--settings-border); - padding: 1.5rem; - box-shadow: var(--settings-shadow); - scroll-margin-top: 1.5rem; + background-color: var(--settings-card); + border: 2px solid var(--settings-border); + padding: 1.5rem; + box-shadow: var(--settings-shadow); + scroll-margin-top: 1.5rem; } .section-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - gap: 1rem; - flex-wrap: wrap; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + gap: 1rem; + flex-wrap: wrap; } .section-title { - font-family: 'Space Grotesk', sans-serif; - font-size: 1.25rem; - line-height: 1.2; - margin: 0; + font-family: "Space Grotesk", sans-serif; + font-size: 1.25rem; + line-height: 1.2; + margin: 0; } .badge-yellow, .badge-blue { - border: 2px solid #000; - padding: 0.3rem 0.8rem; - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - box-shadow: 2px 2px 0 #000; + border: 2px solid #000; + padding: 0.3rem 0.8rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + box-shadow: 2px 2px 0 #000; } .badge-yellow { - background-color: var(--settings-yellow); + background-color: var(--settings-yellow); } .badge-blue { - background-color: var(--settings-blue-badge); + background-color: var(--settings-blue-badge); } .form-row { - display: flex; - gap: 1.5rem; - margin-bottom: 1.5rem; - flex-wrap: wrap; + display: flex; + gap: 1.5rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; } .form-group { - flex: 1; - min-width: 200px; - display: flex; - flex-direction: column; - gap: 0.5rem; + flex: 1; + min-width: 200px; + display: flex; + flex-direction: column; + gap: 0.5rem; } .form-label { - font-size: 0.8rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--settings-muted); + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--settings-muted); } .form-input, .form-textarea { - padding: 0.8rem; - border: 2px solid var(--settings-border); - background-color: var(--settings-muted-surface); - font-family: inherit; - font-size: 0.9rem; - width: 100%; - color: var(--settings-text); + padding: 0.8rem; + border: 2px solid var(--settings-border); + background-color: var(--settings-muted-surface); + font-family: inherit; + font-size: 0.9rem; + width: 100%; + color: var(--settings-text); } .form-input:focus, .form-textarea:focus { - outline: none; - background-color: var(--settings-card); - box-shadow: var(--settings-shadow); + outline: none; + background-color: var(--settings-card); + box-shadow: var(--settings-shadow); } .form-textarea { - min-height: 100px; - resize: vertical; + min-height: 100px; + resize: vertical; } .divider { - height: 2px; - background-color: var(--settings-border); - margin: 2rem 0; + height: 2px; + background-color: var(--settings-border); + margin: 2rem 0; } .btn-action { - background-color: var(--settings-teal); - color: #000; - border: 2px solid var(--settings-border); - padding: 0.8rem 1.5rem; - font-weight: 700; - font-family: inherit; - cursor: pointer; - box-shadow: var(--settings-shadow); + background-color: var(--settings-teal); + color: #000; + border: 2px solid var(--settings-border); + padding: 0.8rem 1.5rem; + font-weight: 700; + font-family: inherit; + cursor: pointer; + box-shadow: var(--settings-shadow); } .btn-action:active { - transform: translate(3px, 3px); - box-shadow: none; + transform: translate(3px, 3px); + box-shadow: none; } .btn-action-right { - float: right; + float: right; } .setting-row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - padding: 1.5rem; - border: 2px solid var(--settings-border); - background-color: var(--settings-muted-surface); - margin-bottom: 1rem; - flex-wrap: wrap; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1.5rem; + border: 2px solid var(--settings-border); + background-color: var(--settings-muted-surface); + margin-bottom: 1rem; + flex-wrap: wrap; } .setting-info h4 { - margin: 0 0 0.3rem; - font-size: 1rem; - color: var(--settings-text); + margin: 0 0 0.3rem; + font-size: 1rem; + color: var(--settings-text); } .setting-info p { - margin: 0; - font-size: 0.85rem; - color: var(--settings-muted); + margin: 0; + font-size: 0.85rem; + color: var(--settings-muted); } body.appearance-dark .setting-info p { - color: #bbb; + color: #bbb; } .toggle-switch, .toggle-switch-small { - background-color: var(--settings-teal); - border: 2px solid var(--settings-border); - position: relative; - cursor: pointer; - flex-shrink: 0; + background-color: var(--settings-teal); + border: 2px solid var(--settings-border); + position: relative; + cursor: pointer; + flex-shrink: 0; } .toggle-switch { - width: 50px; - height: 26px; + width: 50px; + height: 26px; } .toggle-switch-small { - width: 40px; - height: 20px; + width: 40px; + height: 20px; } .toggle-switch::after, .toggle-switch-small::after { - content: ''; - position: absolute; - background-color: #000; - top: 0; - right: 0; + content: ""; + position: absolute; + background-color: #000; + top: 0; + right: 0; } .toggle-switch::after { - width: 14px; - height: 22px; + width: 14px; + height: 22px; } .toggle-switch-small::after { - width: 10px; - height: 16px; + width: 10px; + height: 16px; } .toggle-switch.off, .toggle-switch-small.off { - background-color: #ccc; + background-color: #ccc; } .toggle-switch.off::after, .toggle-switch-small.off::after { - right: auto; - left: 0; + right: auto; + left: 0; } .btn-secondary { - background: var(--settings-card); - border: 2px solid var(--settings-border); - padding: 0.6rem 1.2rem; - font-weight: 700; - font-family: inherit; - cursor: pointer; - box-shadow: var(--settings-shadow); - color: var(--settings-text); + background: var(--settings-card); + border: 2px solid var(--settings-border); + padding: 0.6rem 1.2rem; + font-weight: 700; + font-family: inherit; + cursor: pointer; + box-shadow: var(--settings-shadow); + color: var(--settings-text); } .pwd-panel { - display: none; - margin-bottom: 1.5rem; - padding: 1rem; - border: 2px solid var(--settings-border); - background: var(--settings-card); + display: none; + margin-bottom: 1.5rem; + padding: 1rem; + border: 2px solid var(--settings-border); + background: var(--settings-card); } .pwd-panel.open { - display: block; + display: block; } .danger-zone { - background-color: var(--settings-red-bg); - border: 2px solid var(--settings-border); - padding: 1.5rem; - margin-top: 1rem; + background-color: var(--settings-red-bg); + border: 2px solid var(--settings-border); + padding: 1.5rem; + margin-top: 1rem; } .danger-title, .danger-text { - color: var(--settings-red-text); + color: var(--settings-red-text); } .danger-title { - margin: 0 0 0.5rem; + margin: 0 0 0.5rem; } .danger-text { - margin: 0 0 1rem; - font-size: 0.85rem; + margin: 0 0 1rem; + font-size: 0.85rem; } .btn-danger { - color: var(--settings-red-text); - background: transparent; - border: none; - font-weight: 700; - cursor: pointer; - padding: 0; - text-decoration: underline; + color: var(--settings-red-text); + background: transparent; + border: none; + font-weight: 700; + cursor: pointer; + padding: 0; + text-decoration: underline; } .billing-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 1.5rem; - margin-bottom: 1.5rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; } .billing-card { - border: 2px solid var(--settings-border); - padding: 1.5rem; - box-shadow: var(--settings-shadow); - background: var(--settings-card); + border: 2px solid var(--settings-border); + padding: 1.5rem; + box-shadow: var(--settings-shadow); + background: var(--settings-card); } .billing-card.active-plan { - background-color: var(--settings-muted-surface); + background-color: var(--settings-muted-surface); } .billing-label { - font-size: 0.7rem; - text-transform: uppercase; - font-weight: 700; - letter-spacing: 1px; - margin-bottom: 1rem; - color: var(--settings-muted); + font-size: 0.7rem; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 1px; + margin-bottom: 1rem; + color: var(--settings-muted); } .plan-name, .invoice-date { - font-family: 'Space Grotesk', sans-serif; - font-size: 1.4rem; - font-weight: 800; - margin: 0 0 0.5rem; + font-family: "Space Grotesk", sans-serif; + font-size: 1.4rem; + font-weight: 800; + margin: 0 0 0.5rem; } .plan-price { - font-size: 1.8rem; - font-weight: 700; - color: var(--settings-darkgreen); - margin: 0 0 1.5rem; + font-size: 1.8rem; + font-weight: 700; + color: var(--settings-darkgreen); + margin: 0 0 1.5rem; } .plan-price span { - font-size: 1rem; - color: var(--settings-muted); + font-size: 1rem; + color: var(--settings-muted); } .btn-black { - background: #000; - color: #fff; - border: 2px solid var(--settings-border); - padding: 0.8rem; - font-weight: 700; - cursor: pointer; - width: 100%; - font-family: inherit; + background: #000; + color: #fff; + border: 2px solid var(--settings-border); + padding: 0.8rem; + font-weight: 700; + cursor: pointer; + width: 100%; + font-family: inherit; } .invoice-est { - font-size: 0.85rem; - color: var(--settings-muted); - margin: 0 0 1.5rem; + font-size: 0.85rem; + color: var(--settings-muted); + margin: 0 0 1.5rem; } .card-info { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + font-weight: 600; } .invoices-table { - width: 100%; - border-collapse: collapse; - border: 2px solid var(--settings-border); - background: var(--settings-card); - box-shadow: var(--settings-shadow); + width: 100%; + border-collapse: collapse; + border: 2px solid var(--settings-border); + background: var(--settings-card); + box-shadow: var(--settings-shadow); } .invoices-table th { - background: #000; - color: #fff; - text-align: left; - padding: 0.8rem 1rem; - font-size: 0.8rem; - text-transform: uppercase; + background: #000; + color: #fff; + text-align: left; + padding: 0.8rem 1rem; + font-size: 0.8rem; + text-transform: uppercase; } .invoices-table td { - padding: 1rem; - border-bottom: 2px solid var(--settings-border); - border-right: 2px solid var(--settings-border); - font-weight: 500; + padding: 1rem; + border-bottom: 2px solid var(--settings-border); + border-right: 2px solid var(--settings-border); + font-weight: 500; } .invoices-table tr:last-child td { - border-bottom: none; + border-bottom: none; } .invoices-table td:last-child { - border-right: none; + border-right: none; } .invoices-table tbody tr { - cursor: pointer; + cursor: pointer; } .invoices-table tbody tr:hover { - background: var(--settings-muted-surface); + background: var(--settings-muted-surface); } .badge-paid { - background: var(--settings-teal); - border: 2px solid #000; - padding: 0.2rem 0.5rem; - font-size: 0.7rem; - font-weight: 700; + background: var(--settings-teal); + border: 2px solid #000; + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + font-weight: 700; } .pref-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; } .pref-group { - margin-bottom: 1.75rem; + margin-bottom: 1.75rem; } .pref-group:last-child { - margin-bottom: 0; + margin-bottom: 0; } .pref-label { - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 1px; - margin-bottom: 0.85rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 0.85rem; } .radio-group { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - padding: 0 0.25rem 0.25rem 0; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0 0.25rem 0.25rem 0; } .radio-btn { - border: 2px solid var(--settings-border); - padding: 0.6rem 1rem; - font-weight: 600; - font-size: 0.85rem; - cursor: pointer; - background: var(--settings-card); - flex: 1; - text-align: center; - min-width: 70px; - color: var(--settings-text); - box-shadow: 2px 2px 0 var(--settings-border); + border: 2px solid var(--settings-border); + padding: 0.6rem 1rem; + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + background: var(--settings-card); + flex: 1; + text-align: center; + min-width: 70px; + color: var(--settings-text); + box-shadow: 2px 2px 0 var(--settings-border); } .radio-btn.active { - background: var(--settings-teal); + background: var(--settings-teal); } .pref-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 0; - border-bottom: 2px solid var(--settings-border); + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0; + border-bottom: 2px solid var(--settings-border); } .pref-row:last-child { - border-bottom: none; + border-bottom: none; } .toast { - opacity: 0; - transform: translateY(12px); - transition: - opacity 0.3s ease, - transform 0.3s ease; + opacity: 0; + transform: translateY(12px); + transition: + opacity 0.3s ease, + transform 0.3s ease; } .toast.visible { - opacity: 1; - transform: translateY(0); + opacity: 1; + transform: translateY(0); } .toast.success { - background: #dcfce7; - border: 2px solid #22c55e; - color: #166534; + background: #dcfce7; + border: 2px solid #22c55e; + color: #166534; } .toast.error { - background: #fef2f2; - border: 2px solid #ef4444; - color: #991b1b; + background: #fef2f2; + border: 2px solid #ef4444; + color: #991b1b; } .modal-overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.45); - display: none; - align-items: center; - justify-content: center; - z-index: 900; - padding: 1rem; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: none; + align-items: center; + justify-content: center; + z-index: 900; + padding: 1rem; } .modal-overlay.open { - display: flex; + display: flex; } .modal-card { - background: var(--settings-card); - border: 3px solid var(--settings-border); - box-shadow: 8px 8px 0 var(--settings-border); - padding: 2rem; - max-width: 420px; - width: 100%; + background: var(--settings-card); + border: 3px solid var(--settings-border); + box-shadow: 8px 8px 0 var(--settings-border); + padding: 2rem; + max-width: 420px; + width: 100%; } .modal-card h3 { - font-family: 'Space Grotesk', sans-serif; - margin: 0 0 1rem; + font-family: "Space Grotesk", sans-serif; + margin: 0 0 1rem; } .modal-actions { - display: flex; - gap: 0.75rem; - margin-top: 1.5rem; + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; } .guest-banner { - background: var(--settings-yellow); - border: 2px solid var(--settings-border); - padding: 0.75rem 1rem; - margin-bottom: 1.5rem; - font-weight: 600; - font-size: 0.9rem; + background: var(--settings-yellow); + border: 2px solid var(--settings-border); + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + font-weight: 600; + font-size: 0.9rem; } @media (max-width: 960px) { - .settings-layout { - grid-template-columns: 1fr; - } - - .settings-sidebar { - position: relative; - height: auto; - } - - .content-layout { - grid-template-columns: 1fr; - } - - .sub-nav { - position: static; - flex-direction: row; - flex-wrap: wrap; - } - - .pref-grid { - grid-template-columns: 1fr; - } - - .settings-content { - padding: 2rem 1.25rem 3rem; - } + .settings-layout { + grid-template-columns: 1fr; + } + + .settings-sidebar { + position: relative; + height: auto; + } + + .content-layout { + grid-template-columns: 1fr; + } + + .sub-nav { + position: static; + flex-direction: row; + flex-wrap: wrap; + } + + .pref-grid { + grid-template-columns: 1fr; + } + + .settings-content { + padding: 2rem 1.25rem 3rem; + } } diff --git a/public/css/style.css b/public/css/style.css index 3c9382b6..b308f7d2 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,2 +1,1842 @@ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-500:oklch(69.6% .17 162.48);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-blue-500:oklch(62.3% .214 259.815);--color-indigo-500:oklch(58.5% .233 277.117);--color-purple-500:oklch(62.7% .265 303.9);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-xl:36rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--text-7xl:4.5rem;--text-7xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-md:12px;--blur-xl:24px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-1\/2{top:50%}.top-1\/4{top:25%}.right-1\/4{right:25%}.bottom-1\/4{bottom:25%}.left-0{left:calc(var(--spacing) * 0)}.left-1\/3{left:33.3333%}.left-1\/4{left:25%}.z-10{z-index:10}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.mb-12{margin-bottom:calc(var(--spacing) * 12)}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-24{height:calc(var(--spacing) * 24)}.h-48{height:calc(var(--spacing) * 48)}.h-56{height:calc(var(--spacing) * 56)}.h-64{height:calc(var(--spacing) * 64)}.h-\[90px\]{height:90px}.h-\[520px\]{height:520px}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-0{width:calc(var(--spacing) * 0)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-4{width:calc(var(--spacing) * 4)}.w-6{width:calc(var(--spacing) * 6)}.w-24{width:calc(var(--spacing) * 24)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-\[170px\]{width:170px}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-\[blob_7s_infinite\]{animation:7s infinite blob}.animate-pulse{animation:var(--animate-pulse)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}:where(.space-x-8>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 8) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-x-reverse)))}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-\[16px\]{border-radius:16px}.rounded-\[28px\]{border-radius:28px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-none{--tw-border-style:none;border-style:none}.border-cyan-400{border-color:var(--color-cyan-400)}.border-cyan-400\/20{border-color:#00d2ef33}@supports (color:color-mix(in lab, red, red)){.border-cyan-400\/20{border-color:color-mix(in oklab, var(--color-cyan-400) 20%, transparent)}}.border-cyan-500\/15{border-color:#00b7d726}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/15{border-color:color-mix(in oklab, var(--color-cyan-500) 15%, transparent)}}.border-cyan-500\/20{border-color:#00b7d733}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/20{border-color:color-mix(in oklab, var(--color-cyan-500) 20%, transparent)}}.border-cyan-500\/25{border-color:#00b7d740}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/25{border-color:color-mix(in oklab, var(--color-cyan-500) 25%, transparent)}}.border-emerald-500\/30{border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/30{border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.border-red-500\/30{border-color:color-mix(in oklab, var(--color-red-500) 30%, transparent)}}.border-slate-600{border-color:var(--color-slate-600)}.border-slate-700{border-color:var(--color-slate-700)}.border-slate-800\/80{border-color:#1d293dcc}@supports (color:color-mix(in lab, red, red)){.border-slate-800\/80{border-color:color-mix(in oklab, var(--color-slate-800) 80%, transparent)}}.border-slate-900\/40{border-color:#0f172b66}@supports (color:color-mix(in lab, red, red)){.border-slate-900\/40{border-color:color-mix(in oklab, var(--color-slate-900) 40%, transparent)}}.border-slate-900\/60{border-color:#0f172b99}@supports (color:color-mix(in lab, red, red)){.border-slate-900\/60{border-color:color-mix(in oklab, var(--color-slate-900) 60%, transparent)}}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/\[0\.04\]{border-color:#ffffff0a}@supports (color:color-mix(in lab, red, red)){.border-white\/\[0\.04\]{border-color:color-mix(in oklab, var(--color-white) 4%, transparent)}}.border-white\/\[0\.06\]{border-color:#ffffff0f}@supports (color:color-mix(in lab, red, red)){.border-white\/\[0\.06\]{border-color:color-mix(in oklab, var(--color-white) 6%, transparent)}}.bg-\[\#0d1b2a\]{background-color:#0d1b2a}.bg-\[\#22d3ee\]{background-color:#22d3ee}.bg-\[\#06101f\]{background-color:#06101f}.bg-blue-500\/\[0\.03\]{background-color:#3080ff08}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/\[0\.03\]{background-color:color-mix(in oklab, var(--color-blue-500) 3%, transparent)}}.bg-cyan-400{background-color:var(--color-cyan-400)}.bg-cyan-500{background-color:var(--color-cyan-500)}.bg-cyan-500\/5{background-color:#00b7d70d}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/5{background-color:color-mix(in oklab, var(--color-cyan-500) 5%, transparent)}}.bg-cyan-500\/10{background-color:#00b7d71a}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/10{background-color:color-mix(in oklab, var(--color-cyan-500) 10%, transparent)}}.bg-cyan-500\/\[0\.03\]{background-color:#00b7d708}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/\[0\.03\]{background-color:color-mix(in oklab, var(--color-cyan-500) 3%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-purple-500{background-color:var(--color-purple-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-red-500\/20{background-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/20{background-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.bg-slate-500{background-color:var(--color-slate-500)}.bg-slate-700{background-color:var(--color-slate-700)}.bg-slate-800\/50{background-color:#1d293d80}@supports (color:color-mix(in lab, red, red)){.bg-slate-800\/50{background-color:color-mix(in oklab, var(--color-slate-800) 50%, transparent)}}.bg-slate-900\/50{background-color:#0f172b80}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/50{background-color:color-mix(in oklab, var(--color-slate-900) 50%, transparent)}}.bg-slate-900\/60{background-color:#0f172b99}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/60{background-color:color-mix(in oklab, var(--color-slate-900) 60%, transparent)}}.bg-slate-950\/10{background-color:#0206181a}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/10{background-color:color-mix(in oklab, var(--color-slate-950) 10%, transparent)}}.bg-slate-950\/40{background-color:#02061866}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/40{background-color:color-mix(in oklab, var(--color-slate-950) 40%, transparent)}}.bg-slate-950\/65{background-color:#020618a6}@supports (color:color-mix(in lab, red, red)){.bg-slate-950\/65{background-color:color-mix(in oklab, var(--color-slate-950) 65%, transparent)}}.bg-transparent{background-color:#0000}.bg-white\/\[0\.02\]{background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.bg-white\/\[0\.02\]{background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.bg-gradient-to-r{--tw-gradient-position:to right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-\[linear-gradient\(to_right\,rgba\(255\,255\,255\,0\.012\)_1px\,transparent_1px\)\,linear-gradient\(to_bottom\,rgba\(255\,255\,255\,0\.012\)_1px\,transparent_1px\)\]{background-image:linear-gradient(90deg,#ffffff03 1px,#0000 1px),linear-gradient(#ffffff03 1px,#0000 1px)}.from-cyan-400{--tw-gradient-from:var(--color-cyan-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-blue-500{--tw-gradient-to:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.bg-\[size\:28px_28px\]{background-size:28px 28px}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-12{padding:calc(var(--spacing) * 12)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-20{padding-block:calc(var(--spacing) * 20)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.font-\[Poppins\]{font-family:Poppins}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[8px\]{font-size:8px}.text-\[11px\]{font-size:11px}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.text-\[\#22d3ee\]{color:#22d3ee}.text-cyan-300{color:var(--color-cyan-300)}.text-cyan-400{color:var(--color-cyan-400)}.text-emerald-300{color:var(--color-emerald-300)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-slate-200{color:var(--color-slate-200)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-900{color:var(--color-slate-900)}.text-slate-950{color:var(--color-slate-950)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.opacity-10{opacity:.1}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.mix-blend-screen{mix-blend-mode:screen}.shadow-\[0_8px_24px_rgba\(0\,0\,0\,0\.3\)\]{--tw-shadow:0 8px 24px var(--tw-shadow-color,#0000004d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_8px_32px_rgba\(6\,182\,212\,0\.12\)\,inset_0_0_12px_rgba\(6\,182\,212\,0\.08\)\]{--tw-shadow:0 8px 32px var(--tw-shadow-color,#06b6d41f), inset 0 0 12px var(--tw-shadow-color,#06b6d414);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-cyan-400\/10{--tw-shadow-color:#00d2ef1a}@supports (color:color-mix(in lab, red, red)){.shadow-cyan-400\/10{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-cyan-400) 10%, transparent) var(--tw-shadow-alpha), transparent)}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur-\[60px\]{--tw-blur:blur(60px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.blur-\[70px\]{--tw-blur:blur(70px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.blur-xl{--tw-blur:blur(var(--blur-xl));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.hover\:border-cyan-400:hover{border-color:var(--color-cyan-400)}.hover\:border-cyan-400\/60:hover{border-color:#00d2ef99}@supports (color:color-mix(in lab, red, red)){.hover\:border-cyan-400\/60:hover{border-color:color-mix(in oklab, var(--color-cyan-400) 60%, transparent)}}.hover\:border-white\/20:hover{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.hover\:border-white\/20:hover{border-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.hover\:bg-cyan-400:hover{background-color:var(--color-cyan-400)}.hover\:bg-cyan-400\/10:hover{background-color:#00d2ef1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-cyan-400\/10:hover{background-color:color-mix(in oklab, var(--color-cyan-400) 10%, transparent)}}.hover\:bg-red-500\/30:hover{background-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-red-500\/30:hover{background-color:color-mix(in oklab, var(--color-red-500) 30%, transparent)}}.hover\:text-cyan-200:hover{color:var(--color-cyan-200)}.hover\:text-cyan-300:hover{color:var(--color-cyan-300)}.hover\:text-slate-200:hover{color:var(--color-slate-200)}.hover\:text-white:hover{color:var(--color-white)}.hover\:opacity-85:hover{opacity:.85}.hover\:shadow-\[0_12px_36px_rgba\(6\,182\,212\,0\.22\)\,inset_0_0_16px_rgba\(6\,182\,212\,0\.15\)\]:hover{--tw-shadow:0 12px 36px var(--tw-shadow-color,#06b6d438), inset 0 0 16px var(--tw-shadow-color,#06b6d426);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:brightness-105:hover{--tw-brightness:brightness(105%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (min-width:48rem){.md\:flex{display:flex}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:gap-16{gap:calc(var(--spacing) * 16)}.md\:text-7xl{font-size:var(--text-7xl);line-height:var(--tw-leading,var(--text-7xl--line-height))}.md\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties { + @supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or + ((-moz-orient: inline) and (not (color: rgb(from red r g b)))) { + *, + :before, + :after, + ::backdrop { + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-x-reverse: 0; + --tw-border-style: solid; + --tw-gradient-position: initial; + --tw-gradient-from: #0000; + --tw-gradient-via: #0000; + --tw-gradient-to: #0000; + --tw-gradient-stops: initial; + --tw-gradient-via-stops: initial; + --tw-gradient-from-position: 0%; + --tw-gradient-via-position: 50%; + --tw-gradient-to-position: 100%; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; + --tw-duration: initial; + --tw-ease: initial; + } + } +} +@layer theme { + :root, + :host { + --font-sans: + ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + --color-red-300: oklch(80.8% 0.114 19.571); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-emerald-300: oklch(84.5% 0.143 164.978); + --color-emerald-500: oklch(69.6% 0.17 162.48); + --color-cyan-200: oklch(91.7% 0.08 205.041); + --color-cyan-300: oklch(86.5% 0.127 207.078); + --color-cyan-400: oklch(78.9% 0.154 211.53); + --color-cyan-500: oklch(71.5% 0.143 215.221); + --color-blue-500: oklch(62.3% 0.214 259.815); + --color-indigo-500: oklch(58.5% 0.233 277.117); + --color-purple-500: oklch(62.7% 0.265 303.9); + --color-slate-200: oklch(92.9% 0.013 255.508); + --color-slate-300: oklch(86.9% 0.022 252.894); + --color-slate-400: oklch(70.4% 0.04 256.788); + --color-slate-500: oklch(55.4% 0.046 257.417); + --color-slate-600: oklch(44.6% 0.043 257.281); + --color-slate-700: oklch(37.2% 0.044 257.287); + --color-slate-800: oklch(27.9% 0.041 260.031); + --color-slate-900: oklch(20.8% 0.042 265.755); + --color-slate-950: oklch(12.9% 0.042 264.695); + --color-gray-300: oklch(87.2% 0.01 258.338); + --color-gray-400: oklch(70.7% 0.022 261.325); + --color-white: #fff; + --spacing: 0.25rem; + --container-xs: 20rem; + --container-xl: 36rem; + --container-2xl: 42rem; + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.75); + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-lg: 1.125rem; + --text-lg--line-height: calc(1.75 / 1.125); + --text-xl: 1.25rem; + --text-xl--line-height: calc(1.75 / 1.25); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --text-3xl: 1.875rem; + --text-3xl--line-height: calc(2.25 / 1.875); + --text-4xl: 2.25rem; + --text-4xl--line-height: calc(2.5 / 2.25); + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-7xl: 4.5rem; + --text-7xl--line-height: 1; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --tracking-wide: 0.025em; + --tracking-wider: 0.05em; + --tracking-widest: 0.1em; + --leading-tight: 1.25; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; + --radius-2xl: 1rem; + --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --blur-md: 12px; + --blur-xl: 24px; + --default-transition-duration: 0.15s; + --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + } +} +@layer base { + *, + :after, + :before, + ::backdrop { + box-sizing: border-box; + border: 0 solid; + margin: 0; + padding: 0; + } + ::file-selector-button { + box-sizing: border-box; + border: 0 solid; + margin: 0; + padding: 0; + } + html, + :host { + -webkit-text-size-adjust: 100%; + tab-size: 4; + line-height: 1.5; + font-family: var( + --default-font-family, + ui-sans-serif, + system-ui, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji" + ); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + -webkit-text-decoration: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, + strong { + font-weight: bolder; + } + code, + kbd, + samp, + pre { + font-family: var( + --default-mono-font-family, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + "Liberation Mono", + "Courier New", + monospace + ); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var( + --default-mono-font-variation-settings, + normal + ); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, + sup { + vertical-align: baseline; + font-size: 75%; + line-height: 0; + position: relative; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, + ul, + menu { + list-style: none; + } + img, + svg, + video, + canvas, + audio, + iframe, + embed, + object { + vertical-align: middle; + display: block; + } + img, + video { + max-width: 100%; + height: auto; + } + button, + input, + select, + optgroup, + textarea { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + opacity: 1; + background-color: #0000; + border-radius: 0; + } + ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + opacity: 1; + background-color: #0000; + border-radius: 0; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not ((-webkit-appearance: -apple-pay-button))) or + (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentColor; + } + @supports (color: color-mix(in lab, red, red)) { + ::placeholder { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit { + padding-block: 0; + } + ::-webkit-datetime-edit-year-field { + padding-block: 0; + } + ::-webkit-datetime-edit-month-field { + padding-block: 0; + } + ::-webkit-datetime-edit-day-field { + padding-block: 0; + } + ::-webkit-datetime-edit-hour-field { + padding-block: 0; + } + ::-webkit-datetime-edit-minute-field { + padding-block: 0; + } + ::-webkit-datetime-edit-second-field { + padding-block: 0; + } + ::-webkit-datetime-edit-millisecond-field { + padding-block: 0; + } + ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, + input:where([type="button"], [type="reset"], [type="submit"]) { + appearance: button; + } + ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button { + height: auto; + } + ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer components; +@layer utilities { + .pointer-events-none { + pointer-events: none; + } + .visible { + visibility: visible; + } + .absolute { + position: absolute; + } + .relative { + position: relative; + } + .static { + position: static; + } + .inset-0 { + inset: calc(var(--spacing) * 0); + } + .top-0 { + top: calc(var(--spacing) * 0); + } + .top-1\/2 { + top: 50%; + } + .top-1\/4 { + top: 25%; + } + .right-1\/4 { + right: 25%; + } + .bottom-1\/4 { + bottom: 25%; + } + .left-0 { + left: calc(var(--spacing) * 0); + } + .left-1\/3 { + left: 33.3333%; + } + .left-1\/4 { + left: 25%; + } + .z-10 { + z-index: 10; + } + .container { + width: 100%; + } + @media (min-width: 40rem) { + .container { + max-width: 40rem; + } + } + @media (min-width: 48rem) { + .container { + max-width: 48rem; + } + } + @media (min-width: 64rem) { + .container { + max-width: 64rem; + } + } + @media (min-width: 80rem) { + .container { + max-width: 80rem; + } + } + @media (min-width: 96rem) { + .container { + max-width: 96rem; + } + } + .mx-auto { + margin-inline: auto; + } + .mt-1 { + margin-top: calc(var(--spacing) * 1); + } + .mt-1\.5 { + margin-top: calc(var(--spacing) * 1.5); + } + .mt-2 { + margin-top: calc(var(--spacing) * 2); + } + .mt-4 { + margin-top: calc(var(--spacing) * 4); + } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } + .mt-8 { + margin-top: calc(var(--spacing) * 8); + } + .mr-1\.5 { + margin-right: calc(var(--spacing) * 1.5); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } + .mb-8 { + margin-bottom: calc(var(--spacing) * 8); + } + .mb-10 { + margin-bottom: calc(var(--spacing) * 10); + } + .mb-12 { + margin-bottom: calc(var(--spacing) * 12); + } + .contents { + display: contents; + } + .flex { + display: flex; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline { + display: inline; + } + .inline-flex { + display: inline-flex; + } + .table { + display: table; + } + .h-1\.5 { + height: calc(var(--spacing) * 1.5); + } + .h-2 { + height: calc(var(--spacing) * 2); + } + .h-2\.5 { + height: calc(var(--spacing) * 2.5); + } + .h-4 { + height: calc(var(--spacing) * 4); + } + .h-6 { + height: calc(var(--spacing) * 6); + } + .h-24 { + height: calc(var(--spacing) * 24); + } + .h-48 { + height: calc(var(--spacing) * 48); + } + .h-56 { + height: calc(var(--spacing) * 56); + } + .h-64 { + height: calc(var(--spacing) * 64); + } + .h-\[90px\] { + height: 90px; + } + .h-\[520px\] { + height: 520px; + } + .h-full { + height: 100%; + } + .min-h-screen { + min-height: 100vh; + } + .w-0 { + width: calc(var(--spacing) * 0); + } + .w-1\.5 { + width: calc(var(--spacing) * 1.5); + } + .w-2\.5 { + width: calc(var(--spacing) * 2.5); + } + .w-4 { + width: calc(var(--spacing) * 4); + } + .w-6 { + width: calc(var(--spacing) * 6); + } + .w-24 { + width: calc(var(--spacing) * 24); + } + .w-48 { + width: calc(var(--spacing) * 48); + } + .w-56 { + width: calc(var(--spacing) * 56); + } + .w-64 { + width: calc(var(--spacing) * 64); + } + .w-\[170px\] { + width: 170px; + } + .w-fit { + width: fit-content; + } + .w-full { + width: 100%; + } + .max-w-2xl { + max-width: var(--container-2xl); + } + .max-w-xl { + max-width: var(--container-xl); + } + .max-w-xs { + max-width: var(--container-xs); + } + .flex-shrink-0, + .shrink-0 { + flex-shrink: 0; + } + .flex-grow { + flex-grow: 1; + } + .border-collapse { + border-collapse: collapse; + } + .transform { + transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) + var(--tw-skew-x,) var(--tw-skew-y,); + } + .animate-\[blob_7s_infinite\] { + animation: 7s infinite blob; + } + .animate-pulse { + animation: var(--animate-pulse); + } + .cursor-not-allowed { + cursor: not-allowed; + } + .cursor-pointer { + cursor: pointer; + } + .resize { + resize: both; + } + .grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .flex-col { + flex-direction: column; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-center { + align-items: center; + } + .items-start { + align-items: flex-start; + } + .justify-between { + justify-content: space-between; + } + .justify-center { + justify-content: center; + } + .gap-1 { + gap: calc(var(--spacing) * 1); + } + .gap-1\.5 { + gap: calc(var(--spacing) * 1.5); + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-5 { + gap: calc(var(--spacing) * 5); + } + .gap-6 { + gap: calc(var(--spacing) * 6); + } + .gap-8 { + gap: calc(var(--spacing) * 8); + } + :where(.space-x-8 > :not(:last-child)) { + --tw-space-x-reverse: 0; + margin-inline-start: calc( + calc(var(--spacing) * 8) * var(--tw-space-x-reverse) + ); + margin-inline-end: calc( + calc(var(--spacing) * 8) * calc(1 - var(--tw-space-x-reverse)) + ); + } + .overflow-hidden { + overflow: hidden; + } + .overflow-x-hidden { + overflow-x: hidden; + } + .rounded-2xl { + border-radius: var(--radius-2xl); + } + .rounded-\[16px\] { + border-radius: 16px; + } + .rounded-\[28px\] { + border-radius: 28px; + } + .rounded-full { + border-radius: 3.40282e38px; + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-xl { + border-radius: var(--radius-xl); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-2 { + border-style: var(--tw-border-style); + border-width: 2px; + } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-dashed { + --tw-border-style: dashed; + border-style: dashed; + } + .border-none { + --tw-border-style: none; + border-style: none; + } + .border-cyan-400 { + border-color: var(--color-cyan-400); + } + .border-cyan-400\/20 { + border-color: #00d2ef33; + } + @supports (color: color-mix(in lab, red, red)) { + .border-cyan-400\/20 { + border-color: color-mix(in oklab, var(--color-cyan-400) 20%, transparent); + } + } + .border-cyan-500\/15 { + border-color: #00b7d726; + } + @supports (color: color-mix(in lab, red, red)) { + .border-cyan-500\/15 { + border-color: color-mix(in oklab, var(--color-cyan-500) 15%, transparent); + } + } + .border-cyan-500\/20 { + border-color: #00b7d733; + } + @supports (color: color-mix(in lab, red, red)) { + .border-cyan-500\/20 { + border-color: color-mix(in oklab, var(--color-cyan-500) 20%, transparent); + } + } + .border-cyan-500\/25 { + border-color: #00b7d740; + } + @supports (color: color-mix(in lab, red, red)) { + .border-cyan-500\/25 { + border-color: color-mix(in oklab, var(--color-cyan-500) 25%, transparent); + } + } + .border-emerald-500\/30 { + border-color: #00bb7f4d; + } + @supports (color: color-mix(in lab, red, red)) { + .border-emerald-500\/30 { + border-color: color-mix( + in oklab, + var(--color-emerald-500) 30%, + transparent + ); + } + } + .border-red-500 { + border-color: var(--color-red-500); + } + .border-red-500\/30 { + border-color: #fb2c364d; + } + @supports (color: color-mix(in lab, red, red)) { + .border-red-500\/30 { + border-color: color-mix(in oklab, var(--color-red-500) 30%, transparent); + } + } + .border-slate-600 { + border-color: var(--color-slate-600); + } + .border-slate-700 { + border-color: var(--color-slate-700); + } + .border-slate-800\/80 { + border-color: #1d293dcc; + } + @supports (color: color-mix(in lab, red, red)) { + .border-slate-800\/80 { + border-color: color-mix( + in oklab, + var(--color-slate-800) 80%, + transparent + ); + } + } + .border-slate-900\/40 { + border-color: #0f172b66; + } + @supports (color: color-mix(in lab, red, red)) { + .border-slate-900\/40 { + border-color: color-mix( + in oklab, + var(--color-slate-900) 40%, + transparent + ); + } + } + .border-slate-900\/60 { + border-color: #0f172b99; + } + @supports (color: color-mix(in lab, red, red)) { + .border-slate-900\/60 { + border-color: color-mix( + in oklab, + var(--color-slate-900) 60%, + transparent + ); + } + } + .border-white\/10 { + border-color: #ffffff1a; + } + @supports (color: color-mix(in lab, red, red)) { + .border-white\/10 { + border-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + } + .border-white\/\[0\.04\] { + border-color: #ffffff0a; + } + @supports (color: color-mix(in lab, red, red)) { + .border-white\/\[0\.04\] { + border-color: color-mix(in oklab, var(--color-white) 4%, transparent); + } + } + .border-white\/\[0\.06\] { + border-color: #ffffff0f; + } + @supports (color: color-mix(in lab, red, red)) { + .border-white\/\[0\.06\] { + border-color: color-mix(in oklab, var(--color-white) 6%, transparent); + } + } + .bg-\[\#0d1b2a\] { + background-color: #0d1b2a; + } + .bg-\[\#22d3ee\] { + background-color: #22d3ee; + } + .bg-\[\#06101f\] { + background-color: #06101f; + } + .bg-blue-500\/\[0\.03\] { + background-color: #3080ff08; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-blue-500\/\[0\.03\] { + background-color: color-mix( + in oklab, + var(--color-blue-500) 3%, + transparent + ); + } + } + .bg-cyan-400 { + background-color: var(--color-cyan-400); + } + .bg-cyan-500 { + background-color: var(--color-cyan-500); + } + .bg-cyan-500\/5 { + background-color: #00b7d70d; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-cyan-500\/5 { + background-color: color-mix( + in oklab, + var(--color-cyan-500) 5%, + transparent + ); + } + } + .bg-cyan-500\/10 { + background-color: #00b7d71a; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-cyan-500\/10 { + background-color: color-mix( + in oklab, + var(--color-cyan-500) 10%, + transparent + ); + } + } + .bg-cyan-500\/\[0\.03\] { + background-color: #00b7d708; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-cyan-500\/\[0\.03\] { + background-color: color-mix( + in oklab, + var(--color-cyan-500) 3%, + transparent + ); + } + } + .bg-emerald-500\/10 { + background-color: #00bb7f1a; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-emerald-500\/10 { + background-color: color-mix( + in oklab, + var(--color-emerald-500) 10%, + transparent + ); + } + } + .bg-indigo-500 { + background-color: var(--color-indigo-500); + } + .bg-purple-500 { + background-color: var(--color-purple-500); + } + .bg-red-500\/10 { + background-color: #fb2c361a; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-red-500\/10 { + background-color: color-mix( + in oklab, + var(--color-red-500) 10%, + transparent + ); + } + } + .bg-red-500\/20 { + background-color: #fb2c3633; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-red-500\/20 { + background-color: color-mix( + in oklab, + var(--color-red-500) 20%, + transparent + ); + } + } + .bg-slate-500 { + background-color: var(--color-slate-500); + } + .bg-slate-700 { + background-color: var(--color-slate-700); + } + .bg-slate-800\/50 { + background-color: #1d293d80; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-slate-800\/50 { + background-color: color-mix( + in oklab, + var(--color-slate-800) 50%, + transparent + ); + } + } + .bg-slate-900\/50 { + background-color: #0f172b80; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-slate-900\/50 { + background-color: color-mix( + in oklab, + var(--color-slate-900) 50%, + transparent + ); + } + } + .bg-slate-900\/60 { + background-color: #0f172b99; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-slate-900\/60 { + background-color: color-mix( + in oklab, + var(--color-slate-900) 60%, + transparent + ); + } + } + .bg-slate-950\/10 { + background-color: #0206181a; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-slate-950\/10 { + background-color: color-mix( + in oklab, + var(--color-slate-950) 10%, + transparent + ); + } + } + .bg-slate-950\/40 { + background-color: #02061866; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-slate-950\/40 { + background-color: color-mix( + in oklab, + var(--color-slate-950) 40%, + transparent + ); + } + } + .bg-slate-950\/65 { + background-color: #020618a6; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-slate-950\/65 { + background-color: color-mix( + in oklab, + var(--color-slate-950) 65%, + transparent + ); + } + } + .bg-transparent { + background-color: #0000; + } + .bg-white\/\[0\.02\] { + background-color: #ffffff05; + } + @supports (color: color-mix(in lab, red, red)) { + .bg-white\/\[0\.02\] { + background-color: color-mix(in oklab, var(--color-white) 2%, transparent); + } + } + .bg-gradient-to-r { + --tw-gradient-position: to right in oklab; + background-image: linear-gradient(var(--tw-gradient-stops)); + } + .bg-\[linear-gradient\(to_right\,rgba\(255\,255\,255\,0\.012\)_1px\,transparent_1px\)\,linear-gradient\(to_bottom\,rgba\(255\,255\,255\,0\.012\)_1px\,transparent_1px\)\] { + background-image: + linear-gradient(90deg, #ffffff03 1px, #0000 1px), + linear-gradient(#ffffff03 1px, #0000 1px); + } + .from-cyan-400 { + --tw-gradient-from: var(--color-cyan-400); + --tw-gradient-stops: var( + --tw-gradient-via-stops, + var(--tw-gradient-position), + var(--tw-gradient-from) var(--tw-gradient-from-position), + var(--tw-gradient-to) var(--tw-gradient-to-position) + ); + } + .to-blue-500 { + --tw-gradient-to: var(--color-blue-500); + --tw-gradient-stops: var( + --tw-gradient-via-stops, + var(--tw-gradient-position), + var(--tw-gradient-from) var(--tw-gradient-from-position), + var(--tw-gradient-to) var(--tw-gradient-to-position) + ); + } + .bg-\[size\:28px_28px\] { + background-size: 28px 28px; + } + .p-1\.5 { + padding: calc(var(--spacing) * 1.5); + } + .p-2 { + padding: calc(var(--spacing) * 2); + } + .p-3\.5 { + padding: calc(var(--spacing) * 3.5); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .p-8 { + padding: calc(var(--spacing) * 8); + } + .p-12 { + padding: calc(var(--spacing) * 12); + } + .px-2 { + padding-inline: calc(var(--spacing) * 2); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } + .px-6 { + padding-inline: calc(var(--spacing) * 6); + } + .px-8 { + padding-inline: calc(var(--spacing) * 8); + } + .py-0\.5 { + padding-block: calc(var(--spacing) * 0.5); + } + .py-1 { + padding-block: calc(var(--spacing) * 1); + } + .py-1\.5 { + padding-block: calc(var(--spacing) * 1.5); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-10 { + padding-block: calc(var(--spacing) * 10); + } + .py-12 { + padding-block: calc(var(--spacing) * 12); + } + .py-20 { + padding-block: calc(var(--spacing) * 20); + } + .pb-6 { + padding-bottom: calc(var(--spacing) * 6); + } + .text-center { + text-align: center; + } + .text-left { + text-align: left; + } + .font-\[Poppins\] { + font-family: Poppins; + } + .font-mono { + font-family: var(--font-mono); + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + .text-5xl { + font-size: var(--text-5xl); + line-height: var(--tw-leading, var(--text-5xl--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .text-xs { + font-size: var(--text-xs); + line-height: var(--tw-leading, var(--text-xs--line-height)); + } + .text-\[8px\] { + font-size: 8px; + } + .text-\[11px\] { + font-size: 11px; + } + .leading-tight { + --tw-leading: var(--leading-tight); + line-height: var(--leading-tight); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-wide { + --tw-tracking: var(--tracking-wide); + letter-spacing: var(--tracking-wide); + } + .tracking-wider { + --tw-tracking: var(--tracking-wider); + letter-spacing: var(--tracking-wider); + } + .tracking-widest { + --tw-tracking: var(--tracking-widest); + letter-spacing: var(--tracking-widest); + } + .text-\[\#22d3ee\] { + color: #22d3ee; + } + .text-cyan-300 { + color: var(--color-cyan-300); + } + .text-cyan-400 { + color: var(--color-cyan-400); + } + .text-emerald-300 { + color: var(--color-emerald-300); + } + .text-gray-300 { + color: var(--color-gray-300); + } + .text-gray-400 { + color: var(--color-gray-400); + } + .text-red-300 { + color: var(--color-red-300); + } + .text-red-400 { + color: var(--color-red-400); + } + .text-slate-200 { + color: var(--color-slate-200); + } + .text-slate-300 { + color: var(--color-slate-300); + } + .text-slate-400 { + color: var(--color-slate-400); + } + .text-slate-500 { + color: var(--color-slate-500); + } + .text-slate-600 { + color: var(--color-slate-600); + } + .text-slate-900 { + color: var(--color-slate-900); + } + .text-slate-950 { + color: var(--color-slate-950); + } + .text-white { + color: var(--color-white); + } + .uppercase { + text-transform: uppercase; + } + .placeholder-gray-400::placeholder { + color: var(--color-gray-400); + } + .opacity-10 { + opacity: 0.1; + } + .opacity-50 { + opacity: 0.5; + } + .opacity-60 { + opacity: 0.6; + } + .mix-blend-screen { + mix-blend-mode: screen; + } + .shadow-\[0_8px_24px_rgba\(0\,0\,0\,0\.3\)\] { + --tw-shadow: 0 8px 24px var(--tw-shadow-color, #0000004d); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-\[0_8px_32px_rgba\(6\,182\,212\,0\.12\)\,inset_0_0_12px_rgba\(6\,182\,212\,0\.08\)\] { + --tw-shadow: + 0 8px 32px var(--tw-shadow-color, #06b6d41f), + inset 0 0 12px var(--tw-shadow-color, #06b6d414); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-inner { + --tw-shadow: inset 0 2px 4px 0 var(--tw-shadow-color, #0000000d); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-lg { + --tw-shadow: + 0 10px 15px -3px var(--tw-shadow-color, #0000001a), + 0 4px 6px -4px var(--tw-shadow-color, #0000001a); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-md { + --tw-shadow: + 0 4px 6px -1px var(--tw-shadow-color, #0000001a), + 0 2px 4px -2px var(--tw-shadow-color, #0000001a); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-cyan-400\/10 { + --tw-shadow-color: #00d2ef1a; + } + @supports (color: color-mix(in lab, red, red)) { + .shadow-cyan-400\/10 { + --tw-shadow-color: color-mix( + in oklab, + color-mix(in oklab, var(--color-cyan-400) 10%, transparent) + var(--tw-shadow-alpha), + transparent + ); + } + } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + .blur-\[60px\] { + --tw-blur: blur(60px); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) + var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) + var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .blur-\[70px\] { + --tw-blur: blur(70px); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) + var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) + var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .blur-xl { + --tw-blur: blur(var(--blur-xl)); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) + var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) + var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) + var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) + var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .backdrop-blur-md { + --tw-backdrop-blur: blur(var(--blur-md)); + -webkit-backdrop-filter: var(--tw-backdrop-blur,) + var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) + var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) + var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) + var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) + var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) + var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) + var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) + var(--tw-backdrop-sepia,); + } + .backdrop-filter { + -webkit-backdrop-filter: var(--tw-backdrop-blur,) + var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) + var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) + var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) + var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) + var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) + var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) + var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) + var(--tw-backdrop-sepia,); + } + .transition { + transition-property: + color, + background-color, + border-color, + outline-color, + text-decoration-color, + fill, + stroke, + --tw-gradient-from, + --tw-gradient-via, + --tw-gradient-to, + opacity, + box-shadow, + transform, + translate, + scale, + rotate, + filter, + -webkit-backdrop-filter, + backdrop-filter, + display, + content-visibility, + overlay, + pointer-events; + transition-timing-function: var( + --tw-ease, + var(--default-transition-timing-function) + ); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-all { + transition-property: all; + transition-timing-function: var( + --tw-ease, + var(--default-transition-timing-function) + ); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .transition-colors { + transition-property: + color, background-color, border-color, outline-color, + text-decoration-color, fill, stroke, --tw-gradient-from, + --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var( + --tw-ease, + var(--default-transition-timing-function) + ); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-200 { + --tw-duration: 0.2s; + transition-duration: 0.2s; + } + .duration-300 { + --tw-duration: 0.3s; + transition-duration: 0.3s; + } + .ease-in-out { + --tw-ease: var(--ease-in-out); + transition-timing-function: var(--ease-in-out); + } + .select-none { + -webkit-user-select: none; + user-select: none; + } + @media (hover: hover) { + .hover\:border-cyan-400:hover { + border-color: var(--color-cyan-400); + } + .hover\:border-cyan-400\/60:hover { + border-color: #00d2ef99; + } + @supports (color: color-mix(in lab, red, red)) { + .hover\:border-cyan-400\/60:hover { + border-color: color-mix( + in oklab, + var(--color-cyan-400) 60%, + transparent + ); + } + } + .hover\:border-white\/20:hover { + border-color: #fff3; + } + @supports (color: color-mix(in lab, red, red)) { + .hover\:border-white\/20:hover { + border-color: color-mix(in oklab, var(--color-white) 20%, transparent); + } + } + .hover\:bg-cyan-400:hover { + background-color: var(--color-cyan-400); + } + .hover\:bg-cyan-400\/10:hover { + background-color: #00d2ef1a; + } + @supports (color: color-mix(in lab, red, red)) { + .hover\:bg-cyan-400\/10:hover { + background-color: color-mix( + in oklab, + var(--color-cyan-400) 10%, + transparent + ); + } + } + .hover\:bg-red-500\/30:hover { + background-color: #fb2c364d; + } + @supports (color: color-mix(in lab, red, red)) { + .hover\:bg-red-500\/30:hover { + background-color: color-mix( + in oklab, + var(--color-red-500) 30%, + transparent + ); + } + } + .hover\:text-cyan-200:hover { + color: var(--color-cyan-200); + } + .hover\:text-cyan-300:hover { + color: var(--color-cyan-300); + } + .hover\:text-slate-200:hover { + color: var(--color-slate-200); + } + .hover\:text-white:hover { + color: var(--color-white); + } + .hover\:opacity-85:hover { + opacity: 0.85; + } + .hover\:shadow-\[0_12px_36px_rgba\(6\,182\,212\,0\.22\)\,inset_0_0_16px_rgba\(6\,182\,212\,0\.15\)\]:hover { + --tw-shadow: + 0 12px 36px var(--tw-shadow-color, #06b6d438), + inset 0 0 16px var(--tw-shadow-color, #06b6d426); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .hover\:brightness-105:hover { + --tw-brightness: brightness(105%); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) + var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) + var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .hover\:brightness-110:hover { + --tw-brightness: brightness(110%); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) + var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) + var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + } + .focus\:ring-0:focus { + --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 + calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); + box-shadow: + var(--tw-inset-shadow), var(--tw-inset-ring-shadow), + var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + @media (min-width: 48rem) { + .md\:flex { + display: flex; + } + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .md\:flex-row { + flex-direction: row; + } + .md\:items-end { + align-items: flex-end; + } + .md\:gap-16 { + gap: calc(var(--spacing) * 16); + } + .md\:text-7xl { + font-size: var(--text-7xl); + line-height: var(--tw-leading, var(--text-7xl--line-height)); + } + .md\:text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + } +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-space-x-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-gradient-position { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-via { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-to { + syntax: ""; + inherits: false; + initial-value: #0000; +} +@property --tw-gradient-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-via-stops { + syntax: "*"; + inherits: false; +} +@property --tw-gradient-from-position { + syntax: ""; + inherits: false; + initial-value: 0%; +} +@property --tw-gradient-via-position { + syntax: ""; + inherits: false; + initial-value: 50%; +} +@property --tw-gradient-to-position { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@property --tw-ease { + syntax: "*"; + inherits: false; +} +@keyframes pulse { + 50% { + opacity: 0.5; + } +} diff --git a/public/css/workspace-shell.css b/public/css/workspace-shell.css index af4fadf9..ff128773 100644 --- a/public/css/workspace-shell.css +++ b/public/css/workspace-shell.css @@ -1,498 +1,502 @@ :root { - --ws-bg: #fcfbf7; - --ws-sidebar: #f3efe6; - --ws-border: #000000; - --ws-teal: #1abc9c; - --ws-yellow: #f1c40f; - --ws-darkgreen: #0b5345; - --ws-sidebar-width: 260px; - --border-thick: 2px solid var(--ws-border); - --shadow-retro-btn: 2px 2px 0 var(--ws-border); + --ws-bg: #fcfbf7; + --ws-sidebar: #f3efe6; + --ws-border: #000000; + --ws-teal: #1abc9c; + --ws-yellow: #f1c40f; + --ws-darkgreen: #0b5345; + --ws-sidebar-width: 260px; + --border-thick: 2px solid var(--ws-border); + --shadow-retro-btn: 2px 2px 0 var(--ws-border); } * { - box-sizing: border-box; + box-sizing: border-box; } body.workspace-page { - margin: 0; - font-family: 'Space Grotesk', sans-serif; - color: #000; - background-color: var(--ws-bg); - min-height: 100vh; + margin: 0; + font-family: "Space Grotesk", sans-serif; + color: #000; + background-color: var(--ws-bg); + min-height: 100vh; } .workspace-layout { - display: grid; - grid-template-columns: var(--ws-sidebar-width) minmax(0, 1fr); - min-height: 100vh; - transition: grid-template-columns 0.2s ease; + display: grid; + grid-template-columns: var(--ws-sidebar-width) minmax(0, 1fr); + min-height: 100vh; + transition: grid-template-columns 0.2s ease; } .workspace-layout.sidebar-collapsed { - --ws-sidebar-width: 88px; + --ws-sidebar-width: 88px; } .workspace-sidebar { - border-right: 3px solid var(--ws-border); - background-color: var(--ws-sidebar); - padding: 1.5rem 1rem; - display: flex; - flex-direction: column; - gap: 1.5rem; - position: sticky; - top: 0; - height: 100vh; + border-right: 3px solid var(--ws-border); + background-color: var(--ws-sidebar); + padding: 1.5rem 1rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + position: sticky; + top: 0; + height: 100vh; } .workspace-brand { - padding: 0 0.5rem 1rem; - border-bottom: 2px solid var(--ws-border); - text-decoration: none; - color: inherit; + padding: 0 0.5rem 1rem; + border-bottom: 2px solid var(--ws-border); + text-decoration: none; + color: inherit; } .workspace-brand h1 { - font-family: 'Playfair Display', serif; - font-size: 1.6rem; - margin: 0; - line-height: 1.1; + font-family: "Playfair Display", serif; + font-size: 1.6rem; + margin: 0; + line-height: 1.1; } .workspace-brand span { - font-size: 0.65rem; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; } .workspace-nav { - display: flex; - flex-direction: column; - gap: 0.5rem; - flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1; } .workspace-nav a { - display: flex; - align-items: center; - gap: 0.65rem; - padding: 0.75rem 0.85rem; - border: 2px solid transparent; - text-decoration: none; - color: inherit; - font-weight: 600; - font-size: 0.9rem; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.75rem 0.85rem; + border: 2px solid transparent; + text-decoration: none; + color: inherit; + font-weight: 600; + font-size: 0.9rem; } .workspace-nav a:hover { - border-color: var(--ws-border); - background: #fff; + border-color: var(--ws-border); + background: #fff; } .workspace-nav a.active { - background-color: var(--ws-teal); - border: 2px solid var(--ws-border); - box-shadow: 3px 3px 0 var(--ws-border); + background-color: var(--ws-teal); + border: 2px solid var(--ws-border); + box-shadow: 3px 3px 0 var(--ws-border); } .workspace-sidebar .nav-icon { - width: 18px; - height: 18px; - min-width: 18px; - min-height: 18px; - max-width: 18px; - max-height: 18px; - stroke-width: 2; - flex-shrink: 0; + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + max-width: 18px; + max-height: 18px; + stroke-width: 2; + flex-shrink: 0; } .workspace-sidebar-footer { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding-top: 1rem; - border-top: 2px solid var(--ws-border); + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 1rem; + border-top: 2px solid var(--ws-border); } .btn-upgrade { - display: block; - text-align: center; - padding: 0.85rem 1rem; - background: var(--ws-teal); - color: #000; - border: 2px solid var(--ws-border); - font-weight: 700; - text-decoration: none; - box-shadow: 4px 4px 0 var(--ws-border); - margin-bottom: 0.5rem; + display: block; + text-align: center; + padding: 0.85rem 1rem; + background: var(--ws-teal); + color: #000; + border: 2px solid var(--ws-border); + font-weight: 700; + text-decoration: none; + box-shadow: 4px 4px 0 var(--ws-border); + margin-bottom: 0.5rem; } .workspace-sidebar-footer a { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.85rem; - color: inherit; - text-decoration: none; - font-weight: 600; - font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.85rem; + color: inherit; + text-decoration: none; + font-weight: 600; + font-size: 0.85rem; } .workspace-main { - padding: 2rem 2.5rem 3rem; - overflow-y: auto; + padding: 2rem 2.5rem 3rem; + overflow-y: auto; } .main, .workspace-main { - min-width: 0; + min-width: 0; } .topbar { - position: sticky; - top: 0; - z-index: 10; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1.5rem; - background-color: var(--ws-bg); - border-bottom: 3px solid var(--ws-border); - padding: 1rem 2rem; + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + background-color: var(--ws-bg); + border-bottom: 3px solid var(--ws-border); + padding: 1rem 2rem; } .menu-btn, .icon-btn { - width: 2.55rem; - height: 2.55rem; - display: grid; - place-items: center; - flex: 0 0 auto; - border: var(--border-thick); - background-color: #fff; - box-shadow: var(--shadow-retro-btn); - color: #111; - cursor: pointer; + width: 2.55rem; + height: 2.55rem; + display: grid; + place-items: center; + flex: 0 0 auto; + border: var(--border-thick); + background-color: #fff; + box-shadow: var(--shadow-retro-btn); + color: #111; + cursor: pointer; } .menu-btn:hover, .icon-btn:hover { - background-color: rgba(26, 188, 156, 0.12); - transform: translateY(-2px); + background-color: rgba(26, 188, 156, 0.12); + transform: translateY(-2px); } .menu-btn svg, .icon-btn svg, .action-icon-btn svg { - width: 1.25rem; - height: 1.25rem; - fill: none; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; + width: 1.25rem; + height: 1.25rem; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; } .search-container { - flex: 1; - max-width: 480px; - position: relative; + flex: 1; + max-width: 480px; + position: relative; } .search-input { - width: 100%; - border: var(--border-thick); - background-color: #fff; - color: #111; - padding: 0.55rem 1rem 0.55rem 2.5rem; - font-family: inherit; - font-size: 0.9rem; - outline: none; + width: 100%; + border: var(--border-thick); + background-color: #fff; + color: #111; + padding: 0.55rem 1rem 0.55rem 2.5rem; + font-family: inherit; + font-size: 0.9rem; + outline: none; } .search-input:focus { - box-shadow: 2px 2px 0 var(--ws-border); + box-shadow: 2px 2px 0 var(--ws-border); } .search-icon { - position: absolute; - left: 0.85rem; - top: 50%; - width: 1.1rem; - height: 1.1rem; - pointer-events: none; - transform: translateY(-50%); - fill: none; - stroke: #4b5563; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; + position: absolute; + left: 0.85rem; + top: 50%; + width: 1.1rem; + height: 1.1rem; + pointer-events: none; + transform: translateY(-50%); + fill: none; + stroke: #4b5563; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; } .topbar-actions { - display: flex; - align-items: center; - gap: 0.75rem; + display: flex; + align-items: center; + gap: 0.75rem; } .action-icon-btn { - width: 2rem; - height: 2rem; - display: grid; - place-items: center; - border: 2px solid transparent; - background: transparent; - color: #111; - cursor: pointer; + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + border: 2px solid transparent; + background: transparent; + color: #111; + cursor: pointer; } .action-icon-btn:hover { - border-color: var(--ws-border); - background-color: rgba(26, 188, 156, 0.12); + border-color: var(--ws-border); + background-color: rgba(26, 188, 156, 0.12); } .profile-chip { - display: inline-flex; - align-items: center; - gap: 0.65rem; - padding: 0.35rem 0.75rem; - border: var(--border-thick); - background-color: #fff; - box-shadow: var(--shadow-retro-btn); - color: #111; - text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.65rem; + padding: 0.35rem 0.75rem; + border: var(--border-thick); + background-color: #fff; + box-shadow: var(--shadow-retro-btn); + color: #111; + text-decoration: none; } .profile-chip:hover { - background-color: rgba(26, 188, 156, 0.12); - transform: translateY(-2px); + background-color: rgba(26, 188, 156, 0.12); + transform: translateY(-2px); } .avatar { - width: 1.6rem; - height: 1.6rem; - display: grid; - place-items: center; - flex-shrink: 0; - background-color: var(--ws-teal); - border: 1px solid var(--ws-border); - color: #111; - font-size: 0.75rem; - font-weight: 700; + width: 1.6rem; + height: 1.6rem; + display: grid; + place-items: center; + flex-shrink: 0; + background-color: var(--ws-teal); + border: 1px solid var(--ws-border); + color: #111; + font-size: 0.75rem; + font-weight: 700; } .profile-name { - font-size: 0.85rem; - font-weight: 700; + font-size: 0.85rem; + font-weight: 700; } .primary-action-btn { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.55rem 1.1rem; - background-color: var(--ws-teal); - border: var(--border-thick); - box-shadow: var(--shadow-retro-btn); - color: #111; - cursor: pointer; - font-family: 'Space Grotesk', sans-serif; - font-size: 0.9rem; - font-weight: 700; - text-decoration: none; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 1.1rem; + background-color: var(--ws-teal); + border: var(--border-thick); + box-shadow: var(--shadow-retro-btn); + color: #111; + cursor: pointer; + font-family: "Space Grotesk", sans-serif; + font-size: 0.9rem; + font-weight: 700; + text-decoration: none; } .primary-action-btn:hover { - transform: translate(-2px, -2px); - box-shadow: 4px 4px 0 var(--ws-border); + transform: translate(-2px, -2px); + box-shadow: 4px 4px 0 var(--ws-border); } .primary-action-btn:active { - transform: translate(1px, 1px); - box-shadow: 1px 1px 0 var(--ws-border); + transform: translate(1px, 1px); + box-shadow: 1px 1px 0 var(--ws-border); } .sidebar { - position: sticky; - top: 0; - height: 100vh; - display: flex; - flex-direction: column; - gap: 0; - background-color: var(--ws-sidebar); - border-right: 3px solid var(--ws-border); - padding: 1.5rem 1rem; - overflow-y: auto; - overflow-x: hidden; + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 0; + background-color: var(--ws-sidebar); + border-right: 3px solid var(--ws-border); + padding: 1.5rem 1rem; + overflow-y: auto; + overflow-x: hidden; } .brand { - display: flex; - align-items: center; - gap: 0.75rem; - padding-bottom: 2.2rem; - border-bottom: var(--border-thick); - margin-bottom: 1.5rem; - color: inherit; - text-decoration: none; + display: flex; + align-items: center; + gap: 0.75rem; + padding-bottom: 2.2rem; + border-bottom: var(--border-thick); + margin-bottom: 1.5rem; + color: inherit; + text-decoration: none; } .brand-mark { - width: 2.2rem; - height: 2.2rem; - display: grid; - place-items: center; - flex-shrink: 0; - background-color: var(--ws-teal); - border: var(--border-thick); - box-shadow: 2px 2px 0 var(--ws-border); - color: #111; - font-size: 1.1rem; - font-weight: 800; + width: 2.2rem; + height: 2.2rem; + display: grid; + place-items: center; + flex-shrink: 0; + background-color: var(--ws-teal); + border: var(--border-thick); + box-shadow: 2px 2px 0 var(--ws-border); + color: #111; + font-size: 1.1rem; + font-weight: 800; } .brand-text { - display: block; - color: #111; - font-family: 'Space Grotesk', sans-serif; - font-size: 1.25rem; - font-weight: 700; - line-height: 1.1; + display: block; + color: #111; + font-family: "Space Grotesk", sans-serif; + font-size: 1.25rem; + font-weight: 700; + line-height: 1.1; } .brand-version { - display: block; - margin-top: -2px; - color: #4b5563; - font-family: monospace; - font-size: 0.7rem; - font-weight: 700; + display: block; + margin-top: -2px; + color: #4b5563; + font-family: monospace; + font-size: 0.7rem; + font-weight: 700; } .sidebar-section { - display: grid; - gap: 0.45rem; - margin-bottom: 2rem; + display: grid; + gap: 0.45rem; + margin-bottom: 2rem; } .sidebar-label { - padding-left: 0.5rem; - margin: 0 0 0.35rem; - color: #4b5563; - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; + padding-left: 0.5rem; + margin: 0 0 0.35rem; + color: #4b5563; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } .nav-link, .service-link { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 0.85rem; - border: 2px solid transparent; - color: #374151; - font-size: 0.9rem; - font-weight: 600; - text-decoration: none; - transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.85rem; + border: 2px solid transparent; + color: #374151; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease, + transform 0.15s ease; } .nav-link:hover, .service-link:hover { - background-color: rgba(26, 188, 156, 0.12); - border-color: var(--ws-border); - color: #111; - transform: translateX(4px); + background-color: rgba(26, 188, 156, 0.12); + border-color: var(--ws-border); + color: #111; + transform: translateX(4px); } .nav-link.active, .service-link.active { - color: #111; - background-color: var(--ws-teal); - border: var(--border-thick); - box-shadow: var(--shadow-retro-btn); - font-weight: 700; + color: #111; + background-color: var(--ws-teal); + border: var(--border-thick); + box-shadow: var(--shadow-retro-btn); + font-weight: 700; } .nav-link svg, .service-link svg { - width: 1.25rem; - height: 1.25rem; - flex: 0 0 auto; - fill: none; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; + width: 1.25rem; + height: 1.25rem; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; } .service-name, .nav-text { - min-width: 0; + min-width: 0; } .service-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .sidebar-footer { - display: grid; - gap: 0.65rem; - margin-top: auto; + display: grid; + gap: 0.65rem; + margin-top: auto; } .upgrade-box { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 0.5rem; - padding: 1rem 0.85rem; - background-color: var(--ws-yellow); - border: var(--border-thick); - box-shadow: var(--shadow-retro-btn); - color: #111; + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.5rem; + padding: 1rem 0.85rem; + background-color: var(--ws-yellow); + border: var(--border-thick); + box-shadow: var(--shadow-retro-btn); + color: #111; } .upgrade-box h4 { - margin: 0; - color: #111; - font-size: 0.95rem; - line-height: 1.1; + margin: 0; + color: #111; + font-size: 0.95rem; + line-height: 1.1; } .upgrade-box p { - margin: 0; - color: #222; - line-height: 1.3; + margin: 0; + color: #222; + line-height: 1.3; } .upgrade-btn { - width: 100%; - padding: 0.4rem; - background-color: #48dbfb; - border: var(--border-thick); - box-shadow: 2px 2px 0 var(--ws-border); - color: #111; - cursor: pointer; - font-family: 'Space Grotesk', sans-serif; - font-size: 0.8rem; - font-weight: 700; - text-align: center; + width: 100%; + padding: 0.4rem; + background-color: #48dbfb; + border: var(--border-thick); + box-shadow: 2px 2px 0 var(--ws-border); + color: #111; + cursor: pointer; + font-family: "Space Grotesk", sans-serif; + font-size: 0.8rem; + font-weight: 700; + text-align: center; } .upgrade-btn:hover { - transform: translate(-1px, -1px); - box-shadow: 3px 3px 0 var(--ws-border); + transform: translate(-1px, -1px); + box-shadow: 3px 3px 0 var(--ws-border); } .workspace-layout.sidebar-collapsed .brand { - justify-content: center; - padding-inline: 0; + justify-content: center; + padding-inline: 0; } .workspace-layout.sidebar-collapsed .brand > div, @@ -500,123 +504,125 @@ body.workspace-page { .workspace-layout.sidebar-collapsed .service-name, .workspace-layout.sidebar-collapsed .upgrade-box, .workspace-layout.sidebar-collapsed .nav-text { - display: none; + display: none; } .workspace-layout.sidebar-collapsed .nav-link, .workspace-layout.sidebar-collapsed .service-link { - justify-content: center; - padding: 0.75rem; - font-size: 0; + justify-content: center; + padding: 0.75rem; + font-size: 0; } .user-badge { - display: flex; - align-items: center; - gap: 1rem; - background-color: var(--ws-teal); - border: 2px solid var(--ws-border); - padding: 0.5rem 1rem; - box-shadow: 4px 4px 0 var(--ws-border); - flex-shrink: 0; - text-decoration: none; - color: inherit; + display: flex; + align-items: center; + gap: 1rem; + background-color: var(--ws-teal); + border: 2px solid var(--ws-border); + padding: 0.5rem 1rem; + box-shadow: 4px 4px 0 var(--ws-border); + flex-shrink: 0; + text-decoration: none; + color: inherit; } .user-avatar { - width: 36px; - height: 36px; - background: #000; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - color: #fff; - font-weight: 700; - font-size: 0.8rem; + width: 36px; + height: 36px; + background: #000; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + font-size: 0.8rem; } .user-info { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .user-name { - font-weight: 700; - font-size: 0.9rem; + font-weight: 700; + font-size: 0.9rem; } .user-role { - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 1px; - font-weight: 700; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 1px; + font-weight: 700; } .toast { - position: fixed; - bottom: 1.5rem; - right: 1.5rem; - padding: 1rem 1.25rem; - background: #000; - color: #fff; - border: 2px solid var(--ws-teal); - box-shadow: 4px 4px 0 var(--ws-teal); - font-weight: 600; - z-index: 1000; - opacity: 0; - transform: translateY(12px); - transition: opacity 0.2s, transform 0.2s; - max-width: 320px; + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + padding: 1rem 1.25rem; + background: #000; + color: #fff; + border: 2px solid var(--ws-teal); + box-shadow: 4px 4px 0 var(--ws-teal); + font-weight: 600; + z-index: 1000; + opacity: 0; + transform: translateY(12px); + transition: + opacity 0.2s, + transform 0.2s; + max-width: 320px; } .toast.visible { - opacity: 1; - transform: translateY(0); + opacity: 1; + transform: translateY(0); } .toast.error { - border-color: #c0392b; - box-shadow: 4px 4px 0 #c0392b; + border-color: #c0392b; + box-shadow: 4px 4px 0 #c0392b; } @media (max-width: 960px) { - .workspace-layout { - grid-template-columns: 1fr; - } - - .workspace-sidebar { - position: relative; - height: auto; - } - - .sidebar { - position: relative; - height: auto; - border-right: 0; - border-bottom: 3px solid var(--ws-border); - } - - .topbar { - position: relative; - flex-wrap: wrap; - padding: 1rem; - } - - .search-container { - order: 3; - max-width: none; - flex-basis: 100%; - } - - .topbar-actions { - margin-left: auto; - } + .workspace-layout { + grid-template-columns: 1fr; + } + + .workspace-sidebar { + position: relative; + height: auto; + } + + .sidebar { + position: relative; + height: auto; + border-right: 0; + border-bottom: 3px solid var(--ws-border); + } + + .topbar { + position: relative; + flex-wrap: wrap; + padding: 1rem; + } + + .search-container { + order: 3; + max-width: none; + flex-basis: 100%; + } + + .topbar-actions { + margin-left: auto; + } } @media (max-width: 640px) { - .profile-name, - .primary-action-btn { - display: none; - } + .profile-name, + .primary-action-btn { + display: none; + } } diff --git a/public/js/pages/dashboard.js b/public/js/pages/dashboard.js index 259e8a7a..6be9b16b 100644 --- a/public/js/pages/dashboard.js +++ b/public/js/pages/dashboard.js @@ -1,39 +1,39 @@ -const dashboardLayout = document.getElementById('dashboardLayout'); - const sidebarToggle = document.getElementById('sidebarToggle'); - const sidebarState = localStorage.getItem('creatorosSidebarCollapsed'); +const dashboardLayout = document.getElementById("dashboardLayout"); +const sidebarToggle = document.getElementById("sidebarToggle"); +const sidebarState = localStorage.getItem("creatorosSidebarCollapsed"); - if (sidebarState === 'true') { - dashboardLayout.classList.add('sidebar-collapsed'); - sidebarToggle.setAttribute('aria-expanded', 'false'); - } +if (sidebarState === "true") { + dashboardLayout.classList.add("sidebar-collapsed"); + sidebarToggle.setAttribute("aria-expanded", "false"); +} + +sidebarToggle.addEventListener("click", () => { + const isCollapsed = dashboardLayout.classList.toggle("sidebar-collapsed"); + sidebarToggle.setAttribute("aria-expanded", String(!isCollapsed)); + localStorage.setItem("creatorosSidebarCollapsed", String(isCollapsed)); +}); - sidebarToggle.addEventListener('click', () => { - const isCollapsed = dashboardLayout.classList.toggle('sidebar-collapsed'); - sidebarToggle.setAttribute('aria-expanded', String(!isCollapsed)); - localStorage.setItem('creatorosSidebarCollapsed', String(isCollapsed)); - }); - const toggleBtn = document.getElementById("theme-toggle"); toggleBtn.addEventListener("click", () => { - document.body.classList.toggle("light-mode"); + document.body.classList.toggle("light-mode"); - if(document.body.classList.contains("light-mode")){ - localStorage.setItem("theme", "light"); - toggleBtn.innerText = "🌙"; - } else { - localStorage.setItem("theme", "dark"); - toggleBtn.innerText = "☀️"; - } + if (document.body.classList.contains("light-mode")) { + localStorage.setItem("theme", "light"); + toggleBtn.innerText = "🌙"; + } else { + localStorage.setItem("theme", "dark"); + toggleBtn.innerText = "☀️"; + } }); window.onload = () => { - const savedTheme = localStorage.getItem("theme"); + const savedTheme = localStorage.getItem("theme"); - if(savedTheme === "light"){ - document.body.classList.add("light-mode"); - toggleBtn.innerText = "🌙"; - } else { - toggleBtn.innerText = "☀️"; - } -} \ No newline at end of file + if (savedTheme === "light") { + document.body.classList.add("light-mode"); + toggleBtn.innerText = "🌙"; + } else { + toggleBtn.innerText = "☀️"; + } +}; diff --git a/public/js/pages/file-upload.js b/public/js/pages/file-upload.js index f706e9e5..b99f25ce 100644 --- a/public/js/pages/file-upload.js +++ b/public/js/pages/file-upload.js @@ -1,152 +1,161 @@ -const dropZone = document.getElementById('drop-zone'); - const fileInput = document.getElementById('file-input'); - const uploadIcon = document.getElementById('upload-icon'); - const dropText = document.getElementById('drop-text'); - const dropSub = document.getElementById('drop-sub'); - const fileInfo = document.getElementById('file-info'); - const fileName = document.getElementById('file-name'); - const fileSize = document.getElementById('file-size'); - const removeBtn = document.getElementById('remove-file'); - const uploadBtn = document.getElementById('upload-btn'); - const progressArea = document.getElementById('progress-area'); - const progressBar = document.getElementById('progress-bar'); - const progressPct = document.getElementById('progress-pct'); - const progressLabel = document.getElementById('progress-label'); - const statusEl = document.getElementById('status'); - let selectedFile = null; - - // Firefox fix: preventDefault is REQUIRED on dragover - dropZone.addEventListener('dragover', function(e) { - e.preventDefault(); - e.stopPropagation(); - dropZone.classList.add('border-cyan-400', 'bg-slate-800/50'); - dropZone.classList.remove('border-slate-600'); - }); - - dropZone.addEventListener('dragenter', function(e) { - e.preventDefault(); - e.stopPropagation(); - dropZone.classList.add('border-cyan-400', 'bg-slate-800/50'); - dropZone.classList.remove('border-slate-600'); - }); - - dropZone.addEventListener('dragleave', function(e) { - e.preventDefault(); - e.stopPropagation(); - dropZone.classList.remove('border-cyan-400', 'bg-slate-800/50'); - dropZone.classList.add('border-slate-600'); - }); - - // Firefox fix: preventDefault is REQUIRED on drop - dropZone.addEventListener('drop', function(e) { - e.preventDefault(); - e.stopPropagation(); - dropZone.classList.remove('border-cyan-400', 'bg-slate-800/50'); - dropZone.classList.add('border-slate-600'); - - var files = e.dataTransfer.files; - if (files.length > 0) { - handleFile(files[0]); - } - }); - - dropZone.addEventListener('click', function() { - if (!selectedFile) fileInput.click(); - }); - - fileInput.addEventListener('change', function() { - if (this.files.length > 0) handleFile(this.files[0]); - }); - - removeBtn.addEventListener('click', function(e) { - e.stopPropagation(); - clearSelection(); - }); - - uploadBtn.addEventListener('click', function() { - if (!selectedFile) return; - uploadFile(selectedFile); - }); - - function handleFile(file) { - selectedFile = file; - fileName.textContent = file.name; - fileSize.textContent = formatSize(file.size); - fileInfo.classList.remove('hidden'); - uploadBtn.disabled = false; - uploadBtn.className = 'mt-6 w-full rounded-full bg-gradient-to-r from-cyan-400 to-blue-500 px-6 py-3 font-bold text-slate-900 cursor-pointer hover:brightness-110 transition-all duration-200'; - uploadBtn.textContent = 'Upload ' + file.name; - dropText.textContent = 'File selected'; - dropSub.textContent = 'click to choose a different file'; - uploadIcon.textContent = '✅'; - } - - function clearSelection() { - selectedFile = null; - fileInfo.classList.add('hidden'); - uploadBtn.disabled = true; - uploadBtn.className = 'mt-6 w-full rounded-full bg-gradient-to-r from-cyan-400 to-blue-500 px-6 py-3 font-bold text-slate-900 opacity-50 cursor-not-allowed transition-all duration-200'; - uploadBtn.textContent = 'Select a file to upload'; - fileInput.value = ''; - dropText.textContent = 'Drag & drop a file here'; - dropSub.textContent = 'or click to browse files'; - uploadIcon.textContent = '📁'; - progressArea.classList.add('hidden'); - statusEl.classList.add('hidden'); - } - - function uploadFile(file) { - var formData = new FormData(); - formData.append('file', file); - - var xhr = new XMLHttpRequest(); - - xhr.upload.onprogress = function(e) { - progressArea.classList.remove('hidden'); - if (e.lengthComputable) { - var pct = Math.round((e.loaded / e.total) * 100); - progressBar.style.width = pct + '%'; - progressPct.textContent = pct + '%'; - } - }; - - xhr.onload = function() { - progressBar.style.width = '100%'; - progressPct.textContent = '100%'; - progressLabel.textContent = 'Complete!'; - - if (xhr.status >= 200 && xhr.status < 300) { - var data = JSON.parse(xhr.responseText); - statusEl.className = 'mt-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm text-emerald-300'; - statusEl.innerHTML = 'Upload successful!
File: ' + data.filename + '
Size: ' + formatSize(data.size); - statusEl.classList.remove('hidden'); - } else { - statusEl.className = 'mt-4 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-300'; - statusEl.textContent = 'Upload failed. Please try again.'; - statusEl.classList.remove('hidden'); - } - uploadBtn.disabled = false; - uploadBtn.textContent = 'Upload another file'; - }; - - xhr.onerror = function() { - progressLabel.textContent = 'Error'; - statusEl.className = 'mt-4 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-300'; - statusEl.textContent = 'Network error. Please check your connection.'; - statusEl.classList.remove('hidden'); - uploadBtn.disabled = false; - uploadBtn.textContent = 'Try Again'; - }; - - xhr.open('POST', '/services/file-upload/upload'); - xhr.send(formData); - - uploadBtn.disabled = true; - uploadBtn.textContent = 'Uploading...'; - } - - function formatSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / 1048576).toFixed(1) + ' MB'; - } \ No newline at end of file +const dropZone = document.getElementById("drop-zone"); +const fileInput = document.getElementById("file-input"); +const uploadIcon = document.getElementById("upload-icon"); +const dropText = document.getElementById("drop-text"); +const dropSub = document.getElementById("drop-sub"); +const fileInfo = document.getElementById("file-info"); +const fileName = document.getElementById("file-name"); +const fileSize = document.getElementById("file-size"); +const removeBtn = document.getElementById("remove-file"); +const uploadBtn = document.getElementById("upload-btn"); +const progressArea = document.getElementById("progress-area"); +const progressBar = document.getElementById("progress-bar"); +const progressPct = document.getElementById("progress-pct"); +const progressLabel = document.getElementById("progress-label"); +const statusEl = document.getElementById("status"); +let selectedFile = null; + +// Firefox fix: preventDefault is REQUIRED on dragover +dropZone.addEventListener("dragover", function (e) { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.add("border-cyan-400", "bg-slate-800/50"); + dropZone.classList.remove("border-slate-600"); +}); + +dropZone.addEventListener("dragenter", function (e) { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.add("border-cyan-400", "bg-slate-800/50"); + dropZone.classList.remove("border-slate-600"); +}); + +dropZone.addEventListener("dragleave", function (e) { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.remove("border-cyan-400", "bg-slate-800/50"); + dropZone.classList.add("border-slate-600"); +}); + +// Firefox fix: preventDefault is REQUIRED on drop +dropZone.addEventListener("drop", function (e) { + e.preventDefault(); + e.stopPropagation(); + dropZone.classList.remove("border-cyan-400", "bg-slate-800/50"); + dropZone.classList.add("border-slate-600"); + + var files = e.dataTransfer.files; + if (files.length > 0) { + handleFile(files[0]); + } +}); + +dropZone.addEventListener("click", function () { + if (!selectedFile) fileInput.click(); +}); + +fileInput.addEventListener("change", function () { + if (this.files.length > 0) handleFile(this.files[0]); +}); + +removeBtn.addEventListener("click", function (e) { + e.stopPropagation(); + clearSelection(); +}); + +uploadBtn.addEventListener("click", function () { + if (!selectedFile) return; + uploadFile(selectedFile); +}); + +function handleFile(file) { + selectedFile = file; + fileName.textContent = file.name; + fileSize.textContent = formatSize(file.size); + fileInfo.classList.remove("hidden"); + uploadBtn.disabled = false; + uploadBtn.className = + "mt-6 w-full rounded-full bg-gradient-to-r from-cyan-400 to-blue-500 px-6 py-3 font-bold text-slate-900 cursor-pointer hover:brightness-110 transition-all duration-200"; + uploadBtn.textContent = "Upload " + file.name; + dropText.textContent = "File selected"; + dropSub.textContent = "click to choose a different file"; + uploadIcon.textContent = "✅"; +} + +function clearSelection() { + selectedFile = null; + fileInfo.classList.add("hidden"); + uploadBtn.disabled = true; + uploadBtn.className = + "mt-6 w-full rounded-full bg-gradient-to-r from-cyan-400 to-blue-500 px-6 py-3 font-bold text-slate-900 opacity-50 cursor-not-allowed transition-all duration-200"; + uploadBtn.textContent = "Select a file to upload"; + fileInput.value = ""; + dropText.textContent = "Drag & drop a file here"; + dropSub.textContent = "or click to browse files"; + uploadIcon.textContent = "📁"; + progressArea.classList.add("hidden"); + statusEl.classList.add("hidden"); +} + +function uploadFile(file) { + var formData = new FormData(); + formData.append("file", file); + + var xhr = new XMLHttpRequest(); + + xhr.upload.onprogress = function (e) { + progressArea.classList.remove("hidden"); + if (e.lengthComputable) { + var pct = Math.round((e.loaded / e.total) * 100); + progressBar.style.width = pct + "%"; + progressPct.textContent = pct + "%"; + } + }; + + xhr.onload = function () { + progressBar.style.width = "100%"; + progressPct.textContent = "100%"; + progressLabel.textContent = "Complete!"; + + if (xhr.status >= 200 && xhr.status < 300) { + var data = JSON.parse(xhr.responseText); + statusEl.className = + "mt-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-4 text-sm text-emerald-300"; + statusEl.innerHTML = + "Upload successful!
File: " + + data.filename + + "
Size: " + + formatSize(data.size); + statusEl.classList.remove("hidden"); + } else { + statusEl.className = + "mt-4 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-300"; + statusEl.textContent = "Upload failed. Please try again."; + statusEl.classList.remove("hidden"); + } + uploadBtn.disabled = false; + uploadBtn.textContent = "Upload another file"; + }; + + xhr.onerror = function () { + progressLabel.textContent = "Error"; + statusEl.className = + "mt-4 rounded-xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-300"; + statusEl.textContent = "Network error. Please check your connection."; + statusEl.classList.remove("hidden"); + uploadBtn.disabled = false; + uploadBtn.textContent = "Try Again"; + }; + + xhr.open("POST", "/services/file-upload/upload"); + xhr.send(formData); + + uploadBtn.disabled = true; + uploadBtn.textContent = "Uploading..."; +} + +function formatSize(bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / 1048576).toFixed(1) + " MB"; +} diff --git a/public/js/pages/home.js b/public/js/pages/home.js index 668cfa59..a34faa11 100644 --- a/public/js/pages/home.js +++ b/public/js/pages/home.js @@ -1,71 +1,75 @@ gsap.registerPlugin(ScrollTrigger); - // --- HERO ANIMATIONS --- - const tl = gsap.timeline({ defaults: { ease: "power3.out" } }); +// --- HERO ANIMATIONS --- +const tl = gsap.timeline({ defaults: { ease: "power3.out" } }); - tl.from(".logo", { opacity: 0, y: -30, duration: 0.8, delay: 0.2 }); - tl.from(".nav-links > a", { opacity: 0, y: -30, duration: 0.5, stagger: 0.2 }, "-=0.5"); - tl.from(".hero-title", { opacity: 0, y: 50, duration: 1 }, "-=0.6"); - tl.from(".hero-subtitle", { opacity: 0, y: 40, duration: 0.8 }, "-=0.7"); - tl.from(".hero-form", { opacity: 0, y: 30, duration: 0.8 }, "-=0.6"); +tl.from(".logo", { opacity: 0, y: -30, duration: 0.8, delay: 0.2 }); +tl.from( + ".nav-links > a", + { opacity: 0, y: -30, duration: 0.5, stagger: 0.2 }, + "-=0.5", +); +tl.from(".hero-title", { opacity: 0, y: 50, duration: 1 }, "-=0.6"); +tl.from(".hero-subtitle", { opacity: 0, y: 40, duration: 0.8 }, "-=0.7"); +tl.from(".hero-form", { opacity: 0, y: 30, duration: 0.8 }, "-=0.6"); - // --- SCROLL-TRIGGERED ANIMATIONS --- +// --- SCROLL-TRIGGERED ANIMATIONS --- - // Animate Section Titles on scroll - gsap.utils.toArray('.section-title').forEach(title => { - gsap.from(title, { - opacity: 0, - y: 50, - duration: 0.8, - scrollTrigger: { - trigger: title, - start: "top 85%", - toggleActions: "play none none none" - } - }); - }); +// Animate Section Titles on scroll +gsap.utils.toArray(".section-title").forEach((title) => { + gsap.from(title, { + opacity: 0, + y: 50, + duration: 0.8, + scrollTrigger: { + trigger: title, + start: "top 85%", + toggleActions: "play none none none", + }, + }); +}); - gsap.utils.toArray('.section-subtitle').forEach(subtitle => { - gsap.from(subtitle, { - opacity: 0, - y: 40, - duration: 0.8, - delay: 0.2, - scrollTrigger: { - trigger: subtitle, - start: "top 90%", - toggleActions: "play none none none" - } - }); - }); +gsap.utils.toArray(".section-subtitle").forEach((subtitle) => { + gsap.from(subtitle, { + opacity: 0, + y: 40, + duration: 0.8, + delay: 0.2, + scrollTrigger: { + trigger: subtitle, + start: "top 90%", + toggleActions: "play none none none", + }, + }); +}); - // Animate Feature Cards - gsap.utils.toArray('.feature-card').forEach((card, i) => { - gsap.from(card, { - opacity: 0, - scale: 0.9, - y: 60, - duration: 0.6, - delay: i * 0.15, - scrollTrigger: { - trigger: card, - start: "top 85%", - toggleActions: "play none none none" - } - }); - }); +// Animate Feature Cards +gsap.utils.toArray(".feature-card").forEach((card, i) => { + gsap.from(card, { + opacity: 0, + scale: 0.9, + y: 60, + duration: 0.6, + delay: i * 0.15, + scrollTrigger: { + trigger: card, + start: "top 85%", + toggleActions: "play none none none", + }, + }); +}); - // Animate "How it works" steps - gsap.utils.toArray('.step').forEach((step, i) => { - gsap.from(step, { - opacity: 0, - y: 50, - duration: 0.5, - delay: i * 0.2, - scrollTrigger: { - trigger: ".step", - start: "top 85%", - toggleActions: "play none none none" - } - }); - }); \ No newline at end of file +// Animate "How it works" steps +gsap.utils.toArray(".step").forEach((step, i) => { + gsap.from(step, { + opacity: 0, + y: 50, + duration: 0.5, + delay: i * 0.2, + scrollTrigger: { + trigger: ".step", + start: "top 85%", + toggleActions: "play none none none", + }, + }); +}); diff --git a/public/js/pages/login.js b/public/js/pages/login.js index 9a0f3718..3ac43283 100644 --- a/public/js/pages/login.js +++ b/public/js/pages/login.js @@ -1,14 +1,14 @@ -const googleLogin = document.querySelector('.google-login'); -const emailForm = document.querySelector('#email-login-form'); -const contributorForm = document.querySelector('#contributor-login-form'); +const googleLogin = document.querySelector(".google-login"); +const emailForm = document.querySelector("#email-login-form"); +const contributorForm = document.querySelector("#contributor-login-form"); let emailLoading = false; let contributorLoading = false; -googleLogin?.addEventListener('click', function () { -this.setAttribute('aria-busy', 'true'); +googleLogin?.addEventListener("click", function () { + this.setAttribute("aria-busy", "true"); -``` + ``` const span = this.querySelector('span'); if (span) { @@ -16,14 +16,13 @@ if (span) { } this.disabled = true; -``` - +```; }); -emailForm?.addEventListener('submit', function () { -if (emailLoading) return false; +emailForm?.addEventListener("submit", function () { + if (emailLoading) return false; -``` + ``` emailLoading = true; const submitButton = this.querySelector('button[type="submit"]'); @@ -37,14 +36,13 @@ if (submitButton) { submitButton.textContent = submitButton.dataset.loadingText || 'Signing in...'; } -``` - +```; }); -contributorForm?.addEventListener('submit', async function (event) { -event.preventDefault(); +contributorForm?.addEventListener("submit", async function (event) { + event.preventDefault(); -``` + ``` if (contributorLoading) return; contributorLoading = true; @@ -91,33 +89,32 @@ try { } finally { contributorLoading = false; } -``` - +```; }); // Show/Hide Password Toggle -const togglePasswordBtn = document.querySelector('#toggle-password'); -const passwordInput = document.querySelector('#login-password'); - -togglePasswordBtn?.addEventListener('click', function () { - const isPassword = passwordInput.getAttribute('type') === 'password'; - passwordInput.setAttribute('type', isPassword ? 'text' : 'password'); - - if (isPassword) { - this.innerHTML = ` +const togglePasswordBtn = document.querySelector("#toggle-password"); +const passwordInput = document.querySelector("#login-password"); + +togglePasswordBtn?.addEventListener("click", function () { + const isPassword = passwordInput.getAttribute("type") === "password"; + passwordInput.setAttribute("type", isPassword ? "text" : "password"); + + if (isPassword) { + this.innerHTML = ` `; - this.setAttribute('aria-label', 'Hide password'); - } else { - this.innerHTML = ` + this.setAttribute("aria-label", "Hide password"); + } else { + this.innerHTML = ` `; - this.setAttribute('aria-label', 'Show password'); - } + this.setAttribute("aria-label", "Show password"); + } }); diff --git a/public/js/pages/services-hub.js b/public/js/pages/services-hub.js index 4489bc31..d79c2977 100644 --- a/public/js/pages/services-hub.js +++ b/public/js/pages/services-hub.js @@ -1,184 +1,231 @@ // Navbar interactivity: mobile menu and 3D toggle -(function(){ - const navToggle = document.getElementById('navToggle'); - const mobileMenu = document.getElementById('mobileMenu'); - const nav3d = document.getElementById('nav3dToggle'); - if(navToggle && mobileMenu){ - navToggle.addEventListener('click', ()=>{ - const open = mobileMenu.classList.toggle('open'); - mobileMenu.setAttribute('aria-hidden', (!open).toString()); - navToggle.setAttribute('aria-expanded', open.toString()); +(function () { + const navToggle = document.getElementById("navToggle"); + const mobileMenu = document.getElementById("mobileMenu"); + const nav3d = document.getElementById("nav3dToggle"); + if (navToggle && mobileMenu) { + navToggle.addEventListener("click", () => { + const open = mobileMenu.classList.toggle("open"); + mobileMenu.setAttribute("aria-hidden", (!open).toString()); + navToggle.setAttribute("aria-expanded", open.toString()); }); } // 3D toggle — uses Site3D if available - function update3dButton(enabled){ - if(!nav3d) return; - nav3d.setAttribute('aria-pressed', enabled? 'true':'false'); + function update3dButton(enabled) { + if (!nav3d) return; + nav3d.setAttribute("aria-pressed", enabled ? "true" : "false"); } - if(nav3d){ - const saved = localStorage.getItem('site3d_enabled'); - const enabled = saved === null ? true : saved === '1'; + if (nav3d) { + const saved = localStorage.getItem("site3d_enabled"); + const enabled = saved === null ? true : saved === "1"; update3dButton(enabled); - nav3d.addEventListener('click', ()=>{ - const cur = nav3d.getAttribute('aria-pressed') === 'true'; + nav3d.addEventListener("click", () => { + const cur = nav3d.getAttribute("aria-pressed") === "true"; const next = !cur; update3dButton(next); - localStorage.setItem('site3d_enabled', next? '1':'0'); - if(window.Site3D){ if(next) window.Site3D.enable(); else window.Site3D.disable(); } + localStorage.setItem("site3d_enabled", next ? "1" : "0"); + if (window.Site3D) { + if (next) window.Site3D.enable(); + else window.Site3D.disable(); + } }); } })(); // Navbar scroll trace: update fill based on document scroll progress -(function(){ - const traceFill = document.getElementById('navTraceFill'); - if(!traceFill) return; - function docHeight(){ return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight); } - function updateTrace(){ +(function () { + const traceFill = document.getElementById("navTraceFill"); + if (!traceFill) return; + function docHeight() { + return Math.max( + document.body.scrollHeight, + document.documentElement.scrollHeight, + ); + } + function updateTrace() { const scrollY = window.scrollY || window.pageYOffset; const max = Math.max(0, docHeight() - window.innerHeight); const progress = max > 0 ? Math.min(1, scrollY / max) : 0; traceFill.style.transform = `translateY(-50%) scaleX(${progress})`; } let ticking = false; - window.addEventListener('scroll', ()=>{ if(!ticking){ requestAnimationFrame(()=>{ updateTrace(); ticking=false; }); ticking=true; } }); - window.addEventListener('resize', updateTrace); + window.addEventListener("scroll", () => { + if (!ticking) { + requestAnimationFrame(() => { + updateTrace(); + ticking = false; + }); + ticking = true; + } + }); + window.addEventListener("resize", updateTrace); // init updateTrace(); })(); /* ── CURSOR ── */ -const cursor = document.getElementById('cursor'); -const ring = document.getElementById('cursor-ring'); -let mx=0,my=0,rx=0,ry=0; -document.addEventListener('mousemove', e => { - mx=e.clientX; my=e.clientY; - cursor.style.left=mx+'px'; cursor.style.top=my+'px'; +const cursor = document.getElementById("cursor"); +const ring = document.getElementById("cursor-ring"); +let mx = 0, + my = 0, + rx = 0, + ry = 0; +document.addEventListener("mousemove", (e) => { + mx = e.clientX; + my = e.clientY; + cursor.style.left = mx + "px"; + cursor.style.top = my + "px"; }); -function animateRing(){ - rx+=(mx-rx)*0.1; ry+=(my-ry)*0.1; - ring.style.left=rx+'px'; ring.style.top=ry+'px'; +function animateRing() { + rx += (mx - rx) * 0.1; + ry += (my - ry) * 0.1; + ring.style.left = rx + "px"; + ring.style.top = ry + "px"; requestAnimationFrame(animateRing); } animateRing(); -document.querySelectorAll('a,button,.svc-card').forEach(el=>{ - el.addEventListener('mouseenter',()=>{ - cursor.style.width='20px'; cursor.style.height='20px'; - ring.style.width='54px'; ring.style.height='54px'; - ring.style.borderColor='rgba(0,0,0,0.7)'; +document.querySelectorAll("a,button,.svc-card").forEach((el) => { + el.addEventListener("mouseenter", () => { + cursor.style.width = "20px"; + cursor.style.height = "20px"; + ring.style.width = "54px"; + ring.style.height = "54px"; + ring.style.borderColor = "rgba(0,0,0,0.7)"; }); - el.addEventListener('mouseleave',()=>{ - cursor.style.width='12px'; cursor.style.height='12px'; - ring.style.width='36px'; ring.style.height='36px'; - ring.style.borderColor='rgba(0,0,0,0.45)'; + el.addEventListener("mouseleave", () => { + cursor.style.width = "12px"; + cursor.style.height = "12px"; + ring.style.width = "36px"; + ring.style.height = "36px"; + ring.style.borderColor = "rgba(0,0,0,0.45)"; }); }); /* ── PARTICLES ── */ -const canvas = document.getElementById('particles'); -const ctx = canvas.getContext('2d'); -let W,H,particles=[]; -function resize(){ - W=canvas.width=window.innerWidth; - H=canvas.height=window.innerHeight; +const canvas = document.getElementById("particles"); +const ctx = canvas.getContext("2d"); +let W, + H, + particles = []; +function resize() { + W = canvas.width = window.innerWidth; + H = canvas.height = window.innerHeight; } -resize(); window.addEventListener('resize',resize); +resize(); +window.addEventListener("resize", resize); class Particle { - constructor(){this.reset();} - reset(){ - this.x=Math.random()*W; this.y=Math.random()*H; - this.vx=(Math.random()-0.5)*0.25; this.vy=(Math.random()-0.5)*0.25; - this.r=Math.random()*1.5+0.3; - this.a=Math.random()*0.4+0.05; - this.c=Math.random()>0.6?'34,211,238':'129,140,248'; + constructor() { + this.reset(); } - update(){ - this.x+=this.vx; this.y+=this.vy; - if(this.x<0||this.x>W||this.y<0||this.y>H) this.reset(); + reset() { + this.x = Math.random() * W; + this.y = Math.random() * H; + this.vx = (Math.random() - 0.5) * 0.25; + this.vy = (Math.random() - 0.5) * 0.25; + this.r = Math.random() * 1.5 + 0.3; + this.a = Math.random() * 0.4 + 0.05; + this.c = Math.random() > 0.6 ? "34,211,238" : "129,140,248"; } - draw(){ + update() { + this.x += this.vx; + this.y += this.vy; + if (this.x < 0 || this.x > W || this.y < 0 || this.y > H) this.reset(); + } + draw() { ctx.beginPath(); - ctx.arc(this.x,this.y,this.r,0,Math.PI*2); - ctx.fillStyle=`rgba(${this.c},${this.a})`; + ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); + ctx.fillStyle = `rgba(${this.c},${this.a})`; ctx.fill(); } } -for(let i=0;i<120;i++) particles.push(new Particle()); +for (let i = 0; i < 120; i++) particles.push(new Particle()); // draw connections -function drawConnections(){ - for(let i=0;i{p.update();p.draw();}); +function animateParticles() { + ctx.clearRect(0, 0, W, H); + particles.forEach((p) => { + p.update(); + p.draw(); + }); drawConnections(); requestAnimationFrame(animateParticles); } animateParticles(); /* ── NAVBAR SCROLL ── */ -window.addEventListener('scroll',()=>{ - document.getElementById('navbar').classList.toggle('scrolled', window.scrollY>50); +window.addEventListener("scroll", () => { + document + .getElementById("navbar") + .classList.toggle("scrolled", window.scrollY > 50); }); /* ── DEVICE FRAME MOUSE TILT ── */ -const df = document.getElementById('deviceFrame'); -if(df){ - df.closest('.hero-right').addEventListener('mousemove',e=>{ - const r=df.getBoundingClientRect(); - const cx=r.left+r.width/2, cy=r.top+r.height/2; - const dx=(e.clientX-cx)/r.width*2; - const dy=(e.clientY-cy)/r.height*2; - df.style.transform=`rotateY(${dx*-12}deg) rotateX(${dy*8}deg) rotateZ(${dx*1}deg)`; +const df = document.getElementById("deviceFrame"); +if (df) { + df.closest(".hero-right").addEventListener("mousemove", (e) => { + const r = df.getBoundingClientRect(); + const cx = r.left + r.width / 2, + cy = r.top + r.height / 2; + const dx = ((e.clientX - cx) / r.width) * 2; + const dy = ((e.clientY - cy) / r.height) * 2; + df.style.transform = `rotateY(${dx * -12}deg) rotateX(${dy * 8}deg) rotateZ(${dx * 1}deg)`; }); - df.closest('.hero-right').addEventListener('mouseleave',()=>{ - df.style.transform='rotateY(-18deg) rotateX(8deg) rotateZ(2deg)'; + df.closest(".hero-right").addEventListener("mouseleave", () => { + df.style.transform = "rotateY(-18deg) rotateX(8deg) rotateZ(2deg)"; }); } /* ── INTERSECTION OBSERVER — SERVICE ITEMS ── */ -const items = document.querySelectorAll('.service-item'); -const svcObserver = new IntersectionObserver(entries=>{ - entries.forEach(entry=>{ - if(entry.isIntersecting){ - entry.target.classList.add('visible'); - svcObserver.unobserve(entry.target); - } - }); -},{threshold:0.15}); -items.forEach(el=>svcObserver.observe(el)); +const items = document.querySelectorAll(".service-item"); +const svcObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add("visible"); + svcObserver.unobserve(entry.target); + } + }); + }, + { threshold: 0.15 }, +); +items.forEach((el) => svcObserver.observe(el)); /* ── STATS COUNTER ── */ -const statNums = document.querySelectorAll('.stat-num[data-target]'); -const statObserver = new IntersectionObserver(entries=>{ - entries.forEach(entry=>{ - if(entry.isIntersecting){ - const el=entry.target; - const target=parseInt(el.dataset.target); - const suffix=target>3?'+':''; - let current=0; - const step=Math.max(1,Math.ceil(target/30)); - const timer=setInterval(()=>{ - current=Math.min(current+step,target); - el.textContent=current+suffix; - if(current>=target) clearInterval(timer); - },50); - statObserver.unobserve(el); - } - }); -},{threshold:0.5}); -statNums.forEach(el=>statObserver.observe(el)); \ No newline at end of file +const statNums = document.querySelectorAll(".stat-num[data-target]"); +const statObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const el = entry.target; + const target = parseInt(el.dataset.target); + const suffix = target > 3 ? "+" : ""; + let current = 0; + const step = Math.max(1, Math.ceil(target / 30)); + const timer = setInterval(() => { + current = Math.min(current + step, target); + el.textContent = current + suffix; + if (current >= target) clearInterval(timer); + }, 50); + statObserver.unobserve(el); + } + }); + }, + { threshold: 0.5 }, +); +statNums.forEach((el) => statObserver.observe(el)); diff --git a/public/js/pages/settings.js b/public/js/pages/settings.js index 1bdc4f8a..421b472d 100644 --- a/public/js/pages/settings.js +++ b/public/js/pages/settings.js @@ -1,431 +1,508 @@ (function () { - const body = document.body; - const userData = JSON.parse(body.getAttribute('data-user') || '{}'); - const isGuest = !!userData.isGuestContributor; - - const toastEl = document.getElementById('toast'); - let toastTimer; - let toastStart; - let remainingTime = 4000; - - function initTopbar() { - const layout = document.getElementById('dashboardLayout'); - const sidebarToggle = document.getElementById('sidebarToggle'); - const themeToggle = document.getElementById('theme-toggle'); - const iconLight = themeToggle ? themeToggle.querySelector('.icon-light') : null; - const iconDark = themeToggle ? themeToggle.querySelector('.icon-dark') : null; - - if (layout && sidebarToggle) { - const sidebarState = localStorage.getItem('creatorosSidebarCollapsed'); - if (sidebarState === 'true') { - layout.classList.add('sidebar-collapsed'); - sidebarToggle.setAttribute('aria-expanded', 'false'); - } - - sidebarToggle.addEventListener('click', () => { - const isCollapsed = layout.classList.toggle('sidebar-collapsed'); - sidebarToggle.setAttribute('aria-expanded', String(!isCollapsed)); - localStorage.setItem('creatorosSidebarCollapsed', String(isCollapsed)); - }); - } - - if (themeToggle) { - themeToggle.addEventListener('click', async () => { - const nextMode = body.classList.contains('appearance-dark') ? 'light' : 'dark'; - document.querySelectorAll('#appearance-group .radio-btn').forEach((btn) => { - btn.classList.toggle('active', btn.dataset.val === nextMode); - }); - try { - await updatePreferences({ appearanceMode: nextMode }); - } catch (err) { - showToast(err.message, true); - } - }); - } - - function syncThemeIcon() { - const isDark = body.classList.contains('appearance-dark'); - if (iconLight) iconLight.style.display = isDark ? 'none' : 'block'; - if (iconDark) iconDark.style.display = isDark ? 'block' : 'none'; - } - - window.addEventListener('creatorosSettingsAppearanceChanged', syncThemeIcon); - syncThemeIcon(); - } - - function startToastTimer() { - toastStart = Date.now(); - toastTimer = setTimeout(() => { - toastEl.classList.remove('visible'); - }, remainingTime); - } - - function showToast(message, isError) { - if (toastEl.classList.contains('visible') && toastEl.textContent === message) { - return; - } - - clearTimeout(toastTimer); - remainingTime = 4000; - toastEl.textContent = message; - toastEl.classList.remove('success', 'error'); - toastEl.classList.add(isError ? 'error' : 'success'); - toastEl.classList.add('visible'); - startToastTimer(); + const body = document.body; + const userData = JSON.parse(body.getAttribute("data-user") || "{}"); + const isGuest = !!userData.isGuestContributor; + + const toastEl = document.getElementById("toast"); + let toastTimer; + let toastStart; + let remainingTime = 4000; + + function initTopbar() { + const layout = document.getElementById("dashboardLayout"); + const sidebarToggle = document.getElementById("sidebarToggle"); + const themeToggle = document.getElementById("theme-toggle"); + const iconLight = themeToggle + ? themeToggle.querySelector(".icon-light") + : null; + const iconDark = themeToggle + ? themeToggle.querySelector(".icon-dark") + : null; + + if (layout && sidebarToggle) { + const sidebarState = localStorage.getItem("creatorosSidebarCollapsed"); + if (sidebarState === "true") { + layout.classList.add("sidebar-collapsed"); + sidebarToggle.setAttribute("aria-expanded", "false"); + } + + sidebarToggle.addEventListener("click", () => { + const isCollapsed = layout.classList.toggle("sidebar-collapsed"); + sidebarToggle.setAttribute("aria-expanded", String(!isCollapsed)); + localStorage.setItem("creatorosSidebarCollapsed", String(isCollapsed)); + }); } - toastEl.addEventListener('mouseenter', () => { - clearTimeout(toastTimer); - remainingTime -= Date.now() - toastStart; - }); - - toastEl.addEventListener('mouseleave', () => { - startToastTimer(); - }); - - - function playSoundCue() { - if (!userData.preferences?.soundCues) return; + if (themeToggle) { + themeToggle.addEventListener("click", async () => { + const nextMode = body.classList.contains("appearance-dark") + ? "light" + : "dark"; + document + .querySelectorAll("#appearance-group .radio-btn") + .forEach((btn) => { + btn.classList.toggle("active", btn.dataset.val === nextMode); + }); try { - const ctx = new (window.AudioContext || window.webkitAudioContext)(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.connect(gain); - gain.connect(ctx.destination); - osc.frequency.value = 520; - gain.gain.value = 0.04; - osc.start(); - osc.stop(ctx.currentTime + 0.06); - } catch (_) { - /* optional */ + await updatePreferences({ appearanceMode: nextMode }); + } catch (err) { + showToast(err.message, true); } + }); } - async function apiRequest(url, options) { - const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; - const res = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - ...(csrfToken ? { 'x-csrf-token': csrfToken } : {}), - ...(options && options.headers ? options.headers : {}), - }, - }); - let payload = null; - try { - payload = await res.json(); - } catch (_) { - payload = null; - } - if (!res.ok) { - const msg = payload?.error || payload?.message || res.statusText || 'Request failed'; - throw new Error(msg); - } - return payload; + function syncThemeIcon() { + const isDark = body.classList.contains("appearance-dark"); + if (iconLight) iconLight.style.display = isDark ? "none" : "block"; + if (iconDark) iconDark.style.display = isDark ? "block" : "none"; } - async function updatePreferences(prefs) { - const result = await apiRequest('/api/settings/preferences', { - method: 'PUT', - body: JSON.stringify(prefs), - }); - userData.preferences = { ...userData.preferences, ...result.preferences }; - applyPreferences(); - showToast('Preferences saved'); - playSoundCue(); + window.addEventListener( + "creatorosSettingsAppearanceChanged", + syncThemeIcon, + ); + syncThemeIcon(); + } + + function startToastTimer() { + toastStart = Date.now(); + toastTimer = setTimeout(() => { + toastEl.classList.remove("visible"); + }, remainingTime); + } + + function showToast(message, isError) { + if ( + toastEl.classList.contains("visible") && + toastEl.textContent === message + ) { + return; } - function resolveAppearance(mode) { - if (mode === 'system') { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } - return mode; + clearTimeout(toastTimer); + remainingTime = 4000; + toastEl.textContent = message; + toastEl.classList.remove("success", "error"); + toastEl.classList.add(isError ? "error" : "success"); + toastEl.classList.add("visible"); + startToastTimer(); + } + + toastEl.addEventListener("mouseenter", () => { + clearTimeout(toastTimer); + remainingTime -= Date.now() - toastStart; + }); + + toastEl.addEventListener("mouseleave", () => { + startToastTimer(); + }); + + function playSoundCue() { + if (!userData.preferences?.soundCues) return; + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + osc.frequency.value = 520; + gain.gain.value = 0.04; + osc.start(); + osc.stop(ctx.currentTime + 0.06); + } catch (_) { + /* optional */ } - - function applyPreferences() { - const prefs = userData.preferences || {}; - body.classList.remove('density-compact', 'density-tactile', 'appearance-light', 'appearance-dark', 'no-motion'); - body.classList.add(`density-${prefs.interfaceDensity || 'tactile'}`); - const resolved = resolveAppearance(prefs.appearanceMode || 'light'); - body.classList.add(`appearance-${resolved}`); - if (!prefs.motionEffects) body.classList.add('no-motion'); - localStorage.setItem('creatorosAppearance', prefs.appearanceMode || 'light'); - localStorage.setItem('creatorosDensity', prefs.interfaceDensity || 'tactile'); - localStorage.setItem('creatorosMotion', String(!!prefs.motionEffects)); - localStorage.setItem('creatorosAutoSaveLinks', String(!!prefs.autoSaveLinks)); - window.dispatchEvent(new Event('creatorosSettingsAppearanceChanged')); + } + + async function apiRequest(url, options) { + const csrfToken = document.querySelector( + 'meta[name="csrf-token"]', + )?.content; + const res = await fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...(csrfToken ? { "x-csrf-token": csrfToken } : {}), + ...(options && options.headers ? options.headers : {}), + }, + }); + let payload = null; + try { + payload = await res.json(); + } catch (_) { + payload = null; } - - function updateHeaderProfile(name) { - const parts = name.split(' ').filter(Boolean).slice(0, 2); - const initials = parts.map((p) => p[0].toUpperCase()).join('') || 'CR'; - const profileName = document.querySelector('.profile-chip .profile-name'); - const avatar = document.querySelector('.profile-chip .avatar'); - if (profileName) profileName.textContent = name; - if (avatar) avatar.textContent = initials; - userData.name = name; - userData.initials = initials; + if (!res.ok) { + const msg = + payload?.error || + payload?.message || + res.statusText || + "Request failed"; + throw new Error(msg); } + return payload; + } - // Sub-nav scroll + active state - const subNavItems = document.querySelectorAll('.sub-nav-item'); - const sections = ['profile', 'security', 'billing', 'global'].map((id) => document.getElementById(id)); - - subNavItems.forEach((anchor) => { - anchor.addEventListener('click', (e) => { - e.preventDefault(); - const id = anchor.getAttribute('href').slice(1); - const el = document.getElementById(id); - if (el) { - el.scrollIntoView({ behavior: userData.preferences?.motionEffects === false ? 'auto' : 'smooth' }); - subNavItems.forEach((a) => a.classList.remove('active')); - anchor.classList.add('active'); - } - playSoundCue(); - }); + async function updatePreferences(prefs) { + const result = await apiRequest("/api/settings/preferences", { + method: "PUT", + body: JSON.stringify(prefs), }); - - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const id = entry.target.id; - subNavItems.forEach((a) => { - a.classList.toggle('active', a.getAttribute('href') === `#${id}`); - }); - } - }); - }, - { rootMargin: '-30% 0px -55% 0px', threshold: 0 } + userData.preferences = { ...userData.preferences, ...result.preferences }; + applyPreferences(); + showToast("Preferences saved"); + playSoundCue(); + } + + function resolveAppearance(mode) { + if (mode === "system") { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + return mode; + } + + function applyPreferences() { + const prefs = userData.preferences || {}; + body.classList.remove( + "density-compact", + "density-tactile", + "appearance-light", + "appearance-dark", + "no-motion", ); - sections.forEach((s) => s && observer.observe(s)); - - // Profile form - document.getElementById('profile-form').addEventListener('submit', async (e) => { - e.preventDefault(); - if (isGuest) return showToast('Contributors cannot update profile', true); - try { - const payload = await apiRequest('/api/settings/profile', { - method: 'PUT', - body: JSON.stringify({ - name: document.getElementById('profile-name').value.trim(), - alias: document.getElementById('profile-alias').value.trim(), - bio: document.getElementById('profile-bio').value.trim(), - }), - }); - updateHeaderProfile(payload.user.name); - showToast('Profile updated successfully'); - playSoundCue(); - } catch (err) { - showToast(err.message, true); - } - }); - - // 2FA toggle - const tfaToggle = document.getElementById('tfa-toggle'); - function bindToggle(el, onChange) { - const handler = async () => { - if (isGuest && el.id === 'tfa-toggle') { - return showToast('Contributors cannot change security settings', true); - } - await onChange(el); - }; - el.addEventListener('click', handler); - el.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handler(); - } + body.classList.add(`density-${prefs.interfaceDensity || "tactile"}`); + const resolved = resolveAppearance(prefs.appearanceMode || "light"); + body.classList.add(`appearance-${resolved}`); + if (!prefs.motionEffects) body.classList.add("no-motion"); + localStorage.setItem( + "creatorosAppearance", + prefs.appearanceMode || "light", + ); + localStorage.setItem( + "creatorosDensity", + prefs.interfaceDensity || "tactile", + ); + localStorage.setItem("creatorosMotion", String(!!prefs.motionEffects)); + localStorage.setItem( + "creatorosAutoSaveLinks", + String(!!prefs.autoSaveLinks), + ); + window.dispatchEvent(new Event("creatorosSettingsAppearanceChanged")); + } + + function updateHeaderProfile(name) { + const parts = name.split(" ").filter(Boolean).slice(0, 2); + const initials = parts.map((p) => p[0].toUpperCase()).join("") || "CR"; + const profileName = document.querySelector(".profile-chip .profile-name"); + const avatar = document.querySelector(".profile-chip .avatar"); + if (profileName) profileName.textContent = name; + if (avatar) avatar.textContent = initials; + userData.name = name; + userData.initials = initials; + } + + // Sub-nav scroll + active state + const subNavItems = document.querySelectorAll(".sub-nav-item"); + const sections = ["profile", "security", "billing", "global"].map((id) => + document.getElementById(id), + ); + + subNavItems.forEach((anchor) => { + anchor.addEventListener("click", (e) => { + e.preventDefault(); + const id = anchor.getAttribute("href").slice(1); + const el = document.getElementById(id); + if (el) { + el.scrollIntoView({ + behavior: + userData.preferences?.motionEffects === false ? "auto" : "smooth", }); - } - - bindToggle(tfaToggle, async (el) => { - const enabled = el.classList.contains('off'); - try { - await apiRequest('/api/settings/security/2fa', { - method: 'PUT', - body: JSON.stringify({ enabled }), - }); - el.classList.toggle('off', !enabled); - el.setAttribute('aria-checked', String(enabled)); - showToast(enabled ? '2FA enabled' : '2FA disabled'); - playSoundCue(); - } catch (err) { - showToast(err.message, true); - } + subNavItems.forEach((a) => a.classList.remove("active")); + anchor.classList.add("active"); + } + playSoundCue(); }); - - // Password - document.getElementById('change-pwd-btn').addEventListener('click', () => { - document.getElementById('pwd-form').classList.toggle('open'); - }); - - document.getElementById('save-pwd-btn').addEventListener('click', async () => { - if (isGuest) return showToast('Contributors cannot change password', true); - try { - const result = await apiRequest('/api/settings/security/password', { - method: 'PUT', - body: JSON.stringify({ - oldPassword: document.getElementById('old-pwd').value, - newPassword: document.getElementById('new-pwd').value, - }), - }); - document.getElementById('pwd-form').classList.remove('open'); - document.getElementById('old-pwd').value = ''; - document.getElementById('new-pwd').value = ''; - const days = result.passwordAgeDays ?? 0; - document.getElementById('pwd-age-text').textContent = `Last updated ${days} day${days === 1 ? '' : 's'} ago`; - showToast('Password changed successfully'); - playSoundCue(); - } catch (err) { - showToast(err.message, true); + }); + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + const id = entry.target.id; + subNavItems.forEach((a) => { + a.classList.toggle("active", a.getAttribute("href") === `#${id}`); + }); } + }); + }, + { rootMargin: "-30% 0px -55% 0px", threshold: 0 }, + ); + sections.forEach((s) => s && observer.observe(s)); + + // Profile form + document + .getElementById("profile-form") + .addEventListener("submit", async (e) => { + e.preventDefault(); + if (isGuest) return showToast("Contributors cannot update profile", true); + try { + const payload = await apiRequest("/api/settings/profile", { + method: "PUT", + body: JSON.stringify({ + name: document.getElementById("profile-name").value.trim(), + alias: document.getElementById("profile-alias").value.trim(), + bio: document.getElementById("profile-bio").value.trim(), + }), + }); + updateHeaderProfile(payload.user.name); + showToast("Profile updated successfully"); + playSoundCue(); + } catch (err) { + showToast(err.message, true); + } }); - // Account deletion - document.getElementById('deactivate-btn').addEventListener('click', async () => { - if (isGuest) return showToast('Contributors cannot delete accounts', true); - if (!confirm('Are you sure you want to deactivate your instance? This is permanent.')) return; - try { - await apiRequest('/api/settings/account', { method: 'DELETE' }); - window.location.href = '/login'; - } catch (err) { - showToast(err.message, true); - } + // 2FA toggle + const tfaToggle = document.getElementById("tfa-toggle"); + function bindToggle(el, onChange) { + const handler = async () => { + if (isGuest && el.id === "tfa-toggle") { + return showToast("Contributors cannot change security settings", true); + } + await onChange(el); + }; + el.addEventListener("click", handler); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handler(); + } }); - - // Preferences radios - document.querySelectorAll('#appearance-group .radio-btn').forEach((btn) => { - btn.addEventListener('click', async () => { - document.querySelectorAll('#appearance-group .radio-btn').forEach((b) => b.classList.remove('active')); - btn.classList.add('active'); - try { - await updatePreferences({ appearanceMode: btn.dataset.val }); - } catch (err) { - showToast(err.message, true); - } + } + + bindToggle(tfaToggle, async (el) => { + const enabled = el.classList.contains("off"); + try { + await apiRequest("/api/settings/security/2fa", { + method: "PUT", + body: JSON.stringify({ enabled }), + }); + el.classList.toggle("off", !enabled); + el.setAttribute("aria-checked", String(enabled)); + showToast(enabled ? "2FA enabled" : "2FA disabled"); + playSoundCue(); + } catch (err) { + showToast(err.message, true); + } + }); + + // Password + document.getElementById("change-pwd-btn").addEventListener("click", () => { + document.getElementById("pwd-form").classList.toggle("open"); + }); + + document + .getElementById("save-pwd-btn") + .addEventListener("click", async () => { + if (isGuest) + return showToast("Contributors cannot change password", true); + try { + const result = await apiRequest("/api/settings/security/password", { + method: "PUT", + body: JSON.stringify({ + oldPassword: document.getElementById("old-pwd").value, + newPassword: document.getElementById("new-pwd").value, + }), }); + document.getElementById("pwd-form").classList.remove("open"); + document.getElementById("old-pwd").value = ""; + document.getElementById("new-pwd").value = ""; + const days = result.passwordAgeDays ?? 0; + document.getElementById("pwd-age-text").textContent = + `Last updated ${days} day${days === 1 ? "" : "s"} ago`; + showToast("Password changed successfully"); + playSoundCue(); + } catch (err) { + showToast(err.message, true); + } }); - document.querySelectorAll('#density-group .radio-btn').forEach((btn) => { - btn.addEventListener('click', async () => { - document.querySelectorAll('#density-group .radio-btn').forEach((b) => b.classList.remove('active')); - btn.classList.add('active'); - try { - await updatePreferences({ interfaceDensity: btn.dataset.val }); - } catch (err) { - showToast(err.message, true); - } - }); + // Account deletion + document + .getElementById("deactivate-btn") + .addEventListener("click", async () => { + if (isGuest) + return showToast("Contributors cannot delete accounts", true); + if ( + !confirm( + "Are you sure you want to deactivate your instance? This is permanent.", + ) + ) + return; + try { + await apiRequest("/api/settings/account", { method: "DELETE" }); + window.location.href = "/login"; + } catch (err) { + showToast(err.message, true); + } }); - document.querySelectorAll('.pref-toggle').forEach((toggle) => { - bindToggle(toggle, async (el) => { - const key = el.dataset.key; - const val = el.classList.contains('off'); - try { - await updatePreferences({ [key]: val }); - el.classList.toggle('off', !val); - el.setAttribute('aria-checked', String(val)); - } catch (err) { - showToast(err.message, true); - } - }); + // Preferences radios + document.querySelectorAll("#appearance-group .radio-btn").forEach((btn) => { + btn.addEventListener("click", async () => { + document + .querySelectorAll("#appearance-group .radio-btn") + .forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + try { + await updatePreferences({ appearanceMode: btn.dataset.val }); + } catch (err) { + showToast(err.message, true); + } }); - - // Billing - async function refreshBilling() { - try { - const billing = await apiRequest('/api/settings/billing'); - document.getElementById('plan-name').textContent = billing.planName; - document.getElementById('plan-price').textContent = billing.priceMonthly; - document.getElementById('next-invoice-date').textContent = billing.nextInvoiceLabel; - document.getElementById('invoice-est').textContent = `Estimated: ${billing.estimatedTotal}`; - document.getElementById('card-info-text').textContent = `${billing.cardBrand} ending in ${billing.cardLast4}`; - const tbody = document.querySelector('#invoices-table tbody'); - tbody.innerHTML = billing.invoices - .map( - (inv) => ` + }); + + document.querySelectorAll("#density-group .radio-btn").forEach((btn) => { + btn.addEventListener("click", async () => { + document + .querySelectorAll("#density-group .radio-btn") + .forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + try { + await updatePreferences({ interfaceDensity: btn.dataset.val }); + } catch (err) { + showToast(err.message, true); + } + }); + }); + + document.querySelectorAll(".pref-toggle").forEach((toggle) => { + bindToggle(toggle, async (el) => { + const key = el.dataset.key; + const val = el.classList.contains("off"); + try { + await updatePreferences({ [key]: val }); + el.classList.toggle("off", !val); + el.setAttribute("aria-checked", String(val)); + } catch (err) { + showToast(err.message, true); + } + }); + }); + + // Billing + async function refreshBilling() { + try { + const billing = await apiRequest("/api/settings/billing"); + document.getElementById("plan-name").textContent = billing.planName; + document.getElementById("plan-price").textContent = billing.priceMonthly; + document.getElementById("next-invoice-date").textContent = + billing.nextInvoiceLabel; + document.getElementById("invoice-est").textContent = + `Estimated: ${billing.estimatedTotal}`; + document.getElementById("card-info-text").textContent = + `${billing.cardBrand} ending in ${billing.cardLast4}`; + const tbody = document.querySelector("#invoices-table tbody"); + tbody.innerHTML = billing.invoices + .map( + (inv) => ` ${inv.date} ${inv.invoiceId} ${inv.amount} ${inv.status} - ` - ) - .join(''); - bindInvoiceRows(); - } catch (_) { - /* server-rendered fallback */ - } + `, + ) + .join(""); + bindInvoiceRows(); + } catch (_) { + /* server-rendered fallback */ } - - const billingModal = document.getElementById('billing-modal'); - document.getElementById('manage-subscription-btn').addEventListener('click', () => { - billingModal.classList.add('open'); - billingModal.setAttribute('aria-hidden', 'false'); - playSoundCue(); + } + + const billingModal = document.getElementById("billing-modal"); + document + .getElementById("manage-subscription-btn") + .addEventListener("click", () => { + billingModal.classList.add("open"); + billingModal.setAttribute("aria-hidden", "false"); + playSoundCue(); }); - document.getElementById('billing-modal-close').addEventListener('click', () => { - billingModal.classList.remove('open'); - billingModal.setAttribute('aria-hidden', 'true'); + document + .getElementById("billing-modal-close") + .addEventListener("click", () => { + billingModal.classList.remove("open"); + billingModal.setAttribute("aria-hidden", "true"); }); - billingModal.addEventListener('click', (e) => { - if (e.target === billingModal) { - billingModal.classList.remove('open'); - billingModal.setAttribute('aria-hidden', 'true'); - } - }); - - const invoiceModal = document.getElementById('invoice-modal'); - function bindInvoiceRows() { - document.querySelectorAll('#invoices-table tbody tr').forEach((row) => { - row.onclick = () => { - const cells = row.querySelectorAll('td'); - document.getElementById('invoice-modal-body').textContent = - `Invoice ${cells[1].textContent} for ${cells[2].textContent} on ${cells[0].textContent} — Status: ${cells[3].textContent.trim()}`; - invoiceModal.classList.add('open'); - invoiceModal.setAttribute('aria-hidden', 'false'); - invoiceModal.dataset.invoiceId = row.dataset.invoiceId || cells[1].textContent; - playSoundCue(); - }; - }); + billingModal.addEventListener("click", (e) => { + if (e.target === billingModal) { + billingModal.classList.remove("open"); + billingModal.setAttribute("aria-hidden", "true"); } - bindInvoiceRows(); - - document.getElementById('invoice-modal-close').addEventListener('click', () => { - invoiceModal.classList.remove('open'); - invoiceModal.setAttribute('aria-hidden', 'true'); + }); + + const invoiceModal = document.getElementById("invoice-modal"); + function bindInvoiceRows() { + document.querySelectorAll("#invoices-table tbody tr").forEach((row) => { + row.onclick = () => { + const cells = row.querySelectorAll("td"); + document.getElementById("invoice-modal-body").textContent = + `Invoice ${cells[1].textContent} for ${cells[2].textContent} on ${cells[0].textContent} — Status: ${cells[3].textContent.trim()}`; + invoiceModal.classList.add("open"); + invoiceModal.setAttribute("aria-hidden", "false"); + invoiceModal.dataset.invoiceId = + row.dataset.invoiceId || cells[1].textContent; + playSoundCue(); + }; }); - document.getElementById('invoice-download-btn').addEventListener('click', () => { - const id = invoiceModal.dataset.invoiceId || 'invoice'; - const blob = new Blob([`CreatorOS Invoice ${id}\nGenerated: ${new Date().toISOString()}`], { - type: 'text/plain', - }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = `${id.replace('#', '')}.txt`; - a.click(); - URL.revokeObjectURL(a.href); - showToast('Invoice downloaded'); + } + bindInvoiceRows(); + + document + .getElementById("invoice-modal-close") + .addEventListener("click", () => { + invoiceModal.classList.remove("open"); + invoiceModal.setAttribute("aria-hidden", "true"); + }); + document + .getElementById("invoice-download-btn") + .addEventListener("click", () => { + const id = invoiceModal.dataset.invoiceId || "invoice"; + const blob = new Blob( + [`CreatorOS Invoice ${id}\nGenerated: ${new Date().toISOString()}`], + { + type: "text/plain", + }, + ); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${id.replace("#", "")}.txt`; + a.click(); + URL.revokeObjectURL(a.href); + showToast("Invoice downloaded"); }); - const upgradePlanBtn = document.getElementById('upgrade-plan-btn'); - if (upgradePlanBtn) { - upgradePlanBtn.addEventListener('click', (e) => { - e.preventDefault(); - billingModal.querySelector('#billing-modal-title').textContent = 'Upgrade Plan'; - billingModal.querySelector('#billing-modal-body').textContent = - 'Team and Enterprise tiers are coming soon. Contact support to join the early access list.'; - billingModal.classList.add('open'); - }); - } + const upgradePlanBtn = document.getElementById("upgrade-plan-btn"); + if (upgradePlanBtn) { + upgradePlanBtn.addEventListener("click", (e) => { + e.preventDefault(); + billingModal.querySelector("#billing-modal-title").textContent = + "Upgrade Plan"; + billingModal.querySelector("#billing-modal-body").textContent = + "Team and Enterprise tiers are coming soon. Contact support to join the early access list."; + billingModal.classList.add("open"); + }); + } - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { - if (userData.preferences?.appearanceMode === 'system') applyPreferences(); + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", () => { + if (userData.preferences?.appearanceMode === "system") applyPreferences(); }); - applyPreferences(); - initTopbar(); - refreshBilling(); + applyPreferences(); + initTopbar(); + refreshBilling(); })(); diff --git a/public/js/pages/suggestions.js b/public/js/pages/suggestions.js index 8587c776..66840142 100644 --- a/public/js/pages/suggestions.js +++ b/public/js/pages/suggestions.js @@ -1,40 +1,46 @@ // Sidebar toggle - var layout = document.getElementById('dashboardLayout'); - var toggle = document.getElementById('sidebarToggle'); - if (localStorage.getItem('creatorosSidebarCollapsed') === 'true') { - layout.classList.add('sidebar-collapsed'); - toggle.setAttribute('aria-expanded', 'false'); - } - toggle.addEventListener('click', function() { - var collapsed = layout.classList.toggle('sidebar-collapsed'); - toggle.setAttribute('aria-expanded', String(!collapsed)); - localStorage.setItem('creatorosSidebarCollapsed', String(collapsed)); - }); +var layout = document.getElementById("dashboardLayout"); +var toggle = document.getElementById("sidebarToggle"); +if (localStorage.getItem("creatorosSidebarCollapsed") === "true") { + layout.classList.add("sidebar-collapsed"); + toggle.setAttribute("aria-expanded", "false"); +} +toggle.addEventListener("click", function () { + var collapsed = layout.classList.toggle("sidebar-collapsed"); + toggle.setAttribute("aria-expanded", String(!collapsed)); + localStorage.setItem("creatorosSidebarCollapsed", String(collapsed)); +}); - // Copy buttons - document.querySelectorAll('.copy-btn').forEach(function(btn) { - btn.addEventListener('click', function() { - navigator.clipboard.writeText(btn.getAttribute('data-text')).then(function() { - var orig = btn.textContent; - btn.textContent = '✓ Copied!'; - btn.style.color = '#22c55e'; - btn.style.borderColor = '#22c55e'; - setTimeout(function() { - btn.textContent = orig; - btn.style.color = ''; - btn.style.borderColor = ''; - }, 2000); - }); +// Copy buttons +document.querySelectorAll(".copy-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + navigator.clipboard + .writeText(btn.getAttribute("data-text")) + .then(function () { + var orig = btn.textContent; + btn.textContent = "✓ Copied!"; + btn.style.color = "#22c55e"; + btn.style.borderColor = "#22c55e"; + setTimeout(function () { + btn.textContent = orig; + btn.style.color = ""; + btn.style.borderColor = ""; + }, 2000); }); - }); + }); +}); - // Clickable hashtag tags - document.querySelectorAll('.tag').forEach(function(tag) { - tag.addEventListener('click', function() { - navigator.clipboard.writeText(tag.getAttribute('data-text')).then(function() { - var orig = tag.textContent; - tag.textContent = '✓ copied'; - setTimeout(function() { tag.textContent = orig; }, 1500); - }); +// Clickable hashtag tags +document.querySelectorAll(".tag").forEach(function (tag) { + tag.addEventListener("click", function () { + navigator.clipboard + .writeText(tag.getAttribute("data-text")) + .then(function () { + var orig = tag.textContent; + tag.textContent = "✓ copied"; + setTimeout(function () { + tag.textContent = orig; + }, 1500); }); - }); \ No newline at end of file + }); +}); diff --git a/public/js/site-3d.js b/public/js/site-3d.js index a077819c..b06c76df 100644 --- a/public/js/site-3d.js +++ b/public/js/site-3d.js @@ -1,64 +1,75 @@ -(function(){ +(function () { // Site-wide 3D background: lightweight particle field + slow parallax - if(typeof window === 'undefined') return; + if (typeof window === "undefined") return; // minimal feature detection - if(!('WebGLRenderingContext' in window)) return; + if (!("WebGLRenderingContext" in window)) return; // performance heuristics const hardwareConcurrency = navigator.hardwareConcurrency || 2; - const lowPower = navigator.connection && /2g|slow-2g/.test(navigator.connection.effectiveType || ''); + const lowPower = + navigator.connection && + /2g|slow-2g/.test(navigator.connection.effectiveType || ""); const memory = navigator.deviceMemory || 4; - const prefersReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const prefersReduce = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; // degrade on low devices - if(hardwareConcurrency <= 2 || memory <= 1 || lowPower) { + if (hardwareConcurrency <= 2 || memory <= 1 || lowPower) { // do nothing expensive; keep existing particles canvas only return; } // load only if container present - const container = document.getElementById('site-3d'); - if(!container) return; + const container = document.getElementById("site-3d"); + if (!container) return; // lazy load three if not present - function loadThree(cb){ - if(window.THREE) return cb(); - const s = document.createElement('script'); - s.src = 'https://unpkg.com/three@0.152.2/build/three.min.js'; - s.onload = cb; s.async = true; s.defer = true; + function loadThree(cb) { + if (window.THREE) return cb(); + const s = document.createElement("script"); + s.src = "https://unpkg.com/three@0.152.2/build/three.min.js"; + s.onload = cb; + s.async = true; + s.defer = true; document.head.appendChild(s); } - loadThree(()=>{ - if(!window.THREE) return; + loadThree(() => { + if (!window.THREE) return; const THREE = window.THREE; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 1000); - camera.position.set(0,0,80); + camera.position.set(0, 0, 80); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio,2)); - renderer.setSize(container.clientWidth || window.innerWidth, container.clientHeight || window.innerHeight); - renderer.domElement.style.position = 'absolute'; - renderer.domElement.style.inset = '0'; - renderer.domElement.style.width = '100%'; - renderer.domElement.style.height = '100%'; - renderer.domElement.style.zIndex = '0'; - renderer.domElement.style.pointerEvents = 'none'; + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize( + container.clientWidth || window.innerWidth, + container.clientHeight || window.innerHeight, + ); + renderer.domElement.style.position = "absolute"; + renderer.domElement.style.inset = "0"; + renderer.domElement.style.width = "100%"; + renderer.domElement.style.height = "100%"; + renderer.domElement.style.zIndex = "0"; + renderer.domElement.style.pointerEvents = "none"; container.appendChild(renderer.domElement); - const resize = ()=>{ + const resize = () => { const w = container.clientWidth || window.innerWidth; const h = container.clientHeight || window.innerHeight; - renderer.setSize(w,h); - camera.aspect = w/h; camera.updateProjectionMatrix(); + renderer.setSize(w, h); + camera.aspect = w / h; + camera.updateProjectionMatrix(); // hide if very small - if(w < 520) renderer.domElement.style.opacity = 0; + if (w < 520) renderer.domElement.style.opacity = 0; else renderer.domElement.style.opacity = 1; }; - window.addEventListener('resize', resize); resize(); + window.addEventListener("resize", resize); + resize(); // soft ambient const amb = new THREE.AmbientLight(0xffffff, 0.25); @@ -66,48 +77,75 @@ // a couple of colored points for subtle tint const p1 = new THREE.PointLight(0x22d3ee, 0.35, 300); - p1.position.set(80,40,80); scene.add(p1); + p1.position.set(80, 40, 80); + scene.add(p1); const p2 = new THREE.PointLight(0x818cf8, 0.18, 300); - p2.position.set(-60,-30,60); scene.add(p2); + p2.position.set(-60, -30, 60); + scene.add(p2); // particle cloud const particleCount = 140; - const positions = new Float32Array(particleCount*3); - for(let i=0;i'); - const spriteMat = new THREE.SpriteMaterial({ map: spriteTex, color: 0x67e8f9, opacity: 0.07, transparent: true }); - const spr1 = new THREE.Sprite(spriteMat); spr1.scale.set(220,120,1); spr1.position.set(0,10,-40); scene.add(spr1); - const spr2 = new THREE.Sprite(spriteMat); spr2.scale.set(160,80,1); spr2.position.set(-30,-20,-60); scene.add(spr2); + const spriteTex = new THREE.TextureLoader().load( + 'data:image/svg+xml;utf8,', + ); + const spriteMat = new THREE.SpriteMaterial({ + map: spriteTex, + color: 0x67e8f9, + opacity: 0.07, + transparent: true, + }); + const spr1 = new THREE.Sprite(spriteMat); + spr1.scale.set(220, 120, 1); + spr1.position.set(0, 10, -40); + scene.add(spr1); + const spr2 = new THREE.Sprite(spriteMat); + spr2.scale.set(160, 80, 1); + spr2.position.set(-30, -20, -60); + scene.add(spr2); // mouse parallax - let mx=0,my=0,tx=0,ty=0; - window.addEventListener('mousemove', (e)=>{ + let mx = 0, + my = 0, + tx = 0, + ty = 0; + window.addEventListener("mousemove", (e) => { const r = container.getBoundingClientRect(); - mx = (e.clientX - r.left)/r.width - 0.5; - my = (e.clientY - r.top)/r.height - 0.5; + mx = (e.clientX - r.left) / r.width - 0.5; + my = (e.clientY - r.top) / r.height - 0.5; }); let last = performance.now(); - function tick(now){ - const dt = Math.min(0.06, (now-last)/1000); last = now; - tx += (mx - tx) * 0.06; ty += (my - ty) * 0.06; - camera.position.x = tx * 18; camera.position.y = ty * -10; - camera.lookAt(0,0,0); - - if(!prefersReduce){ + function tick(now) { + const dt = Math.min(0.06, (now - last) / 1000); + last = now; + tx += (mx - tx) * 0.06; + ty += (my - ty) * 0.06; + camera.position.x = tx * 18; + camera.position.y = ty * -10; + camera.lookAt(0, 0, 0); + + if (!prefersReduce) { points.rotation.y += 0.02 * dt; - spr1.position.x += Math.sin(now*0.0002)*0.02; - spr2.position.x += Math.cos(now*0.00014)*0.03; + spr1.position.x += Math.sin(now * 0.0002) * 0.02; + spr2.position.x += Math.cos(now * 0.00014) * 0.03; } renderer.render(scene, camera); @@ -117,8 +155,12 @@ // expose a toggle via localStorage window.Site3D = { - disable: ()=>{ renderer.domElement.style.display='none'; }, - enable: ()=>{ renderer.domElement.style.display='block'; } + disable: () => { + renderer.domElement.style.display = "none"; + }, + enable: () => { + renderer.domElement.style.display = "block"; + }, }; }); })(); diff --git a/public/site.webmanifest b/public/site.webmanifest index 45dc8a20..fa99de77 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1 +1,19 @@ -{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/routes/analytics.js b/routes/analytics.js index eefdab0e..ae8bd178 100644 --- a/routes/analytics.js +++ b/routes/analytics.js @@ -1,18 +1,26 @@ const express = require("express"); const router = express.Router(); const { - getSnapshots, - getLatestSnapshot, - triggerRefresh, - getEngagementHistory, + getSnapshots, + getLatestSnapshot, + triggerRefresh, + getEngagementHistory, } = require("../controller/analytics"); const { validate, objectIdParamSchema } = require("../middleware/validators"); -const validateCreatorId = validate(objectIdParamSchema, 'params'); +const validateCreatorId = validate(objectIdParamSchema, "params"); router.get("/:creatorId/snapshots", validateCreatorId, getSnapshots); -router.get("/:creatorId/snapshots/latest", validateCreatorId, getLatestSnapshot); -router.get("/:creatorId/engagement-history", validateCreatorId, getEngagementHistory); +router.get( + "/:creatorId/snapshots/latest", + validateCreatorId, + getLatestSnapshot, +); +router.get( + "/:creatorId/engagement-history", + validateCreatorId, + getEngagementHistory, +); router.post("/:creatorId/refresh", validateCreatorId, triggerRefresh); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/auth.js b/routes/auth.js index 32c3d2b6..626835aa 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -1,75 +1,90 @@ const express = require("express"); const passport = require("passport"); const GoogleStrategy = require("passport-google-oauth20").Strategy; -const { signup, login, handleGoogleCallback, loginAsContributor, verifyEmail, resendVerificationEmail } = require("../controller/auth"); -const { signupValidator, loginValidator, resendVerificationValidator } = require("../middleware/validators"); +const { + signup, + login, + handleGoogleCallback, + loginAsContributor, + verifyEmail, + resendVerificationEmail, +} = require("../controller/auth"); +const { + signupValidator, + loginValidator, + resendVerificationValidator, +} = require("../middleware/validators"); const connectDB = require("../connect"); -const { signupLimiter, emailVerificationLimiter } = require("../middleware/rateLimiters"); +const { + signupLimiter, + emailVerificationLimiter, +} = require("../middleware/rateLimiters"); const router = express.Router(); const googleAuthConfigured = Boolean( - process.env.GOOGLE_CLIENT_ID && - process.env.GOOGLE_CLIENT_SECRET && - process.env.GOOGLE_CALLBACK_URL + process.env.GOOGLE_CLIENT_ID && + process.env.GOOGLE_CLIENT_SECRET && + process.env.GOOGLE_CALLBACK_URL, ); if (googleAuthConfigured) { - passport.use( - new GoogleStrategy( - { - clientID: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - callbackURL: process.env.GOOGLE_CALLBACK_URL, - }, - async (accessToken, refreshToken, profile, done) => { - try { - await connectDB(); - const User = require("../model/user"); - const email = profile.emails?.[0]?.value?.toLowerCase(); - - if (!email) { - return done(null, false, { message: "Google account does not expose an email address." }); - } - - const googleUser = { - googleId: profile.id, - name: profile.displayName || email.split("@")[0], - email, - avatar: profile.photos?.[0]?.value, - lastLoginAt: new Date(), - }; - - let user = await User.findOne({ googleId: profile.id }); - - if (!user) { - user = await User.findOne({ email }); - } - - if (user) { - user.googleId = googleUser.googleId; - user.name = user.name || googleUser.name; - user.avatar = googleUser.avatar || user.avatar; - user.authProvider = user.password ? user.authProvider : "google"; - user.lastLoginAt = googleUser.lastLoginAt; - await user.save(); - } else { - user = await User.create({ - ...googleUser, - authProvider: "google", - }); - } - - return done(null, user); - } catch (error) { - return done(error); - } - } - ) - ); + passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: process.env.GOOGLE_CALLBACK_URL, + }, + async (accessToken, refreshToken, profile, done) => { + try { + await connectDB(); + const User = require("../model/user"); + const email = profile.emails?.[0]?.value?.toLowerCase(); + + if (!email) { + return done(null, false, { + message: "Google account does not expose an email address.", + }); + } + + const googleUser = { + googleId: profile.id, + name: profile.displayName || email.split("@")[0], + email, + avatar: profile.photos?.[0]?.value, + lastLoginAt: new Date(), + }; + + let user = await User.findOne({ googleId: profile.id }); + + if (!user) { + user = await User.findOne({ email }); + } + + if (user) { + user.googleId = googleUser.googleId; + user.name = user.name || googleUser.name; + user.avatar = googleUser.avatar || user.avatar; + user.authProvider = user.password ? user.authProvider : "google"; + user.lastLoginAt = googleUser.lastLoginAt; + await user.save(); + } else { + user = await User.create({ + ...googleUser, + authProvider: "google", + }); + } + + return done(null, user); + } catch (error) { + return done(error); + } + }, + ), + ); } - /** * @swagger * /signup: @@ -87,10 +102,9 @@ if (googleAuthConfigured) { * description: Internal server error */ router.get("/signup", (req, res) => { - res.render("signup", { error: null }); + res.render("signup", { error: null }); }); - /** * @swagger * /login: @@ -108,18 +122,17 @@ router.get("/signup", (req, res) => { * description: Internal server error */ router.get("/login", (req, res) => { - const errorMessages = { - google_cancelled: "Google sign-in was cancelled.", - google_failed: "Google sign-in failed. Please try again.", - }; - - res.render("login", { - error: errorMessages[req.query.error] || req.query.error || null, - googleAuthConfigured, - }); + const errorMessages = { + google_cancelled: "Google sign-in was cancelled.", + google_failed: "Google sign-in failed. Please try again.", + }; + + res.render("login", { + error: errorMessages[req.query.error] || req.query.error || null, + googleAuthConfigured, + }); }); - /** * @swagger * /signup: @@ -192,7 +205,6 @@ router.post("/login/contributor", loginValidator, loginAsContributor); */ router.post("/api/auth/contributor-login", loginValidator, loginAsContributor); - /** * @swagger * /auth/google: @@ -210,18 +222,19 @@ router.post("/api/auth/contributor-login", loginValidator, loginAsContributor); * description: Internal server error */ router.get("/auth/google", (req, res, next) => { - if (!googleAuthConfigured) { - return res.redirect("/login?error=Google%20sign-in%20is%20not%20configured%20yet."); - } + if (!googleAuthConfigured) { + return res.redirect( + "/login?error=Google%20sign-in%20is%20not%20configured%20yet.", + ); + } - return passport.authenticate("google", { - scope: ["profile", "email"], - session: false, - prompt: "select_account", - })(req, res, next); + return passport.authenticate("google", { + scope: ["profile", "email"], + session: false, + prompt: "select_account", + })(req, res, next); }); - /** * @swagger * /auth/google/callback: @@ -238,18 +251,24 @@ router.get("/auth/google", (req, res, next) => { * 500: * description: Internal server error */ -router.get("/auth/google/callback", (req, res, next) => { +router.get( + "/auth/google/callback", + (req, res, next) => { if (req.query.error) { - const errorCode = req.query.error === "access_denied" ? "google_cancelled" : "google_failed"; - return res.redirect(`/login?error=${errorCode}`); + const errorCode = + req.query.error === "access_denied" + ? "google_cancelled" + : "google_failed"; + return res.redirect(`/login?error=${errorCode}`); } return passport.authenticate("google", { - failureRedirect: "/login?error=google_failed", - session: false, + failureRedirect: "/login?error=google_failed", + session: false, })(req, res, next); -}, handleGoogleCallback); - + }, + handleGoogleCallback, +); /** * @swagger @@ -268,10 +287,13 @@ router.get("/auth/google/callback", (req, res, next) => { * description: Internal server error */ router.get("/verify-email", (req, res) => { - res.render("verify-email", { error: null, success: null, expiredToken: false }); + res.render("verify-email", { + error: null, + success: null, + expiredToken: false, + }); }); - /** * @swagger * /verify-email: @@ -290,7 +312,6 @@ router.get("/verify-email", (req, res) => { */ router.post("/verify-email", emailVerificationLimiter, verifyEmail); - /** * @swagger * /resend-verification: @@ -308,10 +329,9 @@ router.post("/verify-email", emailVerificationLimiter, verifyEmail); * description: Internal server error */ router.get("/resend-verification", (req, res) => { - res.render("resend-verification", { error: null, success: null }); + res.render("resend-verification", { error: null, success: null }); }); - /** * @swagger * /resend-verification: @@ -328,8 +348,12 @@ router.get("/resend-verification", (req, res) => { * 500: * description: Internal server error */ -router.post("/resend-verification", emailVerificationLimiter, resendVerificationValidator, resendVerificationEmail); - +router.post( + "/resend-verification", + emailVerificationLimiter, + resendVerificationValidator, + resendVerificationEmail, +); /** * @swagger @@ -348,8 +372,8 @@ router.post("/resend-verification", emailVerificationLimiter, resendVerification * description: Internal server error */ router.get("/logout", (req, res) => { - res.clearCookie("token"); - res.redirect("/login"); + res.clearCookie("token"); + res.redirect("/login"); }); module.exports = router; diff --git a/routes/collaboration.js b/routes/collaboration.js index 21663e66..629ff3a3 100644 --- a/routes/collaboration.js +++ b/routes/collaboration.js @@ -1,9 +1,11 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -const { getCreatorCrmPage, sendCollaboratorInvite } = require('../controller/collaborationController'); -const { preventContributorWrites } = require('../middleware/auth'); -const { inviteCollaboratorValidator } = require('../middleware/validators'); - +const { + getCreatorCrmPage, + sendCollaboratorInvite, +} = require("../controller/collaborationController"); +const { preventContributorWrites } = require("../middleware/auth"); +const { inviteCollaboratorValidator } = require("../middleware/validators"); /** * @swagger @@ -21,7 +23,7 @@ const { inviteCollaboratorValidator } = require('../middleware/validators'); * 500: * description: Internal server error */ -router.get('/', getCreatorCrmPage); +router.get("/", getCreatorCrmPage); /** * @swagger @@ -39,6 +41,11 @@ router.get('/', getCreatorCrmPage); * 500: * description: Internal server error */ -router.post('/invite', preventContributorWrites, inviteCollaboratorValidator, sendCollaboratorInvite); +router.post( + "/invite", + preventContributorWrites, + inviteCollaboratorValidator, + sendCollaboratorInvite, +); module.exports = router; diff --git a/routes/instagram.js b/routes/instagram.js index debf12ab..38647be9 100644 --- a/routes/instagram.js +++ b/routes/instagram.js @@ -1,10 +1,13 @@ -const express = require('express'); -const { getInstagramProfile } = require('../controller/instagramController'); -const { verifyWebhook, verifyWebhookSignature, handleWebhook } = require('../controller/instagramWebhookController'); +const express = require("express"); +const { getInstagramProfile } = require("../controller/instagramController"); +const { + verifyWebhook, + verifyWebhookSignature, + handleWebhook, +} = require("../controller/instagramWebhookController"); const router = express.Router(); - /** * @swagger * /profile: @@ -21,10 +24,10 @@ const router = express.Router(); * 500: * description: Internal server error */ -router.get('/profile', getInstagramProfile); +router.get("/profile", getInstagramProfile); // Instagram DM Automation Webhook Endpoints -router.get('/webhook', verifyWebhook); -router.post('/webhook', verifyWebhookSignature, handleWebhook); +router.get("/webhook", verifyWebhook); +router.post("/webhook", verifyWebhookSignature, handleWebhook); module.exports = router; diff --git a/routes/settings.js b/routes/settings.js index aa80e3e1..e581d162 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -1,50 +1,60 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -const bcrypt = require('bcryptjs'); -const User = require('../model/user'); -const { preventContributorWrites } = require('../middleware/auth'); +const bcrypt = require("bcryptjs"); +const User = require("../model/user"); +const { preventContributorWrites } = require("../middleware/auth"); -const asyncHandler = fn => (req, res, next) => - Promise.resolve(fn(req, res, next)).catch(next); +const asyncHandler = (fn) => (req, res, next) => + Promise.resolve(fn(req, res, next)).catch(next); function defaultInvoices() { - return [ - { date: 'Sep 24, 2023', invoiceId: '#INV-88219', amount: '$29.00', status: 'PAID' }, - { date: 'Aug 24, 2023', invoiceId: '#INV-87112', amount: '$29.00', status: 'PAID' }, - ]; + return [ + { + date: "Sep 24, 2023", + invoiceId: "#INV-88219", + amount: "$29.00", + status: "PAID", + }, + { + date: "Aug 24, 2023", + invoiceId: "#INV-87112", + amount: "$29.00", + status: "PAID", + }, + ]; } function buildBillingPayload(user) { - const sub = user.subscription || {}; - const nextInvoice = sub.nextInvoiceDate - ? new Date(sub.nextInvoiceDate) - : (() => { - const d = new Date(); - d.setMonth(d.getMonth() + 1); - d.setDate(24); - return d; - })(); - - return { - planName: sub.planName || 'Pro Individual', - priceMonthly: sub.priceMonthly ?? 29, - nextInvoiceDate: nextInvoice.toISOString(), - nextInvoiceLabel: nextInvoice.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }), - estimatedTotal: `$${(sub.priceMonthly ?? 29).toFixed(2)} USD`, - cardBrand: sub.cardBrand || 'VISA', - cardLast4: sub.cardLast4 || '4242', - invoices: defaultInvoices(), - }; + const sub = user.subscription || {}; + const nextInvoice = sub.nextInvoiceDate + ? new Date(sub.nextInvoiceDate) + : (() => { + const d = new Date(); + d.setMonth(d.getMonth() + 1); + d.setDate(24); + return d; + })(); + + return { + planName: sub.planName || "Pro Individual", + priceMonthly: sub.priceMonthly ?? 29, + nextInvoiceDate: nextInvoice.toISOString(), + nextInvoiceLabel: nextInvoice.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }), + estimatedTotal: `$${(sub.priceMonthly ?? 29).toFixed(2)} USD`, + cardBrand: sub.cardBrand || "VISA", + cardLast4: sub.cardLast4 || "4242", + invoices: defaultInvoices(), + }; } function daysSince(date) { - if (!date) return null; - const ms = Date.now() - new Date(date).getTime(); - return Math.max(0, Math.floor(ms / (1000 * 60 * 60 * 24))); + if (!date) return null; + const ms = Date.now() - new Date(date).getTime(); + return Math.max(0, Math.floor(ms / (1000 * 60 * 60 * 24))); } // PUT /api/settings/profile @@ -65,27 +75,30 @@ function daysSince(date) { * 500: * description: Internal server error */ -router.put('/profile', preventContributorWrites, asyncHandler(async (req, res) => { +router.put( + "/profile", + preventContributorWrites, + asyncHandler(async (req, res) => { const { name, alias, bio } = req.body; - + const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); - + if (!user) return res.status(404).json({ error: "User not found" }); + if (name !== undefined) user.name = name; if (alias !== undefined) user.alias = alias; if (bio !== undefined) user.bio = bio; - + await user.save(); res.json({ - message: 'Profile updated successfully', - user: { - name: user.name, - alias: user.alias, - bio: user.bio, - }, + message: "Profile updated successfully", + user: { + name: user.name, + alias: user.alias, + bio: user.bio, + }, }); -})); - + }), +); /** * @swagger @@ -103,11 +116,14 @@ router.put('/profile', preventContributorWrites, asyncHandler(async (req, res) = * 500: * description: Internal server error */ -router.get('/billing', asyncHandler(async (req, res) => { +router.get( + "/billing", + asyncHandler(async (req, res) => { const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); + if (!user) return res.status(404).json({ error: "User not found" }); res.json(buildBillingPayload(user)); -})); + }), +); // PUT /api/settings/security/2fa @@ -127,17 +143,24 @@ router.get('/billing', asyncHandler(async (req, res) => { * 500: * description: Internal server error */ -router.put('/security/2fa', preventContributorWrites, asyncHandler(async (req, res) => { +router.put( + "/security/2fa", + preventContributorWrites, + asyncHandler(async (req, res) => { const { enabled } = req.body; - + const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); - + if (!user) return res.status(404).json({ error: "User not found" }); + user.twoFactorEnabled = !!enabled; await user.save(); - - res.json({ message: '2FA settings updated successfully', twoFactorEnabled: user.twoFactorEnabled }); -})); + + res.json({ + message: "2FA settings updated successfully", + twoFactorEnabled: user.twoFactorEnabled, + }); + }), +); // PUT /api/settings/security/password @@ -157,23 +180,33 @@ router.put('/security/2fa', preventContributorWrites, asyncHandler(async (req, r * 500: * description: Internal server error */ -router.put('/security/password', preventContributorWrites, asyncHandler(async (req, res) => { +router.put( + "/security/password", + preventContributorWrites, + asyncHandler(async (req, res) => { const { oldPassword, newPassword } = req.body; - + const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); - - if (user.authProvider !== 'local') { - return res.status(400).json({ error: 'Cannot change password for third-party authenticated accounts.' }); + if (!user) return res.status(404).json({ error: "User not found" }); + + if (user.authProvider !== "local") { + return res + .status(400) + .json({ + error: + "Cannot change password for third-party authenticated accounts.", + }); } - + const isMatch = await bcrypt.compare(oldPassword, user.password); if (!isMatch) { - return res.status(400).json({ error: 'Incorrect old password' }); + return res.status(400).json({ error: "Incorrect old password" }); } - + if (!newPassword || newPassword.length < 8) { - return res.status(400).json({ error: 'New password must be at least 8 characters.' }); + return res + .status(400) + .json({ error: "New password must be at least 8 characters." }); } const salt = await bcrypt.genSalt(10); @@ -183,11 +216,12 @@ router.put('/security/password', preventContributorWrites, asyncHandler(async (r const days = daysSince(user.passwordChangedAt); res.json({ - message: 'Password updated successfully', - passwordChangedAt: user.passwordChangedAt, - passwordAgeDays: days, + message: "Password updated successfully", + passwordChangedAt: user.passwordChangedAt, + passwordAgeDays: days, }); -})); + }), +); // DELETE /api/settings/account @@ -207,17 +241,21 @@ router.put('/security/password', preventContributorWrites, asyncHandler(async (r * 500: * description: Internal server error */ -router.delete('/account', preventContributorWrites, asyncHandler(async (req, res) => { +router.delete( + "/account", + preventContributorWrites, + asyncHandler(async (req, res) => { const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); - - if (typeof user.deleteOne === 'function') { - await user.deleteOne(); + if (!user) return res.status(404).json({ error: "User not found" }); + + if (typeof user.deleteOne === "function") { + await user.deleteOne(); } - res.clearCookie('token'); - res.json({ message: 'Account deleted successfully' }); -})); + res.clearCookie("token"); + res.json({ message: "Account deleted successfully" }); + }), +); // PUT /api/settings/preferences @@ -237,20 +275,26 @@ router.delete('/account', preventContributorWrites, asyncHandler(async (req, res * 500: * description: Internal server error */ -router.put('/preferences', asyncHandler(async (req, res) => { +router.put( + "/preferences", + asyncHandler(async (req, res) => { // Note: Not using preventContributorWrites here so contributors can still save personal UI preferences const preferences = req.body; - + const user = await User.findById(req.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); - + if (!user) return res.status(404).json({ error: "User not found" }); + user.preferences = { - ...user.preferences, - ...preferences + ...user.preferences, + ...preferences, }; - + await user.save(); - res.json({ message: 'Preferences updated successfully', preferences: user.preferences }); -})); + res.json({ + message: "Preferences updated successfully", + preferences: user.preferences, + }); + }), +); module.exports = router; diff --git a/routes/suggestionRoutes.js b/routes/suggestionRoutes.js index f5b87e52..f5d710da 100644 --- a/routes/suggestionRoutes.js +++ b/routes/suggestionRoutes.js @@ -1,8 +1,10 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); -const { getPage, getSuggestions } = require('../controller/suggestionController'); -const { generateSuggestionValidator } = require('../middleware/validators'); - +const { + getPage, + getSuggestions, +} = require("../controller/suggestionController"); +const { generateSuggestionValidator } = require("../middleware/validators"); /** * @swagger @@ -20,7 +22,7 @@ const { generateSuggestionValidator } = require('../middleware/validators'); * 500: * description: Internal server error */ -router.get('/', getPage); +router.get("/", getPage); /** * @swagger @@ -38,6 +40,6 @@ router.get('/', getPage); * 500: * description: Internal server error */ -router.post('/', generateSuggestionValidator, getSuggestions); +router.post("/", generateSuggestionValidator, getSuggestions); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/url.js b/routes/url.js index 7e102278..d37ba965 100644 --- a/routes/url.js +++ b/routes/url.js @@ -1,18 +1,20 @@ -const express = require('express'); +const express = require("express"); const router = express.Router(); const { - handleGenerateShortUrl, - handleListUserLinks, - handleGetQRCode, - handleDownloadQRCode, - handleUpdateQRColors, - handleGetAnalytics, -} = require('../controller/url'); -const protect = require('../middleware/auth'); -const { preventContributorWrites } = require('../middleware/auth'); -const { urlShortenerApiLimiter } = require('../middleware/rateLimiters'); -const { shortenUrlValidator, updateQrColorsValidator } = require('../middleware/validators'); - + handleGenerateShortUrl, + handleListUserLinks, + handleGetQRCode, + handleDownloadQRCode, + handleUpdateQRColors, + handleGetAnalytics, +} = require("../controller/url"); +const protect = require("../middleware/auth"); +const { preventContributorWrites } = require("../middleware/auth"); +const { urlShortenerApiLimiter } = require("../middleware/rateLimiters"); +const { + shortenUrlValidator, + updateQrColorsValidator, +} = require("../middleware/validators"); /** * @swagger @@ -30,7 +32,7 @@ const { shortenUrlValidator, updateQrColorsValidator } = require('../middleware/ * 500: * description: Internal server error */ -router.get('/', protect, handleListUserLinks); +router.get("/", protect, handleListUserLinks); /** * @swagger @@ -48,7 +50,7 @@ router.get('/', protect, handleListUserLinks); * 500: * description: Internal server error */ -router.get('/analytics/:shortId', handleGetAnalytics); +router.get("/analytics/:shortId", handleGetAnalytics); // ── Short URL Endpoints ───────────────────────────────────────────────────── /** @@ -67,7 +69,14 @@ router.get('/analytics/:shortId', handleGetAnalytics); * 500: * description: Internal server error */ -router.post('/shorten', protect, preventContributorWrites, urlShortenerApiLimiter, shortenUrlValidator, handleGenerateShortUrl); +router.post( + "/shorten", + protect, + preventContributorWrites, + urlShortenerApiLimiter, + shortenUrlValidator, + handleGenerateShortUrl, +); /** * @swagger @@ -85,7 +94,14 @@ router.post('/shorten', protect, preventContributorWrites, urlShortenerApiLimite * 500: * description: Internal server error */ -router.post('/', protect, preventContributorWrites, urlShortenerApiLimiter, shortenUrlValidator, handleGenerateShortUrl); +router.post( + "/", + protect, + preventContributorWrites, + urlShortenerApiLimiter, + shortenUrlValidator, + handleGenerateShortUrl, +); // ── QR Code Endpoints ─────────────────────────────────────────────────────── @@ -105,7 +121,7 @@ router.post('/', protect, preventContributorWrites, urlShortenerApiLimiter, shor * 500: * description: Internal server error */ -router.get('/qr/:shortId/download', handleDownloadQRCode); +router.get("/qr/:shortId/download", handleDownloadQRCode); /** * @swagger @@ -123,7 +139,7 @@ router.get('/qr/:shortId/download', handleDownloadQRCode); * 500: * description: Internal server error */ -router.get('/qr/:shortId', handleGetQRCode); +router.get("/qr/:shortId", handleGetQRCode); /** * @swagger @@ -141,7 +157,13 @@ router.get('/qr/:shortId', handleGetQRCode); * 500: * description: Internal server error */ -router.patch('/qr/:shortId/colors', protect, preventContributorWrites, updateQrColorsValidator, handleUpdateQRColors); +router.patch( + "/qr/:shortId/colors", + protect, + preventContributorWrites, + updateQrColorsValidator, + handleUpdateQRColors, +); // ── Analytics Endpoints ───────────────────────────────────────────────────── @@ -161,6 +183,6 @@ router.patch('/qr/:shortId/colors', protect, preventContributorWrites, updateQrC * 500: * description: Internal server error */ -router.get('/analytics/:shortId', handleGetAnalytics); +router.get("/analytics/:shortId", handleGetAnalytics); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/scratch.js b/scratch.js index c6a47873..6882571f 100644 --- a/scratch.js +++ b/scratch.js @@ -1,6 +1,8 @@ -const app = require('./index'); -const listEndpoints = require('express-list-endpoints'); +const app = require("./index"); +const listEndpoints = require("express-list-endpoints"); try { - const list = require('express-list-endpoints'); - console.log(list(app).map(r => r.path)); -} catch (e) { console.log(e.message); } + const list = require("express-list-endpoints"); + console.log(list(app).map((r) => r.path)); +} catch (e) { + console.log(e.message); +} diff --git a/scripts/addDocs.js b/scripts/addDocs.js index 2a4c674e..e91b4081 100644 --- a/scripts/addDocs.js +++ b/scripts/addDocs.js @@ -1,29 +1,31 @@ -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); const dirs = { - routes: './routes', - controller: './controller', - model: './model', - middleware: './middleware', - utils: './utils' + routes: "./routes", + controller: "./controller", + model: "./model", + middleware: "./middleware", + utils: "./utils", }; function processRoutes() { const dir = dirs.routes; if (!fs.existsSync(dir)) return; - const files = fs.readdirSync(dir).filter(f => f.endsWith('.js')); - - files.forEach(file => { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".js")); + + files.forEach((file) => { const filePath = path.join(dir, file); - let content = fs.readFileSync(filePath, 'utf8'); - + let content = fs.readFileSync(filePath, "utf8"); + // Inject swagger docs above router.(get|post|put|delete|patch) - content = content.replace(/(router\.(get|post|put|delete|patch)\((['"`])([^'"`]+)\3\s*,)/g, (match, full, method, q, routePath) => { - // Don't add if already documented - if (content.indexOf(`* @swagger\n * ${routePath}`) !== -1) return match; - - const swagger = ` + content = content.replace( + /(router\.(get|post|put|delete|patch)\((['"`])([^'"`]+)\3\s*,)/g, + (match, full, method, q, routePath) => { + // Don't add if already documented + if (content.indexOf(`* @swagger\n * ${routePath}`) !== -1) return match; + + const swagger = ` /** * @swagger * ${routePath}: @@ -41,9 +43,10 @@ function processRoutes() { * description: Internal server error */ `; - return swagger + full; - }); - + return swagger + full; + }, + ); + fs.writeFileSync(filePath, content); console.log(`Updated routes: ${file}`); }); @@ -52,15 +55,17 @@ function processRoutes() { function processGeneric(dirName) { const dir = dirs[dirName]; if (!fs.existsSync(dir)) return; - const files = fs.readdirSync(dir).filter(f => f.endsWith('.js')); - - files.forEach(file => { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".js")); + + files.forEach((file) => { const filePath = path.join(dir, file); - let content = fs.readFileSync(filePath, 'utf8'); - + let content = fs.readFileSync(filePath, "utf8"); + // Document `const myFunc = (req, res` or `function myFunc` - content = content.replace(/^(const|let|var)\s+([a-zA-Z0-9_]+)\s*=\s*(async\s+)?(\([^)]*\)|[a-zA-Z0-9_]+)\s*=>/gm, (match, decl, name) => { - const doc = `/** + content = content.replace( + /^(const|let|var)\s+([a-zA-Z0-9_]+)\s*=\s*(async\s+)?(\([^)]*\)|[a-zA-Z0-9_]+)\s*=>/gm, + (match, decl, name) => { + const doc = `/** * @function ${name} * @description Implementation for ${name} * @param {Object} req - Express request object @@ -68,12 +73,15 @@ function processGeneric(dirName) { * @param {Function} next - Express next middleware function * @returns {Promise|void} */\n`; - // check if already documented - if (content.includes(`@function ${name}`)) return match; - return doc + match; - }); + // check if already documented + if (content.includes(`@function ${name}`)) return match; + return doc + match; + }, + ); - content = content.replace(/^async\s+function\s+([a-zA-Z0-9_]+)\s*\(/gm, (match, name) => { + content = content.replace( + /^async\s+function\s+([a-zA-Z0-9_]+)\s*\(/gm, + (match, name) => { const doc = `/** * @function ${name} * @description Implementation for ${name} @@ -84,9 +92,12 @@ function processGeneric(dirName) { */\n`; if (content.includes(`@function ${name}`)) return match; return doc + match; - }); + }, + ); - content = content.replace(/^function\s+([a-zA-Z0-9_]+)\s*\(/gm, (match, name) => { + content = content.replace( + /^function\s+([a-zA-Z0-9_]+)\s*\(/gm, + (match, name) => { const doc = `/** * @function ${name} * @description Implementation for ${name} @@ -94,8 +105,9 @@ function processGeneric(dirName) { */\n`; if (content.includes(`@function ${name}`)) return match; return doc + match; - }); - + }, + ); + fs.writeFileSync(filePath, content); console.log(`Updated ${dirName}: ${file}`); }); @@ -104,31 +116,37 @@ function processGeneric(dirName) { function processModels() { const dir = dirs.model; if (!fs.existsSync(dir)) return; - const files = fs.readdirSync(dir).filter(f => f.endsWith('.js')); - - files.forEach(file => { + const files = fs.readdirSync(dir).filter((f) => f.endsWith(".js")); + + files.forEach((file) => { const filePath = path.join(dir, file); - let content = fs.readFileSync(filePath, 'utf8'); - + let content = fs.readFileSync(filePath, "utf8"); + // Document mongoose schemas - content = content.replace(/^(const|let|var)\s+([a-zA-Z0-9_]+Schema)\s*=\s*new\s+(mongoose\.)?Schema/gm, (match, decl, name) => { - const doc = `/** + content = content.replace( + /^(const|let|var)\s+([a-zA-Z0-9_]+Schema)\s*=\s*new\s+(mongoose\.)?Schema/gm, + (match, decl, name) => { + const doc = `/** * @schema ${name} - * @description Mongoose schema definition for ${name.replace('Schema', '')}. + * @description Mongoose schema definition for ${name.replace("Schema", "")}. */\n`; - if (content.includes(`@schema ${name}`)) return match; - return doc + match; - }); + if (content.includes(`@schema ${name}`)) return match; + return doc + match; + }, + ); // Document mongoose models - content = content.replace(/(module\.exports\s*=\s*|const\s+[a-zA-Z0-9_]+\s*=\s*)(mongoose\.)?model\(/gm, (match) => { - const doc = `/** + content = content.replace( + /(module\.exports\s*=\s*|const\s+[a-zA-Z0-9_]+\s*=\s*)(mongoose\.)?model\(/gm, + (match) => { + const doc = `/** * @model * @description Mongoose model compilation. */\n`; - if (content.includes(`@model`)) return match; - return doc + match; - }); + if (content.includes(`@model`)) return match; + return doc + match; + }, + ); fs.writeFileSync(filePath, content); console.log(`Updated models: ${file}`); @@ -136,9 +154,9 @@ function processModels() { } processRoutes(); -processGeneric('controller'); -processGeneric('middleware'); -processGeneric('utils'); +processGeneric("controller"); +processGeneric("middleware"); +processGeneric("utils"); processModels(); -console.log('Documentation injected successfully.'); +console.log("Documentation injected successfully."); diff --git a/scripts/replace-layout.js b/scripts/replace-layout.js index ae877dd8..e12bfac9 100644 --- a/scripts/replace-layout.js +++ b/scripts/replace-layout.js @@ -1,11 +1,11 @@ -const fs = require('fs'); -const path = require('path'); +const fs = require("fs"); +const path = require("path"); -const viewsDir = path.join(__dirname, '..', 'view'); -const partialsDir = path.join(viewsDir, 'partials', 'layout'); +const viewsDir = path.join(__dirname, "..", "view"); +const partialsDir = path.join(viewsDir, "partials", "layout"); if (!fs.existsSync(partialsDir)) { - fs.mkdirSync(partialsDir, { recursive: true }); + fs.mkdirSync(partialsDir, { recursive: true }); } // 1. Create the sidebar partial based on the extracted dashboard layout @@ -66,7 +66,7 @@ const sidebarContent = ` - content = content.replace(/(?:\s*)? + content = content.replace( + /(?:\s*)?