Add trip planning components and postMessage bridge for rtrips.online
New folk-* web components for collaborative trip planning: - folk-itinerary: timeline with day grouping and category icons - folk-destination: location card with editable notes - folk-budget: expense tracker with progress bar - folk-packing-list: collaborative checklist with progress - folk-booking: booking card with type/status badges Also adds postMessage broadcasting in community-sync for iframe embedding in rtrips.online, and toolbar buttons in canvas.html. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9b8784a0ac
commit
2634d02344
|
|
@ -290,6 +290,10 @@ export class CommunitySync extends EventTarget {
|
|||
#handleShapeChange(shape: FolkShape): void {
|
||||
this.#updateShapeInDoc(shape);
|
||||
this.#syncToServer();
|
||||
|
||||
// Broadcast to parent frame (for rtrips.online integration)
|
||||
const shapeData = this.#shapeToData(shape);
|
||||
this.#postMessageToParent("shape-updated", shapeData);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -410,6 +414,8 @@ export class CommunitySync extends EventTarget {
|
|||
} else if (shapeData) {
|
||||
// Shape created or updated
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
// Broadcast to parent frame
|
||||
this.#postMessageToParent("shape-updated", shapeData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -552,4 +558,26 @@ export class CommunitySync extends EventTarget {
|
|||
this.#syncState = Automerge.initSyncState();
|
||||
this.#applyDocToDOM();
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast shape updates to parent frame (for iframe embedding in rtrips.online)
|
||||
*/
|
||||
#postMessageToParent(type: string, data: ShapeData): void {
|
||||
if (typeof window === "undefined" || window.parent === window) return;
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
source: "rspace-canvas",
|
||||
type,
|
||||
communitySlug: this.#communitySlug,
|
||||
shapeId: data.id,
|
||||
data,
|
||||
},
|
||||
"*"
|
||||
);
|
||||
} catch {
|
||||
// postMessage may fail in certain security contexts
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,359 @@
|
|||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-width: 280px;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
color: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.header.FLIGHT { background: #2563eb; }
|
||||
.header.HOTEL { background: #7c3aed; }
|
||||
.header.CAR_RENTAL { background: #d97706; }
|
||||
.header.TRAIN { background: #059669; }
|
||||
.header.BUS { background: #0891b2; }
|
||||
.header.FERRY { background: #0284c7; }
|
||||
.header.ACTIVITY { background: #0d9488; }
|
||||
.header.RESTAURANT { background: #ea580c; }
|
||||
.header.OTHER { background: #475569; }
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.booking-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.provider {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.details {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confirmation {
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.confirmation .label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.confirmation .code {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.cost {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.status-badge.PLANNED {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.status-badge.BOOKED {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.status-badge.CONFIRMED {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.status-badge.CANCELLED {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
`;
|
||||
|
||||
const BOOKING_TYPE_ICONS: Record<string, string> = {
|
||||
FLIGHT: "\u2708\uFE0F",
|
||||
HOTEL: "\uD83C\uDFE8",
|
||||
CAR_RENTAL: "\uD83D\uDE97",
|
||||
TRAIN: "\uD83D\uDE84",
|
||||
BUS: "\uD83D\uDE8C",
|
||||
FERRY: "\u26F4\uFE0F",
|
||||
ACTIVITY: "\uD83C\uDFAF",
|
||||
RESTAURANT: "\uD83C\uDF7D\uFE0F",
|
||||
OTHER: "\uD83D\uDCCC",
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-booking": FolkBooking;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkBooking extends FolkShape {
|
||||
static override tagName = "folk-booking";
|
||||
|
||||
static {
|
||||
const sheet = new CSSStyleSheet();
|
||||
const parentRules = Array.from(FolkShape.styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
const childRules = Array.from(styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
||||
this.styles = sheet;
|
||||
}
|
||||
|
||||
#bookingType = "OTHER";
|
||||
#provider = "";
|
||||
#confirmationNumber = "";
|
||||
#details = "";
|
||||
#cost: number | null = null;
|
||||
#currency = "USD";
|
||||
#startDate = "";
|
||||
#endDate = "";
|
||||
#bookingStatus = "PLANNED";
|
||||
|
||||
#headerEl: HTMLElement | null = null;
|
||||
#bodyEl: HTMLElement | null = null;
|
||||
|
||||
get bookingType() { return this.#bookingType; }
|
||||
set bookingType(v: string) {
|
||||
this.#bookingType = v;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get provider() { return this.#provider; }
|
||||
set provider(v: string) {
|
||||
this.#provider = v;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get confirmationNumber() { return this.#confirmationNumber; }
|
||||
set confirmationNumber(v: string) {
|
||||
this.#confirmationNumber = v;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get details() { return this.#details; }
|
||||
set details(v: string) {
|
||||
this.#details = v;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get cost() { return this.#cost; }
|
||||
set cost(v: number | null) {
|
||||
this.#cost = v;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get currency() { return this.#currency; }
|
||||
set currency(v: string) { this.#currency = v; this.#render(); }
|
||||
|
||||
get startDate() { return this.#startDate; }
|
||||
set startDate(v: string) { this.#startDate = v; this.#render(); }
|
||||
|
||||
get endDate() { return this.#endDate; }
|
||||
set endDate(v: string) { this.#endDate = v; this.#render(); }
|
||||
|
||||
get bookingStatus() { return this.#bookingStatus; }
|
||||
set bookingStatus(v: string) {
|
||||
this.#bookingStatus = v;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header OTHER">
|
||||
<span class="header-title">
|
||||
<span class="type-icon">\uD83D\uDCCC</span>
|
||||
<span class="type-label">Booking</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">\u00D7</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="booking-body"></div>
|
||||
`;
|
||||
|
||||
const slot = root.querySelector("slot");
|
||||
const containerDiv = slot?.parentElement as HTMLElement;
|
||||
if (containerDiv) {
|
||||
containerDiv.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
this.#headerEl = wrapper.querySelector(".header");
|
||||
this.#bodyEl = wrapper.querySelector(".booking-body");
|
||||
|
||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
this.#render();
|
||||
return root;
|
||||
}
|
||||
|
||||
#escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
#formatDate(dateStr: string): string {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
#render() {
|
||||
if (!this.#headerEl || !this.#bodyEl) return;
|
||||
|
||||
const icon = BOOKING_TYPE_ICONS[this.#bookingType] || BOOKING_TYPE_ICONS.OTHER;
|
||||
|
||||
// Update header
|
||||
this.#headerEl.className = `header ${this.#bookingType}`;
|
||||
const iconEl = this.#headerEl.querySelector(".type-icon");
|
||||
const labelEl = this.#headerEl.querySelector(".type-label");
|
||||
if (iconEl) iconEl.textContent = icon;
|
||||
if (labelEl) labelEl.textContent = this.#bookingType.replace("_", " ");
|
||||
|
||||
// Build body
|
||||
let bodyHTML = "";
|
||||
|
||||
if (this.#provider) {
|
||||
bodyHTML += `<div class="provider">${this.#escapeHtml(this.#provider)}</div>`;
|
||||
}
|
||||
|
||||
if (this.#details) {
|
||||
bodyHTML += `<div class="details">${this.#escapeHtml(this.#details)}</div>`;
|
||||
}
|
||||
|
||||
if (this.#startDate || this.#endDate) {
|
||||
bodyHTML += `<div class="meta-row">`;
|
||||
if (this.#startDate) {
|
||||
bodyHTML += `<div class="meta-item"><span class="meta-label">From:</span><span class="meta-value">${this.#formatDate(this.#startDate)}</span></div>`;
|
||||
}
|
||||
if (this.#endDate) {
|
||||
bodyHTML += `<div class="meta-item"><span class="meta-label">To:</span><span class="meta-value">${this.#formatDate(this.#endDate)}</span></div>`;
|
||||
}
|
||||
bodyHTML += `</div>`;
|
||||
}
|
||||
|
||||
if (this.#confirmationNumber) {
|
||||
bodyHTML += `<div class="confirmation"><span class="label">Confirmation</span><span class="code">${this.#escapeHtml(this.#confirmationNumber)}</span></div>`;
|
||||
}
|
||||
|
||||
if (this.#cost !== null) {
|
||||
bodyHTML += `<div class="cost">${this.#currency} ${this.#cost.toLocaleString()}</div>`;
|
||||
}
|
||||
|
||||
bodyHTML += `<span class="status-badge ${this.#bookingStatus}">${this.#bookingStatus}</span>`;
|
||||
|
||||
this.#bodyEl.innerHTML = bodyHTML;
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-booking",
|
||||
bookingType: this.#bookingType,
|
||||
provider: this.#provider,
|
||||
confirmationNumber: this.#confirmationNumber,
|
||||
details: this.#details,
|
||||
cost: this.#cost,
|
||||
currency: this.#currency,
|
||||
startDate: this.#startDate,
|
||||
endDate: this.#endDate,
|
||||
bookingStatus: this.#bookingStatus,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-width: 280px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #d97706;
|
||||
color: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.budget-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.budget-summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.budget-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.budget-row .label {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.budget-row .value {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.budget-row .remaining {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.budget-row .over {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-fill.ok {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.progress-fill.warning {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.progress-fill.danger {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.expense-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.expense-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 3px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.expense-item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.expense-desc {
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.expense-cat {
|
||||
font-size: 9px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.expense-amount {
|
||||
font-weight: 600;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.add-form input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-form input:focus {
|
||||
border-color: #d97706;
|
||||
}
|
||||
|
||||
.add-form input.amount-input {
|
||||
width: 80px;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface BudgetExpense {
|
||||
id: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-budget": FolkBudget;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkBudget extends FolkShape {
|
||||
static override tagName = "folk-budget";
|
||||
|
||||
static {
|
||||
const sheet = new CSSStyleSheet();
|
||||
const parentRules = Array.from(FolkShape.styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
const childRules = Array.from(styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
||||
this.styles = sheet;
|
||||
}
|
||||
|
||||
#budgetTotal = 0;
|
||||
#currency = "USD";
|
||||
#expenses: BudgetExpense[] = [];
|
||||
#listEl: HTMLElement | null = null;
|
||||
#summaryEl: HTMLElement | null = null;
|
||||
#progressEl: HTMLElement | null = null;
|
||||
|
||||
get budgetTotal() { return this.#budgetTotal; }
|
||||
set budgetTotal(v: number) {
|
||||
this.#budgetTotal = v;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get currency() { return this.#currency; }
|
||||
set currency(v: string) {
|
||||
this.#currency = v;
|
||||
this.#render();
|
||||
}
|
||||
|
||||
get expenses() { return this.#expenses; }
|
||||
set expenses(v: BudgetExpense[]) {
|
||||
this.#expenses = v;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get spent() {
|
||||
return this.#expenses.reduce((sum, e) => sum + e.amount, 0);
|
||||
}
|
||||
|
||||
addExpense(expense: BudgetExpense) {
|
||||
this.#expenses.push(expense);
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span>\uD83D\uDCB0</span>
|
||||
<span>Budget</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">\u00D7</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-body">
|
||||
<div class="budget-summary"></div>
|
||||
<div class="progress-bar"><div class="progress-fill"></div></div>
|
||||
<div class="expense-list"></div>
|
||||
</div>
|
||||
<div class="add-form">
|
||||
<input type="text" placeholder="Expense description..." class="desc-input" />
|
||||
<input type="number" placeholder="0.00" class="amount-input" step="0.01" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
const slot = root.querySelector("slot");
|
||||
const containerDiv = slot?.parentElement as HTMLElement;
|
||||
if (containerDiv) {
|
||||
containerDiv.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
this.#summaryEl = wrapper.querySelector(".budget-summary");
|
||||
this.#progressEl = wrapper.querySelector(".progress-fill");
|
||||
this.#listEl = wrapper.querySelector(".expense-list");
|
||||
|
||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||
const descInput = wrapper.querySelector(".desc-input") as HTMLInputElement;
|
||||
const amountInput = wrapper.querySelector(".amount-input") as HTMLInputElement;
|
||||
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
const addExpense = () => {
|
||||
if (descInput.value.trim() && amountInput.value) {
|
||||
this.addExpense({
|
||||
id: `exp-${Date.now()}`,
|
||||
description: descInput.value.trim(),
|
||||
amount: Number(amountInput.value),
|
||||
category: "OTHER",
|
||||
});
|
||||
descInput.value = "";
|
||||
amountInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
descInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.stopPropagation(); addExpense(); }
|
||||
});
|
||||
amountInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.stopPropagation(); addExpense(); }
|
||||
});
|
||||
descInput.addEventListener("click", (e) => e.stopPropagation());
|
||||
amountInput.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
this.#render();
|
||||
return root;
|
||||
}
|
||||
|
||||
#escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
#render() {
|
||||
if (!this.#summaryEl || !this.#progressEl || !this.#listEl) return;
|
||||
|
||||
const spent = this.spent;
|
||||
const remaining = this.#budgetTotal - spent;
|
||||
const pct = this.#budgetTotal > 0 ? (spent / this.#budgetTotal) * 100 : 0;
|
||||
|
||||
this.#summaryEl.innerHTML = `
|
||||
<div class="budget-row">
|
||||
<span class="label">Budget</span>
|
||||
<span class="value">${this.#currency} ${this.#budgetTotal.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="budget-row">
|
||||
<span class="label">Spent</span>
|
||||
<span class="value">${this.#currency} ${spent.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="budget-row">
|
||||
<span class="label">Remaining</span>
|
||||
<span class="value ${remaining >= 0 ? 'remaining' : 'over'}">${this.#currency} ${Math.abs(remaining).toLocaleString()}${remaining < 0 ? ' over' : ''}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.#progressEl.style.width = `${Math.min(pct, 100)}%`;
|
||||
this.#progressEl.className = `progress-fill ${pct > 90 ? 'danger' : pct > 70 ? 'warning' : 'ok'}`;
|
||||
|
||||
if (this.#expenses.length === 0) {
|
||||
this.#listEl.innerHTML = '<div class="empty-state">No expenses yet</div>';
|
||||
} else {
|
||||
this.#listEl.innerHTML = this.#expenses
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((e) => `
|
||||
<div class="expense-item">
|
||||
<div>
|
||||
<div class="expense-desc">${this.#escapeHtml(e.description)}</div>
|
||||
<div class="expense-cat">${this.#escapeHtml(e.category)}</div>
|
||||
</div>
|
||||
<span class="expense-amount">-${this.#currency} ${e.amount.toLocaleString()}</span>
|
||||
</div>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-budget",
|
||||
budgetTotal: this.#budgetTotal,
|
||||
currency: this.#currency,
|
||||
expenses: this.#expenses,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-width: 260px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #059669;
|
||||
color: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.dest-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dest-name {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.dest-country {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dest-dates {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 8px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dest-dates span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dest-dates .label {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dest-notes {
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
line-height: 1.5;
|
||||
padding: 8px;
|
||||
background: #fafaf9;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e2e8f0;
|
||||
min-height: 40px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dest-notes[contenteditable]:focus {
|
||||
outline: none;
|
||||
border-color: #059669;
|
||||
}
|
||||
|
||||
.coords {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.empty-name {
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-destination": FolkDestination;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkDestination extends FolkShape {
|
||||
static override tagName = "folk-destination";
|
||||
|
||||
static {
|
||||
const sheet = new CSSStyleSheet();
|
||||
const parentRules = Array.from(FolkShape.styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
const childRules = Array.from(styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
||||
this.styles = sheet;
|
||||
}
|
||||
|
||||
#destName = "";
|
||||
#country = "";
|
||||
#lat: number | null = null;
|
||||
#lng: number | null = null;
|
||||
#arrivalDate = "";
|
||||
#departureDate = "";
|
||||
#notes = "";
|
||||
#nameEl: HTMLElement | null = null;
|
||||
#countryEl: HTMLElement | null = null;
|
||||
#notesEl: HTMLElement | null = null;
|
||||
#datesEl: HTMLElement | null = null;
|
||||
|
||||
get destName() { return this.#destName; }
|
||||
set destName(v: string) {
|
||||
this.#destName = v;
|
||||
if (this.#nameEl) this.#nameEl.textContent = v || "Unnamed destination";
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get country() { return this.#country; }
|
||||
set country(v: string) {
|
||||
this.#country = v;
|
||||
if (this.#countryEl) this.#countryEl.textContent = v;
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get lat() { return this.#lat; }
|
||||
set lat(v: number | null) { this.#lat = v; }
|
||||
|
||||
get lng() { return this.#lng; }
|
||||
set lng(v: number | null) { this.#lng = v; }
|
||||
|
||||
get arrivalDate() { return this.#arrivalDate; }
|
||||
set arrivalDate(v: string) {
|
||||
this.#arrivalDate = v;
|
||||
this.#renderDates();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get departureDate() { return this.#departureDate; }
|
||||
set departureDate(v: string) {
|
||||
this.#departureDate = v;
|
||||
this.#renderDates();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get notes() { return this.#notes; }
|
||||
set notes(v: string) {
|
||||
this.#notes = v;
|
||||
if (this.#notesEl && this.#notesEl.textContent !== v) {
|
||||
this.#notesEl.textContent = v;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span>\uD83D\uDCCD</span>
|
||||
<span>Destination</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">\u00D7</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dest-body">
|
||||
<div class="dest-name"></div>
|
||||
<div class="dest-country"></div>
|
||||
<div class="dest-dates"></div>
|
||||
<div class="dest-notes" contenteditable="true"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const slot = root.querySelector("slot");
|
||||
const containerDiv = slot?.parentElement as HTMLElement;
|
||||
if (containerDiv) {
|
||||
containerDiv.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
this.#nameEl = wrapper.querySelector(".dest-name");
|
||||
this.#countryEl = wrapper.querySelector(".dest-country");
|
||||
this.#datesEl = wrapper.querySelector(".dest-dates");
|
||||
this.#notesEl = wrapper.querySelector(".dest-notes");
|
||||
|
||||
if (this.#nameEl) {
|
||||
this.#nameEl.textContent = this.#destName || "Unnamed destination";
|
||||
if (!this.#destName) this.#nameEl.classList.add("empty-name");
|
||||
}
|
||||
if (this.#countryEl) this.#countryEl.textContent = this.#country;
|
||||
|
||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
if (this.#notesEl) {
|
||||
this.#notesEl.textContent = this.#notes;
|
||||
this.#notesEl.addEventListener("input", () => {
|
||||
this.#notes = this.#notesEl!.textContent || "";
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
});
|
||||
this.#notesEl.addEventListener("click", (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
this.#renderDates();
|
||||
return root;
|
||||
}
|
||||
|
||||
#renderDates() {
|
||||
if (!this.#datesEl) return;
|
||||
if (!this.#arrivalDate && !this.#departureDate) {
|
||||
this.#datesEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
this.#datesEl.style.display = "flex";
|
||||
let result = "";
|
||||
if (this.#arrivalDate) {
|
||||
const d = new Date(this.#arrivalDate);
|
||||
result += `<span><span class="label">Arrive:</span> ${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>`;
|
||||
}
|
||||
if (this.#departureDate) {
|
||||
const d = new Date(this.#departureDate);
|
||||
result += `<span><span class="label">Depart:</span> ${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}</span>`;
|
||||
}
|
||||
this.#datesEl.innerHTML = result;
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-destination",
|
||||
destName: this.#destName,
|
||||
country: this.#country,
|
||||
lat: this.#lat,
|
||||
lng: this.#lng,
|
||||
arrivalDate: this.#arrivalDate,
|
||||
departureDate: this.#departureDate,
|
||||
notes: this.#notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-width: 300px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #0d9488;
|
||||
color: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.itinerary-container {
|
||||
padding: 12px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.day-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #0d9488;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.item-time {
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
font-size: 9px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.add-form input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-form input:focus {
|
||||
border-color: #0d9488;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface ItineraryEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
FLIGHT: "\u2708\uFE0F",
|
||||
TRANSPORT: "\uD83D\uDE8C",
|
||||
ACCOMMODATION: "\uD83C\uDFE8",
|
||||
ACTIVITY: "\uD83C\uDFAF",
|
||||
MEAL: "\uD83C\uDF7D\uFE0F",
|
||||
FREE_TIME: "\u2600\uFE0F",
|
||||
OTHER: "\uD83D\uDCCC",
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-itinerary": FolkItinerary;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkItinerary extends FolkShape {
|
||||
static override tagName = "folk-itinerary";
|
||||
|
||||
static {
|
||||
const sheet = new CSSStyleSheet();
|
||||
const parentRules = Array.from(FolkShape.styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
const childRules = Array.from(styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
||||
this.styles = sheet;
|
||||
}
|
||||
|
||||
#items: ItineraryEntry[] = [];
|
||||
#tripTitle = "Trip Itinerary";
|
||||
#listEl: HTMLElement | null = null;
|
||||
|
||||
get items() {
|
||||
return this.#items;
|
||||
}
|
||||
|
||||
set items(items: ItineraryEntry[]) {
|
||||
this.#items = items;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get tripTitle() {
|
||||
return this.#tripTitle;
|
||||
}
|
||||
|
||||
set tripTitle(title: string) {
|
||||
this.#tripTitle = title;
|
||||
this.#render();
|
||||
}
|
||||
|
||||
addItem(item: ItineraryEntry) {
|
||||
this.#items.push(item);
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span>\uD83D\uDCC5</span>
|
||||
<span class="title-text">Itinerary</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">\u00D7</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="itinerary-container">
|
||||
<div class="item-list"></div>
|
||||
</div>
|
||||
<div class="add-form">
|
||||
<input type="text" placeholder="Add itinerary item..." class="add-input" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
const slot = root.querySelector("slot");
|
||||
const containerDiv = slot?.parentElement as HTMLElement;
|
||||
if (containerDiv) {
|
||||
containerDiv.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
this.#listEl = wrapper.querySelector(".item-list");
|
||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||
const addInput = wrapper.querySelector(".add-input") as HTMLInputElement;
|
||||
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
addInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && addInput.value.trim()) {
|
||||
e.stopPropagation();
|
||||
this.addItem({
|
||||
id: `item-${Date.now()}`,
|
||||
title: addInput.value.trim(),
|
||||
category: "ACTIVITY",
|
||||
});
|
||||
addInput.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
addInput.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
this.#render();
|
||||
return root;
|
||||
}
|
||||
|
||||
#escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
#render() {
|
||||
if (!this.#listEl) return;
|
||||
|
||||
if (this.#items.length === 0) {
|
||||
this.#listEl.innerHTML = '<div class="empty-state">No items yet. Type below to add one.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by date
|
||||
const groups = new Map<string, ItineraryEntry[]>();
|
||||
const noDate: ItineraryEntry[] = [];
|
||||
|
||||
for (const item of this.#items) {
|
||||
if (item.date) {
|
||||
const key = item.date.split("T")[0];
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(item);
|
||||
} else {
|
||||
noDate.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
let result = "";
|
||||
|
||||
// Sorted date groups
|
||||
const sortedDates = Array.from(groups.keys()).sort();
|
||||
for (const dateStr of sortedDates) {
|
||||
const items = groups.get(dateStr)!;
|
||||
const date = new Date(dateStr);
|
||||
const label = date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
|
||||
result += `<div class="day-group">`;
|
||||
result += `<div class="day-label">${this.#escapeHtml(label)}</div>`;
|
||||
for (const item of items) {
|
||||
result += this.#renderItem(item);
|
||||
}
|
||||
result += `</div>`;
|
||||
}
|
||||
|
||||
// Items without dates
|
||||
if (noDate.length > 0) {
|
||||
result += `<div class="day-group">`;
|
||||
result += `<div class="day-label">Unscheduled</div>`;
|
||||
for (const item of noDate) {
|
||||
result += this.#renderItem(item);
|
||||
}
|
||||
result += `</div>`;
|
||||
}
|
||||
|
||||
this.#listEl.innerHTML = result;
|
||||
}
|
||||
|
||||
#renderItem(item: ItineraryEntry): string {
|
||||
const icon = CATEGORY_ICONS[item.category] || CATEGORY_ICONS.OTHER;
|
||||
let timeStr = "";
|
||||
if (item.startTime) {
|
||||
timeStr = item.startTime;
|
||||
if (item.endTime) timeStr += ` - ${item.endTime}`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="item">
|
||||
<span class="item-icon">${icon}</span>
|
||||
<div class="item-content">
|
||||
<div class="item-title">${this.#escapeHtml(item.title)}</div>
|
||||
${timeStr ? `<div class="item-time">${this.#escapeHtml(timeStr)}</div>` : ""}
|
||||
${item.description ? `<div class="item-desc">${this.#escapeHtml(item.description)}</div>` : ""}
|
||||
</div>
|
||||
<span class="category-badge">${this.#escapeHtml(item.category)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-itinerary",
|
||||
tripTitle: this.#tripTitle,
|
||||
items: this.#items,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
|
||||
const styles = css`
|
||||
:host {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-width: 260px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #7c3aed;
|
||||
color: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 80px;
|
||||
height: 6px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #7c3aed;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.packing-list {
|
||||
padding: 8px 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.category-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #7c3aed;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 6px 0 3px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #f8fafc;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
margin: 0 -6px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox.checked {
|
||||
background: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
.checkbox.checked::after {
|
||||
content: "\u2713";
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.item-name.packed {
|
||||
text-decoration: line-through;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.item-qty {
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.add-form input {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-form input:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface PackingEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
packed: boolean;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-packing-list": FolkPackingList;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkPackingList extends FolkShape {
|
||||
static override tagName = "folk-packing-list";
|
||||
|
||||
static {
|
||||
const sheet = new CSSStyleSheet();
|
||||
const parentRules = Array.from(FolkShape.styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
const childRules = Array.from(styles.cssRules)
|
||||
.map((r) => r.cssText)
|
||||
.join("\n");
|
||||
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
||||
this.styles = sheet;
|
||||
}
|
||||
|
||||
#items: PackingEntry[] = [];
|
||||
#listEl: HTMLElement | null = null;
|
||||
#progressTextEl: HTMLElement | null = null;
|
||||
#progressFillEl: HTMLElement | null = null;
|
||||
|
||||
get items() { return this.#items; }
|
||||
set items(v: PackingEntry[]) {
|
||||
this.#items = v;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
get packedCount() {
|
||||
return this.#items.filter((i) => i.packed).length;
|
||||
}
|
||||
|
||||
addItem(item: PackingEntry) {
|
||||
this.#items.push(item);
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
toggleItem(id: string) {
|
||||
const item = this.#items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.packed = !item.packed;
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span>\uD83C\uDF92</span>
|
||||
<span>Packing List</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">\u00D7</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-info">
|
||||
<span class="progress-text"></span>
|
||||
<div class="progress-bar"><div class="progress-fill"></div></div>
|
||||
</div>
|
||||
<div class="packing-list"></div>
|
||||
<div class="add-form">
|
||||
<input type="text" placeholder="Add item..." class="add-input" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
const slot = root.querySelector("slot");
|
||||
const containerDiv = slot?.parentElement as HTMLElement;
|
||||
if (containerDiv) {
|
||||
containerDiv.replaceWith(wrapper);
|
||||
}
|
||||
|
||||
this.#listEl = wrapper.querySelector(".packing-list");
|
||||
this.#progressTextEl = wrapper.querySelector(".progress-text");
|
||||
this.#progressFillEl = wrapper.querySelector(".progress-fill");
|
||||
|
||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||
const addInput = wrapper.querySelector(".add-input") as HTMLInputElement;
|
||||
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
addInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && addInput.value.trim()) {
|
||||
e.stopPropagation();
|
||||
this.addItem({
|
||||
id: `pack-${Date.now()}`,
|
||||
name: addInput.value.trim(),
|
||||
packed: false,
|
||||
quantity: 1,
|
||||
});
|
||||
addInput.value = "";
|
||||
}
|
||||
});
|
||||
addInput.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
this.#render();
|
||||
return root;
|
||||
}
|
||||
|
||||
#escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
#render() {
|
||||
if (!this.#listEl || !this.#progressTextEl || !this.#progressFillEl) return;
|
||||
|
||||
const total = this.#items.length;
|
||||
const packed = this.packedCount;
|
||||
const pct = total > 0 ? (packed / total) * 100 : 0;
|
||||
|
||||
this.#progressTextEl.textContent = `${packed}/${total} packed`;
|
||||
this.#progressFillEl.style.width = `${pct}%`;
|
||||
|
||||
if (total === 0) {
|
||||
this.#listEl.innerHTML = '<div style="text-align:center;padding:16px;color:#94a3b8;font-size:12px">No items yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const groups = new Map<string, PackingEntry[]>();
|
||||
for (const item of this.#items) {
|
||||
const cat = item.category || "Other";
|
||||
if (!groups.has(cat)) groups.set(cat, []);
|
||||
groups.get(cat)!.push(item);
|
||||
}
|
||||
|
||||
let result = "";
|
||||
for (const [cat, items] of groups) {
|
||||
result += `<div class="category-label">${this.#escapeHtml(cat)}</div>`;
|
||||
for (const item of items) {
|
||||
result += `
|
||||
<div class="item" data-id="${item.id}">
|
||||
<div class="checkbox ${item.packed ? "checked" : ""}"></div>
|
||||
<span class="item-name ${item.packed ? "packed" : ""}">${this.#escapeHtml(item.name)}</span>
|
||||
${item.quantity > 1 ? `<span class="item-qty">x${item.quantity}</span>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
this.#listEl.innerHTML = result;
|
||||
|
||||
// Add click handlers for checkboxes
|
||||
this.#listEl.querySelectorAll(".item").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const id = (el as HTMLElement).dataset.id;
|
||||
if (id) this.toggleItem(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-packing-list",
|
||||
items: this.#items,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -44,6 +44,13 @@ export * from "./folk-video-chat";
|
|||
export * from "./folk-obs-note";
|
||||
export * from "./folk-workflow-block";
|
||||
|
||||
// Trip Planning Shapes
|
||||
export * from "./folk-itinerary";
|
||||
export * from "./folk-destination";
|
||||
export * from "./folk-budget";
|
||||
export * from "./folk-packing-list";
|
||||
export * from "./folk-booking";
|
||||
|
||||
// Sync
|
||||
export * from "./community-sync";
|
||||
export * from "./presence";
|
||||
|
|
|
|||
|
|
@ -205,6 +205,11 @@
|
|||
<button id="add-video-chat" title="Video Call">📹 Call</button>
|
||||
<button id="add-obs-note" title="Rich Note">📓 Rich Note</button>
|
||||
<button id="add-workflow" title="Workflow Block">⚙️ Workflow</button>
|
||||
<button id="add-itinerary" title="Trip Itinerary">🗓️ Itinerary</button>
|
||||
<button id="add-destination" title="Trip Destination">📍 Destination</button>
|
||||
<button id="add-budget" title="Trip Budget">💰 Budget</button>
|
||||
<button id="add-packing-list" title="Packing List">🎒 Packing</button>
|
||||
<button id="add-booking" title="Trip Booking">✈️ Booking</button>
|
||||
<button id="add-arrow" title="Connect Shapes">↗️ Connect</button>
|
||||
<button id="zoom-in" title="Zoom In">+</button>
|
||||
<button id="zoom-out" title="Zoom Out">-</button>
|
||||
|
|
@ -238,6 +243,11 @@
|
|||
FolkVideoChat,
|
||||
FolkObsNote,
|
||||
FolkWorkflowBlock,
|
||||
FolkItinerary,
|
||||
FolkDestination,
|
||||
FolkBudget,
|
||||
FolkPackingList,
|
||||
FolkBooking,
|
||||
CommunitySync,
|
||||
PresenceManager,
|
||||
generatePeerId
|
||||
|
|
@ -262,6 +272,11 @@
|
|||
FolkVideoChat.define();
|
||||
FolkObsNote.define();
|
||||
FolkWorkflowBlock.define();
|
||||
FolkItinerary.define();
|
||||
FolkDestination.define();
|
||||
FolkBudget.define();
|
||||
FolkPackingList.define();
|
||||
FolkBooking.define();
|
||||
|
||||
// Get community info from URL
|
||||
const hostname = window.location.hostname;
|
||||
|
|
@ -466,6 +481,43 @@
|
|||
if (data.inputs) shape.inputs = data.inputs;
|
||||
if (data.outputs) shape.outputs = data.outputs;
|
||||
break;
|
||||
case "folk-itinerary":
|
||||
shape = document.createElement("folk-itinerary");
|
||||
if (data.tripTitle) shape.tripTitle = data.tripTitle;
|
||||
if (data.items) shape.items = data.items;
|
||||
break;
|
||||
case "folk-destination":
|
||||
shape = document.createElement("folk-destination");
|
||||
if (data.destName) shape.destName = data.destName;
|
||||
if (data.country) shape.country = data.country;
|
||||
if (data.lat != null) shape.lat = data.lat;
|
||||
if (data.lng != null) shape.lng = data.lng;
|
||||
if (data.arrivalDate) shape.arrivalDate = data.arrivalDate;
|
||||
if (data.departureDate) shape.departureDate = data.departureDate;
|
||||
if (data.notes) shape.notes = data.notes;
|
||||
break;
|
||||
case "folk-budget":
|
||||
shape = document.createElement("folk-budget");
|
||||
if (data.budgetTotal != null) shape.budgetTotal = data.budgetTotal;
|
||||
if (data.currency) shape.currency = data.currency;
|
||||
if (data.expenses) shape.expenses = data.expenses;
|
||||
break;
|
||||
case "folk-packing-list":
|
||||
shape = document.createElement("folk-packing-list");
|
||||
if (data.items) shape.items = data.items;
|
||||
break;
|
||||
case "folk-booking":
|
||||
shape = document.createElement("folk-booking");
|
||||
if (data.bookingType) shape.bookingType = data.bookingType;
|
||||
if (data.provider) shape.provider = data.provider;
|
||||
if (data.confirmationNumber) shape.confirmationNumber = data.confirmationNumber;
|
||||
if (data.details) shape.details = data.details;
|
||||
if (data.cost != null) shape.cost = data.cost;
|
||||
if (data.currency) shape.currency = data.currency;
|
||||
if (data.startDate) shape.startDate = data.startDate;
|
||||
if (data.endDate) shape.endDate = data.endDate;
|
||||
if (data.bookingStatus) shape.bookingStatus = data.bookingStatus;
|
||||
break;
|
||||
case "folk-markdown":
|
||||
default:
|
||||
shape = document.createElement("folk-markdown");
|
||||
|
|
@ -747,6 +799,77 @@
|
|||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
// Trip planning components
|
||||
document.getElementById("add-itinerary").addEventListener("click", () => {
|
||||
const id = `shape-${Date.now()}-${++shapeCounter}`;
|
||||
const shape = document.createElement("folk-itinerary");
|
||||
shape.id = id;
|
||||
shape.x = 100 + Math.random() * 200;
|
||||
shape.y = 100 + Math.random() * 200;
|
||||
shape.width = 320;
|
||||
shape.height = 400;
|
||||
|
||||
setupShapeEventListeners(shape);
|
||||
canvas.appendChild(shape);
|
||||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
document.getElementById("add-destination").addEventListener("click", () => {
|
||||
const id = `shape-${Date.now()}-${++shapeCounter}`;
|
||||
const shape = document.createElement("folk-destination");
|
||||
shape.id = id;
|
||||
shape.x = 100 + Math.random() * 200;
|
||||
shape.y = 100 + Math.random() * 200;
|
||||
shape.width = 280;
|
||||
shape.height = 220;
|
||||
|
||||
setupShapeEventListeners(shape);
|
||||
canvas.appendChild(shape);
|
||||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
document.getElementById("add-budget").addEventListener("click", () => {
|
||||
const id = `shape-${Date.now()}-${++shapeCounter}`;
|
||||
const shape = document.createElement("folk-budget");
|
||||
shape.id = id;
|
||||
shape.x = 100 + Math.random() * 200;
|
||||
shape.y = 100 + Math.random() * 200;
|
||||
shape.width = 300;
|
||||
shape.height = 350;
|
||||
|
||||
setupShapeEventListeners(shape);
|
||||
canvas.appendChild(shape);
|
||||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
document.getElementById("add-packing-list").addEventListener("click", () => {
|
||||
const id = `shape-${Date.now()}-${++shapeCounter}`;
|
||||
const shape = document.createElement("folk-packing-list");
|
||||
shape.id = id;
|
||||
shape.x = 100 + Math.random() * 200;
|
||||
shape.y = 100 + Math.random() * 200;
|
||||
shape.width = 280;
|
||||
shape.height = 350;
|
||||
|
||||
setupShapeEventListeners(shape);
|
||||
canvas.appendChild(shape);
|
||||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
document.getElementById("add-booking").addEventListener("click", () => {
|
||||
const id = `shape-${Date.now()}-${++shapeCounter}`;
|
||||
const shape = document.createElement("folk-booking");
|
||||
shape.id = id;
|
||||
shape.x = 100 + Math.random() * 200;
|
||||
shape.y = 100 + Math.random() * 200;
|
||||
shape.width = 300;
|
||||
shape.height = 240;
|
||||
|
||||
setupShapeEventListeners(shape);
|
||||
canvas.appendChild(shape);
|
||||
sync.registerShape(shape);
|
||||
});
|
||||
|
||||
// Arrow connection mode
|
||||
let connectMode = false;
|
||||
let connectSource = null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue