feat: next phase of basic work
This commit is contained in:
440
.github/copilot-cli-instructions.md
vendored
440
.github/copilot-cli-instructions.md
vendored
@@ -1,440 +0,0 @@
|
|||||||
# GitHub Copilot CLI Instructions for bDS
|
|
||||||
|
|
||||||
Quick reference for using GitHub Copilot CLI (`gh copilot`) with this Electron + TypeScript + SQLite project.
|
|
||||||
|
|
||||||
## Project Context for CLI Queries
|
|
||||||
|
|
||||||
When asking Copilot CLI for help, provide this context for better responses:
|
|
||||||
|
|
||||||
```
|
|
||||||
This is an Electron app using TypeScript, React, Drizzle ORM, and @libsql/client for SQLite.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common CLI Patterns
|
|
||||||
|
|
||||||
### Build & Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Ask how to run in development
|
|
||||||
gh copilot suggest "how to run electron app in dev mode with vite HMR"
|
|
||||||
|
|
||||||
# Ask about TypeScript compilation
|
|
||||||
gh copilot suggest "compile typescript for electron main process with tsconfig"
|
|
||||||
|
|
||||||
# Build commands for this project
|
|
||||||
npm run build:main # TypeScript -> JavaScript for main process
|
|
||||||
npm run build:renderer # Vite build for React renderer
|
|
||||||
npm run build # Both
|
|
||||||
npm start # Launch Electron
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Operations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate Drizzle migrations
|
|
||||||
gh copilot suggest "generate drizzle orm migration for sqlite"
|
|
||||||
|
|
||||||
# Query patterns
|
|
||||||
gh copilot suggest "drizzle orm query to select with where clause typescript"
|
|
||||||
|
|
||||||
# Turso sync
|
|
||||||
gh copilot suggest "sync local sqlite with turso remote database libsql"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Electron Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# IPC patterns
|
|
||||||
gh copilot suggest "electron ipc invoke handler with typescript types"
|
|
||||||
|
|
||||||
# Window management
|
|
||||||
gh copilot suggest "electron create browser window with preload script"
|
|
||||||
|
|
||||||
# Menu creation
|
|
||||||
gh copilot suggest "electron application menu with keyboard shortcuts"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TypeScript Quick Fixes
|
|
||||||
|
|
||||||
### Type Errors
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# When you see "Type X is not assignable to type Y"
|
|
||||||
gh copilot explain "typescript error Type X is not assignable to type Y"
|
|
||||||
|
|
||||||
# For generic type issues
|
|
||||||
gh copilot suggest "typescript generic function that returns same type as input"
|
|
||||||
|
|
||||||
# Promise/async issues
|
|
||||||
gh copilot suggest "typescript async function return type Promise"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Patterns
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Discriminated unions
|
|
||||||
gh copilot suggest "typescript discriminated union for status types"
|
|
||||||
|
|
||||||
# Type guards
|
|
||||||
gh copilot suggest "typescript type guard function to narrow union type"
|
|
||||||
|
|
||||||
# Utility types
|
|
||||||
gh copilot suggest "typescript Pick Omit Partial for interface modification"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SQLite & Drizzle Queries
|
|
||||||
|
|
||||||
### Schema Definition
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create new table
|
|
||||||
gh copilot suggest "drizzle orm sqlite table schema with text integer columns"
|
|
||||||
|
|
||||||
# Add index
|
|
||||||
gh copilot suggest "drizzle orm create index on sqlite table"
|
|
||||||
|
|
||||||
# Relations
|
|
||||||
gh copilot suggest "drizzle orm one to many relation sqlite"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Query Building
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Select with conditions
|
|
||||||
gh copilot suggest "drizzle orm select where equals and order by"
|
|
||||||
|
|
||||||
# Insert with returning
|
|
||||||
gh copilot suggest "drizzle orm insert returning inserted row"
|
|
||||||
|
|
||||||
# Update with conditions
|
|
||||||
gh copilot suggest "drizzle orm update set where condition"
|
|
||||||
|
|
||||||
# Transaction
|
|
||||||
gh copilot suggest "drizzle orm transaction with multiple operations"
|
|
||||||
```
|
|
||||||
|
|
||||||
### LibSQL Specifics
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local file database
|
|
||||||
gh copilot suggest "libsql client connect to local sqlite file"
|
|
||||||
|
|
||||||
# Remote Turso connection
|
|
||||||
gh copilot suggest "libsql client connect turso with auth token"
|
|
||||||
|
|
||||||
# Execute multiple statements
|
|
||||||
gh copilot suggest "libsql execute multiple sql statements batch"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sync Implementation
|
|
||||||
|
|
||||||
### Conflict Detection
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh copilot suggest "detect sync conflicts using checksums typescript"
|
|
||||||
gh copilot suggest "last-write-wins conflict resolution pattern"
|
|
||||||
gh copilot suggest "vector clock for distributed sync"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Offline Queue
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh copilot suggest "queue offline operations for later sync typescript"
|
|
||||||
gh copilot suggest "exponential backoff retry logic async"
|
|
||||||
gh copilot suggest "detect online offline status in electron"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sync Status
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh copilot suggest "track sync status pending synced error per record"
|
|
||||||
gh copilot suggest "sync log table for audit trail"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## React & Zustand
|
|
||||||
|
|
||||||
### Store Patterns
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh copilot suggest "zustand store with typescript typed actions"
|
|
||||||
gh copilot suggest "zustand selector to prevent unnecessary rerenders"
|
|
||||||
gh copilot suggest "zustand persist middleware for local storage"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Patterns
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh copilot suggest "react component with zustand store hook"
|
|
||||||
gh copilot suggest "react useEffect cleanup for ipc listener"
|
|
||||||
gh copilot suggest "react context provider with typescript"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Electron Security
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Secure IPC
|
|
||||||
gh copilot suggest "electron context bridge expose api safely"
|
|
||||||
|
|
||||||
# Input validation
|
|
||||||
gh copilot suggest "validate ipc handler input zod typescript"
|
|
||||||
|
|
||||||
# Content Security Policy
|
|
||||||
gh copilot suggest "electron content security policy meta tag"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Operations
|
|
||||||
|
|
||||||
### Markdown with Frontmatter
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh copilot suggest "read markdown file with yaml frontmatter gray-matter"
|
|
||||||
gh copilot suggest "write markdown file with yaml frontmatter node"
|
|
||||||
gh copilot suggest "parse yaml frontmatter to typescript type"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Media Files
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh copilot suggest "copy file to destination directory nodejs"
|
|
||||||
gh copilot suggest "get image dimensions from file nodejs"
|
|
||||||
gh copilot suggest "calculate file checksum md5 nodejs"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sidecar Pattern
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh copilot suggest "json sidecar metadata file for binary asset"
|
|
||||||
gh copilot suggest "read write json file atomic operation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Custom errors
|
|
||||||
gh copilot suggest "typescript custom error class with error code"
|
|
||||||
|
|
||||||
# Result types
|
|
||||||
gh copilot suggest "typescript result type success or error pattern"
|
|
||||||
|
|
||||||
# Async error handling
|
|
||||||
gh copilot suggest "try catch async await with typed error"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test-Driven Development (TDD)
|
|
||||||
|
|
||||||
**This project requires TDD. Write tests BEFORE implementation.**
|
|
||||||
|
|
||||||
### TDD Workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Step 1: Write failing test first
|
|
||||||
gh copilot suggest "vitest test for function that does X should return Y"
|
|
||||||
|
|
||||||
# Step 2: Make test pass with minimal code
|
|
||||||
gh copilot suggest "minimal implementation to make vitest test pass"
|
|
||||||
|
|
||||||
# Step 3: Refactor while keeping tests green
|
|
||||||
gh copilot suggest "refactor function for readability typescript"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test # Run all tests once
|
|
||||||
npm run test:watch # Watch mode (re-run on changes)
|
|
||||||
npm run test:coverage # Generate coverage report
|
|
||||||
npm run test:ui # Open Vitest UI in browser
|
|
||||||
```
|
|
||||||
|
|
||||||
### Writing Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create unit test
|
|
||||||
gh copilot suggest "vitest unit test for typescript async function"
|
|
||||||
|
|
||||||
# Test with mocks
|
|
||||||
gh copilot suggest "vitest mock module fs promises typescript"
|
|
||||||
|
|
||||||
# Test event emitters
|
|
||||||
gh copilot suggest "vitest test eventemitter emit and listen"
|
|
||||||
|
|
||||||
# Test error cases
|
|
||||||
gh copilot suggest "vitest test async function throws error"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mock Patterns
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Mock database
|
|
||||||
gh copilot suggest "vitest mock drizzle orm database connection"
|
|
||||||
|
|
||||||
# Mock file system
|
|
||||||
gh copilot suggest "vitest mock fs promises readFile writeFile"
|
|
||||||
|
|
||||||
# Mock Electron
|
|
||||||
gh copilot suggest "vitest mock electron app ipcMain"
|
|
||||||
|
|
||||||
# Factory functions
|
|
||||||
gh copilot suggest "typescript factory function create test data with overrides"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Coverage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check coverage
|
|
||||||
gh copilot suggest "vitest coverage v8 configuration"
|
|
||||||
|
|
||||||
# Coverage thresholds
|
|
||||||
gh copilot suggest "vitest minimum coverage threshold configuration"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Specific Scenarios
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Task management tests
|
|
||||||
gh copilot suggest "vitest test async task queue with progress callback"
|
|
||||||
|
|
||||||
# Sync engine tests
|
|
||||||
gh copilot suggest "vitest test sync conflict detection by checksum"
|
|
||||||
|
|
||||||
# Post engine tests
|
|
||||||
gh copilot suggest "vitest test markdown frontmatter parse and serialize"
|
|
||||||
|
|
||||||
# Media tests
|
|
||||||
gh copilot suggest "vitest test file checksum calculation md5"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git Operations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Commits
|
|
||||||
gh copilot suggest "git commit message for feature typescript"
|
|
||||||
|
|
||||||
# Branching
|
|
||||||
gh copilot suggest "git branch naming convention feature bugfix"
|
|
||||||
|
|
||||||
# Stashing
|
|
||||||
gh copilot suggest "git stash changes and apply later"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project-Specific Commands
|
|
||||||
|
|
||||||
### Quick Reference
|
|
||||||
|
|
||||||
| Task | Command |
|
|
||||||
|------|---------|
|
|
||||||
| Install deps | `npm install` |
|
|
||||||
| Dev mode | `npm run dev` (runs Vite + TSC watch) |
|
|
||||||
| Build all | `npm run build` |
|
|
||||||
| Start app | `npm start` |
|
|
||||||
| Run tests | `npm run test` |
|
|
||||||
| Watch tests | `npm run test:watch` |
|
|
||||||
| Coverage | `npm run test:coverage` |
|
|
||||||
| Build main only | `npm run build:main` |
|
|
||||||
| Build renderer only | `npm run build:renderer` |
|
|
||||||
|
|
||||||
### File Locations
|
|
||||||
|
|
||||||
| What | Where |
|
|
||||||
|------|-------|
|
|
||||||
| Electron main | `src/main/` |
|
|
||||||
| React renderer | `src/renderer/` |
|
|
||||||
| Database schema | `src/main/database/schema.ts` |
|
|
||||||
| Engine classes | `src/main/engine/` |
|
|
||||||
| IPC handlers | `src/main/ipc/handlers.ts` |
|
|
||||||
| React components | `src/renderer/components/` |
|
|
||||||
| State store | `src/renderer/store/appStore.ts` |
|
|
||||||
|
|
||||||
### Adding a Feature Checklist
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Schema (if new data)
|
|
||||||
gh copilot suggest "add column to drizzle sqlite table"
|
|
||||||
|
|
||||||
# 2. Engine method
|
|
||||||
gh copilot suggest "engine class method with event emitter typescript"
|
|
||||||
|
|
||||||
# 3. IPC handler
|
|
||||||
gh copilot suggest "electron ipc handle invoke pattern"
|
|
||||||
|
|
||||||
# 4. Preload exposure
|
|
||||||
gh copilot suggest "electron preload contextBridge expose"
|
|
||||||
|
|
||||||
# 5. Store action
|
|
||||||
gh copilot suggest "zustand action to update state"
|
|
||||||
|
|
||||||
# 6. UI component
|
|
||||||
gh copilot suggest "react component call electron api"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Module not found
|
|
||||||
gh copilot explain "cannot find module typescript error electron"
|
|
||||||
|
|
||||||
# IPC not working
|
|
||||||
gh copilot explain "electron ipc invoke not receiving response"
|
|
||||||
|
|
||||||
# Database locked
|
|
||||||
gh copilot explain "sqlite database is locked error"
|
|
||||||
|
|
||||||
# Build fails
|
|
||||||
gh copilot explain "vite build error cannot resolve module"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Debugging
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable source maps
|
|
||||||
gh copilot suggest "typescript source maps for electron debugging"
|
|
||||||
|
|
||||||
# DevTools
|
|
||||||
gh copilot suggest "open chrome devtools in electron app"
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
gh copilot suggest "electron log to file in main process"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Best Practices Summary
|
|
||||||
|
|
||||||
When asking Copilot CLI for code in this project:
|
|
||||||
|
|
||||||
1. **Specify TypeScript** - Always mention "typescript" for typed responses
|
|
||||||
2. **Mention Electron context** - "main process" or "renderer process"
|
|
||||||
3. **Reference Drizzle** - Use "drizzle orm" not "raw sql"
|
|
||||||
4. **Async patterns** - Request "async/await" not callbacks
|
|
||||||
5. **Type safety** - Ask for "typed" or "with types" versions
|
|
||||||
|
|
||||||
Example well-formed query:
|
|
||||||
```bash
|
|
||||||
gh copilot suggest "typescript async function to update drizzle orm record with transaction in electron main process"
|
|
||||||
```
|
|
||||||
@@ -4,7 +4,7 @@ Back in the day I had a tool named Python Desktop Server that was a self-contain
|
|||||||
an integrated webserver and database that allowed management of blog posts in an easy local way and a
|
an integrated webserver and database that allowed management of blog posts in an easy local way and a
|
||||||
sync to a cloud system for syncing data and also rendering the full blog.
|
sync to a cloud system for syncing data and also rendering the full blog.
|
||||||
|
|
||||||
## Vision
|
## Main Vision
|
||||||
|
|
||||||
create a electron app in this folder that uses typescript for all the logic code and sqlite and a proper database framework around it for local storage of data and turso/libsql to sync against a cloud location for having offline work capabilities with syncing. The UI should be aligned with the UI patterns used by vscode. The name of the application is "blogging Desktop Server" and the shortname is bDS. Start with default layout for edit and view menues and things like that. I don't want the app to use raw SQL, I want some proper layer between those and proper wiring where all actual functional code is kept in engine classes and the UI realy just does presentation and reacts to state changes properly, so that long-running processes can properly integrate as async tasks.
|
create a electron app in this folder that uses typescript for all the logic code and sqlite and a proper database framework around it for local storage of data and turso/libsql to sync against a cloud location for having offline work capabilities with syncing. The UI should be aligned with the UI patterns used by vscode. The name of the application is "blogging Desktop Server" and the shortname is bDS. Start with default layout for edit and view menues and things like that. I don't want the app to use raw SQL, I want some proper layer between those and proper wiring where all actual functional code is kept in engine classes and the UI realy just does presentation and reacts to state changes properly, so that long-running processes can properly integrate as async tasks.
|
||||||
|
|
||||||
@@ -23,6 +23,30 @@ Integrate toasts as notification mechanism that will be used whenever anything h
|
|||||||
|
|
||||||
Integrated images in posts should be shown with a lightbox effect als galleries when there are multiple photos, or just as single images with lightbox when there is only one. The wysiwyg editor should support this at least on a basic level.
|
Integrated images in posts should be shown with a lightbox effect als galleries when there are multiple photos, or just as single images with lightbox when there is only one. The wysiwyg editor should support this at least on a basic level.
|
||||||
|
|
||||||
|
## UI and UX specifics
|
||||||
|
|
||||||
|
The UI and UX should be aligned with modern applications like vscode. I want iconbar and left sidebar and the
|
||||||
|
big main area with tabbed views. Open views will be automatically opened again on next start, so the user
|
||||||
|
does not have to reselect everything.
|
||||||
|
|
||||||
|
All sidebar areas and panels can be resized easily and resizings are persisted so they are the same on next
|
||||||
|
application start. UI configuration is persisted on the project level, so when I switch projects, I get the
|
||||||
|
same layout and opened tabs as the last time I worked on that project.
|
||||||
|
|
||||||
|
There are no lengthy synchronous actions in the UI thread, everything is offloaded to the main thread and
|
||||||
|
if it is complex, to async tasks, with proper integration with the UI and notification about state of those
|
||||||
|
asynchronous tasks via toasts. There is a central notification framework used by everything, so we are sure
|
||||||
|
that the user is informed about ongoing activities at all times.
|
||||||
|
|
||||||
|
There is a bottom status bar in the app that for example shows how many async tasks are running at any time,
|
||||||
|
so if we run maybe two importer in parallel, the user will be able to see that there are 2 tasks running
|
||||||
|
and will be abl to click on that to see a small popup that lists all active async tasks.
|
||||||
|
|
||||||
|
All preferences are always local to the selected project and project settings are easily reachable via
|
||||||
|
a gear icon in the bottom of the iconbar. Also login credentials can be managed via a user icon in the bottom
|
||||||
|
of the icon bar directly above the gear icon. This is similar to what vscode does, separating logins and
|
||||||
|
settings.
|
||||||
|
|
||||||
## Organizing
|
## Organizing
|
||||||
|
|
||||||
Blog posts should be organized in the app in the main post view where the sidebar lists posts and the main
|
Blog posts should be organized in the app in the main post view where the sidebar lists posts and the main
|
||||||
@@ -31,21 +55,38 @@ The sidebar for posts must also include a calendar view at the top that allows t
|
|||||||
date ranges (by selecting a whole month of a year for example) and then accordingly filter down the post
|
date ranges (by selecting a whole month of a year for example) and then accordingly filter down the post
|
||||||
list in the sidebar.
|
list in the sidebar.
|
||||||
|
|
||||||
Also there must be a way to filter posts by tags and category to look at subsets of posts efficiently.
|
|
||||||
|
|
||||||
And there must be a way to use markdown links of a specific short-form to reference other posts, so that
|
And there must be a way to use markdown links of a specific short-form to reference other posts, so that
|
||||||
I can link to other places, if needed, and those references should also be part of the information about
|
I can link to other places, if needed, and those references should also be part of the information about
|
||||||
posts. So each post should give a "links to" part in the UI (right sidebar or lower area of left sidebar
|
posts. So each post should give a "links to" part in the UI (right sidebar or lower area of left sidebar
|
||||||
like with vscode?) and a "linked to by" part where incoming links are shown.
|
like with vscode?) and a "linked to by" part where incoming links are shown.
|
||||||
|
|
||||||
|
### default category "article"
|
||||||
|
|
||||||
|
This is for articles that are focused on long text. They will show fully only on the full page, but will
|
||||||
|
be summarized in overview pages.
|
||||||
|
|
||||||
|
### category "picture"
|
||||||
|
|
||||||
|
This is a variant of article with less text-focus but usually a strong focus on attached images. This
|
||||||
|
will show the image in a wide format if it is just one, or a gallery view, if it is multiple images
|
||||||
|
and will only show thumbnails for overviews. This means we will need a library to manage image sizes
|
||||||
|
properly for thumbnails during media storage. those thumbnails must be created automatically.
|
||||||
|
|
||||||
|
### category "aside"
|
||||||
|
|
||||||
|
This is a short-form article that does not need a full-article-page, because it will just be a link and
|
||||||
|
a short comment that is shown after the link. This is meant for link collections and should be rendered
|
||||||
|
in a compact form in the overview pages. More on rendering in the publishing pipelin description.
|
||||||
|
|
||||||
## Migrating
|
## Migrating
|
||||||
|
|
||||||
Prepare a proper mass-data importer that can read wordpress backup files, so the user can bring in old wordpress blogs easily. That importer should run asynchronously and properly communicate progress to the user while it is running in the background. The import has to rebuild all metadata properly, so check if we have all the metadata in our model set up in a similar way as Wordpress handles it, so that we have a seamless integration. Posts in Wordpress backups are html, but should be interpreted and transformed into proper markdown in the import.
|
Prepare a proper mass-data importer that can read wordpress backup files, so the user can bring in old wordpress blogs easily. That importer should run asynchronously and properly communicate progress to the user while it is running in the background. The import has to rebuild all metadata properly, so check if we have all the metadata in our model set up in a similar way as Wordpress handles it, so that we have a seamless integration. Posts in Wordpress backups are html, but should be interpreted and transformed into proper markdown in the import.
|
||||||
|
|
||||||
Additionally we need a way to traverse a full HTML website and deduct post structure from that website
|
Additionally we need another importer to traverse a full website and deduct post structure from that website
|
||||||
and rebuild posts in the database based on such a web traversal. To be able to do that, use copilot SDK
|
and rebuild posts in the database based on such a web traversal. To be able to do that, use copilot SDK
|
||||||
to integrate copilot directly, so that HTML pages can be directly inspected and turned into actual blog
|
to integrate copilot directly, so that HTML pages can be directly inspected and turned into actual blog
|
||||||
posts in proper structure and proper markdown, despite the source being HTML.
|
posts in proper structure and proper markdown, despite the source being HTML. This is a variant of the
|
||||||
|
wordpress importer that directly works on already rendered HTML websites.
|
||||||
|
|
||||||
For this AI support during import to work, the blog application needs to provide post management and media
|
For this AI support during import to work, the blog application needs to provide post management and media
|
||||||
management functionality as proper SDK tools to the copilot instance, so that it will be able to work
|
management functionality as proper SDK tools to the copilot instance, so that it will be able to work
|
||||||
@@ -87,6 +128,12 @@ that are used in the page were changed. This requires a cross-reference table th
|
|||||||
with actual HTML files that are referencing them. This needs to be based on how templates use posts in the
|
with actual HTML files that are referencing them. This needs to be based on how templates use posts in the
|
||||||
export pipeline.
|
export pipeline.
|
||||||
|
|
||||||
|
Essentially the publishing pipeline knows what posts changed since last publishing (maybe a version number
|
||||||
|
that is pulled from a central place, so that any change will raise that version and any post and media
|
||||||
|
that has a higher version number of the last publish run is seen as changed) and will run the relevant templates
|
||||||
|
to recreate all linkd pages of the publishing (single post pages for the post but also many overview pages) and
|
||||||
|
of course each page is only updated once in the publishing run, collecting all changed posts for that.
|
||||||
|
|
||||||
The main driver is a proper blog structure with templates. For this I want proper templates I can manage and
|
The main driver is a proper blog structure with templates. For this I want proper templates I can manage and
|
||||||
edit in the application itself. Template editing should provide proper syntax highlighting, so something like
|
edit in the application itself. Template editing should provide proper syntax highlighting, so something like
|
||||||
monaco is important. Choose a good solid template engine for node-js based tools that is especialy targeted
|
monaco is important. Choose a good solid template engine for node-js based tools that is especialy targeted
|
||||||
@@ -103,7 +150,10 @@ that reference overview pages and structure the menu according to site structure
|
|||||||
to allow users to go to specific months and years of the blog.
|
to allow users to go to specific months and years of the blog.
|
||||||
|
|
||||||
Categories and tags must be able to define a template selection for post templates, so that different types
|
Categories and tags must be able to define a template selection for post templates, so that different types
|
||||||
can be represented differently.
|
can be represented differently. this is especially important for the standard categories "article", "picture"
|
||||||
|
and "aside", as they should come right away with templates for their article page and their use in overviews.
|
||||||
|
Every post can define via category or tag (tag-related templates override category templates but can be
|
||||||
|
overridden via article-specific template selections).
|
||||||
|
|
||||||
There must be way to open a browser tab in the application that then uses the applicaiton itself and does
|
There must be way to open a browser tab in the application that then uses the applicaiton itself and does
|
||||||
dynamic rendering of the content, using the same templates and everything else, so that the user can do
|
dynamic rendering of the content, using the same templates and everything else, so that the user can do
|
||||||
137
package-lock.json
generated
137
package-lock.json
generated
@@ -10,11 +10,15 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.4.0",
|
"@libsql/client": "^0.4.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.29.0",
|
"drizzle-orm": "^0.29.0",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
@@ -2437,6 +2441,29 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@monaco-editor/loader": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"state-local": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@monaco-editor/react": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@monaco-editor/loader": "^1.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"monaco-editor": ">= 0.25.0 < 1",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@neon-rs/load": {
|
"node_modules/@neon-rs/load": {
|
||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@neon-rs/load/-/load-0.0.4.tgz",
|
||||||
@@ -3079,6 +3106,13 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@types/uuid": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "9.0.8",
|
"version": "9.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||||
@@ -4353,6 +4387,23 @@
|
|||||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/concurrently/node_modules/date-fns": {
|
||||||
|
"version": "2.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||||
|
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.21.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.11"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/date-fns"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/conf": {
|
"node_modules/conf": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz",
|
||||||
@@ -4573,8 +4624,8 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"license": "MIT",
|
||||||
"license": "MIT"
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/d": {
|
"node_modules/d": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
@@ -4600,20 +4651,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/date-fns": {
|
||||||
"version": "2.30.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.21.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.11"
|
|
||||||
},
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "github",
|
||||||
"url": "https://opencollective.com/date-fns"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debounce-fn": {
|
"node_modules/debounce-fn": {
|
||||||
@@ -4907,6 +4951,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||||
|
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dot-prop": {
|
"node_modules/dot-prop": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
|
||||||
@@ -6253,6 +6306,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/goober": {
|
||||||
|
"version": "2.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||||
|
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"csstype": "^3.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -7239,6 +7301,18 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "14.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||||
|
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/matcher": {
|
"node_modules/matcher": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||||
@@ -7499,6 +7573,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/monaco-editor": {
|
||||||
|
"version": "0.55.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||||
|
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "3.2.7",
|
||||||
|
"marked": "14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mrmime": {
|
"node_modules/mrmime": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
@@ -8049,6 +8134,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -8057,6 +8143,23 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hot-toast": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"goober": "^2.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
@@ -8611,6 +8714,12 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/state-local": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/std-env": {
|
"node_modules/std-env": {
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
|||||||
@@ -42,11 +42,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@libsql/client": "^0.4.0",
|
"@libsql/client": "^0.4.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.29.0",
|
"drizzle-orm": "^0.29.0",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,13 +93,49 @@ export class DatabaseConnection {
|
|||||||
return this.remoteDb;
|
return this.remoteDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLocalClient(): Client | null {
|
||||||
|
return this.localClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveProject(): Promise<{ id: string; name: string; slug: string } | null> {
|
||||||
|
if (!this.localClient) return null;
|
||||||
|
const result = await this.localClient.execute('SELECT id, name, slug FROM projects WHERE is_active = 1 LIMIT 1');
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
const row = result.rows[0];
|
||||||
|
return {
|
||||||
|
id: row.id as string,
|
||||||
|
name: row.name as string,
|
||||||
|
slug: row.slug as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setActiveProject(projectId: string): Promise<void> {
|
||||||
|
if (!this.localClient) return;
|
||||||
|
await this.localClient.execute('UPDATE projects SET is_active = 0');
|
||||||
|
await this.localClient.execute({
|
||||||
|
sql: 'UPDATE projects SET is_active = 1 WHERE id = ?',
|
||||||
|
args: [projectId],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async runMigrations(): Promise<void> {
|
private async runMigrations(): Promise<void> {
|
||||||
if (!this.localClient) return;
|
if (!this.localClient) return;
|
||||||
|
|
||||||
// Create tables if they don't exist using batch execution
|
// Create tables if they don't exist using batch execution
|
||||||
await this.localClient.executeMultiple(`
|
await this.localClient.executeMultiple(`
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS posts (
|
CREATE TABLE IF NOT EXISTS posts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL DEFAULT 'default',
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
excerpt TEXT,
|
excerpt TEXT,
|
||||||
@@ -118,6 +154,7 @@ export class DatabaseConnection {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS media (
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL DEFAULT 'default',
|
||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
original_name TEXT NOT NULL,
|
original_name TEXT NOT NULL,
|
||||||
mime_type TEXT NOT NULL,
|
mime_type TEXT NOT NULL,
|
||||||
@@ -155,10 +192,36 @@ export class DatabaseConnection {
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_project_id ON posts(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
|
CREATE INDEX IF NOT EXISTS idx_posts_sync_status ON posts(sync_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_project_id ON media(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
|
CREATE INDEX IF NOT EXISTS idx_media_sync_status ON media(sync_status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
|
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Create FTS5 virtual table for full-text search
|
||||||
|
await this.localClient.execute(`
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
|
||||||
|
id UNINDEXED,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
excerpt,
|
||||||
|
tags,
|
||||||
|
categories,
|
||||||
|
content_rowid=rowid
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create default project if none exists
|
||||||
|
const existingProjects = await this.localClient.execute('SELECT COUNT(*) as count FROM projects');
|
||||||
|
if (existingProjects.rows[0] && (existingProjects.rows[0].count as number) === 0) {
|
||||||
|
const now = Date.now();
|
||||||
|
await this.localClient.execute({
|
||||||
|
sql: 'INSERT INTO projects (id, name, slug, description, created_at, updated_at, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
args: ['default', 'Default Project', 'default', 'Your first blog project', now, now, 1],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
// Projects table - stores blog projects/websites
|
||||||
|
export const projects = sqliteTable('projects', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
slug: text('slug').notNull().unique(),
|
||||||
|
description: text('description'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
// Posts table - stores metadata for blog posts
|
// Posts table - stores metadata for blog posts
|
||||||
export const posts = sqliteTable('posts', {
|
export const posts = sqliteTable('posts', {
|
||||||
|
projectId: text('project_id').notNull(),
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
slug: text('slug').notNull().unique(),
|
slug: text('slug').notNull().unique(),
|
||||||
@@ -21,6 +33,7 @@ export const posts = sqliteTable('posts', {
|
|||||||
|
|
||||||
// Media table - stores metadata for images and other media
|
// Media table - stores metadata for images and other media
|
||||||
export const media = sqliteTable('media', {
|
export const media = sqliteTable('media', {
|
||||||
|
projectId: text('project_id').notNull(),
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
filename: text('filename').notNull(),
|
filename: text('filename').notNull(),
|
||||||
originalName: text('original_name').notNull(),
|
originalName: text('original_name').notNull(),
|
||||||
@@ -60,6 +73,8 @@ export const settings = sqliteTable('settings', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Types for TypeScript
|
// Types for TypeScript
|
||||||
|
export type Project = typeof projects.$inferSelect;
|
||||||
|
export type NewProject = typeof projects.$inferInsert;
|
||||||
export type Post = typeof posts.$inferSelect;
|
export type Post = typeof posts.$inferSelect;
|
||||||
export type NewPost = typeof posts.$inferInsert;
|
export type NewPost = typeof posts.$inferInsert;
|
||||||
export type Media = typeof media.$inferSelect;
|
export type Media = typeof media.$inferSelect;
|
||||||
|
|||||||
@@ -38,11 +38,25 @@ export interface MediaMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MediaEngine extends EventEmitter {
|
export class MediaEngine extends EventEmitter {
|
||||||
private mediaDir: string;
|
private currentProjectId: string = 'default';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.mediaDir = getDatabase().getDataPaths().media;
|
}
|
||||||
|
|
||||||
|
private getMediaDir(): string {
|
||||||
|
const { app } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
return path.join(userDataPath, 'projects', this.currentProjectId, 'media');
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjectContext(projectId: string): void {
|
||||||
|
this.currentProjectId = projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjectContext(): string {
|
||||||
|
return this.currentProjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateChecksum(buffer: Buffer): string {
|
private calculateChecksum(buffer: Buffer): string {
|
||||||
@@ -191,7 +205,9 @@ export class MediaEngine extends EventEmitter {
|
|||||||
const originalName = path.basename(sourcePath);
|
const originalName = path.basename(sourcePath);
|
||||||
const ext = path.extname(originalName);
|
const ext = path.extname(originalName);
|
||||||
const filename = `${id}${ext}`;
|
const filename = `${id}${ext}`;
|
||||||
const destPath = path.join(this.mediaDir, filename);
|
const mediaDir = this.getMediaDir();
|
||||||
|
await fs.mkdir(mediaDir, { recursive: true });
|
||||||
|
const destPath = path.join(mediaDir, filename);
|
||||||
|
|
||||||
// Copy file to media directory
|
// Copy file to media directory
|
||||||
await fs.writeFile(destPath, sourceBuffer);
|
await fs.writeFile(destPath, sourceBuffer);
|
||||||
@@ -216,6 +232,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
|
|
||||||
const dbMedia: NewMedia = {
|
const dbMedia: NewMedia = {
|
||||||
id: mediaData.id,
|
id: mediaData.id,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
filename: mediaData.filename,
|
filename: mediaData.filename,
|
||||||
originalName: mediaData.originalName,
|
originalName: mediaData.originalName,
|
||||||
mimeType: mediaData.mimeType,
|
mimeType: mediaData.mimeType,
|
||||||
@@ -346,7 +363,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMediaPath(id: string): string {
|
getMediaPath(id: string): string {
|
||||||
return path.join(this.mediaDir, id);
|
return path.join(this.getMediaDir(), id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async rebuildDatabaseFromFiles(): Promise<void> {
|
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||||
@@ -358,14 +375,20 @@ export class MediaEngine extends EventEmitter {
|
|||||||
|
|
||||||
onProgress(0, 'Scanning media directory...');
|
onProgress(0, 'Scanning media directory...');
|
||||||
|
|
||||||
const files = await fs.readdir(this.mediaDir);
|
const mediaDir = this.getMediaDir();
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
files = await fs.readdir(mediaDir);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(mediaDir, { recursive: true });
|
||||||
|
}
|
||||||
const metaFiles = files.filter(f => f.endsWith('.meta'));
|
const metaFiles = files.filter(f => f.endsWith('.meta'));
|
||||||
|
|
||||||
onProgress(10, `Found ${metaFiles.length} media sidecar files`);
|
onProgress(10, `Found ${metaFiles.length} media sidecar files`);
|
||||||
|
|
||||||
for (let i = 0; i < metaFiles.length; i++) {
|
for (let i = 0; i < metaFiles.length; i++) {
|
||||||
const metaFile = metaFiles[i];
|
const metaFile = metaFiles[i];
|
||||||
const sidecarPath = path.join(this.mediaDir, metaFile);
|
const sidecarPath = path.join(mediaDir, metaFile);
|
||||||
const mediaFilePath = sidecarPath.replace('.meta', '');
|
const mediaFilePath = sidecarPath.replace('.meta', '');
|
||||||
|
|
||||||
onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${metaFile}...`);
|
onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${metaFile}...`);
|
||||||
@@ -399,6 +422,7 @@ export class MediaEngine extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
await db.insert(media).values({
|
await db.insert(media).values({
|
||||||
id: metadata.id,
|
id: metadata.id,
|
||||||
|
projectId: this.currentProjectId,
|
||||||
filename,
|
filename,
|
||||||
originalName: metadata.originalName,
|
originalName: metadata.originalName,
|
||||||
mimeType: metadata.mimeType,
|
mimeType: metadata.mimeType,
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import * as fs from 'fs/promises';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, and, desc, gte, lte, like } from 'drizzle-orm';
|
||||||
|
import { app } from 'electron';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { posts, Post, NewPost } from '../database/schema';
|
import { posts, Post, NewPost } from '../database/schema';
|
||||||
import { taskManager, Task } from './TaskManager';
|
import { taskManager, Task } from './TaskManager';
|
||||||
|
|
||||||
export interface PostData {
|
export interface PostData {
|
||||||
id: string;
|
id: string;
|
||||||
|
projectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
@@ -25,6 +27,8 @@ export interface PostData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PostMetadata {
|
export interface PostMetadata {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
@@ -35,15 +39,45 @@ export interface PostMetadata {
|
|||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
id: string;
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
excerpt?: string;
|
||||||
|
matchSnippet?: string;
|
||||||
|
rank?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostFilter {
|
||||||
|
status?: 'draft' | 'published' | 'archived';
|
||||||
|
tags?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PostEngine extends EventEmitter {
|
export class PostEngine extends EventEmitter {
|
||||||
private postsDir: string;
|
private currentProjectId: string = 'default';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.postsDir = getDatabase().getDataPaths().posts;
|
}
|
||||||
|
|
||||||
|
private getPostsDir(): string {
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
return path.join(userDataPath, 'projects', this.currentProjectId, 'posts');
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjectContext(projectId: string): void {
|
||||||
|
this.currentProjectId = projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjectContext(): string {
|
||||||
|
return this.currentProjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateSlug(title: string): string {
|
private generateSlug(title: string): string {
|
||||||
@@ -60,6 +94,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
private async writePostFile(post: PostData): Promise<string> {
|
private async writePostFile(post: PostData): Promise<string> {
|
||||||
const metadata: PostMetadata = {
|
const metadata: PostMetadata = {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
|
projectId: post.projectId,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
@@ -72,8 +107,11 @@ export class PostEngine extends EventEmitter {
|
|||||||
categories: post.categories,
|
categories: post.categories,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const postsDir = this.getPostsDir();
|
||||||
|
await fs.mkdir(postsDir, { recursive: true });
|
||||||
|
|
||||||
const fileContent = matter.stringify(post.content, metadata);
|
const fileContent = matter.stringify(post.content, metadata);
|
||||||
const filePath = path.join(this.postsDir, `${post.slug}.md`);
|
const filePath = path.join(postsDir, `${post.slug}.md`);
|
||||||
|
|
||||||
await fs.writeFile(filePath, fileContent, 'utf-8');
|
await fs.writeFile(filePath, fileContent, 'utf-8');
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -87,6 +125,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: metadata.id,
|
id: metadata.id,
|
||||||
|
projectId: metadata.projectId || this.currentProjectId,
|
||||||
title: metadata.title,
|
title: metadata.title,
|
||||||
slug: metadata.slug,
|
slug: metadata.slug,
|
||||||
excerpt: metadata.excerpt,
|
excerpt: metadata.excerpt,
|
||||||
@@ -107,12 +146,14 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
async createPost(data: Partial<PostData>): Promise<PostData> {
|
async createPost(data: Partial<PostData>): Promise<PostData> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const slug = data.slug || this.generateSlug(data.title || 'untitled');
|
const slug = data.slug || this.generateSlug(data.title || 'untitled');
|
||||||
|
|
||||||
const post: PostData = {
|
const post: PostData = {
|
||||||
id,
|
id,
|
||||||
|
projectId: data.projectId || this.currentProjectId,
|
||||||
title: data.title || 'Untitled',
|
title: data.title || 'Untitled',
|
||||||
slug,
|
slug,
|
||||||
excerpt: data.excerpt,
|
excerpt: data.excerpt,
|
||||||
@@ -133,6 +174,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
// Then update database
|
// Then update database
|
||||||
const dbPost: NewPost = {
|
const dbPost: NewPost = {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
|
projectId: post.projectId,
|
||||||
title: post.title,
|
title: post.title,
|
||||||
slug: post.slug,
|
slug: post.slug,
|
||||||
excerpt: post.excerpt,
|
excerpt: post.excerpt,
|
||||||
@@ -150,12 +192,21 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
await db.insert(posts).values(dbPost);
|
await db.insert(posts).values(dbPost);
|
||||||
|
|
||||||
|
// Update FTS index
|
||||||
|
if (client) {
|
||||||
|
await client.execute({
|
||||||
|
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
args: [post.id, post.title, post.content, post.excerpt || '', post.tags.join(' '), post.categories.join(' ')],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('postCreated', post);
|
this.emit('postCreated', post);
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePost(id: string, data: Partial<PostData>): Promise<PostData | null> {
|
async updatePost(id: string, data: Partial<PostData>): Promise<PostData | null> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
const existing = await this.getPost(id);
|
const existing = await this.getPost(id);
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -166,12 +217,14 @@ export class PostEngine extends EventEmitter {
|
|||||||
...existing,
|
...existing,
|
||||||
...data,
|
...data,
|
||||||
id, // Ensure ID doesn't change
|
id, // Ensure ID doesn't change
|
||||||
|
projectId: existing.projectId, // Ensure projectId doesn't change
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle slug change - need to rename file
|
// Handle slug change - need to rename file
|
||||||
|
const postsDir = this.getPostsDir();
|
||||||
if (data.slug && data.slug !== existing.slug) {
|
if (data.slug && data.slug !== existing.slug) {
|
||||||
const oldPath = path.join(this.postsDir, `${existing.slug}.md`);
|
const oldPath = path.join(postsDir, `${existing.slug}.md`);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(oldPath);
|
await fs.unlink(oldPath);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -199,12 +252,22 @@ export class PostEngine extends EventEmitter {
|
|||||||
})
|
})
|
||||||
.where(eq(posts.id, id));
|
.where(eq(posts.id, id));
|
||||||
|
|
||||||
|
// Update FTS index
|
||||||
|
if (client) {
|
||||||
|
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
|
||||||
|
await client.execute({
|
||||||
|
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
args: [updated.id, updated.title, updated.content, updated.excerpt || '', updated.tags.join(' '), updated.categories.join(' ')],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('postUpdated', updated);
|
this.emit('postUpdated', updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deletePost(id: string): Promise<boolean> {
|
async deletePost(id: string): Promise<boolean> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
const existing = await db.select().from(posts).where(eq(posts.id, id)).get();
|
const existing = await db.select().from(posts).where(eq(posts.id, id)).get();
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -221,6 +284,11 @@ export class PostEngine extends EventEmitter {
|
|||||||
// Delete from database
|
// Delete from database
|
||||||
await db.delete(posts).where(eq(posts.id, id));
|
await db.delete(posts).where(eq(posts.id, id));
|
||||||
|
|
||||||
|
// Delete from FTS index
|
||||||
|
if (client) {
|
||||||
|
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [id] });
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('postDeleted', id);
|
this.emit('postDeleted', id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -240,6 +308,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
// File doesn't exist, reconstruct from database
|
// File doesn't exist, reconstruct from database
|
||||||
return {
|
return {
|
||||||
id: dbPost.id,
|
id: dbPost.id,
|
||||||
|
projectId: dbPost.projectId,
|
||||||
title: dbPost.title,
|
title: dbPost.title,
|
||||||
slug: dbPost.slug,
|
slug: dbPost.slug,
|
||||||
excerpt: dbPost.excerpt || undefined,
|
excerpt: dbPost.excerpt || undefined,
|
||||||
@@ -259,7 +328,12 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
async getAllPosts(): Promise<PostData[]> {
|
async getAllPosts(): Promise<PostData[]> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
const dbPosts = await db.select().from(posts).all();
|
const dbPosts = await db
|
||||||
|
.select()
|
||||||
|
.from(posts)
|
||||||
|
.where(eq(posts.projectId, this.currentProjectId))
|
||||||
|
.orderBy(desc(posts.createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
const result: PostData[] = [];
|
const result: PostData[] = [];
|
||||||
|
|
||||||
@@ -275,7 +349,15 @@ export class PostEngine extends EventEmitter {
|
|||||||
|
|
||||||
async getPostsByStatus(status: 'draft' | 'published' | 'archived'): Promise<PostData[]> {
|
async getPostsByStatus(status: 'draft' | 'published' | 'archived'): Promise<PostData[]> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
const dbPosts = await db.select().from(posts).where(eq(posts.status, status)).all();
|
const dbPosts = await db
|
||||||
|
.select()
|
||||||
|
.from(posts)
|
||||||
|
.where(and(
|
||||||
|
eq(posts.projectId, this.currentProjectId),
|
||||||
|
eq(posts.status, status)
|
||||||
|
))
|
||||||
|
.orderBy(desc(posts.createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
const result: PostData[] = [];
|
const result: PostData[] = [];
|
||||||
|
|
||||||
@@ -289,6 +371,140 @@ export class PostEngine extends EventEmitter {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPostsFiltered(filter: PostFilter): Promise<PostData[]> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const conditions = [eq(posts.projectId, this.currentProjectId)];
|
||||||
|
|
||||||
|
if (filter.status) {
|
||||||
|
conditions.push(eq(posts.status, filter.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.startDate) {
|
||||||
|
conditions.push(gte(posts.createdAt, filter.startDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.endDate) {
|
||||||
|
conditions.push(lte(posts.createdAt, filter.endDate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.year !== undefined) {
|
||||||
|
const startOfYear = new Date(filter.year, 0, 1);
|
||||||
|
const endOfYear = new Date(filter.year + 1, 0, 1);
|
||||||
|
conditions.push(gte(posts.createdAt, startOfYear));
|
||||||
|
conditions.push(lte(posts.createdAt, endOfYear));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.month !== undefined && filter.year !== undefined) {
|
||||||
|
const startOfMonth = new Date(filter.year, filter.month, 1);
|
||||||
|
const endOfMonth = new Date(filter.year, filter.month + 1, 1);
|
||||||
|
conditions.push(gte(posts.createdAt, startOfMonth));
|
||||||
|
conditions.push(lte(posts.createdAt, endOfMonth));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPosts = await db
|
||||||
|
.select()
|
||||||
|
.from(posts)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(posts.createdAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
let result: PostData[] = [];
|
||||||
|
|
||||||
|
for (const dbPost of dbPosts) {
|
||||||
|
const postData = await this.getPost(dbPost.id);
|
||||||
|
if (postData) {
|
||||||
|
// Client-side filtering for tags/categories (JSON array)
|
||||||
|
if (filter.tags && filter.tags.length > 0) {
|
||||||
|
const hasAllTags = filter.tags.every(tag => postData.tags.includes(tag));
|
||||||
|
if (!hasAllTags) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.categories && filter.categories.length > 0) {
|
||||||
|
const hasAnyCategory = filter.categories.some(cat => postData.categories.includes(cat));
|
||||||
|
if (!hasAnyCategory) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(postData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchPosts(query: string): Promise<SearchResult[]> {
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
|
if (!client) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.execute({
|
||||||
|
sql: `SELECT id, title, excerpt, snippet(posts_fts, 2, '<mark>', '</mark>', '...', 32) as snippet, rank
|
||||||
|
FROM posts_fts
|
||||||
|
WHERE posts_fts MATCH ?
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT 50`,
|
||||||
|
args: [query],
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectPosts = await this.getAllPosts();
|
||||||
|
const projectPostIds = new Set(projectPosts.map(p => p.id));
|
||||||
|
|
||||||
|
return result.rows
|
||||||
|
.filter(row => projectPostIds.has(row.id as string))
|
||||||
|
.map(row => ({
|
||||||
|
id: row.id as string,
|
||||||
|
title: row.title as string,
|
||||||
|
slug: '', // Will be filled in by caller if needed
|
||||||
|
excerpt: row.excerpt as string | undefined,
|
||||||
|
matchSnippet: row.snippet as string | undefined,
|
||||||
|
rank: row.rank as number | undefined,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableTags(): Promise<string[]> {
|
||||||
|
const allPosts = await this.getAllPosts();
|
||||||
|
const tags = new Set<string>();
|
||||||
|
for (const post of allPosts) {
|
||||||
|
for (const tag of post.tags) {
|
||||||
|
tags.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(tags).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAvailableCategories(): Promise<string[]> {
|
||||||
|
const allPosts = await this.getAllPosts();
|
||||||
|
const categories = new Set<string>();
|
||||||
|
for (const post of allPosts) {
|
||||||
|
for (const cat of post.categories) {
|
||||||
|
categories.add(cat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(categories).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPostsByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
|
||||||
|
const allPosts = await this.getAllPosts();
|
||||||
|
const counts = new Map<string, { year: number; month: number; count: number }>();
|
||||||
|
|
||||||
|
for (const post of allPosts) {
|
||||||
|
const year = post.createdAt.getFullYear();
|
||||||
|
const month = post.createdAt.getMonth();
|
||||||
|
const key = `${year}-${month}`;
|
||||||
|
const current = counts.get(key) || { year, month, count: 0 };
|
||||||
|
current.count++;
|
||||||
|
counts.set(key, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(counts.values()).sort((a, b) => {
|
||||||
|
if (a.year !== b.year) return b.year - a.year;
|
||||||
|
return b.month - a.month;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async publishPost(id: string): Promise<PostData | null> {
|
async publishPost(id: string): Promise<PostData | null> {
|
||||||
return this.updatePost(id, {
|
return this.updatePost(id, {
|
||||||
status: 'published',
|
status: 'published',
|
||||||
@@ -304,22 +520,30 @@ export class PostEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async rebuildDatabaseFromFiles(): Promise<void> {
|
async rebuildDatabaseFromFiles(): Promise<void> {
|
||||||
|
const postsDir = this.getPostsDir();
|
||||||
const task: Task<void> = {
|
const task: Task<void> = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: 'Rebuild database from post files',
|
name: 'Rebuild database from post files',
|
||||||
execute: async (onProgress) => {
|
execute: async (onProgress) => {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
|
const client = getDatabase().getLocalClient();
|
||||||
|
|
||||||
onProgress(0, 'Scanning posts directory...');
|
onProgress(0, 'Scanning posts directory...');
|
||||||
|
|
||||||
const files = await fs.readdir(this.postsDir);
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
files = await fs.readdir(postsDir);
|
||||||
|
} catch {
|
||||||
|
// Directory might not exist
|
||||||
|
await fs.mkdir(postsDir, { recursive: true });
|
||||||
|
}
|
||||||
const mdFiles = files.filter(f => f.endsWith('.md'));
|
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||||
|
|
||||||
onProgress(10, `Found ${mdFiles.length} post files`);
|
onProgress(10, `Found ${mdFiles.length} post files`);
|
||||||
|
|
||||||
for (let i = 0; i < mdFiles.length; i++) {
|
for (let i = 0; i < mdFiles.length; i++) {
|
||||||
const file = mdFiles[i];
|
const file = mdFiles[i];
|
||||||
const filePath = path.join(this.postsDir, file);
|
const filePath = path.join(postsDir, file);
|
||||||
|
|
||||||
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${file}...`);
|
onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${file}...`);
|
||||||
|
|
||||||
@@ -348,6 +572,7 @@ export class PostEngine extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
await db.insert(posts).values({
|
await db.insert(posts).values({
|
||||||
id: postData.id,
|
id: postData.id,
|
||||||
|
projectId: postData.projectId || this.currentProjectId,
|
||||||
title: postData.title,
|
title: postData.title,
|
||||||
slug: postData.slug,
|
slug: postData.slug,
|
||||||
excerpt: postData.excerpt,
|
excerpt: postData.excerpt,
|
||||||
@@ -363,6 +588,15 @@ export class PostEngine extends EventEmitter {
|
|||||||
categories: JSON.stringify(postData.categories),
|
categories: JSON.stringify(postData.categories),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update FTS index
|
||||||
|
if (client) {
|
||||||
|
await client.execute({ sql: 'DELETE FROM posts_fts WHERE id = ?', args: [postData.id] });
|
||||||
|
await client.execute({
|
||||||
|
sql: 'INSERT INTO posts_fts (id, title, content, excerpt, tags, categories) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
args: [postData.id, postData.title, postData.content, postData.excerpt || '', postData.tags.join(' '), postData.categories.join(' ')],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
239
src/main/engine/ProjectEngine.ts
Normal file
239
src/main/engine/ProjectEngine.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import { getDatabase } from '../database';
|
||||||
|
import { projects, Project, NewProject } from '../database/schema';
|
||||||
|
|
||||||
|
export interface ProjectData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProjectEngine extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureProjectDirectories(slug: string): Promise<void> {
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
const projectDir = path.join(userDataPath, 'projects', slug);
|
||||||
|
const postsDir = path.join(projectDir, 'posts');
|
||||||
|
const mediaDir = path.join(projectDir, 'media');
|
||||||
|
|
||||||
|
await fs.mkdir(projectDir, { recursive: true });
|
||||||
|
await fs.mkdir(postsDir, { recursive: true });
|
||||||
|
await fs.mkdir(mediaDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProject(data: { name: string; description?: string; slug?: string }): Promise<ProjectData> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const now = new Date();
|
||||||
|
const id = uuidv4();
|
||||||
|
const slug = data.slug || this.generateSlug(data.name);
|
||||||
|
|
||||||
|
// Ensure unique slug
|
||||||
|
let finalSlug = slug;
|
||||||
|
let counter = 1;
|
||||||
|
const existing = await db.select().from(projects).all();
|
||||||
|
const existingSlugs = new Set(existing.map(p => p.slug));
|
||||||
|
while (existingSlugs.has(finalSlug)) {
|
||||||
|
finalSlug = `${slug}-${counter}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project: ProjectData = {
|
||||||
|
id,
|
||||||
|
name: data.name,
|
||||||
|
slug: finalSlug,
|
||||||
|
description: data.description,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
isActive: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create directories
|
||||||
|
await this.ensureProjectDirectories(finalSlug);
|
||||||
|
|
||||||
|
// Insert into database
|
||||||
|
const dbProject: NewProject = {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
slug: project.slug,
|
||||||
|
description: project.description,
|
||||||
|
createdAt: project.createdAt,
|
||||||
|
updatedAt: project.updatedAt,
|
||||||
|
isActive: project.isActive,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insert(projects).values(dbProject);
|
||||||
|
|
||||||
|
this.emit('projectCreated', project);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProject(id: string, data: Partial<Omit<ProjectData, 'id' | 'createdAt'>>): Promise<ProjectData | null> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const existing = await db.select().from(projects).where(eq(projects.id, id)).get();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const updated = {
|
||||||
|
...existing,
|
||||||
|
...data,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.update(projects)
|
||||||
|
.set({
|
||||||
|
name: updated.name,
|
||||||
|
slug: updated.slug,
|
||||||
|
description: updated.description,
|
||||||
|
updatedAt: updated.updatedAt,
|
||||||
|
isActive: updated.isActive,
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, id));
|
||||||
|
|
||||||
|
const result: ProjectData = {
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.name,
|
||||||
|
slug: updated.slug,
|
||||||
|
description: updated.description || undefined,
|
||||||
|
createdAt: updated.createdAt,
|
||||||
|
updatedAt: updated.updatedAt,
|
||||||
|
isActive: updated.isActive ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.emit('projectUpdated', result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProject(id: string): Promise<boolean> {
|
||||||
|
// Prevent deleting the default project
|
||||||
|
if (id === 'default') {
|
||||||
|
throw new Error('Cannot delete the default project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const existing = await db.select().from(projects).where(eq(projects.id, id)).get();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Optionally delete project files (posts, media)
|
||||||
|
// For safety, we'll leave them in place
|
||||||
|
|
||||||
|
await db.delete(projects).where(eq(projects.id, id));
|
||||||
|
|
||||||
|
this.emit('projectDeleted', id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProject(id: string): Promise<ProjectData | null> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const dbProject = await db.select().from(projects).where(eq(projects.id, id)).get();
|
||||||
|
|
||||||
|
if (!dbProject) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dbProject.id,
|
||||||
|
name: dbProject.name,
|
||||||
|
slug: dbProject.slug,
|
||||||
|
description: dbProject.description || undefined,
|
||||||
|
createdAt: dbProject.createdAt,
|
||||||
|
updatedAt: dbProject.updatedAt,
|
||||||
|
isActive: dbProject.isActive ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllProjects(): Promise<ProjectData[]> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const dbProjects = await db.select().from(projects).all();
|
||||||
|
|
||||||
|
return dbProjects.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
slug: p.slug,
|
||||||
|
description: p.description || undefined,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
isActive: p.isActive ?? false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveProject(): Promise<ProjectData | null> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
const dbProject = await db.select().from(projects).where(eq(projects.isActive, true)).get();
|
||||||
|
|
||||||
|
if (!dbProject) {
|
||||||
|
// Return default if no active project
|
||||||
|
return this.getProject('default');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dbProject.id,
|
||||||
|
name: dbProject.name,
|
||||||
|
slug: dbProject.slug,
|
||||||
|
description: dbProject.description || undefined,
|
||||||
|
createdAt: dbProject.createdAt,
|
||||||
|
updatedAt: dbProject.updatedAt,
|
||||||
|
isActive: dbProject.isActive ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setActiveProject(id: string): Promise<ProjectData | null> {
|
||||||
|
const db = getDatabase().getLocal();
|
||||||
|
|
||||||
|
// Deactivate all projects
|
||||||
|
await db.update(projects).set({ isActive: false });
|
||||||
|
|
||||||
|
// Activate the selected project
|
||||||
|
await db.update(projects)
|
||||||
|
.set({ isActive: true })
|
||||||
|
.where(eq(projects.id, id));
|
||||||
|
|
||||||
|
const project = await this.getProject(id);
|
||||||
|
if (project) {
|
||||||
|
this.emit('activeProjectChanged', project);
|
||||||
|
}
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProjectPaths(projectSlug: string): { posts: string; media: string } {
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
return {
|
||||||
|
posts: path.join(userDataPath, 'projects', projectSlug, 'posts'),
|
||||||
|
media: path.join(userDataPath, 'projects', projectSlug, 'media'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
let projectEngine: ProjectEngine | null = null;
|
||||||
|
|
||||||
|
export function getProjectEngine(): ProjectEngine {
|
||||||
|
if (!projectEngine) {
|
||||||
|
projectEngine = new ProjectEngine();
|
||||||
|
}
|
||||||
|
return projectEngine;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
|
export { TaskManager, taskManager, type Task, type TaskProgress, type TaskStatus } from './TaskManager';
|
||||||
export { PostEngine, getPostEngine, type PostData } from './PostEngine';
|
export { PostEngine, getPostEngine, type PostData, type PostFilter, type SearchResult } from './PostEngine';
|
||||||
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
|
export { MediaEngine, getMediaEngine, type MediaData } from './MediaEngine';
|
||||||
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
|
export { SyncEngine, getSyncEngine, type SyncConfig, type SyncResult, type SyncDirection, type SyncStatus } from './SyncEngine';
|
||||||
|
export { ProjectEngine, getProjectEngine, type ProjectData } from './ProjectEngine';
|
||||||
|
|||||||
@@ -1,11 +1,59 @@
|
|||||||
import { ipcMain, dialog, shell } from 'electron';
|
import { ipcMain, dialog, shell } from 'electron';
|
||||||
import { getPostEngine, PostData } from '../engine/PostEngine';
|
import { getPostEngine, PostData, PostFilter } from '../engine/PostEngine';
|
||||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||||
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
import { getSyncEngine, SyncConfig, SyncDirection } from '../engine/SyncEngine';
|
||||||
|
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||||
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
|
// ============ Project Handlers ============
|
||||||
|
|
||||||
|
ipcMain.handle('projects:create', async (_, data: { name: string; description?: string; slug?: string }) => {
|
||||||
|
const engine = getProjectEngine();
|
||||||
|
return engine.createProject(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('projects:update', async (_, id: string, data: Partial<ProjectData>) => {
|
||||||
|
const engine = getProjectEngine();
|
||||||
|
return engine.updateProject(id, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('projects:delete', async (_, id: string) => {
|
||||||
|
const engine = getProjectEngine();
|
||||||
|
return engine.deleteProject(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('projects:get', async (_, id: string) => {
|
||||||
|
const engine = getProjectEngine();
|
||||||
|
return engine.getProject(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('projects:getAll', async () => {
|
||||||
|
const engine = getProjectEngine();
|
||||||
|
return engine.getAllProjects();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('projects:getActive', async () => {
|
||||||
|
const engine = getProjectEngine();
|
||||||
|
return engine.getActiveProject();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('projects:setActive', async (_, id: string) => {
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const project = await projectEngine.setActiveProject(id);
|
||||||
|
|
||||||
|
// Update post and media engines to use the new project context
|
||||||
|
if (project) {
|
||||||
|
const postEngine = getPostEngine();
|
||||||
|
const mediaEngine = getMediaEngine();
|
||||||
|
postEngine.setProjectContext(project.id);
|
||||||
|
mediaEngine.setProjectContext(project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Post Handlers ============
|
// ============ Post Handlers ============
|
||||||
|
|
||||||
ipcMain.handle('posts:create', async (_, data: Partial<PostData>) => {
|
ipcMain.handle('posts:create', async (_, data: Partial<PostData>) => {
|
||||||
@@ -53,6 +101,31 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.rebuildDatabaseFromFiles();
|
return engine.rebuildDatabaseFromFiles();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('posts:search', async (_, query: string) => {
|
||||||
|
const engine = getPostEngine();
|
||||||
|
return engine.searchPosts(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('posts:filter', async (_, filter: PostFilter) => {
|
||||||
|
const engine = getPostEngine();
|
||||||
|
return engine.getPostsFiltered(filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('posts:getTags', async () => {
|
||||||
|
const engine = getPostEngine();
|
||||||
|
return engine.getAvailableTags();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('posts:getCategories', async () => {
|
||||||
|
const engine = getPostEngine();
|
||||||
|
return engine.getAvailableCategories();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('posts:getByYearMonth', async () => {
|
||||||
|
const engine = getPostEngine();
|
||||||
|
return engine.getPostsByYearMonth();
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Media Handlers ============
|
// ============ Media Handlers ============
|
||||||
|
|
||||||
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
|
ipcMain.handle('media:import', async (_, sourcePath: string, metadata?: Partial<MediaData>) => {
|
||||||
@@ -189,6 +262,7 @@ export function registerIpcHandlers(): void {
|
|||||||
const postEngine = getPostEngine();
|
const postEngine = getPostEngine();
|
||||||
const mediaEngine = getMediaEngine();
|
const mediaEngine = getMediaEngine();
|
||||||
const syncEngine = getSyncEngine();
|
const syncEngine = getSyncEngine();
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
|
||||||
const forwardEvent = (eventName: string) => {
|
const forwardEvent = (eventName: string) => {
|
||||||
return (...args: unknown[]) => {
|
return (...args: unknown[]) => {
|
||||||
@@ -197,6 +271,11 @@ export function registerIpcHandlers(): void {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
projectEngine.on('projectCreated', forwardEvent('project:created'));
|
||||||
|
projectEngine.on('projectUpdated', forwardEvent('project:updated'));
|
||||||
|
projectEngine.on('projectDeleted', forwardEvent('project:deleted'));
|
||||||
|
projectEngine.on('activeProjectChanged', forwardEvent('project:activeChanged'));
|
||||||
|
|
||||||
postEngine.on('postCreated', forwardEvent('post:created'));
|
postEngine.on('postCreated', forwardEvent('post:created'));
|
||||||
postEngine.on('postUpdated', forwardEvent('post:updated'));
|
postEngine.on('postUpdated', forwardEvent('post:updated'));
|
||||||
postEngine.on('postDeleted', forwardEvent('post:deleted'));
|
postEngine.on('postDeleted', forwardEvent('post:deleted'));
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import { contextBridge, ipcRenderer } from 'electron';
|
|||||||
// Expose protected methods that allow the renderer process to use
|
// Expose protected methods that allow the renderer process to use
|
||||||
// ipcRenderer without exposing the entire object
|
// ipcRenderer without exposing the entire object
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
// Projects
|
||||||
|
projects: {
|
||||||
|
create: (data: { name: string; description?: string; slug?: string }) => ipcRenderer.invoke('projects:create', data),
|
||||||
|
update: (id: string, data: unknown) => ipcRenderer.invoke('projects:update', id, data),
|
||||||
|
delete: (id: string) => ipcRenderer.invoke('projects:delete', id),
|
||||||
|
get: (id: string) => ipcRenderer.invoke('projects:get', id),
|
||||||
|
getAll: () => ipcRenderer.invoke('projects:getAll'),
|
||||||
|
getActive: () => ipcRenderer.invoke('projects:getActive'),
|
||||||
|
setActive: (id: string) => ipcRenderer.invoke('projects:setActive', id),
|
||||||
|
},
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
posts: {
|
posts: {
|
||||||
create: (data: unknown) => ipcRenderer.invoke('posts:create', data),
|
create: (data: unknown) => ipcRenderer.invoke('posts:create', data),
|
||||||
@@ -14,6 +25,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
|
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
|
||||||
unpublish: (id: string) => ipcRenderer.invoke('posts:unpublish', id),
|
unpublish: (id: string) => ipcRenderer.invoke('posts:unpublish', id),
|
||||||
rebuildFromFiles: () => ipcRenderer.invoke('posts:rebuildFromFiles'),
|
rebuildFromFiles: () => ipcRenderer.invoke('posts:rebuildFromFiles'),
|
||||||
|
search: (query: string) => ipcRenderer.invoke('posts:search', query),
|
||||||
|
filter: (filter: unknown) => ipcRenderer.invoke('posts:filter', filter),
|
||||||
|
getTags: () => ipcRenderer.invoke('posts:getTags'),
|
||||||
|
getCategories: () => ipcRenderer.invoke('posts:getCategories'),
|
||||||
|
getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
@@ -67,6 +83,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// Type definitions for the exposed API
|
// Type definitions for the exposed API
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
|
projects: {
|
||||||
|
create: (data: { name: string; description?: string; slug?: string }) => Promise<unknown>;
|
||||||
|
update: (id: string, data: unknown) => Promise<unknown>;
|
||||||
|
delete: (id: string) => Promise<boolean>;
|
||||||
|
get: (id: string) => Promise<unknown>;
|
||||||
|
getAll: () => Promise<unknown[]>;
|
||||||
|
getActive: () => Promise<unknown>;
|
||||||
|
setActive: (id: string) => Promise<unknown>;
|
||||||
|
};
|
||||||
posts: {
|
posts: {
|
||||||
create: (data: unknown) => Promise<unknown>;
|
create: (data: unknown) => Promise<unknown>;
|
||||||
update: (id: string, data: unknown) => Promise<unknown>;
|
update: (id: string, data: unknown) => Promise<unknown>;
|
||||||
@@ -77,6 +102,11 @@ export interface ElectronAPI {
|
|||||||
publish: (id: string) => Promise<unknown>;
|
publish: (id: string) => Promise<unknown>;
|
||||||
unpublish: (id: string) => Promise<unknown>;
|
unpublish: (id: string) => Promise<unknown>;
|
||||||
rebuildFromFiles: () => Promise<void>;
|
rebuildFromFiles: () => Promise<void>;
|
||||||
|
search: (query: string) => Promise<unknown[]>;
|
||||||
|
filter: (filter: unknown) => Promise<unknown[]>;
|
||||||
|
getTags: () => Promise<string[]>;
|
||||||
|
getCategories: () => Promise<string[]>;
|
||||||
|
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||||
};
|
};
|
||||||
media: {
|
media: {
|
||||||
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;
|
import: (sourcePath: string, metadata?: unknown) => Promise<unknown>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { ActivityBar, Sidebar, Editor, StatusBar, Panel } from './components';
|
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, ToastContainer, showToast } from './components';
|
||||||
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -115,12 +115,15 @@ const App: React.FC = () => {
|
|||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('sync:started', () => {
|
window.electronAPI?.on('sync:started', () => {
|
||||||
setSyncStatus('syncing');
|
setSyncStatus('syncing');
|
||||||
|
showToast.loading('Syncing...');
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('sync:completed', async () => {
|
window.electronAPI?.on('sync:completed', async () => {
|
||||||
setSyncStatus('idle');
|
setSyncStatus('idle');
|
||||||
|
showToast.dismiss();
|
||||||
|
showToast.success('Sync completed');
|
||||||
const pending = await window.electronAPI?.sync.getPendingCount();
|
const pending = await window.electronAPI?.sync.getPendingCount();
|
||||||
if (pending) {
|
if (pending) {
|
||||||
setPendingChanges(pending);
|
setPendingChanges(pending);
|
||||||
@@ -131,6 +134,8 @@ const App: React.FC = () => {
|
|||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('sync:failed', () => {
|
window.electronAPI?.on('sync:failed', () => {
|
||||||
setSyncStatus('error');
|
setSyncStatus('error');
|
||||||
|
showToast.dismiss();
|
||||||
|
showToast.error('Sync failed');
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -146,6 +151,7 @@ const App: React.FC = () => {
|
|||||||
window.electronAPI?.on('task:completed', (task: unknown) => {
|
window.electronAPI?.on('task:completed', (task: unknown) => {
|
||||||
const t = task as TaskProgress;
|
const t = task as TaskProgress;
|
||||||
updateTask(t.taskId, t);
|
updateTask(t.taskId, t);
|
||||||
|
showToast.success(`Task completed: ${t.message}`);
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -153,6 +159,7 @@ const App: React.FC = () => {
|
|||||||
window.electronAPI?.on('task:failed', (task: unknown) => {
|
window.electronAPI?.on('task:failed', (task: unknown) => {
|
||||||
const t = task as TaskProgress;
|
const t = task as TaskProgress;
|
||||||
updateTask(t.taskId, t);
|
updateTask(t.taskId, t);
|
||||||
|
showToast.error(`Task failed: ${t.error || t.message}`);
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -256,6 +263,7 @@ const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -154,6 +154,58 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-mode-toggle button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-mode-toggle button:hover {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-mode-toggle button.active {
|
||||||
|
background-color: var(--vscode-button-background);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview {
|
||||||
|
flex: 1;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-field-row .editor-field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-footer {
|
.editor-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import MonacoEditor from '@monaco-editor/react';
|
||||||
import { useAppStore, PostData } from '../../store';
|
import { useAppStore, PostData } from '../../store';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
interface PostEditorProps {
|
interface PostEditorProps {
|
||||||
@@ -11,13 +13,17 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
const [title, setTitle] = useState(post.title);
|
const [title, setTitle] = useState(post.title);
|
||||||
const [content, setContent] = useState(post.content);
|
const [content, setContent] = useState(post.content);
|
||||||
const [tags, setTags] = useState(post.tags.join(', '));
|
const [tags, setTags] = useState(post.tags.join(', '));
|
||||||
|
const [categories, setCategories] = useState(post.categories.join(', '));
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [editorMode, setEditorMode] = useState<'markdown' | 'preview'>('markdown');
|
||||||
|
const editorRef = useRef<unknown>(null);
|
||||||
|
|
||||||
// Reset when post changes
|
// Reset when post changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTitle(post.title);
|
setTitle(post.title);
|
||||||
setContent(post.content);
|
setContent(post.content);
|
||||||
setTags(post.tags.join(', '));
|
setTags(post.tags.join(', '));
|
||||||
|
setCategories(post.categories.join(', '));
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
}, [post.id]);
|
}, [post.id]);
|
||||||
|
|
||||||
@@ -26,9 +32,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
const hasChanges =
|
const hasChanges =
|
||||||
title !== post.title ||
|
title !== post.title ||
|
||||||
content !== post.content ||
|
content !== post.content ||
|
||||||
tags !== post.tags.join(', ');
|
tags !== post.tags.join(', ') ||
|
||||||
|
categories !== post.categories.join(', ');
|
||||||
setIsDirty(hasChanges);
|
setIsDirty(hasChanges);
|
||||||
}, [title, content, tags, post]);
|
}, [title, content, tags, categories, post]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!isDirty) return;
|
if (!isDirty) return;
|
||||||
@@ -38,16 +45,19 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
||||||
|
categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updatePost(post.id, updated as Partial<PostData>);
|
updatePost(post.id, updated as Partial<PostData>);
|
||||||
setIsDirty(false);
|
setIsDirty(false);
|
||||||
|
showToast.success('Post saved');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save post:', error);
|
console.error('Failed to save post:', error);
|
||||||
|
showToast.error('Failed to save post');
|
||||||
}
|
}
|
||||||
}, [post.id, title, content, tags, isDirty, updatePost]);
|
}, [post.id, title, content, tags, categories, isDirty, updatePost]);
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
await handleSave();
|
await handleSave();
|
||||||
@@ -55,9 +65,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
const updated = await window.electronAPI?.posts.publish(post.id);
|
const updated = await window.electronAPI?.posts.publish(post.id);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updatePost(post.id, updated as Partial<PostData>);
|
updatePost(post.id, updated as Partial<PostData>);
|
||||||
|
showToast.success('Post published');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to publish post:', error);
|
console.error('Failed to publish post:', error);
|
||||||
|
showToast.error('Failed to publish post');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,9 +78,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
const updated = await window.electronAPI?.posts.unpublish(post.id);
|
const updated = await window.electronAPI?.posts.unpublish(post.id);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
updatePost(post.id, updated as Partial<PostData>);
|
updatePost(post.id, updated as Partial<PostData>);
|
||||||
|
showToast.success('Post unpublished');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to unpublish post:', error);
|
console.error('Failed to unpublish post:', error);
|
||||||
|
showToast.error('Failed to unpublish post');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,12 +91,20 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
try {
|
try {
|
||||||
await window.electronAPI?.posts.delete(post.id);
|
await window.electronAPI?.posts.delete(post.id);
|
||||||
useAppStore.getState().removePost(post.id);
|
useAppStore.getState().removePost(post.id);
|
||||||
|
useAppStore.getState().setSelectedPost(null);
|
||||||
|
showToast.success('Post deleted');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete post:', error);
|
console.error('Failed to delete post:', error);
|
||||||
|
showToast.error('Failed to delete post');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle Monaco editor mount
|
||||||
|
const handleEditorDidMount = (editor: unknown) => {
|
||||||
|
editorRef.current = editor;
|
||||||
|
};
|
||||||
|
|
||||||
// Save on Ctrl+S
|
// Save on Ctrl+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -158,25 +180,76 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
|
|||||||
className="disabled"
|
className="disabled"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-field">
|
<div className="editor-field-row">
|
||||||
<label>Tags (comma-separated)</label>
|
<div className="editor-field">
|
||||||
<input
|
<label>Tags (comma-separated)</label>
|
||||||
type="text"
|
<input
|
||||||
value={tags}
|
type="text"
|
||||||
onChange={(e) => setTags(e.target.value)}
|
value={tags}
|
||||||
placeholder="tag1, tag2, tag3"
|
onChange={(e) => setTags(e.target.value)}
|
||||||
/>
|
placeholder="tag1, tag2, tag3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="editor-field">
|
||||||
|
<label>Categories (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={categories}
|
||||||
|
onChange={(e) => setCategories(e.target.value)}
|
||||||
|
placeholder="category1, category2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-body">
|
<div className="editor-body">
|
||||||
<label>Content (Markdown)</label>
|
<div className="editor-toolbar">
|
||||||
<textarea
|
<label>Content (Markdown)</label>
|
||||||
value={content}
|
<div className="editor-mode-toggle">
|
||||||
onChange={(e) => setContent(e.target.value)}
|
<button
|
||||||
placeholder="Write your post content in Markdown..."
|
className={editorMode === 'markdown' ? 'active' : ''}
|
||||||
spellCheck
|
onClick={() => setEditorMode('markdown')}
|
||||||
/>
|
>
|
||||||
|
Markdown
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={editorMode === 'preview' ? 'active' : ''}
|
||||||
|
onClick={() => setEditorMode('preview')}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{editorMode === 'markdown' ? (
|
||||||
|
<MonacoEditor
|
||||||
|
height="100%"
|
||||||
|
defaultLanguage="markdown"
|
||||||
|
value={content}
|
||||||
|
onChange={(value) => setContent(value || '')}
|
||||||
|
onMount={handleEditorDidMount}
|
||||||
|
theme="vs-dark"
|
||||||
|
options={{
|
||||||
|
minimap: { enabled: false },
|
||||||
|
wordWrap: 'on',
|
||||||
|
lineNumbers: 'on',
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
|
||||||
|
padding: { top: 12, bottom: 12 },
|
||||||
|
automaticLayout: true,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
renderLineHighlight: 'line',
|
||||||
|
quickSuggestions: false,
|
||||||
|
formatOnPaste: true,
|
||||||
|
cursorStyle: 'line',
|
||||||
|
cursorBlinking: 'smooth',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="editor-preview markdown-body">
|
||||||
|
{/* Simple markdown preview - could be enhanced with a proper renderer */}
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>{content}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
277
src/renderer/components/ProjectSelector/ProjectSelector.css
Normal file
277
src/renderer/components/ProjectSelector/ProjectSelector.css
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
.project-selector {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-selector-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-selector-trigger:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-selector-trigger:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 12px;
|
||||||
|
right: 12px;
|
||||||
|
background-color: var(--vscode-dropdown-background);
|
||||||
|
border: 1px solid var(--vscode-dropdown-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dropdown-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
border-bottom: 1px solid var(--vscode-dropdown-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-dropdown-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item.active {
|
||||||
|
background-color: var(--vscode-list-activeSelectionBackground);
|
||||||
|
color: var(--vscode-list-activeSelectionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--vscode-terminal-ansiGreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-empty {
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dropdown-footer {
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid var(--vscode-dropdown-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-project-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-project-btn:hover {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--vscode-editor-background);
|
||||||
|
border: 1px solid var(--vscode-widget-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--vscode-widget-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input,
|
||||||
|
.form-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input:focus,
|
||||||
|
.form-field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field input::placeholder,
|
||||||
|
.form-field textarea::placeholder {
|
||||||
|
color: var(--vscode-input-placeholderForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--vscode-widget-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background-color: var(--vscode-button-background);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: var(--vscode-button-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 6px 14px;
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
202
src/renderer/components/ProjectSelector/ProjectSelector.tsx
Normal file
202
src/renderer/components/ProjectSelector/ProjectSelector.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useAppStore, ProjectData } from '../../store';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
|
import './ProjectSelector.css';
|
||||||
|
|
||||||
|
export const ProjectSelector: React.FC = () => {
|
||||||
|
const { projects, activeProject, setProjects, setActiveProject, setPosts, setMedia, setSelectedPost, setSelectedMedia } = useAppStore();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [newProjectName, setNewProjectName] = useState('');
|
||||||
|
const [newProjectDescription, setNewProjectDescription] = useState('');
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load projects on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadProjects = async () => {
|
||||||
|
try {
|
||||||
|
const allProjects = await window.electronAPI?.projects.getAll();
|
||||||
|
if (allProjects) {
|
||||||
|
setProjects(allProjects as ProjectData[]);
|
||||||
|
}
|
||||||
|
const active = await window.electronAPI?.projects.getActive();
|
||||||
|
if (active) {
|
||||||
|
setActiveProject(active as ProjectData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load projects:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadProjects();
|
||||||
|
}, [setProjects, setActiveProject]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSwitchProject = async (project: ProjectData) => {
|
||||||
|
if (project.id === activeProject?.id) {
|
||||||
|
setIsOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedProject = await window.electronAPI?.projects.setActive(project.id);
|
||||||
|
if (updatedProject) {
|
||||||
|
setActiveProject(updatedProject as ProjectData);
|
||||||
|
// Clear current selection and reload data
|
||||||
|
setSelectedPost(null);
|
||||||
|
setSelectedMedia(null);
|
||||||
|
|
||||||
|
// Reload posts and media for new project
|
||||||
|
const [posts, media] = await Promise.all([
|
||||||
|
window.electronAPI?.posts.getAll(),
|
||||||
|
window.electronAPI?.media.getAll(),
|
||||||
|
]);
|
||||||
|
if (posts) setPosts(posts);
|
||||||
|
if (media) setMedia(media);
|
||||||
|
|
||||||
|
showToast.success(`Switched to ${project.name}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to switch project:', error);
|
||||||
|
showToast.error('Failed to switch project');
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateProject = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newProjectName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newProject = await window.electronAPI?.projects.create({
|
||||||
|
name: newProjectName.trim(),
|
||||||
|
description: newProjectDescription.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (newProject) {
|
||||||
|
setProjects([...projects, newProject as ProjectData]);
|
||||||
|
showToast.success(`Created project "${newProjectName}"`);
|
||||||
|
setNewProjectName('');
|
||||||
|
setNewProjectDescription('');
|
||||||
|
setShowCreateModal(false);
|
||||||
|
|
||||||
|
// Optionally switch to the new project
|
||||||
|
await handleSwitchProject(newProject as ProjectData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create project:', error);
|
||||||
|
showToast.error('Failed to create project');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="project-selector" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
className="project-selector-trigger"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
title="Switch project"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="project-icon">
|
||||||
|
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="project-name">{activeProject?.name || 'Select Project'}</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="dropdown-arrow">
|
||||||
|
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="project-dropdown">
|
||||||
|
<div className="project-dropdown-header">
|
||||||
|
<span>PROJECTS</span>
|
||||||
|
</div>
|
||||||
|
<div className="project-list">
|
||||||
|
{projects.map(project => (
|
||||||
|
<button
|
||||||
|
key={project.id}
|
||||||
|
className={`project-item ${project.id === activeProject?.id ? 'active' : ''}`}
|
||||||
|
onClick={() => handleSwitchProject(project)}
|
||||||
|
>
|
||||||
|
<span className="project-item-name">{project.name}</span>
|
||||||
|
{project.id === activeProject?.id && (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="check-icon">
|
||||||
|
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<div className="project-empty">No projects yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="project-dropdown-footer">
|
||||||
|
<button className="create-project-btn" onClick={() => { setShowCreateModal(true); setIsOpen(false); }}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
||||||
|
</svg>
|
||||||
|
New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
|
||||||
|
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>Create New Project</h3>
|
||||||
|
<button className="modal-close" onClick={() => setShowCreateModal(false)}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleCreateProject}>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="project-name">Project Name</label>
|
||||||
|
<input
|
||||||
|
id="project-name"
|
||||||
|
type="text"
|
||||||
|
value={newProjectName}
|
||||||
|
onChange={e => setNewProjectName(e.target.value)}
|
||||||
|
placeholder="My Blog"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label htmlFor="project-description">Description (optional)</label>
|
||||||
|
<textarea
|
||||||
|
id="project-description"
|
||||||
|
value={newProjectDescription}
|
||||||
|
onChange={e => setNewProjectDescription(e.target.value)}
|
||||||
|
placeholder="A brief description of this project..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn-secondary" onClick={() => setShowCreateModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary" disabled={!newProjectName.trim()}>
|
||||||
|
Create Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectSelector;
|
||||||
2
src/renderer/components/ProjectSelector/index.ts
Normal file
2
src/renderer/components/ProjectSelector/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { ProjectSelector } from './ProjectSelector';
|
||||||
|
export { default } from './ProjectSelector';
|
||||||
@@ -201,3 +201,261 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search Box */
|
||||||
|
.search-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 28px 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input::placeholder {
|
||||||
|
color: var(--vscode-input-placeholderForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box button[type="submit"] {
|
||||||
|
position: absolute;
|
||||||
|
right: 40px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box button[type="submit"]:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box .clear-search {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box .clear-search:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar header actions */
|
||||||
|
.sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-action.active {
|
||||||
|
background-color: var(--vscode-list-activeSelectionBackground);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar View */
|
||||||
|
.calendar-view {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header .clear-filter {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header .clear-filter:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-years {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-year-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-year-header:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-year-header.selected {
|
||||||
|
background-color: var(--vscode-list-activeSelectionBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-year-header .expand-icon {
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-year-header .year-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-year-header .year-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
background-color: var(--vscode-badge-background);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-months {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
padding-left: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-month {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-month:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-month.selected {
|
||||||
|
background-color: var(--vscode-list-activeSelectionBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-month .month-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Panel */
|
||||||
|
.filter-panel {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-header {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip:hover {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.active {
|
||||||
|
background-color: var(--vscode-button-background);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Status */
|
||||||
|
.filter-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-status button {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-textLink-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-status button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useAppStore, PostData } from '../../store';
|
import { useAppStore, PostData } from '../../store';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
|
import { ProjectSelector } from '../ProjectSelector';
|
||||||
import './Sidebar.css';
|
import './Sidebar.css';
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
@@ -13,8 +15,290 @@ const formatFileSize = (bytes: number) => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
interface CalendarViewProps {
|
||||||
|
onDateSelect: (year: number, month?: number) => void;
|
||||||
|
selectedYear?: number;
|
||||||
|
selectedMonth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear, selectedMonth }) => {
|
||||||
|
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
|
||||||
|
const [expandedYear, setExpandedYear] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
const data = await window.electronAPI?.posts.getByYearMonth();
|
||||||
|
if (data) {
|
||||||
|
setYearMonthData(data as { year: number; month: number; count: number }[]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Group by year
|
||||||
|
const years = [...new Set(yearMonthData.map(d => d.year))].sort((a, b) => b - a);
|
||||||
|
|
||||||
|
const getYearCount = (year: number) => {
|
||||||
|
return yearMonthData.filter(d => d.year === year).reduce((sum, d) => sum + d.count, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMonthsForYear = (year: number) => {
|
||||||
|
return yearMonthData.filter(d => d.year === year).sort((a, b) => b.month - a.month);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="calendar-view">
|
||||||
|
<div className="calendar-header">
|
||||||
|
<span>ARCHIVE</span>
|
||||||
|
{(selectedYear || selectedMonth !== undefined) && (
|
||||||
|
<button className="clear-filter" onClick={() => onDateSelect(0)} title="Clear filter">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="calendar-years">
|
||||||
|
{years.map(year => (
|
||||||
|
<div key={year} className="calendar-year">
|
||||||
|
<div
|
||||||
|
className={`calendar-year-header ${selectedYear === year && selectedMonth === undefined ? 'selected' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setExpandedYear(expandedYear === year ? null : year);
|
||||||
|
onDateSelect(year);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="expand-icon">{expandedYear === year ? '▼' : '▶'}</span>
|
||||||
|
<span className="year-label">{year}</span>
|
||||||
|
<span className="year-count">{getYearCount(year)}</span>
|
||||||
|
</div>
|
||||||
|
{expandedYear === year && (
|
||||||
|
<div className="calendar-months">
|
||||||
|
{getMonthsForYear(year).map(({ month, count }) => (
|
||||||
|
<div
|
||||||
|
key={month}
|
||||||
|
className={`calendar-month ${selectedYear === year && selectedMonth === month ? 'selected' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDateSelect(year, month);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="month-label">{MONTH_NAMES[month]}</span>
|
||||||
|
<span className="month-count">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{years.length === 0 && (
|
||||||
|
<div className="calendar-empty">No posts yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
tags: string[];
|
||||||
|
categories: string[];
|
||||||
|
selectedTags: string[];
|
||||||
|
selectedCategories: string[];
|
||||||
|
onTagSelect: (tags: string[]) => void;
|
||||||
|
onCategorySelect: (categories: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||||
|
tags,
|
||||||
|
categories,
|
||||||
|
selectedTags,
|
||||||
|
selectedCategories,
|
||||||
|
onTagSelect,
|
||||||
|
onCategorySelect,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="filter-panel">
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="filter-section">
|
||||||
|
<div className="filter-header">TAGS</div>
|
||||||
|
<div className="filter-chips">
|
||||||
|
{tags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedTags.includes(tag)) {
|
||||||
|
onTagSelect(selectedTags.filter(t => t !== tag));
|
||||||
|
} else {
|
||||||
|
onTagSelect([...selectedTags, tag]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<div className="filter-section">
|
||||||
|
<div className="filter-header">CATEGORIES</div>
|
||||||
|
<div className="filter-chips">
|
||||||
|
{categories.map(cat => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
className={`filter-chip ${selectedCategories.includes(cat) ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedCategories.includes(cat)) {
|
||||||
|
onCategorySelect(selectedCategories.filter(c => c !== cat));
|
||||||
|
} else {
|
||||||
|
onCategorySelect([...selectedCategories, cat]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SearchBoxProps {
|
||||||
|
onSearch: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSearch(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="search-box" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search posts..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" title="Search">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M15.7 14.3l-4.2-4.2c-.2-.2-.5-.3-.8-.3.9-1.1 1.5-2.5 1.5-4C12.2 2.6 9.6 0 6.4 0S.6 2.6.6 5.8s2.6 5.8 5.8 5.8c1.5 0 2.9-.5 4-1.4 0 .3.1.6.3.8l4.2 4.2c.2.2.5.3.7.3s.5-.1.7-.3c.4-.4.4-1 0-1.4zm-9.3-4c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{query && (
|
||||||
|
<button type="button" className="clear-search" onClick={() => { setQuery(''); onSearch(''); }} title="Clear">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const PostsList: React.FC = () => {
|
const PostsList: React.FC = () => {
|
||||||
const { posts, selectedPostId, setSelectedPost } = useAppStore();
|
const { posts, selectedPostId, setSelectedPost } = useAppStore();
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<PostData[] | null>(null);
|
||||||
|
const [selectedYear, setSelectedYear] = useState<number | undefined>();
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<number | undefined>();
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
|
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||||
|
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [filteredPosts, setFilteredPosts] = useState<PostData[] | null>(null);
|
||||||
|
|
||||||
|
// Load available tags and categories
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFilters = async () => {
|
||||||
|
const [tags, categories] = await Promise.all([
|
||||||
|
window.electronAPI?.posts.getTags(),
|
||||||
|
window.electronAPI?.posts.getCategories(),
|
||||||
|
]);
|
||||||
|
if (tags) setAvailableTags(tags as string[]);
|
||||||
|
if (categories) setAvailableCategories(categories as string[]);
|
||||||
|
};
|
||||||
|
loadFilters();
|
||||||
|
}, [posts]);
|
||||||
|
|
||||||
|
// Handle search
|
||||||
|
const handleSearch = async (query: string) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const results = await window.electronAPI?.posts.search(query);
|
||||||
|
if (results) {
|
||||||
|
// Map search results to PostData (search returns SearchResult with score)
|
||||||
|
const postIds = (results as { id: string }[]).map(r => r.id);
|
||||||
|
setSearchResults(posts.filter(p => postIds.includes(p.id)));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
showToast.error('Search failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle date selection
|
||||||
|
const handleDateSelect = async (year: number, month?: number) => {
|
||||||
|
if (year === 0) {
|
||||||
|
// Clear filter
|
||||||
|
setSelectedYear(undefined);
|
||||||
|
setSelectedMonth(undefined);
|
||||||
|
setFilteredPosts(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedYear(year);
|
||||||
|
setSelectedMonth(month);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await window.electronAPI?.posts.filter({
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
|
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
|
||||||
|
});
|
||||||
|
if (results) {
|
||||||
|
setFilteredPosts(results as PostData[]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Filter failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle tag/category filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
const applyFilters = async () => {
|
||||||
|
if (!selectedYear && selectedTags.length === 0 && selectedCategories.length === 0) {
|
||||||
|
setFilteredPosts(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await window.electronAPI?.posts.filter({
|
||||||
|
year: selectedYear,
|
||||||
|
month: selectedMonth,
|
||||||
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
|
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
|
||||||
|
});
|
||||||
|
if (results) {
|
||||||
|
setFilteredPosts(results as PostData[]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Filter failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
applyFilters();
|
||||||
|
}, [selectedTags, selectedCategories]);
|
||||||
|
|
||||||
const handleCreatePost = async () => {
|
const handleCreatePost = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -24,16 +308,33 @@ const PostsList: React.FC = () => {
|
|||||||
});
|
});
|
||||||
if (newPost) {
|
if (newPost) {
|
||||||
setSelectedPost((newPost as PostData).id);
|
setSelectedPost((newPost as PostData).id);
|
||||||
|
showToast.success('Post created');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create post:', error);
|
console.error('Failed to create post:', error);
|
||||||
|
showToast.error('Failed to create post');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Determine which posts to display
|
||||||
|
const displayPosts = searchResults ?? filteredPosts ?? posts;
|
||||||
|
const isFiltered = searchResults !== null || filteredPosts !== null;
|
||||||
|
const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0;
|
||||||
|
|
||||||
const groupedPosts = {
|
const groupedPosts = {
|
||||||
draft: posts.filter(p => p.status === 'draft'),
|
draft: displayPosts.filter(p => p.status === 'draft'),
|
||||||
published: posts.filter(p => p.status === 'published'),
|
published: displayPosts.filter(p => p.status === 'published'),
|
||||||
archived: posts.filter(p => p.status === 'archived'),
|
archived: displayPosts.filter(p => p.status === 'archived'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllFilters = () => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSearchResults(null);
|
||||||
|
setSelectedYear(undefined);
|
||||||
|
setSelectedMonth(undefined);
|
||||||
|
setSelectedTags([]);
|
||||||
|
setSelectedCategories([]);
|
||||||
|
setFilteredPosts(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,14 +342,57 @@ const PostsList: React.FC = () => {
|
|||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section-header">
|
<div className="sidebar-section-header">
|
||||||
<span>POSTS</span>
|
<span>POSTS</span>
|
||||||
<button className="sidebar-action" onClick={handleCreatePost} title="New Post">
|
<div className="sidebar-actions">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<button
|
||||||
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
className={`sidebar-action ${showFilters ? 'active' : ''}`}
|
||||||
</svg>
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
</button>
|
title="Toggle Filters"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="sidebar-action" onClick={handleCreatePost} title="New Post">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SearchBox onSearch={handleSearch} />
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<>
|
||||||
|
<CalendarView
|
||||||
|
onDateSelect={handleDateSelect}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
/>
|
||||||
|
<FilterPanel
|
||||||
|
tags={availableTags}
|
||||||
|
categories={availableCategories}
|
||||||
|
selectedTags={selectedTags}
|
||||||
|
selectedCategories={selectedCategories}
|
||||||
|
onTagSelect={setSelectedTags}
|
||||||
|
onCategorySelect={setSelectedCategories}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="filter-status">
|
||||||
|
<span>
|
||||||
|
{displayPosts.length} result{displayPosts.length !== 1 ? 's' : ''}
|
||||||
|
{searchQuery && ` for "${searchQuery}"`}
|
||||||
|
</span>
|
||||||
|
<button onClick={clearAllFilters} title="Clear all filters">
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{groupedPosts.draft.length > 0 && (
|
{groupedPosts.draft.length > 0 && (
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section-title">
|
<div className="sidebar-section-title">
|
||||||
@@ -112,12 +456,19 @@ const PostsList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{posts.length === 0 && (
|
{displayPosts.length === 0 && !isFiltered && (
|
||||||
<div className="sidebar-empty">
|
<div className="sidebar-empty">
|
||||||
<p>No posts yet</p>
|
<p>No posts yet</p>
|
||||||
<button onClick={handleCreatePost}>Create your first post</button>
|
<button onClick={handleCreatePost}>Create your first post</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{displayPosts.length === 0 && isFiltered && (
|
||||||
|
<div className="sidebar-empty">
|
||||||
|
<p>No matching posts</p>
|
||||||
|
<button onClick={clearAllFilters}>Clear filters</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -280,6 +631,7 @@ export const Sidebar: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
|
<ProjectSelector />
|
||||||
{activeView === 'posts' && <PostsList />}
|
{activeView === 'posts' && <PostsList />}
|
||||||
{activeView === 'media' && <MediaList />}
|
{activeView === 'media' && <MediaList />}
|
||||||
{activeView === 'settings' && <SettingsPanel />}
|
{activeView === 'settings' && <SettingsPanel />}
|
||||||
|
|||||||
14
src/renderer/components/Toast/Toast.css
Normal file
14
src/renderer/components/Toast/Toast.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.toast-container {
|
||||||
|
z-index: 9999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom styling for toast animations */
|
||||||
|
:root {
|
||||||
|
--toast-enter-duration: 200ms;
|
||||||
|
--toast-exit-duration: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override for VS Code dark theme compatibility */
|
||||||
|
[data-sonner-toast] {
|
||||||
|
font-family: var(--vscode-font-family, 'Segoe UI', sans-serif);
|
||||||
|
}
|
||||||
88
src/renderer/components/Toast/Toast.tsx
Normal file
88
src/renderer/components/Toast/Toast.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Toaster, toast } from 'react-hot-toast';
|
||||||
|
import './Toast.css';
|
||||||
|
|
||||||
|
// Re-export toast for use throughout the app
|
||||||
|
export { toast };
|
||||||
|
|
||||||
|
// Toast types
|
||||||
|
export type ToastType = 'success' | 'error' | 'loading' | 'info';
|
||||||
|
|
||||||
|
// Custom toast functions
|
||||||
|
export const showToast = {
|
||||||
|
success: (message: string) => toast.success(message, {
|
||||||
|
duration: 4000,
|
||||||
|
position: 'bottom-right',
|
||||||
|
}),
|
||||||
|
|
||||||
|
error: (message: string) => toast.error(message, {
|
||||||
|
duration: 6000,
|
||||||
|
position: 'bottom-right',
|
||||||
|
}),
|
||||||
|
|
||||||
|
info: (message: string) => toast(message, {
|
||||||
|
duration: 4000,
|
||||||
|
position: 'bottom-right',
|
||||||
|
icon: 'ℹ️',
|
||||||
|
}),
|
||||||
|
|
||||||
|
loading: (message: string) => toast.loading(message, {
|
||||||
|
position: 'bottom-right',
|
||||||
|
}),
|
||||||
|
|
||||||
|
promise: <T,>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
msgs: { loading: string; success: string; error: string }
|
||||||
|
) => toast.promise(promise, msgs, {
|
||||||
|
position: 'bottom-right',
|
||||||
|
}),
|
||||||
|
|
||||||
|
dismiss: (toastId?: string) => {
|
||||||
|
if (toastId) {
|
||||||
|
toast.dismiss(toastId);
|
||||||
|
} else {
|
||||||
|
toast.dismiss();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toast container component
|
||||||
|
export const ToastContainer: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
reverseOrder={false}
|
||||||
|
gutter={8}
|
||||||
|
containerClassName="toast-container"
|
||||||
|
toastOptions={{
|
||||||
|
// Default options for all toasts
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: 'var(--vscode-notifications-background, #252526)',
|
||||||
|
color: 'var(--vscode-notifications-foreground, #cccccc)',
|
||||||
|
border: '1px solid var(--vscode-notifications-border, #3c3c3c)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontSize: '13px',
|
||||||
|
maxWidth: '400px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||||
|
},
|
||||||
|
// Type-specific styling
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: 'var(--vscode-testing-iconPassed, #89d185)',
|
||||||
|
secondary: 'var(--vscode-notifications-background, #252526)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: 'var(--vscode-testing-iconFailed, #f14c4c)',
|
||||||
|
secondary: 'var(--vscode-notifications-background, #252526)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToastContainer;
|
||||||
1
src/renderer/components/Toast/index.ts
Normal file
1
src/renderer/components/Toast/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
||||||
@@ -3,3 +3,5 @@ export { Sidebar } from './Sidebar';
|
|||||||
export { Editor } from './Editor';
|
export { Editor } from './Editor';
|
||||||
export { StatusBar } from './StatusBar';
|
export { StatusBar } from './StatusBar';
|
||||||
export { Panel } from './Panel';
|
export { Panel } from './Panel';
|
||||||
|
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
|
||||||
|
export { ProjectSelector } from './ProjectSelector';
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
export interface ProjectData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PostData {
|
export interface PostData {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -43,6 +53,10 @@ export interface TaskProgress {
|
|||||||
|
|
||||||
// App State Store
|
// App State Store
|
||||||
interface AppState {
|
interface AppState {
|
||||||
|
// Projects
|
||||||
|
projects: ProjectData[];
|
||||||
|
activeProject: ProjectData | null;
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
activeView: 'posts' | 'media' | 'settings';
|
activeView: 'posts' | 'media' | 'settings';
|
||||||
sidebarVisible: boolean;
|
sidebarVisible: boolean;
|
||||||
@@ -64,6 +78,13 @@ interface AppState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
|
// Project Actions
|
||||||
|
setProjects: (projects: ProjectData[]) => void;
|
||||||
|
setActiveProject: (project: ProjectData | null) => void;
|
||||||
|
addProject: (project: ProjectData) => void;
|
||||||
|
updateProject: (id: string, project: Partial<ProjectData>) => void;
|
||||||
|
removeProject: (id: string) => void;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
|
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
@@ -93,6 +114,10 @@ interface AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAppStore = create<AppState>((set) => ({
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
|
// Initial Project State
|
||||||
|
projects: [],
|
||||||
|
activeProject: null,
|
||||||
|
|
||||||
// Initial UI State
|
// Initial UI State
|
||||||
activeView: 'posts',
|
activeView: 'posts',
|
||||||
sidebarVisible: true,
|
sidebarVisible: true,
|
||||||
@@ -114,6 +139,17 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
|
// Project Actions
|
||||||
|
setProjects: (projects) => set({ projects }),
|
||||||
|
setActiveProject: (activeProject) => set({ activeProject }),
|
||||||
|
addProject: (project) => set((state) => ({ projects: [...state.projects, project] })),
|
||||||
|
updateProject: (id, updatedProject) => set((state) => ({
|
||||||
|
projects: state.projects.map((p) => (p.id === id ? { ...p, ...updatedProject } : p)),
|
||||||
|
})),
|
||||||
|
removeProject: (id) => set((state) => ({
|
||||||
|
projects: state.projects.filter((p) => p.id !== id),
|
||||||
|
})),
|
||||||
|
|
||||||
// UI Actions
|
// UI Actions
|
||||||
setActiveView: (view) => set({ activeView: view }),
|
setActiveView: (view) => set({ activeView: view }),
|
||||||
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
|
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { useAppStore, type PostData, type MediaData, type TaskProgress } from './appStore';
|
export { useAppStore, type ProjectData, type PostData, type MediaData, type TaskProgress } from './appStore';
|
||||||
|
|||||||
44
src/renderer/types/electron.d.ts
vendored
44
src/renderer/types/electron.d.ts
vendored
@@ -1,7 +1,18 @@
|
|||||||
// Type definitions for the Electron API exposed via preload
|
// Type definitions for the Electron API exposed via preload
|
||||||
|
|
||||||
|
export interface ProjectData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PostData {
|
export interface PostData {
|
||||||
id: string;
|
id: string;
|
||||||
|
projectId: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
@@ -15,8 +26,27 @@ export interface PostData {
|
|||||||
categories: string[];
|
categories: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PostFilter {
|
||||||
|
status?: 'draft' | 'published' | 'archived';
|
||||||
|
tags?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
year?: number;
|
||||||
|
month?: number;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
excerpt?: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MediaData {
|
export interface MediaData {
|
||||||
id: string;
|
id: string;
|
||||||
|
projectId: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
originalName: string;
|
originalName: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
@@ -56,6 +86,15 @@ export interface SyncResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
|
projects: {
|
||||||
|
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
|
||||||
|
update: (id: string, data: Partial<ProjectData>) => Promise<ProjectData | null>;
|
||||||
|
delete: (id: string) => Promise<boolean>;
|
||||||
|
get: (id: string) => Promise<ProjectData | null>;
|
||||||
|
getAll: () => Promise<ProjectData[]>;
|
||||||
|
getActive: () => Promise<ProjectData | null>;
|
||||||
|
setActive: (id: string) => Promise<ProjectData | null>;
|
||||||
|
};
|
||||||
posts: {
|
posts: {
|
||||||
create: (data: Partial<PostData>) => Promise<PostData>;
|
create: (data: Partial<PostData>) => Promise<PostData>;
|
||||||
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
|
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
|
||||||
@@ -66,6 +105,11 @@ export interface ElectronAPI {
|
|||||||
publish: (id: string) => Promise<PostData | null>;
|
publish: (id: string) => Promise<PostData | null>;
|
||||||
unpublish: (id: string) => Promise<PostData | null>;
|
unpublish: (id: string) => Promise<PostData | null>;
|
||||||
rebuildFromFiles: () => Promise<void>;
|
rebuildFromFiles: () => Promise<void>;
|
||||||
|
search: (query: string) => Promise<SearchResult[]>;
|
||||||
|
filter: (filter: PostFilter) => Promise<PostData[]>;
|
||||||
|
getTags: () => Promise<string[]>;
|
||||||
|
getCategories: () => Promise<string[]>;
|
||||||
|
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||||
};
|
};
|
||||||
media: {
|
media: {
|
||||||
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
|
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
|
||||||
|
|||||||
Reference in New Issue
Block a user