├── _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 |
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 |
--------------------------------------------------------------------------------