Compare commits

...

8 Commits

14 changed files with 159 additions and 104 deletions

View File

@ -9,6 +9,7 @@ lerna-debug.log*
# No locking # No locking
package-lock.json package-lock.json
yarn.lock
node_modules node_modules
.DS_Store .DS_Store
@ -16,6 +17,7 @@ dist
dist-ssr dist-ssr
coverage coverage
*.local *.local
components.d.ts
/cypress/videos/ /cypress/videos/
/cypress/screenshots/ /cypress/screenshots/

View File

@ -1,24 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
BreadCrumb: typeof import('./src/components/BreadCrumb.vue')['default']
FileExplorer: typeof import('./src/components/FileExplorer.vue')['default']
FileRenameInput: typeof import('./src/components/FileRenameInput.vue')['default']
FileViewer: typeof import('./src/components/FileViewer.vue')['default']
HeaderMain: typeof import('./src/components/HeaderMain.vue')['default']
HeaderSelected: typeof import('./src/components/HeaderSelected.vue')['default']
LoginModal: typeof import('./src/components/LoginModal.vue')['default']
ModalDialog: typeof import('./src/components/ModalDialog.vue')['default']
NotificationLoading: typeof import('./src/components/NotificationLoading.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SvgButton: typeof import('./src/components/SvgButton.vue')['default']
UploadButton: typeof import('./src/components/UploadButton.vue')['default']
}
}

View File

@ -52,7 +52,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
const c = documentStore.fileExplorer.isCursor() const c = documentStore.fileExplorer.isCursor()
const keyup = event.type === 'keyup' const keyup = event.type === 'keyup'
if (event.repeat) { if (event.repeat) {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || (c && event.code === 'Space')) { if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
(c && event.code === 'Space')
) {
event.preventDefault() event.preventDefault()
} }
return return
@ -70,7 +74,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
documentStore.fileExplorer.toggleSelectAll() documentStore.fileExplorer.toggleSelectAll()
} }
// Keys 1-3 to sort columns // Keys 1-3 to sort columns
else if (c && keyup && (event.key === '1' || event.key === '2' || event.key === '3')) { else if (
c &&
keyup &&
(event.key === '1' || event.key === '2' || event.key === '3')
) {
documentStore.fileExplorer.toggleSortColumn(+event.key) documentStore.fileExplorer.toggleSortColumn(+event.key)
} }
// Rename // Rename
@ -85,17 +93,22 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
event.preventDefault() event.preventDefault()
if (!vert) { if (!vert) {
if (timer) { if (timer) {
clearTimeout(timer) // Good for either timeout or interval clearTimeout(timer) // Good for either timeout or interval
timer = null timer = null
} }
return return
} }
if (!timer) { if (!timer) {
// Initial move, then t0 delay until repeats at tr intervals // Initial move, then t0 delay until repeats at tr intervals
documentStore.fileExplorer.cursorMove(vert) const select = event.shiftKey
const t0 = 200, tr = 30 documentStore.fileExplorer.cursorMove(vert, select)
const t0 = 200,
tr = 30
timer = setTimeout( timer = setTimeout(
() => timer = setInterval(() => { documentStore.fileExplorer.cursorMove(vert) }, tr), () =>
(timer = setInterval(() => {
documentStore.fileExplorer.cursorMove(vert, select)
}, tr)),
t0 - tr t0 - tr
) )
} }

View File

@ -46,7 +46,9 @@
--header-background: none; --header-background: none;
--header-color: black; --header-color: black;
} }
nav, .menu, .rename-button { nav,
.menu,
.rename-button {
display: none; display: none;
} }
.breadcrumb > a { .breadcrumb > a {
@ -57,8 +59,12 @@
clip-path: none !important; clip-path: none !important;
max-width: none !important; max-width: none !important;
} }
.breadcrumb > a::after { content: '/'; } .breadcrumb > a::after {
.breadcrumb svg { fill: black !important; } content: '/';
}
.breadcrumb svg {
fill: black !important;
}
main { main {
height: auto !important; height: auto !important;
padding-bottom: 0 !important; padding-bottom: 0 !important;
@ -72,22 +78,27 @@
min-width: 0 !important; min-width: 0 !important;
padding: 0 !important; padding: 0 !important;
} }
.selection input { display: none } .selection input {
.selection input:checked { display: inherit; } display: none;
}
.selection input:checked {
display: inherit;
}
tbody .selection input:checked { tbody .selection input:checked {
opacity: 1 !important; opacity: 1 !important;
transform: scale(0.5); transform: scale(0.5);
top: 0.1rem !important; top: 0.1rem !important;
left: -0.3rem !important; left: -0.3rem !important;
} }
} }
/* Hide scrollbar for all browsers */ /* Hide scrollbar for all browsers */
main::-webkit-scrollbar { display: none; } main::-webkit-scrollbar {
display: none;
}
main { main {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
body { body {
background-color: var(--primary-background); background-color: var(--primary-background);
@ -96,7 +107,8 @@ body {
color: var(--primary-color); color: var(--primary-color);
margin: 0; margin: 0;
} }
tbody .size, tbody .modified { tbody .size,
tbody .modified {
font-family: 'Roboto Mono'; font-family: 'Roboto Mono';
} }
header { header {
@ -121,6 +133,9 @@ button {
min-width: 1rem; min-width: 1rem;
min-height: 1rem; min-height: 1rem;
} }
input {
margin: 0;
}
:focus { :focus {
outline: none; outline: none;
} }
@ -133,6 +148,7 @@ a:hover {
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
border-spacing: 0;
border: 0; border: 0;
gap: 0; gap: 0;
} }
@ -142,7 +158,7 @@ table {
flex-direction: column; flex-direction: column;
} }
main { main {
height: calc(100svh - 9rem); /* fill almost the rest of the screen after header */ height: calc(100svh - 9rem); /* fill almost the rest of the screen after header */
padding-bottom: 3rem; /* convenience space on the bottom */ padding-bottom: 3rem; /* convenience space on the bottom */
overflow-y: scroll; overflow-y: scroll;
} }

View File

@ -8,10 +8,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
//import home from '@/assets/svg/home.svg' import home from '@/assets/svg/home.svg'
import { defineProps, defineAsyncComponent } from 'vue'
const home = defineAsyncComponent(() => import(`@/assets/svg/home.svg`))
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>

View File

@ -48,7 +48,11 @@
" "
/> />
</td> </td>
<td class="modified right">{{ editing.modified }}</td> <td class="modified right">
<time :datetime="new Date(editing.mtime).toISOString().replace('.000', '')">{{
editing.modified
}}</time>
</td>
<td class="size right">{{ editing.sizedisp }}</td> <td class="size right">{{ editing.sizedisp }}</td>
<td class="menu"></td> <td class="menu"></td>
</tr> </tr>
@ -92,17 +96,30 @@
:href="url_for(doc)" :href="url_for(doc)"
tabindex="-1" tabindex="-1"
@contextmenu.stop @contextmenu.stop
@click.stop
@focus.stop="cursor = doc" @focus.stop="cursor = doc"
>{{ doc.name }}</a >{{ doc.name }}</a
> >
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊</button> <button
v-if="cursor == doc"
class="rename-button"
@click="() => (editing = doc)"
>
🖊
</button>
</template> </template>
</td> </td>
<td class="modified right">{{ doc.modified }}</td> <td class="modified right">
<time
:datetime="new Date(1000 * doc.mtime).toISOString().replace('.000', '')"
>{{ doc.modified }}</time
>
</td>
<td class="size right">{{ doc.sizedisp }}</td> <td class="size right">{{ doc.sizedisp }}</td>
<td class="menu"> <td class="menu">
<button tabindex="-1" @click.stop="cursor = doc; contextMenu($event, doc)"> <button
tabindex="-1"
@click.stop="contextMenu($event, doc)"
>
</button> </button>
</td> </td>
@ -190,7 +207,6 @@ defineExpose({
editing.value = cursor.value editing.value = cursor.value
}, },
cursorSelect() { cursorSelect() {
console.log('select', documentStore.selected)
const doc = cursor.value const doc = cursor.value
if (!doc) return if (!doc) return
if (documentStore.selected.has(doc.key)) { if (documentStore.selected.has(doc.key)) {
@ -200,19 +216,31 @@ defineExpose({
} }
this.cursorMove(1) this.cursorMove(1)
}, },
cursorMove(d: number) { cursorMove(d: number, select = false) {
// Move cursor up or down (keyboard navigation) // Move cursor up or down (keyboard navigation)
const documents = sorted(props.documents as FolderDocument[]) const documents = sorted(props.documents as FolderDocument[])
if (documents.length === 0) { if (documents.length === 0) {
cursor.value = null cursor.value = null
return return
} }
const N = documents.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const index = cursor.value !== null ? documents.indexOf(cursor.value) : -1 const increment = (i: number, d: number) => mod(i + d, N + 1)
cursor.value = documents[mod(index + d, documents.length + 1)] ?? null const index =
const tr = document.getElementById( cursor.value !== null ? documents.indexOf(cursor.value) : documents.length
`file-${cursor.value.key}` const moveto = increment(index, d)
) as HTMLTableRowElement | null cursor.value = documents[moveto] ?? null
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = documents[p].key
if (documentStore.selected.has(key)) documentStore.selected.delete(key)
else documentStore.selected.add(key)
}
}
// @ts-ignore // @ts-ignore
scrolltr = tr scrolltr = tr
if (!scrolltimer) { if (!scrolltimer) {
@ -307,6 +335,7 @@ watchEffect(() => {
if (editing.value) cursor.value = editing.value if (editing.value) cursor.value = editing.value
}) })
const contextMenu = (ev: Event, doc: Document) => { const contextMenu = (ev: Event, doc: Document) => {
cursor.value = doc
console.log('Context menu', ev, doc) console.log('Context menu', ev, doc)
} }
</script> </script>
@ -336,9 +365,12 @@ table tbody input[type='checkbox'] {
} }
table .selection { table .selection {
width: 2rem; width: 2rem;
height: 2rem;
text-align: center;
text-overflow: clip;
} }
table .modified { table .modified {
width: 10rem; width: 8rem;
} }
table .size { table .size {
width: 4rem; width: 4rem;
@ -363,12 +395,21 @@ table td {
position: relative; position: relative;
} }
.name .rename-button { .name .rename-button {
padding-left: 1rem;
position: absolute; position: absolute;
right: 0; right: 0;
animation: appear calc(5 * var(--transition-time)) linear; animation: appear calc(5 * var(--transition-time)) linear;
} }
@keyframes appear { from { opacity: 0 } 80% { opacity: 0 } to { opacity: 1 } } @keyframes appear {
from {
opacity: 0;
}
80% {
opacity: 0;
}
to {
opacity: 1;
}
}
thead tr { thead tr {
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd); background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000; color: #000;
@ -379,9 +420,6 @@ tbody tr.cursor {
.right { .right {
text-align: right; text-align: right;
} }
.selection {
width: 2em;
}
.sortcolumn:hover { .sortcolumn:hover {
cursor: pointer; cursor: pointer;
} }
@ -405,18 +443,19 @@ tbody tr.cursor {
.name a { .name a {
text-decoration: none; text-decoration: none;
} }
tr {
height: 2.5rem;
}
tbody .selection input { tbody .selection input {
z-index: 1; z-index: 1;
position: absolute; position: absolute;
opacity: 0; opacity: 0;
left: 0; left: 0.5rem;
top: 0; top: 0;
} }
.selection {
width: 2em;
height: 2em;
}
.selection input:checked { .selection input:checked {
opacity: .7; opacity: 0.7;
} }
.file .selection::before { .file .selection::before {
content: '📄 '; content: '📄 ';

View File

@ -3,6 +3,7 @@
ref="input" ref="input"
id="FileRenameInput" id="FileRenameInput"
type="text" type="text"
autocorrect="off"
v-model="name" v-model="name"
@blur="exit" @blur="exit"
@keyup.esc="exit" @keyup.esc="exit"
@ -34,7 +35,11 @@ const props = defineProps<{
const apply = () => { const apply = () => {
props.exit() props.exit()
if (props.doc.key !== 'new' && (name.value === props.doc.name || name.value.length === 0)) return if (
props.doc.key !== 'new' &&
(name.value === props.doc.name || name.value.length === 0)
)
return
props.rename(props.doc, name.value) props.rename(props.doc, name.value)
} }
</script> </script>
@ -44,9 +49,9 @@ input#FileRenameInput {
color: var(--primary-color); color: var(--primary-color);
background: var(--primary-background); background: var(--primary-background);
border: 0; border: 0;
border-radius: .3rem; border-radius: 0.3rem;
padding: .4rem; padding: 0.4rem;
margin: -.4rem; margin: -0.4rem;
width: 100%; width: 100%;
outline: none; outline: none;
font: inherit; font: inherit;

View File

@ -46,7 +46,7 @@ defineExpose({
/> />
</template> </template>
<SvgButton ref="searchButton" name="find" @click="toggleSearchInput" /> <SvgButton ref="searchButton" name="find" @click="toggleSearchInput" />
<SvgButton name="cog" @click="console.log('TODO open settings')" /> <SvgButton name="cog" @click="console.log('settings menu')" />
</div> </div>
</nav> </nav>
</template> </template>

View File

@ -8,12 +8,12 @@ import router from './router'
import piniaPluginPersistedState from 'pinia-plugin-persistedstate' import piniaPluginPersistedState from 'pinia-plugin-persistedstate'
const app = createApp(App) const app = createApp(App)
app.config.errorHandler = err => { app.config.errorHandler = err => {
/* handle error */ /* handle error */
console.log(err) console.log(err)
} }
const pinia = createPinia() const pinia = createPinia()
pinia.use(piniaPluginPersistedState) pinia.use(piniaPluginPersistedState)
app.use(pinia) app.use(pinia)

View File

@ -30,13 +30,13 @@ export type errorEvent = {
// Raw types the backend /api/watch sends us // Raw types the backend /api/watch sends us
export type FileEntry = { export type FileEntry = {
id: FUID key: FUID
size: number size: number
mtime: number mtime: number
} }
export type DirEntry = { export type DirEntry = {
id: FUID key: FUID
size: number size: number
mtime: number mtime: number
dir: DirList dir: DirList
@ -47,7 +47,7 @@ export type DirList = Record<string, FileEntry | DirEntry>
export type UpdateEntry = { export type UpdateEntry = {
name: string name: string
deleted?: boolean deleted?: boolean
id?: FUID key?: FUID
size?: number size?: number
mtime?: number mtime?: number
dir?: DirList dir?: DirList
@ -99,7 +99,7 @@ export class DocumentHandler {
this.handleUpdateMessage(msg) this.handleUpdateMessage(msg)
break break
case !!msg.space: case !!msg.space:
console.log("Watch space", msg.space) console.log('Watch space', msg.space)
break break
case !!msg.error: case !!msg.error:
this.handleError(msg) this.handleError(msg)
@ -109,14 +109,14 @@ export class DocumentHandler {
} }
private handleRootMessage({ root }: { root: DirEntry }) { private handleRootMessage({ root }: { root: DirEntry }) {
console.log("Watch root", root) console.log('Watch root', root)
if (this.store && this.store.root) { if (this.store && this.store.root) {
this.store.user.isLoggedIn = true this.store.user.isLoggedIn = true
this.store.root = root this.store.root = root
} }
} }
private handleUpdateMessage(updateData: { update: UpdateEntry[] }) { private handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
console.log("Watch update", updateData.update) console.log('Watch update', updateData.update)
let node: DirEntry = this.store.root let node: DirEntry = this.store.root
for (const elem of updateData.update) { for (const elem of updateData.update) {
if (elem.deleted) { if (elem.deleted) {
@ -127,7 +127,7 @@ export class DocumentHandler {
// @ts-ignore // @ts-ignore
node = node.dir[elem.name] ||= {} node = node.dir[elem.name] ||= {}
} }
if (elem.id !== undefined) node.id = elem.id if (elem.key !== undefined) node.key = elem.key
if (elem.size !== undefined) node.size = elem.size if (elem.size !== undefined) node.size = elem.size
if (elem.mtime !== undefined) node.mtime = elem.mtime if (elem.mtime !== undefined) node.mtime = elem.mtime
if (elem.dir !== undefined) node.dir = elem.dir if (elem.dir !== undefined) node.dir = elem.dir

View File

@ -60,10 +60,10 @@ export const useDocumentStore = defineStore({
// Transform data // Transform data
const dataMapped = [] const dataMapped = []
for (const [name, attr] of Object.entries(matched)) { for (const [name, attr] of Object.entries(matched)) {
const { id, size, mtime } = attr const { key, size, mtime } = attr
const element: Document = { const element: Document = {
name, name,
key: id, key,
size, size,
sizedisp: formatSize(size), sizedisp: formatSize(size),
mtime, mtime,
@ -182,21 +182,22 @@ export const useDocumentStore = defineStore({
if (!('dir' in data)) return if (!('dir' in data)) return
for (const [name, attr] of Object.entries(data.dir)) { for (const [name, attr] of Object.entries(data.dir)) {
const fullname = path ? `${path}/${name}` : name const fullname = path ? `${path}/${name}` : name
const key = attr.key
// Is this the file we are looking for? Ignore if nested within another selection. // Is this the file we are looking for? Ignore if nested within another selection.
let r = relpath let r = relpath
if (selected.has(attr.id) && !relpath) { if (selected.has(key) && !relpath) {
ret.selected.add(attr.id) ret.selected.add(key)
ret.rootdir[name] = attr ret.rootdir[name] = attr
r = name r = name
} else if (relpath) { } else if (relpath) {
r = `${relpath}/${name}` r = `${relpath}/${name}`
} }
if (r) { if (r) {
ret.entries[attr.id] = attr ret.entries[key] = attr
ret.fullpath[attr.id] = fullname ret.fullpath[key] = fullname
ret.relpath[attr.id] = r ret.relpath[key] = r
ret.ids.push(attr.id) ret.ids.push(key)
if (!('dir' in attr)) ret.url[attr.id] = `/files/${fullname}` if (!('dir' in attr)) ret.url[key] = `/files/${fullname}`
} }
traverseDir(attr, fullname, r) traverseDir(attr, fullname, r)
} }

View File

@ -27,28 +27,31 @@ export function formatUnixDate(t: number) {
return 'now' return 'now'
} }
if (Math.abs(diff) <= 60000) { if (Math.abs(diff) <= 60000) {
return formatter.format(Math.round(diff / 1000), 'second') return formatter.format(Math.round(diff / 1000), 'second').replace(' ago', '').replaceAll(' ', '\u202F')
} }
if (Math.abs(diff) <= 3600000) { if (Math.abs(diff) <= 3600000) {
return formatter.format(Math.round(diff / 60000), 'minute') return formatter.format(Math.round(diff / 60000), 'minute').replace('utes', '').replace('ute', '').replaceAll(' ', '\u202F')
} }
if (Math.abs(diff) <= 86400000) { if (Math.abs(diff) <= 86400000) {
return formatter.format(Math.round(diff / 3600000), 'hour') return formatter.format(Math.round(diff / 3600000), 'hour').replaceAll(' ', '\u202F')
} }
if (Math.abs(diff) <= 604800000) { if (Math.abs(diff) <= 604800000) {
return formatter.format(Math.round(diff / 86400000), 'day') return formatter.format(Math.round(diff / 86400000), 'day').replaceAll(' ', '\u202F')
} }
const d = date.toLocaleDateString("us", { let d = date.toLocaleDateString('en-ie', {
weekday: 'short', weekday: 'short',
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
}).replace("Sept", "Sep") }).replace("Sept", "Sep")
return (d.length === 16 ? d : d.replace(', ', ',\u202F\u2007')).replaceAll(' ', '\u202F') if (d.length === 14) d = d.replace(' ', ' \u2007') // dom < 10 alignment (add figure space)
d = d.replaceAll(' ', '\u202F').replace('\u202F', '\u00A0') // nobr spaces, thin w/ date but not weekday
d = d.slice(0, -4) + d.slice(-2) // Two digit year is enough
return d
} }
export function getFileExtension(filename: string) { export function getFileExtension(filename: string) {

View File

@ -110,13 +110,13 @@ class ErrorMsg(msgspec.Struct):
class FileEntry(msgspec.Struct): class FileEntry(msgspec.Struct):
id: str key: str
size: int size: int
mtime: int mtime: int
class DirEntry(msgspec.Struct): class DirEntry(msgspec.Struct):
id: str key: str
size: int size: int
mtime: int mtime: int
dir: DirList dir: DirList
@ -146,7 +146,7 @@ class UpdateEntry(msgspec.Struct, omit_defaults=True):
name: str = "" name: str = ""
deleted: bool = False deleted: bool = False
id: str | None = None key: str | None = None
size: int | None = None size: int | None = None
mtime: int | None = None mtime: int | None = None
dir: DirList | None = None dir: DirList | None = None

View File

@ -90,7 +90,9 @@ def format_tree():
return msgspec.json.encode( return msgspec.json.encode(
{ {
"update": [ "update": [
UpdateEntry(id=root.id, size=root.size, mtime=root.mtime, dir=root.dir), UpdateEntry(
key=root.key, size=root.size, mtime=root.mtime, dir=root.dir
),
], ],
}, },
).decode() ).decode()
@ -99,10 +101,11 @@ def format_tree():
def walk(path: Path) -> DirEntry | FileEntry | None: def walk(path: Path) -> DirEntry | FileEntry | None:
try: try:
s = path.stat() s = path.stat()
id_ = fuid(s) key = fuid(s)
assert key, repr(key)
mtime = int(s.st_mtime) mtime = int(s.st_mtime)
if path.is_file(): if path.is_file():
return FileEntry(id_, s.st_size, mtime) return FileEntry(key, s.st_size, mtime)
tree = { tree = {
p.name: v p.name: v
@ -115,7 +118,7 @@ def walk(path: Path) -> DirEntry | FileEntry | None:
mtime = max(mtime, *(v.mtime for v in tree.values())) mtime = max(mtime, *(v.mtime for v in tree.values()))
else: else:
size = 0 size = 0
return DirEntry(id_, size, mtime, tree) return DirEntry(key, size, mtime, tree)
except FileNotFoundError: except FileNotFoundError:
return None return None
except OSError as e: except OSError as e: