Files
cista-storage/frontend/src/components/HeaderMain.vue
T
LeoVasanko 72b3c0d8ce feat(frontend): add text editor flow and create-file UX
- 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
2026-05-06 02:17:17 +00:00

324 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>