├── _headers ├── src ├── App.css ├── vite-env.d.ts ├── main.tsx ├── markwhen │ ├── utils.ts │ ├── colorUtils.ts │ └── store.ts ├── index.css └── App.tsx ├── .github └── FUNDING.yml ├── public ├── calendar1.png ├── calendar2.png ├── mw.json └── vite.svg ├── postcss.config.cjs ├── tsconfig.node.json ├── tailwind.config.cjs ├── .gitignore ├── index.html ├── vite.config.ts ├── CHANGELOG.md ├── tsconfig.json └── package.json /_headers: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | github: kochrt -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/calendar1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mark-when/calendar/HEAD/public/calendar1.png -------------------------------------------------------------------------------- /public/calendar2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mark-when/calendar/HEAD/public/calendar2.png -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"], 4 | safelist: ["cusor-pointer", 'border-*'], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | darkMode: "class", 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Calendar 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { viteSingleFile } from "vite-plugin-singlefile"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), viteSingleFile()], 8 | server: { 9 | port: 6180, 10 | host: "0.0.0.0", 11 | headers: { 12 | "access-control-allow-origin": "*", 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /public/mw.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Calendar", 3 | "description": "Typical calendar. View by day, week, or month.", 4 | "repo": "https://github.com/mark-when/calendar", 5 | "iconSvgPath": "M 8 2 v 4 Z M 16 2 v 4 Z M 3 10 h 18 Z M 8 14 h 0.01 Z M 12 14 h 0.01 Z M 16 14 h 0.01 Z M 8 18 h 0.01 Z M 12 18 h 0 M 16 18 H 16 Z M 3 5 Z Q 3 4 4 4 L 20 4 Q 21 4 21 5 L 21 21 Q 21 22 20 22 L 4 22 Q 3 22 3 21 Z", 6 | "screenshots": ["/calendar1.png", "/calendar2.png"], 7 | "by": "markwhen" 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.3.4 2 | - Bump deps 3 | 4 | ## 1.3.3 5 | 6 | - Rebuild and redeploy 7 | 8 | ## 1.3.2 9 | 10 | - Bump parser and view client 11 | 12 | ## 1.3.1 13 | 14 | - Remove extraneous logging 15 | 16 | ## 1.3.0 17 | 18 | - Update deps, scrolling month view and better all day event support 19 | 20 | ## 1.2.0 21 | - Bump markwhen/view-client 22 | 23 | ## 1.1.0 24 | - Bump markwhen/view-client 25 | 26 | ## 1.0.3 27 | - Bump @markwhen/view-client 28 | 29 | ## 1.0.1 30 | 31 | - Correctly load __markwhen_initial_state -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /src/markwhen/utils.ts: -------------------------------------------------------------------------------- 1 | import { LINK_REGEX, AT_REGEX } from "@markwhen/parser/lib/Types"; 2 | export function toInnerHtml(s: string): string { 3 | return s 4 | .replace(/<|>/g, (match) => { 5 | if (match === "<") { 6 | return "<"; 7 | } 8 | return ">"; 9 | }) 10 | .replace(LINK_REGEX, (substring, linkText, link) => { 11 | return `${linkText}`; 14 | }) 15 | .replace(/&/g, "&") 16 | .replace(AT_REGEX, (substring, at) => { 17 | return `@${at}`; 18 | }); 19 | } 20 | 21 | function addHttpIfNeeded(s: string): string { 22 | if ( 23 | s.startsWith("http://") || 24 | s.startsWith("https://") || 25 | s.startsWith("/") 26 | ) { 27 | return s; 28 | } 29 | return `http://${s}`; 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@markwhen/calendar", 3 | "version": "1.3.5", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build && cp _headers dist/_headers", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@fullcalendar/core": "^6.1.15", 12 | "@fullcalendar/daygrid": "^6.1.15", 13 | "@fullcalendar/interaction": "^6.1.15", 14 | "@fullcalendar/multimonth": "^6.1.15", 15 | "@fullcalendar/react": "^6.1.15", 16 | "@fullcalendar/timegrid": "^6.1.15", 17 | "@markwhen/parser": "^0.15.0", 18 | "@markwhen/view-client": "^1.5.3", 19 | "immer": "^10.1.1", 20 | "react": "^18.3.1", 21 | "react-dom": "^18.3.1", 22 | "zustand": "^5.0.3" 23 | }, 24 | "devDependencies": { 25 | "@types/luxon": "^3.3.1", 26 | "@types/react": "^18.0.24", 27 | "@types/react-dom": "^18.0.8", 28 | "@vitejs/plugin-react": "^4.3.4", 29 | "autoprefixer": "^10.4.21", 30 | "postcss": "^8.5.3", 31 | "tailwindcss": "^3.4.17", 32 | "typescript": "^5.6.2", 33 | "vite": "^6.2.3", 34 | "vite-plugin-singlefile": "^2.2.0" 35 | }, 36 | "files": [ 37 | "dist/index.html" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root, body, #root { 6 | width: 100%; 7 | height: 100%; 8 | padding: 0; 9 | margin: 0; 10 | } 11 | 12 | :root { 13 | --fc-border-color: rgba(155, 155, 155, 0.1) !important; 14 | --fc-today-bg-color: rgb(255 255 255 / 0%) !important; 15 | } 16 | 17 | .calendar-container { 18 | font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 19 | Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 20 | } 21 | 22 | .fc .fc-header-toolbar.fc-toolbar { 23 | @apply p-2 m-0 text-xs; 24 | } 25 | 26 | .firstDayOfMonth { 27 | @apply border-l-2 border-l-zinc-400 !important; 28 | } 29 | 30 | .firstWeekOfMonth { 31 | @apply border-t-2 border-t-zinc-400 !important 32 | } 33 | 34 | .fc-button { 35 | text-transform: capitalize !important; 36 | } 37 | 38 | .fc-today-button { 39 | @apply bg-indigo-700 !important; 40 | } 41 | 42 | .fc-day-sat, .fc-day-sun { 43 | @apply bg-zinc-100 dark:bg-zinc-700 !important; 44 | } 45 | 46 | .fc-day-today .fc-daygrid-day-top { 47 | @apply bg-indigo-500 text-slate-100 !important; 48 | } 49 | 50 | .fc-col-header-cell.fc-day-today { 51 | @apply bg-indigo-500 text-slate-100 !important; 52 | 53 | } -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/markwhen/colorUtils.ts: -------------------------------------------------------------------------------- 1 | // RGB, so we can use rgba(... ) with a different alpha where we need it 2 | export const COLORS = [ 3 | "22, 163, 76", 4 | "2, 132, 199", 5 | "212, 50, 56", 6 | "242, 202, 45", 7 | "80, 73, 229", 8 | "145, 57, 234", 9 | "214, 45, 123", 10 | "234, 88, 11", 11 | "168, 162, 157", 12 | "255, 255, 255", 13 | "0, 0, 0", 14 | ]; 15 | export const HUMAN_COLORS = [ 16 | "green", 17 | "blue", 18 | "red", 19 | "yellow", 20 | "indigo", 21 | "purple", 22 | "pink", 23 | "orange", 24 | "gray", 25 | "white", 26 | "black", 27 | ]; 28 | 29 | export function hexToRgb(hex: string): string | undefined { 30 | hex = hex.replace("#", "").replace(')', ''); 31 | const isShortHex = hex.length === 3; 32 | var r = parseInt( 33 | isShortHex ? hex.slice(0, 1).repeat(2) : hex.slice(0, 2), 34 | 16 35 | ); 36 | if (isNaN(r)) { 37 | return undefined; 38 | } 39 | var g = parseInt( 40 | isShortHex ? hex.slice(1, 2).repeat(2) : hex.slice(2, 4), 41 | 16 42 | ); 43 | if (isNaN(g)) { 44 | return undefined; 45 | } 46 | var b = parseInt( 47 | isShortHex ? hex.slice(2, 3).repeat(2) : hex.slice(4, 6), 48 | 16 49 | ); 50 | if (isNaN(b)) { 51 | return undefined; 52 | } 53 | return `${r}, ${g}, ${b}`; 54 | } 55 | 56 | function componentToHex(c: number) { 57 | var hex = c.toString(16); 58 | return hex.length == 1 ? "0" + hex : hex; 59 | } 60 | 61 | function rgbNumberToHex(...rgb: number[]) { 62 | return ( 63 | "#" + 64 | componentToHex(rgb[0]) + 65 | componentToHex(rgb[1]) + 66 | componentToHex(rgb[2]) 67 | ); 68 | } 69 | 70 | export function rgbStringToHex(s: string) { 71 | return rgbNumberToHex(...s.split(",").map((n) => parseInt(n.trim()))); 72 | } 73 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from "./markwhen/store"; 2 | import FullCalendar from "@fullcalendar/react"; 3 | import dayGridPlugin from "@fullcalendar/daygrid"; 4 | import interactionPlugin from "@fullcalendar/interaction"; 5 | import timeGridPlugin from "@fullcalendar/timegrid"; 6 | import { 7 | EventHoveringArg, 8 | EventClickArg, 9 | DateSelectArg, 10 | DayCellContentArg, 11 | } from "@fullcalendar/core"; 12 | import "./App.css"; 13 | import { createRef, useEffect } from "react"; 14 | import { EventPath } from "@markwhen/view-client/dist/paths"; 15 | import { shallow, useShallow } from "zustand/shallow"; 16 | import { DateTime } from "luxon"; 17 | 18 | function App() { 19 | const [ 20 | requestStateUpdate, 21 | setHoveringPath, 22 | setDetailPath, 23 | showInEditor, 24 | newEvent, 25 | ] = useStore( 26 | useShallow((s) => { 27 | return [ 28 | s.requestStateUpdate, 29 | s.setHoveringPath, 30 | s.setDetailPath, 31 | s.showInEditor, 32 | s.newEvent, 33 | ]; 34 | }) 35 | ); 36 | 37 | const dark = useStore((s) => s.appState?.isDark); 38 | const events = useStore((s) => s.events); 39 | 40 | useEffect(() => { 41 | // We only want an initial update, we do not want to call this on every render 42 | requestStateUpdate(); 43 | }, []); 44 | 45 | const mouseEnter = (e: EventHoveringArg) => 46 | setHoveringPath(e.event.id.split(",").map((i) => parseInt(i))); 47 | 48 | const mouseLeave = () => { 49 | setHoveringPath(); 50 | }; 51 | const eventClick = (e: EventClickArg) => { 52 | const path = e.event.id.split(",").map((i) => parseInt(i)) as EventPath; 53 | setDetailPath(path); 54 | showInEditor(path); 55 | }; 56 | 57 | const select = (selection: DateSelectArg) => { 58 | newEvent( 59 | { fromDateTimeIso: selection.startStr, toDateTimeIso: selection.endStr }, 60 | false 61 | ); 62 | calendarRef.current!.getApi().unselect(); 63 | }; 64 | 65 | const dayCellClassNames = (dc: DayCellContentArg) => { 66 | const classes = []; 67 | const dt = DateTime.fromJSDate(dc.date); 68 | if (dt.day < 8) { 69 | if (dt.day === 1) { 70 | classes.push("firstDayOfMonth"); 71 | } 72 | classes.push("firstWeekOfMonth"); 73 | } 74 | return classes; 75 | }; 76 | 77 | const calendarRef = createRef(); 78 | 79 | return ( 80 |
81 |
84 | 109 |
110 |
111 | ); 112 | } 113 | 114 | export default App; 115 | -------------------------------------------------------------------------------- /src/markwhen/store.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { AppState, MarkwhenState, useLpc } from "@markwhen/view-client"; 3 | import { equivalentPaths, EventPath } from "@markwhen/view-client/dist/paths"; 4 | import { 5 | DateRangeIso, 6 | DateTimeGranularity, 7 | Eventy, 8 | isEvent, 9 | iter, 10 | } from "@markwhen/parser"; 11 | import { EventInput } from "@fullcalendar/core"; 12 | import { DateTime } from "luxon"; 13 | 14 | export const useStore = create<{ 15 | appState: AppState; 16 | markwhenState: MarkwhenState; 17 | requestStateUpdate: () => void; 18 | setHoveringPath: (path?: EventPath) => void; 19 | setDetailPath: (path?: EventPath) => void; 20 | showInEditor: (path?: EventPath) => void; 21 | newEvent: (dateRange: DateRangeIso, immediate: boolean) => void; 22 | events?: EventInput[]; 23 | }>((set) => { 24 | let appState: AppState = { 25 | isDark: false, 26 | colorMap: { default: {} }, 27 | }; 28 | let markwhenState: MarkwhenState = { 29 | // @ts-ignore 30 | parsed: {}, 31 | transformed: undefined, 32 | }; 33 | let events = [] as EventInput[]; 34 | 35 | const { postRequest } = useLpc({ 36 | appState(newState) { 37 | set((s) => { 38 | const eventColor = (node: Eventy) => { 39 | const ourTags = node.tags; 40 | return ourTags 41 | ? // @ts-ignore 42 | newState?.colorMap?.[node.source || "default"][ourTags[0]] 43 | : undefined; 44 | }; 45 | let events = [] as EventInput[]; 46 | const transformed = s?.markwhenState?.transformed; 47 | if (transformed) { 48 | for (const { eventy, path } of iter(transformed)) { 49 | if (isEvent(eventy)) { 50 | const color = eventColor(eventy) || "31, 32, 35"; 51 | const hovering = 52 | newState?.hoveringPath?.join(",") === path.join(","); 53 | const detail = newState?.detailPath?.join(",") === path.join(","); 54 | const dark = newState?.isDark; 55 | const from = DateTime.fromISO( 56 | eventy.dateRangeIso.fromDateTimeIso 57 | ); 58 | const to = DateTime.fromISO(eventy.dateRangeIso.toDateTimeIso); 59 | const allDay = to.diff(from).as("day") > 1; 60 | events.push({ 61 | id: path.join(","), 62 | start: eventy.dateRangeIso.fromDateTimeIso, 63 | end: eventy.dateRangeIso.toDateTimeIso, 64 | title: `${eventy.firstLine.restTrimmed}`, 65 | backgroundColor: `rgba(${color}, ${ 66 | hovering || detail ? 0.95 : 0.8 67 | })`, 68 | allDay, 69 | borderColor: 70 | hovering || detail ? (dark ? "white" : "black") : undefined, 71 | dateText: eventy.firstLine.datePart, 72 | }); 73 | } 74 | } 75 | } 76 | return { 77 | appState: newState, 78 | ...(transformed ? { events } : {}), 79 | }; 80 | }); 81 | }, 82 | markwhenState: (newState) => { 83 | set((oldState) => { 84 | const eventColor = (eventy: Eventy) => { 85 | const ourTags = eventy.tags; 86 | return ourTags 87 | ? oldState.appState?.colorMap?.[ourTags[0]] 88 | : undefined; 89 | }; 90 | 91 | let events = [] as EventInput[]; 92 | const transformed = newState?.transformed; 93 | if (transformed) { 94 | for (const { eventy, path } of iter(transformed)) { 95 | if (isEvent(eventy)) { 96 | const color = eventColor(eventy) || "31, 32, 35"; 97 | const hovering = equivalentPaths( 98 | oldState.appState?.hoveringPath, 99 | path 100 | ); 101 | const detail = equivalentPaths( 102 | oldState.appState?.detailPath, 103 | path 104 | ); 105 | const dark = oldState.appState?.isDark; 106 | events.push({ 107 | id: path.join(","), 108 | start: eventy.dateRangeIso.fromDateTimeIso, 109 | end: eventy.dateRangeIso.toDateTimeIso, 110 | title: `${eventy.firstLine.restTrimmed}`, 111 | backgroundColor: `rgba(${color}, ${ 112 | hovering || detail ? 0.95 : 0.8 113 | })`, 114 | borderColor: 115 | hovering || detail 116 | ? dark 117 | ? "white" 118 | : "black" 119 | : `rgb(${color})`, 120 | dateText: eventy.firstLine.datePart, 121 | }); 122 | } 123 | } 124 | } 125 | return { 126 | events, 127 | markwhenState: newState, 128 | }; 129 | }); 130 | }, 131 | }); 132 | 133 | const requestStateUpdate = () => { 134 | postRequest("appState"); 135 | postRequest("markwhenState"); 136 | }; 137 | const setHoveringPath = (path?: EventPath) => 138 | postRequest("setHoveringPath", path); 139 | const setDetailPath = (path?: EventPath) => 140 | postRequest("setDetailPath", path); 141 | const showInEditor = (path?: EventPath) => postRequest("showInEditor", path); 142 | const newEvent = ( 143 | range: DateRangeIso, 144 | immediate: boolean, 145 | granularity?: DateTimeGranularity 146 | ) => 147 | postRequest("newEvent", { 148 | dateRangeIso: range, 149 | granularity, 150 | immediate, 151 | }); 152 | 153 | return { 154 | requestStateUpdate, 155 | setHoveringPath, 156 | setDetailPath, 157 | showInEditor, 158 | newEvent, 159 | appState, 160 | markwhenState, 161 | events, 162 | }; 163 | }); 164 | 165 | // const useStore = create((set) => { 166 | // const stateAndTransformedEvents = { 167 | // transformedEvents: [] 168 | // } as State & Events; 169 | 170 | // const { postRequest } = useLpc({ 171 | // state: (newState) => { 172 | // set( 173 | // produce(stateAndTransformedEvents, (s) => { 174 | // const eventColor = (node: SomeNode) => { 175 | // const ourTags = isEventNode(node) 176 | // ? eventy.eventDescription.tags 177 | // : node.tags; 178 | // return ourTags 179 | // ? newState.markwhen?.page?.parsed?.tags[ourTags[0]] 180 | // : undefined; 181 | // }; 182 | 183 | // let events = [] as EventInput[]; 184 | // const transformed = newState.markwhen?.page?.transformed; 185 | // if (transformed) { 186 | // for (const { node, path } of iterate(transformed)) { 187 | // if (isEventNode(node)) { 188 | // const color = eventColor(node) || "31, 32, 35"; 189 | // const hovering = eqPath( 190 | // newState.app?.hoveringPath?.pageFiltered?.path, 191 | // path 192 | // ); 193 | // const detail = eqPath(newState.app?.detailPath?.path, path); 194 | // const dark = newState.app?.isDark; 195 | // events.push({ 196 | // id: path.join(","), 197 | // start: eventy.dateRangeIso.fromDateTimeIso, 198 | // end: eventy.dateRangeIso.toDateTimeIso, 199 | // title: `${eventy.eventDescription.eventDescription}`, 200 | // // backgroundColor: `rgba(${color}, ${ 201 | // // hovering || detail ? 0.95 : 0.8 202 | // // })`, 203 | // // borderColor: 204 | // // hovering || detail 205 | // // ? dark 206 | // // ? "white" 207 | // // : "black" 208 | // // : `rgb(${color})`, 209 | // dateText: eventy.dateText, 210 | // }); 211 | // } 212 | // } 213 | // } 214 | // return { ...newState, transformedEvents: events }; 215 | // }) 216 | // ); 217 | // }, 218 | --------------------------------------------------------------------------------