├── .eslintrc.cjs ├── .gitignore ├── README.md ├── bun.lockb ├── components.json ├── docker-compose.yml ├── index.html ├── package.json ├── postcss.config.js ├── src ├── App.tsx ├── components │ ├── calendar-info-card.tsx │ ├── calendar-table.tsx │ ├── connect-nip07-button.tsx │ ├── contact.tsx │ ├── container.tsx │ ├── copy-url-button.tsx │ ├── error-boundary-fallback.tsx │ ├── event-editor.tsx │ ├── events-view-history.tsx │ ├── footer.tsx │ ├── header.tsx │ ├── icons │ │ ├── check-icon.tsx │ │ ├── circle-icon.tsx │ │ ├── close-icon.tsx │ │ ├── copy-icon.tsx │ │ └── triangle-icon.tsx │ ├── join-the-event.tsx │ ├── layout.tsx │ ├── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── skeleton.tsx │ │ ├── spinner.tsx │ │ ├── table.tsx │ │ ├── text-field.tsx │ │ ├── textarea-with-label.tsx │ │ └── textarea.tsx │ └── user.tsx ├── consts.ts ├── contexts │ ├── alert-context.tsx │ └── ndk-context.tsx ├── event.d.ts ├── hooks │ ├── use-alert.ts │ └── use-ndk.ts ├── index.css ├── lib │ ├── formatDate.ts │ ├── user.ts │ └── utils.ts ├── main.tsx ├── pages │ ├── events │ │ └── [naddr].tsx │ ├── index.tsx │ └── mypage.tsx ├── services │ ├── app-local-storage.ts │ ├── contact.ts │ ├── event-calender.ts │ ├── relays.ts │ └── user.ts └── vite-env.d.ts ├── strfry.conf ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.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 | 26 | strfry-db -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/studiokaiji/chronostr/7e192db6a554eb7126b2e9364eb07f26b922bf65/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | strfry: 4 | image: pluja/strfry:latest 5 | restart: unless-stopped 6 | volumes: 7 | - type: bind 8 | source: ./strfry.conf 9 | target: /etc/strfry.conf 10 | - ./strfry-db:/app/strfry-db 11 | ports: 12 | - "7777:7777" 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | chronostr 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datestr", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@nostr-dev-kit/ndk": "^2.8.2", 14 | "@radix-ui/react-dialog": "^1.1.1", 15 | "@radix-ui/react-label": "^2.1.0", 16 | "@radix-ui/react-separator": "^1.1.0", 17 | "@radix-ui/react-slot": "^1.1.0", 18 | "@tanstack/react-query": "^5.45.1", 19 | "autoprefixer": "^10.4.19", 20 | "class-variance-authority": "^0.7.0", 21 | "clsx": "^2.1.1", 22 | "date-fns": "^2.30.0", 23 | "lucide-react": "^0.294.0", 24 | "postcss": "^8.4.38", 25 | "react": "^18.3.1", 26 | "react-day-picker": "^8.10.1", 27 | "react-dom": "^18.3.1", 28 | "react-error-boundary": "^4.1.2", 29 | "react-helmet": "^6.1.0", 30 | "react-router-dom": "^6.23.1", 31 | "tailwind-merge": "^2.3.0", 32 | "tailwindcss": "^3.4.4", 33 | "tailwindcss-animate": "^1.0.7" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "^20.14.7", 37 | "@types/react": "^18.3.3", 38 | "@types/react-dom": "^18.3.0", 39 | "@types/react-helmet": "^6.1.11", 40 | "@typescript-eslint/eslint-plugin": "^6.21.0", 41 | "@typescript-eslint/parser": "^6.21.0", 42 | "@vitejs/plugin-react": "^4.3.1", 43 | "eslint": "^8.57.0", 44 | "eslint-plugin-react-hooks": "^4.6.2", 45 | "eslint-plugin-react-refresh": "^0.4.7", 46 | "typescript": "^5.5.2", 47 | "vite": "^5.3.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { HashRouter, Route, Routes } from "react-router-dom"; 2 | import { IndexPage } from "./pages"; 3 | import { Container } from "./components/container"; 4 | import { AlertContextProvider } from "./contexts/alert-context"; 5 | import { EventCalendarPage } from "./pages/events/[naddr]"; 6 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 7 | import { Suspense } from "react"; 8 | import { Spinner } from "./components/ui/spinner"; 9 | import { NDKContextProvider } from "./contexts/ndk-context"; 10 | import { MyPage } from "./pages/mypage"; 11 | import { ErrorBoundary } from "react-error-boundary"; 12 | import { ErrorBoundaryFallback } from "./components/error-boundary-fallback"; 13 | 14 | const queryClient = new QueryClient({ 15 | defaultOptions: { 16 | queries: { 17 | refetchOnWindowFocus: false, 18 | refetchOnMount: false, 19 | refetchInterval: false, 20 | }, 21 | }, 22 | }); 23 | 24 | function App() { 25 | return ( 26 | 27 | 28 | 29 | 30 | 33 | 34 | 35 | } 36 | > 37 | 38 | 39 | }> 40 | } /> 41 | } /> 42 | 43 | } /> 44 | 45 | 49 | Not Found 50 | 51 | } 52 | /> 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | } 63 | 64 | export default App; 65 | -------------------------------------------------------------------------------- /src/components/calendar-info-card.tsx: -------------------------------------------------------------------------------- 1 | import { formatDate } from "@/lib/formatDate"; 2 | import { ContactDialog } from "./contact"; 3 | import { CopyUrlButton } from "./copy-url-button"; 4 | import { EventEditorDialog } from "./event-editor"; 5 | import { JoinTheEventDialog } from "./join-the-event"; 6 | import { Card, CardDescription, CardTitle } from "./ui/card"; 7 | import { EventCalendar, GetRSVPResponse } from "@/event"; 8 | import { User } from "./user"; 9 | import { useMemo } from "react"; 10 | 11 | type CalendarInfoCardProps = { 12 | pubkey: string; 13 | calendar: EventCalendar; 14 | rsvp?: GetRSVPResponse; 15 | isRSVPLoading?: boolean; 16 | calendarRefetch?: () => void; 17 | rsvpRefetch?: () => void; 18 | onSubmitError?: (error: unknown) => void; 19 | signerType: "nip07" | "privateKey" | null; 20 | displayAction?: boolean; 21 | displayRSVP?: boolean; 22 | small?: boolean; 23 | }; 24 | 25 | export const CalendarInfoCard = ({ 26 | pubkey, 27 | calendar, 28 | rsvp, 29 | isRSVPLoading = false, 30 | calendarRefetch, 31 | rsvpRefetch, 32 | onSubmitError, 33 | signerType, 34 | displayAction, 35 | small, 36 | displayRSVP, 37 | }: CalendarInfoCardProps) => { 38 | const isOwner = calendar.owner.npub === pubkey; 39 | 40 | const myRSVP = useMemo(() => { 41 | if (!rsvp) return undefined; 42 | if (pubkey in rsvp.rsvpPerUsers) { 43 | return rsvp.rsvpPerUsers?.[pubkey]; 44 | } 45 | return undefined; 46 | }, [pubkey, rsvp]); 47 | 48 | return ( 49 | 54 |
55 |
56 |
57 | 58 | {calendar.title} 59 | 60 | {displayAction && } 61 |
62 | 68 | 69 | 70 | 71 | {calendar.description} 72 | 73 |
74 |
75 |
80 | {displayRSVP && ( 81 |

👤 {Object.keys(rsvp?.rsvpPerUsers || {}).length}

82 | )} 83 |

84 | 🗓️ {formatDate(calendar.dates[0].date)} ~{" "} 85 | {formatDate(calendar.dates.slice(-1)[0].date)} 86 |

87 |
88 | {displayAction && ( 89 |
90 | {isOwner ? ( 91 | calendarRefetch?.()} 94 | onEditError={onSubmitError} 95 | /> 96 | ) : ( 97 | 103 | )} 104 | 105 | rsvpRefetch?.()} 115 | onRSVPError={onSubmitError} 116 | /> 117 |
118 | )} 119 |
120 |
121 |
122 | ); 123 | }; 124 | -------------------------------------------------------------------------------- /src/components/calendar-table.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableBody, 4 | TableCell, 5 | TableFooter, 6 | TableHead, 7 | TableHeader, 8 | TableRow, 9 | } from "@/components/ui/table"; 10 | import { CircleIcon } from "@/components/icons/circle-icon"; 11 | import { CloseIcon } from "@/components/icons/close-icon"; 12 | import { TriangleIcon } from "@/components/icons/triangle-icon"; 13 | import type { EventCalendar, GetRSVPResponse } from "@/event"; 14 | import { formatDate } from "@/lib/formatDate"; 15 | import { memo } from "react"; 16 | import { getName } from "@/lib/user"; 17 | 18 | type CalendarProps = { 19 | calendar: EventCalendar; 20 | rsvp?: GetRSVPResponse; 21 | }; 22 | 23 | export const CalendarTable = memo(({ calendar, rsvp }: CalendarProps) => { 24 | const rsvpPerUsers = rsvp?.rsvpPerUsers; 25 | return ( 26 | 27 | 28 | 29 | Name 30 | {calendar?.dates.map(({ id, date, includeTime }) => ( 31 | 32 |
{formatDate(date)}
33 | {includeTime && ( 34 |
35 | {date.getHours()}:{("0" + date.getMinutes()).slice(-2)} 36 |
37 | )} 38 |
39 | ))} 40 |
41 |
42 | 43 | {Object.values(rsvpPerUsers || {}).map((rsvp) => { 44 | return ( 45 | 46 | 47 |
48 | {getName(rsvp.user)} 49 |
50 |
51 | {calendar.dates.map((date) => { 52 | const statusAndEvent = rsvp.rsvp?.[date.id]; 53 | const status = statusAndEvent?.status; 54 | return ( 55 | 56 | {status === "accepted" ? ( 57 | 58 | ) : status === "tentative" ? ( 59 | 60 | ) : ( 61 | 62 | )} 63 | 64 | ); 65 | })} 66 |
67 | ); 68 | })} 69 |
70 | 71 | 72 | Total 73 | {calendar.dates.map((_, i) => { 74 | const total = rsvp?.totals?.[i]; 75 | return ( 76 | 77 |
78 | 79 |
{total?.accepted || 0}
80 |
81 |
82 | 83 |
{total?.tentative || 0}
84 |
85 |
86 | ); 87 | })} 88 |
89 |
90 |
91 | ); 92 | }); 93 | -------------------------------------------------------------------------------- /src/components/connect-nip07-button.tsx: -------------------------------------------------------------------------------- 1 | import { useAlert } from "@/hooks/use-alert"; 2 | import { useCallback } from "react"; 3 | import { Button, ButtonProps } from "./ui/button"; 4 | import { useNDK } from "@/hooks/use-ndk"; 5 | 6 | export const ConnectNIP07Button = ( 7 | props: ButtonProps & { onConnect?: () => void } 8 | ) => { 9 | const { setAlert } = useAlert(); 10 | 11 | const { connectToNip07 } = useNDK(); 12 | 13 | const connect = useCallback(async () => { 14 | try { 15 | await connectToNip07(); 16 | setAlert({ 17 | title: "Account Connected!", 18 | }); 19 | if (props.onConnect) props.onConnect(); 20 | } catch (e) { 21 | setAlert({ 22 | title: "Failed to connect", 23 | description: String(e), 24 | variant: "destructive", 25 | }); 26 | } 27 | }, [connectToNip07, setAlert, props]); 28 | 29 | return ( 30 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/contact.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "./ui/button"; 2 | import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog"; 3 | import { GetRSVPResponse } from "@/event"; 4 | import { FormEvent, memo, useEffect, useState } from "react"; 5 | import { ConnectNIP07Button } from "./connect-nip07-button"; 6 | import { useNDK } from "@/hooks/use-ndk"; 7 | import { Spinner } from "./ui/spinner"; 8 | import { TextareaWithLabel } from "./ui/textarea-with-label"; 9 | import { contactEvent } from "@/services/contact"; 10 | import { NDKEvent } from "@nostr-dev-kit/ndk"; 11 | import { getName } from "@/lib/user"; 12 | 13 | type ContactProps = { 14 | title: string; 15 | rsvp?: GetRSVPResponse; 16 | onContactComplete?: (event: NDKEvent) => void; 17 | onContactCancel?: () => void; 18 | onContactError?: (e: unknown) => void; 19 | }; 20 | 21 | const defaultBody = (title: string) => () => 22 | `chronostr - ${title}\n${location.href}`; 23 | 24 | export const Contact = memo( 25 | ({ 26 | title, 27 | rsvp, 28 | onContactComplete, 29 | onContactCancel, 30 | onContactError, 31 | }: ContactProps) => { 32 | const { ndk } = useNDK(); 33 | 34 | const [displayAuthConfirm, setDisplayAuthConfirm] = useState( 35 | !ndk?.signer && window.nostr 36 | ); 37 | const contactList = Object.values(rsvp?.rsvpPerUsers ?? {}).map( 38 | (e) => e.user 39 | ); 40 | 41 | useEffect(() => { 42 | setDisplayAuthConfirm(!ndk?.signer && window.nostr); 43 | }, [ndk]); 44 | 45 | const [body, setBody] = useState(defaultBody(title)); 46 | const [isLoading, setIsLoading] = useState(false); 47 | 48 | if (contactList.length <= 0) { 49 | return ( 50 |
51 |

No participants

52 |

53 | This event does not appear to have any participants yet. 54 |

55 |
56 | 59 |
60 |
61 | ); 62 | } 63 | if (displayAuthConfirm) { 64 | return ( 65 |
66 |

Connect to Nostr Account?

67 |

68 | If you connect your Nostr account, your profile will be 69 | automatically filled in, and you will be able to make changes to 70 | your schedule and contact members from other browsers as well. 71 |

72 |
73 | 76 | setDisplayAuthConfirm(false)} 78 | /> 79 |
80 |
81 | ); 82 | } 83 | 84 | const replyingTo = () => { 85 | const names = contactList.map(getName); 86 | 87 | if (names.length === 1) { 88 | const name = names[0]; 89 | 90 | return `Replying to ${name}`; 91 | } else { 92 | const former = names.slice(0, -1); 93 | const last = names[names.length - 1]; 94 | 95 | return `Replying to ${former.join(", ")} and ${last}`; 96 | } 97 | }; 98 | 99 | const submit = async (e: FormEvent) => { 100 | e.preventDefault(); 101 | if (!ndk) { 102 | return; 103 | } 104 | 105 | try { 106 | setIsLoading(true); 107 | 108 | const event = await contactEvent(ndk, body, contactList); 109 | 110 | if (onContactComplete) { 111 | onContactComplete(event); 112 | } 113 | } catch (e) { 114 | if (onContactError) { 115 | onContactError(e); 116 | } 117 | } finally { 118 | setIsLoading(false); 119 | } 120 | }; 121 | 122 | return ( 123 |
124 |

Contact

125 | setBody(e.target.value)} 130 | /> 131 | 132 | 135 | 136 | ); 137 | } 138 | ); 139 | 140 | export const ContactDialog = ( 141 | props: ContactProps & { isLoading?: boolean } 142 | ) => { 143 | const [open, setOpen] = useState(false); 144 | 145 | return ( 146 | 147 | 148 | 151 | 152 | 153 | { 156 | if (props.onContactComplete) { 157 | props.onContactComplete(events); 158 | } 159 | setOpen(false); 160 | }} 161 | onContactCancel={() => { 162 | setOpen(false); 163 | }} 164 | /> 165 | 166 | 167 | ); 168 | }; 169 | -------------------------------------------------------------------------------- /src/components/container.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | import { Header } from "./header"; 3 | import { Footer } from "./footer"; 4 | 5 | export const Container = () => { 6 | return ( 7 |
8 |
9 |
10 |
11 | 12 |
13 |
14 |
15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/copy-url-button.tsx: -------------------------------------------------------------------------------- 1 | import { useAlert } from "@/hooks/use-alert"; 2 | import { CopyIcon } from "./icons/copy-icon"; 3 | import { Button } from "./ui/button"; 4 | import { useState } from "react"; 5 | import { CheckIcon } from "./icons/check-icon"; 6 | 7 | type CopyUrlButton = { 8 | url: string; 9 | }; 10 | 11 | export const CopyUrlButton = ({ url }: CopyUrlButton) => { 12 | const { setAlert } = useAlert(); 13 | 14 | const [copied, setCopied] = useState(false); 15 | 16 | const copy = () => { 17 | navigator.clipboard.writeText(url).then(() => { 18 | setAlert({ 19 | title: "Copied!", 20 | lifetimeMs: 1500, 21 | }); 22 | setCopied(true); 23 | 24 | const t = setTimeout(() => { 25 | setCopied(false); 26 | }, 1500); 27 | 28 | return () => clearTimeout(t); 29 | }); 30 | }; 31 | 32 | return ( 33 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/error-boundary-fallback.tsx: -------------------------------------------------------------------------------- 1 | import { useAlert } from "@/hooks/use-alert"; 2 | import { useEffect } from "react"; 3 | import { FallbackProps } from "react-error-boundary"; 4 | import { useNavigate } from "react-router-dom"; 5 | 6 | export const ErrorBoundaryFallback = ( 7 | props: FallbackProps & { redirectTo?: string } 8 | ) => { 9 | const { setAlert } = useAlert(); 10 | const navigate = useNavigate(); 11 | 12 | useEffect(() => { 13 | if (props.error) { 14 | setAlert({ 15 | title: "An error occurred", 16 | description: props.error.message, 17 | variant: "destructive", 18 | }); 19 | if (props.redirectTo) { 20 | navigate(props.redirectTo, { replace: true }); 21 | } 22 | } 23 | }, [navigate, props.error, props.redirectTo, setAlert]); 24 | 25 | return <>; 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/event-editor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type FormEventHandler, 3 | memo, 4 | useCallback, 5 | useEffect, 6 | useMemo, 7 | useState, 8 | } from "react"; 9 | import { Calendar } from "./ui/calendar"; 10 | import { TextField } from "./ui/text-field"; 11 | import { TextareaWithLabel } from "./ui/textarea-with-label"; 12 | import { Label } from "./ui/label"; 13 | import { Button } from "./ui/button"; 14 | import { Separator } from "./ui/separator"; 15 | import { Textarea } from "./ui/textarea"; 16 | import { useAlert } from "@/hooks/use-alert"; 17 | import { Spinner } from "./ui/spinner"; 18 | import { 19 | createEventCalendar, 20 | updateEventCalendar, 21 | } from "@/services/event-calender"; 22 | import { useNavigate } from "react-router-dom"; 23 | import type { EventCalendar, EventDateInput } from "@/event"; 24 | import { useNDK } from "@/hooks/use-ndk"; 25 | import type { SelectSingleEventHandler } from "react-day-picker"; 26 | import { 27 | Dialog, 28 | DialogTrigger, 29 | DialogContent, 30 | DialogHeader, 31 | DialogTitle, 32 | } from "./ui/dialog"; 33 | import { Card } from "./ui/card"; 34 | import { Trash2 } from "lucide-react"; 35 | 36 | type EventEditorProps = { 37 | currentValue?: EventCalendar; 38 | onEditComplete?: (calendarId: string) => void; 39 | onEditError?: (e: unknown) => void; 40 | }; 41 | 42 | const dateToString = (date: Date, includeTime: boolean) => { 43 | const ymd = [ 44 | date.getFullYear(), 45 | (date.getMonth() + 1).toString().padStart(2, "0"), 46 | date.getDate().toString().padStart(2, "0"), 47 | ].join("-"); 48 | 49 | const time = includeTime 50 | ? "T" + 51 | [ 52 | date.getHours().toString().padStart(2, "0"), 53 | date.getMinutes().toString().padStart(2, "0"), 54 | ].join(":") 55 | : ""; 56 | 57 | return ymd + time; 58 | }; 59 | 60 | export const EventEditor = memo( 61 | ({ 62 | currentValue, 63 | onEditComplete: onSaved, 64 | onEditError: onFailed, 65 | }: EventEditorProps) => { 66 | const [title, setTitle] = useState(currentValue?.title || ""); 67 | const [description, setDescription] = useState( 68 | currentValue?.description || "" 69 | ); 70 | 71 | // Update用 72 | const initialDates = currentValue?.dates; 73 | const [removeRequestDateTagIds, setRemoveRequestDateTagIds] = useState< 74 | string[] 75 | >([]); 76 | const currentDates = useMemo(() => { 77 | if (!initialDates) { 78 | return []; 79 | } 80 | return initialDates.filter( 81 | (date) => !removeRequestDateTagIds.includes(date.event.tagId()) 82 | ); 83 | }, [initialDates, removeRequestDateTagIds]); 84 | const removeDate = useCallback( 85 | async (dateEventTagId: string) => { 86 | const newSet = new Set(removeRequestDateTagIds); 87 | newSet.add(dateEventTagId); 88 | setRemoveRequestDateTagIds([...newSet]); 89 | }, 90 | [removeRequestDateTagIds] 91 | ); 92 | 93 | useEffect(() => { 94 | setRemoveRequestDateTagIds([]); 95 | }, [currentValue]); 96 | 97 | const [dateString, setDateString] = useState(""); 98 | 99 | const { setAlert } = useAlert(); 100 | 101 | const selectDate: SelectSingleEventHandler = (date: Date | undefined) => { 102 | if (!date) { 103 | return; 104 | } 105 | const converted = dateToString(date, false); 106 | setDateString((str) => `${str ? `${str}\n` : ""}${converted}`); 107 | }; 108 | 109 | const navigate = useNavigate(); 110 | 111 | const { ndk, connectToNip07 } = useNDK(); 112 | 113 | const [isCreating, setIsCreating] = useState(false); 114 | 115 | const submit: FormEventHandler = async (e) => { 116 | try { 117 | e.preventDefault(); 118 | 119 | if (!ndk) { 120 | return; 121 | } 122 | 123 | setIsCreating(true); 124 | 125 | const strDates = dateString.split("\n"); 126 | const dates: EventDateInput[] = []; 127 | 128 | // フィールドに何も入力されていない場合は、処理をスキップ 129 | if (strDates && dateString) { 130 | for (const strDate of strDates) { 131 | const parsed = safeParseISO8601String(strDate); 132 | if (!parsed) { 133 | setAlert({ 134 | title: "An invalid date was found.", 135 | description: `"${strDate}" is not in accordance with ISO8601.`, 136 | variant: "destructive", 137 | }); 138 | setIsCreating(false); 139 | return; 140 | } 141 | dates.push({ 142 | date: parsed, 143 | includeTime: strDate.includes(":"), 144 | }); 145 | } 146 | } 147 | 148 | const nd = ndk.signer ? ndk : await connectToNip07(); 149 | 150 | const calendarId = currentValue?.id || crypto.randomUUID(); 151 | 152 | const ev = currentValue 153 | ? await updateEventCalendar( 154 | nd, 155 | currentValue.id, 156 | dates, 157 | removeRequestDateTagIds, 158 | title, 159 | description 160 | ) 161 | : await createEventCalendar(nd, { 162 | title, 163 | description, 164 | dates, 165 | }); 166 | 167 | if (currentValue) { 168 | navigate(`/events/${currentValue.id}`); 169 | } else { 170 | const encoded = ev.tagAddress(); 171 | navigate(`/events/${encoded}`); 172 | } 173 | 174 | onSaved?.(calendarId); 175 | 176 | setAlert({ 177 | title: "Success!", 178 | variant: "default", 179 | }); 180 | } catch (e) { 181 | onFailed?.(e); 182 | setAlert({ 183 | title: "Error", 184 | description: String(e), 185 | variant: "destructive", 186 | }); 187 | } finally { 188 | setIsCreating(false); 189 | } 190 | }; 191 | 192 | return ( 193 |
194 | setTitle(e.target.value)} 199 | required 200 | /> 201 | setDescription(e.target.value)} 206 | /> 207 | 208 |
209 | 213 | { 214 | // Update時のみ表示 215 | // 現在の候補日を表示 216 | currentValue && currentDates.length > 0 && ( 217 | <> 218 |
219 | 222 |
223 | {currentDates.map((date) => ( 224 | 228 | 229 | {dateToString(date.date, date.includeTime)} 230 | 231 | 240 | 241 | ))} 242 |
243 |
244 | 245 | ) 246 | } 247 | 248 | {currentValue && currentDates.length > 0 && ( 249 | 252 | )} 253 |
254 | 262 | 266 | 270 |