diff --git a/lib/folk-feed.ts b/lib/folk-feed.ts index 52eb2bc..36dca8c 100644 --- a/lib/folk-feed.ts +++ b/lib/folk-feed.ts @@ -174,6 +174,12 @@ export class FolkFeed extends FolkShape { this.#feedData = data.nodes.slice(0, this.maxItems); } else if (data.flows) { this.#feedData = data.flows.slice(0, this.maxItems); + } else if (data.threads) { + this.#feedData = data.threads.slice(0, this.maxItems); + } else if (data.campaigns) { + this.#feedData = data.campaigns.slice(0, this.maxItems); + } else if (data.drafts) { + this.#feedData = data.drafts.slice(0, this.maxItems); } else { // Try to use the data as-is if it has array-like fields const firstArray = Object.values(data).find(v => Array.isArray(v)); @@ -238,6 +244,12 @@ export class FolkFeed extends FolkShape { itinerary: "trips", default: "trips", }, + socials: { + threads: "threads", + campaigns: "campaigns", + newsletter: "newsletter/drafts", + default: "threads", + }, }; const moduleEndpoints = FEED_ENDPOINTS[this.sourceModule]; diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts index 27cc74a..8debb31 100644 --- a/lib/folk-rapp.ts +++ b/lib/folk-rapp.ts @@ -451,6 +451,10 @@ const MODULE_PORTS: Record = { rwallet: [{ name: "balance-out", type: "number", direction: "output" }, { name: "transfer-trigger", type: "trigger", direction: "input" }, { name: "transfer-data", type: "json", direction: "input" }], + rsocials: [{ name: "threads-out", type: "json", direction: "output" }, + { name: "campaigns-out", type: "json", direction: "output" }, + { name: "post-published", type: "trigger", direction: "output" }, + { name: "campaign-data", type: "json", direction: "output" }], }; const DEFAULT_PORTS: PortDescriptor[] = [ @@ -552,6 +556,20 @@ const WIDGET_API: Record Widge }; }, }, + rsocials: { + path: "/api/threads", + transform: (data) => { + const threads = data?.threads || []; + const campaigns = data?.campaignCount ?? 0; + return { + stat: `${threads.length} thread${threads.length !== 1 ? "s" : ""}`, + rows: threads.slice(0, 3).map((t: any) => ({ + label: t.title || "Untitled", + value: `${t.tweets?.length || 0} tweets`, + })), + }; + }, + }, rnetwork: { path: "/api/graph", transform: (data) => { diff --git a/lib/mi-tool-schema.ts b/lib/mi-tool-schema.ts index e2f27de..6fbff87 100644 --- a/lib/mi-tool-schema.ts +++ b/lib/mi-tool-schema.ts @@ -29,6 +29,9 @@ const TOOL_HINTS: ToolHint[] = [ { tagName: "folk-rapp", label: "rMeets", icon: "πŸ“Ή", keywords: ["meeting", "jitsi", "video", "meet", "conference", "rmeets"] }, { tagName: "folk-workflow-block", label: "Workflow", icon: "βš™οΈ", keywords: ["workflow", "automation", "block", "process"] }, { tagName: "folk-social-post", label: "Social Post", icon: "πŸ“£", keywords: ["social", "post", "twitter", "instagram", "campaign"] }, + { tagName: "folk-social-thread", label: "Thread", icon: "🧡", keywords: ["thread", "tweetstorm", "twitter thread", "tweets", "multi-post"] }, + { tagName: "folk-social-campaign", label: "Campaign", icon: "πŸ“’", keywords: ["campaign", "launch", "marketing", "social campaign", "content plan"] }, + { tagName: "folk-social-newsletter", label: "Newsletter", icon: "πŸ“§", keywords: ["newsletter", "email", "mailout", "subscriber", "mailing list"] }, { tagName: "folk-splat", label: "3D Gaussian", icon: "πŸ’Ž", keywords: ["3d", "splat", "gaussian", "point cloud"] }, { tagName: "folk-drawfast", label: "Drawing", icon: "✏️", keywords: ["draw", "sketch", "whiteboard", "pencil"] }, { tagName: "folk-rapp", label: "rApp Embed", icon: "πŸ“¦", keywords: ["rapp", "module", "embed", "app", "crm", "contacts", "pipeline", "companies"] }, diff --git a/lib/mi-triage-panel.ts b/lib/mi-triage-panel.ts index 5113438..5f983ed 100644 --- a/lib/mi-triage-panel.ts +++ b/lib/mi-triage-panel.ts @@ -16,6 +16,9 @@ const SHAPE_ICONS: Record = { "folk-map": { icon: "πŸ—ΊοΈ", label: "Map" }, "folk-workflow-block": { icon: "βš™οΈ", label: "Workflow" }, "folk-social-post": { icon: "πŸ“£", label: "Social Post" }, + "folk-social-thread": { icon: "🧡", label: "Thread" }, + "folk-social-campaign": { icon: "πŸ“’", label: "Campaign" }, + "folk-social-newsletter": { icon: "πŸ“§", label: "Newsletter" }, "folk-choice-vote": { icon: "πŸ—³οΈ", label: "Vote" }, "folk-prompt": { icon: "πŸ€–", label: "AI Chat" }, "folk-image-gen": { icon: "🎨", label: "AI Image" }, diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 350bde6..062dd70 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -37,7 +37,7 @@ import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from '@tiptap/y-tiptap'; import { RSpaceYjsProvider } from '../yjs-ws-provider'; import { CommentMark } from './comment-mark'; import { SuggestionInsertMark, SuggestionDeleteMark } from './suggestion-marks'; -import { createSuggestionPlugin, acceptSuggestion, rejectSuggestion, acceptAllSuggestions, rejectAllSuggestions } from './suggestion-plugin'; +import { createSuggestionPlugin, acceptSuggestion, rejectSuggestion } from './suggestion-plugin'; import './comment-panel'; const lowlight = createLowlight(common); @@ -295,7 +295,16 @@ class FolkNotesApp extends HTMLElement { rightCol.appendChild(this.contentZone); rightCol.appendChild(this.metaZone); + // Sidebar reopen tab (lives on layout, outside navZone so it's visible when collapsed) + const reopenBtn = document.createElement('button'); + reopenBtn.id = 'sidebar-reopen'; + reopenBtn.className = 'sidebar-reopen'; + reopenBtn.title = 'Show sidebar'; + reopenBtn.textContent = '\u203A'; + reopenBtn.addEventListener('click', () => this.toggleSidebar(true)); + layout.appendChild(this.navZone); + layout.appendChild(reopenBtn); layout.appendChild(rightCol); this.shadow.appendChild(style); @@ -2372,25 +2381,12 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF bar.innerHTML = ` ${this.suggestingMode ? 'Suggesting' : 'Editing'} ${ids.size > 0 ? ` - ${ids.size} suggestion${ids.size !== 1 ? 's' : ''} - - + ${ids.size} suggestion${ids.size !== 1 ? 's' : ''} β€” review in sidebar ` : 'Start typing to suggest changes'} `; - // Wire buttons - bar.querySelector('[data-action="accept-all-suggestions"]')?.addEventListener('click', () => { - if (this.editor) { - acceptAllSuggestions(this.editor); - this.updateSuggestionReviewBar(); - } - }); - bar.querySelector('[data-action="reject-all-suggestions"]')?.addEventListener('click', () => { - if (this.editor) { - rejectAllSuggestions(this.editor); - this.updateSuggestionReviewBar(); - } - }); + // Open sidebar to show suggestions when there are any + if (ids.size > 0) this.showCommentPanel(); } /** Show an accept/reject popover near a clicked suggestion mark. */ @@ -2420,6 +2416,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (this.editor) { acceptSuggestion(this.editor, suggestionId); this.updateSuggestionReviewBar(); + this.syncSuggestionsToPanel(); } pop.remove(); }); @@ -2427,6 +2424,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (this.editor) { rejectSuggestion(this.editor, suggestionId); this.updateSuggestionReviewBar(); + this.syncSuggestionsToPanel(); } pop.remove(); }); @@ -2443,6 +2441,47 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF setTimeout(() => this.shadow.addEventListener('click', close), 0); } + /** Collect all pending suggestions from the editor doc. */ + private collectSuggestions(): { id: string; type: 'insert' | 'delete'; text: string; authorId: string; authorName: string; createdAt: number }[] { + if (!this.editor) return []; + const map = new Map(); + this.editor.state.doc.descendants((node: any) => { + if (!node.isText) return; + for (const mark of node.marks) { + if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { + const id = mark.attrs.suggestionId; + const existing = map.get(id); + if (existing) { + existing.text += node.text || ''; + } else { + map.set(id, { + id, + type: mark.type.name === 'suggestionInsert' ? 'insert' : 'delete', + text: node.text || '', + authorId: mark.attrs.authorId || '', + authorName: mark.attrs.authorName || 'Unknown', + createdAt: mark.attrs.createdAt || Date.now(), + }); + } + } + } + }); + return Array.from(map.values()); + } + + /** Push current suggestions to the comment panel and ensure sidebar is visible. */ + private syncSuggestionsToPanel() { + const panel = this.shadow.querySelector('notes-comment-panel') as any; + if (!panel) return; + const suggestions = this.collectSuggestions(); + panel.suggestions = suggestions; + // Show sidebar if there are suggestions or comments + const sidebar = this.shadow.getElementById('comment-sidebar'); + if (sidebar && suggestions.length > 0) { + sidebar.classList.add('has-comments'); + } + } + /** Show comment panel for a specific thread. */ private showCommentPanel(threadId?: string) { const sidebar = this.shadow.getElementById('comment-sidebar'); @@ -2457,6 +2496,21 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const { noteId, threads } = e.detail; if (noteId) this._demoThreads.set(noteId, threads); }); + // Listen for suggestion accept/reject from comment panel + panel.addEventListener('suggestion-accept', (e: CustomEvent) => { + if (this.editor && e.detail?.suggestionId) { + acceptSuggestion(this.editor, e.detail.suggestionId); + this.updateSuggestionReviewBar(); + this.syncSuggestionsToPanel(); + } + }); + panel.addEventListener('suggestion-reject', (e: CustomEvent) => { + if (this.editor && e.detail?.suggestionId) { + rejectSuggestion(this.editor, e.detail.suggestionId); + this.updateSuggestionReviewBar(); + this.syncSuggestionsToPanel(); + } + }); } panel.noteId = this.editorNoteId; panel.doc = this.doc; @@ -2470,8 +2524,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } else { panel.demoThreads = null; } + // Pass suggestions + panel.suggestions = this.collectSuggestions(); - // Show sidebar when there are comments + // Show sidebar when there are comments or suggestions sidebar.classList.add('has-comments'); } @@ -2496,9 +2552,10 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } }); - // On any change, update the suggestion review bar + // On any change, update the suggestion review bar + sidebar panel this.editor.on('update', () => { this.updateSuggestionReviewBar(); + this.syncSuggestionsToPanel(); }); // Direct click on comment highlight or suggestion marks in the DOM @@ -2802,6 +2859,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF

`; + // Apply collapsed state + const layout = this.shadow.getElementById('notes-layout'); + if (layout) layout.classList.toggle('sidebar-collapsed', !this.sidebarOpen); + // Restore search focus if (hadFocus) { const newInput = this.navZone.querySelector('#search-input') as HTMLInputElement; @@ -2934,9 +2996,18 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } } + private toggleSidebar(open?: boolean) { + this.sidebarOpen = open !== undefined ? open : !this.sidebarOpen; + const layout = this.shadow.getElementById('notes-layout'); + if (layout) layout.classList.toggle('sidebar-collapsed', !this.sidebarOpen); + } + private attachSidebarListeners() { const isDemo = this.space === "demo"; + // Sidebar collapse (reopen button is wired once in connectedCallback) + this.shadow.getElementById('sidebar-collapse')?.addEventListener('click', () => this.toggleSidebar(false)); + // Search const searchInput = this.shadow.getElementById("search-input") as HTMLInputElement; let searchTimeout: any; @@ -3091,7 +3162,21 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF grid-template-columns: 260px 1fr; min-height: 400px; height: calc(100vh - 120px); + position: relative; + transition: grid-template-columns 0.2s ease; } + #notes-layout.sidebar-collapsed { + grid-template-columns: 0px 1fr; + } + #notes-layout.sidebar-collapsed .notes-sidebar { + opacity: 0; + pointer-events: none; + } + #notes-layout.sidebar-collapsed .sidebar-reopen { + opacity: 1; + pointer-events: auto; + } + #nav-zone { overflow: hidden; } .notes-sidebar { display: flex; flex-direction: column; @@ -3099,10 +3184,45 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF background: var(--rs-bg-surface); overflow: hidden; height: 100%; + transition: opacity 0.15s ease; } - .sidebar-header { padding: 12px 12px 8px; } + /* Collapse button in sidebar header */ + .sidebar-collapse { + position: absolute; right: 8px; top: 50%; transform: translateY(-50%); + width: 24px; height: 24px; border-radius: 4px; + border: 1px solid var(--rs-border-subtle, #333); + background: var(--rs-bg-surface, #1e1e2e); + color: var(--rs-text-muted, #888); + font-size: 16px; line-height: 1; + cursor: pointer; display: flex; align-items: center; justify-content: center; + transition: color 0.15s, border-color 0.15s, background 0.15s; + } + .sidebar-collapse:hover { + color: var(--rs-text-primary); + border-color: var(--rs-primary, #6366f1); + background: var(--rs-bg-hover, #252538); + } + /* Reopen tab on left edge */ + .sidebar-reopen { + position: absolute; left: 0; top: 50%; transform: translateY(-50%); + width: 20px; height: 48px; z-index: 10; + border: 1px solid var(--rs-border-subtle, #333); + border-left: none; + border-radius: 0 6px 6px 0; + background: var(--rs-bg-surface, #1e1e2e); + color: var(--rs-text-muted, #888); + font-size: 18px; line-height: 1; + cursor: pointer; display: flex; align-items: center; justify-content: center; + opacity: 0; pointer-events: none; + transition: opacity 0.15s ease, color 0.15s, background 0.15s; + } + .sidebar-reopen:hover { + color: var(--rs-text-primary); + background: var(--rs-bg-hover, #252538); + } + .sidebar-header { padding: 12px 12px 8px; position: relative; } .sidebar-search { - width: 100%; padding: 8px 12px; border-radius: 6px; + width: 100%; padding: 8px 36px 8px 12px; border-radius: 6px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 13px; font-family: inherit; transition: border-color 0.15s; @@ -3550,8 +3670,8 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } /* Sidebar fills screen width */ .notes-sidebar { width: 100%; position: static; transform: none; box-shadow: none; } - /* Hide old overlay FAB (no longer needed) */ - .mobile-sidebar-toggle, .sidebar-overlay { display: none !important; } + /* Hide old overlay FAB + desktop collapse on mobile */ + .mobile-sidebar-toggle, .sidebar-overlay, .sidebar-collapse, .sidebar-reopen { display: none !important; } /* Hide empty state on mobile β€” user sees doc list */ .editor-empty-state { display: none; } /* Show back bar */ diff --git a/modules/rsocials/mod.ts b/modules/rsocials/mod.ts index b0926a9..b70ec57 100644 --- a/modules/rsocials/mod.ts +++ b/modules/rsocials/mod.ts @@ -203,6 +203,45 @@ routes.get("/api/feed", (c) => }), ); +// ── API: Threads (read-only, for cross-rApp consumption) ── + +routes.get("/api/threads", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + const threads = Object.values(doc.threads || {}).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + return c.json({ threads, count: threads.length }); +}); + +routes.get("/api/threads/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const thread = getThreadFromDoc(dataSpace, id); + if (!thread) return c.json({ error: "Thread not found" }, 404); + return c.json(thread); +}); + +// ── API: Campaigns (read-only, for cross-rApp consumption) ── + +routes.get("/api/campaigns", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + const campaigns = Object.values(doc.campaigns || {}).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); + return c.json({ campaigns, count: campaigns.length }); +}); + +routes.get("/api/campaigns/:id", (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + const campaign = doc.campaigns?.[id]; + if (!campaign) return c.json({ error: "Campaign not found" }, 404); + return c.json(campaign); +}); + // ── Image API routes (server-side, need filesystem + FAL_KEY) ── routes.post("/api/threads/:id/image", async (c) => { diff --git a/server/mi-routes.ts b/server/mi-routes.ts index 53eb64a..0b11ad2 100644 --- a/server/mi-routes.ts +++ b/server/mi-routes.ts @@ -235,7 +235,8 @@ include action markers in your response. Each marker is on its own line: Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions. Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt, folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block, -folk-social-post, folk-splat, folk-drawfast, folk-rapp, folk-feed. +folk-social-post, folk-social-thread, folk-social-campaign, folk-social-newsletter, +folk-splat, folk-drawfast, folk-rapp, folk-feed. ## Transforms When the user asks to align, distribute, or arrange selected shapes: @@ -319,6 +320,9 @@ analyze it and classify each distinct piece into the most appropriate canvas sha - Locations / addresses / places β†’ folk-map (set query prop) - Action items / TODOs / tasks β†’ folk-workflow-block (set label, blockType:"action" props) - Social media content / posts β†’ folk-social-post (set content prop) +- Tweet threads / multi-post threads β†’ folk-social-thread (set title, tweets props) +- Marketing campaigns / content plans β†’ folk-social-campaign (set title, description, platforms props) +- Newsletters / email campaigns β†’ folk-social-newsletter (set subject, listName props) - Decisions / polls / questions for voting β†’ folk-choice-vote (set question prop) - Everything else (prose, notes, transcripts, summaries) β†’ folk-markdown (set content prop in markdown format) @@ -340,7 +344,8 @@ Return a JSON object with: const KNOWN_TRIAGE_SHAPES = new Set([ "folk-markdown", "folk-embed", "folk-image", "folk-bookmark", "folk-calendar", "folk-map", - "folk-workflow-block", "folk-social-post", "folk-choice-vote", + "folk-workflow-block", "folk-social-post", "folk-social-thread", + "folk-social-campaign", "folk-social-newsletter", "folk-choice-vote", "folk-prompt", "folk-image-gen", "folk-slide", ]);