rspace-online/lib/folk-itinerary.ts

360 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: "✈️",
TRANSPORT: "🚌",
ACCOMMODATION: "🏨",
ACTIVITY: "🎯",
MEAL: "🍽️",
FREE_TIME: "☀️",
OTHER: "📌",
};
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>📅</span>
<span class="title-text">Itinerary</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</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,
};
}
}