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** |    |
-| **Backend** |   |
-| **Database** |   |
-| **Auth** |   |
-| **Automation** |  |
-| **AI** |   |
-| **Hosting** |    |
+| Layer | Technology |
+| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Frontend** |    |
+| **Backend** |   |
+| **Database** |   |
+| **Auth** |   |
+| **Automation** |  |
+| **AI** |   |
+| **Hosting** |    |
@@ -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*)?