diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5d09a9c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 120, + "singleQuote": true, + "trailingComma": "all", + "semi": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a0b5b..cfa9d21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - yyyy-mm-dd +## [0.3.4] - 2025-12-11 + +- There is a new option in the "Share" mode to "Edit/Clone" the shared event. + ## [0.3.3] - 2025-12-11 - The "Link" toggle has been removed. Now it tries to detect if it's a link or a location diff --git a/package.json b/package.json index 229cc60..395e5c6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "calf", "private": true, - "version": "0.3.2", + "version": "0.3.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index 91c26c5..c493a9c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,10 @@ -import AboutModal from "./AboutModal"; -import { useState, useEffect, useRef, useMemo } from "react"; -import ReactQRCode from "react-qr-code"; -import Share from "./Share"; -import { Button, Input, Textarea, DatePicker, Autocomplete, AutocompleteItem, Link, Alert } from "@heroui/react"; -import { searchPlaces, debounce } from "./nominatim"; -import type { NominatimPlace } from "./nominatim"; +import AboutModal from './AboutModal'; +import { useState, useEffect, useRef, useMemo } from 'react'; +import ReactQRCode from 'react-qr-code'; +import Share from './Share'; +import { Button, Input, Textarea, DatePicker, Autocomplete, AutocompleteItem, Link, Alert } from '@heroui/react'; +import { searchPlaces, debounce } from './nominatim'; +import type { NominatimPlace } from './nominatim'; import { ChatBubbleBottomCenterTextIcon, ClockIcon, @@ -16,9 +16,9 @@ import { MapPinIcon, SunIcon, MoonIcon, -} from "@heroicons/react/16/solid"; -import ChipCheckbox from "./ChipCheckbox"; -import CollapsibleSection from "./CollapsibleSection"; +} from '@heroicons/react/16/solid'; +import ChipCheckbox from './ChipCheckbox'; +import CollapsibleSection from './CollapsibleSection'; import { formToRecord, paramsSerializer, @@ -28,13 +28,13 @@ import { to24Hour, toLocaleTimeFormat, isLink, -} from "./helpers"; -import { initialForm } from "./eventForm"; -import { CalendarDate } from "@internationalized/date"; -import { I18nProvider } from "@react-aria/i18n"; -import { InformationCircleIcon } from "@heroicons/react/24/outline"; +} from './helpers'; +import { initialForm } from './eventForm'; +import { CalendarDate } from '@internationalized/date'; +import { I18nProvider } from '@react-aria/i18n'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; -const sharePath = "/share"; +const sharePath = '/share'; const FixedLabel = ({ children }: { children: React.ReactNode }) => ( {children} @@ -42,12 +42,12 @@ const FixedLabel = ({ children }: { children: React.ReactNode }) => ( function App() { const [form, setForm] = useState(initialForm); - const [step, setStep] = useState<"form" | "share">("form"); - const urlPrefix = import.meta.env.MODE === "production" ? "/calf" : ""; + const [step, setStep] = useState<'form' | 'share'>('form'); + const urlPrefix = import.meta.env.MODE === 'production' ? '/calf' : ''; const origin = window.location.origin; // controlled input for Autocomplete so default timeZone is visible // date values are managed via HeroUI DateInput onChange and stored in form - const [formError, setFormError] = useState(""); + const [formError, setFormError] = useState(''); const [passwordEnabled, setPasswordEnabled] = useState(false); const [suggestions, setSuggestions] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); @@ -55,11 +55,11 @@ function App() { const [aboutOpen, setAboutOpen] = useState(false); // binary theme: dark or light. Default to system preference on first load. const [isDark, setIsDark] = useState(() => - typeof window !== "undefined" && window.matchMedia - ? window.matchMedia("(prefers-color-scheme: dark)").matches + typeof window !== 'undefined' && window.matchMedia + ? window.matchMedia('(prefers-color-scheme: dark)').matches : false, ); - const [shareLink, setShareLink] = useState(""); + const [shareLink, setShareLink] = useState(''); // needed to trigger async share link generation before rendering share step const [pendingShare, setPendingShare] = useState(false); const isSharePage = window.location.pathname === urlPrefix + sharePath; @@ -67,18 +67,48 @@ function App() { const locale = getUserLocale(); + // Parse URL query parameters on initial mount to prefill form + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const t = params.get('t'); + const d = params.get('d'); + const l = params.get('l'); + const sd = params.get('sd'); + const st = params.get('st'); + const ed = params.get('ed'); + const et = params.get('et'); + const tz = params.get('tz'); + const a = params.get('a'); + + // Only update form if there are query parameters to parse + if (t || d || l || sd || st || ed || et || tz || a) { + setForm((prevForm) => ({ + ...prevForm, + title: t || prevForm.title, + description: d || prevForm.description, + location: l || prevForm.location, + sDate: sd ? new CalendarDate(...(sd.split('-').map(Number) as [number, number, number])) : prevForm.sDate, + sTime: st || prevForm.sTime, + eDate: ed ? new CalendarDate(...(ed.split('-').map(Number) as [number, number, number])) : prevForm.eDate, + eTime: et || prevForm.eTime, + timezone: tz || prevForm.timezone, + isAllDay: a === '1' || prevForm.isAllDay, + })); + } + }, []); + // apply theme class when `isDark` changes. Do NOT persist in cookies/localStorage. useEffect(() => { const root = document.documentElement; - if (isDark) root.classList.add("dark"); - else root.classList.remove("dark"); + if (isDark) root.classList.add('dark'); + else root.classList.remove('dark'); }, [isDark]); // debounced search wrapper, memoized to avoid recreation on each render const debouncedSearch = useMemo( () => debounce(async (arg: unknown) => { - const q = String(arg ?? ""); + const q = String(arg ?? ''); if (!q || q.trim().length < 2) { setSuggestions([]); return; @@ -100,33 +130,33 @@ function App() { setShowSuggestions(false); } } - document.addEventListener("click", onDocClick); - return () => document.removeEventListener("click", onDocClick); + document.addEventListener('click', onDocClick); + return () => document.removeEventListener('click', onDocClick); }, []); const onSharePress = async () => { // validate required fields: title, sDate, endDate if (!form.title.trim() || !form.sDate || !form.eDate) { - setFormError("Title, Start date and End date are required to create an event."); + setFormError('Title, Start date and End date are required to create an event.'); return; } // validate start < end if (form.isAllDay) { if (form.eDate <= form.sDate) { - setFormError("End date must be after Start date."); + setFormError('End date must be after Start date.'); return; } } else { if (form.eDate <= form.sDate) { - const startDt = form.sDate.toString() + " " + form.sTime; - const endDt = form.eDate.toString() + " " + form.eTime; + const startDt = form.sDate.toString() + ' ' + form.sTime; + const endDt = form.eDate.toString() + ' ' + form.eTime; if (new Date(endDt) <= new Date(startDt)) { - setFormError("End date/time must be after Start date/time."); + setFormError('End date/time must be after Start date/time.'); return; } } } - setFormError(""); + setFormError(''); // Compose event params for share link: const params = formToRecord(form); if (passwordEnabled && form.password) { @@ -137,7 +167,7 @@ function App() { setShareLink(`${origin}${urlPrefix}${sharePath}?h=${encodeURIComponent(cipher)}`); } finally { setPendingShare(false); - setStep("share"); + setStep('share'); } } else { // Ensure all values are strings @@ -148,7 +178,7 @@ function App() { ); const urlParams = new URLSearchParams(stringParams); setShareLink(`${origin}${urlPrefix}${sharePath}?${urlParams.toString()}`); - setStep("share"); + setStep('share'); } }; @@ -166,8 +196,8 @@ function App() { setForm((f) => ({ ...f, sTime })); if (!form.eDate || !form.sDate) return; - const startDt = new Date(form.sDate.toString() + " " + sTime); - const endDt = new Date(form.eDate.toString() + " " + form.eTime); + const startDt = new Date(form.sDate.toString() + ' ' + sTime); + const endDt = new Date(form.eDate.toString() + ' ' + form.eTime); if (endDt <= startDt) { // adjust end time to be 1 hour after start time @@ -175,7 +205,7 @@ function App() { const eDate = new CalendarDate(newEndDt.getFullYear(), newEndDt.getMonth() + 1, newEndDt.getDate()); const h = newEndDt.getHours(); const m = newEndDt.getMinutes(); - const eTime = `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`; + const eTime = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; setForm((f) => ({ ...f, eDate, eTime })); } }; @@ -186,17 +216,17 @@ function App() { setForm((f) => ({ ...f, eTime })); }; - const iframeSrc = `counter.html?path=${isSharePage ? sharePath : "/"}`; + const iframeSrc = `counter.html?path=${isSharePage ? sharePath : '/'}`; return (
-
+
Calf
Calf (Calendar Factory) @@ -238,7 +268,7 @@ function App() { onPress={() => setAboutOpen(true)} className=" bg-gray-200 dark:bg-gray-700 p-0 min-w-8 xs:ml-2 ml-0 xs:mt-0 mt-2 h-8" > - +
@@ -247,7 +277,7 @@ function App() { ) : ( <> - {step === "form" && ( + {step === 'form' && (
} - placeholder={"Meeting Link/Location"} + placeholder={'Meeting Link/Location'} type="text" onFocus={() => { setShowSuggestions(!isLink(form.location)); @@ -382,14 +412,14 @@ function App() { startContent={} id="timezone" placeholder="Type to filter time zones" - defaultItems={Intl.supportedValuesOf("timeZone").map((tz) => ({ label: tz, value: tz }))} + defaultItems={Intl.supportedValuesOf('timeZone').map((tz) => ({ label: tz, value: tz }))} defaultSelectedKey={form.timezone} onSelectionChange={(v) => { setForm((f) => ({ ...f, timezone: String(v) })); }} isClearable={false} > - {Intl.supportedValuesOf("timeZone").map((tz) => ( + {Intl.supportedValuesOf('timeZone').map((tz) => ( {tz} @@ -450,7 +480,7 @@ function App() { )} } - type={isPassVisible ? "text" : "password"} + type={isPassVisible ? 'text' : 'password'} value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} /> @@ -471,7 +501,7 @@ function App() {
)} - {step === "share" && ( + {step === 'share' && (
Share this link:
{pendingShare ? ( @@ -489,14 +519,14 @@ function App() { )} diff --git a/src/Share.tsx b/src/Share.tsx index 1528be8..554ebd4 100644 --- a/src/Share.tsx +++ b/src/Share.tsx @@ -1,15 +1,15 @@ -import ReactQRCode from "react-qr-code"; -import { generateICal } from "./icalUtils"; -import { ArrowDownTrayIcon, CalendarDaysIcon, GlobeAltIcon, NoSymbolIcon } from "@heroicons/react/16/solid"; -import { Button, Input, Link } from "@heroui/react"; -import { Autocomplete, AutocompleteItem } from "@heroui/react"; -import { useState } from "react"; -import { dateFromParts, decryptString, getUserLocale, isLink, paramsDeserializer } from "./helpers"; -import type { EventQS } from "./eventForm"; +import ReactQRCode from 'react-qr-code'; +import { generateICal } from './icalUtils'; +import { ArrowDownTrayIcon, CalendarDaysIcon, GlobeAltIcon, NoSymbolIcon } from '@heroicons/react/16/solid'; +import { Button, Input, Link } from '@heroui/react'; +import { Autocomplete, AutocompleteItem } from '@heroui/react'; +import { useState } from 'react'; +import { dateFromParts, decryptString, getUserLocale, isLink, paramsDeserializer } from './helpers'; +import type { EventQS } from './eventForm'; function getShareUnlockState() { const params = new URLSearchParams(window.location.search); - const cipher = params.get("h"); + const cipher = params.get('h'); if (cipher) return { protected: true, cipher }; return { protected: false }; } @@ -17,46 +17,46 @@ function getShareUnlockState() { function parseStandardParams(): EventQS { const params = new URLSearchParams(window.location.search); return { - title: params.get("t") || "", - description: params.get("d") || "", - location: params.get("l") || "", - sDate: params.get("sd") || "", - sTime: params.get("st") || "", - eDate: params.get("ed") || "", - eTime: params.get("et") || "", - timezone: params.get("tz") || "", - isAllDay: typeof params.get("a") === "string", + title: params.get('t') || '', + description: params.get('d') || '', + location: params.get('l') || '', + sDate: params.get('sd') || '', + sTime: params.get('st') || '', + eDate: params.get('ed') || '', + eTime: params.get('et') || '', + timezone: params.get('tz') || '', + isAllDay: typeof params.get('a') === 'string', }; } export default function Share({ isDark }: { isDark: boolean }) { const lockState = getShareUnlockState(); - const [password, setPassword] = useState(""); - const [unlockError, setUnlockError] = useState(""); + const [password, setPassword] = useState(''); + const [unlockError, setUnlockError] = useState(''); const [unlocked, setUnlocked] = useState(false); const [protectedEvent, setProtectedEvent] = useState(null); const [selectedTz, setSelectedTz] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone); async function handleUnlock() { - setUnlockError(""); + setUnlockError(''); try { - const base = await decryptString(lockState.cipher || "", password); + const base = await decryptString(lockState.cipher || '', password); const data = paramsDeserializer(base); // set event metadata exactly as "standard" event setProtectedEvent({ - title: data.t ?? "", - description: data.d ?? "", - location: data.l ?? "", - sDate: data.sd ?? "", - sTime: data.st ?? "", - eDate: data.ed ?? "", - eTime: data.et ?? "", - timezone: data.tz ?? "", - isAllDay: data.a === "1", + title: data.t ?? '', + description: data.d ?? '', + location: data.l ?? '', + sDate: data.sd ?? '', + sTime: data.st ?? '', + eDate: data.ed ?? '', + eTime: data.et ?? '', + timezone: data.tz ?? '', + isAllDay: data.a === '1', }); setUnlocked(true); } catch { - setUnlockError("Unlock failed, either link is wrongly copied or password is wrong"); + setUnlockError('Unlock failed, either link is wrongly copied or password is wrong'); } } @@ -102,9 +102,9 @@ export default function Share({ isDark }: { isDark: boolean }) { const tzDisplay = (() => { if (event.timezone) return event.timezone; const off = -startDt.getTimezoneOffset(); - const sign = off >= 0 ? "+" : "-"; - const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0"); - const m = String(Math.abs(off) % 60).padStart(2, "0"); + const sign = off >= 0 ? '+' : '-'; + const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, '0'); + const m = String(Math.abs(off) % 60).padStart(2, '0'); return `${sign}${h}:${m}`; })(); @@ -113,11 +113,11 @@ export default function Share({ isDark }: { isDark: boolean }) { try { const locale = new Intl.Locale(getUserLocale()); const opts: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', timeZone: tz, }; return new Intl.DateTimeFormat(locale, opts).format(date); @@ -131,8 +131,8 @@ export default function Share({ isDark }: { isDark: boolean }) { try { const locale = new Intl.Locale(getUserLocale()); const opts: Intl.DateTimeFormatOptions = { - hour: "2-digit", - minute: "2-digit", + hour: '2-digit', + minute: '2-digit', timeZone: tz, }; return new Intl.DateTimeFormat(locale, opts).format(date); @@ -145,10 +145,10 @@ export default function Share({ isDark }: { isDark: boolean }) { try { const locale = new Intl.Locale(getUserLocale()); const opts: Intl.DateTimeFormatOptions = { - year: "numeric", - month: "short", - day: "numeric", - weekday: "long", + year: 'numeric', + month: 'short', + day: 'numeric', + weekday: 'long', }; return new Intl.DateTimeFormat(locale, opts).format(date); } catch { @@ -158,11 +158,11 @@ export default function Share({ isDark }: { isDark: boolean }) { const ical = generateICal(event); - const icalBlob = new Blob([ical], { type: "text/calendar" }); + const icalBlob = new Blob([ical], { type: 'text/calendar' }); const icalUrl = URL.createObjectURL(icalBlob); const shareLink = window.location.href; - const sanitizeDate = (d: string) => d.replace(/[-:]/g, "").replace(/\.\d{3}/, ""); + const sanitizeDate = (d: string) => d.replace(/[-:]/g, '').replace(/\.\d{3}/, ''); const startStr = event.isAllDay ? event.sDate : startDt.toISOString(); const endStr = event.isAllDay ? event.eDate : endDt.toISOString(); const details = encodeURIComponent(event.description); @@ -174,15 +174,15 @@ export default function Share({ isDark }: { isDark: boolean }) { )}/${sanitizeDate(endStr)}`; const outlookLink = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${title}&body=${details}&location=${location}&startdt=${startStr}&enddt=${endStr}${ - event.isAllDay ? "&allday=true" : "" + event.isAllDay ? '&allday=true' : '' }`; const office365Link = `https://outlook.office.com/calendar/0/deeplink/compose?subject=${title}&body=${details}&location=${location}&startdt=${startStr}&enddt=${endStr}${ - event.isAllDay ? "&allday=true" : "" + event.isAllDay ? '&allday=true' : '' }`; const yahooLink = `https://calendar.yahoo.com/?v=60&title=${title}&st=${startStr}&et=${endStr}&desc=${details}&in_loc=${location}&dur=${ - event.isAllDay ? "allday" : "" + event.isAllDay ? 'allday' : '' }`; // Apple Calendar: open the ICS (do not force download) so the OS can hand it to Calendar @@ -204,7 +204,7 @@ export default function Share({ isDark }: { isDark: boolean }) {
{event.description}
{event.location && (
- Location:{" "} + Location:{' '}
{formatDateOnly(startDt)}
- {formatInTime(startDt, selectedTz)} to {formatInTime(endDt, selectedTz)} + {formatInTime(startDt, selectedTz)} to {formatInTime(endDt, selectedTz)} ({tzDisplay})
) : ( <>
Start: {formatInTimezone(startDt, selectedTz)}
-
End: {formatInTimezone(endDt, selectedTz)}
+
+ End: {formatInTimezone(endDt, selectedTz)} ({tzDisplay}) +
)} -
Original Time Zone: {tzDisplay}
+
-
{shareLink}
+ +
+ {shareLink} +
+ +
+
);