- replace textarea editor with CodeMirror and syntax highlighting\n- integrate editor mode header (save button, hide unused controls)\n- fix breadcrumb/editor navigation behavior and transitions\n- add Create File action with ghost-rename flow and auto-open in editor\n- refine create-file icon shape and plus cutout alignment
324 lines
7.8 KiB
Vue
324 lines
7.8 KiB
Vue
<template>
|
||
<nav class="headermain buttons">
|
||
<template v-if="!props.editorMode">
|
||
<UploadButton :path="props.path" />
|
||
<SvgButton
|
||
name="create-file"
|
||
tooltip="New file"
|
||
@click="() => { store.fileExplorer!.newFile() }"
|
||
/>
|
||
<SvgButton
|
||
name="create-folder"
|
||
tooltip="New folder"
|
||
@click="() => { store.fileExplorer!.newFolder() }"
|
||
/>
|
||
<div class="smallgap"></div>
|
||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||
<div class="search-group">
|
||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||
<input
|
||
ref="search"
|
||
type="search"
|
||
:value="query"
|
||
@input="updateSearch"
|
||
@keydown.escape="clearSearch"
|
||
/>
|
||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||
</div>
|
||
</template>
|
||
<div v-if="!props.editorMode && showSortHints" class="sort-hints">
|
||
<span class="sort-label">Order</span>
|
||
<span class="keycap">1</span>
|
||
<span class="keycap">2</span>
|
||
<span class="keycap">3</span>
|
||
</div>
|
||
<SvgButton
|
||
v-if="props.editorMode"
|
||
name="disk"
|
||
tooltip="Save (Ctrl/Cmd+S)"
|
||
@click="store.editorSave?.()"
|
||
/>
|
||
<div class="spacer smallgap"></div>
|
||
<DiskSpace v-if="store.space.disk" />
|
||
<SvgButton name="cog" @click="settingsMenu" />
|
||
</nav>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { resumeWatching } from '@/repositories/WS'
|
||
import router from '@/router'
|
||
import { useMainStore } from '@/stores/main'
|
||
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||
import { showAuthIframe } from 'paskia'
|
||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||
import DiskSpace from './DiskSpace.vue'
|
||
|
||
const store = useMainStore()
|
||
const ssoStore = useSsoAuthStore()
|
||
const search = ref<HTMLInputElement | null>()
|
||
const textInputFocused = ref(false)
|
||
|
||
const props = defineProps<{
|
||
path: Array<string>
|
||
query: string
|
||
editorMode?: boolean
|
||
}>()
|
||
|
||
const isInputElement = (el: Element | null): boolean => {
|
||
if (!el || !(el instanceof HTMLElement)) return false
|
||
return el instanceof HTMLInputElement
|
||
}
|
||
|
||
const updateTextInputFocused = () => {
|
||
textInputFocused.value = isInputElement(document.activeElement)
|
||
}
|
||
|
||
const showSortHints = computed(() => !textInputFocused.value)
|
||
|
||
const clearSearch = (ev: Event) => {
|
||
const input = search.value
|
||
if (input) {
|
||
input.value = ''
|
||
updateSearch(ev)
|
||
}
|
||
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
|
||
breadcrumb.focus()
|
||
}
|
||
|
||
const focusSearch = () => {
|
||
search.value?.focus()
|
||
}
|
||
|
||
// Track pending route update
|
||
let pendingRouteUpdate: number | null = null
|
||
|
||
const updateSearch = (ev: Event) => {
|
||
const q = (ev.target as HTMLInputElement).value
|
||
const loc = props.path.join('/')
|
||
|
||
// Start search immediately via store (worker handles it async)
|
||
store.search(q, loc)
|
||
|
||
// Cancel any pending route update
|
||
if (pendingRouteUpdate !== null) {
|
||
cancelAnimationFrame(pendingRouteUpdate)
|
||
}
|
||
|
||
// Schedule route update - will be cancelled if user types again
|
||
pendingRouteUpdate = requestAnimationFrame(() => {
|
||
pendingRouteUpdate = null
|
||
let p = loc
|
||
p = p ? `/${p}` : ''
|
||
const url = q ? `${p}//${q}` : p || '/'
|
||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||
// Use replace to avoid building up history for each keystroke
|
||
router.replace(u)
|
||
})
|
||
}
|
||
|
||
const toggleSearchInput = () => {
|
||
search.value?.focus()
|
||
}
|
||
const settingsMenu = (e: Event) => {
|
||
// show the context menu
|
||
const items = []
|
||
|
||
// For external auth, show user name as link to /auth/
|
||
if (ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||
items.push({
|
||
label: '👤 ' + (store.user.username || 'User Account'),
|
||
onClick: () => {
|
||
window.location.href = '/auth/'
|
||
}
|
||
})
|
||
}
|
||
|
||
// Only show password change for non-SSO users
|
||
if (!ssoStore.isExternalAuth && store.user.isLoggedIn) {
|
||
items.push({
|
||
label: '🔑 Change Password',
|
||
onClick: () => {
|
||
store.dialog = 'settings'
|
||
}
|
||
})
|
||
}
|
||
|
||
if (store.user.isLoggedIn) {
|
||
items.push({
|
||
label: '🔑 API Tokens',
|
||
onClick: () => {
|
||
store.dialog = 'tokens'
|
||
}
|
||
})
|
||
}
|
||
|
||
if (store.user.privileged) {
|
||
items.push({
|
||
label: '⚙️ Admin Settings',
|
||
onClick: () => {
|
||
store.dialog = 'usermgmt'
|
||
}
|
||
})
|
||
}
|
||
|
||
if (store.user.isLoggedIn) {
|
||
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||
} else if (store.server.public) {
|
||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||
items.push({
|
||
label: '🔐 Login',
|
||
onClick: async () => {
|
||
try {
|
||
await showAuthIframe('/auth/restricted/#theme=light')
|
||
resumeWatching()
|
||
} catch (e) {
|
||
console.log('Login cancelled')
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
items.push({
|
||
label: 'ℹ️ About Cista...',
|
||
onClick: () => {
|
||
store.dialog = 'about'
|
||
}
|
||
})
|
||
|
||
ContextMenu.showContextMenu({
|
||
// @ts-ignore
|
||
x: e.target.getBoundingClientRect().right,
|
||
// @ts-ignore
|
||
y: e.target.getBoundingClientRect().bottom,
|
||
items
|
||
})
|
||
}
|
||
defineExpose({
|
||
toggleSearchInput,
|
||
clearSearch
|
||
})
|
||
|
||
onMounted(() => {
|
||
updateTextInputFocused()
|
||
window.addEventListener('focusin', updateTextInputFocused)
|
||
window.addEventListener('focusout', updateTextInputFocused)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener('focusin', updateTextInputFocused)
|
||
window.removeEventListener('focusout', updateTextInputFocused)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.buttons {
|
||
flex: 1000 0 auto;
|
||
padding: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
z-index: 10;
|
||
min-height: 3em;
|
||
}
|
||
.search-group {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 1.5em;
|
||
padding: 0 0.3em;
|
||
transition: background 0.2s ease;
|
||
flex: 1 1 auto;
|
||
min-width: 2.5em;
|
||
max-width: 20em;
|
||
}
|
||
.search-group:hover,
|
||
.search-group:focus-within {
|
||
background: rgba(255, 255, 255, 0.2);
|
||
}
|
||
.search-group:focus-within {
|
||
box-shadow: 0 0 0 2px var(--accent-color, #f80);
|
||
}
|
||
.search-group:hover :deep(button.action-button),
|
||
.search-group:focus-within :deep(button.action-button) {
|
||
transform: scale(1.1);
|
||
}
|
||
.search-group:hover :deep(button.action-button svg),
|
||
.search-group:focus-within :deep(button.action-button svg) {
|
||
fill: #fff;
|
||
}
|
||
.search-group:focus-within .search-hint {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.search-group :deep(.action-button) {
|
||
width: 2.2em;
|
||
height: 2.2em;
|
||
min-width: 1.5em;
|
||
min-height: 1.5em;
|
||
flex-shrink: 0;
|
||
}
|
||
.search-group input[type='search'] {
|
||
background: transparent;
|
||
color: var(--header-color);
|
||
border: none;
|
||
outline: none;
|
||
padding: 0.2em 0.5em 0.2em 0;
|
||
font-size: inherit;
|
||
flex: 1 1 3em;
|
||
min-width: 0;
|
||
width: 100%;
|
||
}
|
||
.search-hint {
|
||
position: absolute;
|
||
right: 0.5em;
|
||
font-family: system-ui, sans-serif;
|
||
font-size: 1em;
|
||
font-weight: 700;
|
||
color: #333;
|
||
background: #ccc;
|
||
border: 1px solid #999;
|
||
border-radius: 0.3em;
|
||
padding: 0 0.45em;
|
||
line-height: 1.4;
|
||
cursor: pointer;
|
||
transition: opacity 0.15s ease;
|
||
display: none;
|
||
}
|
||
@media (hover: hover) and (pointer: fine) {
|
||
.search-hint {
|
||
display: block;
|
||
}
|
||
}
|
||
.sort-hints {
|
||
display: none;
|
||
align-items: center;
|
||
gap: 0.25em;
|
||
margin-left: 0.3em;
|
||
white-space: nowrap;
|
||
}
|
||
.sort-label {
|
||
margin-right: 0.2em;
|
||
font-family: system-ui, sans-serif;
|
||
font-size: 1em;
|
||
font-weight: 700;
|
||
color: #ccc;
|
||
}
|
||
.keycap {
|
||
font-family: system-ui, sans-serif;
|
||
font-size: 1em;
|
||
font-weight: 700;
|
||
color: #333;
|
||
background: #ccc;
|
||
border: 1px solid #999;
|
||
border-radius: 0.3em;
|
||
padding: 0 0.45em;
|
||
line-height: 1.4;
|
||
}
|
||
@media screen and (min-width: 800px) {
|
||
.sort-hints {
|
||
display: flex;
|
||
}
|
||
}
|
||
</style>
|