├── .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 |
31 | {props.children || "Connect"}
32 |
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 |
57 | Got it
58 |
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 |
74 | No Thanks
75 |
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 |
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 |
149 | {props.isLoading ? : "✉ Contact"}
150 |
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 |
11 |
12 |
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 |
34 | {copied ? (
35 |
36 | ) : (
37 |
38 | )}
39 |
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 |
304 | );
305 | }
306 | );
307 |
308 | const safeParseISO8601String = (strDate: string) => {
309 | try {
310 | const date = new Date(strDate);
311 | if (Number.isNaN(date.getTime())) {
312 | return null;
313 | }
314 | return date;
315 | } catch {
316 | return null;
317 | }
318 | };
319 |
320 | export const EventEditorDialog = (
321 | props: EventEditorProps & { isLoading?: boolean }
322 | ) => {
323 | const [isOpen, setIsOpen] = useState(false);
324 | const close = useCallback(() => {
325 | setIsOpen(false);
326 | }, []);
327 | return (
328 |
329 |
330 |
331 | {props.isLoading ? : "🖊️ Edit"}
332 |
333 |
334 |
335 |
336 | Edit Event
337 |
338 | {
341 | props.onEditComplete?.(cid);
342 | close();
343 | }}
344 | onEditError={() => {
345 | close();
346 | }}
347 | />
348 |
349 |
350 | );
351 | };
352 |
--------------------------------------------------------------------------------
/src/components/events-view-history.tsx:
--------------------------------------------------------------------------------
1 | export const EventsViewHistory = () => {
2 |
3 | };
--------------------------------------------------------------------------------
/src/components/footer.tsx:
--------------------------------------------------------------------------------
1 | export const Footer = () => {
2 | return (
3 |
4 |
chronostr
5 |
6 | A scheduling adjustment and RSVP tool working on the Nostr.
7 |
8 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/src/components/header.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { ConnectNIP07Button } from "./connect-nip07-button";
3 | import { useNDK } from "@/hooks/use-ndk";
4 | import { Button } from "./ui/button";
5 |
6 | export const Header = () => {
7 | const { ndk, isLoading, signerType, disconnectNIP07 } = useNDK();
8 |
9 | return (
10 |
11 |
12 |
13 |
chronostr
14 |
15 | {isLoading ? (
16 | <>>
17 | ) : (
18 |
19 | {ndk &&
20 | (!signerType ? (
21 | 🔐 Login (NIP-07)
22 | ) : signerType === "nip07" ? (
23 |
24 | Disconnect
25 |
26 | ) : (
27 | <>>
28 | ))}
29 |
30 | )}
31 |
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/src/components/icons/check-icon.tsx:
--------------------------------------------------------------------------------
1 | export const CheckIcon = (props: JSX.IntrinsicElements["svg"]) => {
2 | return (
3 |
4 |
5 |
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/icons/circle-icon.tsx:
--------------------------------------------------------------------------------
1 | export const CircleIcon = (props: JSX.IntrinsicElements["svg"]) => {
2 | return (
3 |
4 |
5 |
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/icons/close-icon.tsx:
--------------------------------------------------------------------------------
1 | export const CloseIcon = (props: JSX.IntrinsicElements["svg"]) => {
2 | return (
3 |
4 |
5 |
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/icons/copy-icon.tsx:
--------------------------------------------------------------------------------
1 | export const CopyIcon = (props: JSX.IntrinsicElements["svg"]) => {
2 | return (
3 |
4 |
5 |
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/icons/triangle-icon.tsx:
--------------------------------------------------------------------------------
1 | export const TriangleIcon = (props: JSX.IntrinsicElements["svg"]) => {
2 | return (
3 |
4 |
5 |
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/join-the-event.tsx:
--------------------------------------------------------------------------------
1 | import { formatDate } from "@/lib/formatDate";
2 | import { Button } from "./ui/button";
3 | import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog";
4 | import { TextField } from "./ui/text-field";
5 | import { EventCalendar, RSVP, RSVPStatus } from "@/event";
6 | import { FormEvent, memo, useCallback, useEffect, useState } from "react";
7 | import { CircleIcon } from "./icons/circle-icon";
8 | import { TriangleIcon } from "./icons/triangle-icon";
9 | import { CloseIcon } from "./icons/close-icon";
10 | import { Label } from "./ui/label";
11 | import { ConnectNIP07Button } from "./connect-nip07-button";
12 | import { useNDK } from "@/hooks/use-ndk";
13 | import { rsvpEvent } from "@/services/event-calender";
14 | import { type NDKEvent } from "@nostr-dev-kit/ndk";
15 | import { Spinner } from "./ui/spinner";
16 |
17 | type JoinTheEventProps = {
18 | eventCalender: EventCalendar;
19 | beforeRSVP?: RSVP;
20 | name?: string;
21 | onRSVPComplete?: (events: NDKEvent[]) => void;
22 | onRSVPError?: (e: unknown) => void;
23 | };
24 |
25 | const rsvpButtonClass = "transition-none";
26 |
27 | const rsvpDefaultIconClass = "w-5 h-5 fill-gray-500";
28 | const rsvpActiveIconClass = "w-5 h-5 fill-white";
29 |
30 | export const JoinTheEvent = memo(
31 | ({
32 | eventCalender,
33 | beforeRSVP,
34 | onRSVPComplete,
35 | onRSVPError,
36 | name: inputName,
37 | }: JoinTheEventProps) => {
38 | const [name, setName] = useState(inputName || "");
39 |
40 | const [rsvpStatuses, setRSVPStatuses] = useState(
41 | beforeRSVP
42 | ? eventCalender.dates.map(
43 | (date) => beforeRSVP?.[date.id]?.status || "declined"
44 | )
45 | : Array(eventCalender.dates.length).fill("declined")
46 | );
47 |
48 | const changeRSVP = useCallback((index: number, rsvp: RSVPStatus) => {
49 | setRSVPStatuses((before) => {
50 | const list = [...before];
51 | list.splice(index, 1, rsvp);
52 | return list;
53 | });
54 | }, []);
55 |
56 | const { ndk } = useNDK();
57 |
58 | const [displayAuthConfirm, setDisplayAuthConfirm] = useState(
59 | !ndk?.signer && window.nostr
60 | );
61 |
62 | useEffect(() => {
63 | setDisplayAuthConfirm(!ndk?.signer && window.nostr);
64 | }, [ndk]);
65 |
66 | const [isLoading, setIsLoading] = useState(false);
67 |
68 | if (displayAuthConfirm) {
69 | return (
70 |
71 |
Connect to Nostr Account?
72 |
73 | If you connect your Nostr account, your profile will be
74 | automatically filled in, and you will be able to make changes to
75 | your schedule and contact members from other browsers as well.
76 |
77 |
78 | setDisplayAuthConfirm(false)}
81 | >
82 | No Thanks
83 |
84 | setDisplayAuthConfirm(false)}
86 | />
87 |
88 |
89 | );
90 | }
91 |
92 | const submit = async (e: FormEvent) => {
93 | e.preventDefault();
94 | if (!ndk) {
95 | return;
96 | }
97 |
98 | try {
99 | setIsLoading(true);
100 |
101 | const rsvpList = eventCalender.dates.map((date, i) => {
102 | const status = rsvpStatuses[i];
103 | return { status, date };
104 | });
105 |
106 | const beforeRSVPEvents = beforeRSVP
107 | ? Object.values(beforeRSVP).map(({ event }) => event)
108 | : undefined;
109 |
110 | const res = await rsvpEvent(
111 | ndk,
112 | {
113 | name,
114 | rsvpList,
115 | calenderId: eventCalender.id,
116 | },
117 | beforeRSVPEvents
118 | );
119 |
120 | if (onRSVPComplete) {
121 | onRSVPComplete(res);
122 | }
123 | } catch (e) {
124 | if (onRSVPError) {
125 | onRSVPError(e);
126 | }
127 | } finally {
128 | setIsLoading(false);
129 | }
130 | };
131 |
132 | return (
133 |
134 | {eventCalender.title}
135 | {!ndk?.signer && (
136 | setName(e.target.value)}
141 | />
142 | )}
143 |
144 |
145 | *
146 | 🗓️ Candidate dates
147 |
148 |
149 | {eventCalender.dates.map((date, i) => {
150 | const status = rsvpStatuses[i];
151 | return (
152 |
156 |
157 | {formatDate(date.date)}
158 |
159 |
160 | changeRSVP(i, "accepted")}
166 | >
167 |
174 |
175 | changeRSVP(i, "tentative")}
181 | >
182 |
189 |
190 | changeRSVP(i, "declined")}
196 | >
197 |
204 |
205 |
206 |
207 | );
208 | })}
209 |
210 |
211 |
212 | {isLoading && } Submit
213 |
214 |
215 | );
216 | }
217 | );
218 |
219 | export const JoinTheEventDialog = (
220 | props: JoinTheEventProps & { isLoading?: boolean }
221 | ) => {
222 | const [open, setOpen] = useState(false);
223 |
224 | return (
225 |
226 |
227 |
228 | {props.isLoading ? (
229 |
230 | ) : props.beforeRSVP ? (
231 | "📝 Re Schedule"
232 | ) : (
233 | "✋ Join"
234 | )}
235 |
236 |
237 |
238 | {
241 | if (props.onRSVPComplete) {
242 | props.onRSVPComplete(events);
243 | }
244 | setOpen(false);
245 | }}
246 | />
247 |
248 |
249 | );
250 | };
251 |
--------------------------------------------------------------------------------
/src/components/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 |
3 | export const Layout = ({ children }: { children: ReactNode }) => {
4 | return (
5 | {children}
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { ChevronLeft, ChevronRight } from "lucide-react";
3 | import { DayPicker } from "react-day-picker";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { buttonVariants } from "@/components/ui/button";
7 |
8 | export type CalendarProps = React.ComponentProps;
9 |
10 | function Calendar({
11 | className,
12 | classNames,
13 | showOutsideDays = true,
14 | ...props
15 | }: CalendarProps) {
16 | return (
17 | (
56 |
57 | ),
58 | IconRight: ({ ...props }) => (
59 |
60 | ),
61 | }}
62 | {...props}
63 | />
64 | );
65 | }
66 | Calendar.displayName = "Calendar";
67 |
68 | export { Calendar };
69 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as DialogPrimitive from "@radix-ui/react-dialog";
3 | import { X } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const Dialog = DialogPrimitive.Root;
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger;
10 |
11 | const DialogPortal = DialogPrimitive.Portal;
12 |
13 | const DialogClose = DialogPrimitive.Close;
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ));
52 | DialogContent.displayName = DialogPrimitive.Content.displayName;
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | );
66 | DialogHeader.displayName = "DialogHeader";
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | );
80 | DialogFooter.displayName = "DialogFooter";
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ));
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ));
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogClose,
114 | DialogTrigger,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription,
120 | };
121 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as LabelPrimitive from "@radix-ui/react-label";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-60"
9 | );
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ));
22 | Label.displayName = LabelPrimitive.Root.displayName;
23 |
24 | export { Label };
25 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref
13 | ) => (
14 |
25 | )
26 | )
27 | Separator.displayName = SeparatorPrimitive.Root.displayName
28 |
29 | export { Separator }
30 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | export const Spinner = (props: JSX.IntrinsicElements["div"]) => {
2 | return (
3 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ));
17 | Table.displayName = "Table";
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ));
25 | TableHeader.displayName = "TableHeader";
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ));
37 | TableBody.displayName = "TableBody";
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ));
52 | TableFooter.displayName = "TableFooter";
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ));
67 | TableRow.displayName = "TableRow";
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 |
81 | ));
82 | TableHead.displayName = "TableHead";
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 |
93 | ));
94 | TableCell.displayName = "TableCell";
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ));
106 | TableCaption.displayName = "TableCaption";
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | };
118 |
--------------------------------------------------------------------------------
/src/components/ui/text-field.tsx:
--------------------------------------------------------------------------------
1 | import { Label } from "./label";
2 | import { Input, InputProps } from "./input";
3 |
4 | type TextFieldProps = {
5 | label: string;
6 | } & InputProps;
7 |
8 | export const TextField = (props: TextFieldProps) => {
9 | const id = props.id || crypto.randomUUID();
10 | return (
11 |
14 |
15 | {props.required && * }
16 | {props.label}
17 |
18 |
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/components/ui/textarea-with-label.tsx:
--------------------------------------------------------------------------------
1 | import { Label } from "@/components/ui/label";
2 | import { Textarea, TextareaProps } from "@/components/ui/textarea";
3 |
4 | type TextareaWithLabelProps = {
5 | label: string;
6 | } & TextareaProps;
7 |
8 | export const TextareaWithLabel = (props: TextareaWithLabelProps) => {
9 | const id = props.id || crypto.randomUUID();
10 | return (
11 |
14 |
15 | {props.required && * }
16 | {props.label}
17 |
18 |
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/src/components/user.tsx:
--------------------------------------------------------------------------------
1 | import { NDKUser } from "@nostr-dev-kit/ndk";
2 | import { useQuery } from "@tanstack/react-query";
3 | import { Skeleton } from "./ui/skeleton";
4 | import { useNDK } from "@/hooks/use-ndk";
5 | import { getUserProfile } from "@/services/user";
6 |
7 | type UserProps = {
8 | user: NDKUser;
9 | type?: "info" | "onlyIcon";
10 | };
11 |
12 | const infoImageClass = "w-6 h-6 rounded-full border";
13 | const onlyIconImageClass = "w-10 h-10 rounded-full border";
14 |
15 | export const User = ({ user, type = "onlyIcon" }: UserProps) => {
16 | const { ndk } = useNDK();
17 |
18 | const { data } = useQuery({
19 | queryKey: [user],
20 | queryFn: async ({ queryKey }) => {
21 | const [user] = queryKey;
22 | if (!ndk) {
23 | return;
24 | }
25 | const profile = await getUserProfile(ndk, user);
26 | return profile;
27 | },
28 | });
29 |
30 | const imageClass = type === "info" ? infoImageClass : onlyIconImageClass;
31 |
32 | return (
33 |
34 | {data?.image ? (
35 |
36 | ) : (
37 |
38 | )}
39 | {type === "info" && (
40 |
{data?.displayName}
41 | )}
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/src/consts.ts:
--------------------------------------------------------------------------------
1 | export const DRAFT_DATE_BASED_CALENDAR_EVENT_KIND = 31926;
2 | export const DRAFT_TIME_BASED_CALENDAR_EVENT_KIND = 31927;
3 | export const DRAFT_CALENDAR_KIND = 31928;
4 | export const CALENDAR_EVENT_RSVP_KIND = 31925;
5 | export const DEFAULT_RELAYS = [
6 | "wss://yabu.me",
7 | "wss://relay.nostr.band",
8 | "wss://nos.lol",
9 | "wss://relay.nostr.band",
10 | "wss://relay.damus.io",
11 | "wss://nostr.mom",
12 | ];
13 | export const RELAY_TIMEOUT = 1000;
14 |
--------------------------------------------------------------------------------
/src/contexts/alert-context.tsx:
--------------------------------------------------------------------------------
1 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
2 | import {
3 | Dispatch,
4 | ReactNode,
5 | SetStateAction,
6 | createContext,
7 | useEffect,
8 | useState,
9 | } from "react";
10 |
11 | type AlertPayload = {
12 | variant?: "default" | "destructive";
13 | title: string;
14 | description?: string;
15 | icon?: ReactNode;
16 | lifetimeMs?: number;
17 | };
18 |
19 | export const alertContext = createContext(null);
20 | export const setAlertContext = createContext<
21 | Dispatch>
22 | >(() => undefined);
23 |
24 | export const AlertContextProvider = ({ children }: { children: ReactNode }) => {
25 | const [alert, setAlert] = useState(null);
26 |
27 | useEffect(() => {
28 | if (!alert) {
29 | return;
30 | }
31 |
32 | const timeout = setTimeout(() => {
33 | setAlert(null);
34 | }, alert.lifetimeMs || 4000);
35 |
36 | return () => clearTimeout(timeout);
37 | }, [alert]);
38 |
39 | return (
40 |
41 |
42 |
47 |
48 | {alert?.icon}
49 | {alert?.title}
50 | {alert?.description}
51 |
52 |
53 | {children}
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/src/contexts/ndk-context.tsx:
--------------------------------------------------------------------------------
1 | import { RELAY_TIMEOUT } from "@/consts";
2 | import { AppLocalStorage } from "@/services/app-local-storage";
3 | import { getRelays } from "@/services/relays";
4 | import NDK, { NDKNip07Signer, NDKSigner } from "@nostr-dev-kit/ndk";
5 | import {
6 | Dispatch,
7 | ReactNode,
8 | SetStateAction,
9 | createContext,
10 | useEffect,
11 | useRef,
12 | useState,
13 | } from "react";
14 |
15 | type SignerType = "privateKey" | "nip07";
16 |
17 | export const ndkContext = createContext(null);
18 | export const setNDKContext = createContext<
19 | Dispatch>
20 | >(() => undefined);
21 | export const ndkSignerTypeContext = createContext(null);
22 | export const setNDKSignerTypeContext = createContext<
23 | Dispatch>
24 | >(() => undefined);
25 |
26 | const appStorage = new AppLocalStorage();
27 |
28 | export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
29 | const [ndk, setNDK] = useState(null);
30 | const isCalledRef = useRef(false);
31 |
32 | const [signerType, setSignerType] = useState(null);
33 |
34 | useEffect(() => {
35 | if (!ndk && !isCalledRef.current) {
36 | isCalledRef.current = true;
37 |
38 | let signer: NDKNip07Signer | undefined = undefined;
39 |
40 | const strConnected = appStorage.getItem("connected");
41 |
42 | if (strConnected && JSON.parse(strConnected.toLowerCase())) {
43 | signer = new NDKNip07Signer();
44 | signer
45 | .user()
46 | .then(() => {
47 | createNewNDK(signer);
48 | setSignerType("nip07");
49 | })
50 | .catch(() => createNewNDK());
51 | } else {
52 | createNewNDK();
53 | }
54 | }
55 | }, [ndk]);
56 |
57 | const createNewNDK = async (signer?: NDKSigner) => {
58 | setNDK(null);
59 |
60 | const newNDK = new NDK({
61 | explicitRelayUrls: getRelays(),
62 | signer,
63 | });
64 | await newNDK.connect(RELAY_TIMEOUT);
65 |
66 | setNDK(newNDK);
67 |
68 | appStorage.setItem("connected", String(!!signer));
69 |
70 | return newNDK;
71 | };
72 |
73 | return (
74 |
75 |
76 |
77 |
78 | {children}
79 |
80 |
81 |
82 |
83 | );
84 | };
85 |
--------------------------------------------------------------------------------
/src/event.d.ts:
--------------------------------------------------------------------------------
1 | import type { NDKUser, NDKEvent } from "@nostr-dev-kit/ndk";
2 |
3 | type EventDateInput = {
4 | date: Date;
5 | includeTime: boolean;
6 | };
7 |
8 | type EventDate = EventDateInput & {
9 | id: string;
10 | event: NDKEvent;
11 | };
12 |
13 | type EventCalendarInput = {
14 | title: string;
15 | description?: string;
16 | dates: EventDateInput[];
17 | };
18 |
19 | type EventCalendar = Omit & {
20 | event: NDKEvent;
21 | id: string;
22 | dates: EventDate[];
23 | owner: NDKUser;
24 | };
25 |
26 | type RSVPStatus = "accepted" | "declined" | "tentative";
27 |
28 | type EventRSVPInput = {
29 | name?: string;
30 | rsvpList: {
31 | date: EventDate;
32 | status: RSVPStatus;
33 | }[];
34 | calenderId: string;
35 | comment?: string;
36 | };
37 |
38 | type RSVP = {
39 | [id in string]: {
40 | status: RSVPStatus;
41 | event: NDKEvent;
42 | };
43 | };
44 |
45 | type RSVPPerUsers = {
46 | [pubkey in string]: {
47 | user: NDKUser;
48 | rsvp: {
49 | [id in string]: {
50 | status: RSVPStatus;
51 | event: NDKEvent;
52 | };
53 | };
54 | };
55 | };
56 |
57 | type RSVPTotal = {
58 | [status in RSVPStatus]: number;
59 | };
60 |
61 | type GetRSVPResponse = {
62 | rsvpPerUsers: RSVPPerUsers;
63 | totals: RSVPTotal[];
64 | };
65 |
--------------------------------------------------------------------------------
/src/hooks/use-alert.ts:
--------------------------------------------------------------------------------
1 | import { alertContext, setAlertContext } from "@/contexts/alert-context";
2 | import { useContext } from "react";
3 |
4 | export const useAlert = () => {
5 | const alert = useContext(alertContext);
6 | const setAlert = useContext(setAlertContext);
7 | return { alert, setAlert };
8 | };
9 |
--------------------------------------------------------------------------------
/src/hooks/use-ndk.ts:
--------------------------------------------------------------------------------
1 | import { RELAY_TIMEOUT } from "@/consts";
2 | import {
3 | ndkContext,
4 | ndkSignerTypeContext,
5 | setNDKContext,
6 | setNDKSignerTypeContext,
7 | } from "@/contexts/ndk-context";
8 | import { AppLocalStorage } from "@/services/app-local-storage";
9 | import { getRelays } from "@/services/relays";
10 | import NDK, { NDKNip07Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
11 | import { useContext, useEffect, useState } from "react";
12 |
13 | const appStorage = new AppLocalStorage();
14 |
15 | export const useNDK = () => {
16 | const ndk = useContext(ndkContext);
17 | const setNDK = useContext(setNDKContext);
18 |
19 | const signerType = useContext(ndkSignerTypeContext);
20 | const setSignerType = useContext(setNDKSignerTypeContext);
21 |
22 | const [isLoading, setIsLoading] = useState(true);
23 |
24 | useEffect(() => {
25 | if (ndk) {
26 | setIsLoading(false);
27 | }
28 | }, [ndk]);
29 |
30 | const connectToNip07 = async () => {
31 | setIsLoading(true);
32 |
33 | const signer = new NDKNip07Signer();
34 | if (!(await signer.user())) {
35 | throw Error("Signer is not ready.");
36 | }
37 |
38 | const newNDK = new NDK({
39 | explicitRelayUrls: getRelays(),
40 | signer,
41 | });
42 |
43 | await newNDK.connect(RELAY_TIMEOUT);
44 |
45 | setNDK(newNDK);
46 | setIsLoading(false);
47 | setSignerType("nip07");
48 |
49 | appStorage.setItem("connected", String(true));
50 |
51 | return newNDK;
52 | };
53 |
54 | const assignPrivateKey = async (privKey: string) => {
55 | setIsLoading(true);
56 |
57 | const signer = new NDKPrivateKeySigner(privKey);
58 |
59 | const newNDK = new NDK({
60 | explicitRelayUrls: getRelays(),
61 | signer,
62 | });
63 |
64 | await newNDK.connect(RELAY_TIMEOUT);
65 |
66 | setNDK(newNDK);
67 | setIsLoading(false);
68 | setSignerType("privateKey");
69 |
70 | return newNDK;
71 | };
72 |
73 | const disconnectNIP07 = async () => {
74 | setIsLoading(true);
75 |
76 | setSignerType(null);
77 | const newNDK = new NDK({
78 | explicitRelayUrls: getRelays(),
79 | });
80 | await newNDK.connect(RELAY_TIMEOUT);
81 |
82 | appStorage.setItem("connected", String(false));
83 |
84 | setIsLoading(false);
85 | };
86 |
87 | return {
88 | ndk,
89 | isLoading,
90 | signerType,
91 | connectToNip07,
92 | disconnectNIP07,
93 | assignPrivateKey,
94 | };
95 | };
96 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 222.2 84% 4.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 84% 4.9%;
15 |
16 | --primary: 222.2 47.4% 11.2%;
17 | --primary-foreground: 210 40% 98%;
18 |
19 | --secondary: 210 40% 96.1%;
20 | --secondary-foreground: 222.2 47.4% 11.2%;
21 |
22 | --muted: 210 40% 96.1%;
23 | --muted-foreground: 215.4 16.3% 46.9%;
24 |
25 | --accent: 210 40% 96.1%;
26 | --accent-foreground: 222.2 47.4% 11.2%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 210 40% 98%;
30 |
31 | --border: 214.3 31.8% 91.4%;
32 | --input: 214.3 31.8% 91.4%;
33 | --ring: 222.2 84% 4.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 222.2 84% 4.9%;
40 | --foreground: 210 40% 98%;
41 |
42 | --card: 222.2 84% 4.9%;
43 | --card-foreground: 210 40% 98%;
44 |
45 | --popover: 222.2 84% 4.9%;
46 | --popover-foreground: 210 40% 98%;
47 |
48 | --primary: 210 40% 98%;
49 | --primary-foreground: 222.2 47.4% 11.2%;
50 |
51 | --secondary: 217.2 32.6% 17.5%;
52 | --secondary-foreground: 210 40% 98%;
53 |
54 | --muted: 217.2 32.6% 17.5%;
55 | --muted-foreground: 215 20.2% 65.1%;
56 |
57 | --accent: 217.2 32.6% 17.5%;
58 | --accent-foreground: 210 40% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 210 40% 98%;
62 |
63 | --border: 217.2 32.6% 17.5%;
64 | --input: 217.2 32.6% 17.5%;
65 | --ring: 212.7 26.8% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
77 |
78 | html body {
79 | @apply bg-slate-50;
80 | }
--------------------------------------------------------------------------------
/src/lib/formatDate.ts:
--------------------------------------------------------------------------------
1 | export const formatDate = (date?: number | Date) => {
2 | const dateTimeFormat = new Intl.DateTimeFormat(undefined, {
3 | month: "2-digit",
4 | day: "2-digit",
5 | weekday: "short",
6 | });
7 | return dateTimeFormat.format(date);
8 | };
9 |
--------------------------------------------------------------------------------
/src/lib/user.ts:
--------------------------------------------------------------------------------
1 | import { NDKUser } from "@nostr-dev-kit/ndk";
2 |
3 | export const getName = (user: NDKUser) =>
4 | user.profile?.displayName || user.profile?.name || user.npub;
5 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.tsx";
4 | import "./index.css";
5 |
6 | ReactDOM.createRoot(document.getElementById("root")!).render(
7 |
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/src/pages/events/[naddr].tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from "@/components/layout";
2 | import { Card } from "@/components/ui/card";
3 | import { useAlert } from "@/hooks/use-alert";
4 | import { getEventCalendar, getRSVP } from "@/services/event-calender";
5 | import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
6 | import { useEffect } from "react";
7 | import { useParams } from "react-router-dom";
8 | import { useNDK } from "@/hooks/use-ndk";
9 | import type NDK from "@nostr-dev-kit/ndk";
10 | import { CalendarTable } from "@/components/calendar-table";
11 | import { AppLocalStorage } from "@/services/app-local-storage";
12 | import { Helmet } from "react-helmet";
13 | import { CalendarInfoCard } from "@/components/calendar-info-card";
14 |
15 | const appStorage = new AppLocalStorage();
16 |
17 | export const EventCalendarPage = () => {
18 | const { naddr } = useParams();
19 | if (!naddr) {
20 | throw Error();
21 | }
22 |
23 | const { setAlert } = useAlert();
24 |
25 | const { ndk, assignPrivateKey, signerType } = useNDK();
26 |
27 | // Queries
28 | const {
29 | data: calendar,
30 | refetch: calendarRefetch,
31 | isLoading: isCalendarLoading,
32 | } = useSuspenseQuery({
33 | queryKey: [ndk, naddr],
34 | queryFn: ({ queryKey }) => {
35 | const [ndk, naddr] = queryKey as [NDK, string];
36 | if (!ndk) {
37 | return null;
38 | }
39 | return getEventCalendar(ndk, naddr);
40 | },
41 | });
42 |
43 | const {
44 | data: rsvp,
45 | error: rsvpError,
46 | refetch: rsvpRefetch,
47 | isLoading: isRSVPLoading,
48 | } = useQuery({
49 | queryKey: [ndk, naddr, "rsvp"],
50 | queryFn: async ({ queryKey }) => {
51 | const [ndk] = queryKey as [NDK?];
52 |
53 | if (!ndk || !calendar) {
54 | return null;
55 | }
56 |
57 | try {
58 | const rsvp = await getRSVP(ndk, calendar.dates, true);
59 | return rsvp;
60 | } catch (e) {
61 | setAlert({
62 | title: "RSVP Fetch Error",
63 | description: String(e),
64 | });
65 | throw e;
66 | }
67 | },
68 | });
69 |
70 | useEffect(() => {
71 | if (rsvpError) {
72 | setAlert({
73 | title: rsvpError.name,
74 | description: rsvpError?.message,
75 | variant: "destructive",
76 | });
77 | }
78 | }, [rsvpError, setAlert]);
79 |
80 | useEffect(() => {
81 | if (!calendar || ndk?.activeUser) {
82 | return;
83 | }
84 |
85 | const privKey = appStorage.getItem(`${calendar.id}.privateKey`);
86 | if (!privKey) {
87 | return;
88 | }
89 |
90 | assignPrivateKey(privKey).catch((e) => {
91 | setAlert({
92 | title: "Account Error",
93 | description: e,
94 | });
95 | });
96 | }, [assignPrivateKey, calendar, ndk?.activeUser, setAlert]);
97 |
98 | const submitErrorHandler = (e: unknown) => {
99 | setAlert({
100 | title: "Failed to Submit.",
101 | description: String(e),
102 | variant: "destructive",
103 | });
104 | };
105 |
106 | if (!calendar) {
107 | return <>>;
108 | }
109 |
110 | return (
111 |
112 |
113 | {calendar.title} | chronostr
114 |
115 |
116 | {rsvp && !isRSVPLoading && (
117 |
129 | )}
130 | {!isCalendarLoading && !isRSVPLoading && (
131 |
132 |
133 |
134 | )}
135 |
136 |
137 | );
138 | };
139 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { EventEditor } from "@/components/event-editor";
2 | import { Layout } from "@/components/layout";
3 | import { Card } from "@/components/ui/card";
4 | import { Helmet } from "react-helmet";
5 |
6 | export const IndexPage = () => {
7 | return (
8 |
9 |
10 | chronostr
11 |
12 |
13 |
14 |
chronostr
15 |
16 | A scheduling adjustment and RSVP tool working on the Nostr.
17 |
18 |
19 |
20 | Create New Event
21 |
22 |
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/src/pages/mypage.tsx:
--------------------------------------------------------------------------------
1 | import { CalendarInfoCard } from "@/components/calendar-info-card";
2 | import { Layout } from "@/components/layout";
3 | import { Spinner } from "@/components/ui/spinner";
4 | import { useAlert } from "@/hooks/use-alert";
5 | import { useNDK } from "@/hooks/use-ndk";
6 | import { getJoinedCalendarEvents } from "@/services/event-calender";
7 | import { useSuspenseQuery } from "@tanstack/react-query";
8 | import { Suspense, useEffect } from "react";
9 | import { Helmet } from "react-helmet";
10 | import { Link, useNavigate } from "react-router-dom";
11 |
12 | export const MyPage = () => {
13 | const { ndk, isLoading } = useNDK();
14 |
15 | const navigate = useNavigate();
16 | const { setAlert } = useAlert();
17 |
18 | useEffect(() => {
19 | if (!ndk && !isLoading) {
20 | setAlert({
21 | title: "Please connect to NIP-07 client first.",
22 | });
23 | navigate("/", { replace: true });
24 | }
25 | }, [ndk, isLoading, setAlert, navigate]);
26 |
27 | return (
28 | <>
29 |
30 | MyPage | chronostr
31 |
32 |
33 | {!isLoading && ndk && (
34 |
35 |
My Page
36 |
37 |
38 | Joined Events
39 |
40 | }>
41 |
42 |
43 |
44 |
45 | )}
46 |
47 | >
48 | );
49 | };
50 |
51 | const JoinedEvents = () => {
52 | const { ndk, signerType } = useNDK();
53 | const { data } = useSuspenseQuery({
54 | queryKey: [ndk, "getJoinedCalendarEvents"],
55 | queryFn: () => {
56 | if (!ndk || !ndk.activeUser?.pubkey) {
57 | return null;
58 | }
59 | return getJoinedCalendarEvents(ndk, ndk.activeUser?.pubkey);
60 | },
61 | });
62 |
63 | if (!data) {
64 | return <>>;
65 | }
66 |
67 | return (
68 |
69 | {data.map((calendar) => (
70 |
75 |
82 |
83 | ))}
84 |
85 | );
86 | };
87 |
--------------------------------------------------------------------------------
/src/services/app-local-storage.ts:
--------------------------------------------------------------------------------
1 | export class AppLocalStorage {
2 | static readonly baseKey = "chronostr.studiokaiji.com";
3 |
4 | setItem(key: string, value: string) {
5 | return localStorage.setItem(`${AppLocalStorage.baseKey}:${key}`, value);
6 | }
7 |
8 | getItem(key: string) {
9 | return localStorage.getItem(`${AppLocalStorage.baseKey}:${key}`);
10 | }
11 |
12 | removeItem(key: string) {
13 | return localStorage.removeItem(`${AppLocalStorage.baseKey}:${key}`);
14 | }
15 |
16 | get length() {
17 | return localStorage.length;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/services/contact.ts:
--------------------------------------------------------------------------------
1 | import NDK, { NDKEvent, NDKUser } from "@nostr-dev-kit/ndk";
2 |
3 | export const contactEvent = async (
4 | ndk: NDK,
5 | body: string,
6 | users: NDKUser[]
7 | ) => {
8 | const ev = new NDKEvent(ndk);
9 | ev.kind = 1;
10 | ev.content = body;
11 |
12 | const tags = [];
13 | for (const user of users) {
14 | tags.push(["p", user.pubkey]);
15 | }
16 | ev.tags = tags;
17 |
18 | await ev.sign();
19 | await ev.publish();
20 |
21 | return ev;
22 | };
23 |
--------------------------------------------------------------------------------
/src/services/event-calender.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CALENDAR_EVENT_RSVP_KIND,
3 | DRAFT_CALENDAR_KIND,
4 | DRAFT_DATE_BASED_CALENDAR_EVENT_KIND,
5 | DRAFT_TIME_BASED_CALENDAR_EVENT_KIND,
6 | } from "@/consts";
7 | import {
8 | EventCalendar,
9 | EventCalendarInput,
10 | EventDate,
11 | EventDateInput,
12 | EventRSVPInput,
13 | RSVPPerUsers,
14 | RSVPStatus,
15 | } from "@/event";
16 | import NDK, {
17 | NDKEvent,
18 | NDKFilter,
19 | NDKPrivateKeySigner,
20 | serializeProfile,
21 | } from "@nostr-dev-kit/ndk";
22 | import { AppLocalStorage } from "./app-local-storage";
23 |
24 | export const updateEventCalendar = async (
25 | ndk: NDK,
26 | calendarId: string,
27 | addDates: EventDateInput[],
28 | removeDateEventTagIds: string[],
29 | title?: string,
30 | description?: string
31 | ) => {
32 | const calendarEvent = await ndk.fetchEvent(calendarId);
33 | if (!calendarEvent) {
34 | throw Error("Calendar Event not found");
35 | }
36 | if (!calendarEvent.dTag) {
37 | throw Error("Invalid Calendar Event");
38 | }
39 |
40 | const calendarWithoutDates = eventToCalendar(calendarEvent, []);
41 | title ??= calendarWithoutDates.title;
42 | description ??= calendarWithoutDates.description;
43 |
44 | // Create Draft Date/Time Calendar Events
45 | const newDateEvents = [];
46 |
47 | for (let i = 0; i < addDates.length; i++) {
48 | const date = addDates[i];
49 | const kind = date.includeTime
50 | ? DRAFT_TIME_BASED_CALENDAR_EVENT_KIND
51 | : DRAFT_DATE_BASED_CALENDAR_EVENT_KIND;
52 |
53 | // tags
54 | const tags = [];
55 |
56 | const id = crypto.randomUUID();
57 |
58 | tags.push(["d", id]);
59 | tags.push(["name", `${title}-candidate-dates-${i}`]);
60 | tags.push(["a", [kind, ndk.activeUser!.pubkey, calendarId].join(":")]);
61 |
62 | const start = date.includeTime
63 | ? String(Math.floor(date.date.getTime() / 1000))
64 | : date.date.toISOString();
65 | tags.push(["start", start]);
66 |
67 | const content = description || "";
68 |
69 | const ev = new NDKEvent(ndk);
70 | ev.kind = kind;
71 | ev.tags = tags;
72 | ev.content = content;
73 |
74 | await ev.sign();
75 |
76 | newDateEvents.push(ev);
77 | }
78 |
79 | // const newDateEvents = await Promise.all(
80 | // addDates.map(async (date, i) => {
81 | // const kind = date.includeTime
82 | // ? DRAFT_TIME_BASED_CALENDAR_EVENT_KIND
83 | // : DRAFT_DATE_BASED_CALENDAR_EVENT_KIND;
84 |
85 | // // tags
86 | // const tags = [];
87 |
88 | // const id = crypto.randomUUID();
89 |
90 | // tags.push(["d", id]);
91 | // tags.push(["name", `${title}-candidate-dates-${i}`]);
92 | // tags.push(["a", [kind, ndk.activeUser!.pubkey, id].join(":")]);
93 |
94 | // const start = date.includeTime
95 | // ? String(Math.floor(date.date.getTime() / 1000))
96 | // : date.date.toISOString();
97 | // tags.push(["start", start]);
98 |
99 | // const content = description || "";
100 |
101 | // const ev = new NDKEvent(ndk);
102 | // ev.kind = kind;
103 | // ev.tags = tags;
104 | // ev.content = content;
105 |
106 | // await ev.sign();
107 |
108 | // return ev;
109 | // })
110 | // );
111 |
112 | // Update Draft Calendar Event
113 | const currentCalendarTags = calendarEvent.getMatchingTags("a");
114 | const baseATags = currentCalendarTags.filter(
115 | (tag) => !removeDateEventTagIds.includes(tag[1])
116 | );
117 |
118 | const draftCalendarEvent = new NDKEvent(ndk);
119 | draftCalendarEvent.kind = DRAFT_CALENDAR_KIND;
120 |
121 | const newATags = newDateEvents.map((ev) => {
122 | const dTag = ev.tags.find((tags) => tags[0] === "d");
123 | if (!dTag) {
124 | throw Error("Invalid event");
125 | }
126 | return ["a", ev.tagId()];
127 | });
128 |
129 | draftCalendarEvent.tags = [
130 | ["d", calendarEvent.dTag],
131 | ["title", title],
132 | ...baseATags,
133 | ...newATags,
134 | ];
135 | draftCalendarEvent.content = description || "";
136 |
137 | await draftCalendarEvent.sign();
138 |
139 | // Publish
140 | for (const ev of newDateEvents) {
141 | await ev.publish();
142 | }
143 |
144 | await draftCalendarEvent.publish();
145 |
146 | // await Promise.all([
147 | // ...newDateEvents.map((ev) => ev.publish()),
148 | // draftCalendarEvent.publish(),
149 | // ]);
150 |
151 | return draftCalendarEvent;
152 | };
153 |
154 | export const createEventCalendar = async (
155 | ndk: NDK,
156 | input: EventCalendarInput
157 | ) => {
158 | const calendarId = crypto.randomUUID();
159 |
160 | // Create Draft Date/Time Calendar Events
161 | const candidateDateEvents = [];
162 |
163 | for (let i = 0; i < input.dates.length; i++) {
164 | const date = input.dates[i];
165 | const kind = date.includeTime
166 | ? DRAFT_TIME_BASED_CALENDAR_EVENT_KIND
167 | : DRAFT_DATE_BASED_CALENDAR_EVENT_KIND;
168 |
169 | // tags
170 | const tags = [];
171 |
172 | const id = crypto.randomUUID();
173 |
174 | tags.push(["d", id]);
175 | tags.push(["name", `${input.title}-candidate-dates-${i}`]);
176 | tags.push(["a", [kind, ndk.activeUser!.pubkey, calendarId].join(":")]);
177 |
178 | const start = date.includeTime
179 | ? String(Math.floor(date.date.getTime() / 1000))
180 | : date.date.toISOString();
181 | tags.push(["start", start]);
182 |
183 | const content = input.description || "";
184 |
185 | const ev = new NDKEvent(ndk);
186 | ev.kind = kind;
187 | ev.tags = tags;
188 | ev.content = content;
189 |
190 | await ev.sign();
191 | candidateDateEvents.push(ev);
192 | }
193 |
194 | // const candidateDateEvents = await Promise.all(
195 | // input.dates.map(async (date, i) => {
196 | // const kind = date.includeTime
197 | // ? DRAFT_TIME_BASED_CALENDAR_EVENT_KIND
198 | // : DRAFT_DATE_BASED_CALENDAR_EVENT_KIND;
199 |
200 | // // tags
201 | // const tags = [];
202 |
203 | // const id = crypto.randomUUID();
204 |
205 | // tags.push(["d", id]);
206 | // tags.push(["name", `${input.title}-candidate-dates-${i}`]);
207 | // tags.push(["a", [kind, ndk.activeUser!.pubkey, id].join(":")]);
208 |
209 | // const start = date.includeTime
210 | // ? String(Math.floor(date.date.getTime() / 1000))
211 | // : date.date.toISOString();
212 | // tags.push(["start", start]);
213 |
214 | // const content = input.description || "";
215 |
216 | // const ev = new NDKEvent(ndk);
217 | // ev.kind = kind;
218 | // ev.tags = tags;
219 | // ev.content = content;
220 |
221 | // await ev.sign();
222 |
223 | // return ev;
224 | // })
225 | // );
226 |
227 | // Create Draft Calendar Event
228 | const draftCalendarEvent = new NDKEvent(ndk);
229 | draftCalendarEvent.kind = DRAFT_CALENDAR_KIND;
230 |
231 | const aTags = candidateDateEvents.map((ev) => {
232 | const dTag = ev.tags.find((tags) => tags[0] === "d");
233 | if (!dTag) {
234 | throw Error("Invalid event");
235 | }
236 | return ["a", ev.tagId()];
237 | });
238 |
239 | draftCalendarEvent.tags = [
240 | ["d", calendarId],
241 | ["title", input.title],
242 | ...aTags,
243 | ];
244 | draftCalendarEvent.content = input.description || "";
245 |
246 | await draftCalendarEvent.sign();
247 |
248 | // Publish all
249 | for (const ev of candidateDateEvents) {
250 | await ev.publish();
251 | }
252 |
253 | await draftCalendarEvent.publish();
254 |
255 | // await Promise.all([
256 | // ...candidateDateEvents.map((ev) => ev.publish()),
257 | // draftCalendarEvent.publish(),
258 | // ]);
259 |
260 | return draftCalendarEvent;
261 | };
262 |
263 | export const getEventCalendar = async (ndk: NDK, naddrOrDTag: string) => {
264 | const calendarEvent = await ndk.fetchEvent(naddrOrDTag);
265 | if (!calendarEvent) {
266 | return null;
267 | }
268 |
269 | const aTags = calendarEvent.getMatchingTags("a");
270 | const filters: NDKFilter[] = [];
271 |
272 | for (const tag of aTags) {
273 | const splitted = tag[1].split(":");
274 | if (splitted.length < 3) {
275 | continue;
276 | }
277 |
278 | const [kind, pubkey, identifier] = splitted;
279 | filters.push({
280 | kinds: [Number(kind)],
281 | authors: [pubkey],
282 | "#d": [identifier],
283 | });
284 | }
285 |
286 | const dateEvents = await ndk.fetchEvents(filters);
287 | const dates: EventDate[] = [];
288 |
289 | for (const ev of dateEvents) {
290 | const date = eventToDate(ev);
291 | if (date) {
292 | dates.push(date);
293 | }
294 | }
295 |
296 | const calendar = eventToCalendar(calendarEvent, dates);
297 | return calendar;
298 | };
299 |
300 | export const rsvpEvent = async (
301 | ndk: NDK,
302 | input: EventRSVPInput,
303 | beforeRSVPEvents?: NDKEvent[]
304 | ) => {
305 | if (beforeRSVPEvents && !ndk.signer) {
306 | throw Error("Invalid Request");
307 | }
308 |
309 | let signer: NDKPrivateKeySigner | undefined;
310 |
311 | // nameが指定されている場合はprivateを指定
312 | if (input.name) {
313 | signer = NDKPrivateKeySigner.generate();
314 |
315 | const user = await signer.user();
316 | user.ndk = ndk;
317 |
318 | let publishRequired = true;
319 |
320 | if (beforeRSVPEvents) {
321 | const profile = await user.fetchProfile({
322 | pool: ndk.pool,
323 | });
324 | publishRequired = profile?.displayName !== input.name;
325 | }
326 |
327 | if (publishRequired) {
328 | user.profile ??= {};
329 | user.profile.displayName = input.name;
330 | user.profile.about = "chronostr anonymous user";
331 |
332 | const event = new NDKEvent(ndk);
333 | event.content = serializeProfile(user.profile);
334 | event.kind = 0;
335 |
336 | await event.sign(signer);
337 |
338 | const appStorage = new AppLocalStorage();
339 |
340 | await event.publish();
341 | appStorage.setItem(
342 | `${input.calenderId}.privateKey`,
343 | signer.privateKey || ""
344 | );
345 | }
346 | }
347 |
348 | const events = [];
349 | for (const rsvp of input.rsvpList) {
350 | const ev = new NDKEvent(ndk);
351 | ev.kind = CALENDAR_EVENT_RSVP_KIND;
352 |
353 | const tags = [];
354 |
355 | tags.push(["a", rsvp.date.id]);
356 |
357 | if (beforeRSVPEvents) {
358 | const currentDTag = beforeRSVPEvents.find(
359 | (bev) => bev.dTag && bev.dTag === rsvp.date.event.dTag
360 | )?.dTag;
361 | const dTag = currentDTag || crypto.randomUUID();
362 | tags.push(["d", dTag]);
363 | } else {
364 | tags.push(["d", crypto.randomUUID()]);
365 | }
366 |
367 | tags.push(["L", "status"]);
368 | tags.push(["l", rsvp.status, "status"]);
369 |
370 | ev.tags = tags;
371 |
372 | await ev.sign(signer);
373 | await ev.publish();
374 |
375 | events.push(ev);
376 | }
377 |
378 | // const events = await Promise.all(
379 | // input.rsvpList.map(async (rsvp) => {
380 | // const ev = new NDKEvent(ndk);
381 | // ev.kind = CALENDAR_EVENT_RSVP_KIND;
382 |
383 | // const tags = [];
384 |
385 | // tags.push(["a", rsvp.date.id]);
386 |
387 | // if (beforeRSVPEvents) {
388 | // const currentDTag = beforeRSVPEvents.find(
389 | // (bev) => bev.dTag && bev.dTag === rsvp.date.event.dTag
390 | // )?.dTag;
391 | // const dTag = currentDTag || crypto.randomUUID();
392 | // tags.push(["d", dTag]);
393 | // } else {
394 | // tags.push(["d", crypto.randomUUID()]);
395 | // }
396 |
397 | // tags.push(["L", "status"]);
398 | // tags.push(["l", rsvp.status, "status"]);
399 |
400 | // ev.tags = tags;
401 |
402 | // await ev.sign(signer);
403 |
404 | // return ev;
405 | // })
406 | // );
407 |
408 | for (const ev of events) {
409 | await ev.publish();
410 | }
411 |
412 | return events;
413 | };
414 |
415 | export const getRSVP = async (
416 | ndk: NDK,
417 | dates: EventDate[],
418 | fetchProfiles = false
419 | ) => {
420 | const aTags = dates.map((date) => date.event.tagId());
421 |
422 | const events = await ndk.fetchEvents([
423 | {
424 | kinds: [Number(CALENDAR_EVENT_RSVP_KIND)],
425 | "#a": aTags,
426 | },
427 | ]);
428 |
429 | const rsvpPerUsers: RSVPPerUsers = {};
430 |
431 | const promises: Promise[] = [];
432 |
433 | const totalMap: {
434 | [id: string]: {
435 | [status in RSVPStatus]: number;
436 | };
437 | } = {};
438 |
439 | events.forEach((ev) => {
440 | const user = ev.author;
441 |
442 | if (!rsvpPerUsers[user.pubkey]) {
443 | rsvpPerUsers[user.pubkey] = {
444 | user: user,
445 | rsvp: {},
446 | };
447 | if (fetchProfiles) {
448 | promises.push(
449 | rsvpPerUsers[user.pubkey].user?.fetchProfile().catch((e) => {
450 | console.error(e);
451 | return null;
452 | })
453 | );
454 | }
455 | }
456 |
457 | const statusTags = ev
458 | .getMatchingTags("l")
459 | .filter((tag) => tag[2] === "status");
460 | if (!statusTags || statusTags.length < 1) {
461 | return;
462 | }
463 |
464 | const statusTag = statusTags[0];
465 | const status = statusTag[1] as RSVPStatus;
466 | if (
467 | status !== "accepted" &&
468 | status !== "tentative" &&
469 | status !== "declined"
470 | ) {
471 | return;
472 | }
473 |
474 | const aTag = ev.tagValue("a");
475 | if (!aTag) {
476 | return;
477 | }
478 |
479 | if (!rsvpPerUsers[user.pubkey].rsvp[aTag]) {
480 | rsvpPerUsers[user.pubkey].rsvp[aTag] = {
481 | event: ev,
482 | status,
483 | };
484 |
485 | totalMap[aTag] ??= {
486 | declined: 0,
487 | tentative: 0,
488 | accepted: 0,
489 | };
490 |
491 | totalMap[aTag][status]++;
492 | }
493 | });
494 |
495 | const totals = dates.map((date) => totalMap[date.id]);
496 |
497 | if (promises.length) {
498 | await new Promise((resolve) => {
499 | Promise.allSettled(promises).then(() => resolve());
500 | setTimeout(() => {
501 | resolve();
502 | }, 3000);
503 | });
504 | }
505 |
506 | return {
507 | rsvpPerUsers,
508 | totals,
509 | };
510 | };
511 |
512 | export const eventToCalendar = (
513 | event: NDKEvent,
514 | dates: EventDate[]
515 | ): EventCalendar => {
516 | const sortedDates = dates.sort((a, b) => a.date.getTime() - b.date.getTime());
517 | return {
518 | title: event.tagValue("title") || "",
519 | description: event.content,
520 | dates: sortedDates,
521 | owner: event.author,
522 | event: event,
523 | id: event.tagAddress(),
524 | };
525 | };
526 |
527 | export const eventToDate = (event: NDKEvent): EventDate | null => {
528 | const start = event.tagValue("start");
529 | if (!start) return null;
530 |
531 | const includeTime = !Number.isNaN(Number(start));
532 | const date = new Date(includeTime ? Number(start) * 1000 : start);
533 | if (!date || Number.isNaN(date.getTime())) {
534 | return null;
535 | }
536 |
537 | return {
538 | date,
539 | includeTime,
540 | id: event.tagAddress(),
541 | event,
542 | };
543 | };
544 |
545 | /**
546 | * ユーザーが参加しているイベントの一覧を取得
547 | */
548 | export const getJoinedCalendarEvents = async (ndk: NDK, pubkey: string) => {
549 | const rsvpEvents = await ndk.fetchEvents([
550 | {
551 | kinds: [CALENDAR_EVENT_RSVP_KIND as number],
552 | authors: [pubkey || ""],
553 | },
554 | ]);
555 |
556 | const dateEventATags = new Set();
557 | for (const rev of rsvpEvents) {
558 | const aTag = rev.tagValue("a");
559 | if (aTag) {
560 | dateEventATags.add(aTag);
561 | }
562 | }
563 |
564 | const dateEvents = await ndk.fetchEvents([
565 | {
566 | kinds: [
567 | DRAFT_DATE_BASED_CALENDAR_EVENT_KIND,
568 | DRAFT_TIME_BASED_CALENDAR_EVENT_KIND,
569 | ] as number[],
570 | "#d": [...dateEventATags]
571 | .map((a) => a.split(":")?.[2] || "")
572 | .filter((t) => t),
573 | },
574 | ]);
575 |
576 | const calendarEventATags = new Set();
577 |
578 | for (const ev of dateEvents) {
579 | const aTag = ev.tagValue("a");
580 | if (!aTag) {
581 | continue;
582 | }
583 | calendarEventATags.add(aTag);
584 | }
585 |
586 | const calendarEvents = await ndk.fetchEvents([
587 | {
588 | kinds: [DRAFT_CALENDAR_KIND as number],
589 | "#d": [...calendarEventATags]
590 | .map((aTag) => aTag.split(":")?.[2] || "")
591 | .filter((t) => t),
592 | },
593 | ]);
594 |
595 | const calendarDateATags = new Set();
596 | for (const ev of calendarEvents) {
597 | const aTag = ev.tagValue("a");
598 | if (!aTag) {
599 | continue;
600 | }
601 | calendarDateATags.add(aTag);
602 | }
603 |
604 | const calendarDateEvents = await ndk.fetchEvents([
605 | {
606 | kinds: [
607 | DRAFT_DATE_BASED_CALENDAR_EVENT_KIND,
608 | DRAFT_TIME_BASED_CALENDAR_EVENT_KIND,
609 | ] as number[],
610 | "#d": [...calendarDateATags]
611 | .map((aTag) => aTag.split(":")?.[2] || "")
612 | .filter((t) => t),
613 | limit: 200,
614 | },
615 | ]);
616 |
617 | const dates = [...calendarDateEvents]
618 | .map((ev) => eventToDate(ev))
619 | .filter((d) => d !== null);
620 |
621 | return [...calendarEvents].map((ev) => eventToCalendar(ev, dates));
622 | };
623 |
--------------------------------------------------------------------------------
/src/services/relays.ts:
--------------------------------------------------------------------------------
1 | import { DEFAULT_RELAYS } from "@/consts";
2 | import { AppLocalStorage } from "./app-local-storage";
3 |
4 | const localKV = new AppLocalStorage();
5 |
6 | export const getRelays = () => {
7 | const item = localKV.getItem("relays");
8 | if (!item) {
9 | return DEFAULT_RELAYS;
10 | }
11 |
12 | const parsed = JSON.parse(item);
13 | if (!Array.isArray(parsed)) {
14 | return DEFAULT_RELAYS;
15 | }
16 |
17 | return parsed as string[];
18 | };
19 |
20 | export const setRelays = (relays: string[]) => {
21 | const key = `relays`;
22 | localStorage.setItem(key, JSON.stringify(relays));
23 | };
24 |
--------------------------------------------------------------------------------
/src/services/user.ts:
--------------------------------------------------------------------------------
1 | import NDK, { NDKUser } from "@nostr-dev-kit/ndk";
2 |
3 | export const getUserProfile = async (ndk: NDK, user: NDKUser) => {
4 | if (user.profile) {
5 | return user.profile;
6 | }
7 |
8 | const profile = await user.fetchProfile({
9 | pool: ndk?.pool,
10 | });
11 |
12 | return profile;
13 | };
14 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/strfry.conf:
--------------------------------------------------------------------------------
1 | ##
2 | ## Default strfry config
3 | ##
4 |
5 | # Directory that contains the strfry LMDB database (restart required)
6 | db = "./strfry-db/"
7 |
8 | dbParams {
9 | # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)
10 | maxreaders = 256
11 |
12 | # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)
13 | mapsize = 10995116277760
14 |
15 | # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)
16 | noReadAhead = false
17 | }
18 |
19 | events {
20 | # Maximum size of normalised JSON, in bytes
21 | maxEventSize = 65536
22 |
23 | # Events newer than this will be rejected
24 | rejectEventsNewerThanSeconds = 900
25 |
26 | # Events older than this will be rejected
27 | rejectEventsOlderThanSeconds = 94608000
28 |
29 | # Ephemeral events older than this will be rejected
30 | rejectEphemeralEventsOlderThanSeconds = 60
31 |
32 | # Ephemeral events will be deleted from the DB when older than this
33 | ephemeralEventsLifetimeSeconds = 300
34 |
35 | # Maximum number of tags allowed
36 | maxNumTags = 2000
37 |
38 | # Maximum size for tag values, in bytes
39 | maxTagValSize = 1024
40 | }
41 |
42 | relay {
43 | # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)
44 | bind = "0.0.0.0"
45 |
46 | # Port to open for the nostr websocket protocol (restart required)
47 | port = 7777
48 |
49 | # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
50 | nofiles = 1000000
51 |
52 | # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
53 | realIpHeader = ""
54 |
55 | info {
56 | # NIP-11: Name of this server. Short/descriptive (< 30 characters)
57 | name = "strfry default"
58 |
59 | # NIP-11: Detailed information about relay, free-form
60 | description = "This is a strfry instance."
61 |
62 | # NIP-11: Administrative nostr pubkey, for contact purposes
63 | pubkey = ""
64 |
65 | # NIP-11: Alternative administrative contact (email, website, etc)
66 | contact = ""
67 | }
68 |
69 | # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)
70 | maxWebsocketPayloadSize = 131072
71 |
72 | # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)
73 | autoPingSeconds = 55
74 |
75 | # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)
76 | enableTcpKeepalive = false
77 |
78 | # How much uninterrupted CPU time a REQ query should get during its DB scan
79 | queryTimesliceBudgetMicroseconds = 10000
80 |
81 | # Maximum records that can be returned per filter
82 | maxFilterLimit = 500
83 |
84 | # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time
85 | maxSubsPerConnection = 20
86 |
87 | writePolicy {
88 | # If non-empty, path to an executable script that implements the writePolicy plugin logic
89 | plugin = ""
90 | }
91 |
92 | compression {
93 | # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)
94 | enabled = true
95 |
96 | # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)
97 | slidingWindow = true
98 | }
99 |
100 | logging {
101 | # Dump all incoming messages
102 | dumpInAll = false
103 |
104 | # Dump all incoming EVENT messages
105 | dumpInEvents = false
106 |
107 | # Dump all incoming REQ/CLOSE messages
108 | dumpInReqs = false
109 |
110 | # Log performance metrics for initial REQ database scans
111 | dbScanPerf = false
112 |
113 | # Log reason for invalid event rejection? Can be disabled to silence excessive logging
114 | invalidEvents = true
115 | }
116 |
117 | numThreads {
118 | # Ingester threads: route incoming requests, validate events/sigs (restart required)
119 | ingester = 3
120 |
121 | # reqWorker threads: Handle initial DB scan for events (restart required)
122 | reqWorker = 3
123 |
124 | # reqMonitor threads: Handle filtering of new events (restart required)
125 | reqMonitor = 3
126 |
127 | # negentropy threads: Handle negentropy protocol messages (restart required)
128 | negentropy = 2
129 | }
130 |
131 | negentropy {
132 | # Support negentropy protocol messages
133 | enabled = true
134 |
135 | # Maximum records that sync will process before returning an error
136 | maxSyncEvents = 1000000
137 | }
138 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: 0 },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: 0 },
67 | },
68 | },
69 | animation: {
70 | "accordion-down": "accordion-down 0.2s ease-out",
71 | "accordion-up": "accordion-up 0.2s ease-out",
72 | },
73 | },
74 | },
75 | plugins: [require("tailwindcss-animate")],
76 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "baseUrl": ".",
23 | "paths": {
24 | "@/*": [
25 | "./src/*"
26 | ]
27 | },
28 | },
29 | "include": ["src"],
30 | "references": [{ "path": "./tsconfig.node.json" }]
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path from "path";
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | resolve: {
9 | alias: {
10 | "@": path.resolve(__dirname, "./src"),
11 | },
12 | },
13 | });
14 |
--------------------------------------------------------------------------------