feat: 功能迭代
This commit is contained in:
@@ -12,7 +12,10 @@
|
|||||||
"Bash(ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node *)",
|
"Bash(ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node *)",
|
||||||
"Bash(pkill -f \"electron-vite\")",
|
"Bash(pkill -f \"electron-vite\")",
|
||||||
"Bash(npm list *)",
|
"Bash(npm list *)",
|
||||||
"Bash(npx electron-vite *)"
|
"Bash(npx electron-vite *)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:primevue.org)",
|
||||||
|
"Bash(curl -s http://localhost:5173)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
package-lock.json
generated
136
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"nanoid": "^5.1.11",
|
"nanoid": "^5.1.11",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.5.5",
|
"primevue": "^4.5.5",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
@@ -2110,6 +2111,39 @@
|
|||||||
"@vue/shared": "3.5.35"
|
"@vue/shared": "3.5.35"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue/devtools-api": {
|
||||||
|
"version": "7.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
|
||||||
|
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-kit": "^7.7.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/devtools-kit": {
|
||||||
|
"version": "7.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
|
||||||
|
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-shared": "^7.7.9",
|
||||||
|
"birpc": "^2.3.0",
|
||||||
|
"hookable": "^5.5.3",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"perfect-debounce": "^1.0.0",
|
||||||
|
"speakingurl": "^14.0.1",
|
||||||
|
"superjson": "^2.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/devtools-shared": {
|
||||||
|
"version": "7.7.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
|
||||||
|
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"rfdc": "^1.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.35",
|
"version": "3.5.35",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz",
|
||||||
@@ -2568,6 +2602,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/birpc": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/boolean": {
|
"node_modules/boolean": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||||
@@ -3003,6 +3046,21 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/copy-anything": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-what": "^5.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||||
@@ -4767,6 +4825,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hookable": {
|
||||||
|
"version": "5.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||||
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hosted-git-info": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||||
@@ -4962,6 +5026,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-what": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/isbinaryfile": {
|
"node_modules/isbinaryfile": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
|
||||||
@@ -6326,6 +6402,12 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mitt": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mkdirp": {
|
"node_modules/mkdirp": {
|
||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
@@ -6591,6 +6673,12 @@
|
|||||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/perfect-debounce": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6609,6 +6697,27 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pinia": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^7.7.7"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.5.0",
|
||||||
|
"vue": "^3.5.11"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/plist": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||||
@@ -6982,6 +7091,12 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rfdc": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rimraf": {
|
"node_modules/rimraf": {
|
||||||
"version": "2.6.3",
|
"version": "2.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
|
||||||
@@ -7248,6 +7363,15 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/speakingurl": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||||
@@ -7335,6 +7459,18 @@
|
|||||||
"node": ">= 8.0"
|
"node": ">= 8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/superjson": {
|
||||||
|
"version": "2.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
|
||||||
|
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"copy-anything": "^4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"electron-vite": "^5.0.0",
|
"electron-vite": "^5.0.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"nanoid": "^5.1.11",
|
"nanoid": "^5.1.11",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.5.5",
|
"primevue": "^4.5.5",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
|
import { app, BrowserWindow, ipcMain, nativeTheme, dialog } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
||||||
import { is } from '@electron-toolkit/utils'
|
import { is } from '@electron-toolkit/utils'
|
||||||
import { StorageService } from './services/StorageService'
|
import { StorageService } from './services/StorageService'
|
||||||
|
|
||||||
let mainWindow = null
|
let mainWindow = null
|
||||||
let storageService = null
|
let storageService = null
|
||||||
|
let workspaceReady = false
|
||||||
|
|
||||||
// 标题栏颜色配置
|
// 标题栏颜色配置
|
||||||
const titleBarColors = {
|
const titleBarColors = {
|
||||||
@@ -18,6 +20,39 @@ const titleBarColors = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Config Management ====================
|
||||||
|
|
||||||
|
const configPath = join(app.getPath('userData'), 'config.json')
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
if (existsSync(configPath)) {
|
||||||
|
const data = readFileSync(configPath, 'utf-8')
|
||||||
|
return JSON.parse(data)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load config:', err)
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(config) {
|
||||||
|
try {
|
||||||
|
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save config:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendWorkspaceStatus() {
|
||||||
|
if (!mainWindow) return
|
||||||
|
const config = loadConfig()
|
||||||
|
mainWindow.webContents.send('workspace:status', {
|
||||||
|
ready: workspaceReady,
|
||||||
|
path: config.workspacePath || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// 根据系统主题设置初始标题栏颜色
|
// 根据系统主题设置初始标题栏颜色
|
||||||
const isDark = nativeTheme.shouldUseDarkColors
|
const isDark = nativeTheme.shouldUseDarkColors
|
||||||
@@ -47,6 +82,11 @@ function createWindow() {
|
|||||||
} else {
|
} else {
|
||||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send workspace status once renderer is ready
|
||||||
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
|
sendWorkspaceStatus()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理渲染进程的主题切换请求
|
// 处理渲染进程的主题切换请求
|
||||||
@@ -54,11 +94,84 @@ ipcMain.handle('theme:set', (_, theme) => {
|
|||||||
if (!mainWindow) return
|
if (!mainWindow) return
|
||||||
|
|
||||||
const colors = theme === 'dark' ? titleBarColors.dark : titleBarColors.light
|
const colors = theme === 'dark' ? titleBarColors.dark : titleBarColors.light
|
||||||
|
try {
|
||||||
mainWindow.setTitleBarOverlay({
|
mainWindow.setTitleBarOverlay({
|
||||||
color: colors.color,
|
color: colors.color,
|
||||||
symbolColor: colors.symbolColor,
|
symbolColor: colors.symbolColor,
|
||||||
height: 36
|
height: 36
|
||||||
})
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// Titlebar overlay may not be enabled on all platforms
|
||||||
|
console.warn('Failed to set titlebar overlay:', err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== Workspace IPC Handlers ====================
|
||||||
|
|
||||||
|
ipcMain.handle('workspace:selectFolder', async () => {
|
||||||
|
if (!mainWindow) return { canceled: true }
|
||||||
|
|
||||||
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
title: '选择笔记存储文件夹',
|
||||||
|
buttonLabel: '选择此文件夹'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return { canceled: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canceled: false, path: result.filePaths[0] }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('workspace:setPath', async (_, workspacePath) => {
|
||||||
|
console.log('[workspace:setPath] Setting workspace to:', workspacePath)
|
||||||
|
try {
|
||||||
|
// Check if this folder already has a Tunji workspace
|
||||||
|
const tunjiDir = join(workspacePath, '.tunji')
|
||||||
|
const isExistingWorkspace = existsSync(tunjiDir)
|
||||||
|
|
||||||
|
// Close existing storage service if any
|
||||||
|
if (storageService) {
|
||||||
|
storageService.flush()
|
||||||
|
storageService.close()
|
||||||
|
storageService = null
|
||||||
|
workspaceReady = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
saveConfig({ workspacePath })
|
||||||
|
console.log('[workspace:setPath] Config saved')
|
||||||
|
|
||||||
|
// Initialize storage service with new path
|
||||||
|
storageService = new StorageService()
|
||||||
|
await storageService.init(workspacePath)
|
||||||
|
workspaceReady = true
|
||||||
|
console.log('[workspace:setPath] StorageService initialized, existing:', isExistingWorkspace)
|
||||||
|
|
||||||
|
// Notify renderer
|
||||||
|
sendWorkspaceStatus()
|
||||||
|
|
||||||
|
return { success: true, path: workspacePath, isExisting: isExistingWorkspace }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[workspace:setPath] Failed:', err)
|
||||||
|
workspaceReady = false
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('workspace:getStatus', () => {
|
||||||
|
const config = loadConfig()
|
||||||
|
return {
|
||||||
|
ready: workspaceReady,
|
||||||
|
path: config.workspacePath || null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('workspace:checkFolder', (_, folderPath) => {
|
||||||
|
const tunjiDir = join(folderPath, '.tunji')
|
||||||
|
const hasWorkspace = existsSync(tunjiDir)
|
||||||
|
return { hasWorkspace }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ==================== Register IPC Handlers ====================
|
// ==================== Register IPC Handlers ====================
|
||||||
@@ -225,15 +338,23 @@ function registerIpcHandlers() {
|
|||||||
// ==================== App Lifecycle ====================
|
// ==================== App Lifecycle ====================
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
// Initialize storage with user data directory
|
// Register IPC handlers first (workspace handlers are already registered above)
|
||||||
const workspacePath = join(app.getPath('documents'), 'Tunji')
|
|
||||||
storageService = new StorageService()
|
|
||||||
await storageService.init(workspacePath)
|
|
||||||
|
|
||||||
// Register IPC handlers
|
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
|
||||||
// Create window
|
// Check if workspace is already configured
|
||||||
|
const config = loadConfig()
|
||||||
|
if (config.workspacePath && existsSync(config.workspacePath)) {
|
||||||
|
try {
|
||||||
|
storageService = new StorageService()
|
||||||
|
await storageService.init(config.workspacePath)
|
||||||
|
workspaceReady = true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to initialize storage:', err)
|
||||||
|
workspaceReady = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create window (will send workspace status on load)
|
||||||
createWindow()
|
createWindow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,4 +37,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
getStats: () => ipcRenderer.invoke('stats:get'),
|
getStats: () => ipcRenderer.invoke('stats:get'),
|
||||||
|
|
||||||
|
// Workspace
|
||||||
|
workspace: {
|
||||||
|
selectFolder: () => ipcRenderer.invoke('workspace:selectFolder'),
|
||||||
|
setPath: (path) => ipcRenderer.invoke('workspace:setPath', path),
|
||||||
|
getStatus: () => ipcRenderer.invoke('workspace:getStatus'),
|
||||||
|
checkFolder: (path) => ipcRenderer.invoke('workspace:checkFolder', path),
|
||||||
|
onStatusChange: (callback) => {
|
||||||
|
ipcRenderer.on('workspace:status', (_, data) => callback(data))
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,41 +1,33 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import SideBar from './components/SideBar.vue'
|
import SideBar from './components/SideBar.vue'
|
||||||
import NotesOverview from './components/NotesOverview.vue'
|
import NotesOverview from './components/NotesOverview.vue'
|
||||||
import EditorPanel from './components/EditorPanel.vue'
|
import EditorPanel from './components/EditorPanel.vue'
|
||||||
import { useTheme } from './composables/useTheme'
|
import WorkspaceSetup from './components/WorkspaceSetup.vue'
|
||||||
import { useNotes } from './composables/useNotes'
|
import { useNotesStore } from './stores/notes'
|
||||||
import { useNotebooks } from './composables/useNotebooks'
|
import { useNotebooksStore } from './stores/notebooks'
|
||||||
import { useTags } from './composables/useTags'
|
import { useTagsStore } from './stores/tags'
|
||||||
|
import { useWorkspaceStore } from './stores/workspace'
|
||||||
|
import { useThemeStore } from './stores/theme'
|
||||||
|
|
||||||
|
// 初始化 stores
|
||||||
|
const notesStore = useNotesStore()
|
||||||
|
const notebooksStore = useNotebooksStore()
|
||||||
|
const tagsStore = useTagsStore()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
// 初始化主题系统
|
// 初始化主题系统
|
||||||
const { theme, resolvedTheme, isDark, toggleTheme } = useTheme()
|
themeStore.initialize()
|
||||||
|
|
||||||
// 数据层
|
// 从 stores 获取响应式数据
|
||||||
const {
|
const allNotes = computed(() => notesStore.notes)
|
||||||
notes: allNotes,
|
const currentNote = computed(() => notesStore.currentNote)
|
||||||
currentNote,
|
const notesLoading = computed(() => notesStore.loading)
|
||||||
loading: notesLoading,
|
const notebooks = computed(() => notebooksStore.notebooks)
|
||||||
loadNotes,
|
const tags = computed(() => tagsStore.tags)
|
||||||
createNote,
|
const workspaceReady = computed(() => workspaceStore.isReady)
|
||||||
getNote,
|
const workspaceLoading = computed(() => workspaceStore.loading)
|
||||||
updateNote,
|
|
||||||
deleteNote,
|
|
||||||
searchNotes,
|
|
||||||
setCurrentNote,
|
|
||||||
clearCurrentNote
|
|
||||||
} = useNotes()
|
|
||||||
|
|
||||||
const {
|
|
||||||
notebooks,
|
|
||||||
loadNotebooks,
|
|
||||||
createNotebook
|
|
||||||
} = useNotebooks()
|
|
||||||
|
|
||||||
const {
|
|
||||||
tags,
|
|
||||||
loadTags
|
|
||||||
} = useTags()
|
|
||||||
|
|
||||||
const currentNav = ref('all')
|
const currentNav = ref('all')
|
||||||
const filterType = ref('all')
|
const filterType = ref('all')
|
||||||
@@ -65,50 +57,87 @@ async function onNavigate(id) {
|
|||||||
currentNav.value = id
|
currentNav.value = id
|
||||||
filterType.value = 'all'
|
filterType.value = 'all'
|
||||||
filterValue.value = null
|
filterValue.value = null
|
||||||
clearCurrentNote()
|
notesStore.clearCurrentNote()
|
||||||
|
|
||||||
if (id === 'trash') {
|
if (id === 'trash') {
|
||||||
await loadNotes({ isTrashed: 1 })
|
await notesStore.loadNotes({ isTrashed: 1 })
|
||||||
} else {
|
} else {
|
||||||
await loadNotes({ isTrashed: 0 })
|
await notesStore.loadNotes({ isTrashed: 0 })
|
||||||
}
|
}
|
||||||
computeOverviewNotes()
|
computeOverviewNotes()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectNote(note) {
|
function onSelectNote(note) {
|
||||||
getNote(note.id)
|
notesStore.getNote(note.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBackToOverview() {
|
function onBackToOverview() {
|
||||||
clearCurrentNote()
|
notesStore.clearCurrentNote()
|
||||||
// Reload notes to reflect any changes
|
// Reload notes to reflect any changes
|
||||||
loadNotes({ isTrashed: filterType.value === 'trash' ? 1 : 0 }).then(() => {
|
notesStore.loadNotes({ isTrashed: filterType.value === 'trash' ? 1 : 0 }).then(() => {
|
||||||
computeOverviewNotes()
|
computeOverviewNotes()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCreateNote() {
|
/**
|
||||||
const notebookId = filterType.value === 'notebook' && filterValue.value
|
* 生成下一个可用的"无标题"序号
|
||||||
|
* 规则:无标题、无标题1、无标题2、无标题3 ...
|
||||||
|
*/
|
||||||
|
function generateUntitledTitle(notebookId) {
|
||||||
|
const prefix = '无标题'
|
||||||
|
const notesInNotebook = notesStore.notes.filter(
|
||||||
|
n => n.notebook_id === notebookId && !n.is_trashed
|
||||||
|
)
|
||||||
|
|
||||||
|
// 收集所有已占用的序号
|
||||||
|
const usedIndices = new Set()
|
||||||
|
for (const note of notesInNotebook) {
|
||||||
|
const title = note.title
|
||||||
|
if (title === prefix) {
|
||||||
|
usedIndices.add(0) // "无标题" 本身对应 index 0
|
||||||
|
} else {
|
||||||
|
const match = title.match(/^无标题(\d+)$/)
|
||||||
|
if (match) {
|
||||||
|
usedIndices.add(parseInt(match[1], 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到最小的未占用序号
|
||||||
|
let index = 0
|
||||||
|
while (usedIndices.has(index)) {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
|
return index === 0 ? prefix : `${prefix}${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateNote(notebookId) {
|
||||||
|
if (!notebookId) {
|
||||||
|
notebookId = filterType.value === 'notebook' && filterValue.value
|
||||||
? filterValue.value.id
|
? filterValue.value.id
|
||||||
: 'default'
|
: 'default'
|
||||||
|
}
|
||||||
|
|
||||||
const note = await createNote({
|
const title = generateUntitledTitle(notebookId)
|
||||||
title: '无标题',
|
|
||||||
|
const note = await notesStore.createNote({
|
||||||
|
title,
|
||||||
content: '',
|
content: '',
|
||||||
notebookId
|
notebookId
|
||||||
})
|
})
|
||||||
|
|
||||||
if (note) {
|
if (note) {
|
||||||
// Auto select the new note
|
// Auto select the new note
|
||||||
getNote(note.id)
|
notesStore.getNote(note.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectNotebook(notebook) {
|
function onSelectNotebook(notebook) {
|
||||||
filterType.value = 'notebook'
|
filterType.value = 'notebook'
|
||||||
filterValue.value = notebook
|
filterValue.value = notebook
|
||||||
clearCurrentNote()
|
notesStore.clearCurrentNote()
|
||||||
loadNotes({ notebookId: notebook.id, isTrashed: 0 }).then(() => {
|
notesStore.loadNotes({ notebookId: notebook.id, isTrashed: 0 }).then(() => {
|
||||||
computeOverviewNotes()
|
computeOverviewNotes()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -116,8 +145,8 @@ function onSelectNotebook(notebook) {
|
|||||||
function onSelectTag(tag) {
|
function onSelectTag(tag) {
|
||||||
filterType.value = 'tag'
|
filterType.value = 'tag'
|
||||||
filterValue.value = tag
|
filterValue.value = tag
|
||||||
clearCurrentNote()
|
notesStore.clearCurrentNote()
|
||||||
loadNotes({ tagId: tag.id, isTrashed: 0 }).then(() => {
|
notesStore.loadNotes({ tagId: tag.id, isTrashed: 0 }).then(() => {
|
||||||
computeOverviewNotes()
|
computeOverviewNotes()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -142,18 +171,55 @@ function getOverviewTitle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize: load data on mount
|
// Initialize: load data on mount
|
||||||
onMounted(async () => {
|
const setupComplete = ref(false)
|
||||||
|
|
||||||
|
async function loadAllData() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadNotes({ isTrashed: 0 }),
|
notesStore.loadNotes({ isTrashed: 0 }),
|
||||||
loadNotebooks(),
|
notebooksStore.loadNotebooks(),
|
||||||
loadTags()
|
tagsStore.loadTags()
|
||||||
])
|
])
|
||||||
computeOverviewNotes()
|
computeOverviewNotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始化 workspace store
|
||||||
|
workspaceStore.initialize()
|
||||||
|
|
||||||
|
// checkStatus is called automatically by workspaceStore on listen
|
||||||
|
// If already ready (e.g. config existed), load data immediately
|
||||||
|
if (workspaceReady.value) {
|
||||||
|
loadAllData()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Watch for workspace becoming ready (either on mount or after setup)
|
||||||
|
watch(workspaceReady, (ready) => {
|
||||||
|
if (ready && !setupComplete.value) {
|
||||||
|
setupComplete.value = true
|
||||||
|
loadAllData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onWorkspaceConfirm(path) {
|
||||||
|
const success = await workspaceStore.initWorkspace(path)
|
||||||
|
if (!success) {
|
||||||
|
// TODO: show error toast
|
||||||
|
console.error('Failed to initialize workspace')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-screen flex flex-col overflow-hidden bg-surface-50">
|
<!-- Workspace Setup (first-time welcome) -->
|
||||||
|
<WorkspaceSetup
|
||||||
|
v-if="!workspaceReady"
|
||||||
|
:loading="workspaceLoading"
|
||||||
|
@confirm="onWorkspaceConfirm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main App -->
|
||||||
|
<div v-else class="h-screen flex flex-col overflow-hidden bg-surface-50">
|
||||||
<!-- Main Layout: Two Columns -->
|
<!-- Main Layout: Two Columns -->
|
||||||
<div class="flex flex-1 overflow-hidden">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ function getTagSeverity(tag) {
|
|||||||
:class="selectedNoteId === note.id ? 'bg-primary-50' : 'hover:bg-surface-50'"
|
:class="selectedNoteId === note.id ? 'bg-primary-50' : 'hover:bg-surface-50'"
|
||||||
@click="selectNote(note)"
|
@click="selectNote(note)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="mb-1">
|
||||||
<span class="text-sm font-semibold text-surface-900 truncate">{{ note.title }}</span>
|
<span class="text-sm font-semibold text-surface-900 line-clamp-2">{{ note.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-surface-500 line-clamp-2 mb-1.5">{{ note.preview }}</p>
|
<p class="text-xs text-surface-500 line-clamp-2 mb-1.5">{{ note.preview }}</p>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -150,10 +150,10 @@ function getPreview(note) {
|
|||||||
<FileText :size="18" class="text-primary-400 shrink-0" />
|
<FileText :size="18" class="text-primary-400 shrink-0" />
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-sm font-semibold text-surface-900 truncate group-hover:text-primary-600 transition-colors">
|
<h3 class="text-sm font-semibold text-surface-900 line-clamp-2 group-hover:text-primary-600 transition-colors">
|
||||||
{{ note.title || '无标题' }}
|
{{ note.title || '无标题' }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs text-surface-500 truncate mt-0.5">{{ getPreview(note) }}</p>
|
<p class="text-xs text-surface-500 line-clamp-2 mt-0.5">{{ getPreview(note) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
|||||||
@@ -1,21 +1,38 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import {ref, computed, onMounted, nextTick} from 'vue'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import InputText from 'primevue/inputtext'
|
import InputText from 'primevue/inputtext'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import Menu from 'primevue/menu'
|
||||||
import IconField from 'primevue/iconfield'
|
import IconField from 'primevue/iconfield'
|
||||||
import InputIcon from 'primevue/inputicon'
|
import InputIcon from 'primevue/inputicon'
|
||||||
import ScrollPanel from 'primevue/scrollpanel'
|
import ScrollPanel from 'primevue/scrollpanel'
|
||||||
|
import Tree from 'primevue/tree'
|
||||||
import {
|
import {
|
||||||
Search, Plus, File, Star, Trash2,
|
Search, Plus, File, Star, Trash2,
|
||||||
Settings, Sun, Moon, Monitor,
|
Settings, Sun, Moon, Monitor,
|
||||||
BookOpen, Tag, ChevronDown, ChevronRight, Hash
|
BookOpen, Tag, ChevronDown, ChevronRight, Hash,
|
||||||
|
MoreHorizontal
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import {useThemeStore} from '../stores/theme'
|
||||||
import { useNotes } from '../composables/useNotes'
|
import {useNotesStore} from '../stores/notes'
|
||||||
|
import {useNotebooksStore} from '../stores/notebooks'
|
||||||
|
import {useNotebookTree} from '../composables/useNotebookTree'
|
||||||
import ButtonIcon from "./ButtonIcon.vue";
|
import ButtonIcon from "./ButtonIcon.vue";
|
||||||
|
|
||||||
const { theme, toggleTheme } = useTheme()
|
const themeStore = useThemeStore()
|
||||||
const { searchNotes } = useNotes()
|
const notesStore = useNotesStore()
|
||||||
|
const notebooksStore = useNotebooksStore()
|
||||||
|
const {notebookTree, flatNotebooks, expandedKeys, loadNotebooks, loadNotes} = useNotebookTree()
|
||||||
|
|
||||||
|
const theme = computed(() => themeStore.theme)
|
||||||
|
const toggleTheme = themeStore.toggleTheme
|
||||||
|
|
||||||
|
const newNotebookParentName = computed(() => {
|
||||||
|
if (!newNotebookParentId.value) return ''
|
||||||
|
const nb = notebooksStore.notebooks.find(n => n.id === newNotebookParentId.value)
|
||||||
|
return nb ? nb.name : ''
|
||||||
|
})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
notebooks: {type: Array, default: () => []},
|
notebooks: {type: Array, default: () => []},
|
||||||
@@ -24,6 +41,15 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['navigate', 'create-note', 'select-notebook', 'select-tag', 'select-note'])
|
const emit = defineEmits(['navigate', 'create-note', 'select-notebook', 'select-tag', 'select-note'])
|
||||||
|
|
||||||
|
// Selected tree node
|
||||||
|
const selectedTreeKey = ref(null)
|
||||||
|
|
||||||
|
// Load notebooks and notes on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadNotebooks()
|
||||||
|
await loadNotes()
|
||||||
|
})
|
||||||
|
|
||||||
// Collapse state
|
// Collapse state
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
|
|
||||||
@@ -58,7 +84,7 @@ async function onSearchInput() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
searchTimer = setTimeout(async () => {
|
searchTimer = setTimeout(async () => {
|
||||||
searchResults.value = await searchNotes(q)
|
searchResults.value = await notesStore.searchNotes(q)
|
||||||
}, 200)
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,13 +111,99 @@ function selectNotebook(notebook) {
|
|||||||
emit('select-notebook', notebook)
|
emit('select-notebook', notebook)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Tree node selection
|
||||||
|
* PrimeVue v4 passes node directly (not wrapped in event object)
|
||||||
|
*/
|
||||||
|
function onTreeSelect(node) {
|
||||||
|
if (node.data.type === 'notebook') {
|
||||||
|
selectedTreeKey.value = node.key
|
||||||
|
activeNav.value = `notebook-${node.data.id}`
|
||||||
|
emit('select-notebook', node.data)
|
||||||
|
} else if (node.data.type === 'note') {
|
||||||
|
selectedTreeKey.value = node.key
|
||||||
|
activeNav.value = `note-${node.data.id}`
|
||||||
|
emit('select-note', node.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function selectTag(tag) {
|
function selectTag(tag) {
|
||||||
activeNav.value = `tag-${tag.id}`
|
activeNav.value = `tag-${tag.id}`
|
||||||
emit('select-tag', tag)
|
emit('select-tag', tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createNote() {
|
function createNote(notebookId) {
|
||||||
emit('create-note')
|
closeMenu()
|
||||||
|
emit('create-note', notebookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notebook actions menu (PrimeVue Menu)
|
||||||
|
const notebookMenuRef = ref(null)
|
||||||
|
const activeMenuNotebookId = ref(null)
|
||||||
|
|
||||||
|
const notebookMenuItems = computed(() => [
|
||||||
|
{
|
||||||
|
label: '新建子笔记本',
|
||||||
|
icon: 'pi pi-folder',
|
||||||
|
command: () => startNewNotebook(activeMenuNotebookId.value)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '新建笔记',
|
||||||
|
icon: 'pi pi-file',
|
||||||
|
command: () => {
|
||||||
|
closeMenu();
|
||||||
|
emit('create-note', activeMenuNotebookId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
function toggleNotebookMenu(notebookId, event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
activeMenuNotebookId.value = notebookId
|
||||||
|
notebookMenuRef.value.toggle(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
activeMenuNotebookId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// New notebook creation (supports parent_id for sub-notebooks)
|
||||||
|
const showNewNotebookDialog = ref(false)
|
||||||
|
const newNotebookName = ref('')
|
||||||
|
const newNotebookInputRef = ref(null)
|
||||||
|
const newNotebookParentId = ref(null)
|
||||||
|
|
||||||
|
function startNewNotebook(parentId = null) {
|
||||||
|
newNotebookName.value = ''
|
||||||
|
newNotebookParentId.value = parentId
|
||||||
|
showNewNotebookDialog.value = true
|
||||||
|
closeMenu()
|
||||||
|
nextTick(() => {
|
||||||
|
newNotebookInputRef.value?.$el?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmNewNotebook() {
|
||||||
|
const name = newNotebookName.value.trim()
|
||||||
|
if (!name) return
|
||||||
|
const data = {name}
|
||||||
|
if (newNotebookParentId.value) {
|
||||||
|
data.parentId = newNotebookParentId.value
|
||||||
|
}
|
||||||
|
const notebook = await notebooksStore.createNotebook(data)
|
||||||
|
if (notebook) {
|
||||||
|
await loadNotebooks()
|
||||||
|
await loadNotes()
|
||||||
|
}
|
||||||
|
showNewNotebookDialog.value = false
|
||||||
|
newNotebookName.value = ''
|
||||||
|
newNotebookParentId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelNewNotebook() {
|
||||||
|
showNewNotebookDialog.value = false
|
||||||
|
newNotebookName.value = ''
|
||||||
|
newNotebookParentId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectSearchNote(note) {
|
function selectSearchNote(note) {
|
||||||
@@ -109,7 +221,8 @@ function getNotebookName(note) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar-container flex flex-col h-full bg-surface-50 border-r border-app-border transition-all duration-300"
|
<div
|
||||||
|
class="sidebar-container flex flex-col h-full bg-surface-50 border-r border-app-border transition-all duration-300"
|
||||||
:class="collapsed ? 'w-14' : 'w-64'">
|
:class="collapsed ? 'w-14' : 'w-64'">
|
||||||
|
|
||||||
<!-- Top Bar: Toggle + (Search & Add when expanded) -->
|
<!-- Top Bar: Toggle + (Search & Add when expanded) -->
|
||||||
@@ -150,7 +263,8 @@ function getNotebookName(note) {
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="w-full flex justify-center">
|
<div class="w-full flex justify-center">
|
||||||
<Button severity="primary" size="small" @click="createNote" v-tooltip.right="'新建笔记'" class="btn-filled w-8 h-8">
|
<Button severity="primary" size="small" @click="createNote" v-tooltip.right="'新建笔记'"
|
||||||
|
class="btn-filled w-8 h-8">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Plus :size="20"/>
|
<Plus :size="20"/>
|
||||||
</template>
|
</template>
|
||||||
@@ -179,7 +293,7 @@ function getNotebookName(note) {
|
|||||||
|
|
||||||
<!-- Notebooks (collapsed) -->
|
<!-- Notebooks (collapsed) -->
|
||||||
<div
|
<div
|
||||||
v-for="nb in notebooks"
|
v-for="nb in flatNotebooks.filter(nb => !nb.parent_id)"
|
||||||
:key="nb.id"
|
:key="nb.id"
|
||||||
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
|
class="w-10 h-10 flex items-center justify-center rounded-lg cursor-pointer transition-colors"
|
||||||
:class="activeNav === `notebook-${nb.id}` ? 'bg-emerald-50 text-emerald-600' : 'text-surface-500 hover:bg-surface-100'"
|
:class="activeNav === `notebook-${nb.id}` ? 'bg-emerald-50 text-emerald-600' : 'text-surface-500 hover:bg-surface-100'"
|
||||||
@@ -245,8 +359,9 @@ function getNotebookName(note) {
|
|||||||
|
|
||||||
<!-- Notebooks Section -->
|
<!-- Notebooks Section -->
|
||||||
<div class="py-1">
|
<div class="py-1">
|
||||||
|
<div class="px-4 py-1.5 flex items-center justify-between">
|
||||||
<div
|
<div
|
||||||
class="px-4 py-1.5 flex items-center justify-between cursor-pointer hover:bg-surface-100 transition-colors"
|
class="flex items-center gap-1 cursor-pointer hover:bg-surface-100 transition-colors rounded px-1 py-0.5 -ml-1"
|
||||||
@click="toggleSection('notebooks')"
|
@click="toggleSection('notebooks')"
|
||||||
>
|
>
|
||||||
<span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">笔记本</span>
|
<span class="text-xs font-semibold text-surface-400 uppercase tracking-wider">笔记本</span>
|
||||||
@@ -261,20 +376,47 @@ function getNotebookName(note) {
|
|||||||
class="text-surface-400 transition-transform"
|
class="text-surface-400 transition-transform"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
class="w-6 h-6 flex items-center justify-center rounded hover:bg-surface-200 text-surface-400 hover:text-surface-600 transition-colors"
|
||||||
|
@click.stop="startNewNotebook"
|
||||||
|
title="新建笔记本"
|
||||||
|
>
|
||||||
|
<Plus :size="14"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<Transition name="collapse">
|
<Transition name="collapse">
|
||||||
<div v-show="expandedSections.notebooks">
|
<div v-show="expandedSections.notebooks">
|
||||||
<div
|
<Tree
|
||||||
v-for="nb in notebooks"
|
:value="notebookTree"
|
||||||
:key="nb.id"
|
:expandedKeys="expandedKeys"
|
||||||
class="flex items-center gap-3 px-4 py-2 mx-2 cursor-pointer transition-colors rounded-lg group"
|
selectionMode="single"
|
||||||
:class="activeNav === `notebook-${nb.id}`
|
:selectionKeys="selectedTreeKey ? { [selectedTreeKey]: true } : {}"
|
||||||
? 'bg-emerald-50 text-emerald-700 font-medium shadow-sm'
|
@node-select="onTreeSelect"
|
||||||
: 'text-surface-600 hover:bg-surface-100'"
|
class="w-full notebook-tree"
|
||||||
@click="selectNotebook(nb)"
|
:pt="{
|
||||||
|
root: { class: 'border-none bg-transparent w-full' },
|
||||||
|
node: { class: 'py-0.5', style: 'width: 100%' },
|
||||||
|
nodeContent: {
|
||||||
|
class: ({ instance }) => [
|
||||||
|
'rounded-lg transition-colors min-w-0',
|
||||||
|
instance.selected
|
||||||
|
? 'bg-primary-50 text-primary-700 font-medium dark:bg-primary-900/30 dark:text-primary-300'
|
||||||
|
: 'hover:bg-surface-100'
|
||||||
|
],
|
||||||
|
style: 'width: 100%'
|
||||||
|
},
|
||||||
|
nodeLabel: { class: 'text-sm truncate' },
|
||||||
|
toggler: { class: 'w-6 h-6 shrink-0' }
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<BookOpen :size="18" />
|
<template #default="slotProps">
|
||||||
<span class="text-sm flex-1">{{ nb.name }}</span>
|
<div class="relative flex items-center justify-between w-full gap-1 py-1 px-1 min-w-0 group/node pr-8">
|
||||||
|
<span class="text-sm truncate flex-1">{{ slotProps.node.label }}1</span>
|
||||||
|
<span>1</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tree>
|
||||||
|
<Menu ref="notebookMenuRef" :model="notebookMenuItems" :popup="true"/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,7 +452,9 @@ function getNotebookName(note) {
|
|||||||
>
|
>
|
||||||
<div class="w-3 h-3 rounded-full flex-shrink-0 bg-amber-400"/>
|
<div class="w-3 h-3 rounded-full flex-shrink-0 bg-amber-400"/>
|
||||||
<span class="text-sm flex-1">{{ tag.name }}</span>
|
<span class="text-sm flex-1">{{ tag.name }}</span>
|
||||||
<span class="text-xs text-surface-400 opacity-0 group-hover:opacity-100 transition-opacity">{{ tag.note_count || 0 }}</span>
|
<span class="text-xs text-surface-400 opacity-0 group-hover:opacity-100 transition-opacity">{{
|
||||||
|
tag.note_count || 0
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -322,14 +466,17 @@ function getNotebookName(note) {
|
|||||||
<div class="p-3 border-t border-app-border">
|
<div class="p-3 border-t border-app-border">
|
||||||
<template v-if="collapsed">
|
<template v-if="collapsed">
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<Button text severity="secondary" @click="toggleTheme" v-tooltip.right="theme === 'light' ? '切换暗色模式' : theme === 'dark' ? '跟随系统' : '切换亮色模式'" class="btn-icon w-10 h-10">
|
<Button text severity="secondary" @click="toggleTheme"
|
||||||
|
v-tooltip.right="theme === 'light' ? '切换暗色模式' : theme === 'dark' ? '跟随系统' : '切换亮色模式'"
|
||||||
|
class="btn-icon w-10 h-10">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Sun v-if="theme === 'light'" :size="20"/>
|
<Sun v-if="theme === 'light'" :size="20"/>
|
||||||
<Moon v-else-if="theme === 'dark'" :size="20"/>
|
<Moon v-else-if="theme === 'dark'" :size="20"/>
|
||||||
<Monitor v-else :size="20"/>
|
<Monitor v-else :size="20"/>
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
<Button text severity="secondary" @click="handleNav('settings')" v-tooltip.right="'设置'" class="btn-icon w-10 h-10">
|
<Button text severity="secondary" @click="handleNav('settings')" v-tooltip.right="'设置'"
|
||||||
|
class="btn-icon w-10 h-10">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Settings :size="20"/>
|
<Settings :size="20"/>
|
||||||
</template>
|
</template>
|
||||||
@@ -346,7 +493,8 @@ function getNotebookName(note) {
|
|||||||
</template>
|
</template>
|
||||||
<span class="text-sm">{{ theme === 'light' ? '浅色' : theme === 'dark' ? '深色' : '系统' }}</span>
|
<span class="text-sm">{{ theme === 'light' ? '浅色' : theme === 'dark' ? '深色' : '系统' }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button text severity="secondary" @click="handleNav('settings')" class="btn-icon h-10 px-3 flex items-center gap-2 flex-1">
|
<Button text severity="secondary" @click="handleNav('settings')"
|
||||||
|
class="btn-icon h-10 px-3 flex items-center gap-2 flex-1">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Settings :size="18"/>
|
<Settings :size="18"/>
|
||||||
</template>
|
</template>
|
||||||
@@ -355,6 +503,37 @@ function getNotebookName(note) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- New Notebook Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="showNewNotebookDialog"
|
||||||
|
:header="newNotebookParentId ? '新建子笔记本' : '新建笔记本'"
|
||||||
|
:modal="true"
|
||||||
|
:closable="true"
|
||||||
|
:draggable="false"
|
||||||
|
class="w-80"
|
||||||
|
@hide="cancelNewNotebook"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div v-if="newNotebookParentId" class="text-sm text-surface-400">
|
||||||
|
父笔记本:<span class="text-surface-600">{{ newNotebookParentName }}</span>
|
||||||
|
</div>
|
||||||
|
<label for="new-notebook-name" class="text-sm text-surface-600">笔记本名称</label>
|
||||||
|
<InputText
|
||||||
|
id="new-notebook-name"
|
||||||
|
ref="newNotebookInputRef"
|
||||||
|
v-model="newNotebookName"
|
||||||
|
placeholder="请输入笔记本名称"
|
||||||
|
class="w-full"
|
||||||
|
autofocus
|
||||||
|
@keyup.enter="confirmNewNotebook"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="取消" severity="secondary" text @click="cancelNewNotebook"/>
|
||||||
|
<Button label="创建" @click="confirmNewNotebook" :disabled="!newNotebookName.trim()"/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -376,4 +555,45 @@ function getNotebookName(note) {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom styles for notebook tree */
|
||||||
|
.notebook-tree :deep(.p-tree) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-tree :deep(.p-tree-node) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-tree :deep(.p-tree-node-content) {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-tree :deep(.p-tree-node-content:hover) {
|
||||||
|
background-color: var(--surface-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-tree :deep(.p-tree-node-selected) {
|
||||||
|
/* Handled by pt nodeContent dynamic class */
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-tree :deep(.p-tree-toggler) {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-tree :deep(.p-tree-node-icon) {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notebook-tree :deep(.p-tree-node-label) {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
115
src/renderer/src/components/WorkspaceSetup.vue
Normal file
115
src/renderer/src/components/WorkspaceSetup.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup>
|
||||||
|
import {ref} from 'vue'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import {FolderOpen, FolderInput, ArrowRight, CheckCircle, BookOpen, AlertTriangle} from '@lucide/vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
loading: {type: Boolean, default: false}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['confirm'])
|
||||||
|
|
||||||
|
const selectedPath = ref(null)
|
||||||
|
const folderHasWorkspace = ref(false)
|
||||||
|
|
||||||
|
async function onSelectFolder() {
|
||||||
|
const result = await window.electronAPI.workspace.selectFolder()
|
||||||
|
if (result && !result.canceled && result.path) {
|
||||||
|
selectedPath.value = result.path
|
||||||
|
// Check if folder already has a Tunji workspace
|
||||||
|
try {
|
||||||
|
const check = await window.electronAPI.workspace.checkFolder(result.path)
|
||||||
|
folderHasWorkspace.value = check?.hasWorkspace || false
|
||||||
|
} catch {
|
||||||
|
folderHasWorkspace.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (selectedPath.value && !folderHasWorkspace.value) {
|
||||||
|
emit('confirm', selectedPath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-app">
|
||||||
|
<div class="flex flex-col items-center max-w-lg px-8">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="mb-8 flex items-center gap-3">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-primary-500 flex items-center justify-center shadow-lg">
|
||||||
|
<BookOpen :size="28" class="text-white"/>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl font-bold text-app-text tracking-tight">Tunji</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Welcome text -->
|
||||||
|
<h2 class="text-xl font-semibold text-app-text mb-2">欢迎使用 Tunji</h2>
|
||||||
|
<p class="text-sm text-app-text-secondary text-center mb-8 leading-relaxed">
|
||||||
|
请选择一个文件夹作为您的笔记存储库。<br/>
|
||||||
|
您的笔记将以 Markdown 文件的形式保存在该文件夹中。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Folder selection area -->
|
||||||
|
<div class="w-full">
|
||||||
|
<!-- No folder selected yet — single button -->
|
||||||
|
<div v-if="!selectedPath" class="flex it0ems-center justify-center">
|
||||||
|
<Button icon="pi pi-folder" label="选择存储文件夹" @click="onSelectFolder" :loading="loading"></Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Folder selected — path card + action buttons in one row -->
|
||||||
|
<div v-else class="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary-50 border border-primary-200 dark:bg-primary-500/10 dark:border-primary-500/20">
|
||||||
|
<CheckCircle :size="18" class="text-primary-500 flex-shrink-0"/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-xs text-primary-600 dark:text-primary-400 mb-0.5">已选择文件夹</div>
|
||||||
|
<div class="text-sm text-app-text font-medium truncate">{{ selectedPath }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Warning: folder already has a workspace -->
|
||||||
|
<div v-if="folderHasWorkspace"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-xl bg-yellow-50 border border-yellow-200 dark:bg-yellow-500/10 dark:border-yellow-500/20">
|
||||||
|
<AlertTriangle :size="18" class="text-yellow-600 dark:text-yellow-400 flex-shrink-0"/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-xs text-yellow-700 dark:text-yellow-300 mb-0.5">该文件夹已存在工作区</div>
|
||||||
|
<div class="text-sm text-yellow-800 dark:text-yellow-200">请选择一个空文件夹来创建新的笔记库</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<Button
|
||||||
|
severity="secondary"
|
||||||
|
size="large"
|
||||||
|
outlined
|
||||||
|
icon="pi pi-folder"
|
||||||
|
class="px-6 py-3"
|
||||||
|
label="重新选择"
|
||||||
|
@click="onSelectFolder"
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
severity="primary"
|
||||||
|
size="large"
|
||||||
|
icon="pi pi-arrow-right"
|
||||||
|
class="btn-filled px-8 py-3 text-base"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="folderHasWorkspace"
|
||||||
|
@click="onConfirm"
|
||||||
|
icon-pos="right"
|
||||||
|
label="开始使用"
|
||||||
|
>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hint -->
|
||||||
|
<p class="mt-8 text-xs text-app-text-muted">
|
||||||
|
后续可在设置中更改存储位置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
import Aura from '@primeuix/themes/aura'
|
import Aura from '@primeuix/themes/aura'
|
||||||
import Tooltip from 'primevue/tooltip'
|
import Tooltip from 'primevue/tooltip'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import './assets/styles/main.css'
|
import './assets/styles/main.css'
|
||||||
import 'primeicons/primeicons.css'
|
import 'primeicons/primeicons.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
theme: {
|
theme: {
|
||||||
@@ -16,6 +19,7 @@ app.use(PrimeVue, {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
app.directive('tooltip', Tooltip)
|
app.directive('tooltip', Tooltip)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
Reference in New Issue
Block a user