implement user invites
This commit is contained in:
@@ -72,6 +72,14 @@
|
||||
"enable_cleanup": false,
|
||||
"enable_data_export": true,
|
||||
"enable_user_api_keys": true,
|
||||
"enable_user_invites": true,
|
||||
"user_invite_slots": 2,
|
||||
"invite_criteria": {
|
||||
"uploads": 10,
|
||||
"age_days": 10,
|
||||
"comments": 10,
|
||||
"tags": 10
|
||||
},
|
||||
"cleanup_timeframe_days": 30,
|
||||
"web_url_upload": true,
|
||||
"enable_youtube_upload": true,
|
||||
|
||||
@@ -825,12 +825,25 @@ CREATE TABLE public.invite_tokens (
|
||||
used_by integer,
|
||||
is_used boolean DEFAULT false,
|
||||
created_by_discord character varying(255) DEFAULT NULL::character varying,
|
||||
created_by_matrix character varying(255) DEFAULT NULL::character varying
|
||||
created_by_matrix character varying(255) DEFAULT NULL::character varying,
|
||||
used_at timestamp with time zone DEFAULT NULL
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.invite_tokens OWNER TO f0ckm;
|
||||
|
||||
--
|
||||
-- Name: idx_invite_tokens_created_by; Type: INDEX; Schema: public; Owner: f0ckm
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_tokens_created_by ON public.invite_tokens (created_by);
|
||||
|
||||
--
|
||||
-- Name: idx_invite_tokens_used_at; Type: INDEX; Schema: public; Owner: f0ckm
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invite_tokens_used_at ON public.invite_tokens (used_at) WHERE is_used = true;
|
||||
|
||||
--
|
||||
-- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
||||
--
|
||||
|
||||
@@ -724,5 +724,29 @@
|
||||
"left_hand_desc": "Du weißt bescheid.",
|
||||
"replying_to": "Antwort an {user}",
|
||||
"reply": "Antworten"
|
||||
},
|
||||
"invites": {
|
||||
"section_title": "Einladungen",
|
||||
"section_desc": "Lade neue Nutzer ein. Du musst alle unten stehenden Kriterien erfüllen, um Einladungstokens zu generieren.",
|
||||
"eligible": "✓ Du bist berechtigt, Einladungen zu generieren.",
|
||||
"not_eligible": "✗ Du erfüllst noch nicht alle Kriterien.",
|
||||
"slots_used": "{used} / {total} Einladungsslots genutzt",
|
||||
"criteria_uploads": "Uploads",
|
||||
"criteria_age": "Kontoalter",
|
||||
"criteria_comments": "Kommentare",
|
||||
"criteria_tags": "Vergebene Tags",
|
||||
"criteria_days": " Tage",
|
||||
"generate_btn": "Einladung generieren",
|
||||
"generating": "Wird generiert…",
|
||||
"loading": "Wird geladen…",
|
||||
"no_invites": "Noch keine Einladungstokens generiert.",
|
||||
"status_unused": "Ungenutzt",
|
||||
"status_used_by": "Genutzt von {user}",
|
||||
"copy_btn": "Kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"delete_btn": "Löschen",
|
||||
"delete_confirm": "Diesen Einladungstoken löschen?",
|
||||
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
||||
"slot_refreshed": "Slot erneuert"
|
||||
}
|
||||
}
|
||||
@@ -726,5 +726,29 @@
|
||||
"left_hand_desc": "You know why.",
|
||||
"replying_to": "Replying to {user}",
|
||||
"reply": "Reply"
|
||||
},
|
||||
"invites": {
|
||||
"section_title": "Invites",
|
||||
"section_desc": "Invite new users to join. You must meet all criteria below to generate invite tokens.",
|
||||
"eligible": "✓ You are eligible to generate invites.",
|
||||
"not_eligible": "✗ You do not yet meet all criteria.",
|
||||
"slots_used": "{used} / {total} invite slots used",
|
||||
"criteria_uploads": "Uploads",
|
||||
"criteria_age": "Account age",
|
||||
"criteria_comments": "Comments",
|
||||
"criteria_tags": "Tags assigned",
|
||||
"criteria_days": " days",
|
||||
"generate_btn": "Generate invite",
|
||||
"generating": "Generating…",
|
||||
"loading": "Loading…",
|
||||
"no_invites": "No invite tokens generated yet.",
|
||||
"status_unused": "Unused",
|
||||
"status_used_by": "Used by {user}",
|
||||
"copy_btn": "Copy",
|
||||
"copied": "Copied!",
|
||||
"delete_btn": "Delete",
|
||||
"delete_confirm": "Delete this invite token?",
|
||||
"slot_refreshes_on": "slot refreshes on {date}",
|
||||
"slot_refreshed": "slot refreshed"
|
||||
}
|
||||
}
|
||||
@@ -722,5 +722,29 @@
|
||||
"left_hand_desc": "Je weet wel waarom.",
|
||||
"replying_to": "Antwoord aan {user}",
|
||||
"reply": "Antwoorden"
|
||||
},
|
||||
"invites": {
|
||||
"section_title": "Uitnodigingen",
|
||||
"section_desc": "Nodig nieuwe gebruikers uit. Je moet aan alle onderstaande criteria voldoen om uitnodigingstokens te genereren.",
|
||||
"eligible": "✓ Je bent bevoegd om uitnodigingen te genereren.",
|
||||
"not_eligible": "✗ Je voldoet nog niet aan alle criteria.",
|
||||
"slots_used": "{used} / {total} uitnodigingsslots gebruikt",
|
||||
"criteria_uploads": "Uploads",
|
||||
"criteria_age": "Accountleeftijd",
|
||||
"criteria_comments": "Opmerkingen",
|
||||
"criteria_tags": "Toegewezen tags",
|
||||
"criteria_days": " dagen",
|
||||
"generate_btn": "Uitnodiging genereren",
|
||||
"generating": "Genereren…",
|
||||
"loading": "Laden…",
|
||||
"no_invites": "Nog geen uitnodigingstokens gegenereerd.",
|
||||
"status_unused": "Ongebruikt",
|
||||
"status_used_by": "Gebruikt door {user}",
|
||||
"copy_btn": "Kopiëren",
|
||||
"copied": "Gekopieërd!",
|
||||
"delete_btn": "Verwijderen",
|
||||
"delete_confirm": "Dit uitnodigingstoken verwijderen?",
|
||||
"slot_refreshes_on": "slot vernieuwd op {date}",
|
||||
"slot_refreshed": "slot vernieuwd"
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@
|
||||
"password_min_hint": "Muss mindestens 20 Zeichen lang sein.",
|
||||
"confirm_password": "Kennwort bestätigen",
|
||||
"email_placeholder": "E-Post",
|
||||
"invite_token": "Einladungskennzeichen",
|
||||
"invite_token": "Einladungskots",
|
||||
"tos_private": "Ich bin mindestens 18 Jahre alt und stimme der Befolgung des Regelwerks zu",
|
||||
"tos_public": "Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese",
|
||||
"tos_terms": "Nutzungsbedingungen",
|
||||
@@ -727,5 +727,29 @@
|
||||
"left_hand_desc": "Sie wissen schon wieso.",
|
||||
"replying_to": "Antwort an {user}",
|
||||
"reply": "Antworten"
|
||||
},
|
||||
"invites": {
|
||||
"section_title": "Einladungswesen",
|
||||
"section_desc": "Laden Sie neue Nutzer ein, beizutreten. Sie müssen alle nachstehenden Kriterien erfüllen, um Einladungskots zu erzeugen.",
|
||||
"eligible": "✓ Sie sind berechtigt, Einladungen zu erzeugen.",
|
||||
"not_eligible": "✗ Sie erfüllen noch nicht alle Kriterien.",
|
||||
"slots_used": "{used} / {total} Einladungsplätze in Benutzung",
|
||||
"criteria_uploads": "Aufladierungen",
|
||||
"criteria_age": "Kontoalter",
|
||||
"criteria_comments": "Kommentare",
|
||||
"criteria_tags": "Vergebene Etiketten",
|
||||
"criteria_days": " Tage",
|
||||
"generate_btn": "Einladung erzeugen",
|
||||
"generating": "Erzeugung wird durchgeführt…",
|
||||
"loading": "Ladung wird aufbereitet…",
|
||||
"no_invites": "Noch keine Einladungskots erzeugt.",
|
||||
"status_unused": "Ungebraucht",
|
||||
"status_used_by": "Gebraucht von {user}",
|
||||
"copy_btn": "Kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"delete_btn": "Löschen",
|
||||
"delete_confirm": "Diesen Einladungskot löschen?",
|
||||
"slot_refreshes_on": "Platz erneuert sich am {date}",
|
||||
"slot_refreshed": "Platz erneuert"
|
||||
}
|
||||
}
|
||||
@@ -811,6 +811,186 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- User Invite System ---
|
||||
// Eligibility: ≥150 uploads, ≥30 days old, ≥66 comments, ≥200 tags
|
||||
// Slots: configurable (default 2), refresh 30 days after a token is used.
|
||||
|
||||
const getInviteCriteria = () => {
|
||||
const ic = cfg.websrv.invite_criteria || {};
|
||||
return {
|
||||
uploads: Number.isFinite(+ic.uploads) ? +ic.uploads : 150,
|
||||
age_days: Number.isFinite(+ic.age_days) ? +ic.age_days : 30,
|
||||
comments: Number.isFinite(+ic.comments) ? +ic.comments : 66,
|
||||
tags: Number.isFinite(+ic.tags) ? +ic.tags : 200,
|
||||
};
|
||||
};
|
||||
|
||||
const getInviteSlots = () => {
|
||||
const n = parseInt(cfg.websrv.user_invite_slots);
|
||||
return Number.isFinite(n) && n > 0 ? n : 2;
|
||||
};
|
||||
|
||||
// GET /api/v2/settings/invites
|
||||
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
|
||||
group.get(/\/invites$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_invites === false) {
|
||||
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||
}
|
||||
try {
|
||||
const userId = +req.session.id;
|
||||
const username = req.session.user;
|
||||
const totalSlots = getInviteSlots();
|
||||
const refreshDays = 30;
|
||||
|
||||
// Gather eligibility stats in one query
|
||||
const [stats] = await db`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
|
||||
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
|
||||
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
|
||||
FROM "user" u
|
||||
WHERE u.id = ${userId}
|
||||
`;
|
||||
|
||||
const INVITE_CRITERIA = getInviteCriteria();
|
||||
const criteria = {
|
||||
uploads: { current: stats.upload_count, required: INVITE_CRITERIA.uploads, met: stats.upload_count >= INVITE_CRITERIA.uploads },
|
||||
age_days: { current: Math.floor(stats.age_days), required: INVITE_CRITERIA.age_days, met: stats.age_days >= INVITE_CRITERIA.age_days },
|
||||
comments: { current: stats.comment_count, required: INVITE_CRITERIA.comments, met: stats.comment_count >= INVITE_CRITERIA.comments },
|
||||
tags: { current: stats.tag_count, required: INVITE_CRITERIA.tags, met: stats.tag_count >= INVITE_CRITERIA.tags },
|
||||
};
|
||||
|
||||
const eligible = Object.values(criteria).every(c => c.met);
|
||||
|
||||
// Fetch all tokens this user created, join used_by name
|
||||
const tokens = await db`
|
||||
SELECT
|
||||
it.id,
|
||||
it.token,
|
||||
it.is_used,
|
||||
it.used_at,
|
||||
it.created_at,
|
||||
u.user AS used_by_name
|
||||
FROM invite_tokens it
|
||||
LEFT JOIN "user" u ON u.id = it.used_by
|
||||
WHERE it.created_by = ${userId}
|
||||
ORDER BY it.created_at DESC
|
||||
`;
|
||||
|
||||
// Slots consumed = tokens used within the last 30 days
|
||||
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
|
||||
const slotsConsumed = tokens.filter(t => t.is_used && t.used_at && new Date(t.used_at) > cutoff).length;
|
||||
const slotsAvailable = Math.max(0, totalSlots - slotsConsumed);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
eligible,
|
||||
criteria,
|
||||
tokens,
|
||||
slots_total: totalSlots,
|
||||
slots_consumed: slotsConsumed,
|
||||
slots_available: slotsAvailable,
|
||||
refresh_days: refreshDays,
|
||||
}, 200);
|
||||
} catch (e) {
|
||||
console.error('[INVITES] GET error:', e);
|
||||
return res.json({ success: false, msg: 'Error fetching invite data' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v2/settings/invites/create
|
||||
// Generates a new invite token if eligible and slots remain.
|
||||
group.post(/\/invites\/create$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_invites === false) {
|
||||
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||
}
|
||||
try {
|
||||
const userId = +req.session.id;
|
||||
const username = req.session.user;
|
||||
const totalSlots = getInviteSlots();
|
||||
const refreshDays = 30;
|
||||
|
||||
// Eligibility check
|
||||
const [stats] = await db`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
|
||||
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
|
||||
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
|
||||
FROM "user" u WHERE u.id = ${userId}
|
||||
`;
|
||||
|
||||
const INVITE_CRITERIA = getInviteCriteria();
|
||||
const eligible =
|
||||
stats.upload_count >= INVITE_CRITERIA.uploads &&
|
||||
stats.age_days >= INVITE_CRITERIA.age_days &&
|
||||
stats.comment_count >= INVITE_CRITERIA.comments &&
|
||||
stats.tag_count >= INVITE_CRITERIA.tags;
|
||||
|
||||
if (!eligible) {
|
||||
return res.json({ success: false, msg: 'You do not meet the eligibility criteria' }, 403);
|
||||
}
|
||||
|
||||
// Check available slots (used within last 30 days)
|
||||
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
|
||||
const [{ slots_consumed }] = await db`
|
||||
SELECT COUNT(*)::int AS slots_consumed
|
||||
FROM invite_tokens
|
||||
WHERE created_by = ${userId}
|
||||
AND is_used = true
|
||||
AND used_at > ${cutoff}
|
||||
`;
|
||||
|
||||
if (slots_consumed >= totalSlots) {
|
||||
return res.json({ success: false, msg: 'No invite slots available. Slots refresh 30 days after use.' }, 403);
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
|
||||
await db`
|
||||
INSERT INTO invite_tokens (token, created_at, created_by)
|
||||
VALUES (${token}, ${~~(Date.now() / 1e3)}, ${userId})
|
||||
`;
|
||||
|
||||
console.log(`[INVITES] User ${username} (${userId}) generated invite token ${token}`);
|
||||
return res.json({ success: true, token }, 200);
|
||||
} catch (e) {
|
||||
console.error('[INVITES] Create error:', e);
|
||||
return res.json({ success: false, msg: 'Error creating invite token' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v2/settings/invites/delete
|
||||
// Deletes an unused invite token owned by the calling user.
|
||||
group.post(/\/invites\/delete$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_invites === false) {
|
||||
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||
}
|
||||
try {
|
||||
const { id } = req.post;
|
||||
if (!id) return res.json({ success: false, msg: 'Missing token ID' }, 400);
|
||||
|
||||
const result = await db`
|
||||
DELETE FROM invite_tokens
|
||||
WHERE id = ${+id}
|
||||
AND created_by = ${+req.session.id}
|
||||
AND is_used = false
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.json({ success: false, msg: 'Token not found or already used' }, 404);
|
||||
}
|
||||
|
||||
return res.json({ success: true }, 200);
|
||||
} catch (e) {
|
||||
console.error('[INVITES] Delete error:', e);
|
||||
return res.json({ success: false, msg: 'Error deleting invite token' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ export default (router, tpl) => {
|
||||
if (tokenRow.length > 0) {
|
||||
await db`
|
||||
update invite_tokens
|
||||
set is_used = true, used_by = ${userId}
|
||||
set is_used = true, used_by = ${userId}, used_at = now()
|
||||
where id = ${tokenRow[0].id}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export default (router, tpl) => {
|
||||
enable_swf: cfg.enable_swf,
|
||||
enable_data_export: cfg.websrv.enable_data_export,
|
||||
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
|
||||
enable_user_invites: cfg.websrv.enable_user_invites !== false,
|
||||
site_domain: cfg.main.url.domain,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<th>Source</th>
|
||||
<th>Used By</th>
|
||||
<th>Created</th>
|
||||
<th>Used At</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -43,11 +44,12 @@
|
||||
'</td>' +
|
||||
'<td data-label="Source">' +
|
||||
(t.created_by_matrix ? '<span style="color: #0DBD8B">[Matrix] ' + t.created_by_matrix + '</span>' :
|
||||
(t.created_by_discord ? '<span style="color: #5865F2"><i class="fab fa-discord"></i> ' + t.created_by_discord + '</span>' :
|
||||
(t.created_by_name ? 'Web/Admin (' + t.created_by_name + ')' : 'Web/Admin'))) +
|
||||
(t.created_by_discord ? '<span style="color: #5865F2"><i class="fab fa-discord"></i> ' + t.created_by_discord + '</span>' :
|
||||
(t.created_by_name ? '<span style="color: var(--accent)">👤 User: ' + t.created_by_name + '</span>' : '<span style="color: var(--text-muted)">Admin</span>'))) +
|
||||
'</td>' +
|
||||
'<td data-label="Used By">' + (t.used_by_name || '-') + '</td>' +
|
||||
'<td data-label="Created">' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '-') + '</td>' +
|
||||
'<td data-label="Used By">' + (t.used_by_name || '—') + '</td>' +
|
||||
'<td data-label="Created">' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '—') + '</td>' +
|
||||
'<td data-label="Used At">' + (t.used_at ? new Date(t.used_at).toLocaleString() : '—') + '</td>' +
|
||||
'<td data-label="Actions">' +
|
||||
(!t.is_used ? '<button onclick="deleteToken(' + t.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em;">Delete</button>' : '') +
|
||||
'</td>' +
|
||||
|
||||
@@ -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