diff --git a/apps/desktop/src/main/handlers/shareHandlers.ts b/apps/desktop/src/main/handlers/shareHandlers.ts index a681f89b..c58f01b6 100644 --- a/apps/desktop/src/main/handlers/shareHandlers.ts +++ b/apps/desktop/src/main/handlers/shareHandlers.ts @@ -20,7 +20,15 @@ export function registerShareHandlers(deps: ShareHandlerDependencies): void { 'share:create', async ( _event, - input: { noteId: string; title: string; content: string } + input: { + noteId: string; + title: string; + content: string; + tags?: string[]; + backlinks?: Array<{ noteId: string; title: string }>; + wordCount?: number; + notebookName?: string; + } ): Promise<{ success: boolean; url?: string; slug?: string; error?: string }> => { try { const result = await apiClient.shareNote(input); diff --git a/apps/desktop/src/main/services/apiClient.ts b/apps/desktop/src/main/services/apiClient.ts index 109b416f..70251063 100644 --- a/apps/desktop/src/main/services/apiClient.ts +++ b/apps/desktop/src/main/services/apiClient.ts @@ -548,6 +548,10 @@ export class ApiClient { noteId: string; title: string; content: string; + tags?: string[]; + backlinks?: Array<{ noteId: string; title: string }>; + wordCount?: number; + notebookName?: string; }): Promise<{ slug: string; url: string }> { return this.request<{ slug: string; url: string }>('/share', { method: 'POST', diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index e89f0d9f..c04daf72 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -631,6 +631,10 @@ export interface ReadiedAPI { noteId: string; title: string; content: string; + tags?: string[]; + backlinks?: Array<{ noteId: string; title: string }>; + wordCount?: number; + notebookName?: string; }) => Promise<{ success: boolean; url?: string; slug?: string; error?: string }>; /** Remove a shared note */ delete: (slug: string) => Promise<{ success: boolean; error?: string }>; diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index f5f0b273..a95f89b2 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -8,6 +8,7 @@ import { useScrollSync } from '../hooks/useScrollSync'; import { useManualTags } from '../hooks/useManualTags'; import { useEmbedResolver } from '../hooks/useEmbedResolver'; import { useBacklinks } from '../hooks/useLinks'; +import { useNotebook } from '../hooks/useNotebooks'; import type { MarkdownEditorHandle } from './MarkdownEditor'; import type { MarkdownPreviewHandle, ToolbarVisibility } from './editor'; import { ImageLightbox } from './ImageLightbox'; @@ -107,6 +108,7 @@ export function NoteEditor({ const [revisionHistoryOpen, setRevisionHistoryOpen] = useState(false); const { data: backlinks } = useBacklinks(note?.id ?? null); const backlinksCount = backlinks?.length ?? 0; + const { data: notebook } = useNotebook(note?.notebookId ?? null); // Lightbox state for embedded images const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null); @@ -168,13 +170,17 @@ export function NoteEditor({ noteId: note.id, title: note.title, content: note.content, + tags: note.tags, + wordCount: note.wordCount, + notebookName: notebook?.name ?? '', + backlinks: (backlinks ?? []).map(bl => ({ noteId: bl.noteId, title: bl.noteTitle })), }); if (result.success) { showToast('Link copied to clipboard'); } else { showToast(result.error || 'Failed to share note', 'error'); } - }, [note, showToast]); + }, [note, notebook, backlinks, showToast]); // Handle title change const handleTitleChange = useCallback( diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts index b76fbc79..2f3c6436 100644 --- a/packages/api/src/db/schema.ts +++ b/packages/api/src/db/schema.ts @@ -245,6 +245,10 @@ export const sharedNotes = sqliteTable( slug: text('slug').notNull().unique(), title: text('title').notNull().default(''), content: text('content').notNull().default(''), + tags: text('tags').notNull().default('[]'), + backlinks: text('backlinks').notNull().default('[]'), + wordCount: integer('word_count').notNull().default(0), + notebookName: text('notebook_name').notNull().default(''), isPublic: integer('is_public', { mode: 'boolean' }).notNull().default(true), createdAt: text('created_at') .notNull() @@ -253,7 +257,10 @@ export const sharedNotes = sqliteTable( .notNull() .$defaultFn(() => new Date().toISOString()), }, - table => [uniqueIndex('shared_notes_user_note_unique').on(table.userId, table.noteId)] + table => [ + uniqueIndex('shared_notes_user_note_unique').on(table.userId, table.noteId), + index('idx_shared_notes_user_public').on(table.userId, table.isPublic), + ] ); /** diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 7e85a817..fb0a5eac 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -32,6 +32,16 @@ const app = new Hono<{ Bindings: Env }>(); app.use('*', logger()); app.use('*', prettyJSON()); app.use('*', secureHeaders()); +// Public share endpoints: permissive CORS (any origin, no credentials) +app.use( + '/share/public/*', + cors({ + origin: '*', + allowMethods: ['GET', 'OPTIONS'], + maxAge: 86400, + }) +); +// All other endpoints: restricted CORS with credentials app.use( '*', cors({ diff --git a/packages/api/src/routes/share.ts b/packages/api/src/routes/share.ts index 9a7d2fe3..377ea6bf 100644 --- a/packages/api/src/routes/share.ts +++ b/packages/api/src/routes/share.ts @@ -2,15 +2,16 @@ * Share Routes * * Public sharing of notes via unique slugs. - * - POST / — Create or update a shared note (authenticated) - * - GET /:slug — Get a shared note (public) - * - DELETE /:slug — Remove a shared note (authenticated, owner only) + * - POST / — Create or update a shared note (authenticated) + * - GET /public/:userId — List public notes for a user (public, paginated) + * - GET /:slug — Get a shared note (public) + * - DELETE /:slug — Remove a shared note (authenticated, owner only) */ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; -import { eq, and } from 'drizzle-orm'; +import { eq, and, desc } from 'drizzle-orm'; import { createDb, type Env } from '../db/client.js'; import { sharedNotes } from '../db/schema.js'; import { authMiddleware, type AuthUser } from '../middleware/auth.js'; @@ -26,13 +27,22 @@ const createShareSchema = z.object({ noteId: z.string().min(1), title: z.string().default(''), content: z.string().default(''), + tags: z.array(z.string()).default([]), + backlinks: z.array(z.object({ noteId: z.string(), title: z.string() })).default([]), + wordCount: z.number().int().min(0).default(0), + notebookName: z.string().default(''), +}); + +const listQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), }); // ─── POST / — Create or update shared note (upsert) ───────────────────────── share.post('/', authMiddleware, zValidator('json', createShareSchema), async c => { const { userId } = c.get('user'); - const { noteId, title, content } = c.req.valid('json'); + const { noteId, title, content, tags, backlinks, wordCount, notebookName } = c.req.valid('json'); const db = createDb(c.env); // Generate slug: first 8 hex chars of a UUID @@ -48,6 +58,10 @@ share.post('/', authMiddleware, zValidator('json', createShareSchema), async c = slug, title, content, + tags: JSON.stringify(tags), + backlinks: JSON.stringify(backlinks), + wordCount, + notebookName, createdAt: now, updatedAt: now, }) @@ -56,6 +70,10 @@ share.post('/', authMiddleware, zValidator('json', createShareSchema), async c = set: { title, content, + tags: JSON.stringify(tags), + backlinks: JSON.stringify(backlinks), + wordCount, + notebookName, updatedAt: now, }, }) @@ -67,6 +85,37 @@ share.post('/', authMiddleware, zValidator('json', createShareSchema), async c = return c.json({ slug: result.slug, url }); }); +// ─── GET /public/:userId — List public notes (no auth) ────────────────────── + +share.get('/public/:userId', zValidator('query', listQuerySchema), async c => { + const userId = c.req.param('userId'); + const { limit, offset } = c.req.valid('query'); + const db = createDb(c.env); + + const notes = await db + .select({ + slug: sharedNotes.slug, + title: sharedNotes.title, + tags: sharedNotes.tags, + wordCount: sharedNotes.wordCount, + notebookName: sharedNotes.notebookName, + createdAt: sharedNotes.createdAt, + updatedAt: sharedNotes.updatedAt, + }) + .from(sharedNotes) + .where(and(eq(sharedNotes.userId, userId), eq(sharedNotes.isPublic, true))) + .orderBy(desc(sharedNotes.updatedAt)) + .limit(limit) + .offset(offset); + + const parsed = notes.map(n => ({ + ...n, + tags: JSON.parse(n.tags ?? '[]'), + })); + + return c.json({ notes: parsed, limit, offset }); +}); + // ─── GET /:slug — Get shared note (public, no auth) ───────────────────────── share.get('/:slug', async c => { @@ -77,6 +126,10 @@ share.get('/:slug', async c => { .select({ title: sharedNotes.title, content: sharedNotes.content, + tags: sharedNotes.tags, + backlinks: sharedNotes.backlinks, + wordCount: sharedNotes.wordCount, + notebookName: sharedNotes.notebookName, createdAt: sharedNotes.createdAt, updatedAt: sharedNotes.updatedAt, }) @@ -88,7 +141,11 @@ share.get('/:slug', async c => { return c.json({ error: 'Not found' }, 404); } - return c.json(note); + return c.json({ + ...note, + tags: JSON.parse(note.tags ?? '[]'), + backlinks: JSON.parse(note.backlinks ?? '[]'), + }); }); // ─── DELETE /:slug — Remove shared note (owner only) ─────────────────────────