diff --git a/README.md b/README.md index 16161db..b3d78a4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,25 @@ -# imgsort2 +# Imgsort 2 +Cross platform, fast and efficient manual file sorting with keybinds. -Self-hosted image and file sorting application \ No newline at end of file +## Description +**Imgsort 2** is web app that lets a client sort files from a "staging" directory on the server into other directories on the server. +It is mainly intended for sorting media that was auto-uploaded from a phone into a self-hosted cloud, like Nextcloud. + +If you are looking for a simple program the lets you the same thing locally, have a look at [imgsort](https://github.com/MatthiasQuintern/imgsort). + +### Web Interface +The web can show images, video and audio. +The user can configure several directories into which the files can be sorted through the web interface. +Each directory has a keybind and a button which, when either is pressed, moves the image into the according directory. + +The web interface has mobile optimizations, letting you sort images/files on the go. + + +## Installation +You need a *php* enabled web server, for example *nginx* with *php-fpm*. +The web server needs to serve the app as well as the directory containg all images, called `rootDir`. +You need to configure the `$rootDir` and `$rootPath` as well as the `$stagingDirName` accordingly in the `imgsort.php` file. + +If you a using nextcloud, make sure to have the `rootDir` directory as an "external" storage media and not in the main nextcloud `data` directory. + +**The app has no authentication system. Only expose it locally or use something like nginx basic auth to restrict access!** diff --git a/src/config.html b/src/config.html new file mode 100644 index 0000000..86a288f --- /dev/null +++ b/src/config.html @@ -0,0 +1,56 @@ + + + + + + Imgsort 2 - Config + + + + +
+
+ +

Add mappings

+ A key must be set for each directory, even if it will not be used.
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Mappings

+ + + + + + + + + + +
KeyDirectory
+
+
+ + + + + + + + diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..3561d45 --- /dev/null +++ b/src/config.js @@ -0,0 +1,84 @@ +/* + * Handle mappings + * Mappings are stored in local storage as a Map (key: directory) + */ + +let statusLine = document.getElementById('status'); + +// these can not be mapped by the user +const reservedMappings = ["u", "s"]; + +/* + * Load mappings, create a table showing them with a "remove" button each + */ +function renderMappings() { + console.log(localStorage.getItem('mappings')); + const mappings = new Map(JSON.parse(localStorage.getItem('mappings')) || []); + console.log(typeof mappings); + const tbody = document.querySelector('#mappingTable tbody'); + tbody.innerHTML = ''; // clear table body + mappings.forEach((directory, key) => { + const row = document.createElement('tr'); + const keyCell = document.createElement('td'); + const directoryCell = document.createElement('td'); + + keyCell.innerHTML = `${key}`; + directoryCell.innerHTML = `${directory}`; + + row.appendChild(keyCell); + row.appendChild(directoryCell); + // remove mapping button + const removeCell = document.createElement('td'); + const removeButton = document.createElement('button'); + removeButton.textContent = 'Remove'; + removeButton.onclick = function() { + removeMapping(key); + }; + removeCell.appendChild(removeButton); + row.appendChild(removeCell) + tbody.appendChild(row); + }); +} + +function removeMapping(key) { + let mappings = new Map(JSON.parse(localStorage.getItem('mappings')) || []); + if (!mappings.has(key)) { + statusLine.innerHTML = `Can not remove mapping '${key}', it does not exist`; + return; + } + statusLine.innerHTML = `Removed mapping '${key}'`; + mappings.delete(key); + localStorage.setItem('mappings', JSON.stringify(Array.from(mappings.entries()))); + renderMappings(); +} + +function addMapping(key, directory) { + if (reservedMappings.includes(key)) { + statusLine.innerHTML = `'${key}' is reserved and can not be mapped`; + return; + } + let mappings = new Map(JSON.parse(localStorage.getItem('mappings')) || []); + statusLine.innerHTML = (mappings.has(key) ? "Changed" : "Added") + ` mapping '${key}' - '${directory}'`; + mappings.set(key, directory); + localStorage.setItem('mappings', JSON.stringify(Array.from(mappings.entries()))); + renderMappings(); +} + +// event listener for add mapping form +document.getElementById('addMappingForm').addEventListener('submit', function(event) { + event.preventDefault(); + const key = document.getElementById('key').value; + const directory = document.getElementById('directory').value; + + addMapping(key, directory); + + // clear input + document.getElementById('key').value = ''; + document.getElementById('directory').value = ''; +}); + +// load existing mappings on page load +window.onload = function() { + renderMappings(); +}; + diff --git a/src/imgsort.php b/src/imgsort.php new file mode 100644 index 0000000..2cb1f9c --- /dev/null +++ b/src/imgsort.php @@ -0,0 +1,136 @@ + diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..07b65e4 --- /dev/null +++ b/src/index.html @@ -0,0 +1,67 @@ + + + + + + Imgsort 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
Imgsort 2
+
+ + +
+
+ +
+
+ + + + +
+
+ Files: +
+
+
+
+ + + + diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..d9dbbf0 --- /dev/null +++ b/src/index.js @@ -0,0 +1,252 @@ +// Handle keyboard input + +const imageExtensions = ['gif','jpg','jpeg','png', 'webp']; +const videoExtensions =['mpg', 'mp2', 'mpeg', 'mpe', 'mpv', 'mp4']; +const audioExtensions =['mp3', 'ogg', 'wav', 'flac']; + + +let statusLine = document.getElementById('status'); + +const mappings = new Map(JSON.parse(localStorage.getItem('mappings')) || []); + +let initialContent = document.getElementById('current-file').innerHTML; + +let history = []; + +// get the path in which the images are located and can be loaded from, by appending to them to the stagingPath +let stagingPath = ""; +async function setStagingPath() { + const response = await fetch("imgsort.php?action=getStagingPath"); + if (response.ok) { + stagingPath = await response.text(); + } + else { + statusLine.innerHTML = `getStagingPath: server returned ${response.status}`; + console.error("Could not get stagingPath from server"); + console.error(response); + } +} + +// list of files to be processed +let fileList = []; +async function setFileList() { + const response = await fetch("imgsort.php?action=getFileList"); + const text = await response.text(); + // console.log(text); + if (response.ok) { + fileList = JSON.parse(text); + // fileList.sort(); + } + else { + statusLine.innerHTML = `getFileList: server returned ${response.status}: '${text}'`; + console.error("Could not get fileList from server"); + console.error(response); + } +} + +/* + * Create button for each mapping + */ +function renderMoveButtons() { + const buttons = document.getElementById('move-buttons'); + buttons.innerHTML = ''; + mappings.forEach((directory, key) => { + const removeButton = document.createElement('button'); + removeButton.innerHTML = `${key} ${directory}`; + removeButton.onclick = function() { + moveFile(directory); + }; + removeButton.class = "move-button"; + buttons.appendChild(removeButton); + }); +} + + +function renderFileList() { + const fileListDiv = document.getElementById('file-list'); + fileListDiv.innerHTML = ''; + fileList.forEach(file => { + const p = document.createElement('p'); + classes = "file-list-item path"; + if (file === currentFile) { + classes += " selected-file-list-item"; + } + p.setAttribute("class", classes) + p.innerHTML = file; + fileListDiv.appendChild(p); + }); +} + + +let currentFile = ""; +let currentFileIdx = null; + +function createMediaFileElement(file, autoplay=false) { + const fileExt = file.split('.').pop(); + if (imageExtensions.includes(fileExt)) { // if image + let img = document.createElement("img"); + img.setAttribute('src', stagingPath + file) + return img; + } else if (videoExtensions.includes(fileExt)) { // if video + let vid = document.createElement("video"); + if (autoplay) vid.setAttribute('autoplay', true); + vid.setAttribute('controls', true); + src = document.createElement("source"); + src.setAttribute("src", stagingPath + file); + src.setAttribute("type", `video/${fileExt}`); + vid.appendChild(src); + return vid; + } else if (audioExtensions.includes(fileExt)) { // if audio + let aud = document.createElement("audio"); + if (autoplay) aud.setAttribute('autoplay', true); + aud.setAttribute('controls', true); + src = document.createElement("source"); + src.setAttribute("src", stagingPath + file); + src.setAttribute("type", `audio/${fileExt}`); + aud.appendChild(src); + return aud; + } else { // if none of the above + let p = document.createElement("p"); + p.innerHTML = 'No preview available'; + return p; + } +} + + +function renderCurrentFile() { + // const currentFileNameDiv = document.getElementById('current-file-name'); + // if (!currentFile) { + // currentFileNameDiv.innerHTML = "No files in staging"; + // } + // else { + // currentFileNameDiv.innerHTML = currentFile; + // } + const currentFileDiv = document.getElementById('current-file'); + currentFileDiv.innerHTML = ""; + let element = createMediaFileElement(currentFile, true); + currentFileDiv.appendChild(element); +} + + +// load media tag of the next file in hidden div, without autoplay +function preloadNextFile() { + const nextFile = fileList[currentFileIdx+1]; + if (!nextFile) { + return + } + const preloadFileDiv = document.getElementById('preload-file'); + preloadFileDiv.innerHTML = ""; + let element = createMediaFileElement(nextFile, false); + preloadFileDiv.appendChild(element); + console.log("Preloaded " + nextFile); +} + + +// set the currentFile variable according to currentFileIdx +function setCurrentFile() { + // if none set and + if (currentFileIdx == null && fileList.length > 0) { + currentFileIdx = 0; + } + // else if (currentFileIdx < fileList.length) { + // } + else if (currentFileIdx >= fileList.length) { + currentFileIdx = fileList.length - 1; + } + // if index is valid + if (typeof(currentFileIdx) == 'number' && currentFileIdx >= 0 && currentFileIdx < fileList.length) { + currentFile = fileList[currentFileIdx]; + } + else { + currentFileIdx = null; + currentFile = ""; + } + renderCurrentFile(); + renderFileList(); +} + + + +async function moveFile(directory) { + if (!currentFile) { + statusLine.innerHTML = "No file to process!"; + return + } + const response = await fetch(`imgsort.php?action=moveFile&file=${escape(currentFile)}&dest=${directory}`); + const text = await response.text(); + // console.log(text); + if (response.ok) { + statusLine.innerHTML = `Moved '${currentFile}' to '${directory}'`; + history.push([currentFile, directory]); + // remove file from list + fileList.splice(currentFileIdx, 1); + // set next file + setCurrentFile(); + preloadNextFile(); + } + else { + statusLine.innerHTML = `moveFile: server returned ${response.status}: '${text}'`; + console.error(`moveFile: server returned ${response.status}: '${text}'`); + } +} + + +async function undo() { + if (!history.length > 0) { + statusLine.innerHTML = "Nothing to undo" + return; + } + const [file, directory] = history.pop(); + const response = await fetch(`imgsort.php?action=undoFile&file=${escape(file)}&dest=${directory}`); + const text = await response.text(); + // console.log(text); + if (response.ok) { + statusLine.innerHTML = `Undo: move '${file}' to '${directory}'`; + await setFileList() + setCurrentFile(); + } + else { + statusLine.innerHTML = `undo: server returned ${response.status}: '${text}'`; + console.error(`undo: server returned ${response.status}: '${text}'`); + } +} + + +function skip() { + currentFileIdx++; + setCurrentFile(); +} + + + +async function initialize() { + await setStagingPath(); + await setFileList(); + renderMoveButtons(); + renderFileList(); + // setCurrentFile(); + preloadNextFile(); +} + +// load existing mappings on page load +window.onload = function() { + initialize(); +}; + +// handle keyboard +document.onkeypress = function(e) { + e = e || window.event; + let keydebug = document.getElementById("keydebug"); + if (keydebug) { + keydebug.innerHTML = e.key; + } + if (e.key == "u") { + undo(); + } + else if (e.key == "s") { + skip(); + } + else if (mappings.has(e.key)) { + moveFile(mappings.get(e.key)); + } +} diff --git a/src/sass/gruvbox_light.sass b/src/sass/gruvbox_light.sass new file mode 100644 index 0000000..9366fe0 --- /dev/null +++ b/src/sass/gruvbox_light.sass @@ -0,0 +1,50 @@ +// ---------------------------------------------------------------------------- +// Gruvbox Light Mode +// https://github.com/morhetz/gruvbox-contrib/blob/master/scss/gruvbox-light.scss +// ---------------------------------------------------------------------------- + +// Light Background +$bg0: #fbf1c7 +$bg0-hard: #f9f5d7 +$bg0-soft: #f2e5bc +$bg1: #ebdbb2 +$bg2: #d5c4a1 +$bg3: #bdae93 +$bg4: #a89984 + +// Light Foreground +$fg0: #282828 +$fg0-hard: #1d2021 +$fg0-soft: #32302f +$fg1: #3c3836 +$fg2: #504945 +$fg3: #665c54 +$fg4: #7c6f64 + +// Light Colors +$dark-red: #cc241d +$dark-green: #98971a +$dark-yellow: #d79921 +$dark-blue: #458588 +$dark-purple: #b16286 +$dark-aqua: #689d6a +$dark-orange: #d65d0e +$dark-gray: #928374 + +$light-red: #9d0006 +$light-green: #79740e +$light-yellow: #b57614 +$light-blue: #076678 +$light-purple: #8f3f71 +$light-aqua: #427b58 +$light-orange: #af3a03 +$light-gray: #7c6f64 + +$alt-red: #fb4934 +$alt-green: #b8bb26 +$alt-yellow: #fabd2f +$alt-blue: #83a598 +$alt-purple: #d3869b +$alt-aqua: #8ec07c +$alt-orange: #f38019 +$alt-gray: #a89984 diff --git a/src/sass/main.sass b/src/sass/main.sass new file mode 100644 index 0000000..8f5d529 --- /dev/null +++ b/src/sass/main.sass @@ -0,0 +1,211 @@ +// this file should be imported into one that has the color definitions +@import "gruvbox_light" +$fg: $fg1 !default +$fg-hl: $fg0 !default +$fg-alt: $fg2 !default +$fg-alt-hl: $fg1 !default +$bg: $bg0-hard !default +$bg-hl: $bg1 !default + +$accent: $light-red !default + +$button-bg: $bg !default +$button-bg-hl: $bg-hl !default +$button-fg: $fg !default +$button-fg-hl: $fg-hl !default +// use variables when they have to be overwriteable by inline style blocks + +$monofont: "UbuntuMono", monospace + +* + font-family: "Ubuntu", Verdana, sans-serif + color: $fg + + +html, body // fill entire screen so that footer is always on bottom + height: 100% + margin: 0 + +body + background-color: $bg + // have main up top and footer at bottom + display: flex + flex-direction: column + min-height: 100% + +nav + margin-top: 0.3rem + flex-shrink: 0 + +main + flex-grow: 1 + * + margin-left: auto + margin-right: auto + text-align: center + + +footer + width: 100% + padding: 0.2rem 1rem + background: $fg + color: $bg + * + color: $bg +.statusline + font-family: $monofont + text-align: left + * + font-family: $monofont + + +a + color: $fg-hl + +.path + font-family: $monofont + +.key + padding: 3px 6px + background: $fg1 + color: $bg0 + border-style: solid + border-width: 1px + border-color: $fg0-hard + border-radius: 4px + +// p +// text-align: justify + +code + color: $fg1 + font-family: $monofont + text-align: left + +hr + color: $accent + +h1, h2 + text-align: center + filter: drop-shadow(0.04em 0.04em 0 $accent) + padding: 0px 20px + +button, input + color: $button-fg + background-color: $button-bg + font-size: 1rem + padding: 0.5rem + border-radius: 0 + filter: drop-shadow(0.1rem 0.1rem 0 $accent) + font-family: $monofont + margin: 0.2rem + .move-buttons + margin: 0 +button:hover + color: $button-fg-hl + background-color: $button-bg-hl + +.add-mapping-form + margin: auto + width: 400px + .form-row + text-align: left + * + text-align: left + label + display: inline-block + width: 100px + text-align: right + input + margin-left: 0 + text-align: left + button + margin-left: 0 + // width: 50% + + + + +#start-button + filter: drop-shadow(0.1rem 0.1rem 0 $light-green) +#configure-button + filter: drop-shadow(0.1rem 0.1rem 0 $light-blue) +#undo-button + filter: drop-shadow(0.1rem 0.1rem 0 $light-gray) +#skip-button + filter: drop-shadow(0.1rem 0.1rem 0 $light-gray) +#key + width: 20px + // #directory + +.file-list + width: 90% + margin: auto + margin-top: 1rem +.file-list-content + overflow: hidden + border-color: $dark-blue + border-style: solid + border-width: 2px + min-height: 0.2rem + p + margin: 0 + padding: 0.1rem + text-align: left +.file-list-item:nth-child(even) + background: $bg1 +.file-list-item:nth-child(odd) + background: $bg2 + +.selected-file-list-item + background: $dark-blue !important + color: $bg1 + + +.main-box + background: $bg1 +.current-file + width: 100% + height: 300px + position: relative + overflow: scroll + + .imgsort2 + font-size: 3rem + line-height: 200px + filter: drop-shadow(0.10rem 0.10rem 0 $accent) + + img, video, audio + max-width: 100% + max-height: 100% + width: auto + height: auto + position: absolute + top: 0 + bottom: 0 + left: 0 + right: 0 + margin: auto + + + +// Desktop optimierung +@media only screen and (min-width: 1000px) + main + display: flex + flex-direction: row + .main-box + width: 75% + .imgsort2 + font-size: 5rem + line-height: 300px + filter: drop-shadow(0.20rem 0.20rem 0 $accent) + + .second-box + display: flex + flex-direction: column + width: 25% + + .current-file + height: 100% + width: 100% diff --git a/src/sass/table.sass b/src/sass/table.sass new file mode 100644 index 0000000..856734e --- /dev/null +++ b/src/sass/table.sass @@ -0,0 +1,33 @@ +// Tabelle für Praktikumsseite +$table-border-fg: $box-border-fg !default +.midtable + overflow-x: auto + table + border-collapse: collapse + width: 75% + margin: auto + + caption + font-size: $table-font-size + padding: 10px + + th, td, tr + padding: 8px + border: 2px solid $table-border-fg + text-align: left + font-size: $table-font-size + margin: auto + + td * + vertical-align: middle + + .flag + height: 1.3em + top: 0 + + tr:nth-child(even) + background: rgba(255, 255, 255, 0.05) + +@media only screen and (max-width: $max-width-medium) + .midtable table + width: 100% diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..78947e6 --- /dev/null +++ b/src/style.css @@ -0,0 +1,229 @@ +* { + font-family: "Ubuntu", Verdana, sans-serif; + color: #3c3836; +} + +html, body { + height: 100%; + margin: 0; +} + +body { + background-color: #f9f5d7; + display: flex; + flex-direction: column; + min-height: 100%; +} + +nav { + margin-top: 0.3rem; + flex-shrink: 0; +} + +main { + flex-grow: 1; +} +main * { + margin-left: auto; + margin-right: auto; + text-align: center; +} + +footer { + width: 100%; + padding: 0.2rem 1rem; + background: #3c3836; + color: #f9f5d7; +} +footer * { + color: #f9f5d7; +} + +.statusline { + font-family: "UbuntuMono", monospace; + text-align: left; +} +.statusline * { + font-family: "UbuntuMono", monospace; +} + +a { + color: #282828; +} + +.path { + font-family: "UbuntuMono", monospace; +} + +.key { + padding: 3px 6px; + background: #3c3836; + color: #fbf1c7; + border-style: solid; + border-width: 1px; + border-color: #1d2021; + border-radius: 4px; +} + +code { + color: #3c3836; + font-family: "UbuntuMono", monospace; + text-align: left; +} + +hr { + color: #9d0006; +} + +h1, h2 { + text-align: center; + filter: drop-shadow(0.04em 0.04em 0 #9d0006); + padding: 0px 20px; +} + +button, input { + color: #3c3836; + background-color: #f9f5d7; + font-size: 1rem; + padding: 0.5rem; + border-radius: 0; + filter: drop-shadow(0.1rem 0.1rem 0 #9d0006); + font-family: "UbuntuMono", monospace; + margin: 0.2rem; +} +button .move-buttons, input .move-buttons { + margin: 0; +} + +button:hover { + color: #282828; + background-color: #ebdbb2; +} + +.add-mapping-form { + margin: auto; + width: 400px; +} +.add-mapping-form .form-row { + text-align: left; +} +.add-mapping-form .form-row * { + text-align: left; +} +.add-mapping-form .form-row label { + display: inline-block; + width: 100px; + text-align: right; +} +.add-mapping-form .form-row input { + margin-left: 0; + text-align: left; +} +.add-mapping-form .form-row button { + margin-left: 0; +} + +#start-button { + filter: drop-shadow(0.1rem 0.1rem 0 #79740e); +} + +#configure-button { + filter: drop-shadow(0.1rem 0.1rem 0 #076678); +} + +#undo-button { + filter: drop-shadow(0.1rem 0.1rem 0 #7c6f64); +} + +#skip-button { + filter: drop-shadow(0.1rem 0.1rem 0 #7c6f64); +} + +#key { + width: 20px; +} + +.file-list { + width: 90%; + margin: auto; + margin-top: 1rem; +} + +.file-list-content { + overflow: hidden; + border-color: #458588; + border-style: solid; + border-width: 2px; + min-height: 0.2rem; +} +.file-list-content p { + margin: 0; + padding: 0.1rem; + text-align: left; +} + +.file-list-item:nth-child(even) { + background: #ebdbb2; +} + +.file-list-item:nth-child(odd) { + background: #d5c4a1; +} + +.selected-file-list-item { + background: #458588 !important; + color: #ebdbb2; +} + +.main-box { + background: #ebdbb2; +} + +.current-file { + width: 100%; + height: 300px; + position: relative; +} +.current-file .imgsort2 { + font-size: 3rem; + line-height: 200px; + filter: drop-shadow(0.1rem 0.1rem 0 #9d0006); +} +.current-file img, .current-file video, .current-file audio { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: auto; +} + +@media only screen and (min-width: 1000px) { + main { + display: flex; + flex-direction: row; + } + .main-box { + width: 75%; + } + .imgsort2 { + font-size: 5rem; + line-height: 300px; + filter: drop-shadow(0.2rem 0.2rem 0 #9d0006); + } + .second-box { + display: flex; + flex-direction: column; + width: 25%; + } + .current-file { + height: 100%; + width: 100%; + } +} + +/*# sourceMappingURL=style.css.map */