This commit is contained in:
metacryst
2026-04-28 20:05:00 -05:00
commit 0d6c7683ff
123 changed files with 20922 additions and 0 deletions

View File

@@ -0,0 +1,612 @@
import server from "/@server/server.js"
import calendarUtil from "../calendarUtil.js"
import "./EventForm.js"
import "../../components/BottomSheet.js"
import "../../components/BackButton.js"
import "../../components/Avatar.js"
css(`
eventdetails- {
display: flex;
flex-direction: column;
height: 100%;
scrollbar-width: none;
-ms-overflow-style: none;
}
eventdetails- ::-webkit-scrollbar { display: none; width: 0; height: 0; }
eventdetails- ::-webkit-scrollbar-thumb { background: transparent; }
eventdetails- ::-webkit-scrollbar-track { background: transparent; }
#eventdetails-toast-wrap {
transition: max-height 0.25s ease, opacity 0.22s ease, padding-top 0.25s ease;
}
`)
class EventDetails extends Shadow {
attachmentsOpen = false;
selectedCalendars = [];
_pendingCalendars = null;
_prevCalendars = null;
constructor(calendars, event = null, onEventEdited = null, onEventDeleted = null) {
super()
this.calendars = calendars;
this.event = event;
this.onEventEdited = onEventEdited;
this.onEventDeleted = onEventDeleted;
this.selectedCalendars = calendars.filter(c => event?.calendars?.includes(c.id));
}
render() {
this.editSheet = BottomSheet(100) // separate sheet for the edit form, layered above this one
const isOwner = this.event?.creator_id === global.profile?.id;
VStack(() => {
this.renderHeader(isOwner)
// ── Error toast ───────────────────────────────────────
VStack(() => {
p("")
.attr({ id: "eventdetails-toast" })
.margin(0)
.padding("0.55em 1.1em")
.background("var(--quillred)")
.color("white")
.fontSize(0.85, em)
.fontWeight("500")
.fontFamily("Arial")
.borderRadius("0.5em")
.boxShadow("0 2px 10px rgba(0,0,0,0.15)")
.whiteSpace("nowrap")
})
.attr({ id: "eventdetails-toast-wrap" })
.alignItems("center")
.overflow("hidden")
.maxHeight(0)
.opacity(0)
.flexShrink(0)
// ── Scrollable body ───────────────────────────────────
VStack(() => {
if (!this.event) return
// VStack(() => {}).height(0.85, em).flexShrink(0)
// ── Calendar / Location / Notes card ──────────────
VStack(() => {
// Calendar row — tappable to expand
HStack(() => {
p("Calendar")
.margin(0).fontSize(0.92, em).color("var(--headertext)").flexShrink(0)
HStack(() => {
this.selectedCalendars.forEach(cal => {
HStack(() => {
p("").width(0.6, em).height(0.6, em)
.background(cal.color)
.borderRadius(50, pct)
.flexShrink(0)
p(cal.name)
.margin(0)
.fontSize(0.82, em)
.fontWeight("600")
.color("var(--text)")
.whiteSpace("nowrap")
})
.gap(0.3, em)
.alignItems("center")
})
})
.attr({ id: "calendar-display" })
.flex(1)
.justifyContent("flex-end")
.flexWrap("wrap")
.gap(0.5, em)
let chevron = p("")
chevron
.attr({ id: "cal-chevron" })
.margin(0).opacity(0.3).fontSize(1.1, em).flexShrink(0)
.transition("transform 0.25s ease")
.state(chevron.parentElement, "open", function (value) {
if(value === "true") {
this.style.transform = "rotate(90deg)"
} else {
console.log("no trans")
this.style.transform = ""
}
})
})
.attr({id: "calendar-row"})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").gap(0.5, em)
.borderBottom("1px solid var(--divider)").cursor("pointer")
.onTap(function () {
const isOpen = this.getAttribute("open") === "true"
if (isOpen) {
this.setAttribute("open", "false")
} else {
this.setAttribute("open", "true")
}
})
// Calendar Expandable List
VStack(() => {
this.calendars.forEach(cal => {
const isSelected = this.selectedCalendars.some(c => c.id === cal.id)
HStack(() => {
HStack(() => {
p("").width(0.65, em).height(0.65, em)
.background(cal.color).borderRadius(50, pct).flexShrink(0)
p(cal.name)
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial")
})
.gap(0.45, em).alignItems("center").flex(1)
p("✓")
.attr({ id: `cal-check-${cal.id}` })
.margin(0).fontSize(0.88, em)
.color("var(--quillred)").fontWeight("700")
.display(isSelected ? "" : "none")
})
.paddingHorizontal(1.25, em).paddingVertical(0.72, em)
.alignItems("center")
.borderBottom("1px solid var(--divider)")
.cursor("pointer")
.onTap(() => {
const prevCalendars = [...this.selectedCalendars]
const i = this.selectedCalendars.findIndex(c => c.id === cal.id)
if (i >= 0) {
if (this.selectedCalendars.length > 1) {
this.selectedCalendars.splice(i, 1)
const check = this.$(`#cal-check-${cal.id}`)
if (check) check.style.display = "none"
}
} else {
this.selectedCalendars.push(cal)
const check = this.$(`#cal-check-${cal.id}`)
if (check) check.style.display = ""
}
this.updateCalendarDisplay()
this.saveCalendars(prevCalendars)
})
})
})
.state(this.$("#calendar-row"), "open", function (value) {
if(value === "false") {
this.style.maxHeight = "0"
} else {
this.style.maxHeight = this.scrollHeight + "px"
}
})
.attr({ id: "cal-picker"})
.overflow("hidden").maxHeight(0)
.transition("max-height 0.3s ease")
// Location row
if (this.event.location) {
HStack(() => {
p("📍").margin(0).fontSize(0.85, em).flexShrink(0)
p(this.event.location)
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").gap(0.65, em)
.borderBottom("1px solid var(--divider)")
}
// Notes row
if (this.event.description) {
HStack(() => {
p("📝").margin(0).fontSize(0.85, em).flexShrink(0).alignSelf("flex-start").paddingTop(0.1, em)
p(this.event.description)
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial")
.whiteSpace("pre-wrap").lineHeight("1.45")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("flex-start").gap(0.65, em)
}
})
.background("var(--darkaccent)").border("1px solid var(--divider)")
.borderRadius(12, px).marginHorizontal(1, em).overflow("hidden").flexShrink(0)
// ── Attachments card ──────────────────────────────
if (this.event.attachments?.length > 0) {
// VStack(() => {}).height(0.85, em).flexShrink(0)
VStack(() => {
HStack(() => {
p("📎").margin(0).fontSize(0.85, em).flexShrink(0)
p("Attachments")
.margin(0).fontSize(0.92, em).color("var(--headertext)").flex(1)
p("▼")
.attr({ id: "attachments-chevron" })
.margin(0).fontSize(0.7, em).color("var(--text)").opacity(0.5)
.display("inline-block")
.transition("transform 0.3s ease")
.transform(this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").gap(0.65, em).cursor("pointer")
.onTap(() => this.toggleAttachments())
VStack(() => {
VStack(() => {
this.event.attachments.forEach(file => this.renderFile(file))
})
.gap(0.75, em).width(100, pct)
.padding("0 1em 0.75em").boxSizing("border-box")
})
.attr({ id: "attachments-content" })
.overflow("hidden").maxHeight("0")
.transition("max-height 0.5s ease")
})
.background("var(--darkaccent)").border("1px solid var(--divider)")
.borderRadius(12, px).marginHorizontal(1, em).overflow("hidden").flexShrink(0)
}
})
.overflowY("scroll").flex(1).minHeight(0).paddingTop(0.85, em).paddingBottom(1.5, em).gap(0.85, em)
// ── Footer: creator avatar + timestamps ───────────────
if (this.event) {
const members = global.currentNetwork.data?.members || []
const creator = members.find(m => m.id === this.event.creator_id)
if (creator) {
HStack(() => {
Avatar(creator, 2)
VStack(() => {
p(`Created ${calendarUtil.timeAgo(this.event.created)} by ${creator.first_name}`)
.margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.5)
if (this.event.updated_at && this.event.updated_at !== this.event.created) {
p(`Last updated ${calendarUtil.timeAgo(this.event.updated_at)}`)
.margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.4)
}
})
.gap(0.15, em)
})
.paddingHorizontal(1, em)
.paddingVertical(0.65, em)
.alignItems("center")
.gap(0.5, em)
.flexShrink(0)
}
}
})
.height(100, pct)
}
renderHeader(isOwner) {
VStack(() => {
HStack(() => {
BackButton(false, true, () => $("bottomsheet-").toggle())
if (isOwner) {
HStack(() => {
// ── Delete button ─────────────────────────────────
button("Delete")
.attr({ type: "button" })
.padding(0.4, rem)
.fontSize(1.25, em)
.boxSizing("border-box")
.outline("none")
.border("none")
.background("transparent")
.color("var(--quillred)")
.onTap(() => this.handleDelete())
button("Edit")
.padding(0.4, rem)
.fontSize(1.25, em)
.color("var(--darkaccent)")
.boxSizing("border-box")
.outline("none")
.border("none")
.zIndex(3)
.onTap((e) => {
e.preventDefault()
let formEl
const closeForm = () => {
this.editSheet._closeOverride = null
this.editSheet.setSheet(false)
}
const onSaveError = () => {
this.editSheet._closeOverride = () => this.editSheet.forceClose()
}
this.editSheet.show(() => {
// For override rows, attach template dates so scope='all' anchors correctly
let eventForForm = this.event;
if (this.event.recurrence_parent_id && !this.event._templateStart) {
const template = global.currentNetwork.data.events.find(e => e.id === this.event.recurrence_parent_id);
if (template) {
eventForForm = {
...this.event,
_templateStart: new Date(template.time_start),
_templateEnd: new Date(template.time_end)
};
}
}
formEl = EventForm(
this.calendars,
(updateResult) => {
closeForm()
const updatedEvent = updateResult?.scope ? updateResult.event : updateResult;
this.event = { ...updatedEvent, time_start: new Date(updatedEvent.time_start), time_end: new Date(updatedEvent.time_end) }
this.selectedCalendars = this.calendars.filter(c => updatedEvent.calendars?.includes(c.id))
setTimeout(() => {
this.onEventEdited(updateResult)
this.rerender()
}, 300)
},
eventForForm,
closeForm,
(deleteResult) => {
closeForm()
$("bottomsheet-").toggle()
this.onEventDeleted(deleteResult)
},
null,
onSaveError
)
})
this.editSheet._closeOverride = () => {
this.editSheet.setSheet(true)
formEl?.handleBack()
}
})
})
.fontFamily("Arial")
.cursor("pointer")
.paddingHorizontal(0.8, rem)
.gap(0.4, rem)
}
})
.width(100, pct)
.justifyContent("space-between")
.alignItems("center")
VStack(() => {
h2(this.event?.title ?? "")
.color("var(--headertext)")
.fontFamily("Arial")
.margin(0)
.fontSize(1.4, em)
p(this.event ? calendarUtil.formatEventTime(this.event) : "")
.margin(0)
.color("var(--headertext)")
.opacity(0.7)
.fontSize(0.85, em)
})
.paddingHorizontal(1, em)
.paddingBottom(1, em)
.gap(0.3, em)
.alignItems("flex-start")
})
.width(100, pct)
.background(util.darkMode() ? "var(--darkred)" : "var(--sidebottombars)")
.borderTopLeftRadius("10px").borderTopRightRadius("10px")
.border("1px solid var(--divider)")
.boxSizing("border-box").flexShrink(0)
.alignItems("flex-start")
}
updateCalendarDisplay() {
const el = this.$("#calendar-display")
if (!el) return
el.innerHTML = this.selectedCalendars.map(cal => `
<div style="display:flex;align-items:center;gap:0.3em;">
<div style="width:0.6em;height:0.6em;background:${cal.color};border-radius:50%;flex-shrink:0;"></div>
<p style="font-size:0.82em;font-weight:600;margin:0;color:var(--text);white-space:nowrap;">${cal.name}</p>
</div>
`).join('')
}
showError(msg) {
const wrap = this.$("#eventdetails-toast-wrap")
const toast = this.$("#eventdetails-toast")
if (!wrap || !toast) return
clearTimeout(this._errorTimer)
if (msg) {
toast.innerText = msg
wrap.style.maxHeight = "3em"
wrap.style.opacity = "1"
wrap.style.paddingTop = "0.85em"
this._errorTimer = setTimeout(() => this.hideError(), 3500)
} else {
this.hideError()
}
}
hideError() {
const wrap = this.$("#eventdetails-toast-wrap")
if (!wrap) return
clearTimeout(this._errorTimer)
wrap.style.maxHeight = "0"
wrap.style.opacity = "0"
wrap.style.paddingTop = "0"
}
saveCalendars(prevCalendars) {
const event = this.event;
const newCalendars = this.selectedCalendars.map(c => c.id);
const isRecurring = !!(event._isOccurrence || event.recurrence_parent_id || event.recurrence_id);
this._prevCalendars = prevCalendars;
this._pendingCalendars = newCalendars;
if (isRecurring) {
$('actionsheetpopup-').show(
"Edit Recurring Event",
[
{ label: "Edit just this event", onTap: () => this.performCalendarSave('single'), destructive: false },
{ label: "Edit this and future events", onTap: () => this.performCalendarSave('future'), destructive: false },
{ label: "Edit all events in series", onTap: () => this.performCalendarSave('all'), destructive: false },
],
() => {
this._pendingCalendars = null;
this._revertCalendars(prevCalendars);
}
);
return;
}
this.performCalendarSave(null);
}
_revertCalendars(prev) {
this.selectedCalendars = prev;
this.calendars.forEach(cal => {
const check = this.$(`#cal-check-${cal.id}`);
if (check) check.style.display = prev.some(c => c.id === cal.id) ? "" : "none";
});
this.updateCalendarDisplay();
}
async performCalendarSave(scope) {
const event = this.event;
const newCalendars = this._pendingCalendars ?? this.selectedCalendars.map(c => c.id);
this._pendingCalendars = null;
const prevCalendars = this._prevCalendars;
try {
if (scope) {
const isOverride = !!event.recurrence_parent_id;
const templateId = isOverride ? event.recurrence_parent_id : event.id;
const occurrenceDate = isOverride
? (event.recurrence_exception_date instanceof Date
? event.recurrence_exception_date.toISOString()
: event.recurrence_exception_date) ?? null
: event._occurrenceDate?.toISOString() ?? null;
const serverEventId = (scope === 'single' && isOverride) ? event.id : templateId;
const result = await server.editEvent(serverEventId, {
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
time_start: event.time_start instanceof Date ? event.time_start.toISOString() : event.time_start,
time_end: event.time_end instanceof Date ? event.time_end.toISOString() : event.time_end,
all_day: event.all_day,
calendars: newCalendars,
scope,
exception_date: occurrenceDate
}, global.currentNetwork.id);
if (result.status === 200) {
const editResult = {
scope,
event: { ...result.event, calendars: newCalendars, attachments: event.attachments ?? [] },
templateId,
occurrenceDate
};
this.event = { ...editResult.event, time_start: new Date(result.event.time_start), time_end: new Date(result.event.time_end) };
this.selectedCalendars = this.calendars.filter(c => newCalendars.includes(c.id));
this._prevCalendars = null;
$("bottomsheet-")._closeOverride = null;
this.onEventEdited(editResult);
} else {
this._revertCalendars(prevCalendars);
this.showError(result.error ?? "Failed to update calendars.");
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose();
}
} else {
const result = await server.editEvent(event.id, { ...event, calendars: newCalendars }, global.currentNetwork.id);
if (result.status === 200) {
this.event = { ...event, calendars: newCalendars };
this._prevCalendars = null;
$("bottomsheet-")._closeOverride = null;
this.onEventEdited(this.event);
} else {
this._revertCalendars(prevCalendars);
this.showError(result.error ?? "Failed to update calendars.");
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose();
}
}
} catch (err) {
console.error("Failed to update calendars:", err);
this._revertCalendars(prevCalendars);
this.showError("Failed to update calendars.");
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose();
}
}
handleDelete() {
const event = this.event;
const isRecurring = !!(event?._isOccurrence || event?.recurrence_parent_id || event?.recurrence_id);
if (isRecurring) {
$('actionsheetpopup-').show(
"Delete Recurring Event",
[
{ label: "Delete just this event", onTap: () => this.performDelete('single') },
{ label: "Delete this and future events", onTap: () => this.performDelete('future') },
{ label: "Delete all events in series", onTap: () => this.performDelete('all') },
],
() => {}
)
return;
}
this.performDelete(null);
}
async performDelete(scope) {
const event = this.event;
const isOverride = !!event.recurrence_parent_id;
const templateId = isOverride ? event.recurrence_parent_id : event.id;
const occurrenceDate = isOverride
? (event.recurrence_exception_date instanceof Date
? event.recurrence_exception_date.toISOString()
: event.recurrence_exception_date) ?? null
: event._occurrenceDate?.toISOString() ?? null;
const serverEventId = (scope === 'single' && isOverride) ? event.id : templateId;
try {
const result = await server.deleteEvent(serverEventId, global.currentNetwork.id, scope, occurrenceDate)
if (result.status === 200) {
$("bottomsheet-").toggle()
const deleteResult = { scope: scope ?? 'all', templateId, occurrenceDate, overrideId: isOverride ? event.id : null };
setTimeout(() => this.onEventDeleted(deleteResult), 300)
} else {
this.showError(result.error ?? "Failed to delete event.")
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose()
}
} catch (err) {
console.error("Failed to delete event:", err)
this.showError("Failed to delete event.")
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose()
}
}
renderFile(file) {
const isImage = file.type?.startsWith("image/");
const url = `${config.SERVER}/db/images/events/${file.name}`;
if (isImage) {
img(url, "100%", "100%")
.borderRadius(8, px).display("block").boxSizing("border-box")
.cursor("pointer")
.onTap(() => $("filepreview-")?.open(file, url))
} else {
HStack(() => {
p("📎").margin(0).fontSize(1, em)
p(file.original_name ?? file.name)
.margin(0).color("var(--text)").fontSize(0.9, em)
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
})
.gap(0.5, em).alignItems("center")
.padding(0.5, em)
.background("var(--searchbackground)")
.borderRadius(8, px).boxSizing("border-box")
.cursor("pointer")
.onTap(() => $("filepreview-")?.open(file, url))
}
}
toggleAttachments() {
this.attachmentsOpen = !this.attachmentsOpen;
const content = this.$("#attachments-content");
const chevron = this.$("#attachments-chevron");
if (content) content.style.maxHeight = this.attachmentsOpen ? content.scrollHeight + "px" : "0";
if (chevron) chevron.style.transform = this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)";
}
}
register(EventDetails)

1156
calendar/Events/EventForm.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,298 @@
css(`
filepreview- {
position: fixed;
inset: 0;
z-index: 300;
pointer-events: none;
font-family: 'Arial';
}
`)
class FilePreview extends Shadow {
_file = null
_url = null
_panelEl = null
open(file, url) {
this._file = file
this._url = url
this.rerender()
requestAnimationFrame(() => requestAnimationFrame(() => {
if (this._panelEl) {
this._panelEl.style.transition = "transform 0.35s cubic-bezier(0.32, 0.72, 0, 1)"
this._panelEl.style.transform = "translateY(0)"
}
}))
}
close() {
if (!this._panelEl) return
this._panelEl.style.transition = "transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)"
this._panelEl.style.transform = "translateY(100%)"
setTimeout(() => {
this._file = null
this._url = null
this._panelEl = null
this.rerender()
}, 300)
}
render() {
if (!this._file) {
this.style.pointerEvents = "none"
return
}
this.style.pointerEvents = "all"
const type = this._file?.type ?? ""
const isImage = type.startsWith("image/")
const isPDF = type === "application/pdf"
const displayName = this._file?.original_name ?? this._file?.name ?? "File"
this._panelEl = VStack(() => {
this._renderHeader(displayName, isImage)
if (isImage) {
this._renderImageViewer()
} else if (isPDF) {
this._renderPDFViewer()
} else {
this._renderFileFallback(displayName)
}
})
.position("fixed")
.inset(0)
.background(isImage ? "#000" : "var(--main)")
.transform("translateY(100%)")
this._addPanelSwipe()
}
_renderHeader(displayName, isImage) {
HStack(() => {
button("⬇")
.fontSize(1.15, em)
.color("var(--headertext)")
.background("transparent")
.border("none")
.outline("none")
.padding(0)
.paddingLeft(1, rem)
.flexShrink(0)
.cursor("pointer")
.onTap(() => window.open(this._url, "_blank"))
p(displayName)
.margin(0)
.flex(1)
.fontSize(0.88, em)
.fontWeight("600")
.color("var(--headertext)")
.overflow("hidden")
.whiteSpace("nowrap")
.textOverflow("ellipsis")
.textAlign("center")
button("Done")
.fontSize(0.88, em)
.fontWeight("600")
.color("var(--quillred)")
.background("transparent")
.border("none")
.outline("none")
.padding(0)
.paddingRight(1, rem)
.flexShrink(0)
.cursor("pointer")
.onTap(() => this.close())
})
.width(100, pct)
.height(52, px)
.gap(0.75, rem)
.alignItems("center")
.justifyContent("center")
.background(util.darkMode() ? "var(--darkaccent)" : "var(--sidebottombars)")
.borderBottom("1px solid var(--divider)")
.boxSizing("border-box")
.flexShrink(0)
}
_renderImageViewer() {
let imgEl = null
const container = VStack(() => {
imgEl = img(this._url, "auto", "auto")
.display("block")
.maxWidth(100, pct)
.maxHeight(100, pct)
.objectFit("contain")
.pointerEvents("none")
})
.flex(1)
.width(100, pct)
.alignItems("center")
.justifyContent("center")
.overflow("hidden")
if (imgEl) this._setupImageGestures(container, imgEl)
}
_setupImageGestures(container, imgEl) {
let scale = 1, tx = 0, ty = 0
let pinchStartDist = null
let panStartX = 0, panStartY = 0
let swipeStartY = null, swipeStartTime = null
let lastTap = 0
container.style.touchAction = "none"
const applyTransform = (animated = false) => {
imgEl.style.transition = animated ? "transform 0.3s ease" : ""
imgEl.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
}
const resetZoom = () => {
scale = 1; tx = 0; ty = 0
applyTransform(true)
}
container.addEventListener("touchstart", (e) => {
e.stopPropagation()
if (e.touches.length === 2) {
pinchStartDist = Math.hypot(
e.touches[1].clientX - e.touches[0].clientX,
e.touches[1].clientY - e.touches[0].clientY
)
swipeStartY = null
} else if (e.touches.length === 1) {
panStartX = e.touches[0].clientX - tx
panStartY = e.touches[0].clientY - ty
if (scale <= 1) {
swipeStartY = e.touches[0].clientY
swipeStartTime = Date.now()
}
}
}, { passive: true })
container.addEventListener("touchmove", (e) => {
if (e.touches.length === 2 && pinchStartDist !== null) {
const dist = Math.hypot(
e.touches[1].clientX - e.touches[0].clientX,
e.touches[1].clientY - e.touches[0].clientY
)
scale = Math.min(Math.max(scale * (dist / pinchStartDist), 1), 6)
pinchStartDist = dist
applyTransform()
} else if (e.touches.length === 1 && scale > 1) {
tx = e.touches[0].clientX - panStartX
ty = e.touches[0].clientY - panStartY
applyTransform()
} else if (e.touches.length === 1 && swipeStartY !== null) {
const dy = e.touches[0].clientY - swipeStartY
if (dy > 5 && this._panelEl) {
this._panelEl.style.transition = ""
this._panelEl.style.transform = `translateY(${Math.max(0, dy)}px)`
}
}
}, { passive: true })
container.addEventListener("touchend", (e) => {
if (e.touches.length < 2) pinchStartDist = null
if (swipeStartY !== null) {
const dy = e.changedTouches[0].clientY - swipeStartY
const vel = dy / Math.max(1, Date.now() - swipeStartTime)
if (dy > window.innerHeight * 0.25 || vel > 0.5) {
this.close()
} else if (this._panelEl) {
this._panelEl.style.transition = "transform 0.3s ease"
this._panelEl.style.transform = "translateY(0)"
}
swipeStartY = null
}
if (scale < 1) resetZoom()
const now = Date.now()
if (e.changedTouches.length === 1 && now - lastTap < 300) {
scale > 1 ? resetZoom() : (() => { scale = 2.5; applyTransform(true) })()
}
lastTap = now
}, { passive: true })
}
_addPanelSwipe() {
let startY = null, startTime = null
this._panelEl.addEventListener("touchstart", (e) => {
if (e.touches.length !== 1) { startY = null; return }
startY = e.touches[0].clientY
startTime = Date.now()
}, { passive: true })
this._panelEl.addEventListener("touchmove", (e) => {
if (startY === null || e.touches.length !== 1) return
const dy = e.touches[0].clientY - startY
if (dy > 0 && this._panelEl) {
this._panelEl.style.transition = ""
this._panelEl.style.transform = `translateY(${dy}px)`
}
}, { passive: true })
this._panelEl.addEventListener("touchend", (e) => {
if (startY === null) return
const dy = e.changedTouches[0].clientY - startY
const vel = dy / Math.max(1, Date.now() - startTime)
if (dy > window.innerHeight * 0.25 || vel > 0.5) {
this.close()
} else if (this._panelEl) {
this._panelEl.style.transition = "transform 0.3s ease"
this._panelEl.style.transform = "translateY(0)"
}
startY = null
}, { passive: true })
}
_renderPDFViewer() {
const wrap = div()
.flex(1)
.width(100, pct)
.overflow("hidden")
wrap.innerHTML = `<iframe src="${this._url}" style="width:100%;height:100%;border:none;display:block;"></iframe>`
}
_renderFileFallback(displayName) {
const type = this._file?.type ?? ""
const icon = {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': "📄",
'application/msword': "📄",
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': "📊",
'application/vnd.ms-excel': "📊",
'application/vnd.openxmlformats-officedocument.presentationml.presentation': "📋",
'application/vnd.ms-powerpoint': "📋",
'text/plain': "📄",
}[type] ?? "📎"
VStack(() => {
p(icon).fontSize(3, em).margin(0)
p(displayName)
.margin(0)
.fontSize(0.9, em)
.color("var(--headertext)")
.textAlign("center")
a(this._url, "Open / Download")
.attr({ download: displayName })
.fontSize(0.9, em)
.color("var(--quillred)")
.fontWeight("600")
.textDecoration("none")
.marginTop(0.5, em)
})
.flex(1)
.alignItems("center")
.justifyContent("center")
.gap(0.75, em)
}
}
register(FilePreview)