Changeset 0.22.5 (#147)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -39,6 +39,12 @@ const API = {
|
||||
refreshToken: this.refreshToken,
|
||||
user: this.user
|
||||
}));
|
||||
// Sync to cookie for Go template page auth (v0.22.5)
|
||||
if (this.accessToken) {
|
||||
document.cookie = `sb_token=${this.accessToken}; path=/; max-age=900; SameSite=Strict`;
|
||||
} else {
|
||||
document.cookie = 'sb_token=; path=/; max-age=0';
|
||||
}
|
||||
},
|
||||
|
||||
clearTokens() {
|
||||
@@ -46,6 +52,7 @@ const API = {
|
||||
this.refreshToken = null;
|
||||
this.user = null;
|
||||
localStorage.removeItem(_storageKey);
|
||||
document.cookie = 'sb_token=; path=/; max-age=0'; // clear page auth cookie
|
||||
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
|
||||
},
|
||||
|
||||
@@ -255,6 +262,32 @@ const API = {
|
||||
mkdirWorkspace(wsId, path) {
|
||||
return this._post(`/api/v1/workspaces/${wsId}/files/mkdir`, { path });
|
||||
},
|
||||
|
||||
// Upload a File object to a workspace (binary-safe, v0.23.0)
|
||||
async uploadWorkspaceFile(wsId, file, destPath) {
|
||||
const path = destPath || file.name;
|
||||
const url = BASE + `/api/v1/workspaces/${wsId}/files/write?path=${encodeURIComponent(path)}`;
|
||||
const doFetch = () => fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
'Content-Length': file.size.toString(),
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
let resp = await doFetch();
|
||||
if (resp.status === 401 && this.refreshToken) {
|
||||
await this._refreshAccessToken();
|
||||
resp = await doFetch();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
throw new Error(data.error || `HTTP ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
},
|
||||
|
||||
getWorkspaceGitStatus(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/status`); },
|
||||
getWorkspaceGitBranches(wsId) { return this._get(`/api/v1/workspaces/${wsId}/git/branches`); },
|
||||
listWorkspaces() { return this._get('/api/v1/workspaces'); },
|
||||
|
||||
@@ -175,6 +175,43 @@ const EditorMode = {
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
/**
|
||||
* Mount into server-rendered editor surface template containers.
|
||||
* Called by the <script> in editor.html when __SURFACE__ === 'editor'.
|
||||
* Bypasses Surfaces registry — the Go template owns the layout.
|
||||
*/
|
||||
mountServerRendered(wsId, wsName) {
|
||||
this._wsId = wsId;
|
||||
this._wsName = wsName || 'Workspace';
|
||||
|
||||
if (!this._built) this._build();
|
||||
|
||||
const headerMount = document.getElementById('editorHeaderMount');
|
||||
const mainMount = document.getElementById('editorMainMount');
|
||||
|
||||
if (headerMount && this._els.header) {
|
||||
headerMount.appendChild(this._els.header);
|
||||
}
|
||||
if (mainMount && this._els.main) {
|
||||
mainMount.appendChild(this._els.main);
|
||||
}
|
||||
|
||||
this._active = true;
|
||||
this._registered = true;
|
||||
|
||||
// Populate file tree and git info
|
||||
this._refreshFileTree();
|
||||
this._refreshGitBranch();
|
||||
|
||||
// Focus active editor if any
|
||||
if (this._activeFile) {
|
||||
const f = this._openFiles.get(this._activeFile);
|
||||
if (f?.editor?.focus) setTimeout(() => f.editor.focus(), 50);
|
||||
}
|
||||
|
||||
console.log(`[EditorMode] Mounted server-rendered for workspace ${wsId}`);
|
||||
},
|
||||
|
||||
_unregister() {
|
||||
if (!this._registered) return;
|
||||
if (this._active) {
|
||||
@@ -325,6 +362,11 @@ const EditorMode = {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
|
||||
<span class="editor-hdr-label">New</span>
|
||||
</button>
|
||||
<button class="editor-hdr-btn" id="editorUploadBtn" title="Upload files">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<span class="editor-hdr-label">Upload</span>
|
||||
</button>
|
||||
<input type="file" id="editorFileInput" multiple style="display:none">
|
||||
<div class="editor-export-wrap">
|
||||
<button class="editor-hdr-btn" id="editorExportBtn" title="Export active file">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
@@ -350,6 +392,15 @@ const EditorMode = {
|
||||
// New file
|
||||
el.querySelector('#editorNewFileBtn').addEventListener('click', () => this._createNewFile());
|
||||
|
||||
// Upload files (v0.23.0 — fixes bug #3)
|
||||
const uploadBtn = el.querySelector('#editorUploadBtn');
|
||||
const fileInput = el.querySelector('#editorFileInput');
|
||||
uploadBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length) this._uploadFiles(fileInput.files);
|
||||
fileInput.value = ''; // reset so same file can be re-selected
|
||||
});
|
||||
|
||||
// Export dropdown
|
||||
const exportBtn = el.querySelector('#editorExportBtn');
|
||||
const exportMenu = el.querySelector('#editorExportMenu');
|
||||
@@ -395,6 +446,25 @@ const EditorMode = {
|
||||
treeContent.innerHTML = '<div class="editor-tree-loading">Loading files…</div>';
|
||||
el.appendChild(treeContent);
|
||||
|
||||
// Drag-and-drop upload (v0.23.0 — fixes bug #3)
|
||||
el.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
el.classList.add('drag-over');
|
||||
});
|
||||
el.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
el.classList.remove('drag-over');
|
||||
});
|
||||
el.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
el.classList.remove('drag-over');
|
||||
if (e.dataTransfer?.files?.length) {
|
||||
this._uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
});
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
@@ -982,6 +1052,46 @@ const EditorMode = {
|
||||
}
|
||||
},
|
||||
|
||||
// ── File Upload (v0.23.0 — fixes bug #3) ─────
|
||||
|
||||
async _uploadFiles(fileList) {
|
||||
if (!this._wsId) return;
|
||||
const files = Array.from(fileList);
|
||||
const toast = typeof UI !== 'undefined' ? UI.toast.bind(UI) : console.log;
|
||||
|
||||
let ok = 0, fail = 0;
|
||||
for (const file of files) {
|
||||
try {
|
||||
await API.uploadWorkspaceFile(this._wsId, file);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
fail++;
|
||||
console.error(`[EditorMode] Upload failed for ${file.name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (ok > 0) {
|
||||
toast(`Uploaded ${ok} file${ok > 1 ? 's' : ''}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success');
|
||||
await this._refreshFileTree();
|
||||
// Open the last uploaded file if it's a text file
|
||||
if (files.length === 1) {
|
||||
const f = files[0];
|
||||
const textTypes = ['text/', 'application/json', 'application/javascript', 'application/xml',
|
||||
'application/yaml', 'application/toml', 'application/x-sh'];
|
||||
const textExts = ['.md', '.txt', '.js', '.ts', '.go', '.py', '.rs', '.c', '.h', '.css',
|
||||
'.html', '.json', '.yaml', '.yml', '.toml', '.sh', '.sql', '.csv', '.xml',
|
||||
'.jsx', '.tsx', '.vue', '.svelte', '.rb', '.java', '.kt', '.swift', '.lua',
|
||||
'.pl', '.php', '.r', '.m', '.env', '.gitignore', '.dockerfile', '.tf', '.hcl'];
|
||||
const isText = textTypes.some(t => f.type.startsWith(t)) ||
|
||||
textExts.some(ext => f.name.toLowerCase().endsWith(ext)) ||
|
||||
f.type === '';
|
||||
if (isText) this._openFile(f.name);
|
||||
}
|
||||
} else if (fail > 0) {
|
||||
toast(`Upload failed for ${fail} file${fail > 1 ? 's' : ''}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Export ───────────────────────────────
|
||||
|
||||
async _exportActiveFile(format) {
|
||||
|
||||
303
src/js/pages.js
Normal file
303
src/js/pages.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard – Pages (v0.22.5)
|
||||
// ==========================================
|
||||
// Client-side handlers for Go template-rendered pages.
|
||||
// Works with server-rendered HTML — no DOM construction,
|
||||
// just event handling and API calls.
|
||||
//
|
||||
// The server pre-populates all dropdowns. This JS handles:
|
||||
// - Cascading model select (provider change → filter models)
|
||||
// - Admin save operations (roles, routing, providers, teams, users, settings)
|
||||
// - Table filtering
|
||||
// ==========================================
|
||||
|
||||
const Pages = {
|
||||
|
||||
// ── Model Select Cascading ───────────────
|
||||
|
||||
roleProviderChanged(selectEl) {
|
||||
const providerID = selectEl.value;
|
||||
const filterType = selectEl.dataset.filterType || '';
|
||||
const row = selectEl.closest('.form-row') || selectEl.parentElement;
|
||||
const modelSelect = row.querySelector('.model-model-select');
|
||||
if (!modelSelect) return;
|
||||
|
||||
const models = _pageData('Models') || [];
|
||||
const filtered = models.filter(m =>
|
||||
m.provider_config_id === providerID &&
|
||||
(!filterType || m.model_type === filterType)
|
||||
);
|
||||
modelSelect.innerHTML = '<option value="">— select model —</option>' +
|
||||
filtered.map(m =>
|
||||
`<option value="${m.model_id}">${_esc(m.display_name || m.model_id)}</option>`
|
||||
).join('');
|
||||
},
|
||||
|
||||
// ── Role Save ────────────────────────────
|
||||
|
||||
async saveRole(roleName) {
|
||||
const container = document.querySelector(`.role-config[data-role="${roleName}"]`);
|
||||
if (!container) return;
|
||||
const getValue = (slot, cls) => {
|
||||
const sel = container.querySelector(`[data-slot="${slot}"].${cls}`);
|
||||
return sel ? sel.value : '';
|
||||
};
|
||||
const body = {
|
||||
role: roleName,
|
||||
primary: getValue('primary','model-provider-select') && getValue('primary','model-model-select')
|
||||
? { provider_config_id: getValue('primary','model-provider-select'), model_id: getValue('primary','model-model-select') }
|
||||
: null,
|
||||
fallback: getValue('fallback','model-provider-select') && getValue('fallback','model-model-select')
|
||||
? { provider_config_id: getValue('fallback','model-provider-select'), model_id: getValue('fallback','model-model-select') }
|
||||
: null,
|
||||
};
|
||||
const resp = await _api('PUT', '/api/v1/admin/roles/' + roleName, body);
|
||||
resp ? _toast('Role saved', 'success') : null;
|
||||
},
|
||||
|
||||
// ── Routing Policy ───────────────────────
|
||||
|
||||
showRoutingForm() { _show('routingPolicyForm'); },
|
||||
hideRoutingForm() { _hide('routingPolicyForm'); },
|
||||
routingScopeChanged(sel) {
|
||||
const row = document.getElementById('routingTeamRow');
|
||||
if (row) row.style.display = sel.value === 'team' ? '' : 'none';
|
||||
},
|
||||
|
||||
async saveRoutingPolicy() {
|
||||
const id = _val('routingPolicyId');
|
||||
const body = {
|
||||
name: _val('routingPolicyName'),
|
||||
priority: parseInt(_val('routingPolicyPriority') || '100'),
|
||||
type: _val('routingPolicyType'),
|
||||
scope: _val('routingPolicyScope'),
|
||||
team_id: _val('routingPolicyScope') === 'team' ? _val('routingPolicyTeamId') : '',
|
||||
config: _parseJSON(_val('routingPolicyConfig')),
|
||||
active: document.getElementById('routingPolicyActive')?.checked ?? true,
|
||||
};
|
||||
if (body.config === null) return; // parse error
|
||||
const ok = await _api(id ? 'PUT' : 'POST',
|
||||
id ? '/api/v1/admin/routing/policies/' + id : '/api/v1/admin/routing/policies', body);
|
||||
if (ok) { this.hideRoutingForm(); window.location.reload(); }
|
||||
},
|
||||
|
||||
// ── Providers ────────────────────────────
|
||||
|
||||
showProviderForm() { _show('providerFormWrap'); _val('providerFormId', ''); _val('providerFormTitle', 'Add Provider'); },
|
||||
hideProviderForm() { _hide('providerFormWrap'); },
|
||||
|
||||
editProvider(id) {
|
||||
const details = (_pageData('ProviderDetails') || []).find(p => p.id === id);
|
||||
if (!details) return;
|
||||
_val('providerFormId', id);
|
||||
_val('providerFormName', details.name);
|
||||
_val('providerFormEndpoint', details.endpoint);
|
||||
document.getElementById('providerFormTitle').textContent = 'Edit Provider';
|
||||
_show('providerFormWrap');
|
||||
},
|
||||
|
||||
async saveProvider() {
|
||||
const id = _val('providerFormId');
|
||||
const body = {
|
||||
name: _val('providerFormName'),
|
||||
provider: _val('providerFormType'),
|
||||
endpoint: _val('providerFormEndpoint'),
|
||||
scope: _val('providerFormScope'),
|
||||
};
|
||||
const key = _val('providerFormKey');
|
||||
if (key) body.api_key = key;
|
||||
const teamId = _val('providerFormTeamId');
|
||||
if (body.scope === 'team' && teamId) body.team_id = teamId;
|
||||
|
||||
const ok = await _api(id ? 'PUT' : 'POST',
|
||||
id ? '/api/v1/admin/providers/' + id : '/api/v1/admin/providers', body);
|
||||
if (ok) { this.hideProviderForm(); window.location.reload(); }
|
||||
},
|
||||
|
||||
async syncProvider(id) {
|
||||
_toast('Syncing…', 'info');
|
||||
const ok = await _api('POST', '/api/v1/admin/providers/' + id + '/sync', {});
|
||||
if (ok) { _toast('Sync complete', 'success'); window.location.reload(); }
|
||||
},
|
||||
|
||||
// ── Model Filtering ──────────────────────
|
||||
|
||||
filterModels(query) {
|
||||
const q = (query || _val('modelSearch') || '').toLowerCase();
|
||||
const typeF = _val('modelTypeFilter') || '';
|
||||
const provF = _val('modelProviderFilter') || '';
|
||||
document.querySelectorAll('#modelTable tbody tr').forEach(tr => {
|
||||
const name = (tr.dataset.name || '').toLowerCase();
|
||||
const type = tr.dataset.type || '';
|
||||
const prov = tr.dataset.provider || '';
|
||||
const show = (!q || name.includes(q)) && (!typeF || type === typeF) && (!provF || prov === provF);
|
||||
tr.style.display = show ? '' : 'none';
|
||||
});
|
||||
},
|
||||
|
||||
// ── Teams ────────────────────────────────
|
||||
|
||||
showTeamForm() { _show('teamFormWrap'); _val('teamFormId', ''); _val('teamFormName', ''); _val('teamFormDescription', ''); },
|
||||
hideTeamForm() { _hide('teamFormWrap'); },
|
||||
|
||||
editTeam(id, name, desc) {
|
||||
_val('teamFormId', id);
|
||||
_val('teamFormName', name);
|
||||
_val('teamFormDescription', desc);
|
||||
document.getElementById('teamFormTitle').textContent = 'Edit Team';
|
||||
_show('teamFormWrap');
|
||||
},
|
||||
|
||||
async saveTeam() {
|
||||
const id = _val('teamFormId');
|
||||
const body = { name: _val('teamFormName'), description: _val('teamFormDescription') };
|
||||
const ok = await _api(id ? 'PUT' : 'POST',
|
||||
id ? '/api/v1/admin/teams/' + id : '/api/v1/admin/teams', body);
|
||||
if (ok) { this.hideTeamForm(); window.location.reload(); }
|
||||
},
|
||||
|
||||
// ── Users ────────────────────────────────
|
||||
|
||||
filterUsers(query) {
|
||||
const q = (query || '').toLowerCase();
|
||||
document.querySelectorAll('#userTable tbody tr').forEach(tr => {
|
||||
tr.style.display = (!q || (tr.dataset.name || '').toLowerCase().includes(q)) ? '' : 'none';
|
||||
});
|
||||
},
|
||||
|
||||
async editUserRole(id, username, currentRole) {
|
||||
const role = prompt(`Role for ${username}:`, currentRole);
|
||||
if (!role || role === currentRole) return;
|
||||
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { role });
|
||||
if (ok) window.location.reload();
|
||||
},
|
||||
|
||||
async toggleUser(id, active) {
|
||||
const ok = await _api('PUT', '/api/v1/admin/users/' + id, { is_active: active });
|
||||
if (ok) window.location.reload();
|
||||
},
|
||||
|
||||
// ── Settings ─────────────────────────────
|
||||
|
||||
async saveSettings() {
|
||||
// Policies
|
||||
const policies = {
|
||||
allow_registration: document.getElementById('settRegEnabled')?.checked ? 'true' : 'false',
|
||||
default_user_active: _val('settRegDefaultState') === 'active' ? 'true' : 'false',
|
||||
allow_user_byok: document.getElementById('settUserBYOK')?.checked ? 'true' : 'false',
|
||||
allow_user_personas: document.getElementById('settUserPersonas')?.checked ? 'true' : 'false',
|
||||
kb_direct_access: document.getElementById('settKBDirect')?.checked ? 'true' : 'false',
|
||||
};
|
||||
|
||||
// Banner
|
||||
const banner = {
|
||||
enabled: document.getElementById('settBannerEnabled')?.checked || false,
|
||||
text: _val('settBannerText'),
|
||||
bg: _val('settBannerBg'),
|
||||
fg: _val('settBannerFg'),
|
||||
position: 'both',
|
||||
};
|
||||
|
||||
// System prompt
|
||||
const system_prompt = { content: _val('settSystemPrompt') };
|
||||
|
||||
const ok = await _api('PUT', '/api/v1/admin/settings', { policies, settings: { banner, system_prompt } });
|
||||
if (ok) _toast('Settings saved', 'success');
|
||||
},
|
||||
|
||||
// ── User Settings (settings surface) ─────
|
||||
|
||||
async saveProfile() {
|
||||
const name = _val('settingsDisplayName');
|
||||
if (!name) { _toast('Display name is required', 'error'); return; }
|
||||
const ok = await _api('PUT', '/api/v1/users/me', { display_name: name });
|
||||
if (ok) _toast('Profile saved', 'success');
|
||||
},
|
||||
|
||||
async changePassword() {
|
||||
const current = _val('settingsCurrentPw');
|
||||
const newPw = _val('settingsNewPw');
|
||||
const confirm = _val('settingsConfirmPw');
|
||||
if (!current || !newPw) { _toast('All password fields are required', 'error'); return; }
|
||||
if (newPw !== confirm) { _toast('Passwords do not match', 'error'); return; }
|
||||
if (newPw.length < 8) { _toast('Password must be at least 8 characters', 'error'); return; }
|
||||
const ok = await _api('PUT', '/api/v1/auth/password', { current_password: current, new_password: newPw });
|
||||
if (ok) {
|
||||
_toast('Password changed', 'success');
|
||||
_val('settingsCurrentPw', '');
|
||||
_val('settingsNewPw', '');
|
||||
_val('settingsConfirmPw', '');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ── Init: banner toggle ──────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const cb = document.getElementById('settBannerEnabled');
|
||||
const fields = document.getElementById('bannerFields');
|
||||
if (cb && fields) {
|
||||
cb.addEventListener('change', () => { fields.style.display = cb.checked ? '' : 'none'; });
|
||||
fields.style.display = cb.checked ? '' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
function _pageData(key) {
|
||||
const d = window.__PAGE_DATA__;
|
||||
return d ? (d[key] || d[key.toLowerCase()]) : null;
|
||||
}
|
||||
|
||||
function _val(id, setVal) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return '';
|
||||
if (setVal !== undefined) { el.value = setVal; return; }
|
||||
return el.value;
|
||||
}
|
||||
|
||||
function _show(id) { const el = document.getElementById(id); if (el) el.style.display = ''; }
|
||||
function _hide(id) { const el = document.getElementById(id); if (el) el.style.display = 'none'; }
|
||||
|
||||
function _esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function _toast(msg, type) {
|
||||
if (typeof UI !== 'undefined' && UI.toast) {
|
||||
UI.toast(msg, type);
|
||||
} else {
|
||||
console[type === 'error' ? 'error' : 'log']('[Pages]', msg);
|
||||
}
|
||||
}
|
||||
|
||||
function _parseJSON(text) {
|
||||
if (!text || !text.trim()) return {};
|
||||
try { return JSON.parse(text); }
|
||||
catch (e) { _toast('Invalid JSON', 'error'); return null; }
|
||||
}
|
||||
|
||||
async function _api(method, path, body) {
|
||||
const base = window.__BASE__ || '';
|
||||
const storageKey = base ? `sb_auth_${base.replace(/\//g, '')}` : 'sb_auth';
|
||||
let token = '';
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
|
||||
token = saved.accessToken || '';
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
try {
|
||||
const resp = await fetch(base + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
if (resp.ok) return true;
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
_toast(err.error || `Error ${resp.status}`, 'error');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_toast('Connection error', 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user