Upgrade JS and Go deps versions (#517)
This commit is contained in:
46
public/ts/admin.ts
Normal file
46
public/ts/admin.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let elems = Array.from(document.getElementsByClassName("toggle-button"));
|
||||
for (let elem of elems) {
|
||||
elem.addEventListener('click', () => {
|
||||
registerDomSetting(elem as HTMLElement)
|
||||
})
|
||||
}
|
||||
|
||||
let copyInviteButtons = Array.from(document.getElementsByClassName("copy-invitation-link"));
|
||||
for (let button of copyInviteButtons) {
|
||||
button.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText((button as HTMLElement).dataset.link).catch((err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const setSetting = (key: string, value: string) => {
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
const data = new URLSearchParams();
|
||||
data.append('key', key);
|
||||
data.append('value', value);
|
||||
if (document.getElementsByName('_csrf').length !== 0) {
|
||||
data.append('_csrf', ((document.getElementsByName('_csrf')[0] as HTMLInputElement).value));
|
||||
}
|
||||
return fetch(`${baseUrl}/admin-panel/set-config`, {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
body: data,
|
||||
});
|
||||
};
|
||||
|
||||
const registerDomSetting = (el: HTMLElement) => {
|
||||
// @ts-ignore
|
||||
el.dataset["bool"] = !(el.dataset["bool"] === 'true');
|
||||
setSetting(el.id, el.dataset["bool"] === 'true' ? '1' : '0')
|
||||
.then(() => {
|
||||
el.classList.toggle("bg-primary-600");
|
||||
el.classList.toggle("dark:bg-gray-400");
|
||||
el.classList.toggle("bg-gray-300");
|
||||
(el.childNodes.item(1) as HTMLElement).classList.toggle("translate-x-5");
|
||||
});
|
||||
};
|
||||
|
||||
1
public/ts/auto.ts
Normal file
1
public/ts/auto.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '../css/auto.css'
|
||||
1
public/ts/dark.ts
Normal file
1
public/ts/dark.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '../css/dark.css'
|
||||
461
public/ts/editor.ts
Normal file
461
public/ts/editor.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
import {EditorView, gutter, keymap, lineNumbers} from "@codemirror/view";
|
||||
import {Compartment, EditorState, Facet, Line, SelectionRange} from "@codemirror/state";
|
||||
import {defaultKeymap, indentLess} from "@codemirror/commands";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
EditorView.theme({}, {dark: true});
|
||||
|
||||
let editorsjs: EditorView[] = [];
|
||||
let editorsParentdom = document.getElementById("editors")!;
|
||||
let allEditorsdom = document.querySelectorAll("#editors > .editor");
|
||||
let firstEditordom = allEditorsdom[0];
|
||||
|
||||
const txtFacet = Facet.define<string>({
|
||||
combine(values) {
|
||||
return values;
|
||||
},
|
||||
});
|
||||
|
||||
let indentSize = new Compartment(),
|
||||
wrapMode = new Compartment(),
|
||||
indentType = new Compartment();
|
||||
|
||||
const newEditor = (dom: HTMLElement, value: string = ""): EditorView => {
|
||||
let editor = new EditorView({
|
||||
doc: value,
|
||||
parent: dom,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
gutter({class: "cm-mygutter"}),
|
||||
keymap.of([
|
||||
{key: "Tab", run: customIndentMore, shift: indentLess},
|
||||
...defaultKeymap,
|
||||
]),
|
||||
indentSize.of(EditorState.tabSize.of(2)),
|
||||
wrapMode.of([]),
|
||||
indentType.of(txtFacet.of("space")),
|
||||
],
|
||||
});
|
||||
|
||||
let mdpreview = dom.querySelector(".md-preview") as HTMLElement;
|
||||
|
||||
let formfilename = dom.querySelector<HTMLInputElement>(".form-filename");
|
||||
|
||||
// check if file ends with .md on pageload
|
||||
if (formfilename!.value.endsWith(".md")) {
|
||||
mdpreview!.classList.remove("hidden");
|
||||
} else {
|
||||
mdpreview!.classList.add("hidden");
|
||||
}
|
||||
|
||||
// event if the filename ends with .md; trigger event
|
||||
formfilename!.onkeyup = (e) => {
|
||||
let filename = (e.target as HTMLInputElement).value;
|
||||
if (filename.endsWith(".md")) {
|
||||
mdpreview!.classList.remove("hidden");
|
||||
} else {
|
||||
mdpreview!.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
let previewShown = false;
|
||||
mdpreview.onclick = () => {
|
||||
previewShown = !previewShown;
|
||||
let divpreview = dom.querySelector("div.preview") as HTMLElement;
|
||||
let cmeditor = dom.querySelector(".cm-editor") as HTMLElement;
|
||||
|
||||
if (!previewShown) {
|
||||
divpreview!.classList.add("hidden");
|
||||
cmeditor!.classList.remove("hidden-important");
|
||||
return;
|
||||
} else {
|
||||
const formData = new FormData();
|
||||
formData.append('content', editor.state.doc.toString());
|
||||
let csrf = document.querySelector<HTMLInputElement>('form#create input[name="_csrf"]').value
|
||||
fetch(`${baseUrl}/preview`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf
|
||||
}
|
||||
}).then(r => r.text()).then(r => {
|
||||
let divpreview = dom.querySelector("div.preview") as HTMLElement;
|
||||
divpreview!.innerHTML = r;
|
||||
divpreview!.classList.remove("hidden");
|
||||
cmeditor!.classList.add("hidden-important");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
dom.querySelector<HTMLInputElement>(".editor-indent-type")!.onchange = (e) => {
|
||||
let newTabType = (e.target as HTMLInputElement).value;
|
||||
setIndentType(editor, !["tab", "space"].includes(newTabType) ? "space" : newTabType);
|
||||
};
|
||||
|
||||
dom.querySelector<HTMLInputElement>(".editor-indent-size")!.onchange = (e) => {
|
||||
let newTabSize = parseInt((e.target as HTMLInputElement).value);
|
||||
setIndentSize(editor, ![2, 4, 8].includes(newTabSize) ? 2 : newTabSize);
|
||||
};
|
||||
|
||||
dom.querySelector<HTMLInputElement>(".editor-wrap-mode")!.onchange = (e) => {
|
||||
let newWrapMode = (e.target as HTMLInputElement).value;
|
||||
setLineWrapping(editor, newWrapMode === "soft");
|
||||
};
|
||||
|
||||
dom.addEventListener("drop", (e) => {
|
||||
e.preventDefault(); // prevent the browser from opening the dropped file
|
||||
(e.target as HTMLInputElement)
|
||||
.closest(".editor")
|
||||
.querySelector<HTMLInputElement>("input.form-filename")!.value =
|
||||
e.dataTransfer.files[0].name;
|
||||
});
|
||||
|
||||
// remove editor on delete
|
||||
let deleteBtns = dom.querySelector<HTMLButtonElement>("button.delete-file");
|
||||
if (deleteBtns !== null) {
|
||||
deleteBtns.onclick = () => {
|
||||
// For both text and binary files, just remove from DOM
|
||||
if (!dom.hasAttribute('data-binary-original-name')) {
|
||||
// Only remove from editors array for text files
|
||||
editorsjs.splice(editorsjs.indexOf(editor), 1);
|
||||
}
|
||||
dom.remove();
|
||||
checkForFirstDeleteButton();
|
||||
};
|
||||
}
|
||||
|
||||
editor.dom.addEventListener("input", function inputConfirmLeave() {
|
||||
if (!editor.inView) return; // skip events outside the viewport
|
||||
|
||||
editor.dom.removeEventListener("input", inputConfirmLeave);
|
||||
window.onbeforeunload = () => {
|
||||
return "Are you sure you want to quit?";
|
||||
};
|
||||
});
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
function getIndentation(state: EditorState): string {
|
||||
// @ts-ignore
|
||||
if (indentType.get(state).value === "tab") {
|
||||
return "\t";
|
||||
}
|
||||
// @ts-ignore
|
||||
return " ".repeat(indentSize.get(state).value);
|
||||
}
|
||||
|
||||
function customIndentMore({state, dispatch,}: { state: EditorState; dispatch: (value: any) => void; }): boolean {
|
||||
let indentation = getIndentation(state);
|
||||
dispatch({
|
||||
...state.update(changeBySelectedLine(state, (line, changes) => {
|
||||
changes.push({from: state.selection.ranges[0].from, insert: indentation,});
|
||||
})),
|
||||
selection: {
|
||||
anchor: state.selection.ranges[0].from + indentation.length,
|
||||
head: state.selection.ranges[0].from + indentation.length,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function changeBySelectedLine(state: EditorState, f: (line: Line, changes: any[]) => void): any {
|
||||
let atLine = -1;
|
||||
return state.changeByRange((range) => {
|
||||
let changes: any[] = [];
|
||||
for (let line = state.doc.lineAt(range.from); ;) {
|
||||
if (line.number > atLine) {
|
||||
f(line, changes);
|
||||
atLine = line.number;
|
||||
}
|
||||
if (range.to <= line.to) break;
|
||||
line = state.doc.lineAt(line.number + 1);
|
||||
}
|
||||
let changeSet = state.changes(changes);
|
||||
return {
|
||||
changes,
|
||||
// @ts-ignore
|
||||
range: new SelectionRange(changeSet.mapPos(range.anchor, 1), changeSet.mapPos(range.head, 1)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setIndentType(view: EditorView, type: string): void {
|
||||
view.dispatch({effects: indentType.reconfigure(txtFacet.of(type))});
|
||||
}
|
||||
|
||||
function setIndentSize(view: EditorView, size: number): void {
|
||||
view.dispatch({effects: indentSize.reconfigure(EditorState.tabSize.of(size))});
|
||||
}
|
||||
|
||||
function setLineWrapping(view: EditorView, enable: boolean): void {
|
||||
view.dispatch({
|
||||
effects: wrapMode.reconfigure(enable ? EditorView.lineWrapping : []),
|
||||
});
|
||||
}
|
||||
|
||||
let arr = Array.from(allEditorsdom);
|
||||
arr.forEach((el: HTMLElement) => {
|
||||
// in case we edit the gist contents
|
||||
let formFileContent =el.querySelector<HTMLInputElement>(".form-filecontent")
|
||||
if (formFileContent !== null) {
|
||||
let currEditor = newEditor(el, el.querySelector<HTMLInputElement>(".form-filecontent")!.value);
|
||||
editorsjs.push(currEditor);
|
||||
} else if (el.hasAttribute('data-binary-original-name')) {
|
||||
// For binary files, just set up the delete button
|
||||
let deleteBtn = el.querySelector<HTMLButtonElement>("button.delete-file");
|
||||
if (deleteBtn) {
|
||||
deleteBtn.onclick = () => {
|
||||
el.remove();
|
||||
checkForFirstDeleteButton();
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
checkForFirstDeleteButton();
|
||||
|
||||
document.getElementById("add-file")!.onclick = () => {
|
||||
const template = document.getElementById("editor-template")!;
|
||||
const newEditorDom = template.firstElementChild!.cloneNode(true) as HTMLElement;
|
||||
|
||||
// creating the new codemirror editor and append it in the editor div
|
||||
editorsjs.push(newEditor(newEditorDom));
|
||||
editorsParentdom.append(newEditorDom);
|
||||
showDeleteButton(newEditorDom);
|
||||
};
|
||||
|
||||
document.querySelector<HTMLFormElement>("form#create")!.onsubmit = () => {
|
||||
let j = 0;
|
||||
document.querySelectorAll<HTMLInputElement>(".form-filecontent").forEach((el) => {
|
||||
if (j < editorsjs.length) {
|
||||
el.value = encodeURIComponent(editorsjs[j++].state.doc.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const fileInput = document.getElementById("file-upload") as HTMLInputElement;
|
||||
if (fileInput) {
|
||||
fileInput.remove();
|
||||
}
|
||||
|
||||
const form = document.querySelector<HTMLFormElement>("form#create")!;
|
||||
|
||||
uploadedFileUUIDs.forEach((fileData) => {
|
||||
const uuidInput = document.createElement('input');
|
||||
uuidInput.type = 'hidden';
|
||||
uuidInput.name = 'uploadedfile_uuid';
|
||||
uuidInput.value = fileData.uuid;
|
||||
form.appendChild(uuidInput);
|
||||
|
||||
const filenameInput = document.createElement('input');
|
||||
filenameInput.type = 'hidden';
|
||||
filenameInput.name = 'uploadedfile_filename';
|
||||
filenameInput.value = fileData.filename;
|
||||
form.appendChild(filenameInput);
|
||||
});
|
||||
|
||||
const binaryFiles = document.querySelectorAll('[data-binary-original-name]');
|
||||
binaryFiles.forEach((fileDiv) => {
|
||||
const originalName = fileDiv.getAttribute('data-binary-original-name');
|
||||
const fileNameInput = fileDiv.querySelector('.form-filename') as HTMLInputElement;
|
||||
|
||||
if (fileNameInput) {
|
||||
fileNameInput.removeAttribute('name');
|
||||
}
|
||||
|
||||
const oldNameInput = document.createElement('input');
|
||||
oldNameInput.type = 'hidden';
|
||||
oldNameInput.name = 'binary_old_name';
|
||||
oldNameInput.value = originalName || '';
|
||||
form.appendChild(oldNameInput);
|
||||
|
||||
const newNameInput = document.createElement('input');
|
||||
newNameInput.type = 'hidden';
|
||||
newNameInput.name = 'binary_new_name';
|
||||
newNameInput.value = fileNameInput?.value || '';
|
||||
form.appendChild(newNameInput);
|
||||
});
|
||||
|
||||
window.onbeforeunload = null;
|
||||
};
|
||||
|
||||
document.getElementById('gist-metadata-btn')!.onclick = (el) => {
|
||||
let metadata = document.getElementById('gist-metadata')!;
|
||||
metadata.classList.toggle('hidden');
|
||||
|
||||
let btn = el.target as HTMLButtonElement;
|
||||
if (btn.innerText.endsWith('▼')) {
|
||||
btn.innerText = btn.innerText.replace('▼', '▲');
|
||||
} else {
|
||||
btn.innerText = btn.innerText.replace('▲', '▼');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function checkForFirstDeleteButton() {
|
||||
// Count total files (both text and binary)
|
||||
const totalFiles = editorsParentdom.querySelectorAll('.editor').length;
|
||||
|
||||
// Hide/show all delete buttons based on total file count
|
||||
const deleteButtons = editorsParentdom.querySelectorAll<HTMLButtonElement>("button.delete-file");
|
||||
deleteButtons.forEach(deleteBtn => {
|
||||
if (totalFiles <= 1) {
|
||||
deleteBtn.classList.add("hidden");
|
||||
deleteBtn.previousElementSibling?.classList.remove("rounded-l-md");
|
||||
deleteBtn.previousElementSibling?.classList.add("rounded-md");
|
||||
} else {
|
||||
deleteBtn.classList.remove("hidden");
|
||||
deleteBtn.previousElementSibling?.classList.add("rounded-l-md");
|
||||
deleteBtn.previousElementSibling?.classList.remove("rounded-md");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteButton(editorDom: HTMLElement) {
|
||||
let deleteBtn = editorDom.querySelector<HTMLButtonElement>("button.delete-file")!;
|
||||
deleteBtn.classList.remove("hidden");
|
||||
deleteBtn.previousElementSibling.classList.add("rounded-l-md");
|
||||
deleteBtn.previousElementSibling.classList.remove("rounded-md");
|
||||
checkForFirstDeleteButton();
|
||||
}
|
||||
|
||||
// File upload functionality
|
||||
let uploadedFileUUIDs: {uuid: string, filename: string}[] = [];
|
||||
const fileUploadInput = document.getElementById("file-upload") as HTMLInputElement;
|
||||
const uploadedFilesContainer = document.getElementById("uploaded-files")!;
|
||||
const fileUploadZone = document.getElementById("file-upload-zone")!.querySelector('.border-dashed') as HTMLElement;
|
||||
|
||||
// Handle file selection
|
||||
const handleFiles = (files: FileList) => {
|
||||
Array.from(files).forEach(file => {
|
||||
if (!uploadedFileUUIDs.find(f => f.filename === file.name)) {
|
||||
uploadFile(file);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Upload file to server
|
||||
const uploadFile = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
const csrf = document.querySelector<HTMLInputElement>('form#create input[name="_csrf"]')?.value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf || ''
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
uploadedFileUUIDs.push({uuid: result.uuid, filename: result.filename});
|
||||
addFileToUI(result.filename, result.uuid, file.size);
|
||||
} else {
|
||||
console.error('Upload failed:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add file to UI
|
||||
const addFileToUI = (filename: string, uuid: string, fileSize: number) => {
|
||||
const fileElement = document.createElement('div');
|
||||
fileElement.className = 'flex items-stretch bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden';
|
||||
fileElement.dataset.uuid = uuid;
|
||||
|
||||
fileElement.innerHTML = `
|
||||
<div class="flex items-center space-x-3 px-3 py-1 flex-1">
|
||||
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">${filename}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">${formatFileSize(fileSize)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="remove-file flex items-center justify-center px-4 border-l-1 dark:border-l-1 text-rose-600 dark:text-rose-400 border-gray-200 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 hover:bg-rose-500 hover:text-white dark:hover:bg-rose-600 hover:border-rose-600 dark:hover:border-rose-700 dark:hover:text-white focus:outline-none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Remove file handler
|
||||
fileElement.querySelector('.remove-file')!.addEventListener('click', async () => {
|
||||
// Remove from server
|
||||
try {
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
const csrf = document.querySelector<HTMLInputElement>('form#create input[name="_csrf"]')?.value;
|
||||
|
||||
await fetch(`${baseUrl}/upload/${uuid}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-CSRF-Token': csrf || ''
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
}
|
||||
|
||||
// Remove from UI and local array
|
||||
uploadedFileUUIDs = uploadedFileUUIDs.filter(f => f.uuid !== uuid);
|
||||
fileElement.remove();
|
||||
});
|
||||
|
||||
uploadedFilesContainer.appendChild(fileElement);
|
||||
};
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// File input change handler
|
||||
fileUploadInput.addEventListener('change', (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) {
|
||||
handleFiles(files);
|
||||
// Clear the input value immediately so it doesn't get submitted with the form
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Drag and drop handlers
|
||||
fileUploadZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadZone.classList.add('border-primary-400', 'dark:border-primary-500');
|
||||
});
|
||||
|
||||
fileUploadZone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadZone.classList.remove('border-primary-400', 'dark:border-primary-500');
|
||||
});
|
||||
|
||||
fileUploadZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadZone.classList.remove('border-primary-400', 'dark:border-primary-500');
|
||||
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files) {
|
||||
handleFiles(files);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
1
public/ts/embed.ts
Normal file
1
public/ts/embed.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "../css/embed.css"
|
||||
83
public/ts/gist.ts
Normal file
83
public/ts/gist.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import '../ts/ipynb.ts';
|
||||
import PDFObject from 'pdfobject';
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.table-code').forEach((el) => {
|
||||
el.addEventListener('click', event => {
|
||||
if (event.target && (event.target as HTMLElement).matches('.line-num')) {
|
||||
Array.from(document.querySelectorAll('.table-code .selected')).forEach((el) => el.classList.remove('selected'));
|
||||
|
||||
const nextSibling = (event.target as HTMLElement).nextSibling;
|
||||
if (nextSibling instanceof HTMLElement) {
|
||||
nextSibling.classList.add('selected');
|
||||
}
|
||||
|
||||
const filename = el.dataset.filenameSlug;
|
||||
const line = (event.target as HTMLElement).textContent;
|
||||
const url = location.protocol + '//' + location.host + location.pathname;
|
||||
const hash = '#file-' + filename + '-' + line;
|
||||
window.history.pushState(null, null, url + hash);
|
||||
location.hash = hash;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let copybtnhtml = `<button type="button" style="top: 1em !important; right: 1em !important;" class="md-code-copy-btn absolute focus-within:z-auto rounded-md dark:border-gray-600 px-2 py-2 opacity-80 font-medium text-slate-700 bg-gray-100 dark:bg-gray-700 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-gray-600 hover:border-gray-500 hover:text-slate-700 dark:hover:text-slate-300 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 7.5V6.108c0-1.135.845-2.098 1.976-2.192.373-.03.748-.057 1.123-.08M15.75 18H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08M15.75 18.75v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5A3.375 3.375 0 006.375 7.5H5.25m11.9-3.664A2.251 2.251 0 0015 2.25h-1.5a2.251 2.251 0 00-2.15 1.586m5.8 0c.065.21.1.433.1.664v.75h-6V4.5c0-.231.035-.454.1-.664M6.75 7.5H4.875c-.621 0-1.125.504-1.125 1.125v12c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V16.5a9 9 0 00-9-9z" /></svg></button>`;
|
||||
|
||||
document.querySelectorAll<HTMLElement>('.markdown-body pre').forEach((el) => {
|
||||
if (el.classList.contains("mermaid")) {
|
||||
return;
|
||||
}
|
||||
el.innerHTML = copybtnhtml + `<span class="code-div">` + el.innerHTML + `</span>`;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.md-code-copy-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
let code = this.nextElementSibling.textContent;
|
||||
navigator.clipboard.writeText(code).catch((err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let checkboxes = document.querySelectorAll('li[data-checkbox-nb] input[type=checkbox]');
|
||||
if (document.getElementById('gist').dataset.own) {
|
||||
document.querySelectorAll<HTMLElement>('li[data-checkbox-nb]').forEach((el) => {
|
||||
let input: HTMLButtonElement = el.querySelector('input[type=checkbox]');
|
||||
input.disabled = false;
|
||||
let checkboxNb = (el as HTMLElement).dataset.checkboxNb;
|
||||
let filename = input.closest<HTMLElement>('div[data-file]').dataset.file;
|
||||
|
||||
input.addEventListener('change', function () {
|
||||
const data = new URLSearchParams();
|
||||
data.append('checkbox', checkboxNb);
|
||||
data.append('file', filename);
|
||||
if (document.getElementsByName('_csrf').length !== 0) {
|
||||
data.append('_csrf', ((document.getElementsByName('_csrf')[0] as HTMLInputElement).value));
|
||||
}
|
||||
checkboxes.forEach((el: HTMLButtonElement) => {
|
||||
el.disabled = true;
|
||||
el.classList.add('text-gray-400')
|
||||
});
|
||||
fetch(window.location.href.split('#')[0] + '/checkbox', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
body: data,
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
checkboxes.forEach((el: HTMLButtonElement) => {
|
||||
el.disabled = false;
|
||||
el.classList.remove('text-gray-400')
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
checkboxes.forEach((el: HTMLButtonElement) => {
|
||||
el.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll(".pdf").forEach((el) => {
|
||||
PDFObject.embed(el.dataset.src || "", el);
|
||||
})
|
||||
126
public/ts/ipynb.ts
Normal file
126
public/ts/ipynb.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import hljs from 'highlight.js';
|
||||
import latex from './latex';
|
||||
import { marked } from 'marked';
|
||||
|
||||
class IPynb {
|
||||
private element: HTMLElement;
|
||||
private cells: HTMLElement[] = [];
|
||||
private language: string = 'python';
|
||||
private notebook: any;
|
||||
|
||||
constructor(element: HTMLElement) {
|
||||
this.element = element;
|
||||
let notebookContent = element.innerText;
|
||||
|
||||
try {
|
||||
this.notebook = JSON.parse(notebookContent);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse Jupyter notebook content:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.notebook) {
|
||||
console.error('Failed to parse Jupyter notebook content:', notebookContent);
|
||||
return;
|
||||
}
|
||||
|
||||
this.language = this.notebook.metadata.kernelspec?.language || 'python';
|
||||
this.cells = this.createCells();
|
||||
}
|
||||
|
||||
mount() {
|
||||
const parent = this.element.parentElement as HTMLElement;
|
||||
parent.removeChild(this.element);
|
||||
parent.innerHTML = this.cells
|
||||
.filter((cell: HTMLElement) => !!cell?.outerHTML)
|
||||
.map((cell: HTMLElement) => cell.outerHTML)
|
||||
.join('');
|
||||
}
|
||||
|
||||
private getOutputs(cell: any): HTMLElement[] {
|
||||
return (cell.outputs || []).map((output: any) => {
|
||||
const outputElement = document.createElement('div');
|
||||
outputElement.classList.add('jupyter-output');
|
||||
|
||||
if (output.output_type === 'stream') {
|
||||
const textElement = document.createElement('pre');
|
||||
textElement.classList.add('stream-output');
|
||||
textElement.textContent = output.text.join('');
|
||||
outputElement.appendChild(textElement);
|
||||
} else if (output.output_type === 'display_data' || output.output_type === 'execute_result') {
|
||||
if (output.data['text/plain']) {
|
||||
outputElement.innerHTML += `\n<pre>${output.data['text/plain']}</pre>`;
|
||||
}
|
||||
if (output.data['text/html']) {
|
||||
outputElement.innerHTML += '\n' + output.data['text/html'];
|
||||
}
|
||||
|
||||
const images = Object.keys(output.data).filter(key => key.startsWith('image/'));
|
||||
if (images.length > 0) {
|
||||
const imgEl = document.createElement('img');
|
||||
const imgType = images[0]; // Use the first image type found
|
||||
imgEl.src = `data:${imgType};base64,${output.data[imgType]}`;
|
||||
outputElement.innerHTML += imgEl.outerHTML;
|
||||
}
|
||||
} else if (output.output_type === 'execute_result') {
|
||||
if (output.data['text/plain']) {
|
||||
outputElement.innerHTML += `<pre>${output.data['text/plain']}</pre>`;
|
||||
}
|
||||
if (output.data['text/html']) {
|
||||
outputElement.innerHTML += output.data['text/html'];
|
||||
}
|
||||
if (output.data['image/png']) {
|
||||
const imgEl = document.createElement('img');
|
||||
imgEl.src = `data:image/png;base64,${output.data['image/png']}`;
|
||||
outputElement.appendChild(imgEl);
|
||||
}
|
||||
} else if (output.output_type === 'error') {
|
||||
outputElement.classList.add('error');
|
||||
outputElement.textContent = `Error: ${output.ename}: ${output.evalue}`;
|
||||
}
|
||||
|
||||
return outputElement;
|
||||
});
|
||||
}
|
||||
|
||||
private createCellElement(cell: any): HTMLElement {
|
||||
const cellElement = document.createElement('div');
|
||||
const source = cell.source.join('');
|
||||
cellElement.classList.add('jupyter-cell');
|
||||
|
||||
switch (cell.cell_type) {
|
||||
case 'markdown':
|
||||
cellElement.classList.add('markdown-cell');
|
||||
cellElement.innerHTML = `<div class="markdown-body">${marked.parse(latex.render(source))}</div>`;
|
||||
break;
|
||||
case 'code':
|
||||
cellElement.classList.add('code-cell');
|
||||
cellElement.innerHTML = `<pre class="hljs"><code class="language-${this.language}">${source}</code></pre>`;
|
||||
hljs.highlightElement(cellElement.querySelector('code') as HTMLElement);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return cellElement;
|
||||
}
|
||||
|
||||
|
||||
private createCells(): HTMLElement[] {
|
||||
return (this.notebook.cells || []).map((cell: any) => {
|
||||
const container = document.createElement('div');
|
||||
const cellElement = this.createCellElement(cell);
|
||||
const outputs = this.getOutputs(cell);
|
||||
|
||||
container.classList.add('jupyter-cell-container');
|
||||
container.appendChild(cellElement);
|
||||
outputs.forEach((output: HTMLElement) => container.appendChild(output));
|
||||
return container;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process Jupyter notebooks
|
||||
document.querySelectorAll<HTMLElement>('.jupyter.notebook pre').forEach((el) => {
|
||||
new IPynb(el).mount();
|
||||
});
|
||||
66
public/ts/latex.ts
Normal file
66
public/ts/latex.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import katex from 'katex';
|
||||
|
||||
const delimiters = [
|
||||
{ left: '\\\$\\\$', right: '\\\$\\\$', multiline: true },
|
||||
{ left: '\\\$', right: '\\\$', multiline: false },
|
||||
{ left: '\\\\\[', right: '\\\\\]', multiline: true },
|
||||
{ left: '\\\\\(', right: '\\\\\)', multiline: false },
|
||||
];
|
||||
|
||||
const delimiterMatchers = delimiters.map(
|
||||
(delimiter) => new RegExp(
|
||||
`${delimiter.left}(.*?)${delimiter.right}`,
|
||||
`g${delimiter.multiline ? 'ms' : ''}`
|
||||
)
|
||||
);
|
||||
|
||||
// Replace LaTeX delimiters in a string with KaTeX rendering
|
||||
function render(text: string): string {
|
||||
// Step 1: Replace all LaTeX expressions with placeholders
|
||||
const expressions: Array<{ placeholder: string; latex: string; displayMode: boolean }> = [];
|
||||
let modifiedText = text;
|
||||
let placeholderIndex = 0;
|
||||
|
||||
// Process each delimiter type
|
||||
delimiters.forEach((delimiter, i) => {
|
||||
// Find all matches and replace with placeholders
|
||||
modifiedText = modifiedText.replace(delimiterMatchers[i], (match, latex) => {
|
||||
if (!latex.trim()) {
|
||||
return match; // Return original if content is empty
|
||||
}
|
||||
|
||||
const placeholder = `__KATEX_PLACEHOLDER_${placeholderIndex++}__`;
|
||||
expressions.push({
|
||||
placeholder,
|
||||
latex,
|
||||
displayMode: delimiter.multiline,
|
||||
});
|
||||
|
||||
return placeholder;
|
||||
});
|
||||
});
|
||||
|
||||
// Step 2: Replace placeholders with rendered LaTeX
|
||||
for (const { placeholder, latex, displayMode } of expressions) {
|
||||
try {
|
||||
const rendered = katex.renderToString(latex, {
|
||||
throwOnError: false,
|
||||
displayMode,
|
||||
});
|
||||
modifiedText = modifiedText.replace(placeholder, rendered);
|
||||
} catch (error) {
|
||||
console.error('KaTeX rendering error:', error);
|
||||
// Replace placeholder with original LaTeX if rendering fails
|
||||
modifiedText = modifiedText.replace(
|
||||
placeholder,
|
||||
displayMode ? `$$${latex}$$` : `$${latex}$`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedText;
|
||||
}
|
||||
|
||||
export default {
|
||||
render,
|
||||
};
|
||||
1
public/ts/light.ts
Normal file
1
public/ts/light.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '../css/light.css'
|
||||
185
public/ts/main.ts
Normal file
185
public/ts/main.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import '../css/tailwind.css';
|
||||
import '../img/favicon-32.png';
|
||||
import '../img/opengist.svg';
|
||||
import jdenticon from 'jdenticon/standalone';
|
||||
|
||||
jdenticon.update("[data-jdenticon-value]")
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('user-btn')?.addEventListener("click" , () => {
|
||||
document.getElementById('user-menu')!.classList.toggle('hidden');
|
||||
})
|
||||
|
||||
document.querySelectorAll('form').forEach((form: HTMLFormElement) => {
|
||||
form.onsubmit = () => {
|
||||
form.querySelectorAll('input[type=datetime-local]').forEach((input: HTMLInputElement) => {
|
||||
const hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'expiredAtUnix'
|
||||
hiddenInput.value = Math.floor(new Date(input.value).getTime() / 1000).toString();
|
||||
form.appendChild(hiddenInput);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
})
|
||||
|
||||
|
||||
|
||||
const rev = document.querySelector<HTMLElement>('.revision-text');
|
||||
if (rev) {
|
||||
const fullRev = rev.innerHTML;
|
||||
const smallRev = fullRev.substring(0, 7);
|
||||
rev.innerHTML = smallRev;
|
||||
|
||||
rev.onmouseover = () => {
|
||||
rev.innerHTML = fullRev;
|
||||
};
|
||||
rev.onmouseout = () => {
|
||||
rev.innerHTML = smallRev;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const colorhash = () => {
|
||||
Array.from(document.querySelectorAll('.table-code .selected')).forEach((el) => el.classList.remove('selected'));
|
||||
const lineEl = document.querySelector<HTMLElement>(location.hash);
|
||||
if (lineEl) {
|
||||
const nextSibling = lineEl.nextSibling;
|
||||
if (nextSibling instanceof HTMLElement) {
|
||||
nextSibling.classList.add('selected');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (location.hash) {
|
||||
colorhash();
|
||||
}
|
||||
window.onhashchange = colorhash;
|
||||
|
||||
document.getElementById('main-menu-button')!.onclick = () => {
|
||||
document.getElementById('mobile-menu')!.classList.toggle('hidden');
|
||||
};
|
||||
|
||||
const tabs = document.getElementById('gist-tabs');
|
||||
if (tabs) {
|
||||
tabs.onchange = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
window.location.href = target.selectedOptions[0].dataset.url || '';
|
||||
};
|
||||
}
|
||||
|
||||
const gistmenutoggle = document.getElementById('gist-menu-toggle');
|
||||
if (gistmenutoggle) {
|
||||
const gistmenucopy = document.getElementById('gist-menu-copy')!;
|
||||
const gistmenubuttoncopy = document.getElementById('gist-menu-button-copy')!;
|
||||
const gistmenuinput = document.getElementById('gist-menu-input') as HTMLInputElement;
|
||||
const gistmenutitle = document.getElementById('gist-menu-title')!;
|
||||
|
||||
gistmenutitle.textContent = gistmenucopy.children[0].firstChild!.textContent;
|
||||
gistmenuinput.value = (gistmenucopy.children[0] as HTMLElement).dataset.link || '';
|
||||
|
||||
gistmenutoggle.onclick = () => {
|
||||
gistmenucopy.classList.toggle('hidden');
|
||||
};
|
||||
|
||||
for (const item of Array.from(gistmenucopy.children)) {
|
||||
(item as HTMLElement).onclick = () => {
|
||||
gistmenutitle.textContent = item.firstChild!.textContent;
|
||||
gistmenuinput.value = (item as HTMLElement).dataset.link || '';
|
||||
gistmenucopy.classList.toggle('hidden');
|
||||
};
|
||||
}
|
||||
|
||||
gistmenubuttoncopy.onclick = () => {
|
||||
const text = gistmenuinput.value;
|
||||
navigator.clipboard.writeText(text).catch((err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const sortgist = document.getElementById('sort-gists-button');
|
||||
if (sortgist) {
|
||||
sortgist.onclick = () => {
|
||||
document.getElementById('sort-gists-dropdown')!.classList.toggle('hidden');
|
||||
};
|
||||
}
|
||||
|
||||
const searchUserGistsVisibility = document.getElementById('search-user-gists-visibility');
|
||||
if (searchUserGistsVisibility) {
|
||||
let dropdown = document.getElementById('search-user-gists-visibility-dropdown');
|
||||
searchUserGistsVisibility.onclick = () => {
|
||||
dropdown!.classList.toggle('hidden');
|
||||
};
|
||||
|
||||
let buttons = dropdown.querySelectorAll('button');
|
||||
buttons.forEach((button) => {
|
||||
button.onclick = () => {
|
||||
let value = document.getElementById('visibility-value') as HTMLInputElement;
|
||||
value.textContent = button.dataset.visibilityStr;
|
||||
dropdown!.classList.add('hidden');
|
||||
dropdown.querySelector('input')!.value = button.dataset.visibility || '';
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const searchUserGistsLanguage = document.getElementById('search-user-gists-language');
|
||||
if (searchUserGistsLanguage) {
|
||||
let dropdown = document.getElementById('search-user-gists-language-dropdown');
|
||||
searchUserGistsLanguage.onclick = () => {
|
||||
dropdown!.classList.toggle('hidden');
|
||||
};
|
||||
let buttons = dropdown.querySelectorAll('button');
|
||||
buttons.forEach((button) => {
|
||||
button.onclick = () => {
|
||||
let value = document.getElementById('language-value') as HTMLInputElement;
|
||||
value.textContent = button.dataset.languageStr;
|
||||
dropdown!.classList.add('hidden');
|
||||
dropdown.querySelector('input')!.value = button.dataset.language || '';
|
||||
};
|
||||
});
|
||||
}
|
||||
document.getElementById('language-btn')!.onclick = () => {
|
||||
document.getElementById('language-list')!.classList.toggle('hidden');
|
||||
};
|
||||
|
||||
|
||||
document.querySelectorAll('.copy-gist-btn').forEach((e: HTMLElement) => {
|
||||
e.onclick = () => {
|
||||
navigator.clipboard.writeText(e.parentNode!.parentNode!.querySelector<HTMLElement>('.gist-content')!.textContent || '').catch((err) => {
|
||||
console.error('Could not copy text: ', err);
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const gistmenuvisibility = document.getElementById('gist-menu-visibility');
|
||||
if (gistmenuvisibility) {
|
||||
let submitgistbutton = (document.getElementById('submit-gist') as HTMLInputElement);
|
||||
document.getElementById('gist-visibility-menu-button')!.onclick = () => {
|
||||
gistmenuvisibility!.classList.toggle('hidden');
|
||||
}
|
||||
const lastVisibility = localStorage.getItem('visibility');
|
||||
Array.from(document.querySelectorAll('.gist-visibility-option')).forEach((el) => {
|
||||
const visibility = (el as HTMLElement).dataset.visibility || '0';
|
||||
(el as HTMLElement).onclick = () => {
|
||||
submitgistbutton.textContent = (el as HTMLElement).dataset.btntext;
|
||||
submitgistbutton!.value = visibility;
|
||||
localStorage.setItem('visibility', visibility);
|
||||
gistmenuvisibility!.classList.add('hidden');
|
||||
}
|
||||
if (lastVisibility === visibility) {
|
||||
(el as HTMLElement).click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const searchinput = document.getElementById('search') as HTMLInputElement;
|
||||
searchinput.addEventListener('focusin', () => {
|
||||
document.getElementById('search-help').classList.remove('hidden');
|
||||
})
|
||||
|
||||
searchinput.addEventListener('focusout', (e) => {
|
||||
document.getElementById('search-help').classList.add('hidden');
|
||||
})
|
||||
});
|
||||
45
public/ts/style_preferences.ts
Normal file
45
public/ts/style_preferences.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const noSoftWrapRadio = document.getElementById('no-soft-wrap');
|
||||
const softWrapRadio = document.getElementById('soft-wrap');
|
||||
|
||||
function updateRootClass() {
|
||||
const table = document.querySelector("table");
|
||||
|
||||
if (softWrapRadio.checked) {
|
||||
table.classList.remove('whitespace-pre');
|
||||
table.classList.add('whitespace-pre-wrap');
|
||||
} else {
|
||||
table.classList.remove('whitespace-pre-wrap');
|
||||
table.classList.add('whitespace-pre');
|
||||
}
|
||||
}
|
||||
|
||||
noSoftWrapRadio.addEventListener('change', updateRootClass);
|
||||
softWrapRadio.addEventListener('change', updateRootClass);
|
||||
|
||||
|
||||
document.getElementById('removedlinecolor').addEventListener('change', function(event) {
|
||||
const color = hexToRgba(event.target.value, 0.1);
|
||||
document.documentElement.style.setProperty('--red-diff', color);
|
||||
});
|
||||
|
||||
document.getElementById('addedlinecolor').addEventListener('change', function(event) {
|
||||
const color = hexToRgba(event.target.value, 0.1);
|
||||
document.documentElement.style.setProperty('--green-diff', color);
|
||||
});
|
||||
|
||||
document.getElementById('gitlinecolor').addEventListener('change', function(event) {
|
||||
const color = hexToRgba(event.target.value, 0.38);
|
||||
document.documentElement.style.setProperty('--git-diff', color);
|
||||
});
|
||||
});
|
||||
|
||||
function hexToRgba(hex, opacity) {
|
||||
hex = hex.replace('#', '');
|
||||
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
182
public/ts/webauthn.ts
Normal file
182
public/ts/webauthn.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
let loginMethod = "login"
|
||||
|
||||
function encodeArrayBufferToBase64Url(buffer) {
|
||||
const base64 = btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
|
||||
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function decodeBase64UrlToArrayBuffer(base64Url) {
|
||||
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
const binaryString = atob(base64);
|
||||
const buffer = new ArrayBuffer(binaryString.length);
|
||||
const view = new Uint8Array(buffer);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
view[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async function bindPasskey() {
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
let waitText = document.getElementById("login-passkey-wait");
|
||||
|
||||
try {
|
||||
this.classList.add('hidden');
|
||||
waitText.classList.remove('hidden');
|
||||
|
||||
let csrf = document.querySelector<HTMLInputElement>('form#webauthn input[name="_csrf"]').value
|
||||
|
||||
const beginResponse = await fetch(`${baseUrl}/webauthn/bind`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: new FormData(document.querySelector<HTMLFormElement>('form#webauthn'))
|
||||
});
|
||||
const beginData = await beginResponse.json();
|
||||
|
||||
beginData.publicKey.challenge = decodeBase64UrlToArrayBuffer(beginData.publicKey.challenge);
|
||||
beginData.publicKey.user.id = decodeBase64UrlToArrayBuffer(beginData.publicKey.user.id);
|
||||
for (const cred of beginData.publicKey.excludeCredentials ?? []) {
|
||||
cred.id = decodeBase64UrlToArrayBuffer(cred.id);
|
||||
}
|
||||
|
||||
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: beginData.publicKey,
|
||||
});
|
||||
|
||||
if (!credential || !credential.rawId || !credential.response) {
|
||||
throw new Error('Credential object is missing required properties');
|
||||
}
|
||||
|
||||
const finishResponse = await fetch(`${baseUrl}/webauthn/bind/finish`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: encodeArrayBufferToBase64Url(credential.rawId),
|
||||
response: {
|
||||
attestationObject: encodeArrayBufferToBase64Url(credential.response.attestationObject),
|
||||
clientDataJSON: encodeArrayBufferToBase64Url(credential.response.clientDataJSON),
|
||||
},
|
||||
type: credential.type,
|
||||
passkeyname: document.querySelector<HTMLInputElement>('form#webauthn input[name="passkeyname"]').value
|
||||
}),
|
||||
});
|
||||
const finishData = await finishResponse.json();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Error during passkey registration:', error);
|
||||
waitText.classList.add('hidden');
|
||||
this.classList.remove('hidden');
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithPasskey() {
|
||||
// @ts-ignore
|
||||
const baseUrl = window.opengist_base_url || '';
|
||||
let waitText = document.getElementById("login-passkey-wait");
|
||||
|
||||
try {
|
||||
this.classList.add('hidden');
|
||||
waitText.classList.remove('hidden');
|
||||
|
||||
let csrf = document.querySelector<HTMLInputElement>('form#webauthn input[name="_csrf"]').value
|
||||
const beginResponse = await fetch(`${baseUrl}/webauthn/${loginMethod}`, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: new FormData(document.querySelector<HTMLFormElement>('form#webauthn'))
|
||||
});
|
||||
const beginData = await beginResponse.json();
|
||||
|
||||
beginData.publicKey.challenge = decodeBase64UrlToArrayBuffer(beginData.publicKey.challenge);
|
||||
|
||||
if (beginData.publicKey.allowCredentials) {
|
||||
beginData.publicKey.allowCredentials = beginData.publicKey.allowCredentials.map(cred => ({
|
||||
...cred,
|
||||
id: decodeBase64UrlToArrayBuffer(cred.id),
|
||||
}));
|
||||
}
|
||||
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: beginData.publicKey,
|
||||
});
|
||||
|
||||
if (!credential || !credential.rawId || !credential.response) {
|
||||
throw new Error('Credential object is missing required properties');
|
||||
}
|
||||
|
||||
const finishResponse = await fetch(`${baseUrl}/webauthn/${loginMethod}/finish`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf
|
||||
},
|
||||
|
||||
body: JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: encodeArrayBufferToBase64Url(credential.rawId),
|
||||
response: {
|
||||
authenticatorData: encodeArrayBufferToBase64Url(credential.response.authenticatorData),
|
||||
clientDataJSON: encodeArrayBufferToBase64Url(credential.response.clientDataJSON),
|
||||
signature: encodeArrayBufferToBase64Url(credential.response.signature),
|
||||
userHandle: encodeArrayBufferToBase64Url(credential.response.userHandle),
|
||||
},
|
||||
type: credential.type,
|
||||
clientExtensionResults: credential.getClientExtensionResults(),
|
||||
}),
|
||||
});
|
||||
const finishData = await finishResponse.json();
|
||||
|
||||
if (!finishResponse.ok) {
|
||||
throw new Error(finishData.message || 'Unknown error');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = `${baseUrl}`;
|
||||
}, 100);
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
waitText.classList.add('hidden');
|
||||
this.classList.remove('hidden');
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const registerButton = document.getElementById('bind-passkey-button');
|
||||
if (registerButton) {
|
||||
registerButton.addEventListener('click', bindPasskey);
|
||||
}
|
||||
|
||||
if (document.documentURI.includes('/mfa')) {
|
||||
loginMethod = "assertion"
|
||||
}
|
||||
|
||||
const loginButton = document.getElementById('login-passkey-button');
|
||||
if (loginButton) {
|
||||
loginButton.addEventListener('click', loginWithPasskey);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user