Built in 4 hours. Saves $7,200/year vs Hootsuite.
A lightweight, self-hosted social media scheduler with password protection, multi-platform support (X/Twitter, LinkedIn, Nostr), and automated posting via cron.
✅ Multi-Platform Posting
- X (Twitter) via OAuth 1.0a
- LinkedIn via OAuth 2.0
- Nostr via NIP-01
✅ Security
- Password-protected UI (SHA-256 hashing)
- 24-hour session expiration
- All pages require authentication
✅ Queue Management
- Schedule posts for specific dates/times
- Filter by status (scheduled, posted, failed)
- Filter by platform
- Bulk import from calendar data
✅ Calendar View
- Month grid showing all scheduled posts
- Click days to see post details
- Visual indicators for multiple posts
✅ Automated Posting
- Cron-based scheduler (runs every 15 minutes)
- Auto-posts at scheduled times
- Updates post status automatically
- Logs all activity
Frontend: Static HTML + Vanilla JS (Tailwind CSS) Backend: Node.js scripts Storage: localStorage (UI) + JSON file (scheduler) Deployment: Netlify (UI) + Self-hosted (scheduler) Authentication: Client-side SHA-256 + session tokens
git clone (your-repo-url)
cd maxisuiteCreate credentials-config.md with your API keys:
## X (Twitter) OAuth 1.0a
- API Key: (insert your X API key)
- API Secret: (insert your X API secret)
- Access Token: (insert your X access token)
- Access Token Secret: (insert your X token secret)
- Account: @(insert your X handle)
## LinkedIn OAuth 2.0
- Client ID: (insert LinkedIn client ID)
- Client Secret: (insert LinkedIn client secret)
- Access Token: (insert LinkedIn access token)
- Redirect URI: https://(your-domain)/callback/linkedin
- User ID: (insert LinkedIn user ID from id_token)
## Nostr
- Private Key (nsec): (insert your Nostr private key)
- Public Key (npub): (insert your Nostr public key)
- Relays: wss://relay.damus.io, wss://relay.primal.net, (add more)Open set-password.html in browser:
- Enter desired password
- Click "Generate Hash"
- Copy the hash
- Update
js/auth.js:
const VALID_PASSWORD_HASH = '(paste your hash here)';# Connect to Netlify
netlify init
# Deploy
netlify deploy --prodOn your server (needs Node.js):
# Install dependencies (if any)
npm install
# Make scripts executable
chmod +x scheduler/*.mjs scheduler/*.sh
# Test scheduler
node scheduler/check-queue.mjs
# Add to cron (runs every 15 minutes)
crontab -e
# Add line:
*/15 * * * * cd /path/to/maxisuite && node scheduler/check-queue.mjsmaxisuite/
├── index.html # Composer (create posts)
├── queue.html # Queue view (manage scheduled posts)
├── calendar.html # Calendar view
├── login.html # Login page
├── set-password.html # Password setup utility
├── import-campaign.html # Bulk import tool
│
├── js/
│ ├── auth.js # Authentication logic
│ ├── queue.js # Queue management
│ ├── calendar.js # Calendar rendering
│ ├── auto-import.js # Auto-import campaign data
│ └── campaign-status.js # Status banner
│
├── scheduler/
│ ├── check-queue.mjs # Main scheduler (checks queue, posts)
│ ├── sync-queue.sh # Initial queue sync
│ └── status.sh # View queue status
│
├── api/
│ └── (platform-specific posting scripts)
│
└── netlify.toml # Netlify config
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MaxiSuite - Login</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
<div class="max-w-md w-full mx-4">
<div class="bg-gray-800 rounded-lg p-8 border border-gray-700">
<div class="text-center mb-8">
<span class="text-5xl">(your logo emoji)</span>
<h1 class="text-3xl font-bold text-yellow-500 mt-2">MaxiSuite</h1>
<p class="text-gray-400 mt-2">Social Media Scheduler</p>
</div>
<form id="login-form" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Password</label>
<input
type="password"
id="password"
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-3 text-white focus:outline-none focus:border-yellow-500"
placeholder="Enter password"
autofocus
>
</div>
<button
type="submit"
class="w-full bg-yellow-500 hover:bg-yellow-600 text-gray-900 font-bold py-3 rounded-lg transition"
>
Login
</button>
<div id="error" class="hidden bg-red-900 border border-red-700 text-red-200 px-4 py-3 rounded text-sm">
Invalid password
</div>
</form>
</div>
</div>
<script src="js/auth.js"></script>
</body>
</html>// MaxiSuite - Authentication
const AUTH_KEY = 'maxisuite-auth';
// Password hash (SHA-256 of the actual password)
// Generate via set-password.html
const VALID_PASSWORD_HASH = '(insert your password hash here)';
// Hash password using SHA-256
async function hashPassword(password) {
const msgBuffer = new TextEncoder().encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Check if user is authenticated
function isAuthenticated() {
const auth = localStorage.getItem(AUTH_KEY);
if (!auth) return false;
try {
const data = JSON.parse(auth);
// Session expires after 24 hours
if (Date.now() - data.timestamp > 24 * 60 * 60 * 1000) {
localStorage.removeItem(AUTH_KEY);
return false;
}
return data.authenticated === true;
} catch {
return false;
}
}
// Require authentication (call at top of every page)
function requireAuth() {
if (!isAuthenticated() && !window.location.pathname.includes('login.html')) {
window.location.href = 'login.html';
}
}
// Handle login form
document.getElementById('login-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const hash = await hashPassword(password);
if (hash === VALID_PASSWORD_HASH) {
// Store authentication
localStorage.setItem(AUTH_KEY, JSON.stringify({
authenticated: true,
timestamp: Date.now()
}));
// Redirect to dashboard
window.location.href = 'index.html';
} else {
// Show error
document.getElementById('error').classList.remove('hidden');
document.getElementById('password').value = '';
document.getElementById('password').focus();
}
});
// Logout function
window.logout = function() {
localStorage.removeItem(AUTH_KEY);
window.location.href = 'login.html';
};<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Set MaxiSuite Password</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100 p-8">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-4">Set MaxiSuite Password</h1>
<p class="text-gray-400 mb-6">Enter your desired password and click "Generate Hash"</p>
<input
type="password"
id="password"
placeholder="Enter password"
class="w-full bg-gray-700 border border-gray-600 rounded px-4 py-3 text-white mb-4"
>
<button
onclick="generateHash()"
class="bg-yellow-500 hover:bg-yellow-600 text-gray-900 font-bold px-6 py-3 rounded"
>
Generate Hash
</button>
<div id="output" class="hidden mt-6">
<p class="text-gray-300 mb-2">Copy this hash and replace VALID_PASSWORD_HASH in js/auth.js:</p>
<pre id="hash" class="bg-gray-800 p-4 rounded border border-gray-700 text-yellow-500 font-mono text-sm overflow-x-auto"></pre>
</div>
</div>
<script>
async function generateHash() {
const password = document.getElementById('password').value;
if (!password) {
alert('Enter a password first!');
return;
}
const msgBuffer = new TextEncoder().encode(password);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
document.getElementById('hash').textContent = hash;
document.getElementById('output').classList.remove('hidden');
}
</script>
</body>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Post Queue - MaxiSuite</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-900 text-gray-100">
<nav class="bg-gray-800 border-b border-gray-700 px-6 py-4">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-2xl">(logo)</span>
<h1 class="text-xl font-bold text-yellow-500">MaxiSuite</h1>
</div>
<div class="flex space-x-6">
<a href="index.html" class="text-gray-400 hover:text-white">Compose</a>
<a href="queue.html" class="text-yellow-500 font-medium">Queue</a>
<a href="calendar.html" class="text-gray-400 hover:text-white">Calendar</a>
<button onclick="logout()" class="text-gray-400 hover:text-red-400 text-sm">Logout</button>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold">Scheduled Posts</h2>
<a href="index.html" class="bg-yellow-500 hover:bg-yellow-600 text-gray-900 font-bold px-4 py-2 rounded-lg">
+ New Post
</a>
</div>
<!-- Filters -->
<div class="bg-gray-800 rounded-lg p-4 mb-6">
<div class="flex flex-wrap gap-4">
<div>
<label class="text-sm text-gray-400 mb-1 block">Status</label>
<select id="filter-status" class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white">
<option value="all">All Statuses</option>
<option value="pending">Pending Approval</option>
<option value="scheduled" selected>Scheduled</option>
<option value="posted">Posted</option>
<option value="failed">Failed</option>
</select>
</div>
<div>
<label class="text-sm text-gray-400 mb-1 block">Platform</label>
<select id="filter-platform" class="bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white">
<option value="all">All Platforms</option>
<option value="x">X (Twitter)</option>
<option value="linkedin">LinkedIn</option>
<option value="nostr">Nostr</option>
</select>
</div>
</div>
</div>
<!-- Post Queue -->
<div id="post-queue" class="space-y-4">
<!-- Posts will be loaded here -->
</div>
</main>
<script src="js/auth.js"></script>
<script src="js/queue.js"></script>
<script>requireAuth();</script>
</body>
</html>// MaxiSuite - Queue Management
const QUEUE_KEY = 'maxisuite-queue';
// Load queue from localStorage
function loadQueue() {
const stored = localStorage.getItem(QUEUE_KEY);
return stored ? JSON.parse(stored) : [];
}
// Save queue to localStorage
function saveQueue(queue) {
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
}
// Render queue
function renderQueue() {
const queue = loadQueue();
const container = document.getElementById('post-queue');
// Filter by status and platform
const statusFilter = document.getElementById('filter-status').value;
const platformFilter = document.getElementById('filter-platform').value;
const filtered = queue.filter(post => {
if (statusFilter !== 'all' && post.status !== statusFilter) return false;
if (platformFilter !== 'all') {
const hasPlatform = post.platforms[platformFilter];
if (!hasPlatform) return false;
}
return true;
});
if (filtered.length === 0) {
container.innerHTML = `
<div class="text-center py-12 text-gray-500">
<p class="text-lg">No posts match your filters</p>
</div>
`;
return;
}
container.innerHTML = filtered.map(post => `
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center space-x-2">
${getStatusBadge(post.status)}
${getPlatformBadges(post.platforms)}
</div>
<div class="text-sm text-gray-400">
${formatDate(post.scheduledFor)}
</div>
</div>
<p class="text-white mb-4">${escapeHtml(post.content)}</p>
<div class="flex space-x-3">
${post.status === 'scheduled' ? `
<button onclick="postNow('${post.id}')" class="bg-yellow-600 hover:bg-yellow-500 text-gray-900 px-4 py-2 rounded text-sm font-medium">
Post Now
</button>
` : ''}
<button onclick="deletePost('${post.id}')" class="bg-red-900 hover:bg-red-800 text-red-200 px-4 py-2 rounded text-sm font-medium">
Delete
</button>
</div>
</div>
`).join('');
}
// Status badge
function getStatusBadge(status) {
const badges = {
scheduled: '<span class="bg-blue-900 text-blue-200 px-3 py-1 rounded-full text-xs font-medium">🔵 SCHEDULED</span>',
posted: '<span class="bg-green-900 text-green-200 px-3 py-1 rounded-full text-xs font-medium">✅ POSTED</span>',
failed: '<span class="bg-red-900 text-red-200 px-3 py-1 rounded-full text-xs font-medium">❌ FAILED</span>'
};
return badges[status] || '';
}
// Platform badges
function getPlatformBadges(platforms) {
const badges = [];
if (platforms.x) badges.push('<span class="text-blue-400 text-sm">𝕏</span>');
if (platforms.linkedin) badges.push('<span class="text-blue-500 text-sm">in</span>');
if (platforms.nostr) badges.push('<span class="text-purple-400 text-sm">⚡</span>');
return badges.join(' ');
}
// Format date
function formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Delete post
window.deletePost = function(id) {
if (confirm('Delete this post permanently?')) {
const queue = loadQueue();
const updated = queue.filter(p => p.id !== id);
saveQueue(updated);
renderQueue();
}
};
// Post now (placeholder - needs backend integration)
window.postNow = function(id) {
alert('Post Now: Integrate with your backend scheduler');
};
// Event listeners
document.getElementById('filter-status')?.addEventListener('change', renderQueue);
document.getElementById('filter-platform')?.addEventListener('change', renderQueue);
// Initial render
renderQueue();#!/usr/bin/env node
/**
* MaxiSuite Scheduler - Check queue and post due items
* Run via cron every 15 minutes
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { execSync } from 'child_process';
import { resolve } from 'path';
const QUEUE_FILE = resolve(process.env.HOME, '(path-to-your-queue-file)/maxisuite-queue.json');
const LOG_FILE = resolve(process.env.HOME, '(path-to-your-log-file)/maxisuite-scheduler.log');
function log(message) {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] ${message}\n`;
console.log(message);
try {
writeFileSync(LOG_FILE, logLine, { flag: 'a' });
} catch (err) {
console.error('Failed to write log:', err.message);
}
}
function loadQueue() {
if (!existsSync(QUEUE_FILE)) {
log('Queue file does not exist');
return [];
}
try {
const data = readFileSync(QUEUE_FILE, 'utf-8');
return JSON.parse(data);
} catch (err) {
log(`ERROR loading queue: ${err.message}`);
return [];
}
}
function saveQueue(queue) {
try {
writeFileSync(QUEUE_FILE, JSON.stringify(queue, null, 2));
log('Queue saved successfully');
} catch (err) {
log(`ERROR saving queue: ${err.message}`);
}
}
function shouldPost(scheduledTime) {
const now = new Date();
const scheduled = new Date(scheduledTime);
// Post if scheduled time is within the last 15 minutes or is now/past
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
return scheduled >= fifteenMinutesAgo && scheduled <= now;
}
function postToX(content) {
try {
// Replace with your X posting script
const result = execSync(
`node (path-to-x-post-script) "${content.replace(/"/g, '\\"')}"`,
{ encoding: 'utf-8', timeout: 30000 }
);
return { success: true, output: result };
} catch (err) {
return { success: false, error: err.message };
}
}
function postToNostr(content) {
try {
// Replace with your Nostr posting script
const result = execSync(
`node (path-to-nostr-post-script) "${content.replace(/"/g, '\\"')}"`,
{ encoding: 'utf-8', timeout: 30000 }
);
return { success: true, output: result };
} catch (err) {
return { success: false, error: err.message };
}
}
function postToLinkedIn(content) {
try {
// Replace with your LinkedIn posting script
// Implementation varies based on your auth setup
return { success: true };
} catch (err) {
return { success: false, error: err.message };
}
}
// Main execution
log('=== MaxiSuite Scheduler Check ===');
const queue = loadQueue();
const dueNow = queue.filter(post => post.status === 'scheduled' && shouldPost(post.scheduledFor));
log(`Checked queue: ${queue.length} total posts, ${dueNow.length} due now`);
if (dueNow.length === 0) {
log('No posts due, exiting');
process.exit(0);
}
// Process each due post
for (const post of dueNow) {
log(`Processing post ${post.id}: "${post.content.substring(0, 50)}..."`);
const results = {};
let allSucceeded = true;
// Post to each platform
if (post.platforms.x) {
log(' → Posting to X...');
const result = postToX(post.content);
results.x = result;
if (result.success) {
log(' ✅ X posted successfully');
} else {
log(` ❌ X failed: ${result.error}`);
allSucceeded = false;
}
}
if (post.platforms.nostr) {
log(' → Posting to Nostr...');
const result = postToNostr(post.content);
results.nostr = result;
if (result.success) {
log(' ✅ Nostr posted successfully');
} else {
log(` ❌ Nostr failed: ${result.error}`);
allSucceeded = false;
}
}
if (post.platforms.linkedin) {
log(' → Posting to LinkedIn...');
const result = postToLinkedIn(post.content);
results.linkedin = result;
if (result.success) {
log(' ✅ LinkedIn posted successfully');
} else {
log(` ❌ LinkedIn failed: ${result.error}`);
allSucceeded = false;
}
}
// Update post status
post.status = allSucceeded ? 'posted' : 'failed';
post.postedAt = new Date().toISOString();
post.results = results;
}
// Save updated queue
saveQueue(queue);
log(`=== Scheduler Complete: ${dueNow.length} posts processed ===`);// x-post.mjs
import crypto from 'crypto';
import https from 'https';
const config = {
apiKey: '(your X API key)',
apiSecret: '(your X API secret)',
accessToken: '(your X access token)',
accessTokenSecret: '(your X token secret)'
};
function generateOAuthSignature(method, url, params, consumerSecret, tokenSecret) {
const sortedParams = Object.keys(params).sort().map(key =>
`${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`
).join('&');
const baseString = `${method}&${encodeURIComponent(url)}&${encodeURIComponent(sortedParams)}`;
const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(tokenSecret)}`;
return crypto.createHmac('sha1', signingKey).update(baseString).digest('base64');
}
function postTweet(text) {
const url = 'https://api.twitter.com/2/tweets';
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(32).toString('hex');
const oauthParams = {
oauth_consumer_key: config.apiKey,
oauth_token: config.accessToken,
oauth_signature_method: 'HMAC-SHA1',
oauth_timestamp: timestamp,
oauth_nonce: nonce,
oauth_version: '1.0'
};
const signature = generateOAuthSignature('POST', url, oauthParams, config.apiSecret, config.accessTokenSecret);
oauthParams.oauth_signature = signature;
const authHeader = 'OAuth ' + Object.keys(oauthParams).map(key =>
`${encodeURIComponent(key)}="${encodeURIComponent(oauthParams[key])}"`
).join(', ');
const postData = JSON.stringify({ text });
return new Promise((resolve, reject) => {
const req = https.request(url, {
method: 'POST',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode === 201) {
resolve(JSON.parse(data));
} else {
reject(new Error(`Failed: ${data}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
// CLI usage
const text = process.argv[2];
if (!text) {
console.error('Usage: node x-post.mjs "Your tweet text"');
process.exit(1);
}
postTweet(text)
.then(result => console.log('✅ Posted:', result))
.catch(err => console.error('❌ Error:', err.message));// nostr-post.mjs
import { Relay } from 'nostr-tools/relay';
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
const config = {
privateKey: '(your Nostr nsec as hex)',
relays: [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol'
]
};
async function postToNostr(content) {
const event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: content,
pubkey: getPublicKey(config.privateKey)
};
const signedEvent = finalizeEvent(event, config.privateKey);
const results = [];
for (const relayUrl of config.relays) {
try {
const relay = await Relay.connect(relayUrl);
await relay.publish(signedEvent);
relay.close();
results.push({ relay: relayUrl, success: true });
} catch (err) {
results.push({ relay: relayUrl, success: false, error: err.message });
}
}
return results;
}
// CLI usage
const content = process.argv[2];
if (!content) {
console.error('Usage: node nostr-post.mjs "Your note text"');
process.exit(1);
}
postToNostr(content)
.then(results => console.log('✅ Posted:', results))
.catch(err => console.error('❌ Error:', err.message));[build]
publish = "."
command = "echo 'MaxiSuite MVP'"
# Redirect for LinkedIn OAuth callback
[[redirects]]
from = "/callback/linkedin"
to = "/callback/linkedin.html"
status = 200
# API endpoints (if using Netlify Functions)
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/:splat"
status = 200
# Security headers
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
X-XSS-Protection = "1; mode=block"- SHA-256 hashing (client-side)
- 24-hour session expiration
- No plaintext passwords stored
requireAuth()on all pages
- Never commit credentials to git
- Use environment variables or secure config files
- Add credentials file to
.gitignore
- Deploy UI on Netlify (automatic HTTPS)
- Use HTTPS for all API calls
- Validate SSL certificates
- Twitter: 50 tweets per 24h (user context)
- LinkedIn: Varies by product tier
- Nostr: No rate limits (but relay-dependent)
- localStorage (browser) for UI state
- JSON file (server) for scheduler queue
- Keep credentials separate from queue data
Replace placeholders:
(your logo emoji)→ Your logo(insert company name)→ Your company- Color scheme in Tailwind classes
Add/remove platforms in:
queue.htmlfiltersscheduler/check-queue.mjsposting logic- Platform badges in
queue.js
Adjust cron interval in crontab:
- Every 15 min:
*/15 * * * * - Every hour:
0 * * * * - Every 30 min:
*/30 * * * *
Options:
- localStorage (current): Simple, client-side only
- JSON file (scheduler): Server-side, persistent
- Database: PostgreSQL, MongoDB for scale
- Cloud storage: S3, Firebase for distributed teams
- Check password hash matches in
js/auth.js - Verify
set-password.htmlgenerated correct hash - Clear browser localStorage and try again
- Check cron job is running:
crontab -l - Verify scheduler has execute permissions:
chmod +x scheduler/*.mjs - Check logs:
tail -f (log-file-path) - Test manually:
node scheduler/check-queue.mjs
- X: Check API tier limits, verify OAuth credentials
- LinkedIn: Ensure "Sign In with LinkedIn" product added
- Nostr: Verify relays are reachable, check private key format
- UI (localStorage) and scheduler (JSON) are separate
- Manual sync needed or build API bridge
- Consider using API endpoints to share queue state
Hootsuite Professional:
- $99/month × 3 users = $297/month
- $3,564/year
MaxiSuite Self-Hosted:
- Server: $5-20/month (DigitalOcean, Linode)
- Netlify: Free tier
- Total: ~$240/year
Savings: $3,324/year (for 3-user setup)
- Auto-cleanup (delete old posts after 30 days)
- Multi-account support per platform
- Thread/carousel support (X)
- Image upload support
- Analytics dashboard
- WhatsApp approval workflow
- Real-time queue sync (websockets)
- Team collaboration features
- Post templates
- AI content suggestions
This project was built in 4 hours as a Hootsuite replacement. It's functional but rough around the edges.
Contributions welcome:
- Bug fixes
- Platform integrations
- UI improvements
- Documentation
(Choose your license: MIT, Apache 2.0, GPL, etc.)
Built by: (your name/organization) Inspired by: The need for affordable, self-hosted social media scheduling Built with: Node.js, Vanilla JS, Tailwind CSS, ☕
For questions, issues, or contributions:
- Email: (your email)
- GitHub: (your repo)
- Twitter: (your handle)
Build in hours. Save thousands. Own your infrastructure.