Merge branch 'dev'
This commit is contained in:
commit
e78b49d74f
|
|
@ -262,6 +262,26 @@ services:
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
# ── KiCad MCP sidecar (PCB design via SSE) ──
|
||||||
|
kicad-mcp:
|
||||||
|
build: ./docker/kicad-mcp
|
||||||
|
container_name: kicad-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- rspace-files:/data/files
|
||||||
|
networks:
|
||||||
|
- rspace-internal
|
||||||
|
|
||||||
|
# ── FreeCAD MCP sidecar (3D CAD via SSE) ──
|
||||||
|
freecad-mcp:
|
||||||
|
build: ./docker/freecad-mcp
|
||||||
|
container_name: freecad-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- rspace-files:/data/files
|
||||||
|
networks:
|
||||||
|
- rspace-internal
|
||||||
|
|
||||||
# ── Scribus noVNC (rDesign DTP workspace) ──
|
# ── Scribus noVNC (rDesign DTP workspace) ──
|
||||||
scribus-novnc:
|
scribus-novnc:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# Install FreeCAD headless (freecad-cmd) and dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
freecad \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set headless Qt/FreeCAD env
|
||||||
|
ENV QT_QPA_PLATFORM=offscreen
|
||||||
|
ENV DISPLAY=""
|
||||||
|
ENV FREECAD_USER_CONFIG=/tmp/.FreeCAD
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy MCP server source
|
||||||
|
COPY freecad-mcp-server/ .
|
||||||
|
|
||||||
|
# Install Node deps + supergateway (stdio→SSE bridge)
|
||||||
|
RUN npm install && npm install -g supergateway
|
||||||
|
|
||||||
|
# Ensure generated files dir exists
|
||||||
|
RUN mkdir -p /data/files/generated
|
||||||
|
|
||||||
|
EXPOSE 8808
|
||||||
|
|
||||||
|
CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808"]
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
# Install KiCad, Python, and build dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
kicad \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
python3-venv \
|
||||||
|
ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Use SWIG backend (headless — no KiCad GUI needed)
|
||||||
|
ENV KICAD_BACKEND=swig
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy MCP server source
|
||||||
|
COPY KiCAD-MCP-Server/ .
|
||||||
|
|
||||||
|
# Install Node deps + supergateway (stdio→SSE bridge)
|
||||||
|
RUN npm install && npm install -g supergateway
|
||||||
|
|
||||||
|
# Install Python requirements (Pillow, cairosvg, etc.)
|
||||||
|
RUN pip3 install --break-system-packages -r python/requirements.txt
|
||||||
|
|
||||||
|
# Ensure generated files dir exists
|
||||||
|
RUN mkdir -p /data/files/generated
|
||||||
|
|
||||||
|
EXPOSE 8809
|
||||||
|
|
||||||
|
CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809"]
|
||||||
|
|
@ -42,6 +42,7 @@ const MODULE_META: Record<string, { badge: string; color: string; name: string;
|
||||||
rmeets: { badge: "rMe", color: "#6ee7b7", name: "rMeets", icon: "📹" },
|
rmeets: { badge: "rMe", color: "#6ee7b7", name: "rMeets", icon: "📹" },
|
||||||
rschedule: { badge: "rSc", color: "#93c5fd", name: "rSchedule", icon: "⏰" },
|
rschedule: { badge: "rSc", color: "#93c5fd", name: "rSchedule", icon: "⏰" },
|
||||||
rsocials: { badge: "rSo", color: "#f9a8d4", name: "rSocials", icon: "📱" },
|
rsocials: { badge: "rSo", color: "#f9a8d4", name: "rSocials", icon: "📱" },
|
||||||
|
rdesign: { badge: "rDe", color: "#7c3aed", name: "rDesign", icon: "🎨" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = css`
|
const styles = css`
|
||||||
|
|
@ -576,6 +577,14 @@ interface WidgetData {
|
||||||
export class FolkRApp extends FolkShape {
|
export class FolkRApp extends FolkShape {
|
||||||
static override tagName = "folk-rapp";
|
static override tagName = "folk-rapp";
|
||||||
|
|
||||||
|
/** Enabled module IDs for picker/switcher filtering (null = all enabled) */
|
||||||
|
static enabledModuleIds: Set<string> | null = null;
|
||||||
|
|
||||||
|
/** Update which modules appear in the picker and switcher dropdowns */
|
||||||
|
static setEnabledModules(ids: string[] | null) {
|
||||||
|
FolkRApp.enabledModuleIds = ids ? new Set(ids) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Port descriptors for data pipe integration (AC#3) */
|
/** Port descriptors for data pipe integration (AC#3) */
|
||||||
static override portDescriptors: PortDescriptor[] = [
|
static override portDescriptors: PortDescriptor[] = [
|
||||||
{ name: "data-in", type: "json", direction: "input" },
|
{ name: "data-in", type: "json", direction: "input" },
|
||||||
|
|
@ -809,7 +818,11 @@ export class FolkRApp extends FolkShape {
|
||||||
}
|
}
|
||||||
|
|
||||||
#buildSwitcher(switcherEl: HTMLElement) {
|
#buildSwitcher(switcherEl: HTMLElement) {
|
||||||
|
const enabledSet = FolkRApp.enabledModuleIds
|
||||||
|
?? ((window as any).__rspaceEnabledModules ? new Set((window as any).__rspaceEnabledModules as string[]) : null);
|
||||||
|
|
||||||
const items = Object.entries(MODULE_META)
|
const items = Object.entries(MODULE_META)
|
||||||
|
.filter(([id]) => !enabledSet || enabledSet.has(id))
|
||||||
.map(([id, meta]) => `
|
.map(([id, meta]) => `
|
||||||
<button class="rapp-switcher-item ${id === this.#moduleId ? "active" : ""}" data-module="${id}">
|
<button class="rapp-switcher-item ${id === this.#moduleId ? "active" : ""}" data-module="${id}">
|
||||||
<span class="rapp-switcher-badge" style="background: ${meta.color}">${meta.badge}</span>
|
<span class="rapp-switcher-badge" style="background: ${meta.color}">${meta.badge}</span>
|
||||||
|
|
@ -1163,7 +1176,11 @@ export class FolkRApp extends FolkShape {
|
||||||
#showPicker() {
|
#showPicker() {
|
||||||
if (!this.#contentEl) return;
|
if (!this.#contentEl) return;
|
||||||
|
|
||||||
|
const enabledSet = FolkRApp.enabledModuleIds
|
||||||
|
?? ((window as any).__rspaceEnabledModules ? new Set((window as any).__rspaceEnabledModules as string[]) : null);
|
||||||
|
|
||||||
const items = Object.entries(MODULE_META)
|
const items = Object.entries(MODULE_META)
|
||||||
|
.filter(([id]) => !enabledSet || enabledSet.has(id))
|
||||||
.map(([id, meta]) => `
|
.map(([id, meta]) => `
|
||||||
<button class="rapp-picker-item" data-module="${id}">
|
<button class="rapp-picker-item" data-module="${id}">
|
||||||
<span class="rapp-picker-badge" style="background: ${meta.color}">${meta.badge}</span>
|
<span class="rapp-picker-badge" style="background: ${meta.color}">${meta.badge}</span>
|
||||||
|
|
|
||||||
|
|
@ -119,50 +119,128 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
return `${Math.floor(diff / 86400000)}d ago`;
|
return `${Math.floor(diff / 86400000)}d ago`;
|
||||||
};
|
};
|
||||||
const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
const { authorId: currentUserId } = this.getSessionInfo();
|
const { authorId: currentUserId, authorName: currentUserName } = this.getSessionInfo();
|
||||||
|
const initials = (name: string) => name.split(/\s+/).map(w => w[0] || '').join('').slice(0, 2).toUpperCase() || '?';
|
||||||
|
const avatarColor = (id: string) => {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) h = id.charCodeAt(i) + ((h << 5) - h);
|
||||||
|
return `hsl(${Math.abs(h) % 360}, 55%, 55%)`;
|
||||||
|
};
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display: block; }
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; }
|
||||||
.panel { border-left: 1px solid var(--rs-border, #e5e7eb); padding: 12px; font-family: system-ui, sans-serif; font-size: 13px; max-height: 80vh; overflow-y: auto; }
|
.panel { padding: 8px 12px; overflow-y: auto; max-height: calc(100vh - 180px); }
|
||||||
.panel-title { font-weight: 600; font-size: 14px; margin-bottom: 12px; color: var(--rs-text-primary, #111); display: flex; justify-content: space-between; align-items: center; }
|
.panel-title {
|
||||||
.thread { margin-bottom: 16px; padding: 10px; border-radius: 8px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border-subtle, #f0f0f0); cursor: pointer; transition: border-color 0.15s; }
|
font-weight: 600; font-size: 13px; padding: 8px 0;
|
||||||
.thread:hover { border-color: var(--rs-border, #e5e7eb); }
|
color: var(--rs-text-secondary, #666);
|
||||||
.thread.active { border-color: var(--rs-primary, #3b82f6); }
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
.thread.resolved { opacity: 0.6; }
|
border-bottom: 1px solid var(--rs-border-subtle, #f0f0f0);
|
||||||
.thread-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
margin-bottom: 8px;
|
||||||
.thread-author { font-weight: 600; color: var(--rs-text-primary, #111); }
|
}
|
||||||
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; }
|
.thread {
|
||||||
.thread-actions { display: flex; gap: 4px; }
|
margin-bottom: 8px;
|
||||||
.thread-action { padding: 2px 6px; border: none; background: none; color: var(--rs-text-secondary, #666); cursor: pointer; font-size: 11px; border-radius: 4px; }
|
padding: 12px;
|
||||||
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); }
|
border-radius: 8px;
|
||||||
.message { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
|
background: var(--rs-bg-surface, #fff);
|
||||||
|
border: 1px solid var(--rs-border-subtle, #e8e8e8);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.thread:hover { box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
|
||||||
|
.thread.active {
|
||||||
|
border-left-color: #fbbc04;
|
||||||
|
box-shadow: 0 1px 6px rgba(251, 188, 4, 0.2);
|
||||||
|
background: color-mix(in srgb, #fbbc04 4%, var(--rs-bg-surface, #fff));
|
||||||
|
}
|
||||||
|
.thread.resolved { opacity: 0.5; }
|
||||||
|
.thread.resolved:hover { opacity: 0.7; }
|
||||||
|
|
||||||
|
/* Author row with avatar */
|
||||||
|
.thread-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.avatar {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 11px; font-weight: 600; color: #fff; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-info { flex: 1; min-width: 0; }
|
||||||
|
.thread-author { font-weight: 600; font-size: 13px; color: var(--rs-text-primary, #111); }
|
||||||
|
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; margin-left: 6px; }
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.message { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
|
||||||
|
.message-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||||
|
.message-avatar { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 600; color: #fff; flex-shrink: 0; }
|
||||||
.message-author { font-weight: 500; font-size: 12px; color: var(--rs-text-secondary, #666); }
|
.message-author { font-weight: 500; font-size: 12px; color: var(--rs-text-secondary, #666); }
|
||||||
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.4; }
|
.message-time { font-size: 10px; color: var(--rs-text-muted, #aaa); }
|
||||||
.reply-form { margin-top: 8px; display: flex; gap: 6px; }
|
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.5; padding-left: 26px; }
|
||||||
.reply-input { flex: 1; padding: 6px 8px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 12px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
|
.first-message-text { color: var(--rs-text-primary, #111); line-height: 1.5; }
|
||||||
.reply-input:focus { border-color: var(--rs-primary, #3b82f6); outline: none; }
|
|
||||||
.reply-btn { padding: 6px 10px; border: none; background: var(--rs-primary, #3b82f6); color: #fff; border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500; }
|
/* Reply form — Google Docs style */
|
||||||
.reply-btn:hover { opacity: 0.9; }
|
.reply-form { margin-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); padding-top: 10px; }
|
||||||
|
.reply-input {
|
||||||
|
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
|
||||||
|
border-radius: 8px; font-size: 13px; font-family: inherit;
|
||||||
|
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
|
||||||
|
resize: none; min-height: 36px;
|
||||||
|
}
|
||||||
|
.reply-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
|
||||||
|
.reply-input::placeholder { color: var(--rs-text-muted, #999); }
|
||||||
|
.reply-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
||||||
|
.reply-btn {
|
||||||
|
padding: 6px 14px; border: none; background: #1a73e8; color: #fff;
|
||||||
|
border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500;
|
||||||
|
}
|
||||||
|
.reply-btn:hover { background: #1557b0; }
|
||||||
|
.reply-cancel-btn {
|
||||||
|
padding: 6px 14px; border: none; background: transparent; color: var(--rs-text-secondary, #666);
|
||||||
|
border-radius: 6px; font-size: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.reply-cancel-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
|
||||||
|
|
||||||
|
/* Thread actions */
|
||||||
|
.thread-actions { display: flex; gap: 2px; margin-top: 8px; justify-content: flex-end; }
|
||||||
|
.thread-action {
|
||||||
|
padding: 4px 8px; border: none; background: none;
|
||||||
|
color: var(--rs-text-muted, #999); cursor: pointer;
|
||||||
|
font-size: 11px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); color: var(--rs-text-primary, #111); }
|
||||||
|
.thread-action.resolve-btn { color: #1a73e8; }
|
||||||
|
.thread-action.resolve-btn:hover { background: color-mix(in srgb, #1a73e8 8%, transparent); }
|
||||||
|
|
||||||
/* Reactions */
|
/* Reactions */
|
||||||
.reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; align-items: center; }
|
.reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; align-items: center; }
|
||||||
.reaction-pill { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 12px; border: 1px solid var(--rs-border-subtle, #e0e0e0); background: var(--rs-bg-surface, #fff); font-size: 12px; cursor: pointer; transition: all 0.15s; user-select: none; }
|
.reaction-pill { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 12px; border: 1px solid var(--rs-border-subtle, #e0e0e0); background: var(--rs-bg-surface, #fff); font-size: 12px; cursor: pointer; transition: all 0.15s; user-select: none; }
|
||||||
.reaction-pill:hover { border-color: var(--rs-primary, #3b82f6); }
|
.reaction-pill:hover { border-color: #1a73e8; }
|
||||||
.reaction-pill.active { border-color: var(--rs-primary, #3b82f6); background: color-mix(in srgb, var(--rs-primary, #3b82f6) 10%, transparent); }
|
.reaction-pill.active { border-color: #1a73e8; background: color-mix(in srgb, #1a73e8 10%, transparent); }
|
||||||
.reaction-pill .count { font-size: 11px; color: var(--rs-text-secondary, #666); }
|
.reaction-pill .count { font-size: 11px; color: var(--rs-text-secondary, #666); }
|
||||||
.reaction-add { padding: 2px 6px; border-radius: 12px; border: 1px dashed var(--rs-border-subtle, #ddd); background: none; font-size: 12px; cursor: pointer; color: var(--rs-text-muted, #999); }
|
.reaction-add { padding: 2px 6px; border-radius: 12px; border: 1px dashed var(--rs-border-subtle, #ddd); background: none; font-size: 12px; cursor: pointer; color: var(--rs-text-muted, #999); }
|
||||||
.reaction-add:hover { border-color: var(--rs-primary, #3b82f6); color: var(--rs-text-primary, #111); }
|
.reaction-add:hover { border-color: #1a73e8; color: var(--rs-text-primary, #111); }
|
||||||
.emoji-picker { display: none; flex-wrap: wrap; gap: 2px; padding: 4px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); margin-top: 4px; }
|
.emoji-picker { display: none; flex-wrap: wrap; gap: 2px; padding: 4px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); margin-top: 4px; }
|
||||||
.emoji-picker.open { display: flex; }
|
.emoji-picker.open { display: flex; }
|
||||||
.emoji-pick { padding: 4px 6px; border: none; background: none; font-size: 16px; cursor: pointer; border-radius: 4px; }
|
.emoji-pick { padding: 4px 6px; border: none; background: none; font-size: 16px; cursor: pointer; border-radius: 4px; }
|
||||||
.emoji-pick:hover { background: var(--rs-bg-hover, #f5f5f5); }
|
.emoji-pick:hover { background: var(--rs-bg-hover, #f5f5f5); }
|
||||||
|
|
||||||
/* Reminders */
|
/* Reminders */
|
||||||
.reminder-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
.reminder-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||||
.reminder-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: color-mix(in srgb, var(--rs-warning, #f59e0b) 15%, transparent); color: var(--rs-text-primary, #111); font-size: 11px; }
|
.reminder-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: color-mix(in srgb, var(--rs-warning, #f59e0b) 15%, transparent); color: var(--rs-text-primary, #111); font-size: 11px; }
|
||||||
.reminder-btn { padding: 2px 8px; border: 1px solid var(--rs-border-subtle, #ddd); border-radius: 12px; background: none; font-size: 11px; cursor: pointer; color: var(--rs-text-secondary, #666); }
|
.reminder-btn { padding: 2px 8px; border: 1px solid var(--rs-border-subtle, #ddd); border-radius: 12px; background: none; font-size: 11px; cursor: pointer; color: var(--rs-text-secondary, #666); }
|
||||||
.reminder-btn:hover { border-color: var(--rs-primary, #3b82f6); }
|
.reminder-btn:hover { border-color: #1a73e8; }
|
||||||
.reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); }
|
.reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); }
|
||||||
.reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
|
.reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
|
||||||
|
|
||||||
|
/* New comment input — shown when thread has no messages */
|
||||||
|
.new-comment-form { margin-top: 4px; }
|
||||||
|
.new-comment-input {
|
||||||
|
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
|
||||||
|
border-radius: 8px; font-size: 13px; font-family: inherit;
|
||||||
|
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
|
||||||
|
resize: none; min-height: 60px;
|
||||||
|
}
|
||||||
|
.new-comment-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
|
||||||
|
.new-comment-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
||||||
</style>
|
</style>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
|
|
@ -171,19 +249,43 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
${threads.map(thread => {
|
${threads.map(thread => {
|
||||||
const reactions = thread.reactions || {};
|
const reactions = thread.reactions || {};
|
||||||
const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0);
|
const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0);
|
||||||
|
const isActive = thread.id === this._activeThreadId;
|
||||||
|
const hasMessages = thread.messages.length > 0;
|
||||||
|
const firstMsg = thread.messages[0];
|
||||||
|
const authorName = firstMsg?.authorName || currentUserName;
|
||||||
|
const authorId = firstMsg?.authorId || currentUserId;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="thread ${thread.id === this._activeThreadId ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
|
<div class="thread ${isActive ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
|
||||||
<div class="thread-header">
|
<div class="thread-header">
|
||||||
<span class="thread-author">${esc(thread.messages[0]?.authorName || 'Anonymous')}</span>
|
<div class="avatar" style="background: ${avatarColor(authorId)}">${initials(authorName)}</div>
|
||||||
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
|
<div class="header-info">
|
||||||
</div>
|
<span class="thread-author">${esc(authorName)}</span>
|
||||||
${thread.messages.map(msg => `
|
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
|
||||||
<div class="message">
|
|
||||||
<div class="message-author">${esc(msg.authorName)}</div>
|
|
||||||
<div class="message-text">${esc(msg.text)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
</div>
|
||||||
${thread.messages.length === 0 ? '<div class="message"><div class="message-text" style="color: var(--rs-text-muted, #999)">Click to add a comment...</div></div>' : ''}
|
${hasMessages ? `
|
||||||
|
<div class="first-message-text">${esc(firstMsg.text)}</div>
|
||||||
|
${thread.messages.slice(1).map(msg => `
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-header">
|
||||||
|
<div class="message-avatar" style="background: ${avatarColor(msg.authorId)}">${initials(msg.authorName)}</div>
|
||||||
|
<span class="message-author">${esc(msg.authorName)}</span>
|
||||||
|
<span class="message-time">${timeAgo(msg.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-text">${esc(msg.text)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
` : `
|
||||||
|
<div class="new-comment-form">
|
||||||
|
<textarea class="new-comment-input" placeholder="Add your comment..." data-new-thread="${thread.id}" autofocus></textarea>
|
||||||
|
<div class="new-comment-actions">
|
||||||
|
<button class="reply-cancel-btn" data-cancel-new="${thread.id}">Cancel</button>
|
||||||
|
<button class="reply-btn" data-submit-new="${thread.id}">Comment</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
${hasMessages && reactionEntries.length > 0 ? `
|
||||||
<div class="reactions-row">
|
<div class="reactions-row">
|
||||||
${reactionEntries.map(([emoji, users]) => `
|
${reactionEntries.map(([emoji, users]) => `
|
||||||
<button class="reaction-pill ${users.includes(currentUserId) ? 'active' : ''}" data-react-thread="${thread.id}" data-react-emoji="${emoji}">${emoji} <span class="count">${users.length}</span></button>
|
<button class="reaction-pill ${users.includes(currentUserId) ? 'active' : ''}" data-react-thread="${thread.id}" data-react-emoji="${emoji}">${emoji} <span class="count">${users.length}</span></button>
|
||||||
|
|
@ -193,19 +295,28 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
<div class="emoji-picker" data-picker="${thread.id}">
|
<div class="emoji-picker" data-picker="${thread.id}">
|
||||||
${REACTION_EMOJIS.map(e => `<button class="emoji-pick" data-pick-thread="${thread.id}" data-pick-emoji="${e}">${e}</button>`).join('')}
|
${REACTION_EMOJIS.map(e => `<button class="emoji-pick" data-pick-thread="${thread.id}" data-pick-emoji="${e}">${e}</button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${hasMessages && thread.reminderAt ? `
|
||||||
<div class="reminder-row">
|
<div class="reminder-row">
|
||||||
${thread.reminderAt
|
<span class="reminder-badge">⏰ ${formatDate(thread.reminderAt)}</span>
|
||||||
? `<span class="reminder-badge">⏰ ${formatDate(thread.reminderAt)}</span><button class="reminder-clear" data-remind-clear="${thread.id}">✕</button>`
|
<button class="reminder-clear" data-remind-clear="${thread.id}">✕</button>
|
||||||
: `<button class="reminder-btn" data-remind-set="${thread.id}">⏰ Remind me</button>`
|
|
||||||
}
|
|
||||||
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
|
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${hasMessages ? `
|
||||||
<div class="reply-form">
|
<div class="reply-form">
|
||||||
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
|
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
|
||||||
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
|
<div class="reply-actions">
|
||||||
|
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
<div class="thread-actions">
|
<div class="thread-actions">
|
||||||
<button class="thread-action" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
|
${hasMessages ? `
|
||||||
|
<button class="reaction-add" data-react-add="${thread.id}" title="React" style="font-size:13px">+</button>
|
||||||
|
<button class="thread-action" data-remind-set="${thread.id}" title="Set reminder">⏰</button>
|
||||||
|
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
|
||||||
|
` : ''}
|
||||||
|
<button class="thread-action resolve-btn" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
|
||||||
<button class="thread-action" data-delete="${thread.id}">Delete</button>
|
<button class="thread-action" data-delete="${thread.id}">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
@ -214,12 +325,21 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.wireEvents();
|
this.wireEvents();
|
||||||
|
|
||||||
|
// Auto-focus new comment textarea
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement;
|
||||||
|
if (newInput) newInput.focus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private wireEvents() {
|
private wireEvents() {
|
||||||
// Click thread to scroll editor to it
|
// Click thread to scroll editor to it
|
||||||
this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
|
this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
|
||||||
el.addEventListener('click', (e) => {
|
el.addEventListener('click', (e) => {
|
||||||
|
// Don't handle clicks on inputs/buttons/textareas
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('input, textarea, button')) return;
|
||||||
const threadId = (el as HTMLElement).dataset.thread;
|
const threadId = (el as HTMLElement).dataset.thread;
|
||||||
if (!threadId || !this._editor) return;
|
if (!threadId || !this._editor) return;
|
||||||
this._activeThreadId = threadId;
|
this._activeThreadId = threadId;
|
||||||
|
|
@ -236,6 +356,46 @@ class NotesCommentPanel extends HTMLElement {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// New comment submit (thread with no messages yet)
|
||||||
|
this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const threadId = (btn as HTMLElement).dataset.submitNew;
|
||||||
|
if (!threadId) return;
|
||||||
|
const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement;
|
||||||
|
const text = textarea?.value?.trim();
|
||||||
|
if (!text) return;
|
||||||
|
this.addReply(threadId, text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// New comment cancel — delete the empty thread
|
||||||
|
this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const threadId = (btn as HTMLElement).dataset.cancelNew;
|
||||||
|
if (threadId) this.deleteThread(threadId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// New comment textarea — Ctrl+Enter to submit, Escape to cancel
|
||||||
|
this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => {
|
||||||
|
textarea.addEventListener('keydown', (e) => {
|
||||||
|
const ke = e as KeyboardEvent;
|
||||||
|
if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
|
||||||
|
const text = (textarea as HTMLTextAreaElement).value.trim();
|
||||||
|
if (threadId && text) this.addReply(threadId, text);
|
||||||
|
} else if (ke.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
|
||||||
|
if (threadId) this.deleteThread(threadId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
textarea.addEventListener('click', (e) => e.stopPropagation());
|
||||||
|
});
|
||||||
|
|
||||||
// Reply
|
// Reply
|
||||||
this.shadow.querySelectorAll('[data-reply]').forEach(btn => {
|
this.shadow.querySelectorAll('[data-reply]').forEach(btn => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
|
|
|
||||||
|
|
@ -1216,14 +1216,17 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
const useYjs = !isDemo && isEditable;
|
const useYjs = !isDemo && isEditable;
|
||||||
|
|
||||||
this.contentZone.innerHTML = `
|
this.contentZone.innerHTML = `
|
||||||
<div class="editor-wrapper">
|
<div class="editor-with-comments">
|
||||||
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
|
<div class="editor-wrapper">
|
||||||
${isEditable ? this.renderToolbar() : ''}
|
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
|
||||||
<div class="collab-status-bar" id="collab-status-bar" style="display:none">
|
${isEditable ? this.renderToolbar() : ''}
|
||||||
<span class="collab-status-dot"></span>
|
<div class="collab-status-bar" id="collab-status-bar" style="display:none">
|
||||||
<span class="collab-status-text"></span>
|
<span class="collab-status-dot"></span>
|
||||||
|
<span class="collab-status-text"></span>
|
||||||
|
</div>
|
||||||
|
<div class="tiptap-container" id="tiptap-container"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tiptap-container" id="tiptap-container"></div>
|
<div class="comment-sidebar" id="comment-sidebar"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -1257,6 +1260,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
|
|
||||||
this.wireTitleInput(note, isEditable, isDemo);
|
this.wireTitleInput(note, isEditable, isDemo);
|
||||||
this.attachToolbarListeners();
|
this.attachToolbarListeners();
|
||||||
|
this.wireCommentHighlightClicks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mount TipTap with Yjs collaboration (real-time co-editing). */
|
/** Mount TipTap with Yjs collaboration (real-time co-editing). */
|
||||||
|
|
@ -1306,6 +1310,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
heading: { levels: [1, 2, 3, 4] },
|
heading: { levels: [1, 2, 3, 4] },
|
||||||
undoRedo: false, // Yjs has its own undo/redo
|
undoRedo: false, // Yjs has its own undo/redo
|
||||||
|
link: false,
|
||||||
|
underline: false,
|
||||||
}),
|
}),
|
||||||
Link.configure({ openOnClick: false }),
|
Link.configure({ openOnClick: false }),
|
||||||
Image, TaskList, TaskItem.configure({ nested: true }),
|
Image, TaskList, TaskItem.configure({ nested: true }),
|
||||||
|
|
@ -1329,6 +1335,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
const s = this.getSessionInfo();
|
const s = this.getSessionInfo();
|
||||||
return { authorId: s.userId, authorName: s.username };
|
return { authorId: s.userId, authorName: s.username };
|
||||||
},
|
},
|
||||||
|
() => this.editor?.view ?? null,
|
||||||
);
|
);
|
||||||
this.editor.registerPlugin(suggestionPlugin);
|
this.editor.registerPlugin(suggestionPlugin);
|
||||||
|
|
||||||
|
|
@ -1379,7 +1386,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
element: container,
|
element: container,
|
||||||
editable: isEditable,
|
editable: isEditable,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] } }),
|
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] }, link: false, underline: false }),
|
||||||
Link.configure({ openOnClick: false }),
|
Link.configure({ openOnClick: false }),
|
||||||
Image, TaskList, TaskItem.configure({ nested: true }),
|
Image, TaskList, TaskItem.configure({ nested: true }),
|
||||||
Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
|
Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
|
||||||
|
|
@ -1656,7 +1663,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
this.editor = new Editor({
|
this.editor = new Editor({
|
||||||
element: container, editable: isEditable,
|
element: container, editable: isEditable,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] } }),
|
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] }, link: false, underline: false }),
|
||||||
Link.configure({ openOnClick: false }), Image,
|
Link.configure({ openOnClick: false }), Image,
|
||||||
Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }),
|
Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }),
|
||||||
Typography, Underline,
|
Typography, Underline,
|
||||||
|
|
@ -1726,7 +1733,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
this.editor = new Editor({
|
this.editor = new Editor({
|
||||||
element: container, editable: isEditable,
|
element: container, editable: isEditable,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }),
|
StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
|
||||||
Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline,
|
Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
|
|
@ -1795,7 +1802,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
this.editor = new Editor({
|
this.editor = new Editor({
|
||||||
element: container, editable: isEditable,
|
element: container, editable: isEditable,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }),
|
StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
|
||||||
Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline,
|
Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
|
|
@ -2300,10 +2307,13 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
|
|
||||||
/** Show comment panel for a specific thread. */
|
/** Show comment panel for a specific thread. */
|
||||||
private showCommentPanel(threadId?: string) {
|
private showCommentPanel(threadId?: string) {
|
||||||
|
const sidebar = this.shadow.getElementById('comment-sidebar');
|
||||||
|
if (!sidebar) return;
|
||||||
|
|
||||||
let panel = this.shadow.querySelector('notes-comment-panel') as any;
|
let panel = this.shadow.querySelector('notes-comment-panel') as any;
|
||||||
if (!panel) {
|
if (!panel) {
|
||||||
panel = document.createElement('notes-comment-panel');
|
panel = document.createElement('notes-comment-panel');
|
||||||
this.metaZone.appendChild(panel);
|
sidebar.appendChild(panel);
|
||||||
// Listen for demo thread mutations from comment panel
|
// Listen for demo thread mutations from comment panel
|
||||||
panel.addEventListener('comment-demo-mutation', (e: CustomEvent) => {
|
panel.addEventListener('comment-demo-mutation', (e: CustomEvent) => {
|
||||||
const { noteId, threads } = e.detail;
|
const { noteId, threads } = e.detail;
|
||||||
|
|
@ -2322,6 +2332,44 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
} else {
|
} else {
|
||||||
panel.demoThreads = null;
|
panel.demoThreads = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show sidebar when there are comments
|
||||||
|
sidebar.classList.add('has-comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide comment sidebar when no comments exist. */
|
||||||
|
private hideCommentPanel() {
|
||||||
|
const sidebar = this.shadow.getElementById('comment-sidebar');
|
||||||
|
if (sidebar) sidebar.classList.remove('has-comments');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wire click handling on comment highlights in the editor to open comment panel. */
|
||||||
|
private wireCommentHighlightClicks() {
|
||||||
|
if (!this.editor) return;
|
||||||
|
|
||||||
|
// On selection change, check if cursor is inside a comment mark
|
||||||
|
this.editor.on('selectionUpdate', () => {
|
||||||
|
if (!this.editor) return;
|
||||||
|
const { $from } = this.editor.state.selection;
|
||||||
|
const commentMark = $from.marks().find(m => m.type.name === 'comment');
|
||||||
|
if (commentMark) {
|
||||||
|
const threadId = commentMark.attrs.threadId;
|
||||||
|
if (threadId) this.showCommentPanel(threadId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direct click on comment highlight in the DOM
|
||||||
|
const container = this.shadow.getElementById('tiptap-container');
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const highlight = target.closest?.('.comment-highlight') as HTMLElement;
|
||||||
|
if (highlight) {
|
||||||
|
const threadId = highlight.getAttribute('data-thread-id');
|
||||||
|
if (threadId) this.showCommentPanel(threadId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleDictation(btn: HTMLElement) {
|
private toggleDictation(btn: HTMLElement) {
|
||||||
|
|
@ -2994,6 +3042,40 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
.notes-right-col #content-zone { flex: 1; overflow-y: auto; padding: 20px; }
|
.notes-right-col #content-zone { flex: 1; overflow-y: auto; padding: 20px; }
|
||||||
.notes-right-col #meta-zone { padding: 0 20px 12px; }
|
.notes-right-col #meta-zone { padding: 0 20px 12px; }
|
||||||
|
|
||||||
|
/* ── Google Docs-like comment sidebar layout ── */
|
||||||
|
.editor-with-comments {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
.editor-with-comments > .editor-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.comment-sidebar {
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.comment-sidebar.has-comments {
|
||||||
|
width: 280px;
|
||||||
|
border-left: 1px solid var(--rs-border, #e5e7eb);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.comment-sidebar.has-comments { width: 240px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.editor-with-comments { flex-direction: column; }
|
||||||
|
.comment-sidebar.has-comments {
|
||||||
|
width: 100%;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--rs-border, #e5e7eb);
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
.editor-empty-state {
|
.editor-empty-state {
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
|
@ -3474,19 +3556,20 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Collaboration: Comment Highlights ── */
|
/* ── Collaboration: Comment Highlights (Google Docs style) ── */
|
||||||
.tiptap-container .tiptap .comment-highlight {
|
.tiptap-container .tiptap .comment-highlight {
|
||||||
background: rgba(250, 204, 21, 0.25);
|
background: rgba(251, 188, 4, 0.2);
|
||||||
border-bottom: 2px solid rgba(250, 204, 21, 0.5);
|
border-bottom: 2px solid rgba(251, 188, 4, 0.5);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
.tiptap-container .tiptap .comment-highlight:hover {
|
.tiptap-container .tiptap .comment-highlight:hover {
|
||||||
background: rgba(250, 204, 21, 0.4);
|
background: rgba(251, 188, 4, 0.35);
|
||||||
}
|
}
|
||||||
.tiptap-container .tiptap .comment-highlight.resolved {
|
.tiptap-container .tiptap .comment-highlight.resolved {
|
||||||
background: rgba(250, 204, 21, 0.08);
|
background: rgba(251, 188, 4, 0.06);
|
||||||
border-bottom-color: rgba(250, 204, 21, 0.15);
|
border-bottom-color: rgba(251, 188, 4, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Collaboration: Suggestions ── */
|
/* ── Collaboration: Suggestions ── */
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
|
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
|
||||||
|
import type { EditorView } from '@tiptap/pm/view';
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core';
|
||||||
|
|
||||||
const pluginKey = new PluginKey('suggestion-plugin');
|
const pluginKey = new PluginKey('suggestion-plugin');
|
||||||
|
|
@ -24,10 +25,12 @@ interface SuggestionPluginState {
|
||||||
* Create the suggestion mode ProseMirror plugin.
|
* Create the suggestion mode ProseMirror plugin.
|
||||||
* @param getSuggesting - callback that returns current suggesting mode state
|
* @param getSuggesting - callback that returns current suggesting mode state
|
||||||
* @param getAuthor - callback that returns { authorId, authorName }
|
* @param getAuthor - callback that returns { authorId, authorName }
|
||||||
|
* @param getView - callback that returns the EditorView (needed to dispatch replacement transactions)
|
||||||
*/
|
*/
|
||||||
export function createSuggestionPlugin(
|
export function createSuggestionPlugin(
|
||||||
getSuggesting: () => boolean,
|
getSuggesting: () => boolean,
|
||||||
getAuthor: () => { authorId: string; authorName: string },
|
getAuthor: () => { authorId: string; authorName: string },
|
||||||
|
getView?: () => EditorView | null,
|
||||||
): Plugin {
|
): Plugin {
|
||||||
return new Plugin({
|
return new Plugin({
|
||||||
key: pluginKey,
|
key: pluginKey,
|
||||||
|
|
@ -125,10 +128,9 @@ export function createSuggestionPlugin(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (blocked && newTr.docChanged) {
|
if (blocked && newTr.docChanged) {
|
||||||
// Dispatch our modified transaction instead
|
// Dispatch our modified transaction instead on the next tick
|
||||||
// We need to use view.dispatch in the next tick
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const view = (state as any).view;
|
const view = getView?.();
|
||||||
if (view) view.dispatch(newTr);
|
if (view) view.dispatch(newTr);
|
||||||
}, 0);
|
}, 0);
|
||||||
return false; // Block the original transaction
|
return false; // Block the original transaction
|
||||||
|
|
|
||||||
|
|
@ -1615,7 +1615,7 @@ routes.get("/", (c) => {
|
||||||
modules: getModuleInfoList(),
|
modules: getModuleInfoList(),
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
|
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
|
||||||
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=6"></script>`,
|
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=7"></script>`,
|
||||||
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
|
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const NATIVE_COIN_ID: Record<string, string> = {
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
prices: Map<string, number>; // address (lowercase) → USD price
|
prices: Map<string, number>; // address (lowercase) → USD price
|
||||||
nativePrice: number;
|
nativePrice: number;
|
||||||
|
cgAvailable: boolean; // true if CoinGecko successfully returned token data
|
||||||
ts: number;
|
ts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,26 +75,43 @@ export async function getNativePrice(chainId: string): Promise<number> {
|
||||||
return data?.[coinId]?.usd ?? 0;
|
return data?.[coinId]?.usd ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch token prices for a batch of contract addresses on a chain */
|
/** Fetch token prices for contract addresses on a chain */
|
||||||
export async function getTokenPrices(
|
export async function getTokenPrices(
|
||||||
chainId: string,
|
chainId: string,
|
||||||
addresses: string[],
|
addresses: string[],
|
||||||
): Promise<Map<string, number>> {
|
): Promise<{ prices: Map<string, number>; available: boolean }> {
|
||||||
const platform = CHAIN_PLATFORM[chainId];
|
const platform = CHAIN_PLATFORM[chainId];
|
||||||
if (!platform || addresses.length === 0) return new Map();
|
if (!platform || addresses.length === 0) return { prices: new Map(), available: false };
|
||||||
|
|
||||||
const lower = addresses.map((a) => a.toLowerCase());
|
const lower = [...new Set(addresses.map((a) => a.toLowerCase()))];
|
||||||
|
const prices = new Map<string, number>();
|
||||||
|
|
||||||
|
// CoinGecko free tier limits to 1 address per request.
|
||||||
|
// For 1 token, do a single lookup. For multiple, try batch (works with Pro/Demo keys).
|
||||||
|
if (lower.length === 1) {
|
||||||
|
const addr = lower[0];
|
||||||
|
const data = await cgFetch(
|
||||||
|
`https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${addr}&vs_currencies=usd`,
|
||||||
|
);
|
||||||
|
if (data && !data.error_code) {
|
||||||
|
if (data[addr]?.usd) prices.set(addr, data[addr].usd);
|
||||||
|
return { prices, available: true };
|
||||||
|
}
|
||||||
|
return { prices, available: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple tokens: batch request (succeeds with CoinGecko Demo/Pro API key)
|
||||||
const data = await cgFetch(
|
const data = await cgFetch(
|
||||||
`https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${lower.join(",")}&vs_currencies=usd`,
|
`https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${lower.join(",")}&vs_currencies=usd`,
|
||||||
);
|
);
|
||||||
|
if (data && !data.error_code) {
|
||||||
const result = new Map<string, number>();
|
|
||||||
if (data) {
|
|
||||||
for (const addr of lower) {
|
for (const addr of lower) {
|
||||||
if (data[addr]?.usd) result.set(addr, data[addr].usd);
|
if (data[addr]?.usd) prices.set(addr, data[addr].usd);
|
||||||
}
|
}
|
||||||
|
return { prices, available: true };
|
||||||
}
|
}
|
||||||
return result;
|
// Batch failed (free tier limit) — degrade gracefully, no spam filtering
|
||||||
|
return { prices, available: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch and cache all prices for a chain (native + tokens) */
|
/** Fetch and cache all prices for a chain (native + tokens) */
|
||||||
|
|
@ -111,13 +129,14 @@ async function fetchChainPrices(
|
||||||
|
|
||||||
const promise = (async (): Promise<CacheEntry> => {
|
const promise = (async (): Promise<CacheEntry> => {
|
||||||
try {
|
try {
|
||||||
const [nativePrice, tokenPrices] = await Promise.all([
|
const [nativePrice, tokenResult] = await Promise.all([
|
||||||
getNativePrice(chainId),
|
getNativePrice(chainId),
|
||||||
getTokenPrices(chainId, tokenAddresses),
|
getTokenPrices(chainId, tokenAddresses),
|
||||||
]);
|
]);
|
||||||
const entry: CacheEntry = {
|
const entry: CacheEntry = {
|
||||||
prices: tokenPrices,
|
prices: tokenResult.prices,
|
||||||
nativePrice,
|
nativePrice,
|
||||||
|
cgAvailable: tokenResult.available,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
};
|
};
|
||||||
cache.set(chainId, entry);
|
cache.set(chainId, entry);
|
||||||
|
|
@ -196,7 +215,8 @@ export async function enrichWithPrices(
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options?.filterSpam) {
|
// Only filter spam when CoinGecko data is available to verify against
|
||||||
|
if (options?.filterSpam && priceData.cgAvailable) {
|
||||||
return enriched.filter((b) => {
|
return enriched.filter((b) => {
|
||||||
// Native tokens always pass
|
// Native tokens always pass
|
||||||
if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") return true;
|
if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") return true;
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,7 @@ function extractPathFromText(text: string, extensions: string[]): string | null
|
||||||
export const KICAD_SYSTEM_PROMPT = `You are a KiCad PCB design assistant. You have access to KiCad MCP tools to create real PCB designs.
|
export const KICAD_SYSTEM_PROMPT = `You are a KiCad PCB design assistant. You have access to KiCad MCP tools to create real PCB designs.
|
||||||
|
|
||||||
Follow this workflow:
|
Follow this workflow:
|
||||||
1. create_project — Create a new KiCad project in /tmp/kicad-gen-<timestamp>/
|
1. create_project — Create a new KiCad project in /data/files/generated/kicad-<timestamp>/
|
||||||
2. search_symbols — Find component symbols in KiCad libraries (e.g. ESP32, BME280, capacitors, resistors)
|
2. search_symbols — Find component symbols in KiCad libraries (e.g. ESP32, BME280, capacitors, resistors)
|
||||||
3. add_schematic_component — Place each component on the schematic
|
3. add_schematic_component — Place each component on the schematic
|
||||||
4. add_schematic_net_label — Add net labels for connections
|
4. add_schematic_net_label — Add net labels for connections
|
||||||
|
|
@ -296,7 +296,7 @@ Follow this workflow:
|
||||||
11. export_gerber, export_bom, export_pdf — Generate manufacturing outputs
|
11. export_gerber, export_bom, export_pdf — Generate manufacturing outputs
|
||||||
|
|
||||||
Important:
|
Important:
|
||||||
- Use /tmp/kicad-gen-${Date.now()}/ as the project directory
|
- Use /data/files/generated/kicad-${Date.now()}/ as the project directory
|
||||||
- Search for real symbols before placing components
|
- Search for real symbols before placing components
|
||||||
- Add decoupling capacitors and pull-up resistors as needed
|
- Add decoupling capacitors and pull-up resistors as needed
|
||||||
- Set reasonable board outline dimensions
|
- Set reasonable board outline dimensions
|
||||||
|
|
@ -307,16 +307,16 @@ Important:
|
||||||
export const FREECAD_SYSTEM_PROMPT = `You are a FreeCAD parametric CAD assistant. You have access to FreeCAD MCP tools to create real 3D models.
|
export const FREECAD_SYSTEM_PROMPT = `You are a FreeCAD parametric CAD assistant. You have access to FreeCAD MCP tools to create real 3D models.
|
||||||
|
|
||||||
Follow this workflow:
|
Follow this workflow:
|
||||||
1. execute_python_script — Create output directory: import os; os.makedirs("/tmp/freecad-gen-<timestamp>", exist_ok=True)
|
1. execute_python_script — Create output directory: import os; os.makedirs("/data/files/generated/freecad-<timestamp>", exist_ok=True)
|
||||||
2. Create base geometry using create_box, create_cylinder, or create_sphere
|
2. Create base geometry using create_box, create_cylinder, or create_sphere
|
||||||
3. Use boolean_operation (union, cut, intersection) to combine shapes
|
3. Use boolean_operation (union, cut, intersection) to combine shapes
|
||||||
4. list_objects to verify the model state
|
4. list_objects to verify the model state
|
||||||
5. save_document to save the FreeCAD file
|
5. save_document to save the FreeCAD file
|
||||||
6. execute_python_script to export STEP: Part.export([obj], "/tmp/freecad-gen-<id>/model.step")
|
6. execute_python_script to export STEP: Part.export([obj], "/data/files/generated/freecad-<id>/model.step")
|
||||||
7. execute_python_script to export STL: Mesh.export([obj], "/tmp/freecad-gen-<id>/model.stl")
|
7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-<id>/model.stl")
|
||||||
|
|
||||||
Important:
|
Important:
|
||||||
- Use /tmp/freecad-gen-${Date.now()}/ as the working directory
|
- Use /data/files/generated/freecad-${Date.now()}/ as the working directory
|
||||||
- For hollow objects, create the outer shell then cut the inner volume
|
- For hollow objects, create the outer shell then cut the inner volume
|
||||||
- For complex shapes, build up from primitives with boolean operations
|
- For complex shapes, build up from primitives with boolean operations
|
||||||
- Wall thickness should be at least 1mm for 3D printing
|
- Wall thickness should be at least 1mm for 3D printing
|
||||||
|
|
|
||||||
|
|
@ -1145,20 +1145,6 @@ async function process3DGenJob(job: Gen3DJob) {
|
||||||
|
|
||||||
// ── Image helpers ──
|
// ── Image helpers ──
|
||||||
|
|
||||||
/** Copy a file from a tmp path to the served generated directory → return server-relative URL */
|
|
||||||
async function copyToServed(srcPath: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const srcFile = Bun.file(srcPath);
|
|
||||||
if (!(await srcFile.exists())) return null;
|
|
||||||
const basename = srcPath.split("/").pop() || `file-${Date.now()}`;
|
|
||||||
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
|
||||||
await Bun.write(resolve(dir, basename), srcFile);
|
|
||||||
return `/data/files/generated/${basename}`;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read a /data/files/generated/... path from disk → base64 */
|
/** Read a /data/files/generated/... path from disk → base64 */
|
||||||
async function readFileAsBase64(serverPath: string): Promise<string> {
|
async function readFileAsBase64(serverPath: string): Promise<string> {
|
||||||
const filename = serverPath.split("/").pop();
|
const filename = serverPath.split("/").pop();
|
||||||
|
|
@ -1653,22 +1639,18 @@ app.post("/api/blender-gen", async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// KiCAD PCB design — MCP stdio bridge
|
// KiCAD PCB design — MCP SSE bridge (sidecar container)
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||||
import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator";
|
import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator";
|
||||||
|
|
||||||
const KICAD_MCP_PATH = process.env.KICAD_MCP_PATH || "/home/jeffe/KiCAD-MCP-Server/dist/index.js";
|
const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/sse";
|
||||||
let kicadClient: Client | null = null;
|
let kicadClient: Client | null = null;
|
||||||
|
|
||||||
async function getKicadClient(): Promise<Client> {
|
async function getKicadClient(): Promise<Client> {
|
||||||
if (kicadClient) return kicadClient;
|
if (kicadClient) return kicadClient;
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
const transport = new SSEClientTransport(new URL(KICAD_MCP_URL));
|
||||||
command: "node",
|
|
||||||
args: [KICAD_MCP_PATH],
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" });
|
const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" });
|
||||||
|
|
||||||
transport.onclose = () => { kicadClient = null; };
|
transport.onclose = () => { kicadClient = null; };
|
||||||
|
|
@ -1705,21 +1687,7 @@ app.post("/api/kicad/generate", async (c) => {
|
||||||
const orch = await runCadAgentLoop(client, KICAD_SYSTEM_PROMPT, enrichedPrompt, GEMINI_API_KEY);
|
const orch = await runCadAgentLoop(client, KICAD_SYSTEM_PROMPT, enrichedPrompt, GEMINI_API_KEY);
|
||||||
const result = assembleKicadResult(orch);
|
const result = assembleKicadResult(orch);
|
||||||
|
|
||||||
// Copy generated files to served directory
|
// Files are already on the shared /data/files volume — no copy needed
|
||||||
const filesToCopy = [
|
|
||||||
{ path: result.schematicSvg, key: "schematicSvg" },
|
|
||||||
{ path: result.boardSvg, key: "boardSvg" },
|
|
||||||
{ path: result.gerberUrl, key: "gerberUrl" },
|
|
||||||
{ path: result.bomUrl, key: "bomUrl" },
|
|
||||||
{ path: result.pdfUrl, key: "pdfUrl" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { path, key } of filesToCopy) {
|
|
||||||
if (path && path.startsWith("/tmp/")) {
|
|
||||||
const served = await copyToServed(path);
|
|
||||||
if (served) (result as any)[key] = served;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
schematic_svg: result.schematicSvg,
|
schematic_svg: result.schematicSvg,
|
||||||
|
|
@ -1774,18 +1742,14 @@ app.post("/api/kicad/:action", async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// FreeCAD parametric CAD — MCP stdio bridge
|
// FreeCAD parametric CAD — MCP SSE bridge (sidecar container)
|
||||||
const FREECAD_MCP_PATH = process.env.FREECAD_MCP_PATH || "/home/jeffe/freecad-mcp-server/build/index.js";
|
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/sse";
|
||||||
let freecadClient: Client | null = null;
|
let freecadClient: Client | null = null;
|
||||||
|
|
||||||
async function getFreecadClient(): Promise<Client> {
|
async function getFreecadClient(): Promise<Client> {
|
||||||
if (freecadClient) return freecadClient;
|
if (freecadClient) return freecadClient;
|
||||||
|
|
||||||
const transport = new StdioClientTransport({
|
const transport = new SSEClientTransport(new URL(FREECAD_MCP_URL));
|
||||||
command: "node",
|
|
||||||
args: [FREECAD_MCP_PATH],
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" });
|
const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" });
|
||||||
|
|
||||||
transport.onclose = () => { freecadClient = null; };
|
transport.onclose = () => { freecadClient = null; };
|
||||||
|
|
@ -1818,14 +1782,7 @@ app.post("/api/freecad/generate", async (c) => {
|
||||||
const orch = await runCadAgentLoop(client, FREECAD_SYSTEM_PROMPT, prompt, GEMINI_API_KEY);
|
const orch = await runCadAgentLoop(client, FREECAD_SYSTEM_PROMPT, prompt, GEMINI_API_KEY);
|
||||||
const result = assembleFreecadResult(orch);
|
const result = assembleFreecadResult(orch);
|
||||||
|
|
||||||
// Copy generated files to served directory
|
// Files are already on the shared /data/files volume — no copy needed
|
||||||
for (const key of ["stepUrl", "stlUrl"] as const) {
|
|
||||||
const path = result[key];
|
|
||||||
if (path && path.startsWith("/tmp/")) {
|
|
||||||
const served = await copyToServed(path);
|
|
||||||
if (served) (result as any)[key] = served;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
preview_url: result.previewUrl,
|
preview_url: result.previewUrl,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ const FAVICON_BADGE_MAP: Record<string, { badge: string; color: string }> = {
|
||||||
rschedule: { badge: "r⏱", color: "#a5b4fc" },
|
rschedule: { badge: "r⏱", color: "#a5b4fc" },
|
||||||
crowdsurf: { badge: "r🏄", color: "#fde68a" },
|
crowdsurf: { badge: "r🏄", color: "#fde68a" },
|
||||||
rids: { badge: "r🪪", color: "#6ee7b7" },
|
rids: { badge: "r🪪", color: "#6ee7b7" },
|
||||||
|
rdesign: { badge: "r🎨", color: "#7c3aed" },
|
||||||
rstack: { badge: "r✨", color: "#c4b5fd" },
|
rstack: { badge: "r✨", color: "#c4b5fd" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -440,6 +441,43 @@ export function renderShell(opts: ShellOptions): string {
|
||||||
_switcher?.setModules(window.__rspaceModuleList);
|
_switcher?.setModules(window.__rspaceModuleList);
|
||||||
_switcher?.setAllModules(window.__rspaceAllModules);
|
_switcher?.setAllModules(window.__rspaceAllModules);
|
||||||
|
|
||||||
|
// Initialize folk-rapp picker/switcher filtering with enabled modules
|
||||||
|
if (window.__rspaceEnabledModules) {
|
||||||
|
customElements.whenDefined('folk-rapp').then(function() {
|
||||||
|
var FolkRApp = customElements.get('folk-rapp');
|
||||||
|
if (FolkRApp && FolkRApp.setEnabledModules) FolkRApp.setEnabledModules(window.__rspaceEnabledModules);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to runtime module toggling from the app switcher "Manage rApps" panel
|
||||||
|
document.addEventListener('modules-changed', function(e) {
|
||||||
|
var detail = e.detail || {};
|
||||||
|
var enabledModules = detail.enabledModules;
|
||||||
|
window.__rspaceEnabledModules = enabledModules;
|
||||||
|
|
||||||
|
// Update tab bar's module list
|
||||||
|
var allMods = window.__rspaceAllModules || [];
|
||||||
|
var enabledSet = new Set(enabledModules);
|
||||||
|
var visible = allMods.filter(function(m) { return m.id === 'rspace' || enabledSet.has(m.id); });
|
||||||
|
var tb = document.querySelector('rstack-tab-bar');
|
||||||
|
if (tb) tb.setModules(visible);
|
||||||
|
|
||||||
|
// Update folk-rapp picker/switcher
|
||||||
|
var FolkRApp = customElements.get('folk-rapp');
|
||||||
|
if (FolkRApp && FolkRApp.setEnabledModules) FolkRApp.setEnabledModules(enabledModules);
|
||||||
|
|
||||||
|
// Re-run toolbar hiding for data-requires-module buttons
|
||||||
|
document.querySelectorAll('[data-requires-module]').forEach(function(el) {
|
||||||
|
el.style.display = enabledSet.has(el.dataset.requiresModule) ? '' : 'none';
|
||||||
|
});
|
||||||
|
// Re-check empty toolbar groups
|
||||||
|
document.querySelectorAll('.toolbar-group').forEach(function(group) {
|
||||||
|
var dropdown = group.querySelector('.toolbar-dropdown');
|
||||||
|
if (!dropdown) return;
|
||||||
|
var vis = dropdown.querySelectorAll('button:not([style*="display: none"]):not(.toolbar-dropdown-header)');
|
||||||
|
group.style.display = vis.length === 0 ? 'none' : '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Welcome tour (guided feature walkthrough for first-time visitors) ──
|
// ── Welcome tour (guided feature walkthrough for first-time visitors) ──
|
||||||
(function() {
|
(function() {
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,19 @@ export function broadcastPresence(opts: PresenceOpts): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a leave signal so peers can immediately remove us.
|
||||||
|
*/
|
||||||
|
export function broadcastLeave(): void {
|
||||||
|
const runtime = (window as any).__rspaceOfflineRuntime;
|
||||||
|
if (!runtime?.isInitialized || !runtime.isOnline) return;
|
||||||
|
const session = getSessionInfo();
|
||||||
|
runtime.sendCustom({
|
||||||
|
type: 'presence-leave',
|
||||||
|
username: session.username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a 10-second heartbeat that broadcasts presence.
|
* Start a 10-second heartbeat that broadcasts presence.
|
||||||
* Returns a cleanup function to stop the heartbeat.
|
* Returns a cleanup function to stop the heartbeat.
|
||||||
|
|
@ -69,5 +82,13 @@ export function broadcastPresence(opts: PresenceOpts): void {
|
||||||
export function startPresenceHeartbeat(getOpts: () => PresenceOpts): () => void {
|
export function startPresenceHeartbeat(getOpts: () => PresenceOpts): () => void {
|
||||||
broadcastPresence(getOpts());
|
broadcastPresence(getOpts());
|
||||||
const timer = setInterval(() => broadcastPresence(getOpts()), 10_000);
|
const timer = setInterval(() => broadcastPresence(getOpts()), 10_000);
|
||||||
return () => clearInterval(timer);
|
|
||||||
|
// Clean up immediately on page unload
|
||||||
|
const onUnload = () => broadcastLeave();
|
||||||
|
window.addEventListener('beforeunload', onUnload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
window.removeEventListener('beforeunload', onUnload);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
#localUsername = 'Anonymous';
|
#localUsername = 'Anonymous';
|
||||||
#unsubAwareness: (() => void) | null = null;
|
#unsubAwareness: (() => void) | null = null;
|
||||||
#unsubPresence: (() => void) | null = null;
|
#unsubPresence: (() => void) | null = null;
|
||||||
|
#unsubLeave: (() => void) | null = null;
|
||||||
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
|
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
#lastCursor = { x: 0, y: 0 };
|
#lastCursor = { x: 0, y: 0 };
|
||||||
#gcInterval: ReturnType<typeof setInterval> | null = null;
|
#gcInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
@ -80,6 +81,9 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#render();
|
this.#render();
|
||||||
this.#renderBadge();
|
this.#renderBadge();
|
||||||
|
|
||||||
|
// GC stale peers every 5s (all modes — prevents lingering ghost peers)
|
||||||
|
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
|
||||||
|
|
||||||
if (!this.#externalPeers) {
|
if (!this.#externalPeers) {
|
||||||
// Explicit doc-id attribute (fallback)
|
// Explicit doc-id attribute (fallback)
|
||||||
const explicitDocId = this.getAttribute('doc-id');
|
const explicitDocId = this.getAttribute('doc-id');
|
||||||
|
|
@ -90,9 +94,6 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
|
|
||||||
// Try connecting to runtime
|
// Try connecting to runtime
|
||||||
this.#tryConnect();
|
this.#tryConnect();
|
||||||
|
|
||||||
// GC stale peers every 5s
|
|
||||||
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click-outside closes panel (listen on document, check composedPath for shadow DOM)
|
// Click-outside closes panel (listen on document, check composedPath for shadow DOM)
|
||||||
|
|
@ -209,6 +210,21 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
this.#renderBadge();
|
this.#renderBadge();
|
||||||
if (this.#panelOpen) this.#renderPanel();
|
if (this.#panelOpen) this.#renderPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for explicit leave signals (immediate cleanup, no GC wait)
|
||||||
|
this.#unsubLeave = runtime.onCustomMessage('presence-leave', (msg: any) => {
|
||||||
|
const pid = msg.peerId;
|
||||||
|
if (!pid || pid === this.#localPeerId) return;
|
||||||
|
if (this.#peers.has(pid)) {
|
||||||
|
this.#peers.delete(pid);
|
||||||
|
this.#renderBadge();
|
||||||
|
if (!this.#badgeOnly) {
|
||||||
|
this.#renderCursors();
|
||||||
|
this.#renderFocusRings();
|
||||||
|
}
|
||||||
|
if (this.#panelOpen) this.#renderPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#connectToDoc() {
|
#connectToDoc() {
|
||||||
|
|
@ -362,9 +378,10 @@ export class RStackCollabOverlay extends HTMLElement {
|
||||||
|
|
||||||
#gcPeers() {
|
#gcPeers() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const staleThreshold = this.#externalPeers ? 30000 : 15000;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for (const [id, peer] of this.#peers) {
|
for (const [id, peer] of this.#peers) {
|
||||||
if (now - peer.lastSeen > 15000) {
|
if (now - peer.lastSeen > staleThreshold) {
|
||||||
this.#peers.delete(id);
|
this.#peers.delete(id);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -663,13 +663,10 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Zoom toggle icon rotates when expanded */
|
/* Zoom toggle icon — expand/minimize swap handled via JS */
|
||||||
#corner-zoom-toggle svg {
|
#corner-zoom-toggle svg {
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
}
|
}
|
||||||
#canvas-corner-tools:not(.collapsed) #corner-zoom-toggle svg {
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header history button ── */
|
/* ── Header history button ── */
|
||||||
.canvas-header-history {
|
.canvas-header-history {
|
||||||
|
|
@ -1700,10 +1697,11 @@
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapsed state on mobile */
|
/* Collapsed state on mobile — flush with bottom-toolbar */
|
||||||
#toolbar.collapsed {
|
#toolbar.collapsed {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
bottom: 60px;
|
bottom: 8px;
|
||||||
|
right: 6px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2537,11 +2535,60 @@
|
||||||
let moduleList = [];
|
let moduleList = [];
|
||||||
fetch("/api/modules").then(r => r.json()).then(data => {
|
fetch("/api/modules").then(r => r.json()).then(data => {
|
||||||
moduleList = data.modules || [];
|
moduleList = data.modules || [];
|
||||||
|
window.__rspaceAllModules = moduleList;
|
||||||
document.querySelector("rstack-app-switcher")?.setModules(moduleList);
|
document.querySelector("rstack-app-switcher")?.setModules(moduleList);
|
||||||
const tb = document.querySelector("rstack-tab-bar");
|
const tb = document.querySelector("rstack-tab-bar");
|
||||||
if (tb) tb.setModules(moduleList);
|
if (tb) tb.setModules(moduleList);
|
||||||
|
|
||||||
|
// Fetch space-specific enabled modules and apply filtering
|
||||||
|
const spaceSlug = window.location.pathname.split("/").filter(Boolean)[0] || "demo";
|
||||||
|
fetch(`/api/spaces/${encodeURIComponent(spaceSlug)}/modules`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(spaceData => {
|
||||||
|
if (!spaceData) return;
|
||||||
|
const enabledIds = spaceData.enabledModules; // null = all
|
||||||
|
window.__rspaceEnabledModules = enabledIds;
|
||||||
|
if (enabledIds) {
|
||||||
|
const enabledSet = new Set(enabledIds);
|
||||||
|
const filtered = moduleList.filter(m => m.id === "rspace" || enabledSet.has(m.id));
|
||||||
|
document.querySelector("rstack-app-switcher")?.setModules(filtered);
|
||||||
|
const tb2 = document.querySelector("rstack-tab-bar");
|
||||||
|
if (tb2) tb2.setModules(filtered);
|
||||||
|
}
|
||||||
|
// Initialize folk-rapp filtering
|
||||||
|
customElements.whenDefined("folk-rapp").then(() => {
|
||||||
|
const FolkRApp = customElements.get("folk-rapp");
|
||||||
|
if (FolkRApp?.setEnabledModules) FolkRApp.setEnabledModules(enabledIds);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// React to runtime module toggling from app switcher
|
||||||
|
document.addEventListener("modules-changed", (e) => {
|
||||||
|
const { enabledModules } = e.detail || {};
|
||||||
|
window.__rspaceEnabledModules = enabledModules;
|
||||||
|
|
||||||
|
const allMods = window.__rspaceAllModules || [];
|
||||||
|
const enabledSet = new Set(enabledModules);
|
||||||
|
const visible = allMods.filter(m => m.id === "rspace" || enabledSet.has(m.id));
|
||||||
|
const tb = document.querySelector("rstack-tab-bar");
|
||||||
|
if (tb) tb.setModules(visible);
|
||||||
|
|
||||||
|
const FolkRApp = customElements.get("folk-rapp");
|
||||||
|
if (FolkRApp?.setEnabledModules) FolkRApp.setEnabledModules(enabledModules);
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-requires-module]").forEach(el => {
|
||||||
|
el.style.display = enabledSet.has(el.dataset.requiresModule) ? "" : "none";
|
||||||
|
});
|
||||||
|
document.querySelectorAll(".toolbar-group").forEach(group => {
|
||||||
|
const dropdown = group.querySelector(".toolbar-dropdown");
|
||||||
|
if (!dropdown) return;
|
||||||
|
const vis = dropdown.querySelectorAll('button:not([style*="display: none"]):not(.toolbar-dropdown-header)');
|
||||||
|
group.style.display = vis.length === 0 ? "none" : "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Dark mode (default dark, toggled from My Account dropdown) ──
|
// ── Dark mode (default dark, toggled from My Account dropdown) ──
|
||||||
{
|
{
|
||||||
const savedTheme = localStorage.getItem("canvas-theme") || "dark";
|
const savedTheme = localStorage.getItem("canvas-theme") || "dark";
|
||||||
|
|
@ -2573,6 +2620,52 @@
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.warn("[Canvas] Service worker registration failed:", err);
|
console.warn("[Canvas] Service worker registration failed:", err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update banner: detect new SW activation and prompt reload
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||||
|
if (!document.getElementById("sw-update-banner")) {
|
||||||
|
showSwUpdateBanner();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
navigator.serviceWorker.getRegistration().then((reg) => {
|
||||||
|
if (!reg) return;
|
||||||
|
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||||
|
showSwUpdateBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reg.addEventListener("updatefound", () => {
|
||||||
|
const nw = reg.installing;
|
||||||
|
if (!nw) return;
|
||||||
|
nw.addEventListener("statechange", () => {
|
||||||
|
if (nw.state === "installed" && navigator.serviceWorker.controller) {
|
||||||
|
showSwUpdateBanner();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function showSwUpdateBanner() {
|
||||||
|
if (document.getElementById("sw-update-banner")) return;
|
||||||
|
const b = document.createElement("div");
|
||||||
|
b.id = "sw-update-banner";
|
||||||
|
b.setAttribute("role", "alert");
|
||||||
|
Object.assign(b.style, {
|
||||||
|
position: "fixed", top: "0", left: "0", right: "0", zIndex: "10000",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", gap: "12px",
|
||||||
|
padding: "10px 16px", background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||||
|
color: "white", fontSize: "14px", fontWeight: "500",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
boxShadow: "0 2px 12px rgba(0,0,0,0.3)",
|
||||||
|
animation: "sw-slide-down 0.3s ease-out",
|
||||||
|
});
|
||||||
|
b.innerHTML = '<span>New version available</span>'
|
||||||
|
+ '<button style="padding:5px 14px;border-radius:6px;border:1.5px solid rgba(255,255,255,0.5);background:rgba(255,255,255,0.15);color:white;font-size:13px;font-weight:600;cursor:pointer">Tap to update</button>'
|
||||||
|
+ '<button style="position:absolute;right:12px;top:50%;transform:translateY(-50%);background:none;border:none;color:rgba(255,255,255,0.7);font-size:20px;cursor:pointer;padding:4px 8px;line-height:1" aria-label="Dismiss">×</button>';
|
||||||
|
document.body.prepend(b);
|
||||||
|
b.querySelector("button")?.addEventListener("click", () => location.reload());
|
||||||
|
b.querySelector("[aria-label=Dismiss]")?.addEventListener("click", () => b.remove());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register custom elements
|
// Register custom elements
|
||||||
|
|
@ -3379,8 +3472,11 @@
|
||||||
sync.addEventListener("presence", (e) => {
|
sync.addEventListener("presence", (e) => {
|
||||||
// Always track last cursor for Navigate-to, but only show cursors in multiplayer
|
// Always track last cursor for Navigate-to, but only show cursors in multiplayer
|
||||||
const pid = e.detail.peerId;
|
const pid = e.detail.peerId;
|
||||||
if (pid && e.detail.cursor && onlinePeers.has(pid)) {
|
if (pid && onlinePeers.has(pid)) {
|
||||||
onlinePeers.get(pid).lastCursor = e.detail.cursor;
|
const peerInfo = onlinePeers.get(pid);
|
||||||
|
if (e.detail.cursor) peerInfo.lastCursor = e.detail.cursor;
|
||||||
|
// Refresh lastSeen on collab overlay so GC doesn't evict active peers
|
||||||
|
collabOverlay?.updatePeer(pid, peerInfo.username, peerInfo.color);
|
||||||
}
|
}
|
||||||
if (isMultiplayer) {
|
if (isMultiplayer) {
|
||||||
presence.updatePresence(e.detail);
|
presence.updatePresence(e.detail);
|
||||||
|
|
@ -6281,8 +6377,14 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
});
|
});
|
||||||
|
|
||||||
// Corner zoom toggle — expand/collapse zoom controls
|
// Corner zoom toggle — expand/collapse zoom controls
|
||||||
document.getElementById("corner-zoom-toggle").addEventListener("click", () => {
|
const cornerTools = document.getElementById("canvas-corner-tools");
|
||||||
document.getElementById("canvas-corner-tools").classList.toggle("collapsed");
|
const zoomToggleBtn = document.getElementById("corner-zoom-toggle");
|
||||||
|
const zoomExpandSVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>';
|
||||||
|
const zoomMinimizeSVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>';
|
||||||
|
zoomToggleBtn.addEventListener("click", () => {
|
||||||
|
const isNowCollapsed = cornerTools.classList.toggle("collapsed");
|
||||||
|
zoomToggleBtn.innerHTML = isNowCollapsed ? zoomExpandSVG : zoomMinimizeSVG;
|
||||||
|
zoomToggleBtn.title = isNowCollapsed ? "Zoom Controls" : "Hide Zoom";
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mobile toolbar toggle — collapse behavior same as desktop
|
// Mobile toolbar toggle — collapse behavior same as desktop
|
||||||
|
|
@ -6397,6 +6499,13 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
||||||
collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar";
|
collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-collapse toolbar on mobile
|
||||||
|
if (window.innerWidth <= 768) {
|
||||||
|
toolbarEl.classList.add("collapsed");
|
||||||
|
collapseBtn.innerHTML = wrenchSVG;
|
||||||
|
collapseBtn.title = "Expand toolbar";
|
||||||
|
}
|
||||||
|
|
||||||
// Mobile zoom controls (separate from toolbar)
|
// Mobile zoom controls (separate from toolbar)
|
||||||
document.getElementById("mz-in").addEventListener("click", () => {
|
document.getElementById("mz-in").addEventListener("click", () => {
|
||||||
scale = Math.min(scale * 1.1, maxScale);
|
scale = Math.min(scale * 1.1, maxScale);
|
||||||
|
|
|
||||||
|
|
@ -132,3 +132,89 @@ document.addEventListener("auth-change", (e) => {
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── SW Update Banner ──
|
||||||
|
// Show "new version available" when a new service worker activates.
|
||||||
|
// The SW calls skipWaiting() so it activates immediately — we detect the
|
||||||
|
// controller change and prompt the user to reload for the fresh content.
|
||||||
|
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
|
||||||
|
// Only listen if there's already a controller (skip first-time install)
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||||||
|
showUpdateBanner();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also detect waiting workers (edge case: skipWaiting didn't fire yet)
|
||||||
|
navigator.serviceWorker.getRegistration().then((reg) => {
|
||||||
|
if (!reg) return;
|
||||||
|
if (reg.waiting && navigator.serviceWorker.controller) {
|
||||||
|
showUpdateBanner();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reg.addEventListener("updatefound", () => {
|
||||||
|
const newWorker = reg.installing;
|
||||||
|
if (!newWorker) return;
|
||||||
|
newWorker.addEventListener("statechange", () => {
|
||||||
|
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
|
||||||
|
showUpdateBanner();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdateBanner() {
|
||||||
|
if (document.getElementById("sw-update-banner")) return;
|
||||||
|
|
||||||
|
const banner = document.createElement("div");
|
||||||
|
banner.id = "sw-update-banner";
|
||||||
|
banner.setAttribute("role", "alert");
|
||||||
|
banner.innerHTML = `
|
||||||
|
<span>New version available</span>
|
||||||
|
<button id="sw-update-btn">Tap to update</button>
|
||||||
|
<button id="sw-update-dismiss" aria-label="Dismiss">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = `
|
||||||
|
#sw-update-banner {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 10000;
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
color: white; font-size: 14px; font-weight: 500;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.3);
|
||||||
|
animation: sw-slide-down 0.3s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes sw-slide-down {
|
||||||
|
from { transform: translateY(-100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
#sw-update-btn {
|
||||||
|
padding: 5px 14px; border-radius: 6px; border: 1.5px solid rgba(255,255,255,0.5);
|
||||||
|
background: rgba(255,255,255,0.15); color: white;
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
#sw-update-btn:hover { background: rgba(255,255,255,0.3); }
|
||||||
|
#sw-update-dismiss {
|
||||||
|
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
|
||||||
|
background: none; border: none; color: rgba(255,255,255,0.7);
|
||||||
|
font-size: 20px; cursor: pointer; padding: 4px 8px; line-height: 1;
|
||||||
|
}
|
||||||
|
#sw-update-dismiss:hover { color: white; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.appendChild(style);
|
||||||
|
document.body.prepend(banner);
|
||||||
|
|
||||||
|
banner.querySelector("#sw-update-btn")!.addEventListener("click", () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
banner.querySelector("#sw-update-dismiss")!.addEventListener("click", () => {
|
||||||
|
banner.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue