# Vue Router Integration Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add vue-router with HTML5 history mode, splitting ClientPage.vue into independent route components while preserving all UI and behavior. **Architecture:** Extract shared state into composables, create route view components from ClientPage.vue template sections, wrap in DefaultLayout with shared header/modals. **Tech Stack:** Vue 3.5, vue-router 4, TypeScript 6 ## Global Constraints - No changes to existing child component styles or logic (FileCard, FileListItem, BrowseToolbar, etc.) - HTML5 history mode (clean URLs like `/browse/1`) - Backend already has `MapFallbackToFile("index.html")` — no backend changes needed ## File Map **New files (~12):** - `src/router/index.ts` - `src/layouts/DefaultLayout.vue` - `src/views/RootPickerView.vue` - `src/views/RecentAddedView.vue` - `src/views/RecentPlayedView.vue` - `src/views/BrowseView.vue` - `src/views/SearchView.vue` - `src/composables/useLibraryRoots.ts` - `src/composables/useMediaPlayer.ts` - `src/composables/useViewMode.ts` - `src/composables/useModals.ts` **Modify files (~5):** - `package.json` — add vue-router - `src/main.ts` — register router - `src/App.vue` — router-view - `src/components/client/ClientHeader.vue` — router-link - `src/components/AdminPage.vue` — router-link **Delete files (1):** - `src/components/ClientPage.vue` --- ## Task 1: Install vue-router + Create Router Config - [ ] **Step 1: Install vue-router** ```bash cd FileShare-Web-VUE && npm install vue-router ``` - [ ] **Step 2: Create src/router/index.ts** ```typescript import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('../layouts/DefaultLayout.vue'), children: [ { path: '', name: 'home', component: () => import('../views/RootPickerView.vue') }, { path: 'recent/added', name: 'recent-added', component: () => import('../views/RecentAddedView.vue') }, { path: 'recent/played', name: 'recent-played', component: () => import('../views/RecentPlayedView.vue') }, { path: 'browse/:rootId', name: 'browse', component: () => import('../views/BrowseView.vue'), props: true }, { path: 'browse/:rootId/*', name: 'browse-path', component: () => import('../views/BrowseView.vue'), props: true }, { path: 'search', name: 'search', component: () => import('../views/SearchView.vue') }, ], }, { path: '/admin', name: 'admin', component: () => import('../components/AdminPage.vue'), }, ], }) export default router ``` - [ ] **Step 3: Register router in src/main.ts** ```typescript import './assets/main.css' import { createApp } from 'vue' import App from './App.vue' import router from './router' createApp(App).use(router).mount('#app') ``` - [ ] **Step 4: Update src/App.vue** ```vue ``` - [ ] **Step 5: Verify build** ```bash npx vue-tsc --noEmit ``` --- ## Task 2: Create Composables - [ ] **Step 1: Create src/composables/useLibraryRoots.ts** Singleton module-level state for roots list. ```typescript import { ref, computed } from 'vue' import { api, type LibraryRootDto } from '../api' const roots = ref([]) const loading = ref(false) const errorMessage = ref('') const activeRoots = computed(() => roots.value.filter(r => r.isEnabled && r.isAvailable)) export function useLibraryRoots() { async function loadRoots() { loading.value = true errorMessage.value = '' try { roots.value = await api.getRoots() } catch (error) { errorMessage.value = error instanceof Error ? error.message : '加载失败' } finally { loading.value = false } } return { roots, loading, errorMessage, activeRoots, loadRoots } } ``` - [ ] **Step 2: Create src/composables/useMediaPlayer.ts** Singleton state for selected file, media player, playback control. Contains all the video progress saving, resume logic from ClientPage lines 324-458. ```typescript import { ref, computed, onBeforeUnmount } from 'vue' import { api, type FileRecordDto } from '../api' type MediaPlayerHandle = { getVideoElement: () => HTMLVideoElement | null playVideo: () => Promise | undefined resetVideo: () => void } const selectedFile = ref(null) const textPreview = ref(null) const mediaPlayer = ref(null) const resumePosition = ref(0) const showResumePrompt = ref(false) const resumeRequested = ref(false) let lastPositionSave = 0 function getVideoElement() { return mediaPlayer.value?.getVideoElement() ?? null } function setMediaPlayer(el: unknown) { mediaPlayer.value = (el as MediaPlayerHandle) ?? null } function setError(error: unknown) { // Will be wired to layout error state } function updatePlaybackPosition(id: number, position: number) { if (selectedFile.value?.id === id) { selectedFile.value.playbackPosition = position } } function saveVideoProgress(position?: number) { if (!selectedFile.value || selectedFile.value.mediaType !== 'video') return const video = getVideoElement() if (!video) return const rawPosition = position ?? video.currentTime if (!Number.isFinite(rawPosition)) return const nextPosition = Math.floor(rawPosition) if (!Number.isFinite(nextPosition) || nextPosition < 0) return const fileId = selectedFile.value.id api.saveFileProgress(fileId, nextPosition) .then(() => updatePlaybackPosition(fileId, nextPosition)) .catch(() => {}) } function flushVideoProgress() { const video = getVideoElement() if (!video || video.currentTime === 0 || !Number.isFinite(video.currentTime) || video.ended) return saveVideoProgress() } function resetResume() { resumePosition.value = 0 resumeRequested.value = false showResumePrompt.value = false } function tryResume(file: FileRecordDto) { if (file.mediaType !== 'video' || !file.playbackPosition || file.playbackPosition <= 0) return resumePosition.value = file.playbackPosition resumeRequested.value = false showResumePrompt.value = true } function seekToResumePosition() { const video = getVideoElement() if (!video || !resumeRequested.value || resumePosition.value <= 0 || video.readyState < 1) return const maxPosition = Number.isFinite(video.duration) && video.duration > 1 ? Math.max(0, video.duration - 1) : resumePosition.value video.currentTime = Math.min(resumePosition.value, maxPosition) resumeRequested.value = false } function resumePlayback() { showResumePrompt.value = false resumeRequested.value = true seekToResumePosition() mediaPlayer.value?.playVideo()?.catch(() => {}) } function dismissResume() { mediaPlayer.value?.resetVideo() saveVideoProgress(0) resetResume() } function handleVideoPlay() { if (showResumePrompt.value && resumePosition.value > 0) { resumeRequested.value = true } seekToResumePosition() showResumePrompt.value = false } function handleVideoTimeUpdate() { const video = getVideoElement() if (!video || video.paused || video.currentTime === 0 || !Number.isFinite(video.currentTime)) return const now = Date.now() if (now - lastPositionSave < 5000) return lastPositionSave = now saveVideoProgress() } function handleVideoPause() { flushVideoProgress() } function handleVideoEnded() { saveVideoProgress(0) } async function selectFile(file: FileRecordDto) { flushVideoProgress() resetResume() lastPositionSave = 0 selectedFile.value = file textPreview.value = null if (file.mediaType === 'video' || file.mediaType === 'audio') { api.markFilePlayed(file.id).catch(() => {}) if (file.mediaType === 'video') tryResume(file) } if (file.mediaType === 'text') { try { textPreview.value = await api.getTextPreview(file.id) } catch {} } } const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '') const selectedThumbnailUrl = computed(() => selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : '') export function useMediaPlayer() { return { selectedFile, textPreview, mediaPlayer, resumePosition, showResumePrompt, resumeRequested, setMediaPlayer, flushVideoProgress, selectFile, seekToResumePosition, resumePlayback, dismissResume, handleVideoPlay, handleVideoTimeUpdate, handleVideoPause, handleVideoEnded, selectedMediaUrl, selectedThumbnailUrl, } } ``` - [ ] **Step 3: Create src/composables/useViewMode.ts** ```typescript import { ref } from 'vue' const viewMode = ref<'list' | 'grid'>('list') export function useViewMode() { return { viewMode } } ``` - [ ] **Step 4: Create src/composables/useModals.ts** ```typescript import { ref } from 'vue' import type ConfirmModal from '../components/ConfirmModal.vue' import type QrCodeModal from '../components/QrCodeModal.vue' const confirmModal = ref | null>(null) const qrModal = ref | null>(null) export function useModals() { return { confirmModal, qrModal } } ``` - [ ] **Step 5: Verify build** ```bash npx vue-tsc --noEmit ``` --- ## Task 3: Create DefaultLayout + View Components - [ ] **Step 1: Create src/layouts/DefaultLayout.vue** Contains ClientHeader + router-view + ConfirmModal + QrCodeModal + error banner. Handles search submission (navigates to /search), back navigation, root loading. ```vue ``` - [ ] **Step 2: Create src/views/RootPickerView.vue** ```vue ``` - [ ] **Step 3: Create src/views/RecentAddedView.vue** Extracts the recent-added section from ClientPage.vue (lines 571-639 with activeTab='recent-added'). - [ ] **Step 4: Create src/views/RecentPlayedView.vue** Same pattern, for recent-played. - [ ] **Step 5: Create src/views/BrowseView.vue** Extracts the browse section (lines 647-761). Uses route params for rootId and path. - [ ] **Step 6: Create src/views/SearchView.vue** Extracts the search section (lines 504-569). Reads `route.query.q` on mount to trigger search. --- ## Task 4: Update Navigation Links - [ ] **Step 1: Update ClientHeader.vue** Replace `` with ``. - [ ] **Step 2: Update AdminPage.vue** Replace `` with ``. - [ ] **Step 3: Verify build** ```bash dotnet build FileShare-API/FileShare-API.csproj cd FileShare-Web-VUE && npx vue-tsc --noEmit ``` --- ## Task 5: Cleanup - [ ] **Step 1: Delete ClientPage.vue** ```bash rm src/components/ClientPage.vue ``` - [ ] **Step 2: Final verification** ```bash npx vue-tsc --noEmit ```