Merge branch 'dev'
This commit is contained in:
commit
46bcbb87b4
|
|
@ -137,6 +137,8 @@ class FolkNotesApp extends HTMLElement {
|
||||||
private expandedNotebooks = new Set<string>();
|
private expandedNotebooks = new Set<string>();
|
||||||
private notebookNotes = new Map<string, Note[]>();
|
private notebookNotes = new Map<string, Note[]>();
|
||||||
private sidebarOpen = true;
|
private sidebarOpen = true;
|
||||||
|
private mobileEditing = false;
|
||||||
|
private _resizeHandler: (() => void) | null = null;
|
||||||
|
|
||||||
// Zone-based rendering
|
// Zone-based rendering
|
||||||
private navZone!: HTMLDivElement;
|
private navZone!: HTMLDivElement;
|
||||||
|
|
@ -219,6 +221,18 @@ class FolkNotesApp extends HTMLElement {
|
||||||
if (!localStorage.getItem("rnotes_tour_done")) {
|
if (!localStorage.getItem("rnotes_tour_done")) {
|
||||||
setTimeout(() => this._tour.start(), 1200);
|
setTimeout(() => this._tour.start(), 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile resize handler — sync mobile-editing state on viewport change
|
||||||
|
this._resizeHandler = () => {
|
||||||
|
if (window.innerWidth > 768) {
|
||||||
|
// Switched to desktop — remove mobile-editing so both panels show
|
||||||
|
this.setMobileEditing(false);
|
||||||
|
} else if (this.selectedNote && this.editor) {
|
||||||
|
// Went back to mobile with a note open — restore editor screen
|
||||||
|
this.setMobileEditing(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', this._resizeHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async subscribeOfflineRuntime() {
|
private async subscribeOfflineRuntime() {
|
||||||
|
|
@ -285,27 +299,6 @@ class FolkNotesApp extends HTMLElement {
|
||||||
|
|
||||||
this.shadow.appendChild(style);
|
this.shadow.appendChild(style);
|
||||||
this.shadow.appendChild(layout);
|
this.shadow.appendChild(layout);
|
||||||
|
|
||||||
// Mobile sidebar toggle
|
|
||||||
const mobileToggle = document.createElement('button');
|
|
||||||
mobileToggle.className = 'mobile-sidebar-toggle';
|
|
||||||
mobileToggle.innerHTML = '<span class="mobile-toggle-icon">\u{1F4C4}</span><span class="mobile-toggle-label">Docs</span>';
|
|
||||||
mobileToggle.addEventListener('click', () => {
|
|
||||||
this.sidebarOpen = !this.sidebarOpen;
|
|
||||||
this.navZone.querySelector('.notes-sidebar')?.classList.toggle('open', this.sidebarOpen);
|
|
||||||
this.shadow.querySelector('.sidebar-overlay')?.classList.toggle('open', this.sidebarOpen);
|
|
||||||
});
|
|
||||||
this.shadow.appendChild(mobileToggle);
|
|
||||||
|
|
||||||
// Mobile overlay
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'sidebar-overlay';
|
|
||||||
overlay.addEventListener('click', () => {
|
|
||||||
this.sidebarOpen = false;
|
|
||||||
this.navZone.querySelector('.notes-sidebar')?.classList.remove('open');
|
|
||||||
overlay.classList.remove('open');
|
|
||||||
});
|
|
||||||
this.shadow.appendChild(overlay);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Demo data ──
|
// ── Demo data ──
|
||||||
|
|
@ -577,9 +570,33 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
this.mountEditor(newNote);
|
this.mountEditor(newNote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mobile stack navigation ──
|
||||||
|
|
||||||
|
private setMobileEditing(editing: boolean) {
|
||||||
|
this.mobileEditing = editing;
|
||||||
|
this.shadow.getElementById('notes-layout')?.classList.toggle('mobile-editing', editing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mobileGoBack() {
|
||||||
|
this.setMobileEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mobileBackBarHtml(): string {
|
||||||
|
const title = this.selectedNotebook?.title || 'Notes';
|
||||||
|
return `<div class="mobile-back-bar"><button class="mobile-back-btn">← ${this.esc(title)}</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMobile(): boolean {
|
||||||
|
return window.innerWidth <= 768;
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
this.destroyEditor();
|
this.destroyEditor();
|
||||||
this.cleanupPresence();
|
this.cleanupPresence();
|
||||||
|
if (this._resizeHandler) {
|
||||||
|
window.removeEventListener('resize', this._resizeHandler);
|
||||||
|
this._resizeHandler = null;
|
||||||
|
}
|
||||||
this._offlineUnsub?.();
|
this._offlineUnsub?.();
|
||||||
this._offlineUnsub = null;
|
this._offlineUnsub = null;
|
||||||
for (const unsub of this._offlineNotebookUnsubs) unsub();
|
for (const unsub of this._offlineNotebookUnsubs) unsub();
|
||||||
|
|
@ -1051,11 +1068,9 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
private async openNote(noteId: string, notebookId: string) {
|
private async openNote(noteId: string, notebookId: string) {
|
||||||
const isDemo = this.space === "demo";
|
const isDemo = this.space === "demo";
|
||||||
|
|
||||||
// Auto-close sidebar on mobile
|
// Mobile: slide to editor screen
|
||||||
if (window.innerWidth <= 768) {
|
if (this.isMobile()) {
|
||||||
this.sidebarOpen = false;
|
this.setMobileEditing(true);
|
||||||
this.navZone.querySelector('.notes-sidebar')?.classList.remove('open');
|
|
||||||
this.shadow.querySelector('.sidebar-overlay')?.classList.remove('open');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand notebook if not expanded
|
// Expand notebook if not expanded
|
||||||
|
|
@ -1210,6 +1225,11 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
case 'AUDIO': this.mountAudioView(note, isEditable, isDemo); break;
|
case 'AUDIO': this.mountAudioView(note, isEditable, isDemo); break;
|
||||||
default: this.mountTiptapEditor(note, isEditable, isDemo); break;
|
default: this.mountTiptapEditor(note, isEditable, isDemo); break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile: inject back bar and slide to editor
|
||||||
|
this.contentZone.insertAdjacentHTML('afterbegin', this.mobileBackBarHtml());
|
||||||
|
this.contentZone.querySelector('.mobile-back-btn')?.addEventListener('click', () => this.mobileGoBack());
|
||||||
|
if (this.isMobile()) this.setMobileEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) {
|
private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) {
|
||||||
|
|
@ -3229,22 +3249,9 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
.editor-empty-state svg { width: 48px; height: 48px; opacity: 0.4; }
|
.editor-empty-state svg { width: 48px; height: 48px; opacity: 0.4; }
|
||||||
.editor-empty-state p { font-size: 14px; }
|
.editor-empty-state p { font-size: 14px; }
|
||||||
|
|
||||||
/* Mobile sidebar */
|
/* Mobile sidebar (legacy — hidden, replaced by stack nav) */
|
||||||
.mobile-sidebar-toggle {
|
.mobile-sidebar-toggle { display: none; }
|
||||||
display: none; position: fixed; bottom: 20px; left: 20px; z-index: 198;
|
.sidebar-overlay { display: none; }
|
||||||
min-width: 44px; height: 44px; border-radius: 22px; border: none;
|
|
||||||
padding: 0 14px; gap: 4px;
|
|
||||||
background: var(--rs-primary); color: #fff; font-size: 13px;
|
|
||||||
cursor: pointer; box-shadow: var(--rs-shadow-md);
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.mobile-toggle-icon { font-size: 18px; }
|
|
||||||
.mobile-toggle-label { font-weight: 600; font-family: inherit; }
|
|
||||||
.sidebar-overlay {
|
|
||||||
display: none; position: fixed; inset: 0;
|
|
||||||
background: rgba(0,0,0,0.4); z-index: 199;
|
|
||||||
}
|
|
||||||
.sidebar-overlay.open { display: block; }
|
|
||||||
|
|
||||||
/* ── Navigation ── */
|
/* ── Navigation ── */
|
||||||
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; }
|
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: var(--rs-primary); color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; transition: background 0.15s; display: flex; align-items: center; gap: 4px; }
|
||||||
|
|
@ -3521,15 +3528,42 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
.tiptap-container .tiptap s { color: var(--rs-text-muted); }
|
.tiptap-container .tiptap s { color: var(--rs-text-muted); }
|
||||||
.tiptap-container .tiptap u { text-underline-offset: 3px; }
|
.tiptap-container .tiptap u { text-underline-offset: 3px; }
|
||||||
|
|
||||||
|
/* ── Mobile back bar (hidden on desktop) ── */
|
||||||
|
.mobile-back-bar { display: none; }
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#notes-layout { grid-template-columns: 1fr; }
|
/* Two-screen horizontal stack: nav (100%) + editor (100%) side-by-side */
|
||||||
.notes-sidebar {
|
#notes-layout {
|
||||||
position: fixed; left: 0; top: 0; bottom: 0; width: 280px;
|
display: flex; overflow: hidden;
|
||||||
z-index: 200; transform: translateX(-100%);
|
grid-template-columns: unset;
|
||||||
transition: transform 0.25s ease; box-shadow: var(--rs-shadow-lg);
|
|
||||||
}
|
}
|
||||||
.notes-sidebar.open { transform: translateX(0); }
|
#nav-zone, .notes-right-col {
|
||||||
.mobile-sidebar-toggle { display: flex !important; }
|
flex: 0 0 100%; min-width: 0;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
/* Slide both panels left when editing */
|
||||||
|
#notes-layout.mobile-editing > #nav-zone,
|
||||||
|
#notes-layout.mobile-editing > .notes-right-col {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
/* 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 empty state on mobile — user sees doc list */
|
||||||
|
.editor-empty-state { display: none; }
|
||||||
|
/* Show back bar */
|
||||||
|
.mobile-back-bar {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 8px 12px; border-bottom: 1px solid var(--rs-border-subtle);
|
||||||
|
background: var(--rs-bg-surface);
|
||||||
|
}
|
||||||
|
.mobile-back-btn {
|
||||||
|
background: none; border: none; color: var(--rs-primary);
|
||||||
|
font-size: 15px; font-weight: 600; cursor: pointer;
|
||||||
|
padding: 4px 8px; border-radius: 6px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.mobile-back-btn:hover { background: var(--rs-bg-surface-raised); }
|
||||||
|
/* Tighten editor padding */
|
||||||
.editor-wrapper .editable-title { padding: 12px 14px 0; }
|
.editor-wrapper .editable-title { padding: 12px 14px 0; }
|
||||||
.tiptap-container .tiptap { padding: 14px 16px; }
|
.tiptap-container .tiptap { padding: 14px 16px; }
|
||||||
.sidebar-footer-btn { min-height: 36px; padding: 7px 12px; }
|
.sidebar-footer-btn { min-height: 36px; padding: 7px 12px; }
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import type { RSpaceModule } from "../../shared/module";
|
||||||
import type { SyncServer } from "../../server/local-first/sync-server";
|
import type { SyncServer } from "../../server/local-first/sync-server";
|
||||||
import { renderLanding } from "./landing";
|
import { renderLanding } from "./landing";
|
||||||
import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data";
|
import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data";
|
||||||
import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard } from "./schemas";
|
import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard, type NewsletterDraft } from "./schemas";
|
||||||
import {
|
import {
|
||||||
generateImageFromPrompt,
|
generateImageFromPrompt,
|
||||||
downloadAndSaveImage,
|
downloadAndSaveImage,
|
||||||
|
|
@ -58,9 +58,18 @@ function ensureDoc(space: string): SocialsDoc {
|
||||||
d.campaignWorkflows = {};
|
d.campaignWorkflows = {};
|
||||||
d.pendingApprovals = {};
|
d.pendingApprovals = {};
|
||||||
d.campaignWizards = {};
|
d.campaignWizards = {};
|
||||||
|
d.newsletterDrafts = {};
|
||||||
});
|
});
|
||||||
_syncServer!.setDoc(docId, doc);
|
_syncServer!.setDoc(docId, doc);
|
||||||
}
|
}
|
||||||
|
// Migrate existing docs missing newsletterDrafts
|
||||||
|
if (!doc.newsletterDrafts) {
|
||||||
|
const docId = socialsDocId(space);
|
||||||
|
_syncServer!.changeDoc<SocialsDoc>(docId, "add newsletterDrafts", (d) => {
|
||||||
|
d.newsletterDrafts = {} as any;
|
||||||
|
});
|
||||||
|
doc = _syncServer!.getDoc<SocialsDoc>(docId)!;
|
||||||
|
}
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -563,6 +572,112 @@ routes.delete("/api/newsletter/campaigns/:id", async (c) => {
|
||||||
return c.json(data, res.status as any);
|
return c.json(data, res.status as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Newsletter Drafts (Automerge-backed) ──
|
||||||
|
|
||||||
|
routes.get("/api/newsletter/drafts", async (c) => {
|
||||||
|
const auth = await requireNewsletterRole(c, "moderator");
|
||||||
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
||||||
|
const doc = ensureDoc(dataSpace);
|
||||||
|
const drafts = Object.values(doc.newsletterDrafts || {}).sort((a, b) => b.updatedAt - a.updatedAt);
|
||||||
|
return c.json({ drafts });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/newsletter/drafts", async (c) => {
|
||||||
|
const auth = await requireNewsletterRole(c, "admin");
|
||||||
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
||||||
|
const body = await c.req.json<{ title?: string; subject?: string; body?: string }>();
|
||||||
|
if (!body.title || !body.subject) return c.json({ error: "title and subject required" }, 400);
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = Date.now();
|
||||||
|
const draft: NewsletterDraft = {
|
||||||
|
id,
|
||||||
|
title: body.title,
|
||||||
|
subject: body.subject,
|
||||||
|
body: body.body || '',
|
||||||
|
status: 'draft',
|
||||||
|
subscribers: [],
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdBy: auth.claims.did as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const docId = socialsDocId(dataSpace);
|
||||||
|
_syncServer!.changeDoc<SocialsDoc>(docId, `create newsletter draft ${id}`, (d) => {
|
||||||
|
if (!d.newsletterDrafts) d.newsletterDrafts = {} as any;
|
||||||
|
d.newsletterDrafts[id] = draft;
|
||||||
|
});
|
||||||
|
return c.json({ draft }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/api/newsletter/drafts/:draftId", async (c) => {
|
||||||
|
const auth = await requireNewsletterRole(c, "moderator");
|
||||||
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
||||||
|
const draftId = c.req.param("draftId");
|
||||||
|
const doc = ensureDoc(dataSpace);
|
||||||
|
const draft = doc.newsletterDrafts?.[draftId];
|
||||||
|
if (!draft) return c.json({ error: "Draft not found" }, 404);
|
||||||
|
return c.json({ draft });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put("/api/newsletter/drafts/:draftId", async (c) => {
|
||||||
|
const auth = await requireNewsletterRole(c, "admin");
|
||||||
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
||||||
|
const draftId = c.req.param("draftId");
|
||||||
|
const doc = ensureDoc(dataSpace);
|
||||||
|
if (!doc.newsletterDrafts?.[draftId]) return c.json({ error: "Draft not found" }, 404);
|
||||||
|
|
||||||
|
const body = await c.req.json<{ title?: string; subject?: string; body?: string; status?: string; subscribers?: { email: string; name?: string }[] }>();
|
||||||
|
const docId = socialsDocId(dataSpace);
|
||||||
|
_syncServer!.changeDoc<SocialsDoc>(docId, `update newsletter draft ${draftId}`, (d) => {
|
||||||
|
const draft = d.newsletterDrafts[draftId];
|
||||||
|
if (body.title !== undefined) draft.title = body.title;
|
||||||
|
if (body.subject !== undefined) draft.subject = body.subject;
|
||||||
|
if (body.body !== undefined) draft.body = body.body;
|
||||||
|
if (body.status === 'draft' || body.status === 'ready' || body.status === 'sent') draft.status = body.status;
|
||||||
|
if (body.subscribers) {
|
||||||
|
draft.subscribers = body.subscribers.map(s => ({
|
||||||
|
email: s.email,
|
||||||
|
name: s.name || '',
|
||||||
|
addedAt: Date.now(),
|
||||||
|
})) as any;
|
||||||
|
}
|
||||||
|
draft.updatedAt = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
|
||||||
|
return c.json({ draft: updated.newsletterDrafts[draftId] });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.delete("/api/newsletter/drafts/:draftId", async (c) => {
|
||||||
|
const auth = await requireNewsletterRole(c, "admin");
|
||||||
|
if (auth instanceof Response) return auth;
|
||||||
|
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
||||||
|
const draftId = c.req.param("draftId");
|
||||||
|
const doc = ensureDoc(dataSpace);
|
||||||
|
if (!doc.newsletterDrafts?.[draftId]) return c.json({ error: "Draft not found" }, 404);
|
||||||
|
|
||||||
|
const docId = socialsDocId(dataSpace);
|
||||||
|
_syncServer!.changeDoc<SocialsDoc>(docId, `delete newsletter draft ${draftId}`, (d) => {
|
||||||
|
delete d.newsletterDrafts[draftId];
|
||||||
|
});
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Newsletter Approval Bridge ──
|
// ── Newsletter Approval Bridge ──
|
||||||
|
|
||||||
routes.post("/api/newsletter/campaigns/:id/submit-approval", async (c) => {
|
routes.post("/api/newsletter/campaigns/:id/submit-approval", async (c) => {
|
||||||
|
|
@ -1987,7 +2102,7 @@ routes.get("/scheduler", (c) => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
routes.get("/newsletter-list", async (c) => {
|
routes.get("/newsletter", async (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
|
||||||
// Resolve caller role for UI gating (viewer fallback for unauthenticated)
|
// Resolve caller role for UI gating (viewer fallback for unauthenticated)
|
||||||
|
|
@ -2013,6 +2128,15 @@ routes.get("/newsletter-list", async (c) => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Legacy redirect
|
||||||
|
routes.get("/newsletter-list", (c) => {
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const host = c.req.header("host")?.split(":")[0] || "";
|
||||||
|
const isSubdomain = (host.endsWith(".rspace.online") && host !== "rspace.online" && !host.startsWith("www.")) || host.endsWith(".rsocials.online");
|
||||||
|
const base = isSubdomain ? "/rsocials" : `/${space}/rsocials`;
|
||||||
|
return c.redirect(`${base}/newsletter`, 301);
|
||||||
|
});
|
||||||
|
|
||||||
routes.get("/feed", (c) => {
|
routes.get("/feed", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const dataSpace = c.get("effectiveSpace") || space;
|
const dataSpace = c.get("effectiveSpace") || space;
|
||||||
|
|
@ -2081,13 +2205,6 @@ routes.get("/", (c) => {
|
||||||
<p>Plan and manage multi-platform social media campaigns</p>
|
<p>Plan and manage multi-platform social media campaigns</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="${base}/campaign-wizard">
|
|
||||||
<span class="nav-icon">🧙</span>
|
|
||||||
<div class="nav-body">
|
|
||||||
<h3>Campaign Wizard</h3>
|
|
||||||
<p>AI-guided step-by-step campaign creation with progressive approval</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<a href="${base}/threads">
|
<a href="${base}/threads">
|
||||||
<span class="nav-icon">🧵</span>
|
<span class="nav-icon">🧵</span>
|
||||||
<div class="nav-body">
|
<div class="nav-body">
|
||||||
|
|
@ -2095,11 +2212,11 @@ routes.get("/", (c) => {
|
||||||
<p>Browse draft posts, compose threads, and schedule content with live preview</p>
|
<p>Browse draft posts, compose threads, and schedule content with live preview</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="${base}/newsletter-list">
|
<a href="${base}/newsletter">
|
||||||
<span class="nav-icon">📧</span>
|
<span class="nav-icon">📧</span>
|
||||||
<div class="nav-body">
|
<div class="nav-body">
|
||||||
<h3>Newsletter List</h3>
|
<h3>Newsletter</h3>
|
||||||
<p>Manage newsletter subscribers and send campaigns via Listmonk</p>
|
<p>Draft newsletters, manage subscribers, and send campaigns</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -2160,18 +2277,6 @@ export const socialsModule: RSpaceModule = {
|
||||||
{ icon: "👥", title: "Team Workflow", text: "Draft, review, and approve posts collaboratively before publishing." },
|
{ icon: "👥", title: "Team Workflow", text: "Draft, review, and approve posts collaboratively before publishing." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "campaign-wizard",
|
|
||||||
title: "Campaign Wizard",
|
|
||||||
icon: "\uD83E\uDDD9",
|
|
||||||
tagline: "rSocials Tool",
|
|
||||||
description: "AI-guided step-by-step campaign creation. Paste a brief, review the proposed structure, approve generated content, and activate everything at once.",
|
|
||||||
features: [
|
|
||||||
{ icon: "\uD83E\uDD16", title: "AI Analysis", text: "AI extracts audience, platforms, tone, and key messages from your raw brief." },
|
|
||||||
{ icon: "\uD83D\uDCCB", title: "Progressive Approval", text: "Review and approve each step: structure, content, then full review before committing." },
|
|
||||||
{ icon: "\u26A1", title: "One-Click Activate", text: "Commits campaign, creates threads, drafts newsletters, and builds workflow DAG simultaneously." },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "threads",
|
path: "threads",
|
||||||
title: "Posts & Threads",
|
title: "Posts & Threads",
|
||||||
|
|
@ -2185,15 +2290,15 @@ export const socialsModule: RSpaceModule = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "newsletter-list",
|
path: "newsletter",
|
||||||
title: "Newsletter List",
|
title: "Newsletter",
|
||||||
icon: "📧",
|
icon: "📧",
|
||||||
tagline: "rSocials Tool",
|
tagline: "rSocials Tool",
|
||||||
description: "Manage newsletter subscribers and send email campaigns via the embedded Listmonk interface.",
|
description: "Draft newsletters collaboratively, manage subscriber lists, and send email campaigns. Works standalone or with Listmonk integration.",
|
||||||
features: [
|
features: [
|
||||||
{ icon: "👥", title: "Subscriber Management", text: "View, import, and manage newsletter subscribers across multiple mailing lists." },
|
{ icon: "✍️", title: "Draft Editor", text: "Compose newsletter drafts with HTML editor, live preview, and collaborative editing." },
|
||||||
{ icon: "📨", title: "Email Campaigns", text: "Compose and send email campaigns with templates and scheduling." },
|
{ icon: "👥", title: "Subscriber Management", text: "Build and manage subscriber lists directly, or import from Listmonk." },
|
||||||
{ icon: "📊", title: "Analytics", text: "Track open rates, click-throughs, and subscriber engagement." },
|
{ icon: "📨", title: "Send Campaigns", text: "Send newsletters via Listmonk when configured, or manage drafts independently." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,7 @@ export interface SocialsDoc {
|
||||||
campaignWorkflows: Record<string, CampaignWorkflow>;
|
campaignWorkflows: Record<string, CampaignWorkflow>;
|
||||||
pendingApprovals: Record<string, PendingApproval>;
|
pendingApprovals: Record<string, PendingApproval>;
|
||||||
campaignWizards: Record<string, CampaignWizard>;
|
campaignWizards: Record<string, CampaignWizard>;
|
||||||
|
newsletterDrafts: Record<string, NewsletterDraft>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Schema registration ──
|
// ── Schema registration ──
|
||||||
|
|
@ -454,12 +455,12 @@ export interface SocialsDoc {
|
||||||
export const socialsSchema: DocSchema<SocialsDoc> = {
|
export const socialsSchema: DocSchema<SocialsDoc> = {
|
||||||
module: 'socials',
|
module: 'socials',
|
||||||
collection: 'data',
|
collection: 'data',
|
||||||
version: 6,
|
version: 7,
|
||||||
init: (): SocialsDoc => ({
|
init: (): SocialsDoc => ({
|
||||||
meta: {
|
meta: {
|
||||||
module: 'socials',
|
module: 'socials',
|
||||||
collection: 'data',
|
collection: 'data',
|
||||||
version: 6,
|
version: 7,
|
||||||
spaceSlug: '',
|
spaceSlug: '',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
|
|
@ -470,6 +471,7 @@ export const socialsSchema: DocSchema<SocialsDoc> = {
|
||||||
campaignWorkflows: {},
|
campaignWorkflows: {},
|
||||||
pendingApprovals: {},
|
pendingApprovals: {},
|
||||||
campaignWizards: {},
|
campaignWizards: {},
|
||||||
|
newsletterDrafts: {},
|
||||||
}),
|
}),
|
||||||
migrate: (doc: SocialsDoc, _fromVersion: number): SocialsDoc => {
|
migrate: (doc: SocialsDoc, _fromVersion: number): SocialsDoc => {
|
||||||
if (!doc.campaignFlows) (doc as any).campaignFlows = {};
|
if (!doc.campaignFlows) (doc as any).campaignFlows = {};
|
||||||
|
|
@ -477,7 +479,8 @@ export const socialsSchema: DocSchema<SocialsDoc> = {
|
||||||
if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {};
|
if (!doc.campaignWorkflows) (doc as any).campaignWorkflows = {};
|
||||||
if (!doc.pendingApprovals) (doc as any).pendingApprovals = {};
|
if (!doc.pendingApprovals) (doc as any).pendingApprovals = {};
|
||||||
if (!doc.campaignWizards) (doc as any).campaignWizards = {};
|
if (!doc.campaignWizards) (doc as any).campaignWizards = {};
|
||||||
if (doc.meta) doc.meta.version = 6;
|
if (!doc.newsletterDrafts) (doc as any).newsletterDrafts = {};
|
||||||
|
if (doc.meta) doc.meta.version = 7;
|
||||||
return doc;
|
return doc;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,97 @@ mi.post("/execute-server-action", async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── POST /suggestions — dynamic data-driven suggestions ──
|
||||||
|
|
||||||
|
mi.post("/suggestions", async (c) => {
|
||||||
|
const { space, module: currentModule } = await c.req.json();
|
||||||
|
const suggestions: { label: string; icon: string; prompt: string; autoSend?: boolean }[] = [];
|
||||||
|
|
||||||
|
if (!space) return c.json({ suggestions });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check upcoming events
|
||||||
|
const upcoming = getUpcomingEventsForMI(space, 1, 3);
|
||||||
|
if (upcoming.length > 0) {
|
||||||
|
const next = upcoming[0];
|
||||||
|
const startMs = Date.parse(next.start);
|
||||||
|
const hoursUntil = Math.round((startMs - Date.now()) / 3600000);
|
||||||
|
if (hoursUntil > 0 && hoursUntil <= 24) {
|
||||||
|
const timeLabel = hoursUntil === 1 ? "1 hour" : `${hoursUntil} hours`;
|
||||||
|
suggestions.push({
|
||||||
|
label: `${next.title} in ${timeLabel}`,
|
||||||
|
icon: "⏰",
|
||||||
|
prompt: `Tell me about the upcoming event "${next.title}"`,
|
||||||
|
autoSend: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check open tasks
|
||||||
|
const tasks = getRecentTasksForMI(space, 10);
|
||||||
|
const openTasks = tasks.filter((t) => t.status !== "DONE");
|
||||||
|
if (openTasks.length > 0) {
|
||||||
|
suggestions.push({
|
||||||
|
label: `${openTasks.length} open task${openTasks.length > 1 ? "s" : ""}`,
|
||||||
|
icon: "📋",
|
||||||
|
prompt: "Show my open tasks",
|
||||||
|
autoSend: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if current module has zero content — "get started" suggestion
|
||||||
|
if (currentModule === "rnotes") {
|
||||||
|
const notes = getRecentNotesForMI(space, 1);
|
||||||
|
if (notes.length === 0) {
|
||||||
|
suggestions.push({
|
||||||
|
label: "Create your first note",
|
||||||
|
icon: "📝",
|
||||||
|
prompt: "Help me create my first notebook",
|
||||||
|
autoSend: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (currentModule === "rtasks") {
|
||||||
|
const t = getRecentTasksForMI(space, 1);
|
||||||
|
if (t.length === 0) {
|
||||||
|
suggestions.push({
|
||||||
|
label: "Create your first task",
|
||||||
|
icon: "✅",
|
||||||
|
prompt: "Help me create my first task board",
|
||||||
|
autoSend: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (currentModule === "rcal") {
|
||||||
|
const ev = getUpcomingEventsForMI(space, 30, 1);
|
||||||
|
if (ev.length === 0) {
|
||||||
|
suggestions.push({
|
||||||
|
label: "Add your first event",
|
||||||
|
icon: "📅",
|
||||||
|
prompt: "Help me create my first calendar event",
|
||||||
|
autoSend: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent note to continue editing
|
||||||
|
if (currentModule === "rnotes") {
|
||||||
|
const recent = getRecentNotesForMI(space, 1);
|
||||||
|
if (recent.length > 0) {
|
||||||
|
suggestions.push({
|
||||||
|
label: `Continue "${recent[0].title}"`,
|
||||||
|
icon: "📝",
|
||||||
|
prompt: `Help me continue working on "${recent[0].title}"`,
|
||||||
|
autoSend: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("[mi/suggestions]", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max 3 dynamic suggestions
|
||||||
|
return c.json({ suggestions: suggestions.slice(0, 3) });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Fallback response (when AI is unavailable) ──
|
// ── Fallback response (when AI is unavailable) ──
|
||||||
|
|
||||||
function generateFallbackResponse(
|
function generateFallbackResponse(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import type { MiAction } from "../../lib/mi-actions";
|
||||||
import { MiActionExecutor } from "../../lib/mi-action-executor";
|
import { MiActionExecutor } from "../../lib/mi-action-executor";
|
||||||
import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema";
|
import { suggestTools, type ToolHint } from "../../lib/mi-tool-schema";
|
||||||
import { SpeechDictation } from "../../lib/speech-dictation";
|
import { SpeechDictation } from "../../lib/speech-dictation";
|
||||||
|
import { getContextSuggestions } from "../../lib/mi-suggestions";
|
||||||
|
import type { MiSuggestion } from "../../lib/mi-suggestions";
|
||||||
|
|
||||||
interface MiMessage {
|
interface MiMessage {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
|
|
@ -39,6 +41,10 @@ export class RStackMi extends HTMLElement {
|
||||||
#availableModels: MiModelConfig[] = [];
|
#availableModels: MiModelConfig[] = [];
|
||||||
#pendingConfirm: { actions: MiAction[]; resolve: (ok: boolean) => void } | null = null;
|
#pendingConfirm: { actions: MiAction[]; resolve: (ok: boolean) => void } | null = null;
|
||||||
#scaffoldProgress: { current: number; total: number; label: string } | null = null;
|
#scaffoldProgress: { current: number; total: number; label: string } | null = null;
|
||||||
|
#suggestions: MiSuggestion[] = [];
|
||||||
|
#dynamicSuggestions: MiSuggestion[] = [];
|
||||||
|
#placeholderIdx = 0;
|
||||||
|
#placeholderTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -54,6 +60,7 @@ export class RStackMi extends HTMLElement {
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
document.removeEventListener("keydown", this.#keyHandler);
|
document.removeEventListener("keydown", this.#keyHandler);
|
||||||
|
if (this.#placeholderTimer) clearInterval(this.#placeholderTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
#keyHandler = (e: KeyboardEvent) => {
|
#keyHandler = (e: KeyboardEvent) => {
|
||||||
|
|
@ -73,6 +80,7 @@ export class RStackMi extends HTMLElement {
|
||||||
panel?.classList.add("open");
|
panel?.classList.add("open");
|
||||||
bar?.classList.add("focused");
|
bar?.classList.add("focused");
|
||||||
}
|
}
|
||||||
|
this.#loadSuggestions();
|
||||||
const input = this.#shadow.getElementById("mi-input") as HTMLTextAreaElement | null;
|
const input = this.#shadow.getElementById("mi-input") as HTMLTextAreaElement | null;
|
||||||
input?.focus();
|
input?.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -148,10 +156,11 @@ export class RStackMi extends HTMLElement {
|
||||||
<button class="mi-panel-btn" id="mi-close" title="Close">×</button>
|
<button class="mi-panel-btn" id="mi-close" title="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mi-messages" id="mi-messages">
|
<div class="mi-messages" id="mi-messages">
|
||||||
<div class="mi-welcome">
|
<div class="mi-welcome" id="mi-welcome">
|
||||||
<span class="mi-welcome-icon">✧</span>
|
<span class="mi-welcome-icon">✧</span>
|
||||||
<p>Hi, I'm <strong>mi</strong> — your mycelial intelligence guide.</p>
|
<p>Hi, I'm <strong>mi</strong> — your mycelial intelligence guide.</p>
|
||||||
<p class="mi-welcome-sub">I can create content across all rApps, set up spaces, connect knowledge, and help you build.</p>
|
<p class="mi-welcome-sub">I can create content across all rApps, set up spaces, connect knowledge, and help you build.</p>
|
||||||
|
<div class="mi-suggestions" id="mi-suggestions"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mi-confirm" id="mi-confirm" style="display:none;">
|
<div class="mi-confirm" id="mi-confirm" style="display:none;">
|
||||||
|
|
@ -189,6 +198,7 @@ export class RStackMi extends HTMLElement {
|
||||||
barInput.addEventListener("focus", () => {
|
barInput.addEventListener("focus", () => {
|
||||||
panel.classList.add("open");
|
panel.classList.add("open");
|
||||||
bar.classList.add("focused");
|
bar.classList.add("focused");
|
||||||
|
this.#loadSuggestions();
|
||||||
// Transfer any text to the panel input
|
// Transfer any text to the panel input
|
||||||
if (barInput.value.trim()) {
|
if (barInput.value.trim()) {
|
||||||
input.value = barInput.value;
|
input.value = barInput.value;
|
||||||
|
|
@ -266,6 +276,7 @@ export class RStackMi extends HTMLElement {
|
||||||
panel.classList.add("open");
|
panel.classList.add("open");
|
||||||
panel.classList.remove("hidden");
|
panel.classList.remove("hidden");
|
||||||
bar.classList.add("focused");
|
bar.classList.add("focused");
|
||||||
|
this.#loadSuggestions();
|
||||||
input.focus();
|
input.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -320,6 +331,113 @@ export class RStackMi extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#loadSuggestions() {
|
||||||
|
const ctx = this.#gatherContext();
|
||||||
|
this.#suggestions = getContextSuggestions({
|
||||||
|
module: ctx.module || "",
|
||||||
|
hasShapes: !!(ctx.openShapes?.length),
|
||||||
|
hasSelectedShapes: !!(ctx.selectedShapes?.length),
|
||||||
|
});
|
||||||
|
this.#renderSuggestions();
|
||||||
|
this.#rotatePlaceholder();
|
||||||
|
|
||||||
|
// Fetch dynamic suggestions (non-blocking)
|
||||||
|
this.#fetchDynamicSuggestions(ctx.space, ctx.module);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #fetchDynamicSuggestions(space: string, module: string) {
|
||||||
|
if (!space) return;
|
||||||
|
try {
|
||||||
|
const token = getAccessToken();
|
||||||
|
const res = await fetch("/api/mi/suggestions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ space, module }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
this.#dynamicSuggestions = data.suggestions || [];
|
||||||
|
this.#renderSuggestions();
|
||||||
|
}
|
||||||
|
} catch { /* offline — skip dynamic suggestions */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderSuggestions() {
|
||||||
|
const container = this.#shadow.getElementById("mi-suggestions");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Don't show suggestions once a conversation has started
|
||||||
|
if (this.#messages.length > 0) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSuggestions = [...this.#suggestions];
|
||||||
|
const hasDynamic = this.#dynamicSuggestions.length > 0;
|
||||||
|
|
||||||
|
let html = allSuggestions
|
||||||
|
.map((s) => `<button class="mi-suggest-chip" data-prompt="${this.#escapeAttr(s.prompt)}" data-autosend="${s.autoSend !== false}">${s.icon} ${this.#escapeHtml(s.label)}</button>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
if (hasDynamic) {
|
||||||
|
html += `<div class="mi-suggest-section">For you</div>`;
|
||||||
|
html += this.#dynamicSuggestions
|
||||||
|
.map((s) => `<button class="mi-suggest-chip dynamic" data-prompt="${this.#escapeAttr(s.prompt)}" data-autosend="${s.autoSend !== false}">${s.icon} ${this.#escapeHtml(s.label)}</button>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Wire chip clicks
|
||||||
|
container.querySelectorAll<HTMLButtonElement>(".mi-suggest-chip").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const prompt = btn.dataset.prompt || "";
|
||||||
|
const autoSend = btn.dataset.autosend === "true";
|
||||||
|
if (autoSend) {
|
||||||
|
this.#ask(prompt);
|
||||||
|
} else {
|
||||||
|
const input = this.#shadow.getElementById("mi-input") as HTMLTextAreaElement | null;
|
||||||
|
if (input) {
|
||||||
|
input.value = prompt;
|
||||||
|
input.focus();
|
||||||
|
this.#autoResize(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#escapeAttr(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotatePlaceholder() {
|
||||||
|
const barInput = this.#shadow.getElementById("mi-bar-input") as HTMLInputElement | null;
|
||||||
|
if (!barInput) return;
|
||||||
|
|
||||||
|
const allSuggestions = [...this.#suggestions, ...this.#dynamicSuggestions];
|
||||||
|
if (allSuggestions.length === 0) return;
|
||||||
|
|
||||||
|
// Set initial placeholder from a random suggestion
|
||||||
|
this.#placeholderIdx = Math.floor(Math.random() * allSuggestions.length);
|
||||||
|
const hint = allSuggestions[this.#placeholderIdx];
|
||||||
|
barInput.placeholder = `Ask mi anything... try "${hint.label}"`;
|
||||||
|
|
||||||
|
// Rotate every 8 seconds
|
||||||
|
if (this.#placeholderTimer) clearInterval(this.#placeholderTimer);
|
||||||
|
this.#placeholderTimer = setInterval(() => {
|
||||||
|
const current = [...this.#suggestions, ...this.#dynamicSuggestions];
|
||||||
|
if (current.length === 0) return;
|
||||||
|
this.#placeholderIdx = (this.#placeholderIdx + 1) % current.length;
|
||||||
|
const s = current[this.#placeholderIdx];
|
||||||
|
barInput.placeholder = `Ask mi anything... try "${s.label}"`;
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
#minimize() {
|
#minimize() {
|
||||||
this.#minimized = true;
|
this.#minimized = true;
|
||||||
const panel = this.#shadow.getElementById("mi-panel")!;
|
const panel = this.#shadow.getElementById("mi-panel")!;
|
||||||
|
|
@ -804,6 +922,35 @@ const STYLES = `
|
||||||
.mi-welcome p { font-size: 0.9rem; line-height: 1.5; margin: 0; color: var(--rs-text-primary); }
|
.mi-welcome p { font-size: 0.9rem; line-height: 1.5; margin: 0; color: var(--rs-text-primary); }
|
||||||
.mi-welcome-sub { font-size: 0.8rem; opacity: 0.6; margin-top: 6px !important; }
|
.mi-welcome-sub { font-size: 0.8rem; opacity: 0.6; margin-top: 6px !important; }
|
||||||
|
|
||||||
|
/* ── Suggestion chips ── */
|
||||||
|
.mi-suggestions {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px; padding: 12px 0 4px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.mi-suggest-chip {
|
||||||
|
padding: 6px 12px; border-radius: 16px; border: 1px solid var(--rs-border);
|
||||||
|
font-size: 0.78rem; cursor: pointer; transition: all 0.15s;
|
||||||
|
font-family: inherit; white-space: nowrap;
|
||||||
|
background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary);
|
||||||
|
}
|
||||||
|
.mi-suggest-chip:hover {
|
||||||
|
background: var(--rs-bg-hover);
|
||||||
|
border-color: rgba(6,182,212,0.4);
|
||||||
|
box-shadow: 0 0 0 1px rgba(6,182,212,0.15);
|
||||||
|
}
|
||||||
|
.mi-suggest-chip.dynamic {
|
||||||
|
border-color: rgba(124,58,237,0.3);
|
||||||
|
}
|
||||||
|
.mi-suggest-chip.dynamic:hover {
|
||||||
|
border-color: rgba(124,58,237,0.5);
|
||||||
|
box-shadow: 0 0 0 1px rgba(124,58,237,0.15);
|
||||||
|
}
|
||||||
|
.mi-suggest-section {
|
||||||
|
width: 100%; font-size: 0.65rem; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: var(--rs-text-muted); margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.mi-msg { display: flex; flex-direction: column; gap: 4px; }
|
.mi-msg { display: flex; flex-direction: column; gap: 4px; }
|
||||||
.mi-msg-who { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
.mi-msg-who { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
.mi-msg--user .mi-msg-who { color: #06b6d4; }
|
.mi-msg--user .mi-msg-who { color: #06b6d4; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue