883 lines
24 KiB
HTML
883 lines
24 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Feature Requests - Plugin Compass</title>
|
|
<link rel="icon" type="image/png" href="/assets/Plugin.png">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=Inter:wght@400;600&display=swap"
|
|
rel="stylesheet">
|
|
<link rel="stylesheet" href="/chat/styles.css">
|
|
<style>
|
|
:root {
|
|
--shopify-green: #008060;
|
|
--shopify-green-dark: #004c3f;
|
|
--shopify-green-light: #e3f5ef;
|
|
--accent: #5A31F4;
|
|
--accent-2: #8B5CF6;
|
|
}
|
|
|
|
body {
|
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
}
|
|
|
|
.fr-container {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
padding: 40px 24px;
|
|
}
|
|
|
|
.fr-header {
|
|
background: #fff;
|
|
border: 1px solid var(--border, #e6e9ee);
|
|
border-radius: 16px;
|
|
padding: 24px;
|
|
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.05);
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.fr-header h1 {
|
|
font-family: 'Space Grotesk', sans-serif;
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
color: #1a1a1a;
|
|
margin: 0 0 8px 0;
|
|
}
|
|
|
|
.fr-header p {
|
|
color: #6b757d;
|
|
font-size: 15px;
|
|
margin: 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.fr-form {
|
|
background: #fff;
|
|
border: 1px solid var(--border, #e6e9ee);
|
|
border-radius: 16px;
|
|
padding: 24px;
|
|
margin-bottom: 32px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
|
}
|
|
|
|
.fr-form h2 {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
margin: 0 0 16px 0;
|
|
color: #1a1a1a;
|
|
}
|
|
|
|
.fr-input {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
border: 2px solid #dee2e6;
|
|
border-radius: 12px;
|
|
font-size: 15px;
|
|
color: #1a1a1a;
|
|
transition: all 0.2s;
|
|
outline: none;
|
|
margin-bottom: 12px;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.fr-input:focus {
|
|
border-color: var(--shopify-green);
|
|
box-shadow: 0 0 0 3px var(--shopify-green-light);
|
|
}
|
|
|
|
.fr-input::placeholder {
|
|
color: #adb5bd;
|
|
}
|
|
|
|
.fr-textarea {
|
|
width: 100%;
|
|
padding: 12px 16px;
|
|
border: 2px solid #dee2e6;
|
|
border-radius: 12px;
|
|
font-size: 15px;
|
|
color: #1a1a1a;
|
|
transition: all 0.2s;
|
|
outline: none;
|
|
margin-bottom: 16px;
|
|
font-family: inherit;
|
|
min-height: 100px;
|
|
resize: vertical;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.fr-textarea:focus {
|
|
border-color: var(--shopify-green);
|
|
box-shadow: 0 0 0 3px var(--shopify-green-light);
|
|
}
|
|
|
|
.fr-submit-btn {
|
|
background: linear-gradient(135deg, var(--shopify-green), var(--shopify-green-dark));
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 12px;
|
|
padding: 12px 24px;
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.fr-submit-btn:hover:not(:disabled) {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 8px 24px rgba(0, 128, 96, 0.2);
|
|
}
|
|
|
|
.fr-submit-btn:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.fr-list-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.fr-list-header h2 {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
color: #1a1a1a;
|
|
}
|
|
|
|
.fr-sort {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.fr-sort-btn {
|
|
padding: 6px 12px;
|
|
border: 1px solid #dee2e6;
|
|
background: #fff;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
color: #6b757d;
|
|
}
|
|
|
|
.fr-sort-btn.active {
|
|
background: var(--shopify-green);
|
|
color: #fff;
|
|
border-color: var(--shopify-green);
|
|
}
|
|
|
|
.fr-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.fr-card {
|
|
background: #fff;
|
|
border: 1px solid var(--border, #e6e9ee);
|
|
border-radius: 16px;
|
|
padding: 20px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.fr-card:hover {
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
|
}
|
|
|
|
.fr-card-header {
|
|
display: flex;
|
|
gap: 16px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.fr-vote {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
min-width: 48px;
|
|
}
|
|
|
|
.fr-vote-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 10px;
|
|
border: 1px solid #dee2e6;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #6b757d;
|
|
}
|
|
|
|
.fr-vote-btn:hover:not(:disabled) {
|
|
border-color: var(--shopify-green);
|
|
color: var(--shopify-green);
|
|
background: var(--shopify-green-light);
|
|
}
|
|
|
|
.fr-vote-btn.voted {
|
|
background: var(--shopify-green);
|
|
color: #fff;
|
|
border-color: var(--shopify-green);
|
|
}
|
|
|
|
.fr-vote-btn:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.fr-vote-count {
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
color: #1a1a1a;
|
|
}
|
|
|
|
.fr-content {
|
|
flex: 1;
|
|
}
|
|
|
|
.fr-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: #1a1a1a;
|
|
margin: 0 0 8px 0;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.fr-description {
|
|
color: #6b757d;
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
margin: 0 0 12px 0;
|
|
}
|
|
|
|
.fr-meta {
|
|
display: flex;
|
|
gap: 16px;
|
|
font-size: 12px;
|
|
color: #adb5bd;
|
|
}
|
|
|
|
.fr-empty {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
background: #fff;
|
|
border-radius: 16px;
|
|
border: 2px dashed #dee2e6;
|
|
}
|
|
|
|
.fr-empty-icon {
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 50%;
|
|
background: var(--shopify-green-light);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 28px;
|
|
margin: 0 auto 16px;
|
|
color: var(--shopify-green);
|
|
}
|
|
|
|
.fr-empty h3 {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
margin: 0 0 8px 0;
|
|
color: #1a1a1a;
|
|
}
|
|
|
|
.fr-empty p {
|
|
color: #6b757d;
|
|
margin: 0;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid #f1f3f5;
|
|
border-top-color: var(--shopify-green);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
margin: 0 auto 16px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.nav-bar {
|
|
background: white;
|
|
border-bottom: 1px solid #e9ecef;
|
|
padding: 16px 24px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.nav-content {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.brand-mark {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 10px;
|
|
background: linear-gradient(135deg, var(--shopify-green), var(--shopify-green-dark));
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.brand-text {
|
|
font-family: 'Space Grotesk', sans-serif;
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: #1a1a1a;
|
|
}
|
|
|
|
.nav-links {
|
|
display: flex;
|
|
gap: 24px;
|
|
align-items: center;
|
|
}
|
|
|
|
.user-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 10px;
|
|
border: 1px solid #e9ecef;
|
|
border-radius: 12px;
|
|
text-decoration: none;
|
|
color: inherit;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.user-chip:hover {
|
|
border-color: var(--shopify-green);
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.user-chip-avatar {
|
|
width: 34px;
|
|
height: 34px;
|
|
border-radius: 999px;
|
|
background: var(--shopify-green-light);
|
|
color: var(--shopify-green);
|
|
display: grid;
|
|
place-items: center;
|
|
font-weight: 700;
|
|
letter-spacing: 0.02em;
|
|
text-transform: uppercase;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.nav-link {
|
|
color: #6c757d;
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.nav-link:hover {
|
|
color: var(--shopify-green);
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
right: 24px;
|
|
background: #1a1a1a;
|
|
color: #fff;
|
|
padding: 12px 20px;
|
|
border-radius: 12px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
|
transform: translateY(100px);
|
|
opacity: 0;
|
|
transition: all 0.3s ease;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.toast.show {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
}
|
|
|
|
.toast.error {
|
|
background: #dc3545;
|
|
}
|
|
|
|
.toast.success {
|
|
background: #28a745;
|
|
}
|
|
</style>
|
|
|
|
<!-- PostHog Analytics -->
|
|
<script src="/posthog.js"></script>
|
|
</head>
|
|
|
|
<body>
|
|
<nav class="nav-bar">
|
|
<div class="nav-content">
|
|
<a href="/" class="brand">
|
|
<img src="/assets/Plugin.png" alt="Plugin Compass" style="width: 32px; height: 32px; border-radius: 8px;">
|
|
<span class="brand-text">Plugin Compass</span>
|
|
</a>
|
|
<div class="nav-links">
|
|
<a href="/apps" class="nav-link">My Apps</a>
|
|
<a href="/settings" class="nav-link">Settings</a>
|
|
<div id="nav-auth-section">
|
|
<!-- This will be populated by JavaScript based on auth state -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="fr-container">
|
|
<div class="fr-header">
|
|
<h1>Feature Requests</h1>
|
|
<p>Help shape the future of Plugin Compass. Share your ideas and vote on features you'd like to see.</p>
|
|
</div>
|
|
|
|
<div class="fr-form">
|
|
<h2>Submit a Feature Request</h2>
|
|
<div id="auth-notice" style="display: none; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 8px; padding: 12px; margin-bottom: 16px; color: #856404;">
|
|
<strong>Sign in required:</strong> Please <a href="/login?next=%2Ffeature-requests" style="color: #0056b3;">sign in</a> to submit feature requests.
|
|
</div>
|
|
<input type="text" id="fr-title" class="fr-input" placeholder="Feature title (e.g., 'Add dark mode support')" maxlength="150" />
|
|
<textarea id="fr-description" class="fr-textarea" placeholder="Describe your feature request in detail. What problem would it solve? How would you use it?" maxlength="2000"></textarea>
|
|
<button class="fr-submit-btn" id="fr-submit">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
</svg>
|
|
Submit Feature Request
|
|
</button>
|
|
</div>
|
|
|
|
<div class="fr-list-header">
|
|
<h2>All Requests</h2>
|
|
<div class="fr-sort">
|
|
<button class="fr-sort-btn active" data-sort="votes">Most Voted</button>
|
|
<button class="fr-sort-btn" data-sort="newest">Newest</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="fr-list">
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<p>Loading feature requests...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
const state = {
|
|
userId: null,
|
|
featureRequests: [],
|
|
sortBy: 'votes',
|
|
};
|
|
|
|
const userChip = document.getElementById('fr-user-chip');
|
|
const userAvatar = document.getElementById('fr-user-avatar');
|
|
const frTitle = document.getElementById('fr-title');
|
|
const frDescription = document.getElementById('fr-description');
|
|
const frSubmit = document.getElementById('fr-submit');
|
|
const frList = document.getElementById('fr-list');
|
|
const toast = document.getElementById('toast');
|
|
|
|
function cyrb53(str, seed = 0) {
|
|
let h1 = 0xdeadbeef ^ seed;
|
|
let h2 = 0x41c6ce57 ^ seed;
|
|
for (let i = 0, ch; i < str.length; i++) {
|
|
ch = str.charCodeAt(i);
|
|
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
}
|
|
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
|
}
|
|
|
|
function computeAccountId(email) {
|
|
const normalized = (email || '').trim().toLowerCase();
|
|
if (!normalized) return '';
|
|
const hash = cyrb53(normalized);
|
|
return `acct-${hash.toString(16)}`;
|
|
}
|
|
|
|
function resolveUserId() {
|
|
try {
|
|
const keys = ['shopify_ai_user', 'wordpress_plugin_ai_user'];
|
|
for (const key of keys) {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (parsed && parsed.email) {
|
|
return parsed.accountId || computeAccountId(parsed.email);
|
|
}
|
|
}
|
|
}
|
|
} catch (_) { }
|
|
return '';
|
|
}
|
|
|
|
function readLocalEmail() {
|
|
const keys = ['shopify_ai_user', 'wordpress_plugin_ai_user'];
|
|
for (const key of keys) {
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
if (!raw) continue;
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed?.email) return parsed.email;
|
|
} catch (_) { }
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function setUserChipEmail(email) {
|
|
const safe = (email || '').trim();
|
|
if (userAvatar) userAvatar.textContent = safe ? safe.charAt(0).toUpperCase() : '?';
|
|
}
|
|
|
|
async function loadUserChip() {
|
|
let email = '';
|
|
try {
|
|
const resp = await fetch('/api/account', { credentials: 'same-origin' });
|
|
if (resp.ok) {
|
|
const data = await resp.json().catch(() => ({}));
|
|
email = data?.account?.email || '';
|
|
}
|
|
} catch (_) { }
|
|
if (!email) email = readLocalEmail();
|
|
setUserChipEmail(email);
|
|
}
|
|
|
|
state.userId = resolveUserId();
|
|
try {
|
|
document.cookie = `chat_user=${encodeURIComponent(state.userId)}; path=/; SameSite=Lax`;
|
|
} catch (_) { }
|
|
loadUserChip();
|
|
|
|
function updateFormState() {
|
|
const authNotice = document.getElementById('auth-notice');
|
|
const submitBtn = document.getElementById('fr-submit');
|
|
const titleInput = document.getElementById('fr-title');
|
|
const descInput = document.getElementById('fr-description');
|
|
|
|
if (!state.userId) {
|
|
// Unauthenticated state
|
|
authNotice.style.display = 'block';
|
|
submitBtn.disabled = true;
|
|
titleInput.disabled = true;
|
|
descInput.disabled = true;
|
|
titleInput.placeholder = 'Sign in to submit feature requests';
|
|
descInput.placeholder = 'Sign in to submit feature requests';
|
|
} else {
|
|
// Authenticated state
|
|
authNotice.style.display = 'none';
|
|
submitBtn.disabled = false;
|
|
titleInput.disabled = false;
|
|
descInput.disabled = false;
|
|
titleInput.placeholder = 'Feature title (e.g., \'Add dark mode support\')';
|
|
descInput.placeholder = 'Describe your feature request in detail. What problem would it solve? How would you use it?';
|
|
}
|
|
}
|
|
|
|
function updateNavigation() {
|
|
const navAuthSection = document.getElementById('nav-auth-section');
|
|
|
|
if (!state.userId) {
|
|
// Unauthenticated state - show sign in link
|
|
navAuthSection.innerHTML = '<a href="/login?next=%2Ffeature-requests" class="nav-link" style="background: var(--shopify-green); color: white; padding: 8px 16px; border-radius: 8px; text-decoration: none;">Sign In</a>';
|
|
} else {
|
|
// Authenticated state - show user chip
|
|
navAuthSection.innerHTML = `
|
|
<div class="user-chip" id="fr-user-chip" title="Account & settings">
|
|
<div class="user-chip-avatar" id="fr-user-avatar">${getUserInitials()}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function getUserInitials() {
|
|
try {
|
|
const keys = ['shopify_ai_user', 'wordpress_plugin_ai_user'];
|
|
for (const key of keys) {
|
|
const stored = localStorage.getItem(key);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (parsed && parsed.email) {
|
|
return parsed.email.charAt(0).toUpperCase();
|
|
}
|
|
}
|
|
}
|
|
} catch (_) { }
|
|
return '?';
|
|
}
|
|
|
|
updateFormState();
|
|
updateNavigation();
|
|
|
|
function showToast(message, type = 'info') {
|
|
toast.textContent = message;
|
|
toast.className = 'toast ' + type;
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'X-User-Id': state.userId,
|
|
...(options.headers || {}),
|
|
};
|
|
const res = await fetch(path, { headers, ...options });
|
|
const text = await res.text();
|
|
const json = text ? JSON.parse(text) : {};
|
|
if (!res.ok) {
|
|
throw new Error(json.error || res.statusText);
|
|
}
|
|
return json;
|
|
}
|
|
|
|
function formatDate(timestamp) {
|
|
if (!timestamp) return '';
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
|
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
function renderFeatureRequests() {
|
|
if (state.featureRequests.length === 0) {
|
|
frList.innerHTML = `
|
|
<div class="fr-empty">
|
|
<div class="fr-empty-icon">💡</div>
|
|
<h3>No feature requests yet</h3>
|
|
<p>Be the first to submit a feature request!</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const listHtml = state.featureRequests.map(fr => `
|
|
<div class="fr-card" data-id="${fr.id}">
|
|
<div class="fr-card-header">
|
|
<div class="fr-vote">
|
|
<button class="fr-vote-btn ${fr.hasVoted ? 'voted' : ''}" onclick="upvote('${fr.id}')" ${!state.userId ? 'disabled' : ''} title="${!state.userId ? 'Sign in to vote' : ''}">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="${fr.hasVoted ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="18 15 12 9 6 15"></polyline>
|
|
</svg>
|
|
</button>
|
|
<span class="fr-vote-count">${fr.votes}</span>
|
|
${!state.userId ? '<span style="font-size: 10px; color: #adb5bd; text-align: center;">Sign in to vote</span>' : ''}
|
|
</div>
|
|
<div class="fr-content">
|
|
<h3 class="fr-title">${escapeHtml(fr.title)}</h3>
|
|
<p class="fr-description">${escapeHtml(fr.description)}</p>
|
|
<div class="fr-meta">
|
|
<span>Submitted by ${escapeHtml(fr.authorEmail || 'Anonymous')}</span>
|
|
<span>•</span>
|
|
<span>${formatDate(fr.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
frList.innerHTML = `<div class="fr-list">${listHtml}</div>`;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
async function loadFeatureRequests() {
|
|
try {
|
|
const data = await api('/api/feature-requests');
|
|
state.featureRequests = data.featureRequests || [];
|
|
renderFeatureRequests();
|
|
} catch (error) {
|
|
frList.innerHTML = `
|
|
<div class="fr-empty">
|
|
<div class="fr-empty-icon">⚠️</div>
|
|
<h3>Failed to load</h3>
|
|
<p>${escapeHtml(error.message)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
async function submitFeatureRequest() {
|
|
if (!state.userId) {
|
|
showToast('Please sign in to submit feature requests', 'error');
|
|
return;
|
|
}
|
|
|
|
const title = frTitle.value.trim();
|
|
const description = frDescription.value.trim();
|
|
|
|
if (!title || title.length < 3) {
|
|
showToast('Title must be at least 3 characters', 'error');
|
|
frTitle.focus();
|
|
return;
|
|
}
|
|
|
|
if (!description || description.length < 10) {
|
|
showToast('Description must be at least 10 characters', 'error');
|
|
frDescription.focus();
|
|
return;
|
|
}
|
|
|
|
frSubmit.disabled = true;
|
|
frSubmit.innerHTML = '<span class="spinner" style="width:18px;height:18px;border-width:2px;margin:0;"></span> Submitting...';
|
|
|
|
try {
|
|
const data = await api('/api/feature-requests', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ title, description }),
|
|
});
|
|
|
|
if (data.featureRequest) {
|
|
state.featureRequests.unshift(data.featureRequest);
|
|
renderFeatureRequests();
|
|
frTitle.value = '';
|
|
frDescription.value = '';
|
|
showToast('Feature request submitted!', 'success');
|
|
}
|
|
} catch (error) {
|
|
showToast(error.message || 'Failed to submit', 'error');
|
|
} finally {
|
|
frSubmit.disabled = false;
|
|
frSubmit.innerHTML = `
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
</svg>
|
|
Submit Feature Request
|
|
`;
|
|
}
|
|
}
|
|
|
|
async function upvote(id) {
|
|
if (!state.userId) {
|
|
showToast('Please sign in to vote', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const data = await api(`/api/feature-requests/${id}/upvote`, {
|
|
method: 'POST',
|
|
});
|
|
|
|
const fr = state.featureRequests.find(f => f.id === id);
|
|
if (fr) {
|
|
fr.votes = data.votes;
|
|
fr.hasVoted = data.hasVoted;
|
|
}
|
|
|
|
renderFeatureRequests();
|
|
} catch (error) {
|
|
showToast(error.message || 'Failed to vote', 'error');
|
|
}
|
|
}
|
|
|
|
function sortFeatureRequests() {
|
|
if (state.sortBy === 'votes') {
|
|
state.featureRequests.sort((a, b) => {
|
|
if (b.votes !== a.votes) return b.votes - a.votes;
|
|
return new Date(b.createdAt) - new Date(a.createdAt);
|
|
});
|
|
} else {
|
|
state.featureRequests.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
}
|
|
renderFeatureRequests();
|
|
}
|
|
|
|
// Event listeners
|
|
frSubmit.addEventListener('click', submitFeatureRequest);
|
|
|
|
document.querySelectorAll('.fr-sort-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.fr-sort-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
state.sortBy = btn.dataset.sort;
|
|
sortFeatureRequests();
|
|
});
|
|
});
|
|
|
|
frTitle.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
frDescription.focus();
|
|
}
|
|
});
|
|
|
|
frDescription.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && e.ctrlKey) {
|
|
submitFeatureRequest();
|
|
}
|
|
});
|
|
|
|
// Initial load
|
|
loadFeatureRequests();
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|