Compare commits
2 Commits
f29cf02fb7
...
ba95df2a44
| Author | SHA1 | Date |
|---|---|---|
|
|
ba95df2a44 | |
|
|
8042e86815 |
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* rSchedule Automerge document schemas — Calendly-style booking.
|
||||
*
|
||||
* Three docs per space (or per user-space):
|
||||
* {space}:schedule:config — settings + availability rules + overrides + gcal state
|
||||
* {space}:schedule:bookings — bookings this space HOSTS (it is the bookee)
|
||||
* {space}:schedule:invitations — bookings this space is INVITED to (as attendee)
|
||||
*
|
||||
* On booking create, server writes to host's `bookings` doc AND each invitee's
|
||||
* `invitations` doc so every party sees the booking in their own schedule view
|
||||
* without cross-space reads.
|
||||
*/
|
||||
|
||||
import type { DocSchema } from '../../shared/local-first/document';
|
||||
|
||||
// ── Entity reference (space slug or user DID) ──
|
||||
|
||||
export type EntityKind = 'space' | 'user';
|
||||
|
||||
export interface EntityRef {
|
||||
kind: EntityKind;
|
||||
/** For `space`: the space slug. For `user`: the did:key:... */
|
||||
id: string;
|
||||
/** Display label — denormalized for UI, refreshed on render. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// ── Config doc ──
|
||||
|
||||
export interface ScheduleSettings {
|
||||
displayName: string;
|
||||
email: string;
|
||||
bookingMessage: string;
|
||||
/** Minutes per slot (15, 30, 45, 60, 90, 120). */
|
||||
slotDurationMin: number;
|
||||
bufferBeforeMin: number;
|
||||
bufferAfterMin: number;
|
||||
minNoticeHours: number;
|
||||
maxAdvanceDays: number;
|
||||
timezone: string;
|
||||
autoShiftTimezone: boolean;
|
||||
}
|
||||
|
||||
export interface AvailabilityRule {
|
||||
id: string;
|
||||
/** 0 = Sunday, 6 = Saturday (JS Date convention) */
|
||||
dayOfWeek: number;
|
||||
/** "HH:MM" 24h format, in `settings.timezone` */
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isActive: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface AvailabilityOverride {
|
||||
id: string;
|
||||
/** ISO date "YYYY-MM-DD" in `settings.timezone` */
|
||||
date: string;
|
||||
/** If `isBlocked=true`, the whole day is unavailable. Otherwise uses startTime/endTime. */
|
||||
isBlocked: boolean;
|
||||
startTime: string | null;
|
||||
endTime: string | null;
|
||||
reason: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface GoogleAuthMeta {
|
||||
connected: boolean;
|
||||
connectedAt: number | null;
|
||||
email: string | null;
|
||||
calendarIds: string[];
|
||||
/** Opaque sync token used for incremental list calls. */
|
||||
syncToken: string | null;
|
||||
lastSyncAt: number | null;
|
||||
lastSyncStatus: 'ok' | 'error' | null;
|
||||
lastSyncError: string | null;
|
||||
}
|
||||
|
||||
/** Cached Google Calendar busy events (opaque + non-cancelled). */
|
||||
export interface CachedGcalEvent {
|
||||
id: string;
|
||||
googleEventId: string;
|
||||
calendarId: string;
|
||||
title: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
allDay: boolean;
|
||||
status: 'confirmed' | 'tentative' | 'cancelled';
|
||||
transparency: 'opaque' | 'transparent';
|
||||
}
|
||||
|
||||
export interface ScheduleConfigDoc {
|
||||
meta: {
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
spaceSlug: string;
|
||||
createdAt: number;
|
||||
};
|
||||
settings: ScheduleSettings;
|
||||
rules: Record<string, AvailabilityRule>;
|
||||
overrides: Record<string, AvailabilityOverride>;
|
||||
googleAuth: GoogleAuthMeta;
|
||||
googleEvents: Record<string, CachedGcalEvent>;
|
||||
}
|
||||
|
||||
// ── Booking doc (host side) ──
|
||||
|
||||
export type BookingStatus = 'confirmed' | 'cancelled' | 'completed';
|
||||
|
||||
export interface BookingAttendee {
|
||||
id: string;
|
||||
entity: EntityRef | null;
|
||||
name: string;
|
||||
email: string;
|
||||
status: 'invited' | 'accepted' | 'declined';
|
||||
invitedAt: number;
|
||||
respondedAt: number | null;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
id: string;
|
||||
host: EntityRef;
|
||||
guestName: string;
|
||||
guestEmail: string;
|
||||
guestNote: string;
|
||||
attendees: Record<string, BookingAttendee>;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
timezone: string;
|
||||
status: BookingStatus;
|
||||
meetingLink: string | null;
|
||||
googleEventId: string | null;
|
||||
cancelToken: string;
|
||||
cancellationReason: string | null;
|
||||
reminderSentAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ScheduleBookingsDoc {
|
||||
meta: {
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
spaceSlug: string;
|
||||
createdAt: number;
|
||||
};
|
||||
bookings: Record<string, Booking>;
|
||||
}
|
||||
|
||||
// ── Invitations doc (attendee side) ──
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
host: EntityRef;
|
||||
title: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
timezone: string;
|
||||
status: BookingStatus;
|
||||
response: 'invited' | 'accepted' | 'declined';
|
||||
meetingLink: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ScheduleInvitationsDoc {
|
||||
meta: {
|
||||
module: string;
|
||||
collection: string;
|
||||
version: number;
|
||||
spaceSlug: string;
|
||||
createdAt: number;
|
||||
};
|
||||
invitations: Record<string, Invitation>;
|
||||
}
|
||||
|
||||
// ── Defaults ──
|
||||
|
||||
export const DEFAULT_SETTINGS: ScheduleSettings = {
|
||||
displayName: '',
|
||||
email: '',
|
||||
bookingMessage: 'Book a time to chat.',
|
||||
slotDurationMin: 30,
|
||||
bufferBeforeMin: 0,
|
||||
bufferAfterMin: 0,
|
||||
minNoticeHours: 2,
|
||||
maxAdvanceDays: 60,
|
||||
timezone: 'America/Vancouver',
|
||||
autoShiftTimezone: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_GOOGLE_AUTH: GoogleAuthMeta = {
|
||||
connected: false,
|
||||
connectedAt: null,
|
||||
email: null,
|
||||
calendarIds: [],
|
||||
syncToken: null,
|
||||
lastSyncAt: null,
|
||||
lastSyncStatus: null,
|
||||
lastSyncError: null,
|
||||
};
|
||||
|
||||
// ── Schema registrations ──
|
||||
|
||||
export const scheduleConfigSchema: DocSchema<ScheduleConfigDoc> = {
|
||||
module: 'schedule',
|
||||
collection: 'config',
|
||||
version: 1,
|
||||
init: (): ScheduleConfigDoc => ({
|
||||
meta: { module: 'schedule', collection: 'config', version: 1, spaceSlug: '', createdAt: Date.now() },
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
rules: {},
|
||||
overrides: {},
|
||||
googleAuth: { ...DEFAULT_GOOGLE_AUTH },
|
||||
googleEvents: {},
|
||||
}),
|
||||
};
|
||||
|
||||
export const scheduleBookingsSchema: DocSchema<ScheduleBookingsDoc> = {
|
||||
module: 'schedule',
|
||||
collection: 'bookings',
|
||||
version: 1,
|
||||
init: (): ScheduleBookingsDoc => ({
|
||||
meta: { module: 'schedule', collection: 'bookings', version: 1, spaceSlug: '', createdAt: Date.now() },
|
||||
bookings: {},
|
||||
}),
|
||||
};
|
||||
|
||||
export const scheduleInvitationsSchema: DocSchema<ScheduleInvitationsDoc> = {
|
||||
module: 'schedule',
|
||||
collection: 'invitations',
|
||||
version: 1,
|
||||
init: (): ScheduleInvitationsDoc => ({
|
||||
meta: { module: 'schedule', collection: 'invitations', version: 1, spaceSlug: '', createdAt: Date.now() },
|
||||
invitations: {},
|
||||
}),
|
||||
};
|
||||
|
||||
// ── DocId helpers ──
|
||||
|
||||
export function scheduleConfigDocId(space: string) {
|
||||
return `${space}:schedule:config` as const;
|
||||
}
|
||||
|
||||
export function scheduleBookingsDocId(space: string) {
|
||||
return `${space}:schedule:bookings` as const;
|
||||
}
|
||||
|
||||
export function scheduleInvitationsDocId(space: string) {
|
||||
return `${space}:schedule:invitations` as const;
|
||||
}
|
||||
|
||||
// ── Limits ──
|
||||
|
||||
export const MAX_RULES_PER_SPACE = 50;
|
||||
export const MAX_OVERRIDES_PER_SPACE = 500;
|
||||
export const MAX_GCAL_EVENTS_CACHED = 2000;
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* <folk-app-canvas> — Canvas host for an rApp's feature widgets.
|
||||
*
|
||||
* Replaces per-rApp tab-based navigation with a unified canvas surface where
|
||||
* each feature is a togglable widget. Supports grid mode (default — snap to
|
||||
* CSS grid template) and free mode (optional — absolute positioning; future).
|
||||
*
|
||||
* Attributes:
|
||||
* app-id — rApp identifier (used for layout persistence key, e.g. "rtasks")
|
||||
* space — current space slug
|
||||
* layout — JSON string of { id, gridArea, minimized }[] (optional; in-memory default)
|
||||
*
|
||||
* Child structure:
|
||||
* <folk-app-canvas>
|
||||
* <template slot="widget-registry"> — declarative widget menu (see below)
|
||||
* <li data-widget-id="board" data-title="Board" data-icon="📋" data-grid-area="board" data-default="1">
|
||||
* <folk-tasks-board></folk-tasks-board>
|
||||
* </li>
|
||||
* ...
|
||||
* </template>
|
||||
* </folk-app-canvas>
|
||||
*
|
||||
* Each <li> in the registry describes a widget definition. On canvas mount,
|
||||
* widgets marked data-default="1" are added to the visible grid. Toolbar
|
||||
* buttons toggle additional widgets.
|
||||
*
|
||||
* Grid template: responsive. Two-column on desktop, single-column on mobile.
|
||||
* Consumers can override via `grid-template` attribute.
|
||||
*/
|
||||
|
||||
import './folk-widget';
|
||||
|
||||
interface WidgetDef {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
gridArea: string;
|
||||
element: HTMLElement;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
class FolkAppCanvas extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private widgets = new Map<string, WidgetDef>();
|
||||
private visibleIds = new Set<string>();
|
||||
private _rendered = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.collectWidgetRegistry();
|
||||
this.loadInitialVisibility();
|
||||
this.render();
|
||||
this._rendered = true;
|
||||
}
|
||||
|
||||
private collectWidgetRegistry() {
|
||||
const tpl = this.querySelector('template[slot="widget-registry"]') as HTMLTemplateElement | null;
|
||||
if (!tpl) {
|
||||
console.warn('[folk-app-canvas] no widget-registry template found');
|
||||
return;
|
||||
}
|
||||
const nodes = tpl.content.querySelectorAll('li[data-widget-id]');
|
||||
nodes.forEach((li) => {
|
||||
const id = (li as HTMLElement).dataset.widgetId!;
|
||||
const title = (li as HTMLElement).dataset.title || id;
|
||||
const icon = (li as HTMLElement).dataset.icon || '';
|
||||
const gridArea = (li as HTMLElement).dataset.gridArea || id;
|
||||
const isDefault = (li as HTMLElement).dataset.default === '1';
|
||||
// Take the first element child as the widget content
|
||||
const content = (li as HTMLElement).firstElementChild as HTMLElement | null;
|
||||
if (!content) return;
|
||||
this.widgets.set(id, { id, title, icon, gridArea, element: content.cloneNode(true) as HTMLElement, default: isDefault });
|
||||
});
|
||||
}
|
||||
|
||||
private loadInitialVisibility() {
|
||||
// URL override: ?widgets=board,activity
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const widgetsParam = params.get('widgets');
|
||||
if (widgetsParam) {
|
||||
widgetsParam.split(',').forEach(id => {
|
||||
if (this.widgets.has(id.trim())) this.visibleIds.add(id.trim());
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Otherwise use defaults
|
||||
this.widgets.forEach((w, id) => {
|
||||
if (w.default) this.visibleIds.add(id);
|
||||
});
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>${this.getStyles()}</style>
|
||||
<div class="canvas-root">
|
||||
<nav class="toolbar" part="toolbar">
|
||||
<div class="toolbar-title">
|
||||
<span class="toolbar-label">Widgets</span>
|
||||
</div>
|
||||
<div class="toolbar-buttons">
|
||||
${Array.from(this.widgets.values()).map(w => this.renderToolbarBtn(w)).join('')}
|
||||
</div>
|
||||
<div class="toolbar-meta">
|
||||
<button class="meta-btn" data-action="reset" title="Reset to default layout">Reset</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="grid" part="grid">
|
||||
${this.renderGridSlots()}
|
||||
</div>
|
||||
</div>`;
|
||||
this.bindEvents();
|
||||
this.mountVisibleWidgets();
|
||||
}
|
||||
|
||||
private renderToolbarBtn(w: WidgetDef): string {
|
||||
const active = this.visibleIds.has(w.id);
|
||||
return `<button class="tb-btn ${active ? 'active' : ''}" data-action="toggle" data-widget-id="${this.esc(w.id)}" title="${this.esc(w.title)}">
|
||||
<span class="tb-icon">${this.esc(w.icon)}</span>
|
||||
<span class="tb-label">${this.esc(w.title)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private renderGridSlots(): string {
|
||||
// Empty grid cells — widgets will be mounted into the grid via JS with grid-area
|
||||
return `<div class="grid-slots" id="grid-slots"></div>`;
|
||||
}
|
||||
|
||||
private mountVisibleWidgets() {
|
||||
const slots = this.shadow.getElementById('grid-slots');
|
||||
if (!slots) return;
|
||||
slots.innerHTML = '';
|
||||
this.visibleIds.forEach(id => {
|
||||
const def = this.widgets.get(id);
|
||||
if (!def) return;
|
||||
const wrapper = document.createElement('folk-widget');
|
||||
wrapper.setAttribute('widget-id', id);
|
||||
wrapper.setAttribute('title', def.title);
|
||||
wrapper.setAttribute('icon', def.icon);
|
||||
wrapper.setAttribute('grid-area', def.gridArea);
|
||||
wrapper.appendChild(def.element.cloneNode(true));
|
||||
wrapper.addEventListener('widget-close', () => {
|
||||
this.visibleIds.delete(id);
|
||||
this.render();
|
||||
});
|
||||
slots.appendChild(wrapper);
|
||||
});
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
this.shadow.querySelectorAll('[data-action="toggle"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = (btn as HTMLElement).dataset.widgetId!;
|
||||
if (this.visibleIds.has(id)) this.visibleIds.delete(id);
|
||||
else this.visibleIds.add(id);
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
this.shadow.querySelector('[data-action="reset"]')?.addEventListener('click', () => {
|
||||
this.visibleIds.clear();
|
||||
this.widgets.forEach((w, id) => { if (w.default) this.visibleIds.add(id); });
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private getStyles(): string {
|
||||
// Default grid template: designed for up-to-4 widgets in a 2x2 arrangement.
|
||||
// Consuming rApps should override via ::part(grid) { grid-template: ... }
|
||||
return `
|
||||
:host { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; background: var(--rs-bg-base); }
|
||||
.canvas-root { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--rs-bg-surface);
|
||||
border-bottom: 1px solid var(--rs-border);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.toolbar-title { display: flex; align-items: center; flex-shrink: 0; }
|
||||
.toolbar-label { font-size: 0.75rem; font-weight: 600; color: var(--rs-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.toolbar-buttons { display: flex; gap: 0.375rem; flex: 1; flex-wrap: wrap; }
|
||||
.toolbar-meta { display: flex; gap: 0.375rem; flex-shrink: 0; }
|
||||
.tb-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--rs-border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--rs-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tb-btn:hover { border-color: var(--rs-border-strong); color: var(--rs-text-primary); }
|
||||
.tb-btn.active {
|
||||
background: rgba(99,102,241,0.12);
|
||||
border-color: var(--rs-primary, #6366f1);
|
||||
color: var(--rs-text-primary);
|
||||
}
|
||||
.tb-icon { font-size: 0.95rem; line-height: 1; }
|
||||
.meta-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--rs-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--rs-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.meta-btn:hover { color: var(--rs-text-primary); border-color: var(--rs-border-strong); }
|
||||
|
||||
.grid { flex: 1; min-height: 0; overflow: hidden; padding: 1rem; }
|
||||
.grid-slots {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
grid-auto-flow: dense;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
/* Named grid areas — rApps can reference via widget grid-area="board" etc.
|
||||
If an area isn't defined in the template, the widget falls back to auto-placement. */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid-slots {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: minmax(260px, auto);
|
||||
}
|
||||
.toolbar { padding: 0.5rem 0.75rem; }
|
||||
.tb-label { display: none; }
|
||||
.tb-btn { padding: 0.375rem 0.5rem; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('folk-app-canvas')) customElements.define('folk-app-canvas', FolkAppCanvas);
|
||||
|
||||
export {};
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* <folk-widget> — Widget shell for rApp canvas surfaces.
|
||||
*
|
||||
* Provides a consistent title bar (icon + label + controls) and a slotted body.
|
||||
* Used inside <folk-app-canvas> to wrap feature views as togglable overlays.
|
||||
*
|
||||
* Attributes:
|
||||
* widget-id — unique id within the canvas (used for layout + toolbar state)
|
||||
* title — display label
|
||||
* icon — emoji/glyph shown in title bar
|
||||
* grid-area — optional CSS grid-area (for grid-mode canvas)
|
||||
* minimized — boolean; collapses body
|
||||
* no-close — hides the close button
|
||||
*
|
||||
* Events:
|
||||
* widget-close — user clicked ×
|
||||
* widget-minimize — user toggled –
|
||||
* widget-focus — user clicked header (for z-stacking / detail panels)
|
||||
*/
|
||||
|
||||
class FolkWidget extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['title', 'icon', 'minimized', 'grid-area'];
|
||||
}
|
||||
|
||||
private shadow: ShadowRoot;
|
||||
private _rendered = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.shadow = this.attachShadow({ mode: 'open' });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (!this._rendered) {
|
||||
this.render();
|
||||
this._rendered = true;
|
||||
}
|
||||
this.applyGridArea();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string) {
|
||||
if (!this._rendered) return;
|
||||
if (name === 'title' || name === 'icon') {
|
||||
this.updateHeader();
|
||||
} else if (name === 'minimized') {
|
||||
this.updateMinimized();
|
||||
} else if (name === 'grid-area') {
|
||||
this.applyGridArea();
|
||||
}
|
||||
}
|
||||
|
||||
private applyGridArea() {
|
||||
const area = this.getAttribute('grid-area');
|
||||
if (area) this.style.gridArea = area;
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.shadow.innerHTML = `
|
||||
<style>${this.getStyles()}</style>
|
||||
<div class="widget" part="widget">
|
||||
<header class="header" part="header">
|
||||
<div class="title">
|
||||
<span class="icon" part="icon">${this.esc(this.getAttribute('icon') || '')}</span>
|
||||
<span class="label" part="label">${this.esc(this.getAttribute('title') || 'Widget')}</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="ctl" data-action="minimize" title="Minimize" aria-label="Minimize">–</button>
|
||||
${this.hasAttribute('no-close') ? '' : `<button class="ctl" data-action="close" title="Close" aria-label="Close">×</button>`}
|
||||
</div>
|
||||
</header>
|
||||
<div class="body" part="body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>`;
|
||||
this.bindEvents();
|
||||
this.updateMinimized();
|
||||
}
|
||||
|
||||
private updateHeader() {
|
||||
const icon = this.shadow.querySelector('.icon');
|
||||
const label = this.shadow.querySelector('.label');
|
||||
if (icon) icon.textContent = this.getAttribute('icon') || '';
|
||||
if (label) label.textContent = this.getAttribute('title') || 'Widget';
|
||||
}
|
||||
|
||||
private updateMinimized() {
|
||||
const w = this.shadow.querySelector('.widget');
|
||||
if (!w) return;
|
||||
w.classList.toggle('minimized', this.hasAttribute('minimized'));
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
const header = this.shadow.querySelector('.header');
|
||||
header?.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.controls')) return;
|
||||
this.dispatchEvent(new CustomEvent('widget-focus', { bubbles: true, composed: true, detail: { widgetId: this.getAttribute('widget-id') } }));
|
||||
});
|
||||
this.shadow.querySelector('[data-action="minimize"]')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const mini = this.hasAttribute('minimized');
|
||||
if (mini) this.removeAttribute('minimized'); else this.setAttribute('minimized', '');
|
||||
this.dispatchEvent(new CustomEvent('widget-minimize', { bubbles: true, composed: true, detail: { widgetId: this.getAttribute('widget-id'), minimized: !mini } }));
|
||||
});
|
||||
this.shadow.querySelector('[data-action="close"]')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent('widget-close', { bubbles: true, composed: true, detail: { widgetId: this.getAttribute('widget-id') } }));
|
||||
});
|
||||
}
|
||||
|
||||
private getStyles(): string {
|
||||
return `
|
||||
:host { display: block; min-width: 0; min-height: 0; }
|
||||
.widget {
|
||||
display: flex; flex-direction: column;
|
||||
height: 100%; width: 100%;
|
||||
background: var(--rs-bg-surface);
|
||||
border: 1px solid var(--rs-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--rs-bg-surface-raised, var(--rs-bg-hover));
|
||||
border-bottom: 1px solid var(--rs-border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.title { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
|
||||
.icon { font-size: 1rem; flex-shrink: 0; }
|
||||
.label { font-size: 0.8125rem; font-weight: 600; color: var(--rs-text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.controls { display: flex; gap: 0.125rem; flex-shrink: 0; }
|
||||
.ctl {
|
||||
background: transparent; border: none; color: var(--rs-text-secondary);
|
||||
cursor: pointer; padding: 0.125rem 0.5rem; border-radius: 4px;
|
||||
font-size: 1rem; line-height: 1; font-weight: 500;
|
||||
}
|
||||
.ctl:hover { background: var(--rs-bg-hover); color: var(--rs-text-primary); }
|
||||
.body { flex: 1; min-height: 0; overflow: auto; display: flex; flex-direction: column; }
|
||||
.body > ::slotted(*) { flex: 1; min-height: 0; }
|
||||
.widget.minimized .body { display: none; }
|
||||
.widget.minimized { height: auto !important; }
|
||||
`;
|
||||
}
|
||||
|
||||
private esc(s: string): string {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('folk-widget')) customElements.define('folk-widget', FolkWidget);
|
||||
|
||||
export {};
|
||||
Loading…
Reference in New Issue