implement user invites
This commit is contained in:
@@ -474,6 +474,268 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(enable_user_invites)
|
||||
<h2>{{ t('invites.section_title') }}</h2>
|
||||
<div id="invite-section" class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
|
||||
<p style="color: var(--text-muted); margin-bottom: 15px;">{{ t('invites.section_desc') }}</p>
|
||||
|
||||
<!-- Criteria grid -->
|
||||
<div id="invite-criteria-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 18px;">
|
||||
<div class="invite-criterion" id="ic-uploads">
|
||||
<span class="ic-icon">⋯</span>
|
||||
<span class="ic-label">{{ t('invites.criteria_uploads') }}</span>
|
||||
<span class="ic-values"></span>
|
||||
</div>
|
||||
<div class="invite-criterion" id="ic-age">
|
||||
<span class="ic-icon">⋯</span>
|
||||
<span class="ic-label">{{ t('invites.criteria_age') }}</span>
|
||||
<span class="ic-values"></span>
|
||||
</div>
|
||||
<div class="invite-criterion" id="ic-comments">
|
||||
<span class="ic-icon">⋯</span>
|
||||
<span class="ic-label">{{ t('invites.criteria_comments') }}</span>
|
||||
<span class="ic-values"></span>
|
||||
</div>
|
||||
<div class="invite-criterion" id="ic-tags">
|
||||
<span class="ic-icon">⋯</span>
|
||||
<span class="ic-label">{{ t('invites.criteria_tags') }}</span>
|
||||
<span class="ic-values"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Eligibility badge + slot indicator -->
|
||||
<div id="invite-status-line" style="margin-bottom: 16px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap;">
|
||||
<span id="invite-eligible-badge" style="font-weight: bold; font-size: 0.95em;"></span>
|
||||
<span id="invite-slots-info" style="color: var(--text-muted); font-size: 0.9em;"></span>
|
||||
</div>
|
||||
|
||||
<!-- Token list -->
|
||||
<div id="invite-token-list" style="margin-bottom: 16px; display: flex; flex-direction: column; gap: 8px;">
|
||||
<span class="text-muted" style="font-size: 0.9em;">{{ t('invites.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Generate button + status -->
|
||||
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
|
||||
<button type="button" id="btn-gen-invite" class="button button-primary" disabled>{{ t('invites.generate_btn') }}</button>
|
||||
</div>
|
||||
<div id="invite-action-status" class="avatar-status" style="margin-top: 10px;"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.invite-criterion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--nav-border-color);
|
||||
background: rgba(255,255,255,0.03);
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.invite-criterion.met {
|
||||
border-color: rgba(80, 200, 120, 0.5);
|
||||
background: rgba(80, 200, 120, 0.07);
|
||||
}
|
||||
.invite-criterion.unmet {
|
||||
border-color: rgba(255, 90, 90, 0.35);
|
||||
background: rgba(255, 90, 90, 0.05);
|
||||
}
|
||||
.ic-icon { font-size: 1.2em; }
|
||||
.ic-label { font-weight: bold; color: var(--text-muted); }
|
||||
.ic-values { font-family: monospace; font-size: 1.05em; }
|
||||
.invite-token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--nav-border-color);
|
||||
background: rgba(255,255,255,0.02);
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.invite-token-code {
|
||||
font-family: monospace;
|
||||
font-size: 1.05em;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
user-select: all;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
.invite-token-status-used { color: #ff6b6b; }
|
||||
.invite-token-status-unused { color: #51cf66; }
|
||||
.invite-refresh-note { font-size: 0.82em; color: var(--text-muted); }
|
||||
</style>
|
||||
|
||||
<!-- i18n strings for invite JS (rendered server-side) -->
|
||||
<div id="invite-i18n" style="display:none"
|
||||
data-eligible="{{ t('invites.eligible') }}"
|
||||
data-not-eligible="{{ t('invites.not_eligible') }}"
|
||||
data-slots-used="{{ t('invites.slots_used') }}"
|
||||
data-days="{{ t('invites.criteria_days') }}"
|
||||
data-no-invites="{{ t('invites.no_invites') }}"
|
||||
data-status-unused="{{ t('invites.status_unused') }}"
|
||||
data-status-used-by="{{ t('invites.status_used_by') }}"
|
||||
data-delete-btn="{{ t('invites.delete_btn') }}"
|
||||
data-delete-confirm="{{ t('invites.delete_confirm') }}"
|
||||
data-slot-refreshes-on="{{ t('invites.slot_refreshes_on') }}"
|
||||
data-slot-refreshed="{{ t('invites.slot_refreshed') }}"
|
||||
data-generating="{{ t('invites.generating') }}"
|
||||
></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Read all translated strings from server-rendered data attributes.
|
||||
// Attributes must be lowercase kebab-case: data-foo-bar → dataset.fooBar
|
||||
const ds = document.getElementById('invite-i18n')?.dataset || {};
|
||||
const T = (key, vars) => {
|
||||
let str = ds[key] || key;
|
||||
if (vars) Object.entries(vars).forEach(([k, v]) => { str = str.replaceAll('{' + k + '}', String(v)); });
|
||||
return str;
|
||||
};
|
||||
|
||||
const statusEl = document.getElementById('invite-action-status');
|
||||
const genBtn = document.getElementById('btn-gen-invite');
|
||||
const tokenList = document.getElementById('invite-token-list');
|
||||
|
||||
const setStatus = (msg, ok) => {
|
||||
statusEl.textContent = msg;
|
||||
statusEl.style.color = ok ? '#51cf66' : '#ff6b6b';
|
||||
};
|
||||
|
||||
const formatDate = ts => {
|
||||
if (!ts) return '—';
|
||||
const d = (typeof ts === 'number' || /^\d+$/.test(String(ts)))
|
||||
? new Date(parseInt(ts) * 1000)
|
||||
: new Date(ts);
|
||||
return d.toLocaleDateString();
|
||||
};
|
||||
|
||||
const loadInvites = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/invites');
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
tokenList.innerHTML = '<span style="color:#ff6b6b">' + (data.msg || 'Error') + '</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update criteria grid
|
||||
const criteriaMap = {
|
||||
'ic-uploads': data.criteria.uploads,
|
||||
'ic-age': data.criteria.age_days,
|
||||
'ic-comments': data.criteria.comments,
|
||||
'ic-tags': data.criteria.tags,
|
||||
};
|
||||
Object.entries(criteriaMap).forEach(([id, c]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.toggle('met', c.met);
|
||||
el.classList.toggle('unmet', !c.met);
|
||||
el.querySelector('.ic-icon').textContent = c.met ? '✓' : '✗';
|
||||
const unit = id === 'ic-age' ? ' ' + T('days') : '';
|
||||
el.querySelector('.ic-values').textContent = c.current + ' / ' + c.required + unit;
|
||||
});
|
||||
|
||||
// Eligibility badge
|
||||
const badge = document.getElementById('invite-eligible-badge');
|
||||
badge.textContent = data.eligible ? T('eligible') : T('notEligible');
|
||||
badge.style.color = data.eligible ? '#51cf66' : '#ff6b6b';
|
||||
|
||||
// Slots info
|
||||
document.getElementById('invite-slots-info').textContent =
|
||||
T('slotsUsed', { used: data.slots_consumed, total: data.slots_total });
|
||||
|
||||
// Generate button state
|
||||
genBtn.disabled = !data.eligible || data.slots_available <= 0;
|
||||
|
||||
// Token list
|
||||
if (!data.tokens || data.tokens.length === 0) {
|
||||
tokenList.innerHTML = '<span class="text-muted" style="font-size:0.9em;">' + T('noInvites') + '</span>';
|
||||
} else {
|
||||
tokenList.innerHTML = data.tokens.map(tok => {
|
||||
const isUsed = tok.is_used;
|
||||
const usedAt = tok.used_at ? formatDate(tok.used_at) : null;
|
||||
let refreshNote = '';
|
||||
if (isUsed && tok.used_at) {
|
||||
const usedAtMs = (typeof tok.used_at === 'number' || /^\d+$/.test(String(tok.used_at)))
|
||||
? parseInt(tok.used_at) * 1000
|
||||
: new Date(tok.used_at).getTime();
|
||||
const refreshDate = new Date(usedAtMs + 30 * 24 * 60 * 60 * 1000);
|
||||
refreshNote = '<span class="invite-refresh-note"> — ' +
|
||||
(refreshDate > new Date()
|
||||
? T('slotRefreshesOn', { date: refreshDate.toLocaleDateString() })
|
||||
: T('slotRefreshed')) +
|
||||
'</span>';
|
||||
}
|
||||
const userLink = tok.used_by_name
|
||||
? '<a href="/user/' + encodeURIComponent(tok.used_by_name.toLowerCase()) + '" style="color:inherit;text-decoration:underline;">' + tok.used_by_name + '</a>'
|
||||
: '?';
|
||||
const statusHtml = isUsed
|
||||
? '<span class="invite-token-status-used">' + T('statusUsedBy', { user: userLink }) + ' (' + (usedAt || '?') + ')</span>' + refreshNote
|
||||
: '<span class="invite-token-status-unused">' + T('statusUnused') + '</span>';
|
||||
const deleteBtn = !isUsed
|
||||
? '<button class="button button-danger" style="padding:3px 10px;font-size:0.8em;" data-invite-delete="' + tok.id + '">' + T('deleteBtn') + '</button>'
|
||||
: '';
|
||||
return '<div class="invite-token-row">' +
|
||||
'<span class="invite-token-code">' + tok.token + '</span>' +
|
||||
statusHtml + deleteBtn +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
tokenList.querySelectorAll('[data-invite-delete]').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(T('deleteConfirm'))) return;
|
||||
const res = await fetch('/api/v2/settings/invites/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
|
||||
body: JSON.stringify({ id: +btn.dataset.inviteDelete })
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.success) { loadInvites(); } else { setStatus(d.msg || 'Error', false); }
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Invites] load error:', e);
|
||||
tokenList.innerHTML = '<span style="color:#ff6b6b">Failed to load</span>';
|
||||
}
|
||||
};
|
||||
|
||||
genBtn.addEventListener('click', async () => {
|
||||
const origLabel = genBtn.textContent;
|
||||
genBtn.disabled = true;
|
||||
genBtn.textContent = T('generating');
|
||||
statusEl.textContent = '';
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/invites/create', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setStatus('✓', true);
|
||||
loadInvites();
|
||||
} else {
|
||||
setStatus(data.msg || 'Error', false);
|
||||
genBtn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus('Network error', false);
|
||||
genBtn.disabled = false;
|
||||
} finally {
|
||||
genBtn.textContent = origLabel;
|
||||
}
|
||||
});
|
||||
|
||||
loadInvites();
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
|
||||
<style>
|
||||
@keyframes exportDotBounce {
|
||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.3; }
|
||||
|
||||
Reference in New Issue
Block a user