443 lines
10 KiB
Svelte
443 lines
10 KiB
Svelte
<script>
|
|
import { formatDataVolume, formatDate } from "../util/Formatting.svelte";
|
|
|
|
// Main elements
|
|
let directoryArea
|
|
let nodeContainer
|
|
let statusBar = "Loading..."
|
|
|
|
// Internal state, contains a list of all files in the directory, visible
|
|
// files in the directory and the last scroll position. These are used for
|
|
// rendering the file list correctly
|
|
|
|
// type: {icon, name, href, type, size, sizeLabel, dateCreated, selected}
|
|
let allFiles = []
|
|
|
|
export const reset = () => {
|
|
allFiles = []
|
|
}
|
|
|
|
export const addFile = (id, icon, name, href, type, size, sizeLabel, dateCreated) => {
|
|
allFiles.push({
|
|
id: id,
|
|
icon: icon,
|
|
name: name,
|
|
href: href,
|
|
type: type,
|
|
size: size,
|
|
sizeLabel: sizeLabel,
|
|
dateCreated: dateCreated,
|
|
selected: false,
|
|
filtered: false,
|
|
visible: false,
|
|
})
|
|
}
|
|
|
|
export const renderFiles = () => {
|
|
search(lastSearchTerm)
|
|
}
|
|
|
|
export const getSelectedFiles = () => {
|
|
let selectedFiles = []
|
|
|
|
for (let i in allFiles) {
|
|
if (allFiles[i].selected) {
|
|
selectedFiles.push(allFiles[i])
|
|
}
|
|
}
|
|
|
|
return selectedFiles
|
|
}
|
|
|
|
// search filters the allFiles array on a search term. All files which match the
|
|
// search term will be put into visibleFiles. The visibleFiles array will then
|
|
// be rendered by render_visible_files
|
|
let lastSearchTerm = ""
|
|
export const search = (term) => {
|
|
term = term.toLowerCase()
|
|
lastSearchTerm = term
|
|
|
|
if (term === "") {
|
|
for (let i in allFiles) {
|
|
allFiles[i].filtered = false
|
|
}
|
|
sortBy("")
|
|
render_visible_files()
|
|
return
|
|
}
|
|
|
|
let fileName = ""
|
|
for (let i in allFiles) {
|
|
fileName = allFiles[i].name.toLowerCase()
|
|
|
|
if (fileName.includes(term)) {
|
|
// If a file name contains the search term we include it in the results
|
|
allFiles[i].filtered = false
|
|
} else {
|
|
allFiles[i].filtered = true
|
|
}
|
|
}
|
|
|
|
sortBy("")
|
|
render_visible_files()
|
|
}
|
|
|
|
// searchSubmit opens the first file in the search results
|
|
export const searchSubmit = () => {
|
|
for (let i in allFiles) {
|
|
if (allFiles[i].visible && !allFiles[i].filtered) {
|
|
window.open(allFiles[i].href, "_blank")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sorting internal state. By default we sort by dateCreated in descending
|
|
// order (new to old)
|
|
let currentSortField = "dateCreated"
|
|
let currentSortAscending = false
|
|
let tableColumns = [
|
|
{ name: "Name", field: "name", width: "" },
|
|
{ name: "Creation date", field: "dateCreated", width: "160px" },
|
|
{ name: "Size", field: "size", width: "90px" },
|
|
{ name: "Type", field: "type", width: "200px" },
|
|
]
|
|
const sortBy = (field) => {
|
|
if (field === "") {
|
|
// If no sort field is provided we use the last used sort field
|
|
field = currentSortField
|
|
} else {
|
|
// If a sort field is provided we check in which direction we have to
|
|
// sort
|
|
if (currentSortField !== field) {
|
|
// If this field is a different field than before we sort it in
|
|
// ascending order
|
|
currentSortAscending = true
|
|
currentSortField = field
|
|
} else if (currentSortField === field) {
|
|
// If it is the same field as before we reverse the sort order
|
|
currentSortAscending = !currentSortAscending
|
|
}
|
|
}
|
|
|
|
// Add the arrow to the sort label. First remove the arrow from all sort
|
|
// labels
|
|
let colIdx = 0
|
|
|
|
for (let i in tableColumns) {
|
|
if (tableColumns[i].field == field) {
|
|
colIdx = i
|
|
}
|
|
tableColumns[i].name = tableColumns[i].name.replace("▲ ", "").replace("▼ ", "")
|
|
}
|
|
|
|
// Then prepend the arrow to the current sort label
|
|
if (currentSortAscending) {
|
|
tableColumns[colIdx].name = "▼ " + tableColumns[colIdx].name
|
|
} else {
|
|
tableColumns[colIdx].name = "▲ " + tableColumns[colIdx].name
|
|
}
|
|
|
|
tableColumns = tableColumns
|
|
|
|
let fieldA, fieldB
|
|
allFiles.sort((a, b) => {
|
|
fieldA = a[currentSortField]
|
|
fieldB = b[currentSortField]
|
|
|
|
if (typeof (fieldA) === "number") {
|
|
if (currentSortAscending) {
|
|
return fieldA - fieldB
|
|
} else {
|
|
return fieldB - fieldA
|
|
}
|
|
} else {
|
|
if (currentSortAscending) {
|
|
return fieldA.localeCompare(fieldB, undefined, {numeric: true})
|
|
} else {
|
|
return fieldB.localeCompare(fieldA, undefined, {numeric: true})
|
|
}
|
|
}
|
|
})
|
|
|
|
render_visible_files()
|
|
}
|
|
|
|
// Scroll event for rendering new file nodes when they become visible. For
|
|
// performance reasons the files will only be rendered once every 100ms. If a
|
|
// scroll event comes in and we're not done with the previous frame yet the
|
|
// event will be ignored
|
|
let render_timeout = false;
|
|
const onScroll = (e) => {
|
|
if (render_timeout) {
|
|
return
|
|
}
|
|
|
|
render_timeout = true
|
|
setTimeout(() => {
|
|
render_visible_files()
|
|
render_timeout = false
|
|
}, 100)
|
|
}
|
|
|
|
const render_visible_files = () => {
|
|
const fileHeight = 40
|
|
|
|
let paddingTop = directoryArea.scrollTop - directoryArea.scrollTop % fileHeight
|
|
let start = Math.floor(paddingTop / fileHeight) - 5
|
|
if (start < 0) { start = 0 }
|
|
|
|
let end = Math.ceil((paddingTop + directoryArea.clientHeight) / fileHeight) + 5
|
|
if (end > allFiles.length) { end = allFiles.length - 1 }
|
|
|
|
nodeContainer.style.paddingTop = (start * fileHeight) + "px"
|
|
|
|
// All files which have not been filtered out by the search function. We
|
|
// pretend that files with filtered == true do not exist
|
|
let totalFiles = 0
|
|
let totalSize = 0
|
|
let selectedFiles = 0
|
|
let selectedSize = 0
|
|
|
|
for (let i in allFiles) {
|
|
if (totalFiles >= start && totalFiles <= end && !allFiles[i].filtered) {
|
|
allFiles[i].visible = true
|
|
} else {
|
|
allFiles[i].visible = false
|
|
}
|
|
if (!allFiles[i].filtered) {
|
|
totalFiles++
|
|
totalSize += allFiles[i].size
|
|
|
|
if (allFiles[i].selected) {
|
|
selectedFiles++
|
|
selectedSize += allFiles[i].size
|
|
}
|
|
}
|
|
}
|
|
|
|
nodeContainer.style.height = (totalFiles * fileHeight) + "px"
|
|
statusBar = totalFiles + " items ("+formatDataVolume(totalSize, 4)+")"
|
|
|
|
if (selectedFiles !== 0) {
|
|
statusBar += ", "+selectedFiles+" selected ("+formatDataVolume(selectedSize, 4)+")"
|
|
}
|
|
}
|
|
|
|
let selectionMode = false
|
|
export const setSelectionMode = (s) => {
|
|
selectionMode = s
|
|
|
|
// When selection mode is disabled we automatically deselect all files
|
|
if (!s) {
|
|
for (let i in allFiles) {
|
|
allFiles[i].selected = false
|
|
}
|
|
render_visible_files()
|
|
}
|
|
}
|
|
|
|
let shift_pressed = false
|
|
const detect_shift = (e) => {
|
|
if (e.key !== "Shift") {
|
|
return
|
|
}
|
|
|
|
shift_pressed = e.type === "keydown"
|
|
}
|
|
|
|
export let multi_select = true
|
|
let last_selected_node = -1
|
|
const node_click = (index) => {
|
|
if (selectionMode) {
|
|
if (multi_select && shift_pressed && last_selected_node != -1) {
|
|
let id_low = last_selected_node
|
|
let id_high = last_selected_node
|
|
if (last_selected_node < index) {
|
|
id_high = index
|
|
} else {
|
|
id_low = index
|
|
}
|
|
|
|
for (let i = id_low; i <= id_high && !allFiles[i].filtered; i++) {
|
|
if (i != last_selected_node) {
|
|
allFiles[i].selected = !allFiles[i].selected
|
|
}
|
|
}
|
|
} else {
|
|
// If multi select is disabled we deselect all other files before
|
|
// selecting this one
|
|
if (!multi_select) {
|
|
for (let i in allFiles) {
|
|
allFiles[i].selected = false
|
|
}
|
|
}
|
|
|
|
allFiles[index].selected = !allFiles[index].selected
|
|
}
|
|
|
|
last_selected_node = index
|
|
render_visible_files()
|
|
} else {
|
|
window.open(allFiles[index].href, "_blank")
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:window on:keydown={detect_shift} on:keyup={detect_shift} />
|
|
|
|
<div id="directory_element">
|
|
<div class="directory_sorters">
|
|
{#each tableColumns as col}
|
|
<button style="min-width: {col.width}" on:click={sortBy(col.field)} class="sorter_button">
|
|
{col.name}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
<div bind:this={directoryArea} on:scroll={onScroll} id="directory_area" class="directory_area">
|
|
<div bind:this={nodeContainer} id="node_container" class="directory_node_container">
|
|
{#each allFiles as file, index}
|
|
{#if file.visible && !file.filtered}
|
|
<a class="node"
|
|
href={file.href}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title="{file.name}"
|
|
class:node_selected={file.selected}
|
|
on:click|preventDefault={() => {node_click(index)}}
|
|
>
|
|
<div>
|
|
<img src={file.icon} alt="thumbnail" />
|
|
<span>{file.name}</span>
|
|
</div>
|
|
<div style="width: {tableColumns[1].width}">
|
|
<span>{formatDate(new Date(file.dateCreated), true, true, false)}</span>
|
|
</div>
|
|
<div style="width: {tableColumns[2].width}">
|
|
<span>{file.sizeLabel}</span>
|
|
</div>
|
|
<div style="width: {tableColumns[3].width}">
|
|
<span>{file.type}</span>
|
|
</div>
|
|
</a>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<div id="footer">
|
|
{statusBar}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
#directory_element {
|
|
flex: 1 1 auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: auto;
|
|
text-align: left;
|
|
}
|
|
|
|
.directory_sorters {
|
|
flex: 0 0 auto;
|
|
display: flex;
|
|
flex-direction: row;
|
|
overflow: hidden;
|
|
background: var(--body_background);
|
|
min-width: 850px;
|
|
border-top-left-radius: 8px;
|
|
border-top-right-radius: 8px;
|
|
border-bottom: 1px solid var(--separator);
|
|
}
|
|
.sorter_button {
|
|
display: inline-block;
|
|
margin: 4px 10px;
|
|
text-align: initial;
|
|
background: none;
|
|
box-shadow: none;
|
|
}
|
|
.sorter_button:hover {
|
|
background: var(--input_hover_background);
|
|
}
|
|
|
|
.directory_sorters > :first-child,
|
|
.node > :first-child {
|
|
flex-shrink: 1;
|
|
flex-grow: 1;
|
|
}
|
|
.directory_sorters > :not(:first-child),
|
|
.node > :not(:first-child) {
|
|
flex-shrink: 0;
|
|
flex-grow: 0;
|
|
}
|
|
|
|
#directory_area {
|
|
flex: 1 1 auto;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow-x: auto;
|
|
background: var(--body_background);
|
|
border-bottom-left-radius: 8px;
|
|
border-bottom-right-radius: 8px;
|
|
}
|
|
#node_container {
|
|
display: block;
|
|
min-width: 850px;
|
|
}
|
|
|
|
#footer {
|
|
flex-shrink: 0;
|
|
color: var(--background_text_color);
|
|
padding: 4px;
|
|
}
|
|
|
|
.node {
|
|
display: flex;
|
|
flex-direction: row;
|
|
|
|
position: static;
|
|
height: 40px;
|
|
overflow: hidden;
|
|
|
|
/* I use padding instead of margin here because it goves me more precise
|
|
control over the size.
|
|
Check out https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing */
|
|
margin: 0;
|
|
color: var(--body_text_color);
|
|
text-decoration: none;
|
|
transition: background 0.2s;
|
|
}
|
|
.node:hover:not(.node_selected) {
|
|
background: var(--input_background);
|
|
color: var(--input_text);
|
|
text-decoration: none;
|
|
}
|
|
.node_selected {
|
|
background: var(--highlight_background);
|
|
color: var(--highlight_text_color);
|
|
}
|
|
.node > div {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
margin: auto 10px;
|
|
padding: 4px;
|
|
display: inline-block;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.node > div > span {
|
|
margin: auto;
|
|
display: block;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.node > div > img {
|
|
max-height: 100%;
|
|
margin-right: 6px;
|
|
width: auto;
|
|
min-width: auto;
|
|
float: left;
|
|
display: block;
|
|
}
|
|
</style>
|