Jobs: Bewerbungssystem für Social-Media-Manager/in
Backend:
- job_applications + job_application_docs Tabellen in DB
- luna_trial_until Spalte in users (Migration)
- routes/jobs.py: POST /apply (FormData + Datei-Upload, max 3×10MB),
GET /my-application, GET /luna-trial-status
- Admin: GET/PATCH /admin/applications, GET /admin/applications/{id}/docs/{doc_id}
- Bei Bewerbung: 14-Tage Luna-Probezugang automatisch aktiviert
- Bei Annahme: is_social_media=1 + Gründer-Status gesetzt
- Status-Mails (pending/reviewing/accepted/rejected) via email_html-Template
- auth.py: require_social_media prüft auch luna_trial_until
Frontend:
- pages/jobs.js: Stellenausschreibung + Bewerbungsformular
(Name, E-Mail, Hund, Social-Handle, Motivation, Datei-Upload)
- Luna-Probezugang Teaser mit Countdown wenn aktiv
- Bestehende Bewerbung: Status-Screen statt Formular
- app.js: 'jobs' Seite registriert
- admin.js: neuer Tab 'Bewerbungen' (filtert nach Status,
Statuswechsel per Dropdown, Detailansicht mit Anhang-Download,
Admin-Notiz-Feld)
- admin.js: Tab 'Jobs' → 'Scheduler' umbenannt
This commit is contained in:
parent
59856e61a1
commit
f378edab5d
7 changed files with 738 additions and 4 deletions
|
|
@ -18,7 +18,8 @@ window.Page_admin = (() => {
|
|||
{ id: 'social', label: 'Social Media', icon: 'camera' },
|
||||
{ id: 'analytics', label: 'Analytics', icon: 'target' },
|
||||
{ id: 'system', label: 'System', icon: 'gear' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'clock' },
|
||||
{ id: 'jobs', label: 'Scheduler', icon: 'clock' },
|
||||
{ id: 'bewerbungen', label: 'Bewerbungen', icon: 'user-plus' },
|
||||
{ id: 'partner', label: 'Partner', icon: 'handshake' },
|
||||
{ id: 'outreach', label: 'Outreach', icon: 'envelope-simple' },
|
||||
{ id: 'audit', label: 'Audit-Log', icon: 'clipboard-text' },
|
||||
|
|
@ -93,6 +94,7 @@ window.Page_admin = (() => {
|
|||
case 'partner': await _renderPartner(el); break;
|
||||
case 'outreach': await _renderOutreach(el); break;
|
||||
case 'audit': await _renderAudit(el); break;
|
||||
case 'bewerbungen': await _renderBewerbungen(el); break;
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||
|
|
@ -2375,6 +2377,125 @@ window.Page_admin = (() => {
|
|||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// BEWERBUNGEN — Social-Media-Job
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderBewerbungen(el) {
|
||||
let _statusFilter = 'pending';
|
||||
|
||||
async function _load() {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;gap:var(--space-2);margin-bottom:var(--space-4);flex-wrap:wrap;align-items:center">
|
||||
${['pending','reviewing','accepted','rejected','alle'].map(s => `
|
||||
<button class="btn btn-sm ${s===_statusFilter?'btn-primary':'btn-ghost'} adm-bew-filter" data-s="${s}">
|
||||
${s==='pending'?'⏳ Neu':s==='reviewing'?'🔍 In Prüfung':s==='accepted'?'✅ Angenommen':s==='rejected'?'❌ Abgelehnt':'Alle'}
|
||||
</button>`).join('')}
|
||||
</div>
|
||||
<div id="adm-bew-list">${UI.skeleton(3)}</div>`;
|
||||
|
||||
el.querySelectorAll('.adm-bew-filter').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_statusFilter = btn.dataset.s;
|
||||
_load();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const rows = await API.get(`/jobs/admin/applications?status=${_statusFilter}`);
|
||||
const list = el.querySelector('#adm-bew-list');
|
||||
if (!rows.length) {
|
||||
list.innerHTML = _emptyState('user-plus', 'Keine Bewerbungen', 'Noch keine Bewerbungen in diesem Status.');
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(r => `
|
||||
<div class="card" style="margin-bottom:var(--space-3);padding:var(--space-4)" data-id="${r.id}">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:var(--space-3)">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:700;font-size:var(--text-base)">${_esc(r.name)}
|
||||
${r.username ? `<span style="color:var(--c-text-muted);font-weight:400;font-size:var(--text-sm)">(@${_esc(r.username)})</span>` : ''}
|
||||
</div>
|
||||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm);margin-top:2px">
|
||||
${_esc(r.email)} · @${_esc(r.social_handle||'—')}
|
||||
${r.dog_name ? ` · 🐕 ${_esc(r.dog_name)} (${_esc(r.dog_rasse||'')})` : ''}
|
||||
</div>
|
||||
<div style="color:var(--c-text-muted);font-size:var(--text-xs);margin-top:2px">
|
||||
${r.created_at?.slice(0,16).replace('T',' ')} · ${r.doc_count} Anhang/Anhänge
|
||||
</div>
|
||||
<div style="margin-top:var(--space-2);font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
|
||||
${_esc((r.motivation||'').slice(0,200))}${(r.motivation||'').length>200?'…':''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:var(--space-2);min-width:120px">
|
||||
<button class="btn btn-sm btn-primary adm-bew-view" data-id="${r.id}">Details</button>
|
||||
<select class="form-control adm-bew-status" data-id="${r.id}"
|
||||
style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2)">
|
||||
<option value="pending" ${r.status==='pending' ?'selected':''}>⏳ Neu</option>
|
||||
<option value="reviewing" ${r.status==='reviewing'?'selected':''}>🔍 Prüfung</option>
|
||||
<option value="accepted" ${r.status==='accepted' ?'selected':''}>✅ Angenommen</option>
|
||||
<option value="rejected" ${r.status==='rejected' ?'selected':''}>❌ Abgelehnt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
list.querySelectorAll('.adm-bew-status').forEach(sel => {
|
||||
sel.addEventListener('change', async () => {
|
||||
const id = sel.dataset.id;
|
||||
await API.patch(`/jobs/admin/applications/${id}`, { status: sel.value });
|
||||
UI.toast.success('Status aktualisiert.');
|
||||
setTimeout(_load, 500);
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.adm-bew-view').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.dataset.id;
|
||||
const app = await API.get(`/jobs/admin/applications/${id}`);
|
||||
const docsHtml = app.docs?.length
|
||||
? app.docs.map(d => `<a href="/api/jobs/admin/applications/${id}/docs/${d.id}"
|
||||
target="_blank" style="display:block;color:var(--c-primary);font-size:var(--text-sm);margin:4px 0">
|
||||
📎 ${_esc(d.filename)}</a>`).join('')
|
||||
: '<span style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Anhänge</span>';
|
||||
|
||||
UI.modal.open({
|
||||
title: `Bewerbung — ${_esc(app.name)}`,
|
||||
body: `
|
||||
<div style="display:grid;gap:var(--space-3)">
|
||||
<div><b>E-Mail:</b> ${_esc(app.email)}</div>
|
||||
<div><b>Social:</b> @${_esc(app.social_handle||'—')}</div>
|
||||
${app.dog_name ? `<div><b>Hund:</b> ${_esc(app.dog_name)} (${_esc(app.dog_rasse||'')})</div>` : ''}
|
||||
<div><b>Motivation:</b><br>
|
||||
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-3);
|
||||
margin-top:var(--space-1);font-size:var(--text-sm);white-space:pre-wrap">${_esc(app.motivation)}</div>
|
||||
</div>
|
||||
<div><b>Anhänge:</b><br>${docsHtml}</div>
|
||||
<div>
|
||||
<b>Admin-Notiz:</b>
|
||||
<textarea id="adm-bew-note" class="form-control" rows="2" style="margin-top:var(--space-1)"
|
||||
placeholder="Interne Notiz / Nachricht an Bewerber">${_esc(app.admin_note||'')}</textarea>
|
||||
</div>
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
|
||||
});
|
||||
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {
|
||||
const note = document.getElementById('adm-bew-note')?.value || '';
|
||||
await API.patch(`/jobs/admin/applications/${id}`, { admin_note: note });
|
||||
UI.toast.success('Notiz gespeichert.');
|
||||
UI.modal.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
el.querySelector('#adm-bew-list').innerHTML = _emptyState('warning', 'Fehler', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
await _load();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
|
|
|
|||
260
backend/static/js/pages/jobs.js
Normal file
260
backend/static/js/pages/jobs.js
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Social-Media-Job Bewerbung
|
||||
============================================================ */
|
||||
|
||||
window.Page_jobs = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
|
||||
async function init(container, appState) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
await _render();
|
||||
}
|
||||
|
||||
async function _render() {
|
||||
// Bestehende Bewerbung prüfen (nur wenn eingeloggt)
|
||||
let existingApp = null;
|
||||
let trialStatus = null;
|
||||
if (_appState.user) {
|
||||
try {
|
||||
const r = await API.get('/jobs/my-application');
|
||||
existingApp = r.application;
|
||||
trialStatus = await API.get('/jobs/luna-trial-status');
|
||||
} catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
_container.innerHTML = `
|
||||
<div style="max-width:640px;margin:0 auto;padding:var(--space-4)">
|
||||
|
||||
<!-- Header -->
|
||||
<div style="text-align:center;margin-bottom:var(--space-6)">
|
||||
<div style="font-size:48px;margin-bottom:var(--space-3)">🐾</div>
|
||||
<h1 style="font-size:var(--text-2xl);font-weight:800;margin:0 0 var(--space-2)">
|
||||
Social-Media-Manager/in gesucht
|
||||
</h1>
|
||||
<p style="color:var(--c-text-secondary);margin:0">
|
||||
Werde das Gesicht von Ban Yaro auf Instagram & TikTok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stellenbeschreibung -->
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-5)">
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Die Stelle</h2>
|
||||
<div style="display:grid;gap:var(--space-3)">
|
||||
${_infoRow('📍', 'Remote', '100 % flexibel — du arbeitest wann und wie du willst')}
|
||||
${_infoRow('📅', 'Umfang', '1–2 Posts pro Woche auf Instagram & TikTok')}
|
||||
${_infoRow('💶', 'Vergütung', '50 € / Monat — wächst mit der Community')}
|
||||
${_infoRow('🤖', 'Luna an deiner Seite', 'Unser KI-Assistent schreibt Captions, generiert Post-Ideen und Hashtags — du wählst aus und postest')}
|
||||
${_infoRow('⭐', 'Gründer-Status', 'Du wirst Teil der ersten 100 Gründer — für immer')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Luna-Probezugang Teaser -->
|
||||
<div style="background:linear-gradient(135deg,var(--c-primary),#e8a857);border-radius:var(--radius-lg);
|
||||
padding:var(--space-5);margin-bottom:var(--space-4);color:#fff">
|
||||
<div style="font-size:var(--text-lg);font-weight:800;margin-bottom:var(--space-2)">
|
||||
🤖 Luna 14 Tage kostenlos testen
|
||||
</div>
|
||||
<p style="margin:0;opacity:.9;font-size:var(--text-sm)">
|
||||
Mit deiner Bewerbung schalten wir dir sofort den vollen Zugang zu Luna frei —
|
||||
unserem KI-Assistenten für Social-Media-Posts. Probiere ihn einfach aus,
|
||||
bevor du dich entscheidest.
|
||||
</p>
|
||||
${trialStatus?.active ? `<div style="margin-top:var(--space-3);background:rgba(255,255,255,.2);
|
||||
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);font-weight:700;font-size:var(--text-sm)">
|
||||
✅ Dein Probezugang läuft noch ${trialStatus.remaining_days} Tage</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Wen wir suchen -->
|
||||
<div class="card" style="margin-bottom:var(--space-4)">
|
||||
<div style="padding:var(--space-5)">
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">Wen wir suchen</h2>
|
||||
<ul style="margin:0;padding-left:var(--space-5);display:grid;gap:var(--space-2);color:var(--c-text-secondary);font-size:var(--text-sm)">
|
||||
<li>Du hast einen Hund — und liebst ihn sehr 🐕</li>
|
||||
<li>Du bist auf Instagram oder TikTok zuhause (nicht professionell, aber aktiv)</li>
|
||||
<li>Du schreibst gerne und authentisch auf Deutsch</li>
|
||||
<li>Du hast Lust, eine junge App bekannt zu machen — aus Überzeugung</li>
|
||||
<li>Kein Lebenslauf nötig. Kein Bewerbungs-Anschreiben. Einfach du.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bewerbungsformular oder Status -->
|
||||
${existingApp ? _renderStatus(existingApp) : _renderForm()}
|
||||
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!existingApp) {
|
||||
_bindForm();
|
||||
}
|
||||
}
|
||||
|
||||
function _infoRow(icon, label, text) {
|
||||
return `
|
||||
<div style="display:flex;gap:var(--space-3);align-items:flex-start">
|
||||
<span style="font-size:20px;line-height:1.4">${icon}</span>
|
||||
<div>
|
||||
<div style="font-weight:700;font-size:var(--text-sm)">${label}</div>
|
||||
<div style="color:var(--c-text-secondary);font-size:var(--text-sm)">${text}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderStatus(app) {
|
||||
const statusMap = {
|
||||
pending: { icon: '⏳', text: 'Deine Bewerbung ist eingegangen — wir melden uns bald!', color: 'var(--c-text-secondary)' },
|
||||
reviewing: { icon: '🔍', text: 'Wir schauen uns deine Bewerbung gerade genauer an.', color: '#0284c7' },
|
||||
accepted: { icon: '🎉', text: 'Herzlichen Glückwunsch — du bist dabei!', color: 'var(--c-success)' },
|
||||
rejected: { icon: '😔', text: 'Es hat diesmal leider nicht geklappt.', color: 'var(--c-danger)' },
|
||||
};
|
||||
const s = statusMap[app.status] || statusMap.pending;
|
||||
return `
|
||||
<div class="card" style="padding:var(--space-5);text-align:center">
|
||||
<div style="font-size:40px;margin-bottom:var(--space-3)">${s.icon}</div>
|
||||
<div style="font-weight:700;color:${s.color};font-size:var(--text-lg);margin-bottom:var(--space-2)">${s.text}</div>
|
||||
<div style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
||||
Bewerbung eingereicht: ${app.created_at?.slice(0,10)}
|
||||
</div>
|
||||
${app.admin_note ? `<div style="margin-top:var(--space-3);background:var(--c-surface-2);
|
||||
border-radius:var(--radius-md);padding:var(--space-3);font-size:var(--text-sm);
|
||||
color:var(--c-text-secondary);text-align:left">${UI.esc(app.admin_note)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderForm() {
|
||||
const u = _appState.user;
|
||||
return `
|
||||
<div class="card">
|
||||
<div style="padding:var(--space-5)">
|
||||
<h2 style="font-size:var(--text-lg);font-weight:700;margin:0 0 var(--space-4);color:var(--c-primary)">
|
||||
Jetzt bewerben
|
||||
</h2>
|
||||
<form id="jobs-form" novalidate>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Dein Name *</label>
|
||||
<input class="form-control" type="text" name="name"
|
||||
value="${u ? UI.esc(u.name) : ''}" placeholder="Vorname oder Nickname" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">E-Mail *</label>
|
||||
<input class="form-control" type="email" name="email"
|
||||
value="${u ? UI.esc(u.email || '') : ''}" placeholder="deine@email.de" required>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="form-label">Hunde-Name</label>
|
||||
<input class="form-control" type="text" name="dog_name" placeholder="z. B. Bella">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0">
|
||||
<label class="form-label">Rasse</label>
|
||||
<input class="form-control" type="text" name="dog_rasse" placeholder="z. B. Labrador">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Instagram oder TikTok Handle *</label>
|
||||
<div style="position:relative">
|
||||
<span style="position:absolute;left:var(--space-3);top:50%;transform:translateY(-50%);
|
||||
color:var(--c-text-muted)">@</span>
|
||||
<input class="form-control" type="text" name="social_handle"
|
||||
style="padding-left:var(--space-7)" placeholder="dein_handle" required>
|
||||
</div>
|
||||
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Dein öffentliches Profil auf Instagram oder TikTok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Warum du? *</label>
|
||||
<textarea class="form-control" name="motivation" rows="5"
|
||||
placeholder="Erzähl uns kurz wer du bist, was dich an Ban Yaro begeistert und was du dir von der Stelle vorstellst. Kein formeller Ton nötig — schreib einfach wie du sprichst." required
|
||||
style="resize:vertical"></textarea>
|
||||
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Mindestens 80 Zeichen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Anhänge (optional)</label>
|
||||
<input class="form-control" type="file" name="files" id="jobs-files"
|
||||
multiple accept=".pdf,.jpg,.jpeg,.png,.webp,.mp4,.mov"
|
||||
style="padding:var(--space-2)">
|
||||
<p style="margin:var(--space-1) 0 0;font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Beispiel-Posts, Portfolio, kurzes Video von dir und deinem Hund — max. 3 Dateien, je 10 MB.
|
||||
PDF, Bild oder Video.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${!u ? `<div style="background:var(--c-surface-2);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
margin-bottom:var(--space-4)">
|
||||
💡 <b>Tipp:</b> Wenn du dich vorher
|
||||
<a href="#" id="jobs-login-link" style="color:var(--c-primary)">anmeldest oder registrierst</a>,
|
||||
bekommst du sofort den 14-tägigen Luna-Probezugang.
|
||||
</div>` : ''}
|
||||
|
||||
<button type="submit" class="btn btn-primary w-full" style="margin-top:var(--space-2)">
|
||||
Bewerbung absenden + Luna freischalten 🚀
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _bindForm() {
|
||||
document.getElementById('jobs-login-link')?.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (window.App) App.navigate('settings');
|
||||
});
|
||||
|
||||
document.getElementById('jobs-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const btn = e.target.querySelector('[type="submit"]');
|
||||
const fd = new FormData(e.target);
|
||||
|
||||
// Dateien aus file-input übernehmen
|
||||
const fileInput = document.getElementById('jobs-files');
|
||||
if (fileInput?.files?.length) {
|
||||
fd.delete('files');
|
||||
for (const f of fileInput.files) fd.append('files', f);
|
||||
}
|
||||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const resp = await fetch('/api/jobs/apply', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('by_token') || ''}` },
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || 'Fehler beim Absenden.');
|
||||
}
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.luna_trial) {
|
||||
UI.toast.success('🎉 Bewerbung eingegangen! Dein Luna-Probezugang ist jetzt aktiv.');
|
||||
// User-State aktualisieren damit Luna sofort zugänglich ist
|
||||
if (_appState.user && window.API) {
|
||||
try { _appState.user = await API.auth.me(); } catch { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
UI.toast.success('Bewerbung eingegangen! Wir melden uns bald.');
|
||||
}
|
||||
|
||||
await _render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue