Major changes:
- File selections working - CSS more responsive, more consistent use of colors and variables - Keyboard navigation - Added context menu buttons and handler, the menu is still missing - Added download and settings buttons (no functions yet) - Various minor fixes everywhere
This commit is contained in:
		| @@ -92,10 +92,10 @@ const props = withDefaults( | ||||
| .breadcrumb a:nth-child(even) { | ||||
|   background: var(--breadcrumb-background-even); | ||||
| } | ||||
| .breadcrumb a:nth-child(odd):hover { | ||||
| .breadcrumb a:nth-child(odd):hover, .breadcrumb a:focus:nth-child(odd) { | ||||
|   background: var(--breadcrumb-hover-background-odd); | ||||
| } | ||||
| .breadcrumb a:nth-child(even):hover { | ||||
| .breadcrumb a:nth-child(even):hover, .breadcrumb a:focus:nth-child(even) { | ||||
|   background: var(--breadcrumb-hover-background-even); | ||||
| } | ||||
| .breadcrumb a:hover { | ||||
|   | ||||
| @@ -1,96 +1,104 @@ | ||||
| <template> | ||||
|   <main> | ||||
|     <table v-if="props.documents.length || editing"> | ||||
|       <thead> | ||||
|         <tr> | ||||
|           <th class="selection"> | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               v-model="allSelected" | ||||
|               :indeterminate="selectionIndeterminate" | ||||
|             /> | ||||
|           </th> | ||||
|           <th | ||||
|             class="sortcolumn" | ||||
|             :class="{ sortactive: sort === 'name' }" | ||||
|             @click="toggleSort('name')" | ||||
|           > | ||||
|             Name | ||||
|           </th> | ||||
|           <th | ||||
|             class="sortcolumn modified right" | ||||
|             :class="{ sortactive: sort === 'modified' }" | ||||
|             @click="toggleSort('modified')" | ||||
|           > | ||||
|             Modified | ||||
|           </th> | ||||
|           <th | ||||
|             class="sortcolumn size right" | ||||
|             :class="{ sortactive: sort === 'size' }" | ||||
|             @click="toggleSort('size')" | ||||
|           > | ||||
|             Size | ||||
|           </th> | ||||
|         </tr> | ||||
|       </thead> | ||||
|       <tbody> | ||||
|         <tr v-if="editing?.key === 'new'" class="folder"> | ||||
|           <td class="selection"></td> | ||||
|           <td class="name"> | ||||
|             <FileRenameInput | ||||
|               :doc="editing" | ||||
|               :rename="mkdir" | ||||
|   <table v-if="props.documents.length || editing" @blur="cursor = null"> | ||||
|     <thead> | ||||
|       <tr> | ||||
|         <th class="selection"> | ||||
|           <input | ||||
|             type="checkbox" | ||||
|             tabindex="-1" | ||||
|             v-model="allSelected" | ||||
|             :indeterminate="selectionIndeterminate" | ||||
|           /> | ||||
|         </th> | ||||
|         <th | ||||
|           class="sortcolumn" | ||||
|           :class="{ sortactive: sort === 'name' }" | ||||
|           @click="toggleSort('name')" | ||||
|         > | ||||
|           Name | ||||
|         </th> | ||||
|         <th | ||||
|           class="sortcolumn modified right" | ||||
|           :class="{ sortactive: sort === 'modified' }" | ||||
|           @click="toggleSort('modified')" | ||||
|         > | ||||
|           Modified | ||||
|         </th> | ||||
|         <th | ||||
|           class="sortcolumn modified right" | ||||
|           :class="{ sortactive: sort === 'size' }" | ||||
|           @click="toggleSort('size')" | ||||
|         > | ||||
|           Size | ||||
|         </th> | ||||
|         <th class="menu"></th> | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <tr v-if="editing?.key === 'new'" class="folder"> | ||||
|         <td class="selection"></td> | ||||
|         <td class="name"> | ||||
|           <FileRenameInput | ||||
|             :doc="editing" | ||||
|             :rename="mkdir" | ||||
|             :exit=" | ||||
|               () => { | ||||
|                 editing = null | ||||
|               } | ||||
|             " | ||||
|           /> | ||||
|         </td> | ||||
|         <td class="modified right">{{ editing.modified }}</td> | ||||
|         <td class="size right">{{ editing.sizedisp }}</td> | ||||
|         <td class="menu"></td> | ||||
|       </tr> | ||||
|       <tr | ||||
|         v-for="doc of sorted(props.documents as FolderDocument[])" | ||||
|         :key="doc.key" | ||||
|         :id="`file-${doc.key}`" | ||||
|         :class="{ | ||||
|           file: doc.type === 'file', | ||||
|           folder: doc.type === 'folder', | ||||
|           cursor: cursor === doc | ||||
|         }" | ||||
|         @click="cursor = cursor === doc ? null : doc" | ||||
|         @contextmenu.prevent="contextMenu($event, doc)" | ||||
|       > | ||||
|         <td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null"> | ||||
|           <input | ||||
|             type="checkbox" | ||||
|             tabindex="-1" | ||||
|             :checked="documentStore.selected.has(doc.key)" | ||||
|             @change="($event.target as HTMLInputElement).checked ? documentStore.selected.add(doc.key) : documentStore.selected.delete(doc.key)" | ||||
|           /> | ||||
|         </td> | ||||
|         <td class="name"> | ||||
|           <template v-if="editing === doc" | ||||
|             ><FileRenameInput | ||||
|               :doc="doc" | ||||
|               :rename="rename" | ||||
|               :exit=" | ||||
|                 () => { | ||||
|                   editing = null | ||||
|                 } | ||||
|               " | ||||
|             /> | ||||
|           </td> | ||||
|           <td class="right">{{ editing.modified }}</td> | ||||
|           <td class="right">{{ editing.sizedisp }}</td> | ||||
|         </tr> | ||||
|         <tr | ||||
|           v-for="doc of sorted(props.documents as FolderDocument[])" | ||||
|           :key="doc.key" | ||||
|           :class="doc.type === 'folder' ? 'folder' : 'file'" | ||||
|         > | ||||
|           <td class="selection"> | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               :checked="doc.key in documentStore.selected" | ||||
|               @change="documentStore.selected.add(doc.key)" | ||||
|             /> | ||||
|           </td> | ||||
|           <td class="name"> | ||||
|             <template v-if="editing === doc" | ||||
|               ><FileRenameInput | ||||
|                 :doc="doc" | ||||
|                 :rename="rename" | ||||
|                 :exit=" | ||||
|                   () => { | ||||
|                     editing = null | ||||
|                   } | ||||
|                 " | ||||
|             /></template> | ||||
|             <template v-else> | ||||
|               <a :href="url_for(doc)">{{ doc.name }}</a> | ||||
|               <button @click="() => (editing = doc)">🖊️</button> | ||||
|             </template> | ||||
|           </td> | ||||
|           <td class="right">{{ doc.modified }}</td> | ||||
|           <td class="right">{{ doc.sizedisp }}</td> | ||||
|         </tr> | ||||
|       </tbody> | ||||
|     </table> | ||||
|     <div v-else> | ||||
|       <p>No files</p> | ||||
|     </div> | ||||
|   </main> | ||||
|           /></template> | ||||
|           <template v-else> | ||||
|             <a :href="url_for(doc)" tabindex="-1" @contextmenu.stop @click.stop @focus.stop="cursor = doc">{{ doc.name }}</a> | ||||
|             <button @click="() => (editing = doc)">🖊️</button> | ||||
|           </template> | ||||
|         </td> | ||||
|         <td class="modified right">{{ doc.modified }}</td> | ||||
|         <td class="size right">{{ doc.sizedisp }}</td> | ||||
|         <td class="menu"><button tabindex="-1" @click.stop="cursor = doc; contextMenu($event, doc)">⋮</button></td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
|   <div v-else class="empty-container">Nothing to see here</div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, computed } from 'vue' | ||||
| import { ref, computed, watchEffect } from 'vue' | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import type { Document, FolderDocument } from '@/repositories/Document' | ||||
| import FileRenameInput from './FileRenameInput.vue' | ||||
| @@ -113,8 +121,9 @@ const linkBasePath = computed(() => { | ||||
| const filesBasePath = computed(() => `/files${linkBasePath.value}`) | ||||
| const url_for = (doc: FolderDocument) => | ||||
|   doc.type === 'folder' | ||||
|     ? `#${linkBasePath.value}/${doc.name}` | ||||
|     ? `#${linkBasePath.value}/${doc.name}/` | ||||
|     : `${filesBasePath.value}/${doc.name}` | ||||
| const cursor = ref<FolderDocument | null>(null) | ||||
| // File rename | ||||
| const editing = ref<FolderDocument | null>(null) | ||||
| const rename = (doc: FolderDocument, newName: string) => { | ||||
| @@ -152,6 +161,46 @@ defineExpose({ | ||||
|       modified: formatUnixDate(now) | ||||
|     } | ||||
|   }, | ||||
|   toggleSelectAll() { | ||||
|     console.log("Select") | ||||
|     allSelected.value = !allSelected.value | ||||
|   }, | ||||
|   isCursor() { | ||||
|     return cursor.value !== null && editing.value === null | ||||
|   }, | ||||
|   cursorRename() { | ||||
|     editing.value = cursor.value | ||||
|   }, | ||||
|   cursorSelect() { | ||||
|     console.log("select", documentStore.selected) | ||||
|     const doc = cursor.value | ||||
|     if (!doc) return | ||||
|     if (documentStore.selected.has(doc.key)) { | ||||
|       documentStore.selected.delete(doc.key) | ||||
|     } else { | ||||
|       documentStore.selected.add(doc.key) | ||||
|     } | ||||
|   }, | ||||
|   cursorMove(d: number) { | ||||
|     // Move cursor up or down (keyboard navigation) | ||||
|     const documents = sorted(props.documents as FolderDocument[]) | ||||
|     if (documents.length === 0) { | ||||
|       cursor.value = null | ||||
|       return | ||||
|     } | ||||
|     const mod = (a: number, b: number) => ((a % b) + b) % b | ||||
|     const index = cursor.value !== null ? documents.indexOf(cursor.value) : -1 | ||||
|     cursor.value = documents[mod(index + d, documents.length + 1)] ?? null | ||||
|     const tr = document.getElementById(`file-${cursor.value.key}`) as HTMLTableRowElement | null | ||||
|     // @ts-ignore | ||||
|     if (tr) tr.scrollIntoView({ block: 'center' }) | ||||
|   } | ||||
| }) | ||||
| watchEffect(() => { | ||||
|   if (cursor.value) { | ||||
|     const a = document.querySelector(`#file-${cursor.value.key} .name a`) as HTMLAnchorElement | null | ||||
|     if (a) a.focus() | ||||
|   } | ||||
| }) | ||||
| const mkdir = (doc: FolderDocument, name: string) => { | ||||
|   const control = createWebSocket('/api/control', (ev: MessageEvent) => { | ||||
| @@ -194,7 +243,7 @@ const selectionIndeterminate = computed({ | ||||
|   get: () => { | ||||
|     return ( | ||||
|       props.documents.length > 0 && | ||||
|       props.documents.some((doc: Document) => doc.key in documentStore.selected) && | ||||
|       props.documents.some((doc: Document) => documentStore.selected.has(doc.key)) && | ||||
|       !allSelected.value | ||||
|     ) | ||||
|   }, | ||||
| @@ -205,10 +254,11 @@ const allSelected = computed({ | ||||
|   get: () => { | ||||
|     return ( | ||||
|       props.documents.length > 0 && | ||||
|       props.documents.every((doc: Document) => doc.key in documentStore.selected) | ||||
|       props.documents.every((doc: Document) => documentStore.selected.has(doc.key)) | ||||
|     ) | ||||
|   }, | ||||
|   set: (value: boolean) => { | ||||
|     console.log("Setting allSelected", value) | ||||
|     for (const doc of props.documents) { | ||||
|       if (value) { | ||||
|         documentStore.selected.add(doc.key) | ||||
| @@ -218,26 +268,39 @@ const allSelected = computed({ | ||||
|     } | ||||
|   } | ||||
| }) | ||||
|  | ||||
| const contextMenu = (ev: Event, doc: Document) => { | ||||
|   console.log('Context menu', ev, doc) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| <style scoped> | ||||
| table { | ||||
|   width: 100%; | ||||
|   table-layout: fixed; | ||||
| } | ||||
| table input[type='checkbox'] { | ||||
|   width: 1em; | ||||
|   height: 1em; | ||||
|   width: 1rem; | ||||
|   height: 1rem; | ||||
| } | ||||
| table .selection { | ||||
|   width: 1rem; | ||||
| } | ||||
| table .modified { | ||||
|   width: 10em; | ||||
|   width: 10rem; | ||||
| } | ||||
| table .size { | ||||
|   width: 6em; | ||||
|   width: 5rem; | ||||
| } | ||||
| table .menu { | ||||
|   width: 1rem; | ||||
| } | ||||
| tbody td { | ||||
|   font-size: 1.2rem; | ||||
| } | ||||
| table th, | ||||
| table td { | ||||
|   padding: 0.5em; | ||||
|   padding: 0 0.5rem; | ||||
|   font-weight: normal; | ||||
|   text-align: left; | ||||
|   white-space: nowrap; | ||||
| @@ -246,28 +309,20 @@ table td { | ||||
| } | ||||
| .name { | ||||
|   white-space: nowrap; | ||||
|   text-overflow: initial; | ||||
|   overflow: initial; | ||||
| } | ||||
| .name button { | ||||
|   visibility: hidden; | ||||
|   padding-left: 1em; | ||||
|   padding-left: 1rem; | ||||
| } | ||||
| .name:hover button { | ||||
|   visibility: visible; | ||||
| } | ||||
| .name button { | ||||
|   cursor: pointer; | ||||
|   border: 0; | ||||
|   background: transparent; | ||||
| } | ||||
| thead tr { | ||||
|   border: 1px solid #ddd; | ||||
|   background: #ddd; | ||||
|   background: linear-gradient(to bottom, #eee, #fff 30%, #ddd); | ||||
|   color: #000; | ||||
| } | ||||
| tbody tr:hover { | ||||
|   background: #00f8; | ||||
| tbody tr.cursor { | ||||
|   background: var(--accent-color); | ||||
| } | ||||
| .right { | ||||
|   text-align: right; | ||||
| @@ -279,7 +334,7 @@ tbody tr:hover { | ||||
|   cursor: pointer; | ||||
| } | ||||
| .sortcolumn:hover::after { | ||||
|   color: #f80; | ||||
|   color: var(--accent-color); | ||||
| } | ||||
| .sortcolumn { | ||||
|   padding-right: 1.7em; | ||||
| @@ -289,16 +344,12 @@ tbody tr:hover { | ||||
|   color: #888; | ||||
|   margin: 0 1em 0 0.5em; | ||||
|   position: absolute; | ||||
|   transition: all 0.2s linear; | ||||
|   transition: all var(--transition-time) linear; | ||||
| } | ||||
| .sortactive::after { | ||||
|   transform: rotate(90deg); | ||||
|   color: #000; | ||||
| } | ||||
| main { | ||||
|   padding: 5px; | ||||
|   height: 100%; | ||||
| } | ||||
| .more-action { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| @@ -325,4 +376,10 @@ main { | ||||
|   content: '📁 '; | ||||
|   font-size: 1.5em; | ||||
| } | ||||
| .empty-container { | ||||
|   padding-top: 3rem; | ||||
|   text-align: center; | ||||
|   font-size: 3rem; | ||||
|   color: var(--accent-color); | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -43,5 +43,6 @@ input#FileRenameInput { | ||||
|   width: 90%; | ||||
|   outline: none; | ||||
|   background: transparent; | ||||
|   font: inherit; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,30 +1,27 @@ | ||||
| <script setup lang="ts"> | ||||
| import { useDocumentStore } from '@/stores/documents' | ||||
| import { ref, nextTick } from 'vue' | ||||
| import { ref, nextTick, watchEffect } from 'vue' | ||||
|  | ||||
| const documentStore = useDocumentStore() | ||||
| const searchQuery = ref<string>('') | ||||
| const showSearchInput = ref<boolean>(false) | ||||
| const search = ref<HTMLInputElement | null>() | ||||
|  | ||||
| const toggleSearchInput = () => { | ||||
|   showSearchInput.value = !showSearchInput.value | ||||
|   if (!showSearchInput.value) { | ||||
|     searchQuery.value = '' | ||||
|   } | ||||
|   nextTick(() => { | ||||
|     const input = search.value | ||||
|     if (input) input.focus() | ||||
|     executeSearch() | ||||
|   }) | ||||
| } | ||||
|  | ||||
| const executeSearch = (ev: Event) => { | ||||
|   // FIXME: Make reactive instead of this update handler | ||||
|   const query = (ev.target as HTMLInputElement).value | ||||
|   console.log('Searching', query) | ||||
|   documentStore.setFilter(query) | ||||
|   console.log('Filtered') | ||||
| const executeSearch = () => { | ||||
|   documentStore.setFilter(search.value?.value ?? '') | ||||
| } | ||||
|  | ||||
| defineExpose({ | ||||
|   toggleSearchInput | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -32,26 +29,28 @@ const executeSearch = (ev: Event) => { | ||||
|     <div class="buttons"> | ||||
|       <UploadButton /> | ||||
|       <SvgButton name="create-folder" @click="() => documentStore.fileExplorer.newFolder()"/> | ||||
|       <template v-if="true"> | ||||
|       <template v-if="documentStore.selected.size > 0"> | ||||
|         <div class="smallgap"></div> | ||||
|         <p>N selected files:</p> | ||||
|         <p class="select-text">{{ documentStore.selected.size }} selected ➤</p> | ||||
|         <!-- Needs better icons for copy/move/remove --> | ||||
|         <SvgButton name="copy" /> | ||||
|         <SvgButton name="download" /> | ||||
|        <SvgButton name="copy" /> | ||||
|         <SvgButton name="paste" /> | ||||
|         <SvgButton name="trash" /> | ||||
|         <button @click="documentStore.selected.clear()">❌</button> | ||||
|       </template> | ||||
|       <div class="spacer"></div> | ||||
|       <SvgButton name="find" @click="toggleSearchInput" /> | ||||
|       <template v-if="showSearchInput"> | ||||
|         <input | ||||
|           ref="search" | ||||
|           type="search" | ||||
|           v-model="searchQuery" | ||||
|           class="margin-input" | ||||
|           @keyup.esc="toggleSearchInput" | ||||
|           @input="executeSearch" | ||||
|         /> | ||||
|       </template> | ||||
|       <SvgButton name="find" @click="toggleSearchInput" /> | ||||
|       <SvgButton name="cog" @click="console.log('TODO open settings')" /> | ||||
|     </div> | ||||
|   </nav> | ||||
| </template> | ||||
| @@ -68,6 +67,9 @@ const executeSearch = (ev: Event) => { | ||||
| .smallgap { | ||||
|   margin-left: 2em; | ||||
| } | ||||
| .select-text { | ||||
|   color: var(--accent-color); | ||||
| } | ||||
| .search-widget { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   | ||||
| @@ -26,7 +26,7 @@ | ||||
|       <h3 v-if="loginForm.error.length > 0" class="error-text"> | ||||
|         {{ loginForm.error }} | ||||
|       </h3> | ||||
|       <input type="submit" class="button-login" /> | ||||
|       <input id="submit" type="submit" class="button-login" /> | ||||
|     </form> | ||||
|   </ModalDialog> | ||||
| </template> | ||||
|   | ||||
| @@ -24,14 +24,17 @@ button { | ||||
|   transition: all 0.2s ease; | ||||
|   padding: 0.5rem; | ||||
| } | ||||
| button:hover { | ||||
| button:hover, button:focus { | ||||
|   color: #fff; | ||||
|   transform: scale(1.1); | ||||
| } | ||||
| svg { | ||||
|   fill: #ccc; | ||||
|   transform: fill 0.2s ease; | ||||
|   width: 1rem; | ||||
|   height: 1rem; | ||||
| } | ||||
| button:hover svg { | ||||
| button:hover svg, button:focus svg { | ||||
|   fill: #fff; | ||||
| } | ||||
| </style> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Leo Vasanko
					Leo Vasanko