Workspaces now fully functional (using indexedDB)
Signed-off-by: Victor Seiji Hariki <victorseijih@gmail.com>
This commit is contained in:
parent
827349bf05
commit
c4ef6ccce4
15 changed files with 397 additions and 49 deletions
|
@ -1,5 +1,6 @@
|
|||
:root {
|
||||
--c-primary: #2c3333;
|
||||
--c-disabled: rgb(81, 81, 81);
|
||||
--c-hover: hsl(180, 7%, 30%);
|
||||
--c-active: hsl(180, 7%, 25%);
|
||||
--c-primary-accent: hsl(180, 7%, 40%);
|
||||
|
|
|
@ -105,6 +105,39 @@
|
|||
mask-image: url("../res/icons/paintbrush.svg");
|
||||
}
|
||||
|
||||
.ui.inline-icon.icon-save::after,
|
||||
.ui.icon > .icon-save {
|
||||
-webkit-mask-image: url("../res/icons/save.svg");
|
||||
mask-image: url("../res/icons/save.svg");
|
||||
}
|
||||
|
||||
.ui.inline-icon.icon-pencil::after,
|
||||
.ui.icon > .icon-pencil {
|
||||
-webkit-mask-image: url("../res/icons/pencil.svg");
|
||||
mask-image: url("../res/icons/pencil.svg");
|
||||
}
|
||||
|
||||
.ui.inline-icon.icon-download::after,
|
||||
.ui.icon > .icon-download {
|
||||
-webkit-mask-image: url("../res/icons/download.svg");
|
||||
mask-image: url("../res/icons/download.svg");
|
||||
}
|
||||
.ui.inline-icon.icon-upload::after,
|
||||
.ui.icon > .icon-upload {
|
||||
-webkit-mask-image: url("../res/icons/upload.svg");
|
||||
mask-image: url("../res/icons/upload.svg");
|
||||
}
|
||||
.ui.inline-icon.icon-more-horizontal::after,
|
||||
.ui.icon > .icon-more-horizontal {
|
||||
-webkit-mask-image: url("../res/icons/more-horizontal.svg");
|
||||
mask-image: url("../res/icons/more-horizontal.svg");
|
||||
}
|
||||
.ui.inline-icon.icon-trash::after,
|
||||
.ui.icon > .icon-trash {
|
||||
-webkit-mask-image: url("../res/icons/trash.svg");
|
||||
mask-image: url("../res/icons/trash.svg");
|
||||
}
|
||||
|
||||
.ui.inline-icon.icon-expand::after,
|
||||
.ui.icon > .icon-expand {
|
||||
-webkit-mask-image: url("../res/icons/expand.svg");
|
||||
|
|
59
css/ui/workspace.css
Normal file
59
css/ui/workspace.css
Normal file
|
@ -0,0 +1,59 @@
|
|||
#workspace-select input.autocomplete-text {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
padding-left: 5px;
|
||||
|
||||
border: none;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#workspace-select-area .buttons > *:last-child {
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.workspace-btn {
|
||||
cursor: pointer;
|
||||
|
||||
border: 0;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
|
||||
background-color: var(--c-primary);
|
||||
}
|
||||
|
||||
.workspace-btn:disabled {
|
||||
cursor: default;
|
||||
background-color: var(--c-disabled) !important;
|
||||
}
|
||||
|
||||
.workspace-btn:hover {
|
||||
background-color: var(--c-hover);
|
||||
}
|
||||
|
||||
.workspace-btn:active {
|
||||
background-color: var(--c-active);
|
||||
}
|
||||
|
||||
.workspace-collapsible {
|
||||
position: relative;
|
||||
|
||||
width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.workspace-collapsible > *:first-child {
|
||||
display: flex;
|
||||
|
||||
width: fit-content;
|
||||
height: 21px;
|
||||
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
|
||||
.workspace-collapsible.collapsed > *:first-child {
|
||||
width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
47
index.html
47
index.html
|
@ -12,6 +12,7 @@
|
|||
|
||||
<link href="css/ui/generic.css?v=30837f8" rel="stylesheet" />
|
||||
|
||||
<link href="css/ui/workspace.css?v=30837f8" rel="stylesheet" />
|
||||
<link href="css/ui/history.css?v=0b03861" rel="stylesheet" />
|
||||
<link href="css/ui/layers.css?v=1d66c2b" rel="stylesheet" />
|
||||
<link href="css/ui/toolbar.css?v=109c78f" rel="stylesheet" />
|
||||
|
@ -38,6 +39,46 @@
|
|||
</button>
|
||||
</div>
|
||||
<div id="info" class="menu-container" style="min-width: 200px">
|
||||
<div
|
||||
id="workspace-select-area"
|
||||
style="display: flex; margin-bottom: 5px">
|
||||
<div id="workspace-select"></div>
|
||||
<div class="buttons" style="display: flex">
|
||||
<button
|
||||
id="save-workspace-btn"
|
||||
class="ui inline-icon icon-save workspace-btn"
|
||||
title="Save Workspace"></button>
|
||||
<button
|
||||
id="rename-workspace-btn"
|
||||
class="ui inline-icon icon-pencil workspace-btn"
|
||||
title="Rename Workspace"></button>
|
||||
<button
|
||||
id="more-workspace-btn"
|
||||
class="ui inline-icon icon-more-horizontal workspace-btn"
|
||||
title="More Options"></button>
|
||||
<div
|
||||
id="more-workspace-menu"
|
||||
class="workspace-collapsible collapsed">
|
||||
<div>
|
||||
<button
|
||||
id="export-workspace-btn"
|
||||
class="ui inline-icon icon-download workspace-btn"
|
||||
title="Export Workspace"></button>
|
||||
<button
|
||||
id="import-workspace-btn"
|
||||
class="ui inline-icon icon-upload workspace-btn"
|
||||
title="Import Workspace"></button>
|
||||
<button
|
||||
id="delete-workspace-btn"
|
||||
class="ui inline-icon icon-trash workspace-btn"
|
||||
title="Delete Workspace"></button>
|
||||
</div>
|
||||
<div style="width: 5px; background-color: var(--c-primary)"></div>
|
||||
</div>
|
||||
<div style="width: 5px; background-color: var(--c-primary)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="host-field-wrapper">
|
||||
<div class="host-field">
|
||||
<div class="label">Host</div>
|
||||
|
@ -164,8 +205,6 @@
|
|||
<!-- Save/load image section -->
|
||||
<button type="button" class="collapsible">Save/Upscaling</button>
|
||||
<div class="content">
|
||||
<button onclick="saveWorkspaceToFile()">Save Workspace</button>
|
||||
<button onclick="loadWorkspaceFromFile()">Load Workspace</button>
|
||||
<button onclick="downloadCanvas()">Save canvas</button>
|
||||
<br />
|
||||
<label>Choose upscaler</label>
|
||||
|
@ -365,6 +404,7 @@
|
|||
<script
|
||||
src="js/lib/workspaces.js?v=4fbd55b"
|
||||
type="text/javascript"></script>
|
||||
<script src="js/lib/db.js?v=ac30d16" type="text/javascript"></script>
|
||||
<script src="js/lib/input.js?v=aa14afc" type="text/javascript"></script>
|
||||
<script src="js/lib/layers.js?v=1a452a1" type="text/javascript"></script>
|
||||
<script src="js/lib/commands.js?v=262f0bf" type="text/javascript"></script>
|
||||
|
@ -412,6 +452,9 @@
|
|||
type="text/javascript"></script>
|
||||
|
||||
<!-- Initialize -->
|
||||
<script
|
||||
src="js/initalize/workspace.populate.js?v=fd01c47"
|
||||
type="text/javascript"></script>
|
||||
<script
|
||||
src="js/initalize/shortcuts.populate.js?v=fd01c47"
|
||||
type="text/javascript"></script>
|
||||
|
|
15
js/index.js
15
js/index.js
|
@ -886,7 +886,7 @@ async function exportWorkspaceState() {
|
|||
|
||||
async function importWorkspaceState(state) {
|
||||
// Start from zero, effectively
|
||||
await commands.undo(commands._history.length);
|
||||
await commands.clear();
|
||||
|
||||
// Setup initial layer
|
||||
const layer = uil.layerIndex.default;
|
||||
|
@ -965,19 +965,6 @@ async function saveWorkspaceToFile() {
|
|||
link.click();
|
||||
}
|
||||
|
||||
async function loadWorkspaceFromFile() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "application/json";
|
||||
input.addEventListener("change", async (evn) => {
|
||||
let files = Array.from(input.files);
|
||||
const json = await files[0].text();
|
||||
|
||||
importWorkspaceState(JSON.parse(json));
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function getUpscalers() {
|
||||
/*
|
||||
so for some reason when upscalers request returns upscalers, the real-esrgan model names are incorrect, and need to be fetched from /sdapi/v1/realesrgan-models
|
||||
|
|
179
js/initalize/workspace.populate.js
Normal file
179
js/initalize/workspace.populate.js
Normal file
|
@ -0,0 +1,179 @@
|
|||
(() => {
|
||||
const saveWorkspaceBtn = document.getElementById("save-workspace-btn");
|
||||
const renameWorkspaceBtn = document.getElementById("rename-workspace-btn");
|
||||
const moreWorkspaceBtn = document.getElementById("more-workspace-btn");
|
||||
const expandedWorkspaceMenu = document.getElementById("more-workspace-menu");
|
||||
const exportWorkspaceBtn = document.getElementById("export-workspace-btn");
|
||||
const importWorkspaceBtn = document.getElementById("import-workspace-btn");
|
||||
const deleteWorkspaceBtn = document.getElementById("delete-workspace-btn");
|
||||
|
||||
moreWorkspaceBtn.addEventListener("click", () => {
|
||||
expandedWorkspaceMenu.classList.toggle("collapsed");
|
||||
});
|
||||
|
||||
const workspaceAutocomplete = createAutoComplete(
|
||||
"Workspace",
|
||||
document.getElementById("workspace-select")
|
||||
);
|
||||
|
||||
workspaceAutocomplete.options = [{name: "Default", value: "default"}];
|
||||
workspaceAutocomplete.value = "default";
|
||||
renameWorkspaceBtn.disabled = true;
|
||||
deleteWorkspaceBtn.disabled = true;
|
||||
|
||||
workspaceAutocomplete.onchange.on(async ({name, value}) => {
|
||||
if (value === "default") {
|
||||
renameWorkspaceBtn.disabled = true;
|
||||
deleteWorkspaceBtn.disabled = true;
|
||||
await commands.clear();
|
||||
return;
|
||||
}
|
||||
renameWorkspaceBtn.disabled = false;
|
||||
deleteWorkspaceBtn.disabled = false;
|
||||
|
||||
const workspaces = db
|
||||
.transaction("workspaces", "readonly")
|
||||
.objectStore("workspaces");
|
||||
|
||||
workspaces.get(value).onsuccess = (e) => {
|
||||
console.debug("[workspace.populate] Loading workspace");
|
||||
|
||||
const res = e.target.result;
|
||||
const {workspace} = res;
|
||||
importWorkspaceState(workspace);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates Workspace selection list
|
||||
*/
|
||||
const listWorkspaces = async (value = undefined) => {
|
||||
const options = [{name: "Default", value: "default"}];
|
||||
|
||||
const workspaces = db
|
||||
.transaction("workspaces", "readonly")
|
||||
.objectStore("workspaces");
|
||||
|
||||
workspaces.openCursor().onsuccess = (e) => {
|
||||
/** @type {IDBCursor} */
|
||||
const c = e.target.result;
|
||||
if (c) {
|
||||
options.push({name: c.value.name, value: c.key});
|
||||
c.continue();
|
||||
} else {
|
||||
const previousValue = workspaceAutocomplete.value;
|
||||
|
||||
workspaceAutocomplete.options = options;
|
||||
workspaceAutocomplete.value = value ?? previousValue;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const saveWorkspaceToDB = async (value) => {
|
||||
const workspace = await exportWorkspaceState();
|
||||
|
||||
const workspaces = db
|
||||
.transaction("workspaces", "readwrite")
|
||||
.objectStore("workspaces");
|
||||
|
||||
let id = value;
|
||||
if (value === "default" && commands._history.length > 0) {
|
||||
// If Workspace is the Default
|
||||
const name = (prompt("Please enter the workspace name") ?? "").trim();
|
||||
|
||||
if (name) {
|
||||
id = guid();
|
||||
workspaces.add({id, name, workspace}).onsuccess = () => {
|
||||
listWorkspaces(id);
|
||||
alert(`Workspace saved as '${name}'`);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
workspaces.get(id).onsuccess = (e) => {
|
||||
const ws = e.target.result;
|
||||
if (ws) {
|
||||
workspaces.put({id, workspace}).onsuccess = () => {
|
||||
alert(`Workspace saved as '${ws.value.name}'`);
|
||||
listWorkspaces();
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Normal Workspace Export/Import
|
||||
exportWorkspaceBtn.addEventListener("click", () => saveWorkspaceToFile());
|
||||
importWorkspaceBtn.addEventListener("click", () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "application/json";
|
||||
input.addEventListener("change", async (evn) => {
|
||||
let files = Array.from(input.files);
|
||||
const json = await files[0].text();
|
||||
|
||||
await importWorkspaceState(JSON.parse(json));
|
||||
saveWorkspaceToDB("default");
|
||||
});
|
||||
input.click();
|
||||
});
|
||||
|
||||
const onDatabaseLoad = async () => {
|
||||
// Get workspaces from database
|
||||
listWorkspaces();
|
||||
|
||||
// Save Workspace Button
|
||||
saveWorkspaceBtn.addEventListener(
|
||||
"click",
|
||||
saveWorkspaceToDB(workspaceAutocomplete.value)
|
||||
);
|
||||
|
||||
// Rename Workspace
|
||||
renameWorkspaceBtn.addEventListener("click", () => {
|
||||
const workspaces = db
|
||||
.transaction("workspaces", "readwrite")
|
||||
.objectStore("workspaces");
|
||||
|
||||
let id = workspaceAutocomplete.value;
|
||||
|
||||
workspaces.get(id).onsuccess = (e) => {
|
||||
const workspace = e.target.result;
|
||||
const name = prompt(
|
||||
`Please enter the new workspace name.\nOriginal is '${workspace.name}'`
|
||||
).trim();
|
||||
|
||||
if (!name) return;
|
||||
|
||||
workspace.name = name;
|
||||
|
||||
workspaces.put(workspace).onsuccess = () => {
|
||||
listWorkspaces();
|
||||
};
|
||||
};
|
||||
});
|
||||
// Delete Workspace
|
||||
deleteWorkspaceBtn.addEventListener("click", () => {
|
||||
const workspaces = db
|
||||
.transaction("workspaces", "readwrite")
|
||||
.objectStore("workspaces");
|
||||
|
||||
let id = workspaceAutocomplete.value;
|
||||
|
||||
workspaces.get(id).onsuccess = (e) => {
|
||||
const workspace = e.target.result;
|
||||
|
||||
if (
|
||||
confirm(
|
||||
`Do you really want to delete the workspace '${workspace.name}'?`
|
||||
)
|
||||
) {
|
||||
workspaces.delete(id).onsuccess = (e) => {
|
||||
listWorkspaces("default");
|
||||
};
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
if (db) onDatabaseLoad();
|
||||
else ondatabaseload.on(onDatabaseLoad);
|
||||
})();
|
|
@ -57,6 +57,21 @@ const commands = makeReadOnly(
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears the history
|
||||
*/
|
||||
async clear() {
|
||||
await this.undo(this._history.length);
|
||||
|
||||
this._history.splice(0, this._history.length);
|
||||
|
||||
_commands_events.emit({
|
||||
action: "clear",
|
||||
state: {},
|
||||
current: commands._current,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Imports an exported command and runs it
|
||||
*
|
||||
|
|
36
js/lib/db.js
Normal file
36
js/lib/db.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
const idb = window.indexedDB.open("openoutpaint", 2);
|
||||
|
||||
idb.onerror = (e) => {
|
||||
console.warn("[stamp] Failed to connect to IndexedDB");
|
||||
console.warn(e);
|
||||
};
|
||||
|
||||
idb.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
|
||||
console.debug(`[stamp] Setting up database version ${db.version}`);
|
||||
|
||||
if (e.oldVersion < 1) {
|
||||
// Resources Store
|
||||
const resourcesStore = db.createObjectStore("resources", {
|
||||
keyPath: "id",
|
||||
});
|
||||
resourcesStore.createIndex("name", "name", {unique: false});
|
||||
}
|
||||
|
||||
// Workspaces Store
|
||||
const workspacesStore = db.createObjectStore("workspaces", {
|
||||
keyPath: "id",
|
||||
});
|
||||
workspacesStore.createIndex("name", "name", {unique: false});
|
||||
};
|
||||
|
||||
/** @type {IDBDatabase} */
|
||||
let db = null;
|
||||
/** @type {Observer<{db: IDBDatabase}>} */
|
||||
const ondatabaseload = new Observer();
|
||||
|
||||
idb.onsuccess = (e) => {
|
||||
db = e.target.result;
|
||||
ondatabaseload.emit({db});
|
||||
};
|
|
@ -209,7 +209,6 @@ function createSlider(name, wrapper, options = {}) {
|
|||
* @param {{name: string, value: string, optionelcb: (el: HTMLOptionElement) => void}[]} options.options Options to add to the selector
|
||||
* @param {object} extraEl Additional element to include in wrapper div (e.g. model refresh button)
|
||||
* @param {string} extraClass Additional class to attach to the autocomplete input element
|
||||
* @returns {AutoCompleteElement}
|
||||
*/
|
||||
function createAutoComplete(
|
||||
name,
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
};
|
||||
|
||||
_commands_events.on((message) => {
|
||||
if (message.action === "run") {
|
||||
if (message.action === "run" || message.action === "clear") {
|
||||
Array.from(historyView.children).forEach((child) => {
|
||||
if (
|
||||
!commands._history.find((entry) => `hist-${entry.id}` === child.id)
|
||||
|
|
|
@ -146,13 +146,9 @@ const stampTool = () =>
|
|||
};
|
||||
|
||||
// Open IndexedDB connection
|
||||
const IDBOpenRequest = window.indexedDB.open("stamp", 1);
|
||||
|
||||
// Synchronizes resources array with the DOM and Local Storage
|
||||
const syncResources = () => {
|
||||
// Saves to IndexedDB
|
||||
/** @type {IDBDatabase} */
|
||||
const db = state.stampDB;
|
||||
const resources = db
|
||||
.transaction("resources", "readwrite")
|
||||
.objectStore("resources");
|
||||
|
@ -590,35 +586,9 @@ const stampTool = () =>
|
|||
state.ctxmenu.resourceList = resourceList;
|
||||
|
||||
// Performs resource fetch from IndexedDB
|
||||
|
||||
IDBOpenRequest.onerror = (e) => {
|
||||
console.warn("[stamp] Failed to connect to IndexedDB");
|
||||
console.warn(e);
|
||||
};
|
||||
|
||||
IDBOpenRequest.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
|
||||
console.debug(`[stamp] Setting up database version ${db.version}`);
|
||||
|
||||
const resourcesStore = db.createObjectStore("resources", {
|
||||
keyPath: "id",
|
||||
});
|
||||
resourcesStore.createIndex("name", "name", {unique: false});
|
||||
};
|
||||
|
||||
IDBOpenRequest.onsuccess = async (e) => {
|
||||
const loadResources = async () => {
|
||||
console.debug("[stamp] Connected to IndexedDB");
|
||||
|
||||
state.stampDB = e.target.result;
|
||||
|
||||
state.stampDB.onerror = (evn) => {
|
||||
console.warn(`[stamp] Database Error:`);
|
||||
console.warn(evn.target.errorCode);
|
||||
};
|
||||
|
||||
/** @type {IDBDatabase} */
|
||||
const db = state.stampDB;
|
||||
/** @type {IDBRequest<{id: string, name: string, src: string}[]>} */
|
||||
const FetchAllTransaction = db
|
||||
.transaction("resources")
|
||||
|
@ -648,6 +618,9 @@ const stampTool = () =>
|
|||
syncResources();
|
||||
};
|
||||
};
|
||||
|
||||
if (db) loadResources();
|
||||
else ondatabaseload.on(loadResources);
|
||||
}
|
||||
},
|
||||
populateContextMenu: (menu, state) => {
|
||||
|
|
6
res/icons/more-horizontal.svg
Normal file
6
res/icons/more-horizontal.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="19" cy="12" r="1"></circle>
|
||||
<circle cx="5" cy="12" r="1"></circle>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 314 B |
5
res/icons/pencil.svg
Normal file
5
res/icons/pencil.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="2" x2="22" y2="6"></line>
|
||||
<path d="M7.5 20.5 19 9l-4-4L3.5 16.5 2 22z"></path>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 290 B |
6
res/icons/save.svg
Normal file
6
res/icons/save.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||||
<polyline points="7 3 7 8 15 8"></polyline>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 374 B |
6
res/icons/upload.svg
Normal file
6
res/icons/upload.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 345 B |
Loading…
Reference in a new issue