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:
skepsismusic
2026-02-06 00:13:31 +01:00
commit ebe3e2aa8f
97 changed files with 25033 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View 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
View 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
View 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
View 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)}`
}

View 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

View 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>

View 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">&lt;/&gt;</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>

View 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>

View 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>

View 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 = `![${altText}](${result.url})`
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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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')

View 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
View 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
}
})

View 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'

View 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
}
})

View 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
}
})

View 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
}
})

View 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
View 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
}
})

View 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
}
})

View 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
View 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
View 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'
}

View 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">&lsaquo;</button>
<h2>{{ monthLabel }}</h2>
<button @click="nextMonth" title="Next month">&rsaquo;</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>

View 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>

View 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">&#9744;</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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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,
},
},
},
})