implement user invites

This commit is contained in:
2026-05-23 19:14:13 +02:00
parent dd6cda2b44
commit bf92d53620
11 changed files with 569 additions and 7 deletions

View File

@@ -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; }