- Normalize page titles to text-2xl font-semibold (was font-bold in TopicsView, DocumentView, SharedView) - Normalize section titles to text-lg font-semibold (was text-xl in SettingsPreferencesTab, SettingsAiTab, SettingsCloudTab, BackupCodesDisplay, CloudCredentialModal) - Normalize panel headings to text-sm font-semibold (SettingsAccountTab, DocumentView) - Normalize AdminOverviewView page title from text-xl to text-2xl font-semibold - Replace decorative sidebar skeleton inline styles with static Tailwind width classes (w-12/w-16/w-20) - Add focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 to all interactive buttons across the app - Add active:bg-* states to primary and secondary buttons for coherent press feedback - Update nav-link scoped CSS in AppSidebar and AdminSidebar to include active:bg-gray-200 and focus-visible ring - Add 7 new tests: VISUAL-01 skeleton class invariant, VISUAL-04 focus-visible invariant, VISUAL-03 typography invariant (36 test files, 270 tests pass)
266 lines
9.8 KiB
Vue
266 lines
9.8 KiB
Vue
<template>
|
|
<aside class="w-64 bg-white border-r border-gray-200 flex flex-col h-full shrink-0">
|
|
<div class="px-6 py-5 border-b border-gray-100">
|
|
<h1 class="text-lg font-bold text-indigo-600 tracking-tight">DocuVault</h1>
|
|
<p class="text-xs text-gray-400 mt-0.5">Document Manager</p>
|
|
</div>
|
|
|
|
<nav class="flex-1 px-3 py-4 overflow-y-auto">
|
|
<router-link
|
|
to="/topics"
|
|
class="nav-link"
|
|
:class="{ 'nav-link-active': $route.path.startsWith('/topics') }"
|
|
>
|
|
<AppIcon name="tag" class="w-4 h-4 mr-2 shrink-0" />
|
|
All Topics
|
|
</router-link>
|
|
|
|
<router-link
|
|
to="/shared"
|
|
class="nav-link"
|
|
:class="{ 'nav-link-active': $route.path === '/shared' }"
|
|
>
|
|
<div class="w-4 h-4 mr-2 shrink-0 rounded bg-purple-50 flex items-center justify-center">
|
|
<AppIcon name="inbox" class="w-3 h-3 text-purple-500" />
|
|
</div>
|
|
<span class="flex-1">Shared with me</span>
|
|
<span
|
|
v-if="sharedCount > 0"
|
|
class="ml-auto bg-purple-100 text-purple-600 text-xs font-semibold rounded-full px-2 min-w-[18px] text-center"
|
|
>
|
|
{{ sharedCount }}
|
|
</span>
|
|
</router-link>
|
|
|
|
<div class="mt-3">
|
|
<div class="flex items-center gap-0.5">
|
|
<button
|
|
@click="foldersExpanded = !foldersExpanded"
|
|
class="p-1 rounded hover:bg-gray-100 active:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
:title="foldersExpanded ? 'Collapse folders' : 'Expand folders'"
|
|
>
|
|
<AppIcon
|
|
name="chevronRight"
|
|
class="w-3 h-3 transition-transform duration-150"
|
|
:class="foldersExpanded ? 'rotate-90' : ''"
|
|
/>
|
|
</button>
|
|
|
|
<router-link
|
|
to="/"
|
|
class="nav-link flex-1 min-w-0"
|
|
:class="{ 'nav-link-active': $route.path === '/' || $route.path.startsWith('/folders/') }"
|
|
>
|
|
<AppIcon name="folder" class="w-4 h-4 mr-2 shrink-0 text-amber-500" />
|
|
Folders
|
|
</router-link>
|
|
</div>
|
|
|
|
<template v-if="foldersExpanded">
|
|
<div v-if="loadingRoots" class="pl-7 py-1 space-y-1">
|
|
<div v-for="n in 3" :key="`sk-f-${n}`" class="flex items-center gap-2 py-1">
|
|
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
|
<div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
|
|
</div>
|
|
</div>
|
|
<EmptyState
|
|
v-else-if="foldersStore.rootFolders.length === 0"
|
|
size="sm"
|
|
icon="folder"
|
|
headline="Create a folder in the file manager"
|
|
class="pl-7"
|
|
/>
|
|
<FolderTreeItem
|
|
v-for="folder in foldersStore.rootFolders"
|
|
:key="folder.id"
|
|
:folder="folder"
|
|
:depth="1"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<div class="flex items-center gap-0.5">
|
|
<button
|
|
@click="cloudExpanded = !cloudExpanded"
|
|
class="p-1 rounded hover:bg-gray-100 active:bg-gray-200 text-gray-400 hover:text-gray-600 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1"
|
|
:title="cloudExpanded ? 'Collapse cloud storage' : 'Expand cloud storage'"
|
|
>
|
|
<AppIcon
|
|
name="chevronRight"
|
|
class="w-3 h-3 transition-transform duration-150"
|
|
:class="cloudExpanded ? 'rotate-90' : ''"
|
|
/>
|
|
</button>
|
|
|
|
<router-link
|
|
to="/cloud"
|
|
class="nav-link flex-1 min-w-0"
|
|
:class="{ 'nav-link-active': $route.path.startsWith('/cloud') }"
|
|
>
|
|
<AppIcon name="cloud" class="w-4 h-4 mr-2 shrink-0 text-sky-500" />
|
|
Cloud Storage
|
|
</router-link>
|
|
</div>
|
|
|
|
<template v-if="cloudExpanded">
|
|
<div v-if="loadingCloudConnections" class="pl-7 py-1 space-y-1">
|
|
<div v-for="n in 3" :key="`sk-c-${n}`" class="flex items-center gap-2 py-1">
|
|
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
|
<div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
|
|
</div>
|
|
</div>
|
|
<EmptyState
|
|
v-else-if="activeCloudConnections.length === 0"
|
|
size="sm"
|
|
icon="cloud"
|
|
headline="Connect in Settings"
|
|
class="pl-7"
|
|
>
|
|
<template #cta>
|
|
<router-link to="/settings" class="ml-1 text-indigo-600 hover:underline">Settings</router-link>
|
|
</template>
|
|
</EmptyState>
|
|
<CloudProviderTreeItem
|
|
v-for="connection in activeCloudConnections"
|
|
:key="connection.id"
|
|
:connection="connection"
|
|
:depth="1"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="mt-3">
|
|
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Topics</p>
|
|
<div v-if="topicsStore.loading" class="px-3 py-1 space-y-1">
|
|
<div v-for="n in 3" :key="`sk-t-${n}`" class="flex items-center gap-2 py-1">
|
|
<div class="w-4 h-4 bg-gray-100 rounded animate-pulse shrink-0"></div>
|
|
<div class="h-3 bg-gray-100 rounded animate-pulse" :class="n === 1 ? 'w-12' : n === 2 ? 'w-16' : 'w-20'"></div>
|
|
</div>
|
|
</div>
|
|
<EmptyState
|
|
v-else-if="topicsStore.topics.length === 0"
|
|
size="sm"
|
|
icon="tag"
|
|
headline="No topics yet"
|
|
class="px-3"
|
|
/>
|
|
<router-link
|
|
v-for="topic in topicsStore.topics"
|
|
:key="topic.id"
|
|
:to="`/topics/${encodeURIComponent(topic.name)}`"
|
|
class="nav-link text-sm"
|
|
:class="{ 'nav-link-active': $route.params.name === topic.name }"
|
|
>
|
|
<span
|
|
class="w-2.5 h-2.5 rounded-full mr-2 shrink-0"
|
|
:style="{ backgroundColor: topic.color }"
|
|
></span>
|
|
<span class="truncate">{{ topic.name }}</span>
|
|
<span class="ml-auto text-xs text-gray-400">{{ topic.doc_count }}</span>
|
|
</router-link>
|
|
</div>
|
|
</nav>
|
|
|
|
<QuotaBar />
|
|
|
|
<div class="px-3 py-4 border-t border-gray-100">
|
|
<router-link
|
|
v-if="authStore.user?.role === 'admin'"
|
|
to="/admin"
|
|
class="nav-link"
|
|
:class="{ 'nav-link-active': $route.path === '/admin' }"
|
|
>
|
|
<AppIcon name="shield" class="w-4 h-4 mr-2 shrink-0" />
|
|
Admin
|
|
</router-link>
|
|
|
|
<router-link
|
|
to="/settings"
|
|
class="nav-link"
|
|
:class="{ 'nav-link-active': $route.path === '/settings' }"
|
|
>
|
|
<AppIcon name="cog" class="w-4 h-4 mr-2 shrink-0" />
|
|
Settings
|
|
</router-link>
|
|
|
|
<div v-if="authStore.user" class="flex items-center gap-3 px-4 py-3 border-t border-gray-100 mt-2 -mx-3">
|
|
<div class="bg-indigo-100 text-indigo-700 text-xs font-semibold rounded-full w-8 h-8 flex items-center justify-center shrink-0">
|
|
{{ authStore.user.email ? authStore.user.email[0].toUpperCase() : '?' }}
|
|
</div>
|
|
<span class="text-xs text-gray-600 truncate flex-1">{{ authStore.user.email }}</span>
|
|
<button
|
|
@click="signOut"
|
|
aria-label="Sign out"
|
|
class="text-gray-400 hover:text-gray-600 active:text-gray-800 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 rounded"
|
|
>
|
|
<AppIcon name="logout" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import AppIcon from '../ui/AppIcon.vue'
|
|
import { useTopicsStore } from '../../stores/topics.js'
|
|
import { useAuthStore } from '../../stores/auth.js'
|
|
import { useFoldersStore } from '../../stores/folders.js'
|
|
import { useCloudConnectionsStore } from '../../stores/cloudConnections.js'
|
|
import QuotaBar from './QuotaBar.vue'
|
|
import FolderTreeItem from '../folders/FolderTreeItem.vue'
|
|
import CloudProviderTreeItem from '../cloud/CloudProviderTreeItem.vue'
|
|
import EmptyState from '../ui/EmptyState.vue'
|
|
import * as api from '../../api/client.js'
|
|
|
|
const topicsStore = useTopicsStore()
|
|
const authStore = useAuthStore()
|
|
const foldersStore = useFoldersStore()
|
|
const cloudConnectionsStore = useCloudConnectionsStore()
|
|
const router = useRouter()
|
|
|
|
const sharedCount = ref(0)
|
|
const loadingRoots = ref(true)
|
|
const foldersExpanded = ref(false)
|
|
const cloudExpanded = ref(true)
|
|
|
|
const activeCloudConnections = computed(() =>
|
|
cloudConnectionsStore.connections.filter(c => c.status === 'ACTIVE')
|
|
)
|
|
const loadingCloudConnections = computed(() => cloudConnectionsStore.loading)
|
|
|
|
watch(() => foldersStore.treeVersion, () => foldersStore.fetchRootFolders())
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
await foldersStore.fetchRootFolders()
|
|
} finally {
|
|
loadingRoots.value = false
|
|
}
|
|
try {
|
|
const data = await api.getSharedWithMe()
|
|
const items = Array.isArray(data) ? data : (data.items ?? [])
|
|
sharedCount.value = items.length
|
|
} catch {
|
|
sharedCount.value = 0
|
|
}
|
|
cloudConnectionsStore.fetchConnections()
|
|
})
|
|
|
|
async function signOut() {
|
|
await authStore.logout()
|
|
router.push('/login')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.nav-link {
|
|
@apply flex items-center px-3 py-2 rounded-lg text-gray-600 hover:bg-gray-100 hover:text-gray-900 active:bg-gray-200 transition-colors text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1;
|
|
}
|
|
.nav-link-active {
|
|
@apply bg-indigo-50 text-indigo-700;
|
|
}
|
|
</style>
|