Initial release: Ironpad v0.1.0 - Local-first, file-based project and knowledge management system. Rust backend, Vue 3 frontend, Milkdown editor, Git integration, cross-platform builds. Built with AI using Open Method.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
143
frontend/README.md
Normal file
143
frontend/README.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Ironpad Frontend
|
||||
|
||||
Vue 3 single-page application for Ironpad.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 (requires backend running on port 3000).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Vue 3** with Composition API and `<script setup>`
|
||||
- **TypeScript** for type safety
|
||||
- **Vite** for fast development and builds
|
||||
- **Pinia** for state management
|
||||
- **Vue Router** for navigation
|
||||
- **Milkdown** for WYSIWYG Markdown editing
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API client for backend communication
|
||||
│ └── client.ts
|
||||
├── components/ # Reusable Vue components
|
||||
│ ├── MilkdownEditor.vue # Editor wrapper
|
||||
│ ├── MilkdownEditorCore.vue # Core editor logic
|
||||
│ ├── Sidebar.vue # Navigation sidebar
|
||||
│ ├── GitPanel.vue # Git operations panel
|
||||
│ └── ...
|
||||
├── composables/ # Vue composables
|
||||
│ └── useWebSocket.ts
|
||||
├── router/ # Vue Router configuration
|
||||
│ └── index.ts
|
||||
├── stores/ # Pinia stores
|
||||
│ ├── notes.ts # Notes state
|
||||
│ ├── projects.ts # Projects state
|
||||
│ ├── tasks.ts # Tasks state
|
||||
│ ├── git.ts # Git state
|
||||
│ ├── theme.ts # Theme state
|
||||
│ ├── ui.ts # UI state
|
||||
│ ├── websocket.ts # WebSocket state
|
||||
│ └── workspace.ts # Workspace state
|
||||
├── types/ # TypeScript type definitions
|
||||
│ └── index.ts
|
||||
├── views/ # Route views
|
||||
│ ├── DashboardView.vue # Home page with project cards + task summaries
|
||||
│ ├── ProjectView.vue # Project overview with editor
|
||||
│ ├── ProjectNotesView.vue # Project notes split view
|
||||
│ ├── ProjectsView.vue # Projects management list
|
||||
│ ├── TasksView.vue # Task split view (list + detail)
|
||||
│ ├── CalendarView.vue # Month grid calendar
|
||||
│ └── DailyView.vue # Daily notes
|
||||
├── App.vue # Root component
|
||||
├── main.ts # Entry point
|
||||
└── style.css # Global styles
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### Milkdown Editor
|
||||
|
||||
The editor consists of two components:
|
||||
|
||||
- **MilkdownEditor.vue** — Wrapper component that accepts a `:key` prop for recreation
|
||||
- **MilkdownEditorCore.vue** — Core editor using the `@milkdown/vue` integration
|
||||
|
||||
**Critical Pattern**: When switching between notes/tasks, content MUST be set BEFORE updating the editor key:
|
||||
|
||||
```javascript
|
||||
// CORRECT order:
|
||||
editorContent.value = newContent // Set content first
|
||||
editorKey.value = noteId // Then trigger editor recreation
|
||||
|
||||
// WRONG order (causes stale content):
|
||||
editorKey.value = noteId // Editor recreates with wrong content
|
||||
editorContent.value = newContent // Too late!
|
||||
```
|
||||
|
||||
### Task System Features
|
||||
|
||||
The task view (`TasksView.vue`) includes:
|
||||
|
||||
- **Tag system** — tags stored in YAML frontmatter, filterable via tag bar, autocomplete from project tags
|
||||
- **Subtasks** — tasks with `parent_id` grouped under parents, inline creation, count badges
|
||||
- **Recurring tasks** — daily/weekly/monthly/yearly, auto-creates next on completion
|
||||
- **Due date picker** — inline date input, clearable, color-coded urgency display
|
||||
- **Active/Backlog toggle** — move tasks between states
|
||||
|
||||
### Dashboard (`DashboardView.vue`)
|
||||
|
||||
Cross-project home page showing all projects as cards with:
|
||||
- Active task count, backlog count, overdue count
|
||||
- Top 5 active tasks per project with tags and due dates
|
||||
- Click-through to project or individual task
|
||||
|
||||
### Calendar (`CalendarView.vue`)
|
||||
|
||||
Month grid calendar showing:
|
||||
- Tasks plotted by `due_date` (only tasks with dates appear)
|
||||
- Daily notes shown as blue dots
|
||||
- Color-coded urgency (overdue=red, today=red, soon=yellow)
|
||||
- Navigation: prev/next month, Today button
|
||||
|
||||
### State Management
|
||||
|
||||
Each domain has its own Pinia store:
|
||||
|
||||
- `notesStore` — Standalone notes CRUD
|
||||
- `projectsStore` — Projects list and details
|
||||
- `tasksStore` — Project tasks with active/backlog sections, tag filtering, subtask helpers
|
||||
- `gitStore` — Git status, commits, push/pull
|
||||
- `themeStore` — Dark/light mode
|
||||
- `uiStore` — Search panel, modals
|
||||
- `websocketStore` — Real-time connection state
|
||||
- `workspaceStore` — Active project tracking
|
||||
|
||||
### Auto-save Behavior
|
||||
|
||||
Views implement smart auto-save that:
|
||||
1. Tracks the "last saved content" when a note/task loads
|
||||
2. Only saves when content differs from last saved
|
||||
3. Uses 1-second debounce to batch rapid edits
|
||||
4. Prevents unnecessary saves when just opening items
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server (hot reload)
|
||||
npm run build # Production build to dist/
|
||||
npm run preview # Preview production build
|
||||
npm run lint # Run ESLint
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
The frontend expects the backend API at `http://localhost:3000`. This is configured in `src/api/client.ts`.
|
||||
|
||||
For production, build the frontend and serve from any static host, configuring the API URL as needed.
|
||||
38
frontend/index.html
Normal file
38
frontend/index.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>Ironpad</title>
|
||||
<script>
|
||||
// Apply saved theme immediately to prevent flash
|
||||
(function() {
|
||||
var saved = localStorage.getItem('ironpad-theme');
|
||||
if (saved === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
// Default is dark (already set on html tag)
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
[data-theme="light"] body {
|
||||
background: #ffffff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
4025
frontend/package-lock.json
generated
Normal file
4025
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.39.12",
|
||||
"@milkdown/crepe": "^7.18.0",
|
||||
"@milkdown/kit": "^7.18.0",
|
||||
"@milkdown/vue": "^7.18.0",
|
||||
"@prosemirror-adapter/vue": "^0.4.6",
|
||||
"codemirror": "^6.0.2",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.15.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"typescript": "~5.7.3",
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.10"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
324
frontend/src/App.vue
Normal file
324
frontend/src/App.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
useNotesStore,
|
||||
useProjectsStore,
|
||||
useTasksStore,
|
||||
useUiStore,
|
||||
useGitStore,
|
||||
useWebSocketStore,
|
||||
useWorkspaceStore,
|
||||
useThemeStore
|
||||
} from './stores'
|
||||
import { useWebSocket } from './composables/useWebSocket'
|
||||
import TopBar from './components/TopBar.vue'
|
||||
import Sidebar from './components/Sidebar.vue'
|
||||
import ConflictBanner from './components/ConflictBanner.vue'
|
||||
|
||||
const notesStore = useNotesStore()
|
||||
const projectsStore = useProjectsStore()
|
||||
const tasksStore = useTasksStore()
|
||||
const uiStore = useUiStore()
|
||||
const gitStore = useGitStore()
|
||||
const wsStore = useWebSocketStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// Non-blocking external edit notification (replaces blocking confirm())
|
||||
const externalEditPath = ref<string | null>(null)
|
||||
|
||||
function reloadExternalEdit() {
|
||||
if (notesStore.currentNote && externalEditPath.value) {
|
||||
notesStore.loadNote(notesStore.currentNote.id)
|
||||
}
|
||||
externalEditPath.value = null
|
||||
}
|
||||
|
||||
function dismissExternalEdit() {
|
||||
externalEditPath.value = null
|
||||
}
|
||||
|
||||
// Initialize theme immediately (before mount for no flash)
|
||||
themeStore.init()
|
||||
|
||||
// WebSocket connection with handlers
|
||||
const { connected, clientId } = useWebSocket({
|
||||
onFileCreated: () => {
|
||||
notesStore.loadNotes()
|
||||
projectsStore.loadProjects()
|
||||
},
|
||||
onFileModified: (path) => {
|
||||
notesStore.loadNotes()
|
||||
// Non-blocking notification if current note was modified externally
|
||||
if (notesStore.currentNote?.path === path) {
|
||||
externalEditPath.value = path
|
||||
}
|
||||
},
|
||||
onFileDeleted: () => {
|
||||
notesStore.loadNotes()
|
||||
projectsStore.loadProjects()
|
||||
},
|
||||
onFileLocked: (path, lockClientId, lockType) => {
|
||||
wsStore.addFileLock({
|
||||
path,
|
||||
client_id: lockClientId,
|
||||
lock_type: lockType as 'editor' | 'task_view'
|
||||
})
|
||||
},
|
||||
onFileUnlocked: (path) => {
|
||||
wsStore.removeFileLock(path)
|
||||
},
|
||||
onGitConflict: (files) => {
|
||||
wsStore.setGitConflicts(files)
|
||||
}
|
||||
})
|
||||
|
||||
// Sync WebSocket state to store
|
||||
import { watch } from 'vue'
|
||||
// Note: watch imported separately to maintain original code structure
|
||||
watch(connected, (val) => wsStore.setConnected(val))
|
||||
watch(clientId, (val) => wsStore.setClientId(val))
|
||||
|
||||
// Keyboard shortcuts
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ctrl/Cmd + K for search
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
uiStore.toggleSearch()
|
||||
}
|
||||
// Ctrl/Cmd + S to save
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (notesStore.currentNote) {
|
||||
// Trigger save - the view will handle it
|
||||
const event = new CustomEvent('save-note')
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
// Escape to close panels
|
||||
if (e.key === 'Escape') {
|
||||
uiStore.closeSearch()
|
||||
uiStore.closeTasks()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Load initial data
|
||||
await Promise.all([
|
||||
notesStore.loadNotes(),
|
||||
projectsStore.loadProjects(),
|
||||
tasksStore.loadAllTasks(),
|
||||
gitStore.loadStatus(),
|
||||
gitStore.loadRemote()
|
||||
])
|
||||
|
||||
// Load saved active project
|
||||
await workspaceStore.loadSavedProject()
|
||||
|
||||
// Check for git conflicts and remote status periodically
|
||||
setInterval(() => {
|
||||
gitStore.checkConflicts()
|
||||
gitStore.loadRemote()
|
||||
}, 60000)
|
||||
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app-layout">
|
||||
<TopBar />
|
||||
<div id="app-container">
|
||||
<Sidebar />
|
||||
<main class="main">
|
||||
<ConflictBanner />
|
||||
<div v-if="externalEditPath" class="external-edit-banner">
|
||||
File modified externally.
|
||||
<button @click="reloadExternalEdit" class="primary" style="margin-left: 8px">Reload</button>
|
||||
<button @click="dismissExternalEdit" style="margin-left: 4px">Dismiss</button>
|
||||
</div>
|
||||
<div v-if="uiStore.globalError" class="error-message">
|
||||
{{ uiStore.globalError }}
|
||||
<button @click="uiStore.clearGlobalError" style="margin-left: 12px">Dismiss</button>
|
||||
</div>
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
--sidebar-width: 280px;
|
||||
--header-height: 52px;
|
||||
--font-mono: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
|
||||
/* Light theme as base (will be overridden by dark) */
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-secondary: #f8f9fa;
|
||||
--color-bg-hover: #f1f3f5;
|
||||
--color-border: #e1e4e8;
|
||||
--color-text: #24292e;
|
||||
--color-text-secondary: #586069;
|
||||
--color-primary: #0366d6;
|
||||
--color-danger: #cb2431;
|
||||
--color-success: #28a745;
|
||||
--color-warning: #f0ad4e;
|
||||
}
|
||||
|
||||
/* Light theme explicit */
|
||||
[data-theme="light"] {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-secondary: #f8f9fa;
|
||||
--color-bg-hover: #f1f3f5;
|
||||
--color-border: #e1e4e8;
|
||||
--color-text: #24292e;
|
||||
--color-text-secondary: #586069;
|
||||
--color-primary: #0366d6;
|
||||
--color-danger: #cb2431;
|
||||
--color-success: #28a745;
|
||||
--color-warning: #f0ad4e;
|
||||
}
|
||||
|
||||
/* Dark theme - overrides light when data-theme="dark" */
|
||||
[data-theme="dark"] {
|
||||
--color-bg: #1a1a1a;
|
||||
--color-bg-secondary: #232323;
|
||||
--color-bg-hover: #2d2d2d;
|
||||
--color-border: #3c3c3c;
|
||||
--color-text: #e0e0e0;
|
||||
--color-text-secondary: #999999;
|
||||
--color-primary: #58a6ff;
|
||||
--color-danger: #f85149;
|
||||
--color-success: #56d364;
|
||||
--color-warning: #d29922;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.external-edit-banner {
|
||||
padding: 10px 16px;
|
||||
background: var(--color-warning);
|
||||
color: #1a1a1a;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="search"] {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="search"]:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
240
frontend/src/api/client.ts
Normal file
240
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// API client for Ironpad backend
|
||||
|
||||
import type {
|
||||
Note,
|
||||
NoteSummary,
|
||||
Project,
|
||||
ProjectWithContent,
|
||||
ProjectNote,
|
||||
ProjectNoteWithContent,
|
||||
Task,
|
||||
TaskWithContent,
|
||||
SearchResult,
|
||||
GitStatus,
|
||||
CommitInfo,
|
||||
CommitDetail,
|
||||
DiffInfo,
|
||||
RemoteInfo,
|
||||
DailyNote
|
||||
} from '../types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${url}`, options)
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
// Handle empty responses
|
||||
const contentType = res.headers.get('content-type')
|
||||
if (contentType?.includes('application/json')) {
|
||||
return res.json()
|
||||
}
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
// Notes API
|
||||
export const notesApi = {
|
||||
list: () => request<NoteSummary[]>('/notes'),
|
||||
|
||||
get: (id: string) => request<Note>(`/notes/${encodeURIComponent(id)}`),
|
||||
|
||||
create: () => request<Note>('/notes', { method: 'POST' }),
|
||||
|
||||
update: (id: string, content: string) =>
|
||||
request<Note>(`/notes/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
}),
|
||||
|
||||
delete: (id: string) =>
|
||||
request<void>(`/notes/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// Projects API
|
||||
export const projectsApi = {
|
||||
list: () => request<Project[]>('/projects'),
|
||||
|
||||
get: (id: string) => request<Project>(`/projects/${encodeURIComponent(id)}`),
|
||||
|
||||
getContent: (id: string) =>
|
||||
request<ProjectWithContent>(`/projects/${encodeURIComponent(id)}/content`),
|
||||
|
||||
updateContent: (id: string, content: string) =>
|
||||
request<ProjectWithContent>(`/projects/${encodeURIComponent(id)}/content`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
}),
|
||||
|
||||
create: (name: string) =>
|
||||
request<Project>('/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
}),
|
||||
|
||||
// Project Notes
|
||||
listNotes: (projectId: string) =>
|
||||
request<ProjectNote[]>(`/projects/${encodeURIComponent(projectId)}/notes`),
|
||||
|
||||
getNote: (projectId: string, noteId: string) =>
|
||||
request<ProjectNoteWithContent>(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`),
|
||||
|
||||
createNote: (projectId: string, title?: string) =>
|
||||
request<ProjectNoteWithContent>(`/projects/${encodeURIComponent(projectId)}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title })
|
||||
}),
|
||||
|
||||
updateNote: (projectId: string, noteId: string, content: string) =>
|
||||
request<ProjectNoteWithContent>(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
}),
|
||||
|
||||
deleteNote: (projectId: string, noteId: string) =>
|
||||
request<void>(`/projects/${encodeURIComponent(projectId)}/notes/${encodeURIComponent(noteId)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Tasks API (file-based tasks)
|
||||
export const tasksApi = {
|
||||
// List all tasks across all projects
|
||||
listAll: () => request<Task[]>('/tasks'),
|
||||
|
||||
// List tasks for a specific project
|
||||
list: (projectId: string) =>
|
||||
request<Task[]>(`/projects/${encodeURIComponent(projectId)}/tasks`),
|
||||
|
||||
// Get a single task with content
|
||||
get: (projectId: string, taskId: string) =>
|
||||
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`),
|
||||
|
||||
// Create a new task
|
||||
create: (projectId: string, title: string, section?: string, parentId?: string) =>
|
||||
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, section, parent_id: parentId || undefined })
|
||||
}),
|
||||
|
||||
// Update task content (markdown body)
|
||||
updateContent: (projectId: string, taskId: string, content: string) =>
|
||||
request<TaskWithContent>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
}),
|
||||
|
||||
// Toggle task completion
|
||||
toggle: (projectId: string, taskId: string) =>
|
||||
request<Task>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/toggle`, {
|
||||
method: 'PUT'
|
||||
}),
|
||||
|
||||
// Update task metadata
|
||||
updateMeta: (projectId: string, taskId: string, meta: { title?: string; section?: string; priority?: string; due_date?: string; is_active?: boolean; tags?: string[]; recurrence?: string; recurrence_interval?: number }) =>
|
||||
request<Task>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}/meta`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(meta)
|
||||
}),
|
||||
|
||||
// Delete (archive) a task
|
||||
delete: (projectId: string, taskId: string) =>
|
||||
request<void>(`/projects/${encodeURIComponent(projectId)}/tasks/${encodeURIComponent(taskId)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Search API
|
||||
export const searchApi = {
|
||||
search: (query: string) =>
|
||||
request<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
|
||||
}
|
||||
|
||||
// Git API
|
||||
export const gitApi = {
|
||||
status: () => request<GitStatus>('/git/status'),
|
||||
|
||||
commit: (message?: string) =>
|
||||
request<CommitInfo>('/git/commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message })
|
||||
}),
|
||||
|
||||
push: () => request<{ success: boolean; message: string }>('/git/push', { method: 'POST' }),
|
||||
|
||||
conflicts: () => request<string[]>('/git/conflicts'),
|
||||
|
||||
// Commit history
|
||||
log: (limit?: number) =>
|
||||
request<CommitDetail[]>(`/git/log${limit ? `?limit=${limit}` : ''}`),
|
||||
|
||||
// Working directory diff (uncommitted changes)
|
||||
diff: () => request<DiffInfo>('/git/diff'),
|
||||
|
||||
// Diff for a specific commit
|
||||
commitDiff: (commitId: string) =>
|
||||
request<DiffInfo>(`/git/diff/${encodeURIComponent(commitId)}`),
|
||||
|
||||
// Remote repository info
|
||||
remote: () => request<RemoteInfo | null>('/git/remote'),
|
||||
|
||||
// Fetch from remote
|
||||
fetch: () => request<{ success: boolean; message: string }>('/git/fetch', { method: 'POST' })
|
||||
}
|
||||
|
||||
// Daily Notes API
|
||||
export const dailyApi = {
|
||||
list: () => request<DailyNote[]>('/daily'),
|
||||
|
||||
today: () => request<DailyNote>('/daily/today'),
|
||||
|
||||
get: (date: string) => request<DailyNote>(`/daily/${date}`),
|
||||
|
||||
create: (date: string, content?: string) =>
|
||||
request<DailyNote>(`/daily/${date}`, {
|
||||
method: 'POST',
|
||||
headers: content ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: content ? JSON.stringify({ content }) : undefined
|
||||
}),
|
||||
|
||||
update: (date: string, content: string) =>
|
||||
request<DailyNote>(`/daily/${date}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: content
|
||||
})
|
||||
}
|
||||
|
||||
// Assets API
|
||||
export const assetsApi = {
|
||||
upload: async (file: File, projectId?: string): Promise<{ url: string; filename: string }> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const params = projectId ? `?project=${encodeURIComponent(projectId)}` : ''
|
||||
const res = await fetch(`${API_BASE}/assets/upload${params}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
},
|
||||
|
||||
getUrl: (project: string, filename: string) =>
|
||||
`${API_BASE}/assets/${encodeURIComponent(project)}/${encodeURIComponent(filename)}`
|
||||
}
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
65
frontend/src/components/ConflictBanner.vue
Normal file
65
frontend/src/components/ConflictBanner.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { useGitStore } from '../stores'
|
||||
|
||||
const gitStore = useGitStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="gitStore.hasConflicts" class="conflict-banner">
|
||||
<span class="icon">⚠️</span>
|
||||
<span class="message">
|
||||
Git conflicts detected in {{ gitStore.conflicts.length }} file(s).
|
||||
Please resolve conflicts manually using git or an external tool.
|
||||
</span>
|
||||
<details class="conflict-files">
|
||||
<summary>Show files</summary>
|
||||
<ul>
|
||||
<li v-for="file in gitStore.conflicts" :key="file">{{ file }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conflict-banner {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.conflict-files {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.conflict-files summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conflict-files ul {
|
||||
margin: 8px 0 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.conflict-files li {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
322
frontend/src/components/EditorToolbar.vue
Normal file
322
frontend/src/components/EditorToolbar.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'format': [type: string, extra?: string]
|
||||
'insert-image': [file: File]
|
||||
'insert-link': []
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Formatting actions
|
||||
function bold() { emit('format', 'bold') }
|
||||
function italic() { emit('format', 'italic') }
|
||||
function strikethrough() { emit('format', 'strikethrough') }
|
||||
function heading(level: number) { emit('format', 'heading', String(level)) }
|
||||
function link() { emit('insert-link') }
|
||||
function code() { emit('format', 'code') }
|
||||
function codeBlock() { emit('format', 'codeblock') }
|
||||
function quote() { emit('format', 'quote') }
|
||||
function bulletList() { emit('format', 'bullet') }
|
||||
function numberedList() { emit('format', 'numbered') }
|
||||
function taskList() { emit('format', 'task') }
|
||||
function horizontalRule() { emit('format', 'hr') }
|
||||
|
||||
// Image handling
|
||||
function triggerImageUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
emit('insert-image', file)
|
||||
// Reset input so same file can be selected again
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Heading dropdown
|
||||
const showHeadingDropdown = ref(false)
|
||||
|
||||
function toggleHeadingDropdown() {
|
||||
showHeadingDropdown.value = !showHeadingDropdown.value
|
||||
}
|
||||
|
||||
function selectHeading(level: number) {
|
||||
heading(level)
|
||||
showHeadingDropdown.value = false
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function closeDropdowns() {
|
||||
showHeadingDropdown.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-toolbar" @click.stop>
|
||||
<!-- Hidden file input for image upload -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- Text formatting group -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="bold"
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<span class="icon">B</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="italic"
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<span class="icon italic">I</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="strikethrough"
|
||||
title="Strikethrough"
|
||||
>
|
||||
<span class="icon strikethrough">S</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="code"
|
||||
title="Inline code (Ctrl+`)"
|
||||
>
|
||||
<span class="icon mono"></></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Heading dropdown -->
|
||||
<div class="toolbar-group">
|
||||
<div class="dropdown-container">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="toggleHeadingDropdown"
|
||||
title="Heading"
|
||||
>
|
||||
<span class="icon">H</span>
|
||||
<span class="dropdown-arrow">▾</span>
|
||||
</button>
|
||||
<div v-if="showHeadingDropdown" class="dropdown-menu" @click.stop>
|
||||
<button @click="selectHeading(1)">Heading 1</button>
|
||||
<button @click="selectHeading(2)">Heading 2</button>
|
||||
<button @click="selectHeading(3)">Heading 3</button>
|
||||
<button @click="selectHeading(4)">Heading 4</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Insert group -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="link"
|
||||
title="Insert link"
|
||||
>
|
||||
<span class="icon">🔗</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="triggerImageUpload"
|
||||
title="Insert image"
|
||||
>
|
||||
<span class="icon">🖼️</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="codeBlock"
|
||||
title="Code block"
|
||||
>
|
||||
<span class="icon mono">{}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- List group -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="bulletList"
|
||||
title="Bullet list"
|
||||
>
|
||||
<span class="icon">•</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="numberedList"
|
||||
title="Numbered list"
|
||||
>
|
||||
<span class="icon">1.</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="taskList"
|
||||
title="Task list"
|
||||
>
|
||||
<span class="icon">☐</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- Block group -->
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="quote"
|
||||
title="Quote"
|
||||
>
|
||||
<span class="icon">"</span>
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
@click="horizontalRule"
|
||||
title="Horizontal rule"
|
||||
>
|
||||
<span class="icon">—</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Click outside to close dropdowns -->
|
||||
<div
|
||||
v-if="showHeadingDropdown"
|
||||
class="dropdown-overlay"
|
||||
@click="closeDropdowns"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.toolbar-btn:active {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.toolbar-btn .icon {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toolbar-btn .icon.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.toolbar-btn .icon.strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.toolbar-btn .icon.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolbar-btn .dropdown-arrow {
|
||||
font-size: 8px;
|
||||
margin-left: 2px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--color-border);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
min-width: 120px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dropdown-menu button:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.dropdown-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
</style>
|
||||
879
frontend/src/components/GitPanel.vue
Normal file
879
frontend/src/components/GitPanel.vue
Normal file
@@ -0,0 +1,879 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useGitStore } from '../stores'
|
||||
|
||||
const gitStore = useGitStore()
|
||||
|
||||
// Local state
|
||||
const commitMessage = ref('')
|
||||
const activeTab = ref<'changes' | 'history'>('changes')
|
||||
const expandedFiles = ref<Set<string>>(new Set())
|
||||
|
||||
// Computed
|
||||
const canCommit = computed(() =>
|
||||
gitStore.hasChanges && commitMessage.value.trim().length > 0
|
||||
)
|
||||
|
||||
const remoteDisplay = computed(() => {
|
||||
if (!gitStore.remote) return null
|
||||
const url = gitStore.remote.url
|
||||
// Extract repo name from URL (handles both https and ssh)
|
||||
const match = url.match(/[:/]([^/]+\/[^/.]+)(?:\.git)?$/)
|
||||
return match ? match[1] : url
|
||||
})
|
||||
|
||||
// Actions
|
||||
async function doCommit() {
|
||||
if (!canCommit.value) return
|
||||
const result = await gitStore.commit(commitMessage.value.trim())
|
||||
if (result) {
|
||||
commitMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function doPush() {
|
||||
try {
|
||||
await gitStore.push()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
async function doFetch() {
|
||||
try {
|
||||
await gitStore.fetchRemote()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
function selectCommit(commitId: string) {
|
||||
if (gitStore.selectedCommitId === commitId) {
|
||||
gitStore.clearSelectedCommit()
|
||||
} else {
|
||||
gitStore.loadCommitDiff(commitId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFile(path: string) {
|
||||
if (expandedFiles.value.has(path)) {
|
||||
expandedFiles.value.delete(path)
|
||||
} else {
|
||||
expandedFiles.value.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
case 'added': return '+'
|
||||
case 'modified': return '~'
|
||||
case 'deleted': return '-'
|
||||
case 'renamed': return '→'
|
||||
default: return '?'
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
case 'added': return 'status-added'
|
||||
case 'modified': return 'status-modified'
|
||||
case 'deleted': return 'status-deleted'
|
||||
case 'renamed': return 'status-renamed'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Close panel on escape
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
gitStore.togglePanel()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for panel open to load data
|
||||
watch(() => gitStore.panelOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
gitStore.clearSelectedCommit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="panel">
|
||||
<div v-if="gitStore.panelOpen" class="git-panel-overlay" @click.self="gitStore.togglePanel()">
|
||||
<div class="git-panel">
|
||||
<!-- Header -->
|
||||
<header class="panel-header">
|
||||
<div class="header-left">
|
||||
<span class="branch-icon">⎇</span>
|
||||
<span class="branch-name">{{ gitStore.branch }}</span>
|
||||
<span v-if="gitStore.hasChanges" class="changes-badge">
|
||||
{{ gitStore.changedFilesCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div v-if="gitStore.remote" class="remote-info">
|
||||
<span class="remote-name" :title="gitStore.remote.url">{{ remoteDisplay }}</span>
|
||||
<span v-if="gitStore.remote.ahead > 0" class="ahead">↑{{ gitStore.remote.ahead }}</span>
|
||||
<span v-if="gitStore.remote.behind > 0" class="behind">↓{{ gitStore.remote.behind }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="gitStore.togglePanel()" title="Close (Esc)">×</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Error Banner -->
|
||||
<div v-if="gitStore.error" class="error-banner">
|
||||
{{ gitStore.error }}
|
||||
<button @click="gitStore.clearError()">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
:class="['tab', { active: activeTab === 'changes' }]"
|
||||
@click="activeTab = 'changes'"
|
||||
>
|
||||
Changes
|
||||
<span v-if="gitStore.hasChanges" class="tab-badge">{{ gitStore.changedFilesCount }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="['tab', { active: activeTab === 'history' }]"
|
||||
@click="activeTab = 'history'"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="panel-content">
|
||||
<!-- Changes Tab -->
|
||||
<div v-if="activeTab === 'changes'" class="changes-tab">
|
||||
<!-- Commit Form -->
|
||||
<div class="commit-form">
|
||||
<textarea
|
||||
v-model="commitMessage"
|
||||
placeholder="Commit message..."
|
||||
class="commit-input"
|
||||
rows="2"
|
||||
@keydown.ctrl.enter="doCommit"
|
||||
></textarea>
|
||||
<div class="commit-actions">
|
||||
<button
|
||||
class="commit-btn"
|
||||
:disabled="!canCommit || gitStore.committing"
|
||||
@click="doCommit"
|
||||
>
|
||||
{{ gitStore.committing ? 'Committing...' : 'Commit' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="gitStore.hasRemote"
|
||||
class="push-btn"
|
||||
:disabled="gitStore.pushing"
|
||||
@click="doPush"
|
||||
:title="gitStore.canPush ? `Push ${gitStore.remote?.ahead} commits` : 'Push to remote'"
|
||||
>
|
||||
{{ gitStore.pushing ? '...' : '↑ Push' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="gitStore.hasRemote"
|
||||
class="fetch-btn"
|
||||
:disabled="gitStore.fetching"
|
||||
@click="doFetch"
|
||||
title="Fetch from remote"
|
||||
>
|
||||
{{ gitStore.fetching ? '...' : '↓ Fetch' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changed Files List -->
|
||||
<div v-if="gitStore.hasChanges" class="files-list">
|
||||
<div class="section-header">
|
||||
<span>Staged Changes</span>
|
||||
<span class="stats" v-if="gitStore.workingDiff">
|
||||
<span class="insertions">+{{ gitStore.workingDiff.stats.insertions }}</span>
|
||||
<span class="deletions">-{{ gitStore.workingDiff.stats.deletions }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="gitStore.diffLoading && !gitStore.workingDiff" class="loading">
|
||||
Loading diff...
|
||||
</div>
|
||||
|
||||
<div v-else-if="gitStore.workingDiff" class="diff-files">
|
||||
<div
|
||||
v-for="file in gitStore.workingDiff.files"
|
||||
:key="file.path"
|
||||
class="diff-file"
|
||||
>
|
||||
<div
|
||||
class="file-header"
|
||||
@click="toggleFile(file.path)"
|
||||
>
|
||||
<span :class="['status-icon', getStatusClass(file.status)]">
|
||||
{{ getStatusIcon(file.status) }}
|
||||
</span>
|
||||
<span class="file-path">{{ file.path }}</span>
|
||||
<span class="file-stats">
|
||||
<span v-if="file.additions" class="insertions">+{{ file.additions }}</span>
|
||||
<span v-if="file.deletions" class="deletions">-{{ file.deletions }}</span>
|
||||
</span>
|
||||
<span class="expand-icon">{{ expandedFiles.has(file.path) ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedFiles.has(file.path)" class="file-diff">
|
||||
<div v-for="(hunk, i) in file.hunks" :key="i" class="diff-hunk">
|
||||
<div class="hunk-header">{{ hunk.header }}</div>
|
||||
<div class="diff-lines">
|
||||
<div
|
||||
v-for="(line, j) in hunk.lines"
|
||||
:key="j"
|
||||
:class="['diff-line', {
|
||||
'line-add': line.origin === '+',
|
||||
'line-del': line.origin === '-',
|
||||
'line-ctx': line.origin === ' '
|
||||
}]"
|
||||
>
|
||||
<span class="line-origin">{{ line.origin }}</span>
|
||||
<span class="line-content">{{ line.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-changes">
|
||||
<span class="icon">✓</span>
|
||||
<span>No changes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Tab -->
|
||||
<div v-if="activeTab === 'history'" class="history-tab">
|
||||
<div v-if="gitStore.historyLoading && !gitStore.history.length" class="loading">
|
||||
Loading history...
|
||||
</div>
|
||||
|
||||
<div v-else-if="gitStore.history.length === 0" class="no-history">
|
||||
No commits yet
|
||||
</div>
|
||||
|
||||
<div v-else class="commit-list">
|
||||
<div
|
||||
v-for="commit in gitStore.history"
|
||||
:key="commit.id"
|
||||
:class="['commit-item', { selected: gitStore.selectedCommitId === commit.id }]"
|
||||
@click="selectCommit(commit.id)"
|
||||
>
|
||||
<div class="commit-header">
|
||||
<span class="commit-id">{{ commit.short_id }}</span>
|
||||
<span class="commit-time">{{ formatTimestamp(commit.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="commit-message">{{ commit.message }}</div>
|
||||
<div class="commit-meta">
|
||||
<span class="commit-author">{{ commit.author }}</span>
|
||||
<span v-if="commit.files_changed" class="commit-files">
|
||||
{{ commit.files_changed }} file{{ commit.files_changed !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Commit Diff (expanded) -->
|
||||
<div
|
||||
v-if="gitStore.selectedCommitId === commit.id && gitStore.selectedCommitDiff"
|
||||
class="commit-diff"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-for="file in gitStore.selectedCommitDiff.files"
|
||||
:key="file.path"
|
||||
class="diff-file"
|
||||
>
|
||||
<div
|
||||
class="file-header"
|
||||
@click="toggleFile(`${commit.id}:${file.path}`)"
|
||||
>
|
||||
<span :class="['status-icon', getStatusClass(file.status)]">
|
||||
{{ getStatusIcon(file.status) }}
|
||||
</span>
|
||||
<span class="file-path">{{ file.path }}</span>
|
||||
<span class="file-stats">
|
||||
<span v-if="file.additions" class="insertions">+{{ file.additions }}</span>
|
||||
<span v-if="file.deletions" class="deletions">-{{ file.deletions }}</span>
|
||||
</span>
|
||||
<span class="expand-icon">
|
||||
{{ expandedFiles.has(`${commit.id}:${file.path}`) ? '▼' : '▶' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="expandedFiles.has(`${commit.id}:${file.path}`)" class="file-diff">
|
||||
<div v-for="(hunk, i) in file.hunks" :key="i" class="diff-hunk">
|
||||
<div class="hunk-header">{{ hunk.header }}</div>
|
||||
<div class="diff-lines">
|
||||
<div
|
||||
v-for="(line, j) in hunk.lines"
|
||||
:key="j"
|
||||
:class="['diff-line', {
|
||||
'line-add': line.origin === '+',
|
||||
'line-del': line.origin === '-',
|
||||
'line-ctx': line.origin === ' '
|
||||
}]"
|
||||
>
|
||||
<span class="line-origin">{{ line.origin }}</span>
|
||||
<span class="line-content">{{ line.content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="gitStore.selectedCommitId === commit.id && gitStore.diffLoading"
|
||||
class="loading"
|
||||
>
|
||||
Loading diff...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Panel Overlay */
|
||||
.git-panel-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Panel */
|
||||
.git-panel {
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.panel-enter-active,
|
||||
.panel-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.panel-enter-active .git-panel,
|
||||
.panel-leave-active .git-panel {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.panel-enter-from,
|
||||
.panel-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.panel-enter-from .git-panel,
|
||||
.panel-leave-to .git-panel {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.branch-icon {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.branch-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.changes-badge {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.remote-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.remote-name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ahead {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.behind {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-banner button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Changes Tab */
|
||||
.changes-tab {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.commit-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.commit-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.commit-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.commit-input::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.commit-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.commit-btn,
|
||||
.push-btn,
|
||||
.fetch-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.commit-btn {
|
||||
flex: 1;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.commit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.push-btn,
|
||||
.fetch-btn {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.push-btn:hover:not(:disabled),
|
||||
.fetch-btn:hover:not(:disabled) {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.push-btn:disabled,
|
||||
.fetch-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Files List */
|
||||
.files-list {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.insertions {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.deletions {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Diff Files */
|
||||
.diff-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.diff-file {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-header:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-added { color: var(--color-success); }
|
||||
.status-modified { color: var(--color-warning); }
|
||||
.status-deleted { color: var(--color-danger); }
|
||||
.status-renamed { color: var(--color-primary); }
|
||||
|
||||
.file-path {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.file-stats {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* File Diff Content */
|
||||
.file-diff {
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.diff-hunk {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hunk-header {
|
||||
padding: 4px 12px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.diff-lines {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: flex;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.line-origin {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.line-content {
|
||||
flex: 1;
|
||||
padding-right: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.line-add {
|
||||
background: rgba(46, 160, 67, 0.15);
|
||||
}
|
||||
|
||||
.line-add .line-origin {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.line-del {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
}
|
||||
|
||||
.line-del .line-origin {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* No Changes */
|
||||
.no-changes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-secondary);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.no-changes .icon {
|
||||
font-size: 32px;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* History Tab */
|
||||
.history-tab {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.commit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.commit-item {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.commit-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.commit-item.selected {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.commit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.commit-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.commit-time {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commit-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.commit-diff {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.no-history {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
123
frontend/src/components/GitStatus.vue
Normal file
123
frontend/src/components/GitStatus.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { useGitStore } from '../stores'
|
||||
import GitPanel from './GitPanel.vue'
|
||||
|
||||
const gitStore = useGitStore()
|
||||
|
||||
function openPanel() {
|
||||
gitStore.togglePanel()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Git Panel (slides in from right) -->
|
||||
<GitPanel />
|
||||
|
||||
<div v-if="gitStore.isRepo" class="git-status" @click="openPanel" title="Open Git panel">
|
||||
<div class="git-info">
|
||||
<span :class="['git-indicator', { 'has-changes': gitStore.hasChanges }]">●</span>
|
||||
<span class="git-branch">{{ gitStore.branch }}</span>
|
||||
<span v-if="gitStore.hasChanges" class="git-changes">
|
||||
{{ gitStore.changedFilesCount }} changes
|
||||
</span>
|
||||
</div>
|
||||
<div class="git-sync-status">
|
||||
<span v-if="gitStore.remote?.ahead" class="sync-ahead" title="Commits ahead">
|
||||
↑{{ gitStore.remote.ahead }}
|
||||
</span>
|
||||
<span v-if="gitStore.remote?.behind" class="sync-behind" title="Commits behind">
|
||||
↓{{ gitStore.remote.behind }}
|
||||
</span>
|
||||
<span class="expand-hint">⋯</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflict Warning -->
|
||||
<div v-if="gitStore.hasConflicts" class="git-conflicts" @click="openPanel">
|
||||
⚠️ Git conflicts detected ({{ gitStore.conflicts.length }} files)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.git-status {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.git-status:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.git-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.git-indicator {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.git-indicator.has-changes {
|
||||
color: var(--color-primary);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.git-branch {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.git-changes {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.git-sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sync-ahead {
|
||||
color: var(--color-success);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sync-behind {
|
||||
color: var(--color-warning);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expand-hint {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.git-conflicts {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.git-conflicts:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
</style>
|
||||
533
frontend/src/components/MarkdownEditor.vue
Normal file
533
frontend/src/components/MarkdownEditor.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, shallowRef } from 'vue'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from '@codemirror/language'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { useThemeStore } from '../stores'
|
||||
import { assetsApi } from '../api/client'
|
||||
import EditorToolbar from './EditorToolbar.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
placeholder?: string
|
||||
projectId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const editorContainer = ref<HTMLDivElement | null>(null)
|
||||
const editorView = shallowRef<EditorView | null>(null)
|
||||
const uploading = ref(false)
|
||||
|
||||
// Check if dark mode is active
|
||||
function isDarkMode(): boolean {
|
||||
return themeStore.getEffectiveTheme() === 'dark'
|
||||
}
|
||||
|
||||
// Create custom theme for light mode
|
||||
const lightTheme = EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'var(--color-bg)',
|
||||
color: 'var(--color-text)'
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: "'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace",
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
padding: '16px 0'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-bg-secondary)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid var(--color-border)'
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--color-border)'
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.03)'
|
||||
},
|
||||
'&.cm-focused .cm-cursor': {
|
||||
borderLeftColor: 'var(--color-primary)'
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'rgba(3, 102, 214, 0.2)'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
// Create dark theme extension
|
||||
const darkTheme = EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'var(--color-bg)',
|
||||
color: 'var(--color-text)'
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: "'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace",
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
padding: '16px 0'
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-bg-secondary)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid var(--color-border)'
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--color-border)'
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)'
|
||||
},
|
||||
'&.cm-focused .cm-cursor': {
|
||||
borderLeftColor: 'var(--color-primary)'
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'rgba(79, 195, 247, 0.2)'
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto'
|
||||
}
|
||||
}, { dark: true })
|
||||
|
||||
// Markdown formatting keybindings
|
||||
function toggleBold(view: EditorView): boolean {
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
|
||||
if (selectedText) {
|
||||
// Check if already bold
|
||||
const isBold = selectedText.startsWith('**') && selectedText.endsWith('**')
|
||||
let newText: string
|
||||
|
||||
if (isBold) {
|
||||
newText = selectedText.slice(2, -2)
|
||||
} else {
|
||||
newText = `**${selectedText}**`
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: newText }
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleItalic(view: EditorView): boolean {
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
|
||||
if (selectedText) {
|
||||
// Check if already italic (single asterisk, not bold)
|
||||
const isItalic = selectedText.startsWith('*') && selectedText.endsWith('*') &&
|
||||
!selectedText.startsWith('**')
|
||||
let newText: string
|
||||
|
||||
if (isItalic) {
|
||||
newText = selectedText.slice(1, -1)
|
||||
} else {
|
||||
newText = `*${selectedText}*`
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: newText }
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function toggleCode(view: EditorView): boolean {
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
|
||||
if (selectedText) {
|
||||
const isCode = selectedText.startsWith('`') && selectedText.endsWith('`')
|
||||
let newText: string
|
||||
|
||||
if (isCode) {
|
||||
newText = selectedText.slice(1, -1)
|
||||
} else {
|
||||
newText = `\`${selectedText}\``
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: newText }
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const markdownKeymap = keymap.of([
|
||||
{ key: 'Mod-b', run: toggleBold },
|
||||
{ key: 'Mod-i', run: toggleItalic },
|
||||
{ key: 'Mod-`', run: toggleCode }
|
||||
])
|
||||
|
||||
// Toolbar format handler
|
||||
function handleFormat(type: string, extra?: string) {
|
||||
const view = editorView.value
|
||||
if (!view) return
|
||||
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
const line = view.state.doc.lineAt(from)
|
||||
const lineStart = line.from
|
||||
const lineText = line.text
|
||||
|
||||
let insert = ''
|
||||
let newFrom = from
|
||||
let newTo = to
|
||||
|
||||
switch (type) {
|
||||
case 'bold':
|
||||
if (selectedText) {
|
||||
insert = `**${selectedText}**`
|
||||
} else {
|
||||
insert = '**bold**'
|
||||
newFrom = from + 2
|
||||
newTo = from + 6
|
||||
}
|
||||
break
|
||||
|
||||
case 'italic':
|
||||
if (selectedText) {
|
||||
insert = `*${selectedText}*`
|
||||
} else {
|
||||
insert = '*italic*'
|
||||
newFrom = from + 1
|
||||
newTo = from + 7
|
||||
}
|
||||
break
|
||||
|
||||
case 'strikethrough':
|
||||
if (selectedText) {
|
||||
insert = `~~${selectedText}~~`
|
||||
} else {
|
||||
insert = '~~strikethrough~~'
|
||||
newFrom = from + 2
|
||||
newTo = from + 15
|
||||
}
|
||||
break
|
||||
|
||||
case 'code':
|
||||
if (selectedText) {
|
||||
insert = `\`${selectedText}\``
|
||||
} else {
|
||||
insert = '`code`'
|
||||
newFrom = from + 1
|
||||
newTo = from + 5
|
||||
}
|
||||
break
|
||||
|
||||
case 'codeblock':
|
||||
if (selectedText) {
|
||||
insert = `\n\`\`\`\n${selectedText}\n\`\`\`\n`
|
||||
} else {
|
||||
insert = '\n```\ncode\n```\n'
|
||||
newFrom = from + 5
|
||||
newTo = from + 9
|
||||
}
|
||||
break
|
||||
|
||||
case 'heading':
|
||||
const level = parseInt(extra || '2')
|
||||
const prefix = '#'.repeat(level) + ' '
|
||||
// Check if line already has heading
|
||||
const headingMatch = lineText.match(/^(#{1,6})\s/)
|
||||
if (headingMatch) {
|
||||
// Replace existing heading
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart + headingMatch[0].length, insert: prefix }
|
||||
})
|
||||
return
|
||||
} else {
|
||||
// Insert at line start
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: prefix }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
case 'quote':
|
||||
// Add > at start of each selected line
|
||||
if (selectedText.includes('\n')) {
|
||||
insert = selectedText.split('\n').map(l => `> ${l}`).join('\n')
|
||||
} else if (selectedText) {
|
||||
insert = `> ${selectedText}`
|
||||
} else {
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: '> ' }
|
||||
})
|
||||
return
|
||||
}
|
||||
break
|
||||
|
||||
case 'bullet':
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: '- ' }
|
||||
})
|
||||
return
|
||||
|
||||
case 'numbered':
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: '1. ' }
|
||||
})
|
||||
return
|
||||
|
||||
case 'task':
|
||||
view.dispatch({
|
||||
changes: { from: lineStart, to: lineStart, insert: '- [ ] ' }
|
||||
})
|
||||
return
|
||||
|
||||
case 'hr':
|
||||
insert = '\n---\n'
|
||||
break
|
||||
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: newFrom, head: newTo }
|
||||
})
|
||||
view.focus()
|
||||
}
|
||||
|
||||
// Link insertion with prompt
|
||||
function handleInsertLink() {
|
||||
const view = editorView.value
|
||||
if (!view) return
|
||||
|
||||
const { from, to } = view.state.selection.main
|
||||
const selectedText = view.state.sliceDoc(from, to)
|
||||
|
||||
const url = prompt('Enter URL:', 'https://')
|
||||
if (!url) return
|
||||
|
||||
const linkText = selectedText || 'link text'
|
||||
const insert = `[${linkText}](${url})`
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert }
|
||||
})
|
||||
view.focus()
|
||||
}
|
||||
|
||||
// Image upload and insertion
|
||||
async function handleInsertImage(file: File) {
|
||||
const view = editorView.value
|
||||
if (!view) return
|
||||
|
||||
uploading.value = true
|
||||
|
||||
try {
|
||||
// Upload via assets API
|
||||
const result = await assetsApi.upload(file, props.projectId)
|
||||
|
||||
// Insert markdown image
|
||||
const { from, to } = view.state.selection.main
|
||||
const altText = file.name.replace(/\.[^/.]+$/, '') // filename without extension
|
||||
const insert = ``
|
||||
|
||||
view.dispatch({
|
||||
changes: { from, to, insert }
|
||||
})
|
||||
view.focus()
|
||||
} catch (err) {
|
||||
console.error('Failed to upload image:', err)
|
||||
alert('Failed to upload image: ' + (err instanceof Error ? err.message : 'Unknown error'))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function createEditorState(content: string): EditorState {
|
||||
const dark = isDarkMode()
|
||||
|
||||
const extensions = [
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
highlightActiveLineGutter(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
markdown({ base: markdownLanguage }),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
dark ? darkTheme : lightTheme,
|
||||
dark ? oneDark : [],
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...historyKeymap
|
||||
]),
|
||||
markdownKeymap,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
emit('update:modelValue', update.state.doc.toString())
|
||||
}
|
||||
}),
|
||||
EditorState.readOnly.of(props.readonly ?? false),
|
||||
EditorView.editable.of(!(props.readonly ?? false))
|
||||
].flat()
|
||||
|
||||
return EditorState.create({
|
||||
doc: content,
|
||||
extensions
|
||||
})
|
||||
}
|
||||
|
||||
function initEditor() {
|
||||
if (!editorContainer.value) return
|
||||
|
||||
const state = createEditorState(props.modelValue)
|
||||
|
||||
editorView.value = new EditorView({
|
||||
state,
|
||||
parent: editorContainer.value
|
||||
})
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
if (editorView.value) {
|
||||
editorView.value.destroy()
|
||||
editorView.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for external content changes
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (!editorView.value) return
|
||||
|
||||
const currentValue = editorView.value.state.doc.toString()
|
||||
if (newValue !== currentValue) {
|
||||
editorView.value.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editorView.value.state.doc.length,
|
||||
insert: newValue
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for readonly changes
|
||||
watch(() => props.readonly, () => {
|
||||
if (!editorView.value) return
|
||||
|
||||
// Recreate editor with new readonly state
|
||||
const content = editorView.value.state.doc.toString()
|
||||
destroyEditor()
|
||||
initEditor()
|
||||
|
||||
// Restore content
|
||||
if (editorView.value && content) {
|
||||
editorView.value.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: editorView.value.state.doc.length,
|
||||
insert: content
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for theme changes
|
||||
function handleThemeChange() {
|
||||
if (!editorView.value) return
|
||||
destroyEditor()
|
||||
initEditor()
|
||||
// Content will be restored via props.modelValue
|
||||
}
|
||||
|
||||
// Watch theme store changes
|
||||
watch(() => themeStore.getEffectiveTheme(), handleThemeChange)
|
||||
|
||||
onMounted(() => {
|
||||
initEditor()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyEditor()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-editor-wrapper" :class="{ readonly, uploading }">
|
||||
<!-- Toolbar (shows when not readonly) -->
|
||||
<EditorToolbar
|
||||
v-if="!readonly"
|
||||
@format="handleFormat"
|
||||
@insert-link="handleInsertLink"
|
||||
@insert-image="handleInsertImage"
|
||||
/>
|
||||
|
||||
<!-- Upload indicator -->
|
||||
<div v-if="uploading" class="upload-indicator">
|
||||
Uploading image...
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div ref="editorContainer" class="markdown-editor"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-editor-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-editor :deep(.cm-editor) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-editor :deep(.cm-scroller) {
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.markdown-editor-wrapper.readonly .markdown-editor :deep(.cm-editor) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.upload-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.markdown-editor-wrapper.uploading .markdown-editor {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
204
frontend/src/components/MarkdownPreview.vue
Normal file
204
frontend/src/components/MarkdownPreview.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
}>()
|
||||
|
||||
// Initialize markdown-it with CommonMark preset
|
||||
const md = ref<MarkdownIt | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
md.value = new MarkdownIt({
|
||||
html: false, // Disable HTML tags in source
|
||||
xhtmlOut: true, // Use '/' to close single tags (<br />)
|
||||
breaks: true, // Convert '\n' in paragraphs into <br>
|
||||
linkify: true, // Autoconvert URL-like text to links
|
||||
typographer: true, // Enable smartquotes and other typographic replacements
|
||||
})
|
||||
})
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
if (!md.value) return ''
|
||||
|
||||
try {
|
||||
return md.value.render(props.content)
|
||||
} catch (e) {
|
||||
console.error('Markdown rendering error:', e)
|
||||
return `<pre>${props.content}</pre>`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-preview">
|
||||
<div class="preview-content" v-html="renderedHtml"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-preview {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
max-width: 800px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Markdown styling */
|
||||
.preview-content :deep(h1) {
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
margin: 0.67em 0;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.preview-content :deep(h2) {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.5em;
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.preview-content :deep(h3) {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.5em;
|
||||
}
|
||||
|
||||
.preview-content :deep(h4),
|
||||
.preview-content :deep(h5),
|
||||
.preview-content :deep(h6) {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin: 1em 0 0.5em;
|
||||
}
|
||||
|
||||
.preview-content :deep(p) {
|
||||
margin: 0.5em 0 1em;
|
||||
}
|
||||
|
||||
.preview-content :deep(ul),
|
||||
.preview-content :deep(ol) {
|
||||
margin: 0.5em 0 1em;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.preview-content :deep(li) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.preview-content :deep(li > ul),
|
||||
.preview-content :deep(li > ol) {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.preview-content :deep(blockquote) {
|
||||
margin: 1em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 4px solid var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.preview-content :deep(blockquote p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-content :deep(code) {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.2em 0.4em;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-content :deep(pre) {
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.preview-content :deep(pre code) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preview-content :deep(hr) {
|
||||
margin: 2em 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.preview-content :deep(a) {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.preview-content :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.preview-content :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.preview-content :deep(table) {
|
||||
width: 100%;
|
||||
margin: 1em 0;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.preview-content :deep(th),
|
||||
.preview-content :deep(td) {
|
||||
padding: 0.5em 1em;
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.preview-content :deep(th) {
|
||||
background: var(--color-bg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-content :deep(tr:nth-child(even)) {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* Task list styling */
|
||||
.preview-content :deep(input[type="checkbox"]) {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.preview-content :deep(li.task-list-item) {
|
||||
list-style: none;
|
||||
margin-left: -1.5em;
|
||||
}
|
||||
|
||||
/* Strong and emphasis */
|
||||
.preview-content :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-content :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.preview-content :deep(del) {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/components/MilkdownEditor.vue
Normal file
108
frontend/src/components/MilkdownEditor.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { MilkdownProvider } from '@milkdown/vue'
|
||||
import { Crepe } from '@milkdown/crepe'
|
||||
import { useThemeStore } from '../stores'
|
||||
import { assetsApi } from '../api/client'
|
||||
import MilkdownEditorCore from './MilkdownEditorCore.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
placeholder?: string
|
||||
projectId?: string
|
||||
editorKey?: string | number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const uploading = ref(false)
|
||||
const editorInstance = ref<Crepe | null>(null)
|
||||
|
||||
const isDarkMode = computed(() => themeStore.getEffectiveTheme() === 'dark')
|
||||
|
||||
// Handle content updates from the core editor
|
||||
function handleContentUpdate(value: string) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// Store editor instance when ready
|
||||
function handleEditorReady(crepe: Crepe) {
|
||||
editorInstance.value = crepe
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="milkdown-editor-wrapper"
|
||||
:class="{
|
||||
readonly,
|
||||
uploading,
|
||||
'dark-mode': isDarkMode
|
||||
}"
|
||||
>
|
||||
<!-- Upload indicator -->
|
||||
<div v-if="uploading" class="upload-indicator">
|
||||
Uploading image...
|
||||
</div>
|
||||
|
||||
<!-- Milkdown Editor with Crepe (includes built-in toolbar) -->
|
||||
<!-- CRITICAL: Key the entire container to force full remount when switching notes/tasks -->
|
||||
<!-- This ensures the Milkdown editor instance is completely recreated, not just updated -->
|
||||
<div :key="editorKey" class="milkdown-container" :class="{ 'is-readonly': readonly }">
|
||||
<MilkdownProvider>
|
||||
<MilkdownEditorCore
|
||||
:model-value="modelValue"
|
||||
:readonly="readonly"
|
||||
@update:model-value="handleContentUpdate"
|
||||
@editor-ready="handleEditorReady"
|
||||
/>
|
||||
</MilkdownProvider>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.milkdown-editor-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.milkdown-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.milkdown-container.is-readonly {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.upload-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 10;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.milkdown-editor-wrapper.uploading .milkdown-container {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
442
frontend/src/components/MilkdownEditorCore.vue
Normal file
442
frontend/src/components/MilkdownEditorCore.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, computed, ref, onUnmounted } from 'vue'
|
||||
import { Milkdown, useEditor } from '@milkdown/vue'
|
||||
import { Crepe, CrepeFeature } from '@milkdown/crepe'
|
||||
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'
|
||||
import { replaceAll } from '@milkdown/kit/utils'
|
||||
import { useThemeStore } from '../stores'
|
||||
|
||||
// Import Crepe common styles (layout, components)
|
||||
import '@milkdown/crepe/theme/common/style.css'
|
||||
|
||||
// Import the frame theme (light) - we override for dark mode via CSS
|
||||
import '@milkdown/crepe/theme/frame.css'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'editor-ready': [editor: Crepe]
|
||||
}>()
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
// Use mode directly for reactivity, then compute effective theme
|
||||
const isDarkMode = computed(() => {
|
||||
const mode = themeStore.mode
|
||||
if (mode === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return mode === 'dark'
|
||||
})
|
||||
|
||||
// CRITICAL: Use refs for instance-scoped state that must reset when component recreates
|
||||
// These were previously module-level lets which caused stale content bugs when switching notes/tasks
|
||||
const isExternalUpdate = ref(false)
|
||||
const currentContent = ref(props.modelValue)
|
||||
const pendingContent = ref<string | null>(null)
|
||||
const editorReady = ref(false)
|
||||
|
||||
// Cleanup any pending timeouts/intervals on unmount
|
||||
let externalUpdateTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingRetryInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onUnmounted(() => {
|
||||
if (externalUpdateTimeout) {
|
||||
clearTimeout(externalUpdateTimeout)
|
||||
externalUpdateTimeout = null
|
||||
}
|
||||
if (pendingRetryInterval) {
|
||||
clearInterval(pendingRetryInterval)
|
||||
pendingRetryInterval = null
|
||||
}
|
||||
})
|
||||
|
||||
// Try to apply pending content - called when editor might be ready
|
||||
function tryApplyPendingContent() {
|
||||
if (pendingContent.value === null) return false
|
||||
|
||||
const crepe = get()
|
||||
if (!crepe) return false
|
||||
|
||||
try {
|
||||
const editor = crepe.editor
|
||||
if (!editor || typeof editor.action !== 'function') return false
|
||||
|
||||
console.log('[MilkdownEditorCore] Applying pending content, length:', pendingContent.value.length)
|
||||
isExternalUpdate.value = true
|
||||
editor.action(replaceAll(pendingContent.value))
|
||||
currentContent.value = pendingContent.value
|
||||
pendingContent.value = null
|
||||
editorReady.value = true
|
||||
|
||||
if (externalUpdateTimeout) clearTimeout(externalUpdateTimeout)
|
||||
externalUpdateTimeout = setTimeout(() => { isExternalUpdate.value = false }, 50)
|
||||
|
||||
// Stop retry interval if running
|
||||
if (pendingRetryInterval) {
|
||||
clearInterval(pendingRetryInterval)
|
||||
pendingRetryInterval = null
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.warn('[MilkdownEditorCore] Failed to apply pending content:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const { get, loading } = useEditor((root) => {
|
||||
const crepe = new Crepe({
|
||||
root,
|
||||
defaultValue: props.modelValue,
|
||||
features: {
|
||||
[CrepeFeature.CodeMirror]: true,
|
||||
[CrepeFeature.ListItem]: true,
|
||||
[CrepeFeature.LinkTooltip]: true,
|
||||
[CrepeFeature.Cursor]: true,
|
||||
[CrepeFeature.ImageBlock]: true,
|
||||
[CrepeFeature.BlockEdit]: true,
|
||||
[CrepeFeature.Toolbar]: true,
|
||||
[CrepeFeature.Placeholder]: true,
|
||||
[CrepeFeature.Table]: true,
|
||||
[CrepeFeature.Latex]: false, // Disable LaTeX for now
|
||||
},
|
||||
featureConfigs: {
|
||||
[CrepeFeature.Placeholder]: {
|
||||
text: 'Start writing...',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Add listener plugin for content changes
|
||||
crepe.editor
|
||||
.config((ctx) => {
|
||||
const listenerHandler = ctx.get(listenerCtx)
|
||||
listenerHandler.markdownUpdated((ctx, markdown, prevMarkdown) => {
|
||||
// CRITICAL: Only emit content changes if:
|
||||
// 1. Content actually changed
|
||||
// 2. We're not in the middle of an external update
|
||||
// 3. Editor is ready (not still applying pending content)
|
||||
// 4. No pending content waiting to be applied (prevents emitting stale content)
|
||||
if (markdown !== prevMarkdown && !isExternalUpdate.value && editorReady.value && pendingContent.value === null) {
|
||||
console.log('[MilkdownEditorCore] User edit, emitting content length:', markdown.length)
|
||||
currentContent.value = markdown
|
||||
emit('update:modelValue', markdown)
|
||||
} else if (markdown !== prevMarkdown) {
|
||||
console.log('[MilkdownEditorCore] Content changed but not emitting:', {
|
||||
isExternalUpdate: isExternalUpdate.value,
|
||||
editorReady: editorReady.value,
|
||||
hasPendingContent: pendingContent.value !== null
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.use(listener)
|
||||
|
||||
return crepe
|
||||
})
|
||||
|
||||
// Emit editor instance when ready, and apply any pending content
|
||||
watch(loading, (isLoading) => {
|
||||
if (!isLoading) {
|
||||
const crepe = get()
|
||||
if (crepe) {
|
||||
emit('editor-ready', crepe)
|
||||
|
||||
// Try to apply pending content - might need retries if editor not fully ready
|
||||
if (pendingContent.value !== null) {
|
||||
if (!tryApplyPendingContent()) {
|
||||
// Editor not ready yet, start retry interval
|
||||
console.log('[MilkdownEditorCore] Editor not ready after loading, starting retry')
|
||||
startPendingRetry()
|
||||
}
|
||||
} else {
|
||||
editorReady.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Start a retry interval for applying pending content
|
||||
function startPendingRetry() {
|
||||
if (pendingRetryInterval) return // Already retrying
|
||||
|
||||
let retryCount = 0
|
||||
const maxRetries = 20 // 2 seconds max
|
||||
|
||||
pendingRetryInterval = setInterval(() => {
|
||||
retryCount++
|
||||
console.log('[MilkdownEditorCore] Retry attempt', retryCount, 'to apply pending content')
|
||||
|
||||
if (tryApplyPendingContent()) {
|
||||
// Success - interval cleared in tryApplyPendingContent
|
||||
return
|
||||
}
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
console.error('[MilkdownEditorCore] Failed to apply pending content after', maxRetries, 'retries')
|
||||
if (pendingRetryInterval) {
|
||||
clearInterval(pendingRetryInterval)
|
||||
pendingRetryInterval = null
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// Watch for external content changes
|
||||
watch(() => props.modelValue, async (newValue) => {
|
||||
console.log('[MilkdownEditorCore] modelValue changed, length:', newValue?.length, 'loading:', loading.value, 'currentContent length:', currentContent.value?.length, 'editorReady:', editorReady.value)
|
||||
|
||||
// If editor is still loading, store the content to apply after load
|
||||
if (loading.value) {
|
||||
console.log('[MilkdownEditorCore] Editor loading, storing as pending content')
|
||||
pendingContent.value = newValue
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if content hasn't actually changed
|
||||
if (newValue === currentContent.value) {
|
||||
console.log('[MilkdownEditorCore] Content unchanged, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
// Store new content as pending and try to apply
|
||||
pendingContent.value = newValue
|
||||
|
||||
if (!tryApplyPendingContent()) {
|
||||
// Editor not ready, start retry mechanism
|
||||
console.log('[MilkdownEditorCore] Editor not ready, starting retry for new content')
|
||||
startPendingRetry()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['crepe-editor', { 'dark-theme': isDarkMode }]">
|
||||
<Milkdown />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Dark theme override for Milkdown Crepe
|
||||
* When .dark-theme is applied, override Crepe's light theme variables
|
||||
*/
|
||||
.crepe-editor.dark-theme .milkdown {
|
||||
--crepe-color-background: #1a1a1a;
|
||||
--crepe-color-on-background: #e0e0e0;
|
||||
--crepe-color-surface: #232323;
|
||||
--crepe-color-surface-low: #1a1a1a;
|
||||
--crepe-color-on-surface: #e0e0e0;
|
||||
--crepe-color-on-surface-variant: #999999;
|
||||
--crepe-color-outline: #3c3c3c;
|
||||
--crepe-color-primary: #58a6ff;
|
||||
--crepe-color-secondary: #232323;
|
||||
--crepe-color-on-secondary: #e0e0e0;
|
||||
--crepe-color-inverse: #e0e0e0;
|
||||
--crepe-color-on-inverse: #1a1a1a;
|
||||
--crepe-color-inline-code: #58a6ff;
|
||||
--crepe-color-error: #f85149;
|
||||
--crepe-color-hover: #2d2d2d;
|
||||
--crepe-color-selected: #3c3c3c;
|
||||
--crepe-color-inline-area: #2d2d2d;
|
||||
--crepe-shadow-1: 0px 1px 2px 0px rgba(0, 0, 0, 0.5), 0px 1px 3px 1px rgba(0, 0, 0, 0.3);
|
||||
--crepe-shadow-2: 0px 1px 2px 0px rgba(0, 0, 0, 0.5), 0px 2px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Editor container layout */
|
||||
.crepe-editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Dark theme background for container */
|
||||
.crepe-editor.dark-theme {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor .milkdown {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The actual editor area */
|
||||
.crepe-editor .ProseMirror {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
outline: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Dark theme for ProseMirror content area */
|
||||
.crepe-editor.dark-theme .ProseMirror {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Toolbar styling - dark theme */
|
||||
.crepe-editor.dark-theme .milkdown-toolbar,
|
||||
.crepe-editor.dark-theme milkdown-toolbar {
|
||||
background: #232323;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme .milkdown-toolbar button,
|
||||
.crepe-editor.dark-theme milkdown-toolbar button {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme .milkdown-toolbar button:hover,
|
||||
.crepe-editor.dark-theme milkdown-toolbar button:hover {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
/* Block handle and menus - dark theme */
|
||||
.crepe-editor.dark-theme [data-block-handle],
|
||||
.crepe-editor.dark-theme .slash-menu,
|
||||
.crepe-editor.dark-theme .link-tooltip,
|
||||
.crepe-editor.dark-theme milkdown-slash-menu,
|
||||
.crepe-editor.dark-theme milkdown-link-tooltip {
|
||||
background: #232323;
|
||||
border: 1px solid #3c3c3c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Menu items - dark theme */
|
||||
.crepe-editor.dark-theme .slash-menu-item,
|
||||
.crepe-editor.dark-theme [role="menuitem"] {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme .slash-menu-item:hover,
|
||||
.crepe-editor.dark-theme [role="menuitem"]:hover {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
/* Code blocks - dark theme */
|
||||
.crepe-editor.dark-theme pre {
|
||||
background: #232323;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme pre code {
|
||||
background: transparent;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor code {
|
||||
font-family: var(--font-mono, 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Inline code - dark theme */
|
||||
.crepe-editor.dark-theme :not(pre) > code {
|
||||
background: #2d2d2d;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
/* Blockquote - dark theme */
|
||||
.crepe-editor.dark-theme blockquote {
|
||||
border-left: 3px solid #3c3c3c;
|
||||
padding-left: 16px;
|
||||
margin-left: 0;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
/* Tables - dark theme */
|
||||
.crepe-editor table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme th,
|
||||
.crepe-editor.dark-theme td {
|
||||
border: 1px solid #3c3c3c;
|
||||
padding: 8px 12px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme th {
|
||||
background: #232323;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Links - dark theme */
|
||||
.crepe-editor.dark-theme a {
|
||||
color: #58a6ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.crepe-editor.dark-theme a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Horizontal rule - dark theme */
|
||||
.crepe-editor.dark-theme hr {
|
||||
border: none;
|
||||
border-top: 1px solid #3c3c3c;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
/* Task list */
|
||||
.crepe-editor li[data-task-list-item] {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.crepe-editor li[data-task-list-item]::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Headings - dark theme */
|
||||
.crepe-editor.dark-theme h1,
|
||||
.crepe-editor.dark-theme h2,
|
||||
.crepe-editor.dark-theme h3,
|
||||
.crepe-editor.dark-theme h4,
|
||||
.crepe-editor.dark-theme h5,
|
||||
.crepe-editor.dark-theme h6 {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Lists - dark theme */
|
||||
.crepe-editor.dark-theme ul,
|
||||
.crepe-editor.dark-theme ol,
|
||||
.crepe-editor.dark-theme li {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Placeholder - dark theme */
|
||||
.crepe-editor.dark-theme .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: #999999;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Selection - dark theme */
|
||||
.crepe-editor.dark-theme .ProseMirror ::selection {
|
||||
background: #58a6ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Focus state */
|
||||
.crepe-editor .ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Image blocks */
|
||||
.crepe-editor img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
102
frontend/src/components/NoteList.vue
Normal file
102
frontend/src/components/NoteList.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useNotesStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const notesStore = useNotesStore()
|
||||
|
||||
const selectedNoteId = computed(() => route.params.id as string | undefined)
|
||||
|
||||
function selectNote(id: string) {
|
||||
router.push({ name: 'note', params: { id } })
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="notesStore.loading" class="loading">Loading notes...</div>
|
||||
<div v-else-if="notesStore.sortedNotes.length === 0" class="empty">No notes yet</div>
|
||||
<ul v-else class="note-list">
|
||||
<li
|
||||
v-for="note in notesStore.sortedNotes"
|
||||
:key="note.id"
|
||||
:class="['note-item', { active: note.id === selectedNoteId }]"
|
||||
@click="selectNote(note.id)"
|
||||
>
|
||||
<div class="note-item-title">{{ note.title }}</div>
|
||||
<div class="note-item-meta">
|
||||
<span class="type-badge">{{ note.note_type }}</span>
|
||||
<span v-if="note.updated"> · {{ formatDate(note.updated) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.note-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.note-item.active {
|
||||
background: var(--color-border);
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.note-item-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
124
frontend/src/components/ProjectList.vue
Normal file
124
frontend/src/components/ProjectList.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useProjectsStore } from '../stores'
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const projectsStore = useProjectsStore()
|
||||
|
||||
const selectedProjectId = computed(() => route.params.id as string | undefined)
|
||||
|
||||
function selectProject(id: string) {
|
||||
router.push({ name: 'project', params: { id } })
|
||||
}
|
||||
|
||||
function goToTasks(id: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
router.push({ name: 'project-tasks', params: { id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-list-container">
|
||||
<div class="project-actions">
|
||||
<button class="create-btn" @click="emit('create')">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<div v-if="projectsStore.loading" class="loading">Loading projects...</div>
|
||||
<div v-else-if="projectsStore.sortedProjects.length === 0" class="empty">No projects yet</div>
|
||||
<ul v-else class="project-list">
|
||||
<li
|
||||
v-for="project in projectsStore.sortedProjects"
|
||||
:key="project.id"
|
||||
:class="['project-item', { active: project.id === selectedProjectId }]"
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
<div class="project-item-content">
|
||||
<div class="project-item-name">{{ project.name }}</div>
|
||||
<button
|
||||
class="tasks-btn"
|
||||
@click="goToTasks(project.id, $event)"
|
||||
title="View Tasks"
|
||||
>
|
||||
☑
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-list-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
background: var(--color-border);
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.project-item-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-item-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tasks-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.project-item:hover .tasks-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
32
frontend/src/components/ReadOnlyBanner.vue
Normal file
32
frontend/src/components/ReadOnlyBanner.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="read-only-banner">
|
||||
<span class="icon">🔒</span>
|
||||
<span class="message">{{ message || 'This file is being edited elsewhere. Read-only mode.' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-only-banner {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
127
frontend/src/components/SearchPanel.vue
Normal file
127
frontend/src/components/SearchPanel.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotesStore, useUiStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const notesStore = useNotesStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const localQuery = ref('')
|
||||
let searchTimeout: number | null = null
|
||||
|
||||
watch(localQuery, (query) => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = window.setTimeout(() => {
|
||||
uiStore.search(query)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
function openSearchResult(result: { path: string }) {
|
||||
// Find note by path
|
||||
const note = notesStore.notes.find(n => n.path === result.path)
|
||||
if (note) {
|
||||
router.push({ name: 'note', params: { id: note.id } })
|
||||
uiStore.closeSearch()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-panel">
|
||||
<input
|
||||
v-model="localQuery"
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
class="search-input"
|
||||
autofocus
|
||||
/>
|
||||
<div v-if="uiStore.isSearching" class="loading">Searching...</div>
|
||||
<div v-else-if="uiStore.searchResults.length" class="search-results">
|
||||
<div
|
||||
v-for="result in uiStore.searchResults"
|
||||
:key="result.path"
|
||||
class="search-result"
|
||||
@click="openSearchResult(result)"
|
||||
>
|
||||
<div class="search-result-title">{{ result.title }}</div>
|
||||
<div
|
||||
v-for="match in result.matches.slice(0, 2)"
|
||||
:key="match.line_number"
|
||||
class="search-result-match"
|
||||
>
|
||||
<span class="line-num">{{ match.line_number }}:</span>
|
||||
{{ match.line_content.slice(0, 80) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="localQuery.length >= 2" class="no-results">No results found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-panel {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 12px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-result:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.search-result-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.search-result-match {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-num {
|
||||
color: var(--color-primary);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.no-results,
|
||||
.loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
338
frontend/src/components/Sidebar.vue
Normal file
338
frontend/src/components/Sidebar.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useWorkspaceStore, useTasksStore, useUiStore } from '../stores'
|
||||
import SearchPanel from './SearchPanel.vue'
|
||||
import TaskPanel from './TaskPanel.vue'
|
||||
import GitStatus from './GitStatus.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const tasksStore = useTasksStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const activeProject = computed(() => workspaceStore.activeProject)
|
||||
const activeProjectId = computed(() => workspaceStore.activeProjectId)
|
||||
|
||||
// Load tasks when project changes
|
||||
watch(activeProjectId, async (id) => {
|
||||
if (id) {
|
||||
await tasksStore.loadProjectTasks(id)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const activeTasks = computed(() => tasksStore.activeTasks)
|
||||
const completedTasks = computed(() => tasksStore.completedTasks)
|
||||
|
||||
function goToProjectOverview() {
|
||||
if (activeProjectId.value) {
|
||||
router.push({ name: 'project', params: { id: activeProjectId.value } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToProjectTasks() {
|
||||
if (activeProjectId.value) {
|
||||
router.push({ name: 'project-tasks', params: { id: activeProjectId.value } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToProjectNotes() {
|
||||
if (activeProjectId.value) {
|
||||
router.push({ name: 'project-notes', params: { id: activeProjectId.value } })
|
||||
}
|
||||
}
|
||||
|
||||
function goToDaily() {
|
||||
router.push({ name: 'daily' })
|
||||
}
|
||||
|
||||
function goToCalendar() {
|
||||
router.push({ name: 'calendar' })
|
||||
}
|
||||
|
||||
function goToProjects() {
|
||||
router.push({ name: 'projects' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<!-- Search Panel (overlay) -->
|
||||
<SearchPanel v-if="uiStore.showSearch" />
|
||||
|
||||
<!-- Tasks Panel (overlay) -->
|
||||
<TaskPanel v-else-if="uiStore.showTasks" />
|
||||
|
||||
<!-- Main Content -->
|
||||
<template v-else>
|
||||
<!-- No Project Selected -->
|
||||
<div v-if="!activeProject" class="no-project">
|
||||
<div class="no-project-content">
|
||||
<h3>No Project Selected</h3>
|
||||
<p>Select a project from the dropdown above to get started.</p>
|
||||
<button class="primary" @click="goToProjects">Browse Projects</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Content -->
|
||||
<template v-else>
|
||||
<!-- Project Navigation -->
|
||||
<nav class="project-nav">
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'project' }]"
|
||||
@click="goToProjectOverview"
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'project-notes' }]"
|
||||
@click="goToProjectNotes"
|
||||
>
|
||||
Notes
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'project-tasks' }]"
|
||||
@click="goToProjectTasks"
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'daily' || route.name === 'daily-note' }]"
|
||||
@click="goToDaily"
|
||||
>
|
||||
Daily
|
||||
</button>
|
||||
<button
|
||||
:class="['nav-item', { active: route.name === 'calendar' }]"
|
||||
@click="goToCalendar"
|
||||
>
|
||||
Calendar
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
<div class="quick-stats">
|
||||
<div class="stat-item" @click="goToProjectTasks">
|
||||
<span class="stat-value">{{ activeTasks.length }}</span>
|
||||
<span class="stat-label">Active Tasks</span>
|
||||
</div>
|
||||
<div class="stat-item" @click="goToProjectTasks">
|
||||
<span class="stat-value">{{ completedTasks.length }}</span>
|
||||
<span class="stat-label">Completed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Tasks Preview -->
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header">
|
||||
<h4>Active Tasks</h4>
|
||||
<button class="link-btn" @click="goToProjectTasks">View All</button>
|
||||
</div>
|
||||
<div v-if="activeTasks.length === 0" class="empty-section">
|
||||
No active tasks
|
||||
</div>
|
||||
<ul v-else class="task-preview-list">
|
||||
<li v-for="task in activeTasks.slice(0, 5)" :key="task.id" class="task-preview-item">
|
||||
<span class="task-checkbox">☐</span>
|
||||
<span class="task-text">{{ task.title }}</span>
|
||||
</li>
|
||||
<li v-if="activeTasks.length > 5" class="task-more">
|
||||
+{{ activeTasks.length - 5 }} more...
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Git Status -->
|
||||
<GitStatus />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
max-width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.no-project {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.no-project-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-project-content h3 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.no-project-content p {
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.project-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
min-width: 70px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-header h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-preview-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-preview-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.task-preview-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-more {
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
156
frontend/src/components/TaskPanel.vue
Normal file
156
frontend/src/components/TaskPanel.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTasksStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const tasksStore = useTasksStore()
|
||||
|
||||
const taskFilter = ref<'all' | 'pending' | 'completed'>('pending')
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
const tasks = tasksStore.allTasks
|
||||
if (taskFilter.value === 'all') return tasks
|
||||
if (taskFilter.value === 'pending') return tasks.filter(t => !t.completed)
|
||||
return tasks.filter(t => t.completed)
|
||||
})
|
||||
|
||||
function goToTask(task: { id: string; project_id: string }) {
|
||||
router.push({
|
||||
name: 'project-tasks',
|
||||
params: { id: task.project_id, taskId: task.id }
|
||||
})
|
||||
}
|
||||
|
||||
async function toggleTask(task: { id: string; project_id: string }) {
|
||||
try {
|
||||
await tasksStore.toggleTask(task.project_id, task.id)
|
||||
// Refresh global task list
|
||||
await tasksStore.loadAllTasks()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tasksStore.loadAllTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tasks-panel">
|
||||
<div class="task-filters">
|
||||
<button :class="{ active: taskFilter === 'pending' }" @click="taskFilter = 'pending'">
|
||||
Pending
|
||||
</button>
|
||||
<button :class="{ active: taskFilter === 'all' }" @click="taskFilter = 'all'">
|
||||
All
|
||||
</button>
|
||||
<button :class="{ active: taskFilter === 'completed' }" @click="taskFilter = 'completed'">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="tasksStore.loading" class="loading">Loading tasks...</div>
|
||||
<div v-else-if="filteredTasks.length === 0" class="no-tasks">No tasks</div>
|
||||
<div v-else class="task-list">
|
||||
<div
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="task-item"
|
||||
:class="{ completed: task.completed }"
|
||||
@click="goToTask(task)"
|
||||
>
|
||||
<button class="task-checkbox" @click.stop="toggleTask(task)">
|
||||
{{ task.completed ? '☑' : '☐' }}
|
||||
</button>
|
||||
<span class="task-text">{{ task.title }}</span>
|
||||
<span class="task-source">
|
||||
{{ task.project_id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tasks-panel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-filters {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.task-filters button {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-filters button.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.task-item {
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.task-item.completed .task-text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.task-checkbox {
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-source {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.task-source:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.no-tasks,
|
||||
.loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
</style>
|
||||
316
frontend/src/components/TopBar.vue
Normal file
316
frontend/src/components/TopBar.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectsStore, useWorkspaceStore, useUiStore, useThemeStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const uiStore = useUiStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const isDark = computed(() => themeStore.getEffectiveTheme() === 'dark')
|
||||
|
||||
const dropdownOpen = ref(false)
|
||||
const showNewProjectInput = ref(false)
|
||||
const newProjectName = ref('')
|
||||
|
||||
const activeProject = computed(() => workspaceStore.activeProject)
|
||||
const projects = computed(() => projectsStore.sortedProjects)
|
||||
|
||||
function toggleDropdown() {
|
||||
dropdownOpen.value = !dropdownOpen.value
|
||||
showNewProjectInput.value = false
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
dropdownOpen.value = false
|
||||
showNewProjectInput.value = false
|
||||
newProjectName.value = ''
|
||||
}
|
||||
|
||||
async function selectProject(projectId: string) {
|
||||
await workspaceStore.setActiveProject(projectId)
|
||||
closeDropdown()
|
||||
router.push({ name: 'project', params: { id: projectId } })
|
||||
}
|
||||
|
||||
function showCreateProject() {
|
||||
showNewProjectInput.value = true
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
if (!newProjectName.value.trim()) return
|
||||
|
||||
try {
|
||||
const project = await projectsStore.createProject(newProjectName.value.trim())
|
||||
await workspaceStore.setActiveProject(project.id)
|
||||
closeDropdown()
|
||||
router.push({ name: 'project', params: { id: project.id } })
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
function goToProjects() {
|
||||
closeDropdown()
|
||||
router.push({ name: 'projects' })
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push({ name: 'home' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<h1 class="app-title" @click="goHome" style="cursor: pointer" title="Dashboard">Ironpad</h1>
|
||||
|
||||
<div class="project-selector">
|
||||
<button class="project-button" @click="toggleDropdown">
|
||||
<span class="project-name">
|
||||
{{ activeProject?.name ?? 'Select Project' }}
|
||||
</span>
|
||||
<span class="dropdown-arrow">{{ dropdownOpen ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="dropdownOpen" class="dropdown-menu" @click.stop>
|
||||
<div v-if="!showNewProjectInput" class="dropdown-content">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:class="['dropdown-item', { active: project.id === activeProject?.id }]"
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
{{ project.name }}
|
||||
</div>
|
||||
|
||||
<div v-if="projects.length === 0" class="dropdown-empty">
|
||||
No projects yet
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<div class="dropdown-item action" @click="showCreateProject">
|
||||
+ New Project
|
||||
</div>
|
||||
|
||||
<div class="dropdown-item action" @click="goToProjects">
|
||||
Manage Projects
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="new-project-form">
|
||||
<input
|
||||
v-model="newProjectName"
|
||||
type="text"
|
||||
placeholder="Project name..."
|
||||
class="new-project-input"
|
||||
@keyup.enter="createProject"
|
||||
@keyup.escape="closeDropdown"
|
||||
autofocus
|
||||
/>
|
||||
<div class="form-buttons">
|
||||
<button class="primary" @click="createProject" :disabled="!newProjectName.trim()">
|
||||
Create
|
||||
</button>
|
||||
<button @click="closeDropdown">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<button
|
||||
@click="uiStore.toggleSearch()"
|
||||
:class="{ active: uiStore.showSearch }"
|
||||
title="Search (Ctrl+K)"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
@click="uiStore.toggleTasks()"
|
||||
:class="{ active: uiStore.showTasks }"
|
||||
title="Tasks"
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
class="theme-toggle"
|
||||
@click="themeStore.toggleTheme()"
|
||||
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
>
|
||||
{{ isDark ? '☀️' : '🌙' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Click outside to close dropdown -->
|
||||
<div v-if="dropdownOpen" class="dropdown-overlay" @click="closeDropdown"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
height: var(--header-height);
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.project-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.project-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.project-button:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 220px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.dropdown-item.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dropdown-item.action {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.new-project-form {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.new-project-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.new-project-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-right button {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.topbar-right button.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
font-size: 16px;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.dropdown-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
}
|
||||
</style>
|
||||
148
frontend/src/composables/useWebSocket.ts
Normal file
148
frontend/src/composables/useWebSocket.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { WsMessage, WsConnectedPayload } from '../types'
|
||||
|
||||
export interface UseWebSocketOptions {
|
||||
onFileCreated?: (path: string) => void
|
||||
onFileModified?: (path: string) => void
|
||||
onFileDeleted?: (path: string) => void
|
||||
onFileRenamed?: (from: string, to: string) => void
|
||||
onFileLocked?: (path: string, clientId: string, lockType: string) => void
|
||||
onFileUnlocked?: (path: string) => void
|
||||
onGitConflict?: (files: string[]) => void
|
||||
}
|
||||
|
||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||
const connected = ref(false)
|
||||
const clientId = ref<string | null>(null)
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimeout: number | null = null
|
||||
let reconnectAttempts = 0
|
||||
const MAX_RECONNECT_DELAY = 30000 // 30 seconds max
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
reconnectAttempts = 0 // Reset backoff on successful connection
|
||||
console.log('WebSocket connected')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WsMessage
|
||||
handleMessage(msg)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse WebSocket message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
connected.value = false
|
||||
clientId.value = null
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY)
|
||||
reconnectAttempts++
|
||||
console.log(`WebSocket disconnected, reconnecting in ${delay / 1000}s...`)
|
||||
reconnectTimeout = window.setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('WebSocket error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(msg: WsMessage) {
|
||||
switch (msg.type) {
|
||||
case 'Connected': {
|
||||
const payload = msg.payload as WsConnectedPayload
|
||||
clientId.value = payload.client_id
|
||||
console.log('WebSocket client ID:', payload.client_id)
|
||||
break
|
||||
}
|
||||
case 'FileCreated': {
|
||||
const payload = msg.payload as { path: string }
|
||||
options.onFileCreated?.(payload.path)
|
||||
break
|
||||
}
|
||||
case 'FileModified': {
|
||||
const payload = msg.payload as { path: string }
|
||||
options.onFileModified?.(payload.path)
|
||||
break
|
||||
}
|
||||
case 'FileDeleted': {
|
||||
const payload = msg.payload as { path: string }
|
||||
options.onFileDeleted?.(payload.path)
|
||||
break
|
||||
}
|
||||
case 'FileRenamed': {
|
||||
const payload = msg.payload as { from: string; to: string }
|
||||
options.onFileRenamed?.(payload.from, payload.to)
|
||||
break
|
||||
}
|
||||
case 'FileLocked': {
|
||||
const payload = msg.payload as { path: string; client_id: string; lock_type: string }
|
||||
options.onFileLocked?.(payload.path, payload.client_id, payload.lock_type)
|
||||
break
|
||||
}
|
||||
case 'FileUnlocked': {
|
||||
const payload = msg.payload as { path: string }
|
||||
options.onFileUnlocked?.(payload.path)
|
||||
break
|
||||
}
|
||||
case 'GitConflict': {
|
||||
const payload = msg.payload as { files: string[] }
|
||||
options.onGitConflict?.(payload.files)
|
||||
break
|
||||
}
|
||||
case 'Ping':
|
||||
// Heartbeat, no action needed
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function send(message: object) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message))
|
||||
}
|
||||
}
|
||||
|
||||
function lockFile(path: string, lockType: 'editor' | 'task_view') {
|
||||
send({ type: 'lock_file', path, lock_type: lockType })
|
||||
}
|
||||
|
||||
function unlockFile(path: string) {
|
||||
send({ type: 'unlock_file', path })
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
connected,
|
||||
clientId,
|
||||
send,
|
||||
lockFile,
|
||||
unlockFile,
|
||||
disconnect
|
||||
}
|
||||
}
|
||||
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
53
frontend/src/router/index.ts
Normal file
53
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../views/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
name: 'projects',
|
||||
component: () => import('../views/ProjectsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
name: 'project',
|
||||
component: () => import('../views/ProjectView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/projects/:id/notes/:noteId?',
|
||||
name: 'project-notes',
|
||||
component: () => import('../views/ProjectNotesView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/projects/:id/tasks/:taskId?',
|
||||
name: 'project-tasks',
|
||||
component: () => import('../views/TasksView.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('../views/CalendarView.vue')
|
||||
},
|
||||
{
|
||||
path: '/daily',
|
||||
name: 'daily',
|
||||
component: () => import('../views/DailyView.vue')
|
||||
},
|
||||
{
|
||||
path: '/daily/:date',
|
||||
name: 'daily-note',
|
||||
component: () => import('../views/DailyView.vue'),
|
||||
props: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
218
frontend/src/stores/git.ts
Normal file
218
frontend/src/stores/git.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { GitStatus, CommitInfo, CommitDetail, DiffInfo, RemoteInfo } from '../types'
|
||||
import { gitApi } from '../api/client'
|
||||
|
||||
export const useGitStore = defineStore('git', () => {
|
||||
// State
|
||||
const status = ref<GitStatus | null>(null)
|
||||
const loading = ref(false)
|
||||
const committing = ref(false)
|
||||
const pushing = ref(false)
|
||||
const fetching = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const conflicts = ref<string[]>([])
|
||||
|
||||
// New state for expanded git features
|
||||
const history = ref<CommitDetail[]>([])
|
||||
const historyLoading = ref(false)
|
||||
const workingDiff = ref<DiffInfo | null>(null)
|
||||
const diffLoading = ref(false)
|
||||
const selectedCommitDiff = ref<DiffInfo | null>(null)
|
||||
const selectedCommitId = ref<string | null>(null)
|
||||
const remote = ref<RemoteInfo | null>(null)
|
||||
const panelOpen = ref(false)
|
||||
|
||||
// Getters
|
||||
const hasChanges = computed(() => status.value?.has_changes ?? false)
|
||||
const hasConflicts = computed(() => conflicts.value.length > 0)
|
||||
const branch = computed(() => status.value?.branch ?? 'main')
|
||||
const isRepo = computed(() => status.value?.is_repo ?? false)
|
||||
const changedFilesCount = computed(() => status.value?.files.length ?? 0)
|
||||
const hasRemote = computed(() => remote.value !== null)
|
||||
const canPush = computed(() => (remote.value?.ahead ?? 0) > 0)
|
||||
const canPull = computed(() => (remote.value?.behind ?? 0) > 0)
|
||||
|
||||
// Actions
|
||||
async function loadStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
status.value = await gitApi.status()
|
||||
} catch (err) {
|
||||
error.value = `Failed to load git status: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(message?: string): Promise<CommitInfo | null> {
|
||||
try {
|
||||
committing.value = true
|
||||
error.value = null
|
||||
const result = await gitApi.commit(message)
|
||||
await loadStatus()
|
||||
// Refresh history and diff after commit
|
||||
await Promise.all([loadHistory(), loadWorkingDiff(), loadRemote()])
|
||||
return result
|
||||
} catch (err) {
|
||||
error.value = `Commit failed: ${err}`
|
||||
return null
|
||||
} finally {
|
||||
committing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function push() {
|
||||
try {
|
||||
pushing.value = true
|
||||
error.value = null
|
||||
const result = await gitApi.push()
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
await Promise.all([loadStatus(), loadRemote()])
|
||||
} catch (err) {
|
||||
error.value = `Push failed: ${err}`
|
||||
throw err
|
||||
} finally {
|
||||
pushing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote() {
|
||||
try {
|
||||
fetching.value = true
|
||||
error.value = null
|
||||
const result = await gitApi.fetch()
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
await loadRemote()
|
||||
} catch (err) {
|
||||
error.value = `Fetch failed: ${err}`
|
||||
throw err
|
||||
} finally {
|
||||
fetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConflicts() {
|
||||
try {
|
||||
error.value = null
|
||||
conflicts.value = await gitApi.conflicts()
|
||||
} catch (err) {
|
||||
// Conflicts endpoint might not exist yet, ignore error
|
||||
conflicts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory(limit?: number) {
|
||||
try {
|
||||
historyLoading.value = true
|
||||
history.value = await gitApi.log(limit)
|
||||
} catch (err) {
|
||||
console.error('Failed to load git history:', err)
|
||||
history.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkingDiff() {
|
||||
try {
|
||||
diffLoading.value = true
|
||||
workingDiff.value = await gitApi.diff()
|
||||
} catch (err) {
|
||||
console.error('Failed to load working diff:', err)
|
||||
workingDiff.value = null
|
||||
} finally {
|
||||
diffLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCommitDiff(commitId: string) {
|
||||
try {
|
||||
diffLoading.value = true
|
||||
selectedCommitId.value = commitId
|
||||
selectedCommitDiff.value = await gitApi.commitDiff(commitId)
|
||||
} catch (err) {
|
||||
console.error('Failed to load commit diff:', err)
|
||||
selectedCommitDiff.value = null
|
||||
} finally {
|
||||
diffLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRemote() {
|
||||
try {
|
||||
remote.value = await gitApi.remote()
|
||||
} catch (err) {
|
||||
console.error('Failed to load remote info:', err)
|
||||
remote.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelectedCommit() {
|
||||
selectedCommitId.value = null
|
||||
selectedCommitDiff.value = null
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen.value = !panelOpen.value
|
||||
if (panelOpen.value) {
|
||||
// Load all data when opening panel
|
||||
Promise.all([
|
||||
loadStatus(),
|
||||
loadHistory(),
|
||||
loadWorkingDiff(),
|
||||
loadRemote()
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
status,
|
||||
loading,
|
||||
committing,
|
||||
pushing,
|
||||
fetching,
|
||||
error,
|
||||
conflicts,
|
||||
history,
|
||||
historyLoading,
|
||||
workingDiff,
|
||||
diffLoading,
|
||||
selectedCommitDiff,
|
||||
selectedCommitId,
|
||||
remote,
|
||||
panelOpen,
|
||||
// Getters
|
||||
hasChanges,
|
||||
hasConflicts,
|
||||
branch,
|
||||
isRepo,
|
||||
changedFilesCount,
|
||||
hasRemote,
|
||||
canPush,
|
||||
canPull,
|
||||
// Actions
|
||||
loadStatus,
|
||||
commit,
|
||||
push,
|
||||
fetchRemote,
|
||||
checkConflicts,
|
||||
loadHistory,
|
||||
loadWorkingDiff,
|
||||
loadCommitDiff,
|
||||
loadRemote,
|
||||
clearSelectedCommit,
|
||||
togglePanel,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
8
frontend/src/stores/index.ts
Normal file
8
frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useNotesStore } from './notes'
|
||||
export { useProjectsStore } from './projects'
|
||||
export { useTasksStore } from './tasks'
|
||||
export { useUiStore } from './ui'
|
||||
export { useWebSocketStore } from './websocket'
|
||||
export { useGitStore } from './git'
|
||||
export { useWorkspaceStore } from './workspace'
|
||||
export { useThemeStore } from './theme'
|
||||
131
frontend/src/stores/notes.ts
Normal file
131
frontend/src/stores/notes.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Note, NoteSummary } from '../types'
|
||||
import { notesApi } from '../api/client'
|
||||
|
||||
export const useNotesStore = defineStore('notes', () => {
|
||||
// State
|
||||
const notes = ref<NoteSummary[]>([])
|
||||
const currentNote = ref<Note | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadingNote = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
|
||||
// Getters
|
||||
const sortedNotes = computed(() =>
|
||||
[...notes.value].sort((a, b) => {
|
||||
const dateA = a.updated ? new Date(a.updated).getTime() : 0
|
||||
const dateB = b.updated ? new Date(b.updated).getTime() : 0
|
||||
return dateB - dateA
|
||||
})
|
||||
)
|
||||
|
||||
const getNoteById = computed(() => (id: string) =>
|
||||
notes.value.find(n => n.id === id)
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function loadNotes() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
notes.value = await notesApi.list()
|
||||
} catch (err) {
|
||||
error.value = `Failed to load notes: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNote(id: string) {
|
||||
try {
|
||||
loadingNote.value = true
|
||||
error.value = null
|
||||
currentNote.value = await notesApi.get(id)
|
||||
saveStatus.value = 'idle'
|
||||
} catch (err) {
|
||||
error.value = `Failed to load note: ${err}`
|
||||
currentNote.value = null
|
||||
} finally {
|
||||
loadingNote.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
try {
|
||||
error.value = null
|
||||
const newNote = await notesApi.create()
|
||||
await loadNotes()
|
||||
return newNote
|
||||
} catch (err) {
|
||||
error.value = `Failed to create note: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote(content: string) {
|
||||
if (!currentNote.value) return
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
currentNote.value = await notesApi.update(currentNote.value.id, content)
|
||||
saveStatus.value = 'saved'
|
||||
await loadNotes() // Refresh list to update timestamps
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
saveStatus.value = 'error'
|
||||
error.value = `Failed to save note: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote(id?: string) {
|
||||
const noteId = id || currentNote.value?.id
|
||||
if (!noteId) return
|
||||
|
||||
try {
|
||||
error.value = null
|
||||
await notesApi.delete(noteId)
|
||||
if (currentNote.value?.id === noteId) {
|
||||
currentNote.value = null
|
||||
}
|
||||
await loadNotes()
|
||||
} catch (err) {
|
||||
error.value = `Failed to archive note: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function clearCurrentNote() {
|
||||
currentNote.value = null
|
||||
saveStatus.value = 'idle'
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
notes,
|
||||
currentNote,
|
||||
loading,
|
||||
loadingNote,
|
||||
error,
|
||||
saveStatus,
|
||||
// Getters
|
||||
sortedNotes,
|
||||
getNoteById,
|
||||
// Actions
|
||||
loadNotes,
|
||||
loadNote,
|
||||
createNote,
|
||||
saveNote,
|
||||
deleteNote,
|
||||
clearCurrentNote,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
84
frontend/src/stores/projects.ts
Normal file
84
frontend/src/stores/projects.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Project } from '../types'
|
||||
import { projectsApi } from '../api/client'
|
||||
|
||||
export const useProjectsStore = defineStore('projects', () => {
|
||||
// State
|
||||
const projects = ref<Project[]>([])
|
||||
const currentProject = ref<Project | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const sortedProjects = computed(() =>
|
||||
[...projects.value].sort((a, b) => a.name.localeCompare(b.name))
|
||||
)
|
||||
|
||||
const getProjectById = computed(() => (id: string) =>
|
||||
projects.value.find(p => p.id === id)
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function loadProjects() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
projects.value = await projectsApi.list()
|
||||
} catch (err) {
|
||||
error.value = `Failed to load projects: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject(id: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
currentProject.value = await projectsApi.get(id)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load project: ${err}`
|
||||
currentProject.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(name: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const newProject = await projectsApi.create(name)
|
||||
await loadProjects()
|
||||
return newProject
|
||||
} catch (err) {
|
||||
error.value = `Failed to create project: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function clearCurrentProject() {
|
||||
currentProject.value = null
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
projects,
|
||||
currentProject,
|
||||
loading,
|
||||
error,
|
||||
// Getters
|
||||
sortedProjects,
|
||||
getProjectById,
|
||||
// Actions
|
||||
loadProjects,
|
||||
loadProject,
|
||||
createProject,
|
||||
clearCurrentProject,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
235
frontend/src/stores/tasks.ts
Normal file
235
frontend/src/stores/tasks.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Task, TaskWithContent } from '../types'
|
||||
import { tasksApi } from '../api/client'
|
||||
|
||||
export const useTasksStore = defineStore('tasks', () => {
|
||||
// State
|
||||
const tasks = ref<Task[]>([])
|
||||
const allTasks = ref<Task[]>([])
|
||||
const currentProjectId = ref<string | null>(null)
|
||||
const selectedTask = ref<TaskWithContent | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const activeTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.completed && t.section !== 'Backlog')
|
||||
)
|
||||
|
||||
const completedTasks = computed(() =>
|
||||
tasks.value.filter(t => t.completed || t.section === 'Completed')
|
||||
)
|
||||
|
||||
const backlogTasks = computed(() =>
|
||||
tasks.value.filter(t => !t.completed && t.section === 'Backlog')
|
||||
)
|
||||
|
||||
const pendingTasks = computed(() =>
|
||||
allTasks.value.filter(t => !t.completed)
|
||||
)
|
||||
|
||||
const tasksByProject = computed(() => (projectId: string) =>
|
||||
allTasks.value.filter(t => t.project_id === projectId)
|
||||
)
|
||||
|
||||
/** All unique tags used across tasks in the current project */
|
||||
const projectTags = computed(() => {
|
||||
const tagSet = new Set<string>()
|
||||
for (const task of tasks.value) {
|
||||
if (task.tags) {
|
||||
for (const tag of task.tags) {
|
||||
tagSet.add(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...tagSet].sort()
|
||||
})
|
||||
|
||||
// Actions
|
||||
async function loadAllTasks() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
allTasks.value = await tasksApi.listAll()
|
||||
} catch (err) {
|
||||
error.value = `Failed to load tasks: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectTasks(projectId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
currentProjectId.value = projectId
|
||||
tasks.value = await tasksApi.list(projectId)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load project tasks: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTask(projectId: string, taskId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
selectedTask.value = await tasksApi.get(projectId, taskId)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load task: ${err}`
|
||||
selectedTask.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask(projectId: string, title: string, section?: string, parentId?: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const task = await tasksApi.create(projectId, title, section, parentId)
|
||||
|
||||
// Refresh tasks list
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
return task
|
||||
} catch (err) {
|
||||
error.value = `Failed to create task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskContent(projectId: string, taskId: string, content: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const task = await tasksApi.updateContent(projectId, taskId, content)
|
||||
selectedTask.value = task
|
||||
|
||||
// Refresh tasks list to update timestamps
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
return task
|
||||
} catch (err) {
|
||||
error.value = `Failed to update task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTask(projectId: string, taskId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
await tasksApi.toggle(projectId, taskId)
|
||||
|
||||
// Refresh tasks
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
// Update selected task if it's the one being toggled
|
||||
if (selectedTask.value?.id === taskId) {
|
||||
await loadTask(projectId, taskId)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to toggle task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTaskMeta(
|
||||
projectId: string,
|
||||
taskId: string,
|
||||
meta: { title?: string; section?: string; priority?: string; due_date?: string; is_active?: boolean; tags?: string[]; recurrence?: string; recurrence_interval?: number }
|
||||
) {
|
||||
try {
|
||||
error.value = null
|
||||
await tasksApi.updateMeta(projectId, taskId, meta)
|
||||
|
||||
// Refresh tasks
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
|
||||
// Update selected task if it's the one being updated
|
||||
if (selectedTask.value?.id === taskId) {
|
||||
await loadTask(projectId, taskId)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to update task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(projectId: string, taskId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
await tasksApi.delete(projectId, taskId)
|
||||
|
||||
// Clear selected task if it was deleted
|
||||
if (selectedTask.value?.id === taskId) {
|
||||
selectedTask.value = null
|
||||
}
|
||||
|
||||
// Refresh tasks
|
||||
if (currentProjectId.value === projectId) {
|
||||
await loadProjectTasks(projectId)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to delete task: ${err}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function selectTask(task: Task | null) {
|
||||
if (task && currentProjectId.value) {
|
||||
loadTask(currentProjectId.value, task.id)
|
||||
} else {
|
||||
selectedTask.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelectedTask() {
|
||||
selectedTask.value = null
|
||||
}
|
||||
|
||||
function clearProjectTasks() {
|
||||
tasks.value = []
|
||||
currentProjectId.value = null
|
||||
selectedTask.value = null
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tasks,
|
||||
allTasks,
|
||||
currentProjectId,
|
||||
selectedTask,
|
||||
loading,
|
||||
error,
|
||||
// Getters
|
||||
activeTasks,
|
||||
completedTasks,
|
||||
backlogTasks,
|
||||
pendingTasks,
|
||||
tasksByProject,
|
||||
projectTags,
|
||||
// Actions
|
||||
loadAllTasks,
|
||||
loadProjectTasks,
|
||||
loadTask,
|
||||
createTask,
|
||||
updateTaskContent,
|
||||
toggleTask,
|
||||
updateTaskMeta,
|
||||
deleteTask,
|
||||
selectTask,
|
||||
clearSelectedTask,
|
||||
clearProjectTasks,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
76
frontend/src/stores/theme.ts
Normal file
76
frontend/src/stores/theme.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'system'
|
||||
|
||||
const STORAGE_KEY = 'ironpad-theme'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
// Default to dark mode
|
||||
const mode = ref<ThemeMode>('dark')
|
||||
|
||||
// Load saved preference
|
||||
function loadSavedTheme() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved && ['dark', 'light', 'system'].includes(saved)) {
|
||||
mode.value = saved as ThemeMode
|
||||
}
|
||||
}
|
||||
|
||||
// Get the effective theme (resolves 'system' to actual theme)
|
||||
function getEffectiveTheme(): 'dark' | 'light' {
|
||||
if (mode.value === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return mode.value
|
||||
}
|
||||
|
||||
// Apply theme to document
|
||||
function applyTheme() {
|
||||
const effectiveTheme = getEffectiveTheme()
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme)
|
||||
|
||||
// Also set class for easier CSS targeting
|
||||
document.documentElement.classList.remove('theme-dark', 'theme-light')
|
||||
document.documentElement.classList.add(`theme-${effectiveTheme}`)
|
||||
}
|
||||
|
||||
// Set theme mode
|
||||
function setTheme(newMode: ThemeMode) {
|
||||
mode.value = newMode
|
||||
localStorage.setItem(STORAGE_KEY, newMode)
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
// Toggle between dark and light
|
||||
function toggleTheme() {
|
||||
const current = getEffectiveTheme()
|
||||
setTheme(current === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
loadSavedTheme()
|
||||
|
||||
// Ensure data-theme attribute is set (even if same as default)
|
||||
applyTheme()
|
||||
|
||||
// Listen for system theme changes when in system mode
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (mode.value === 'system') {
|
||||
applyTheme()
|
||||
}
|
||||
})
|
||||
|
||||
// Theme is now initialized
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
getEffectiveTheme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
applyTheme,
|
||||
init
|
||||
}
|
||||
})
|
||||
125
frontend/src/stores/ui.ts
Normal file
125
frontend/src/stores/ui.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SearchResult } from '../types'
|
||||
import { searchApi } from '../api/client'
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
// State
|
||||
const showSearch = ref(false)
|
||||
const showTasks = ref(false)
|
||||
const showPreview = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<SearchResult[]>([])
|
||||
const isSearching = ref(false)
|
||||
const globalError = ref<string | null>(null)
|
||||
const sidebarSection = ref<'notes' | 'projects' | 'daily'>('notes')
|
||||
|
||||
// Getters
|
||||
const hasSearchResults = computed(() => searchResults.value.length > 0)
|
||||
|
||||
// Actions
|
||||
function openSearch() {
|
||||
showSearch.value = true
|
||||
showTasks.value = false
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
showSearch.value = false
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
if (showSearch.value) {
|
||||
closeSearch()
|
||||
} else {
|
||||
openSearch()
|
||||
}
|
||||
}
|
||||
|
||||
function openTasks() {
|
||||
showTasks.value = true
|
||||
showSearch.value = false
|
||||
}
|
||||
|
||||
function closeTasks() {
|
||||
showTasks.value = false
|
||||
}
|
||||
|
||||
function toggleTasks() {
|
||||
if (showTasks.value) {
|
||||
closeTasks()
|
||||
} else {
|
||||
openTasks()
|
||||
}
|
||||
}
|
||||
|
||||
function togglePreview() {
|
||||
showPreview.value = !showPreview.value
|
||||
// Persist preference
|
||||
localStorage.setItem('ironpad-show-preview', String(showPreview.value))
|
||||
}
|
||||
|
||||
function loadPreviewPreference() {
|
||||
const saved = localStorage.getItem('ironpad-show-preview')
|
||||
if (saved !== null) {
|
||||
showPreview.value = saved === 'true'
|
||||
}
|
||||
}
|
||||
|
||||
async function search(query: string) {
|
||||
if (query.length < 2) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSearching.value = true
|
||||
searchQuery.value = query
|
||||
searchResults.value = await searchApi.search(query)
|
||||
} catch (err) {
|
||||
globalError.value = `Search failed: ${err}`
|
||||
} finally {
|
||||
isSearching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setSidebarSection(section: 'notes' | 'projects' | 'daily') {
|
||||
sidebarSection.value = section
|
||||
}
|
||||
|
||||
function setGlobalError(message: string | null) {
|
||||
globalError.value = message
|
||||
}
|
||||
|
||||
function clearGlobalError() {
|
||||
globalError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
showSearch,
|
||||
showTasks,
|
||||
showPreview,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
isSearching,
|
||||
globalError,
|
||||
sidebarSection,
|
||||
// Getters
|
||||
hasSearchResults,
|
||||
// Actions
|
||||
openSearch,
|
||||
closeSearch,
|
||||
toggleSearch,
|
||||
openTasks,
|
||||
closeTasks,
|
||||
toggleTasks,
|
||||
togglePreview,
|
||||
loadPreviewPreference,
|
||||
search,
|
||||
setSidebarSection,
|
||||
setGlobalError,
|
||||
clearGlobalError
|
||||
}
|
||||
})
|
||||
67
frontend/src/stores/websocket.ts
Normal file
67
frontend/src/stores/websocket.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { FileLock } from '../types'
|
||||
|
||||
export const useWebSocketStore = defineStore('websocket', () => {
|
||||
// State
|
||||
const connected = ref(false)
|
||||
const clientId = ref<string | null>(null)
|
||||
const fileLocks = ref<Map<string, FileLock>>(new Map())
|
||||
const gitConflicts = ref<string[]>([])
|
||||
|
||||
// Actions
|
||||
function setConnected(value: boolean) {
|
||||
connected.value = value
|
||||
}
|
||||
|
||||
function setClientId(id: string | null) {
|
||||
clientId.value = id
|
||||
}
|
||||
|
||||
function addFileLock(lock: FileLock) {
|
||||
fileLocks.value.set(lock.path, lock)
|
||||
}
|
||||
|
||||
function removeFileLock(path: string) {
|
||||
fileLocks.value.delete(path)
|
||||
}
|
||||
|
||||
function isFileLocked(path: string): FileLock | undefined {
|
||||
return fileLocks.value.get(path)
|
||||
}
|
||||
|
||||
function isFileLockedByOther(path: string): boolean {
|
||||
const lock = fileLocks.value.get(path)
|
||||
return lock !== undefined && lock.client_id !== clientId.value
|
||||
}
|
||||
|
||||
function setGitConflicts(files: string[]) {
|
||||
gitConflicts.value = files
|
||||
}
|
||||
|
||||
function clearGitConflicts() {
|
||||
gitConflicts.value = []
|
||||
}
|
||||
|
||||
function clearAllLocks() {
|
||||
fileLocks.value.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
connected,
|
||||
clientId,
|
||||
fileLocks,
|
||||
gitConflicts,
|
||||
// Actions
|
||||
setConnected,
|
||||
setClientId,
|
||||
addFileLock,
|
||||
removeFileLock,
|
||||
isFileLocked,
|
||||
isFileLockedByOther,
|
||||
setGitConflicts,
|
||||
clearGitConflicts,
|
||||
clearAllLocks
|
||||
}
|
||||
})
|
||||
76
frontend/src/stores/workspace.ts
Normal file
76
frontend/src/stores/workspace.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Project } from '../types'
|
||||
import { projectsApi } from '../api/client'
|
||||
|
||||
const STORAGE_KEY = 'ironpad-active-project'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
// State
|
||||
const activeProjectId = ref<string | null>(null)
|
||||
const activeProject = ref<Project | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Getters
|
||||
const hasActiveProject = computed(() => activeProjectId.value !== null)
|
||||
|
||||
// Actions
|
||||
async function setActiveProject(projectId: string | null) {
|
||||
if (projectId === activeProjectId.value) return
|
||||
|
||||
activeProjectId.value = projectId
|
||||
|
||||
if (projectId) {
|
||||
// Persist to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, projectId)
|
||||
|
||||
// Load project details
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
activeProject.value = await projectsApi.get(projectId)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load project: ${err}`
|
||||
activeProject.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
activeProject.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSavedProject() {
|
||||
const savedId = localStorage.getItem(STORAGE_KEY)
|
||||
if (savedId) {
|
||||
await setActiveProject(savedId)
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveProject() {
|
||||
activeProjectId.value = null
|
||||
activeProject.value = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
activeProjectId,
|
||||
activeProject,
|
||||
loading,
|
||||
error,
|
||||
// Getters
|
||||
hasActiveProject,
|
||||
// Actions
|
||||
setActiveProject,
|
||||
loadSavedProject,
|
||||
clearActiveProject,
|
||||
clearError
|
||||
}
|
||||
})
|
||||
242
frontend/src/style.css
Normal file
242
frontend/src/style.css
Normal file
@@ -0,0 +1,242 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/*
|
||||
* Theme variables are defined in App.vue
|
||||
* This file only contains component styles
|
||||
*/
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
background: var(--color-bg-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Note list */
|
||||
.note-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.note-item.active {
|
||||
background: var(--color-border);
|
||||
border-left-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.note-item-title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-item-meta {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Editor */
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
border: none;
|
||||
resize: none;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
border-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status.saving {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.status.saved {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
padding: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Button group */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Type badge */
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
175
frontend/src/types/index.ts
Normal file
175
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// Types for Ironpad
|
||||
|
||||
export interface NoteSummary {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
note_type: string
|
||||
updated?: string
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string
|
||||
path: string
|
||||
note_type: string
|
||||
frontmatter: Record<string, unknown>
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
created: string
|
||||
}
|
||||
|
||||
export interface ProjectWithContent extends Project {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ProjectNote {
|
||||
id: string
|
||||
title: string
|
||||
path: string
|
||||
project_id: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface ProjectNoteWithContent extends ProjectNote {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
title: string
|
||||
completed: boolean
|
||||
section: string
|
||||
priority?: string
|
||||
due_date?: string
|
||||
is_active: boolean
|
||||
tags: string[]
|
||||
parent_id?: string
|
||||
recurrence?: string
|
||||
recurrence_interval?: number
|
||||
project_id: string
|
||||
path: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface TaskWithContent extends Task {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
path: string
|
||||
title: string
|
||||
matches: { line_number: number; line_content: string }[]
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
is_repo: boolean
|
||||
branch?: string
|
||||
has_changes: boolean
|
||||
files: { path: string; status: string }[]
|
||||
last_commit?: { id: string; message: string; timestamp: string }
|
||||
conflicts?: string[]
|
||||
}
|
||||
|
||||
export interface CommitInfo {
|
||||
id: string
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface CommitDetail {
|
||||
id: string
|
||||
short_id: string
|
||||
message: string
|
||||
author: string
|
||||
timestamp: string
|
||||
files_changed: number
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
origin: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
header: string
|
||||
lines: DiffLine[]
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
path: string
|
||||
status: string
|
||||
additions: number
|
||||
deletions: number
|
||||
hunks: DiffHunk[]
|
||||
}
|
||||
|
||||
export interface DiffStats {
|
||||
files_changed: number
|
||||
insertions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
export interface DiffInfo {
|
||||
files: FileDiff[]
|
||||
stats: DiffStats
|
||||
}
|
||||
|
||||
export interface RemoteInfo {
|
||||
name: string
|
||||
url: string
|
||||
has_upstream: boolean
|
||||
ahead: number
|
||||
behind: number
|
||||
}
|
||||
|
||||
export interface DailyNote {
|
||||
id: string
|
||||
date: string
|
||||
path: string
|
||||
content: string
|
||||
frontmatter: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface FileLock {
|
||||
path: string
|
||||
client_id: string
|
||||
lock_type: 'editor' | 'task_view'
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
export type WsMessageType =
|
||||
| 'Connected'
|
||||
| 'FileCreated'
|
||||
| 'FileModified'
|
||||
| 'FileDeleted'
|
||||
| 'FileRenamed'
|
||||
| 'FileLocked'
|
||||
| 'FileUnlocked'
|
||||
| 'GitConflict'
|
||||
| 'Ping'
|
||||
|
||||
export interface WsMessage {
|
||||
type: WsMessageType
|
||||
payload?: unknown
|
||||
}
|
||||
|
||||
export interface WsConnectedPayload {
|
||||
client_id: string
|
||||
}
|
||||
|
||||
export interface WsFilePayload {
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface WsFileLockPayload {
|
||||
path: string
|
||||
client_id: string
|
||||
lock_type: 'editor' | 'task_view'
|
||||
}
|
||||
426
frontend/src/views/CalendarView.vue
Normal file
426
frontend/src/views/CalendarView.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTasksStore, useProjectsStore, useWorkspaceStore } from '../stores'
|
||||
import { dailyApi } from '../api/client'
|
||||
import type { Task, DailyNote } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const tasksStore = useTasksStore()
|
||||
const projectsStore = useProjectsStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
// Current month being displayed
|
||||
const currentYear = ref(new Date().getFullYear())
|
||||
const currentMonth = ref(new Date().getMonth()) // 0-indexed
|
||||
|
||||
// Daily notes dates
|
||||
const dailyDates = ref<Set<string>>(new Set())
|
||||
|
||||
const monthNames = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
]
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
const monthLabel = computed(() => `${monthNames[currentMonth.value]} ${currentYear.value}`)
|
||||
|
||||
// Build calendar grid
|
||||
const calendarDays = computed(() => {
|
||||
const year = currentYear.value
|
||||
const month = currentMonth.value
|
||||
|
||||
// First day of the month
|
||||
const firstDay = new Date(year, month, 1)
|
||||
// Day of week (0=Sun, 1=Mon...) - shift to Mon=0
|
||||
let startDow = firstDay.getDay() - 1
|
||||
if (startDow < 0) startDow = 6
|
||||
|
||||
// Days in this month
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
|
||||
// Days in previous month (for padding)
|
||||
const daysInPrevMonth = new Date(year, month, 0).getDate()
|
||||
|
||||
const days: { date: string; day: number; isCurrentMonth: boolean; isToday: boolean }[] = []
|
||||
|
||||
// Previous month padding
|
||||
for (let i = startDow - 1; i >= 0; i--) {
|
||||
const d = daysInPrevMonth - i
|
||||
const m = month === 0 ? 11 : month - 1
|
||||
const y = month === 0 ? year - 1 : year
|
||||
days.push({
|
||||
date: formatDate(y, m, d),
|
||||
day: d,
|
||||
isCurrentMonth: false,
|
||||
isToday: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Current month
|
||||
const today = new Date()
|
||||
const todayStr = formatDate(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = formatDate(year, month, d)
|
||||
days.push({
|
||||
date: dateStr,
|
||||
day: d,
|
||||
isCurrentMonth: true,
|
||||
isToday: dateStr === todayStr,
|
||||
})
|
||||
}
|
||||
|
||||
// Next month padding (fill to 6 rows * 7 = 42 cells, or at least complete the row)
|
||||
const remaining = 7 - (days.length % 7)
|
||||
if (remaining < 7) {
|
||||
for (let d = 1; d <= remaining; d++) {
|
||||
const m = month === 11 ? 0 : month + 1
|
||||
const y = month === 11 ? year + 1 : year
|
||||
days.push({
|
||||
date: formatDate(y, m, d),
|
||||
day: d,
|
||||
isCurrentMonth: false,
|
||||
isToday: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return days
|
||||
})
|
||||
|
||||
function formatDate(y: number, m: number, d: number): string {
|
||||
return `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Tasks grouped by due date
|
||||
const tasksByDate = computed(() => {
|
||||
const map = new Map<string, Task[]>()
|
||||
for (const task of tasksStore.allTasks) {
|
||||
if (task.due_date && !task.completed) {
|
||||
const existing = map.get(task.due_date) || []
|
||||
existing.push(task)
|
||||
map.set(task.due_date, existing)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function getTasksForDate(dateStr: string): Task[] {
|
||||
return tasksByDate.value.get(dateStr) || []
|
||||
}
|
||||
|
||||
function hasDailyNote(dateStr: string): boolean {
|
||||
return dailyDates.value.has(dateStr)
|
||||
}
|
||||
|
||||
// Navigation
|
||||
function prevMonth() {
|
||||
if (currentMonth.value === 0) {
|
||||
currentMonth.value = 11
|
||||
currentYear.value--
|
||||
} else {
|
||||
currentMonth.value--
|
||||
}
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (currentMonth.value === 11) {
|
||||
currentMonth.value = 0
|
||||
currentYear.value++
|
||||
} else {
|
||||
currentMonth.value++
|
||||
}
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const today = new Date()
|
||||
currentYear.value = today.getFullYear()
|
||||
currentMonth.value = today.getMonth()
|
||||
}
|
||||
|
||||
function clickDate(dateStr: string) {
|
||||
// Navigate to daily note for this date
|
||||
router.push({ name: 'daily-note', params: { date: dateStr } })
|
||||
}
|
||||
|
||||
function clickTask(task: Task) {
|
||||
workspaceStore.setActiveProject(task.project_id)
|
||||
router.push({
|
||||
name: 'project-tasks',
|
||||
params: { id: task.project_id, taskId: task.id }
|
||||
})
|
||||
}
|
||||
|
||||
function projectName(projectId: string): string {
|
||||
const p = projectsStore.getProjectById(projectId)
|
||||
return p?.name || projectId
|
||||
}
|
||||
|
||||
function formatDueClass(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diff = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diff < 0) return 'overdue'
|
||||
if (diff === 0) return 'today'
|
||||
if (diff <= 3) return 'soon'
|
||||
return ''
|
||||
}
|
||||
|
||||
// Load data
|
||||
async function loadDailyDates() {
|
||||
try {
|
||||
const notes: DailyNote[] = await dailyApi.list()
|
||||
dailyDates.value = new Set(notes.map(n => n.date))
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
tasksStore.loadAllTasks(),
|
||||
projectsStore.loadProjects(),
|
||||
loadDailyDates(),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-view">
|
||||
<!-- Header -->
|
||||
<div class="calendar-header">
|
||||
<div class="header-left">
|
||||
<button @click="prevMonth" title="Previous month">‹</button>
|
||||
<h2>{{ monthLabel }}</h2>
|
||||
<button @click="nextMonth" title="Next month">›</button>
|
||||
<button class="small today-btn" @click="goToToday">Today</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Day names -->
|
||||
<div class="calendar-grid day-names">
|
||||
<div v-for="name in dayNames" :key="name" class="day-name">{{ name }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar grid -->
|
||||
<div class="calendar-grid calendar-body">
|
||||
<div
|
||||
v-for="(day, idx) in calendarDays"
|
||||
:key="idx"
|
||||
:class="['calendar-cell', {
|
||||
'other-month': !day.isCurrentMonth,
|
||||
'is-today': day.isToday,
|
||||
'has-tasks': getTasksForDate(day.date).length > 0,
|
||||
}]"
|
||||
>
|
||||
<div class="cell-header" @click="clickDate(day.date)">
|
||||
<span class="cell-day">{{ day.day }}</span>
|
||||
<span v-if="hasDailyNote(day.date)" class="daily-dot" title="Daily note"></span>
|
||||
</div>
|
||||
<div class="cell-tasks">
|
||||
<div
|
||||
v-for="task in getTasksForDate(day.date).slice(0, 3)"
|
||||
:key="task.id"
|
||||
:class="['cell-task', formatDueClass(day.date)]"
|
||||
@click.stop="clickTask(task)"
|
||||
:title="`${projectName(task.project_id)}: ${task.title}`"
|
||||
>
|
||||
{{ task.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getTasksForDate(day.date).length > 3"
|
||||
class="cell-more"
|
||||
@click="clickDate(day.date)"
|
||||
>
|
||||
+{{ getTasksForDate(day.date).length - 3 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left button {
|
||||
font-size: 18px;
|
||||
padding: 4px 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
font-size: 12px !important;
|
||||
padding: 4px 10px !important;
|
||||
}
|
||||
|
||||
.calendar-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Grid layout */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
}
|
||||
|
||||
.day-names {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.calendar-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
grid-auto-rows: minmax(100px, 1fr);
|
||||
}
|
||||
|
||||
/* Calendar cells */
|
||||
.calendar-cell {
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 4px;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-cell:nth-child(7n) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.calendar-cell.other-month {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.calendar-cell.is-today {
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
.calendar-cell.is-today .cell-day {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cell-header:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.cell-day {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.daily-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tasks in cells */
|
||||
.cell-tasks {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.cell-task {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cell-task:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.cell-task.overdue {
|
||||
border-left-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.cell-task.today {
|
||||
border-left-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.cell-task.soon {
|
||||
border-left-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.cell-more {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cell-more:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
294
frontend/src/views/DailyView.vue
Normal file
294
frontend/src/views/DailyView.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { dailyApi } from '../api/client'
|
||||
import { useGitStore } from '../stores'
|
||||
import type { DailyNote } from '../types'
|
||||
import MilkdownEditor from '../components/MilkdownEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
date?: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const gitStore = useGitStore()
|
||||
|
||||
const currentDate = computed((): string => {
|
||||
if (props.date) return props.date
|
||||
const routeDate = route.params.date
|
||||
if (typeof routeDate === 'string') return routeDate
|
||||
return getTodayDate()
|
||||
})
|
||||
|
||||
// Note state
|
||||
const dailyNote = ref<DailyNote | null>(null)
|
||||
const editorContent = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const noteExists = ref(false) // Track if the note file actually exists
|
||||
let saveTimeout: number | null = null
|
||||
|
||||
// Default template for daily notes
|
||||
function getDefaultTemplate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
const formatted = date.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
return `# ${formatted}
|
||||
|
||||
## Today's Focus
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ]
|
||||
`
|
||||
}
|
||||
|
||||
function getTodayDate(): string {
|
||||
return new Date().toISOString().split('T')[0] as string
|
||||
}
|
||||
|
||||
function formatDateDisplay(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
return date.toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
router.push({ name: 'daily' })
|
||||
}
|
||||
|
||||
function goToPrevDay() {
|
||||
const date = new Date(currentDate.value + 'T00:00:00')
|
||||
date.setDate(date.getDate() - 1)
|
||||
const prevDate = date.toISOString().split('T')[0]
|
||||
router.push({ name: 'daily-note', params: { date: prevDate } })
|
||||
}
|
||||
|
||||
function goToNextDay() {
|
||||
const date = new Date(currentDate.value + 'T00:00:00')
|
||||
date.setDate(date.getDate() + 1)
|
||||
const nextDate = date.toISOString().split('T')[0]
|
||||
router.push({ name: 'daily-note', params: { date: nextDate } })
|
||||
}
|
||||
|
||||
function scheduleAutoSave() {
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
saveStatus.value = 'idle'
|
||||
saveTimeout = window.setTimeout(saveNote, 1000)
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
// Don't save if content is just the template or empty
|
||||
const template = getDefaultTemplate(currentDate.value)
|
||||
const trimmedContent = editorContent.value.trim()
|
||||
const trimmedTemplate = template.trim()
|
||||
|
||||
if (!trimmedContent || trimmedContent === trimmedTemplate) {
|
||||
// Don't save empty/template-only content
|
||||
saveStatus.value = 'idle'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
|
||||
if (!noteExists.value) {
|
||||
// Create the note first
|
||||
dailyNote.value = await dailyApi.create(currentDate.value, editorContent.value)
|
||||
noteExists.value = true
|
||||
} else {
|
||||
// Update existing note using the date
|
||||
await dailyApi.update(currentDate.value, editorContent.value)
|
||||
}
|
||||
|
||||
saveStatus.value = 'saved'
|
||||
gitStore.loadStatus()
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
saveStatus.value = 'error'
|
||||
error.value = `Failed to save: ${err}`
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDailyNote() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
noteExists.value = false
|
||||
|
||||
try {
|
||||
// Try to get existing daily note (don't auto-create)
|
||||
dailyNote.value = await dailyApi.get(currentDate.value)
|
||||
editorContent.value = dailyNote.value?.content ?? ''
|
||||
noteExists.value = true
|
||||
} catch (err) {
|
||||
// Note doesn't exist - that's fine, show template but don't create file
|
||||
dailyNote.value = null
|
||||
editorContent.value = getDefaultTemplate(currentDate.value)
|
||||
noteExists.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for content changes - auto-save
|
||||
watch(editorContent, (newContent, oldContent) => {
|
||||
// Only trigger auto-save if content actually changed (not on initial load)
|
||||
if (oldContent !== undefined && newContent !== oldContent) {
|
||||
scheduleAutoSave()
|
||||
}
|
||||
})
|
||||
|
||||
watch(currentDate, () => {
|
||||
loadDailyNote()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="daily-view">
|
||||
<div class="view-header">
|
||||
<div class="date-nav">
|
||||
<button @click="goToPrevDay" title="Previous day">←</button>
|
||||
<h2>{{ formatDateDisplay(currentDate) }}</h2>
|
||||
<button @click="goToNextDay" title="Next day">→</button>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<span :class="['status', saveStatus]">
|
||||
<template v-if="saveStatus === 'saving'">Saving...</template>
|
||||
<template v-else-if="saveStatus === 'saved'">Saved</template>
|
||||
<template v-else-if="saveStatus === 'error'">Save failed</template>
|
||||
</span>
|
||||
<span v-if="!noteExists" class="note-status">Draft</span>
|
||||
<button @click="goToToday" v-if="currentDate !== getTodayDate()">Today</button>
|
||||
<button @click="saveNote" :disabled="saveStatus === 'saving'">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
<button @click="error = null">Dismiss</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading daily note...</div>
|
||||
|
||||
<div v-else class="view-content">
|
||||
<div class="editor-container">
|
||||
<MilkdownEditor
|
||||
v-model="editorContent"
|
||||
placeholder="What's on your mind today?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.daily-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.date-nav button {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status.saving { color: var(--color-primary); }
|
||||
.status.saved { color: var(--color-success); }
|
||||
.status.error { color: var(--color-danger); }
|
||||
|
||||
.note-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-message button {
|
||||
background: transparent;
|
||||
border: 1px solid white;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
</style>
|
||||
415
frontend/src/views/DashboardView.vue
Normal file
415
frontend/src/views/DashboardView.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectsStore, useTasksStore, useWorkspaceStore } from '../stores'
|
||||
import type { Task } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
const tasksStore = useTasksStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
// Group tasks by project
|
||||
const projectSummaries = computed(() => {
|
||||
return projectsStore.sortedProjects.map(project => {
|
||||
const projectTasks = tasksStore.allTasks.filter(t => t.project_id === project.id)
|
||||
const active = projectTasks.filter(t => !t.completed && t.is_active)
|
||||
const backlog = projectTasks.filter(t => !t.completed && !t.is_active)
|
||||
const completed = projectTasks.filter(t => t.completed)
|
||||
const overdue = active.filter(t => {
|
||||
if (!t.due_date) return false
|
||||
return new Date(t.due_date) < new Date()
|
||||
})
|
||||
|
||||
return {
|
||||
...project,
|
||||
activeTasks: active,
|
||||
backlogCount: backlog.length,
|
||||
completedCount: completed.length,
|
||||
overdueCount: overdue.length,
|
||||
totalCount: projectTasks.length,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function formatDueDate(dateStr?: string) {
|
||||
if (!dateStr) return null
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays < 0) return { text: 'Overdue', class: 'overdue' }
|
||||
if (diffDays === 0) return { text: 'Today', class: 'today' }
|
||||
if (diffDays === 1) return { text: 'Tomorrow', class: 'soon' }
|
||||
if (diffDays <= 7) return { text: `${diffDays}d`, class: 'soon' }
|
||||
return { text: date.toLocaleDateString(), class: '' }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function goToProject(projectId: string) {
|
||||
workspaceStore.setActiveProject(projectId)
|
||||
router.push({ name: 'project', params: { id: projectId } })
|
||||
}
|
||||
|
||||
function goToProjectTasks(projectId: string) {
|
||||
workspaceStore.setActiveProject(projectId)
|
||||
router.push({ name: 'project-tasks', params: { id: projectId } })
|
||||
}
|
||||
|
||||
function goToTask(projectId: string, task: Task) {
|
||||
workspaceStore.setActiveProject(projectId)
|
||||
router.push({ name: 'project-tasks', params: { id: projectId, taskId: task.id } })
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Project name:')
|
||||
if (!name) return
|
||||
|
||||
try {
|
||||
const project = await projectsStore.createProject(name)
|
||||
await workspaceStore.setActiveProject(project.id)
|
||||
router.push({ name: 'project', params: { id: project.id } })
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
projectsStore.loadProjects(),
|
||||
tasksStore.loadAllTasks()
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard-header">
|
||||
<div class="header-left">
|
||||
<h2>Dashboard</h2>
|
||||
<span class="project-count">{{ projectSummaries.length }} projects</span>
|
||||
</div>
|
||||
<button class="primary" @click="createProject">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<div v-else-if="projectSummaries.length === 0" class="empty-state">
|
||||
<h3>Welcome to Ironpad</h3>
|
||||
<p>Create your first project to get started.</p>
|
||||
<button class="primary" @click="createProject" style="margin-top: 16px">Create Project</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="dashboard-grid">
|
||||
<div
|
||||
v-for="project in projectSummaries"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="card-header" @click="goToProject(project.id)">
|
||||
<h3 class="card-title">{{ project.name }}</h3>
|
||||
<div class="card-stats">
|
||||
<span class="stat active-stat" :title="`${project.activeTasks.length} active`">
|
||||
{{ project.activeTasks.length }} active
|
||||
</span>
|
||||
<span v-if="project.backlogCount > 0" class="stat backlog-stat" :title="`${project.backlogCount} backlog`">
|
||||
{{ project.backlogCount }} backlog
|
||||
</span>
|
||||
<span v-if="project.overdueCount > 0" class="stat overdue-stat" :title="`${project.overdueCount} overdue`">
|
||||
{{ project.overdueCount }} overdue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Tasks List -->
|
||||
<div class="card-tasks" v-if="project.activeTasks.length > 0">
|
||||
<div
|
||||
v-for="task in project.activeTasks.slice(0, 5)"
|
||||
:key="task.id"
|
||||
class="card-task-item"
|
||||
@click="goToTask(project.id, task)"
|
||||
>
|
||||
<span class="task-checkbox">☐</span>
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
<div class="task-meta">
|
||||
<span
|
||||
v-for="tag in task.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
class="task-tag"
|
||||
>{{ tag }}</span>
|
||||
<span
|
||||
v-if="task.due_date && formatDueDate(task.due_date)"
|
||||
:class="['task-due', formatDueDate(task.due_date)?.class]"
|
||||
>{{ formatDueDate(task.due_date)?.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="project.activeTasks.length > 5"
|
||||
class="card-task-more"
|
||||
@click="goToProjectTasks(project.id)"
|
||||
>
|
||||
+{{ project.activeTasks.length - 5 }} more tasks...
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card-empty">
|
||||
No active tasks
|
||||
</div>
|
||||
|
||||
<!-- Card Footer -->
|
||||
<div class="card-footer">
|
||||
<span class="completed-count" v-if="project.completedCount > 0">
|
||||
{{ project.completedCount }} completed
|
||||
</span>
|
||||
<button class="link-btn" @click="goToProjectTasks(project.id)">View All Tasks</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
padding: 0 24px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 20px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* Project Card */
|
||||
.project-card {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.active-stat {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.backlog-stat {
|
||||
background: rgba(153, 153, 153, 0.15);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.overdue-stat {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Tasks in card */
|
||||
.card-tasks {
|
||||
padding: 0 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.card-task-item:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.card-task-item .task-checkbox {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-task-item .task-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-due {
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.task-due.overdue {
|
||||
color: var(--color-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-due.today {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.task-due.soon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card-task-more {
|
||||
padding: 8px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-task-more:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
padding: 16px 20px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Card Footer */
|
||||
.card-footer {
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.completed-count {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* States */
|
||||
.loading,
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text);
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
358
frontend/src/views/NotesView.vue
Normal file
358
frontend/src/views/NotesView.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useNotesStore, useWebSocketStore, useGitStore } from '../stores'
|
||||
import MilkdownEditor from '../components/MilkdownEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const notesStore = useNotesStore()
|
||||
const wsStore = useWebSocketStore()
|
||||
const gitStore = useGitStore()
|
||||
|
||||
const editorContent = ref('')
|
||||
let saveTimeout: number | null = null
|
||||
|
||||
// CRITICAL: Separate key for editor recreation - only update AFTER content is ready
|
||||
const editorKey = ref<string | null>(null)
|
||||
|
||||
// Track the last saved/loaded content to detect actual user changes
|
||||
// This prevents unnecessary saves when just opening a note
|
||||
const lastSavedContent = ref<string | null>(null)
|
||||
|
||||
// Track which note ID the pending save is for
|
||||
let pendingSaveNoteId: string | null = null
|
||||
|
||||
const noteId = computed(() => props.id || (route.params.id as string))
|
||||
const selectedNote = computed(() => notesStore.getNoteById(noteId.value))
|
||||
|
||||
const isReadOnly = computed(() => {
|
||||
if (!notesStore.currentNote) return false
|
||||
return wsStore.isFileLockedByOther(notesStore.currentNote.path)
|
||||
})
|
||||
|
||||
function clearPendingSave() {
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout)
|
||||
saveTimeout = null
|
||||
}
|
||||
pendingSaveNoteId = null
|
||||
}
|
||||
|
||||
// Save current content immediately before switching notes
|
||||
async function saveBeforeSwitch() {
|
||||
const noteIdToSave = pendingSaveNoteId
|
||||
const contentToSave = editorContent.value
|
||||
const currentNoteId = notesStore.currentNote?.id
|
||||
|
||||
console.log('[NotesView] saveBeforeSwitch called:', {
|
||||
noteIdToSave,
|
||||
currentNoteId,
|
||||
contentLength: contentToSave?.length,
|
||||
hasCurrentNote: !!notesStore.currentNote
|
||||
})
|
||||
|
||||
// Clear pending state first
|
||||
clearPendingSave()
|
||||
|
||||
// Only save if we had a pending save for the current note
|
||||
if (!noteIdToSave || !notesStore.currentNote) {
|
||||
console.log('[NotesView] Skipping save - no pending save or no current note')
|
||||
return
|
||||
}
|
||||
if (notesStore.currentNote.id !== noteIdToSave) {
|
||||
console.log('[NotesView] Skipping save - note ID mismatch:', { currentNoteId: notesStore.currentNote.id, noteIdToSave })
|
||||
return
|
||||
}
|
||||
|
||||
// Only save if content actually changed
|
||||
if (contentToSave === lastSavedContent.value) {
|
||||
console.log('[NotesView] Skipping save before switch - content unchanged')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[NotesView] Saving content before switch:', { noteIdToSave, contentLength: contentToSave.length })
|
||||
try {
|
||||
await notesStore.saveNote(contentToSave)
|
||||
lastSavedContent.value = contentToSave
|
||||
console.log('[NotesView] Save completed successfully')
|
||||
} catch (err) {
|
||||
console.error('[NotesView] Save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save with debounce
|
||||
function scheduleAutoSave() {
|
||||
console.log('[NotesView] scheduleAutoSave called for note:', noteId.value)
|
||||
clearPendingSave()
|
||||
notesStore.saveStatus = 'idle'
|
||||
// Capture the current note ID for this save operation
|
||||
pendingSaveNoteId = noteId.value || null
|
||||
saveTimeout = window.setTimeout(async () => {
|
||||
const noteIdToSave = pendingSaveNoteId
|
||||
const contentToSave = editorContent.value
|
||||
|
||||
// Clear pending state
|
||||
pendingSaveNoteId = null
|
||||
saveTimeout = null
|
||||
|
||||
// Verify we're still on the same note - critical check to prevent overwrites
|
||||
if (!noteIdToSave || !notesStore.currentNote || noteId.value !== noteIdToSave) {
|
||||
console.log('[NotesView] Skipping save - note changed:', { noteIdToSave, currentNoteId: noteId.value })
|
||||
return
|
||||
}
|
||||
|
||||
// Double-check the current note ID matches
|
||||
if (notesStore.currentNote.id !== noteIdToSave) {
|
||||
console.log('[NotesView] Skipping save - currentNote mismatch:', { noteIdToSave, currentNoteId: notesStore.currentNote.id })
|
||||
return
|
||||
}
|
||||
|
||||
// Final check: only save if content actually changed from last saved
|
||||
if (contentToSave === lastSavedContent.value) {
|
||||
console.log('[NotesView] Skipping save - content unchanged from last save')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await notesStore.saveNote(contentToSave)
|
||||
// Update last saved content on success
|
||||
lastSavedContent.value = contentToSave
|
||||
gitStore.loadStatus()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
clearPendingSave()
|
||||
if (!notesStore.currentNote) return
|
||||
|
||||
try {
|
||||
await notesStore.saveNote(editorContent.value)
|
||||
// Update last saved content on success
|
||||
lastSavedContent.value = editorContent.value
|
||||
gitStore.loadStatus()
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote() {
|
||||
if (!confirm('Archive this note?')) return
|
||||
try {
|
||||
await notesStore.deleteNote()
|
||||
router.push({ name: 'home' })
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for note changes
|
||||
watch(noteId, async (newId, oldId) => {
|
||||
console.log('[NotesView] noteId changed:', { oldId, newId, pendingSaveNoteId })
|
||||
|
||||
// Save any pending content from the previous note BEFORE switching
|
||||
if (oldId && pendingSaveNoteId) {
|
||||
console.log('[NotesView] Has pending save, calling saveBeforeSwitch')
|
||||
await saveBeforeSwitch()
|
||||
} else {
|
||||
console.log('[NotesView] No pending save, clearing')
|
||||
clearPendingSave()
|
||||
}
|
||||
notesStore.saveStatus = 'idle'
|
||||
|
||||
if (newId) {
|
||||
console.log('[NotesView] Loading note:', newId)
|
||||
await notesStore.loadNote(newId)
|
||||
|
||||
// CRITICAL: Set content BEFORE updating editorKey
|
||||
const loadedContent = notesStore.currentNote?.content ?? ''
|
||||
console.log('[NotesView] Setting editor content, length:', loadedContent.length)
|
||||
editorContent.value = loadedContent
|
||||
|
||||
// Track this as the "original" content - only save if user makes changes
|
||||
lastSavedContent.value = loadedContent
|
||||
|
||||
// NOW update the editor key - this triggers editor recreation with correct content
|
||||
editorKey.value = newId
|
||||
console.log('[NotesView] Updated editorKey to:', newId)
|
||||
} else {
|
||||
notesStore.clearCurrentNote()
|
||||
editorContent.value = ''
|
||||
lastSavedContent.value = null
|
||||
editorKey.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for content changes - ONLY save when content differs from last saved
|
||||
watch(editorContent, (newContent) => {
|
||||
// Skip if no note loaded or read-only
|
||||
if (!notesStore.currentNote || isReadOnly.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Only schedule auto-save if content actually differs from last saved/loaded
|
||||
// This prevents unnecessary saves when just opening a note
|
||||
if (lastSavedContent.value !== null && newContent !== lastSavedContent.value) {
|
||||
console.log('[NotesView] Content changed from last saved, scheduling auto-save')
|
||||
scheduleAutoSave()
|
||||
}
|
||||
})
|
||||
|
||||
// Milkdown is WYSIWYG - no separate preview needed
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notes-view">
|
||||
<template v-if="notesStore.currentNote && editorKey">
|
||||
<div class="view-header">
|
||||
<h2>{{ selectedNote?.title ?? notesStore.currentNote.id }}</h2>
|
||||
<div class="button-group">
|
||||
<span :class="['status', notesStore.saveStatus]">
|
||||
<template v-if="notesStore.saveStatus === 'saving'">Saving...</template>
|
||||
<template v-else-if="notesStore.saveStatus === 'saved'">Saved</template>
|
||||
<template v-else-if="notesStore.saveStatus === 'error'">Save failed</template>
|
||||
</span>
|
||||
<span v-if="wsStore.connected" class="ws-status connected" title="Real-time sync active">●</span>
|
||||
<span v-else class="ws-status" title="Connecting...">○</span>
|
||||
<button @click="saveNote" :disabled="notesStore.saveStatus === 'saving'">Save</button>
|
||||
<button class="danger" @click="deleteNote">Archive</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isReadOnly" class="read-only-banner">
|
||||
🔒 This file is being edited elsewhere. Read-only mode.
|
||||
</div>
|
||||
|
||||
<div class="view-content">
|
||||
<div v-if="notesStore.loadingNote" class="loading">Loading note...</div>
|
||||
<div v-else class="editor-container">
|
||||
<MilkdownEditor
|
||||
v-model="editorContent"
|
||||
:editor-key="editorKey"
|
||||
:readonly="isReadOnly"
|
||||
placeholder="Start writing..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<h2>No note selected</h2>
|
||||
<p>Select a note from the sidebar or create a new one.</p>
|
||||
<p class="shortcuts">
|
||||
<kbd>Ctrl+K</kbd> Search · <kbd>Ctrl+S</kbd> Save
|
||||
</p>
|
||||
<button class="primary" @click="notesStore.createNote().then(n => router.push({ name: 'note', params: { id: n.id } }))" style="margin-top: 16px">
|
||||
Create Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notes-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status.saving { color: var(--color-primary); }
|
||||
.status.saved { color: var(--color-success); }
|
||||
.status.error { color: var(--color-danger); }
|
||||
|
||||
.ws-status {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ws-status.connected {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.read-only-banner {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.view-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shortcuts kbd {
|
||||
background: var(--color-border);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
529
frontend/src/views/ProjectNotesView.vue
Normal file
529
frontend/src/views/ProjectNotesView.vue
Normal file
@@ -0,0 +1,529 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWorkspaceStore, useGitStore } from '../stores'
|
||||
import { projectsApi } from '../api/client'
|
||||
import type { ProjectNote, ProjectNoteWithContent } from '../types'
|
||||
import MilkdownEditor from '../components/MilkdownEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
noteId?: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const gitStore = useGitStore()
|
||||
|
||||
const projectId = computed(() => props.id || (route.params.id as string))
|
||||
const currentNoteId = computed(() => props.noteId || (route.params.noteId as string | undefined))
|
||||
|
||||
// Notes list state
|
||||
const notes = ref<ProjectNote[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Editor state
|
||||
const selectedNote = ref<ProjectNoteWithContent | null>(null)
|
||||
const editorContent = ref('')
|
||||
const editorLoading = ref(false)
|
||||
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
let saveTimeout: number | null = null
|
||||
|
||||
// CRITICAL: Separate key for editor recreation - only update AFTER content is ready
|
||||
// This prevents the race condition where the editor recreates before content loads
|
||||
const editorKey = ref<string | null>(null)
|
||||
|
||||
// Track the last saved/loaded content to detect actual user changes
|
||||
// This prevents unnecessary saves when just opening a note
|
||||
const lastSavedContent = ref<string | null>(null)
|
||||
|
||||
// Track which note ID the pending save is for
|
||||
let pendingSaveNoteId: string | null = null
|
||||
|
||||
async function loadNotes() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
notes.value = await projectsApi.listNotes(projectId.value)
|
||||
} catch (err) {
|
||||
error.value = `Failed to load notes: ${err}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNote(noteId: string) {
|
||||
editorLoading.value = true
|
||||
try {
|
||||
selectedNote.value = await projectsApi.getNote(projectId.value, noteId)
|
||||
|
||||
// CRITICAL: Set content BEFORE updating editorKey
|
||||
// This ensures when the editor recreates, it has the correct defaultValue
|
||||
const newContent = selectedNote.value?.content ?? ''
|
||||
editorContent.value = newContent
|
||||
console.log('[ProjectNotesView] Loaded note content, length:', newContent.length)
|
||||
|
||||
// Track this as the "original" content - only save if user makes changes
|
||||
lastSavedContent.value = newContent
|
||||
|
||||
// NOW update the editor key - this triggers editor recreation with correct content
|
||||
editorKey.value = noteId
|
||||
console.log('[ProjectNotesView] Updated editorKey to:', noteId)
|
||||
} catch {
|
||||
selectedNote.value = null
|
||||
editorContent.value = ''
|
||||
lastSavedContent.value = null
|
||||
editorKey.value = null
|
||||
} finally {
|
||||
editorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote() {
|
||||
const title = prompt('Note title (optional):')
|
||||
try {
|
||||
const note = await projectsApi.createNote(projectId.value, title || undefined)
|
||||
await loadNotes() // Refresh list
|
||||
// Select the new note
|
||||
const filename = note.path.split('/').pop()?.replace('.md', '')
|
||||
if (filename) {
|
||||
router.push({ name: 'project-notes', params: { id: projectId.value, noteId: filename } })
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to create note: ${err}`
|
||||
}
|
||||
}
|
||||
|
||||
function selectNote(note: ProjectNote) {
|
||||
const filename = note.path.split('/').pop()?.replace('.md', '')
|
||||
if (filename) {
|
||||
router.push({ name: 'project-notes', params: { id: projectId.value, noteId: filename } })
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingSave() {
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout)
|
||||
saveTimeout = null
|
||||
}
|
||||
pendingSaveNoteId = null
|
||||
}
|
||||
|
||||
// Save current content immediately before switching notes
|
||||
async function saveBeforeSwitch() {
|
||||
const noteIdToSave = pendingSaveNoteId
|
||||
const contentToSave = editorContent.value
|
||||
|
||||
console.log('[ProjectNotesView] saveBeforeSwitch called:', {
|
||||
noteIdToSave,
|
||||
hasSelectedNote: !!selectedNote.value,
|
||||
contentLength: contentToSave?.length
|
||||
})
|
||||
|
||||
// Clear pending state first
|
||||
clearPendingSave()
|
||||
|
||||
// Only save if we had a pending save for the current note
|
||||
if (!noteIdToSave || !selectedNote.value) {
|
||||
console.log('[ProjectNotesView] Skipping save - no pending save or no selected note')
|
||||
return
|
||||
}
|
||||
|
||||
// Only save if content actually changed
|
||||
if (contentToSave === lastSavedContent.value) {
|
||||
console.log('[ProjectNotesView] Skipping save before switch - content unchanged')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[ProjectNotesView] Saving content before switch:', { noteIdToSave, contentLength: contentToSave.length })
|
||||
try {
|
||||
await projectsApi.updateNote(projectId.value, noteIdToSave, contentToSave)
|
||||
lastSavedContent.value = contentToSave
|
||||
console.log('[ProjectNotesView] Save completed successfully')
|
||||
gitStore.loadStatus()
|
||||
} catch (err) {
|
||||
console.error('[ProjectNotesView] Save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoSave() {
|
||||
console.log('[ProjectNotesView] scheduleAutoSave called for note:', currentNoteId.value)
|
||||
clearPendingSave()
|
||||
saveStatus.value = 'idle'
|
||||
// Capture the current note ID for this save operation
|
||||
pendingSaveNoteId = currentNoteId.value || null
|
||||
saveTimeout = window.setTimeout(saveNoteContent, 1000)
|
||||
}
|
||||
|
||||
async function saveNoteContent() {
|
||||
const noteIdToSave = pendingSaveNoteId
|
||||
const contentToSave = editorContent.value
|
||||
|
||||
// Clear pending state
|
||||
pendingSaveNoteId = null
|
||||
saveTimeout = null
|
||||
|
||||
// Verify we're still on the same note - critical check to prevent overwrites
|
||||
if (!noteIdToSave || !selectedNote.value || currentNoteId.value !== noteIdToSave) {
|
||||
console.log('[ProjectNotesView] Skipping save - note changed:', { noteIdToSave, currentNoteId: currentNoteId.value })
|
||||
return
|
||||
}
|
||||
|
||||
// Final check: only save if content actually changed from last saved
|
||||
if (contentToSave === lastSavedContent.value) {
|
||||
console.log('[ProjectNotesView] Skipping save - content unchanged from last save')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
const savedNote = await projectsApi.updateNote(projectId.value, noteIdToSave, contentToSave)
|
||||
|
||||
// Only update state if we're still on the same note
|
||||
if (currentNoteId.value === noteIdToSave) {
|
||||
selectedNote.value = savedNote
|
||||
lastSavedContent.value = contentToSave
|
||||
saveStatus.value = 'saved'
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
}
|
||||
gitStore.loadStatus()
|
||||
await loadNotes() // Refresh list to update timestamps
|
||||
} catch (err) {
|
||||
if (currentNoteId.value === noteIdToSave) {
|
||||
saveStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
clearPendingSave()
|
||||
if (!selectedNote.value || !currentNoteId.value) return
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
selectedNote.value = await projectsApi.updateNote(projectId.value, currentNoteId.value, editorContent.value)
|
||||
lastSavedContent.value = editorContent.value
|
||||
saveStatus.value = 'saved'
|
||||
gitStore.loadStatus()
|
||||
await loadNotes() // Refresh list to update timestamps
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
saveStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote() {
|
||||
if (!selectedNote.value || !currentNoteId.value) return
|
||||
if (!confirm('Are you sure you want to delete this note?')) return
|
||||
|
||||
try {
|
||||
await projectsApi.deleteNote(projectId.value, currentNoteId.value)
|
||||
selectedNote.value = null
|
||||
editorContent.value = ''
|
||||
router.push({ name: 'project-notes', params: { id: projectId.value } })
|
||||
await loadNotes()
|
||||
} catch (err) {
|
||||
alert(`Failed to delete note: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(note: ProjectNote) {
|
||||
const filename = note.path.split('/').pop()?.replace('.md', '')
|
||||
return filename === currentNoteId.value
|
||||
}
|
||||
|
||||
// Watch for content changes - ONLY save when content differs from last saved
|
||||
watch(editorContent, (newContent) => {
|
||||
// Skip if no note loaded
|
||||
if (!selectedNote.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Only schedule auto-save if content actually differs from last saved/loaded
|
||||
// This prevents unnecessary saves when just opening a note
|
||||
if (lastSavedContent.value !== null && newContent !== lastSavedContent.value) {
|
||||
console.log('[ProjectNotesView] Content changed from last saved, scheduling auto-save')
|
||||
scheduleAutoSave()
|
||||
}
|
||||
})
|
||||
|
||||
watch(projectId, () => {
|
||||
// Clear any pending saves when switching projects
|
||||
clearPendingSave()
|
||||
editorContent.value = ''
|
||||
lastSavedContent.value = null
|
||||
selectedNote.value = null
|
||||
|
||||
loadNotes()
|
||||
}, { immediate: true })
|
||||
|
||||
watch(currentNoteId, async (noteId, oldNoteId) => {
|
||||
console.log('[ProjectNotesView] currentNoteId changed:', { oldNoteId, noteId, pendingSaveNoteId })
|
||||
|
||||
// Save any pending content from the previous note BEFORE switching
|
||||
if (oldNoteId && pendingSaveNoteId) {
|
||||
console.log('[ProjectNotesView] Has pending save, calling saveBeforeSwitch')
|
||||
await saveBeforeSwitch()
|
||||
} else {
|
||||
clearPendingSave()
|
||||
}
|
||||
saveStatus.value = 'idle'
|
||||
|
||||
if (noteId) {
|
||||
console.log('[ProjectNotesView] Loading note:', noteId)
|
||||
await loadNote(noteId)
|
||||
} else {
|
||||
selectedNote.value = null
|
||||
editorContent.value = ''
|
||||
lastSavedContent.value = null
|
||||
editorKey.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
if (workspaceStore.activeProjectId !== projectId.value) {
|
||||
workspaceStore.setActiveProject(projectId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notes-split-view">
|
||||
<!-- Notes List Panel -->
|
||||
<div class="notes-list-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Notes</h3>
|
||||
<button class="primary small" @click="createNote">+ New</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-small">Loading...</div>
|
||||
|
||||
<div v-else-if="notes.length === 0" class="empty-list">
|
||||
<p>No notes yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="notes-list">
|
||||
<div
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
:class="['note-item', { selected: isSelected(note) }]"
|
||||
@click="selectNote(note)"
|
||||
>
|
||||
<div class="note-title">{{ note.title || 'Untitled' }}</div>
|
||||
<div class="note-meta">{{ formatDate(note.updated) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Panel -->
|
||||
<div class="editor-panel">
|
||||
<template v-if="currentNoteId && selectedNote && editorKey">
|
||||
<div class="editor-header">
|
||||
<h3>{{ selectedNote.title || 'Untitled' }}</h3>
|
||||
<div class="editor-actions">
|
||||
<span :class="['status', saveStatus]">
|
||||
<template v-if="saveStatus === 'saving'">Saving...</template>
|
||||
<template v-else-if="saveStatus === 'saved'">Saved</template>
|
||||
<template v-else-if="saveStatus === 'error'">Error</template>
|
||||
</span>
|
||||
<button @click="saveNote" :disabled="saveStatus === 'saving'">Save</button>
|
||||
<button class="danger" @click="deleteNote">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-content">
|
||||
<MilkdownEditor
|
||||
v-model="editorContent"
|
||||
:editor-key="editorKey"
|
||||
:project-id="projectId"
|
||||
placeholder="Write your note..."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="editorLoading">
|
||||
<div class="editor-placeholder">
|
||||
<p>Loading note...</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="editor-placeholder">
|
||||
<h3>Select a note</h3>
|
||||
<p>Choose a note from the list or create a new one.</p>
|
||||
<button class="primary" @click="createNote">+ New Note</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notes-split-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Notes List Panel */
|
||||
.notes-list-panel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button.small {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notes-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.note-item:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.note-item.selected {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.note-item.selected .note-meta {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.note-title {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note-meta {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-small,
|
||||
.empty-list {
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Editor Panel */
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
height: 52px;
|
||||
min-height: 52px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-header h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status.saving { color: var(--color-primary); }
|
||||
.status.saved { color: var(--color-success); }
|
||||
.status.error { color: var(--color-danger); }
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.editor-placeholder h3 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.editor-placeholder p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
188
frontend/src/views/ProjectView.vue
Normal file
188
frontend/src/views/ProjectView.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useProjectsStore, useGitStore, useWorkspaceStore } from '../stores'
|
||||
import { projectsApi } from '../api/client'
|
||||
import type { ProjectWithContent } from '../types'
|
||||
import MilkdownEditor from '../components/MilkdownEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
const gitStore = useGitStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
const projectId = computed(() => props.id || (route.params.id as string))
|
||||
const project = computed(() => projectsStore.getProjectById(projectId.value))
|
||||
const projectContent = ref<ProjectWithContent | null>(null)
|
||||
const editorContent = ref('')
|
||||
const loading = ref(false)
|
||||
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
let saveTimeout: number | null = null
|
||||
|
||||
function goToTasks() {
|
||||
router.push({ name: 'project-tasks', params: { id: projectId.value } })
|
||||
}
|
||||
|
||||
function scheduleAutoSave() {
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
saveStatus.value = 'idle'
|
||||
saveTimeout = window.setTimeout(saveNote, 1000)
|
||||
}
|
||||
|
||||
async function saveNote() {
|
||||
if (!projectContent.value) return
|
||||
|
||||
try {
|
||||
saveStatus.value = 'saving'
|
||||
projectContent.value = await projectsApi.updateContent(projectId.value, editorContent.value)
|
||||
saveStatus.value = 'saved'
|
||||
gitStore.loadStatus()
|
||||
setTimeout(() => {
|
||||
if (saveStatus.value === 'saved') saveStatus.value = 'idle'
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
saveStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectContent() {
|
||||
loading.value = true
|
||||
try {
|
||||
projectContent.value = await projectsApi.getContent(projectId.value)
|
||||
editorContent.value = projectContent.value?.content ?? ''
|
||||
} catch {
|
||||
projectContent.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(editorContent, (newContent, oldContent) => {
|
||||
if (projectContent.value && oldContent !== undefined && newContent !== oldContent) {
|
||||
scheduleAutoSave()
|
||||
}
|
||||
})
|
||||
|
||||
watch(projectId, () => {
|
||||
loadProjectContent()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
await projectsStore.loadProject(projectId.value)
|
||||
|
||||
// Set as active project
|
||||
if (workspaceStore.activeProjectId !== projectId.value) {
|
||||
await workspaceStore.setActiveProject(projectId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="project-view">
|
||||
<div class="view-header">
|
||||
<h2>{{ project?.name ?? projectId }}</h2>
|
||||
<div class="button-group">
|
||||
<span :class="['status', saveStatus]">
|
||||
<template v-if="saveStatus === 'saving'">Saving...</template>
|
||||
<template v-else-if="saveStatus === 'saved'">Saved</template>
|
||||
<template v-else-if="saveStatus === 'error'">Save failed</template>
|
||||
</span>
|
||||
<button class="primary" @click="goToTasks">View Tasks</button>
|
||||
<button @click="saveNote" :disabled="saveStatus === 'saving'">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading project...</div>
|
||||
|
||||
<div v-else-if="projectContent" class="view-content">
|
||||
<div class="editor-container">
|
||||
<MilkdownEditor
|
||||
v-model="editorContent"
|
||||
:project-id="projectId"
|
||||
placeholder="Write about this project..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<h3>Project not found</h3>
|
||||
<p>This project doesn't exist or couldn't be loaded.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
height: var(--header-height);
|
||||
min-height: var(--header-height);
|
||||
max-height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status.saving { color: var(--color-primary); }
|
||||
.status.saved { color: var(--color-success); }
|
||||
.status.error { color: var(--color-danger); }
|
||||
|
||||
.view-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
132
frontend/src/views/ProjectsView.vue
Normal file
132
frontend/src/views/ProjectsView.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectsStore } from '../stores'
|
||||
|
||||
const router = useRouter()
|
||||
const projectsStore = useProjectsStore()
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Project name:')
|
||||
if (!name) return
|
||||
|
||||
try {
|
||||
const project = await projectsStore.createProject(name)
|
||||
router.push({ name: 'project', params: { id: project.id } })
|
||||
} catch {
|
||||
// Error handled in store
|
||||
}
|
||||
}
|
||||
|
||||
function selectProject(id: string) {
|
||||
router.push({ name: 'project', params: { id } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
projectsStore.loadProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="projects-view">
|
||||
<div class="view-header">
|
||||
<h2>Projects</h2>
|
||||
<button class="primary" @click="createProject">+ New Project</button>
|
||||
</div>
|
||||
|
||||
<div v-if="projectsStore.loading" class="loading">Loading projects...</div>
|
||||
|
||||
<div v-else-if="projectsStore.projects.length === 0" class="empty-state">
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create your first project to get started.</p>
|
||||
<button class="primary" @click="createProject" style="margin-top: 16px">Create Project</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="projects-grid">
|
||||
<div
|
||||
v-for="project in projectsStore.sortedProjects"
|
||||
:key="project.id"
|
||||
class="project-card"
|
||||
@click="selectProject(project.id)"
|
||||
>
|
||||
<h3>{{ project.name }}</h3>
|
||||
<p class="project-path">{{ project.path }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.projects-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.view-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.project-card h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-path {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
1624
frontend/src/views/TasksView.vue
Normal file
1624
frontend/src/views/TasksView.vue
Normal file
File diff suppressed because it is too large
Load Diff
16
frontend/tsconfig.app.json
Normal file
16
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:3000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user