Add files via upload

This commit is contained in:
Michilis
2025-07-19 09:31:12 +02:00
committed by GitHub
parent c94a4d8c46
commit 9fdbc127e7
41 changed files with 8978 additions and 1 deletions

92
Context.md Normal file
View File

@@ -0,0 +1,92 @@
# context.md
## Purpose
The goal of this app is to provide a minimalist but powerful tool for people to track life milestones using the Nostr protocol. It's completely censorship-resistant and optionally anonymous.
Examples:
* Days **since** quitting alcohol
* Days **since** last porn use
* Days **since** last cigarette
* Days **until** a planned event like a trip, wedding, or conference
The app encourages positive habit tracking and event anticipation. Counters are personal but shareable, with optional zapping support.
---
## User Roles
### Anonymous Visitor
* Can view public counters
* Cannot create or save counters
### Nostr Logged-In User
* Can create counters (published to Nostr relays)
* Can edit/delete own counters (via NIP-01 signature)
* Can receive zaps
---
## User Flows
### 1. Login
* User connects a Nostr signer (e.g. extension or mobile app)
* App stores the pubkey in state (no server-side session needed)
### 2. Create a Counter
* Click `+ Days Since` or `+ Days Until`
* Fill form: title, type, date, visibility
* App constructs a `kind: 30078` event
* Signs and publishes it to relays
### 3. View Dashboard
* Shows counters created by the user
* Sorted by creation or date proximity
### 4. Public Viewing
* Counters from all users marked `public`
* Optional featured section
* Zap button visible if NIP-05 or lightning address is detected
### 5. Share a Counter
* Permalink to `/counter/:slug`
* Users can repost or share
---
## Event Fetch Logic
* Use NDK to fetch kind 30078
* Filter by:
* Author pubkey (for personal dashboard)
* Tag `visibility=public` for public view
* Parse date and type for rendering
---
## Edge Cases & Decisions
* Two counters with same `d` tag? Latest one overrides for that user
* Private counters are not displayed publicly, even if published to relays
* If date is in the future and type is `since`, display warning
* Zap metadata must be fetched separately using NIP-05 or kind:0 event
---
## Philosophy
This is a lightweight, open, and empowering way to track progress. It's built on top of Nostr to ensure:
* Decentralized data storage
* No vendor lock-in
* Optional pseudonymity
* Easy integration with the Lightning Network (via zaps)

177
README.md
View File

@@ -1 +1,178 @@
# NostrCount
A decentralized life milestone tracker built on the Nostr protocol. Track your progress, celebrate achievements, and share your journey with the world.
## Features
- **Censorship Resistant**: Built on Nostr for decentralized data storage
- **Lightning Zaps**: Support creators with Bitcoin Lightning Network payments
- **Private or Public**: Choose to keep counters private or share them publicly
- **Two Counter Types**: Track "days since" achievements or "days until" events
- **Real-time Updates**: Automatic syncing across devices through Nostr relays
- **Modern UI**: Beautiful, responsive design with Tailwind CSS
## Getting Started
### Prerequisites
- Node.js (v18 or higher)
- npm or yarn
- A Nostr extension (like Alby, nos2x, or Flamingo) for signing events
### Installation
1. Clone the repository:
```bash
git clone https://github.com/yourusername/nostrcount.git
cd nostrcount
```
2. Install dependencies:
```bash
npm install
```
3. Create environment file:
```bash
cp .env.example .env
```
4. Start the development server:
```bash
npm run dev
```
5. Open your browser to `http://localhost:3000`
## Usage
### Creating a Counter
1. Connect your Nostr extension by clicking "Login"
2. Go to your Dashboard
3. Click "Create Counter"
4. Fill in the details:
- **Title**: What you're tracking (e.g., "Quit Smoking")
- **Date**: The reference date
- **Type**: "Days Since" or "Days Until"
- **Visibility**: Public or Private
### Sharing Counters
Public counters can be shared with a direct link. Each counter has a unique URL that others can view and even zap (tip) if they have Lightning Network setup.
### Zapping
Support others on their journey by sending Lightning Network tips. Click the "Zap" button on any public counter to send sats!
## Architecture
### Tech Stack
- **Frontend**: React 18 + TypeScript + Vite
- **Styling**: Tailwind CSS
- **Nostr Integration**: NDK (Nostr Development Kit)
- **Date Handling**: Day.js
- **Routing**: React Router
- **Icons**: Lucide React
### Nostr Implementation
- **Event Kind**: 30078 (Parameterized Replaceable Events)
- **Event Tags**:
- `d`: Unique identifier/slug
- `type`: "since" or "until"
- `title`: Human-readable counter name
- `date`: ISO date string
- `visibility`: "public" or "private"
### Data Storage
All counter data is stored on Nostr relays as events. No centralized database is required, making the app fully decentralized and censorship-resistant.
## Development
### Project Structure
```
src/
├── components/ # React components
│ ├── CounterCard.tsx # Individual counter display
│ ├── CounterFormModal.tsx # Counter creation/editing
│ ├── Header.tsx # Navigation header
│ ├── LoadingSpinner.tsx # Loading component
│ └── ZapButton.tsx # Lightning zap functionality
├── contexts/ # React contexts
│ └── NDKContext.tsx # Nostr connection management
├── hooks/ # Custom React hooks
│ └── useCounters.ts # Counter data management
├── pages/ # Page components
│ ├── CounterDetail.tsx # Individual counter view
│ ├── Dashboard.tsx # User dashboard
│ └── Home.tsx # Landing page
├── types/ # TypeScript types
│ └── index.ts # Type definitions
├── utils/ # Utility functions
│ ├── date.ts # Date calculation helpers
│ └── nostr.ts # Nostr event helpers
└── App.tsx # Main app component
```
### Available Scripts
- `npm run dev`: Start development server
- `npm run build`: Build for production
- `npm run preview`: Preview production build
- `npm run lint`: Run ESLint
### Adding New Features
1. **New Counter Types**: Modify the `Counter` type in `src/types/index.ts`
2. **New Relays**: Update `DEFAULT_RELAYS` in `src/utils/nostr.ts`
3. **Styling**: Use Tailwind CSS classes or extend the theme in `tailwind.config.js`
## Deployment
### Build for Production
```bash
npm run build
```
The built files will be in the `dist` directory, ready for deployment to any static hosting service.
### Recommended Hosting
- **Vercel**: Zero-config deployments with automatic HTTPS
- **Netlify**: Simple drag-and-drop deployments
- **GitHub Pages**: Free hosting for open source projects
- **IPFS**: Decentralized hosting matching the decentralized nature of Nostr
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/amazing-feature`
3. Commit your changes: `git commit -m 'Add amazing feature'`
4. Push to the branch: `git push origin feature/amazing-feature`
5. Open a Pull Request
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
- **Issues**: Report bugs or request features on GitHub
- **Discussions**: Join the community discussion
- **Zaps**: Support the project with Lightning Network tips
## Acknowledgments
- [Nostr Protocol](https://nostr.com) for the decentralized foundation
- [NDK](https://github.com/nostr-dev-kit/ndk) for the excellent Nostr development kit
- [Tailwind CSS](https://tailwindcss.com) for the amazing styling framework
- All the Nostr relay operators keeping the network running
---
**Built with ⚡ by the Nostr community**

121
architecture.md Normal file
View File

@@ -0,0 +1,121 @@
# Architecture.md
## Overview
"Nostr Count" is a fully client-side web app that allows users to track and share life counters using the Nostr protocol. Events are stored as `kind: 30078` and fetched using Nostr relays. It includes two counter types:
* Days **since** a past date (e.g., quit smoking)
* Days **until** a future event (e.g., vacation)
The app does **not require a backend**, but can optionally use one for caching, trending counters, and zap analytics.
---
## Tech Stack
### Frontend
* **React 18 + TypeScript** UI and component structure
* **Tailwind CSS** Styling
* **NDK (Nostr Dev Kit)** Nostr integration
* **Day.js** Date calculation and formatting
* **Vite** Build tool (or Next.js if SSR required)
### Nostr Relays
* Default relays (configurable via `.env` or app settings):
* `wss://relay.azzamo.net`
* `wss://relay.damus.io`
* `wss://nostr.oxtr.dev`
* Events published and read via NDK
### Optional Backend (Future Option)
* Node.js/Express or FastAPI
* Purpose:
* Cache counters per pubkey
* Track zap totals per counter
* Optional user API for bookmarks/favorites
---
## Event Structure (kind: 30078)
```json
{
"kind": 30078,
"content": "", // Not used for now
"tags": [
["d", "quit-smoking"],
["type", "since"],
["title", "Quit smoking"],
["date", "2023-09-01"],
["visibility", "public"]
],
"created_at": 1690000000,
"pubkey": "<user-pubkey>"
}
```
### Required Tags
* `d`: unique ID or slug (e.g. kebab-case of title)
* `type`: `since` or `until`
* `title`: human-readable name of the counter
* `date`: ISO date string (YYYY-MM-DD)
* `visibility`: `public` or `private`
---
## App Pages / Routes
### `/`
* Landing page
* Featured public counters
* CTA: "Create a Counter"
### `/dashboard`
* Login required
* List of users counters (from pubkey)
* Create new counter button
### `/counter/:slug`
* View a public counter
* Show days since/until, zap button, author
---
## Components
### `CounterCard`
* Displays a single counter (days diff + title)
* Button to share or open
### `CounterFormModal`
* Modal for creating/editing a counter
* Inputs: title, date, type, visibility
### `ZapButton`
* Renders LN zap request if author has Lightning address in NIP-05/metadata
### `NDKProvider`
* Provides global access to NDK instance
* Handles login, signer, and event fetch/publish
---
## Future Features (Not MVP)
* Comments (reply to counter event)
* Counter streaks
* Private reminders
* iCal export or notification system

163
counter.md Normal file
View File

@@ -0,0 +1,163 @@
## Purpose
This guide explains how to create and publish "Days Since / Until" counter events on Nostr using NDK (Nostr Development Kit).
Counters are stored as `kind: 30078` events with metadata tags for rendering.
---
## Prerequisites
* NDK installed
* Signer available (via NIP-07, NIP-46, or npub/nsec input)
* Relay pool configured and connected
---
## Example Setup
```ts
import NDK, { NDKEvent, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
const ndk = new NDK({
explicitRelayUrls: [
"wss://relay.azzamo.net",
"wss://relay.damus.io"
]
});
await ndk.connect();
// Login with nsec
const signer = new NDKPrivateKeySigner("nsec1...");
ndk.signer = signer;
await ndk.signer.user();
```
You can also support login via npub (read-only) or NIP-07 browser extension.
---
## Create Counter Function
```ts
async function publishCounter({
title,
date,
type = "since", // or "until"
visibility = "public", // or "private"
}: {
title: string;
date: string; // format YYYY-MM-DD
type?: "since" | "until";
visibility?: "public" | "private";
}) {
const event = new NDKEvent(ndk);
event.kind = 30078;
event.tags = [
["type", type],
["title", title],
["date", date],
["visibility", visibility],
];
event.content = "";
await event.sign();
await event.publish();
return event;
}
```
---
## Public Counter Example
```ts
const ev = await publishCounter({
title: "Quit smoking",
date: "2024-12-01",
type: "since",
visibility: "public",
});
console.log("View at /counter/" + ev.id);
```
### Published JSON Output:
```json
{
"kind": 30078,
"content": "",
"tags": [
["type", "since"],
["title", "Quit smoking"],
["date", "2024-12-01"],
["visibility", "public"]
],
"created_at": 1725000000,
"id": "note1...",
"pubkey": "npub1..."
}
```
### Slug / Permalink
Use the full event ID as the URL slug:
```
/counter/note1xyz... ← based on event.id (NIP-19 encoded)
```
---
## Private Counter Example
```ts
await publishCounter({
title: "Last relapse",
date: "2025-07-01",
type: "since",
visibility: "private",
});
```
> Note: Private counters are still published to relays but can be filtered out in the app logic.
---
## Reading Events
To fetch all public counters:
```ts
const events = await ndk.fetchEvents({
kinds: [30078]
});
const publicEvents = Array.from(events).filter(e =>
e.tags.find(([k, v]) => k === "visibility" && v === "public")
);
```
To fetch your own counters:
```ts
const user = await ndk.signer?.user();
const events = await ndk.fetchEvents({
kinds: [30078],
authors: [user?.pubkey || ""]
});
```
---
## Notes
* The URL slug for counters is the **event ID** (NIP-19 encoded if needed)
* Event `id` is used to lookup and display the counter
* Lightning zaps should be handled via metadata (fetch kind:0 or NIP-05 info)
* Updating a counter means publishing a new event with new content
---
Let me know if you want to include zaps, NIP-75 fundraising goals, or update/delete flows.

1
dist/assets/index-0ed31904.css vendored Normal file

File diff suppressed because one or more lines are too long

189
dist/assets/index-91eab0ce.js vendored Normal file

File diff suppressed because one or more lines are too long

81
dist/assets/ndk-40656944.js vendored Normal file

File diff suppressed because one or more lines are too long

59
dist/assets/vendor-beb84f6c.js vendored Normal file

File diff suppressed because one or more lines are too long

20
dist/index.html vendored Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NostrCount - Track Life Milestones</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-91eab0ce.js"></script>
<link rel="modulepreload" crossorigin href="/assets/ndk-40656944.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-beb84f6c.js">
<link rel="stylesheet" href="/assets/index-0ed31904.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

6
dist/vite.svg vendored Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v18h18"/>
<path d="M18 17V9"/>
<path d="M13 17V5"/>
<path d="M8 17v-3"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

7
env.example Normal file
View File

@@ -0,0 +1,7 @@
# Nostr Relay Configuration
VITE_DEFAULT_RELAYS=wss://relay.azzamo.net,wss://relay.damus.io,wss://nostr.oxtr.dev,wss://nos.lol,wss://relay.snort.social
# App Configuration
VITE_APP_NAME=NostrCount
VITE_APP_DESCRIPTION=Track life milestones on Nostr
VITE_APP_URL=https://nostrcount.com

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NostrCount - Track Life Milestones</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4158
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "nostrcount",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@nostr-dev-kit/ndk": "^2.14.32",
"@nostr-dev-kit/ndk-cache-dexie": "^2.6.33",
"dayjs": "^1.11.10",
"lucide-react": "^0.292.0",
"nostr-tools": "^2.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-router-dom": "^6.19.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.1.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^4.5.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

6
public/vite.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v18h18"/>
<path d="M18 17V9"/>
<path d="M13 17V5"/>
<path d="M8 17v-3"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

44
src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { NDKProvider } from './contexts/NDKContext';
import { Header } from './components/Header';
import { Home } from './pages/Home';
import { Dashboard } from './pages/Dashboard';
import { CounterDetail } from './pages/CounterDetail';
import { BrowseCounters } from './pages/BrowseCounters';
import { Test } from './pages/Test';
import './index.css';
function App() {
return (
<NDKProvider>
<Router>
<div className="min-h-screen bg-gray-50">
<Header />
<main>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/counter/:slug" element={<CounterDetail />} />
<Route path="/browse" element={<BrowseCounters />} />
<Route path="/test" element={<Test />} />
</Routes>
</main>
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
},
}}
/>
</div>
</Router>
</NDKProvider>
);
}
export default App;

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Calendar, Clock, Eye, EyeOff, Share2, Trash2, Edit, ExternalLink } from 'lucide-react';
import type { Counter } from '../types';
import { calculateDaysDiff, formatDate } from '../utils/date';
import { useNDK } from '../contexts/NDKContext';
interface CounterCardProps {
counter: Counter;
onDelete?: (slug: string) => void;
onEdit?: (counter: Counter) => void;
onShare?: (counter: Counter) => void;
showActions?: boolean;
}
export const CounterCard: React.FC<CounterCardProps> = ({
counter,
onDelete,
onEdit,
onShare,
showActions = false,
}) => {
const { user } = useNDK();
const isOwner = user?.pubkey === counter.pubkey;
const daysDiff = calculateDaysDiff(counter.date, counter.type);
const isOverdue = counter.type === 'until' && daysDiff < 0;
const getDayText = () => {
if (counter.type === 'since') {
return daysDiff === 0 ? 'Today' : `${daysDiff} days`;
} else {
if (daysDiff === 0) return 'Today';
if (daysDiff > 0) return `${daysDiff} days`;
return `${Math.abs(daysDiff)} days ago`;
}
};
const getTypeText = () => {
if (counter.type === 'since') {
return 'since';
} else {
return isOverdue ? 'since' : 'until';
}
};
return (
<div className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 border border-gray-200">
<div className="p-6">
<div className="flex items-start justify-between mb-4">
<Link
to={`/counter/${counter.slug}`}
className="text-lg font-semibold text-gray-900 leading-tight hover:text-blue-600 transition-colors"
>
{counter.title}
</Link>
<div className="flex items-center gap-2">
{counter.visibility === 'private' ? (
<EyeOff className="w-4 h-4 text-gray-400" />
) : (
<Eye className="w-4 h-4 text-gray-400" />
)}
{showActions && (
<div className="flex items-center gap-1">
<Link
to={`/counter/${counter.slug}`}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
title="View counter"
>
<ExternalLink className="w-4 h-4" />
</Link>
{onShare && (
<button
onClick={() => onShare(counter)}
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
title="Share counter"
>
<Share2 className="w-4 h-4" />
</button>
)}
{isOwner && onEdit && (
<button
onClick={() => onEdit(counter)}
className="p-1 text-gray-400 hover:text-green-600 transition-colors"
title="Edit counter"
>
<Edit className="w-4 h-4" />
</button>
)}
{isOwner && onDelete && (
<button
onClick={() => onDelete(counter.slug)}
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
title="Delete counter"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
</div>
<div className="text-center py-4">
<div className={`text-4xl font-bold mb-2 ${
isOverdue ? 'text-red-600' : 'text-blue-600'
}`}>
{getDayText()}
</div>
<div className="text-gray-600 text-sm">
{getTypeText()} {formatDate(counter.date)}
</div>
</div>
<div className="flex items-center justify-between text-sm text-gray-500 pt-4 border-t border-gray-100">
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
<span>{formatDate(counter.date)}</span>
</div>
<div className="flex items-center gap-1">
<Clock className="w-4 h-4" />
<span>Created {new Date(counter.createdAt * 1000).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,226 @@
import React, { useState, useEffect } from 'react';
import { X, Calendar, Eye, EyeOff } from 'lucide-react';
import type { CounterFormData } from '../types';
import { getTodayISOString, isDateValid } from '../utils/date';
import { deriveCounterType } from '../utils/nostr';
interface CounterFormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: CounterFormData) => Promise<void>;
initialData?: CounterFormData;
isLoading?: boolean;
}
export const CounterFormModal: React.FC<CounterFormModalProps> = ({
isOpen,
onClose,
onSubmit,
initialData,
isLoading = false,
}) => {
const [formData, setFormData] = useState<CounterFormData>({
title: '',
date: getTodayISOString(),
type: 'since',
visibility: 'private',
});
const [errors, setErrors] = useState<Partial<CounterFormData>>({});
useEffect(() => {
if (initialData) {
setFormData(initialData);
} else {
setFormData({
title: '',
date: getTodayISOString(),
type: 'since',
visibility: 'private',
});
}
setErrors({});
}, [initialData, isOpen]);
// Auto-derive type from date
const derivedType = deriveCounterType(formData.date);
const typeDescription = derivedType === 'since' ? 'Days Since' : 'Days Until';
const validateForm = (): boolean => {
const newErrors: Partial<CounterFormData> = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
} else if (formData.title.length > 100) {
newErrors.title = 'Title must be 100 characters or less';
}
if (!formData.date) {
newErrors.date = 'Date is required';
} else if (!isDateValid(formData.date)) {
newErrors.date = 'Invalid date format';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
// Use the derived type instead of the form type
const submitData = {
...formData,
type: derivedType,
};
await onSubmit(submitData);
onClose();
} catch (error) {
console.error('Error submitting form:', error);
}
};
const handleInputChange = (
field: keyof CounterFormData,
value: string
) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error for this field
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
{initialData ? 'Edit Counter' : 'Create Counter'}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Title
</label>
<input
type="text"
value={formData.title}
onChange={(e) => handleInputChange('title', e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.title ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="e.g., Quit smoking"
disabled={isLoading}
/>
{errors.title && (
<p className="mt-1 text-sm text-red-600">{errors.title}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date
</label>
<div className="relative">
<input
type="date"
value={formData.date}
onChange={(e) => handleInputChange('date', e.target.value)}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.date ? 'border-red-500' : 'border-gray-300'
}`}
disabled={isLoading}
/>
<Calendar className="absolute right-3 top-2.5 w-5 h-5 text-gray-400 pointer-events-none" />
</div>
{errors.date && (
<p className="mt-1 text-sm text-red-600">{errors.date}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Counter Type
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-300 rounded-md">
<p className="text-sm text-gray-700">
<span className="font-medium">{typeDescription}</span>
<span className="text-gray-500 ml-2">
(auto-detected from date)
</span>
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Visibility
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleInputChange('visibility', 'private')}
className={`px-4 py-2 rounded-md border font-medium transition-colors flex items-center justify-center gap-2 ${
formData.visibility === 'private'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
disabled={isLoading}
>
<EyeOff className="w-4 h-4" />
Private
</button>
<button
type="button"
onClick={() => handleInputChange('visibility', 'public')}
className={`px-4 py-2 rounded-md border font-medium transition-colors flex items-center justify-center gap-2 ${
formData.visibility === 'public'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
disabled={isLoading}
>
<Eye className="w-4 h-4" />
Public
</button>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors font-medium"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? 'Saving...' : initialData ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,212 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Loader2 } from 'lucide-react';
import { useNDK } from '../contexts/NDKContext';
import type { Counter, CounterFormData } from '../types';
import toast from 'react-hot-toast';
interface EditCounterModalProps {
isOpen: boolean;
onClose: () => void;
counter: Counter;
onUpdate: (updatedCounter: Counter) => void;
}
export const EditCounterModal: React.FC<EditCounterModalProps> = ({
isOpen,
onClose,
counter,
onUpdate,
}) => {
const { publishEvent } = useNDK();
const [formData, setFormData] = useState<CounterFormData>({
title: '',
date: '',
type: 'since',
visibility: 'public',
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form data when modal opens
useEffect(() => {
if (isOpen && counter) {
setFormData({
title: counter.title,
date: counter.date,
type: counter.type,
visibility: counter.visibility,
});
}
}, [isOpen, counter]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title.trim()) {
toast.error('Title is required');
return;
}
if (!formData.date) {
toast.error('Date is required');
return;
}
try {
setIsSubmitting(true);
// Create updated event data
const eventData = {
kind: 30078,
content: '',
tags: [
['type', formData.type],
['title', formData.title],
['date', formData.date],
['visibility', formData.visibility],
],
created_at: Math.floor(Date.now() / 1000),
};
console.log('Updating counter with data:', eventData);
// Publish the updated event
const event = await publishEvent(eventData);
// Convert to Counter object
const { eventToCounter } = await import('../utils/nostr');
const updatedCounter = eventToCounter(event);
if (updatedCounter) {
onUpdate(updatedCounter);
toast.success('Counter updated successfully!');
onClose();
} else {
throw new Error('Failed to parse updated counter');
}
} catch (error) {
console.error('Error updating counter:', error);
toast.error('Failed to update counter. Please try again.');
} finally {
setIsSubmitting(false);
}
};
const handleClose = () => {
if (!isSubmitting) {
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Edit Counter
</h3>
<button
onClick={handleClose}
disabled={isSubmitting}
className="text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Title
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter counter title"
disabled={isSubmitting}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date
</label>
<input
type="date"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSubmitting}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Type
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'since' | 'until' })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSubmitting}
>
<option value="since">Days Since</option>
<option value="until">Days Until</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Visibility
</label>
<select
value={formData.visibility}
onChange={(e) => setFormData({ ...formData, visibility: e.target.value as 'public' | 'private' })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSubmitting}
>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={handleClose}
disabled={isSubmitting}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg font-medium hover:bg-gray-200 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !formData.title.trim() || !formData.date}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Updating...
</>
) : (
<>
<Save className="w-4 h-4" />
Update Counter
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};

119
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { User, LogOut, Home, BarChart3, Eye } from 'lucide-react';
import { useNDK } from '../contexts/NDKContext';
import { LoginModal } from './LoginModal';
export const Header: React.FC = () => {
const { userProfile, isConnected, logout } = useNDK();
const location = useLocation();
const [showLoginModal, setShowLoginModal] = useState(false);
const isActive = (path: string) => location.pathname === path;
const handleAuth = () => {
if (isConnected) {
logout();
} else {
setShowLoginModal(true);
}
};
return (
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center gap-8">
<Link to="/" className="flex items-center gap-2">
<BarChart3 className="w-8 h-8 text-blue-600" />
<span className="text-xl font-bold text-gray-900">NostrCount</span>
</Link>
<nav className="hidden md:flex items-center gap-6">
<Link
to="/"
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/')
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Home className="w-4 h-4" />
Home
</Link>
<Link
to="/browse"
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/browse')
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<Eye className="w-4 h-4" />
Browse
</Link>
{isConnected && (
<Link
to="/dashboard"
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/dashboard')
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
<BarChart3 className="w-4 h-4" />
Dashboard
</Link>
)}
</nav>
</div>
<div className="flex items-center gap-4">
{isConnected && userProfile && (
<div className="flex items-center gap-2">
{userProfile.picture && (
<img
src={userProfile.picture}
alt={userProfile.name || 'User'}
className="w-8 h-8 rounded-full"
/>
)}
<span className="text-sm font-medium text-gray-700">
{userProfile.display_name || userProfile.name || 'Anonymous'}
</span>
</div>
)}
<button
onClick={handleAuth}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
isConnected
? 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isConnected ? (
<>
<LogOut className="w-4 h-4" />
Logout
</>
) : (
<>
<User className="w-4 h-4" />
Login
</>
)}
</button>
</div>
</div>
</div>
<LoginModal
isOpen={showLoginModal}
onClose={() => setShowLoginModal(false)}
/>
</header>
);
};

View File

@@ -0,0 +1,21 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'md',
className = '',
}) => {
const sizeClasses = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
};
return (
<div className={`animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 ${sizeClasses[size]} ${className}`} />
);
};

View File

@@ -0,0 +1,419 @@
import React, { useState } from 'react';
import { X, Key, Zap, Plus, Copy, ExternalLink } from 'lucide-react';
import { useNDK } from '../contexts/NDKContext';
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
}
type LoginMethod = 'extension' | 'keys' | 'create' | 'nip55';
type CreateStep = 'username' | 'keys';
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose }) => {
const { login, loginWithKeys, loginWithNip55, updateUserProfile } = useNDK();
const [loginMethod, setLoginMethod] = useState<LoginMethod>('extension');
const [createStep, setCreateStep] = useState<CreateStep>('username');
// Keys login
const [keyInput, setKeyInput] = useState('');
const [keyType, setKeyType] = useState<'npub' | 'nsec' | 'unknown'>('unknown');
// Account creation
const [username, setUsername] = useState('');
const [newPrivateKey, setNewPrivateKey] = useState('');
const [newPublicKey, setNewPublicKey] = useState('');
// NIP-55
const [nip55Url, setNip55Url] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const detectKeyType = (input: string): 'npub' | 'nsec' | 'unknown' => {
if (input.startsWith('npub')) return 'npub';
if (input.startsWith('nsec')) return 'nsec';
return 'unknown';
};
const handleKeyInputChange = (value: string) => {
setKeyInput(value);
setKeyType(detectKeyType(value));
};
const handleExtensionLogin = async () => {
try {
setLoading(true);
setError(null);
await login();
onClose();
} catch (err) {
console.error('Extension login failed:', err);
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
const handleKeysLogin = async () => {
if (!keyInput.trim()) {
setError('Please enter a public or private key');
return;
}
try {
setLoading(true);
setError(null);
if (keyType === 'nsec') {
// Private key provided - we can derive the public key
await loginWithKeys('', keyInput.trim());
} else if (keyType === 'npub') {
// Only public key provided - read-only mode
setError('Public key only provides read-only access. Please provide a private key for full access.');
return;
} else {
setError('Please enter a valid npub or nsec key');
return;
}
onClose();
} catch (err) {
console.error('Keys login failed:', err);
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
const handleCreateAccount = async () => {
if (!username.trim()) {
setError('Please enter a username');
return;
}
try {
setLoading(true);
setError(null);
// Generate new key pair
const privateKey = generateSecretKey();
const publicKey = getPublicKey(privateKey);
// Encode to nostr format
const nsec = nip19.nsecEncode(privateKey);
const npub = nip19.npubEncode(publicKey);
setNewPrivateKey(nsec);
setNewPublicKey(npub);
setCreateStep('keys');
} catch (err) {
console.error('Account creation failed:', err);
setError('Failed to create account');
} finally {
setLoading(false);
}
};
const handleUseNewKeys = async () => {
try {
setLoading(true);
setError(null);
// Login with the new keys
await loginWithKeys(newPublicKey, newPrivateKey);
// Update user profile with the username
await updateUserProfile({
name: username,
display_name: username,
});
onClose();
} catch (err) {
console.error('Login with new keys failed:', err);
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
const handleNip55Login = async () => {
if (!nip55Url.trim()) {
setError('Please enter a NIP-55 URL');
return;
}
try {
setLoading(true);
setError(null);
await loginWithNip55(nip55Url);
onClose();
} catch (err) {
console.error('NIP-55 login failed:', err);
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const resetForm = () => {
setKeyInput('');
setKeyType('unknown');
setUsername('');
setNewPrivateKey('');
setNewPublicKey('');
setNip55Url('');
setError(null);
setCreateStep('username');
};
const handleClose = () => {
resetForm();
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
Connect to Nostr
</h2>
<button
onClick={handleClose}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
disabled={loading}
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="p-6">
<div className="flex gap-2 mb-6">
<button
onClick={() => setLoginMethod('extension')}
className={`flex-1 px-3 py-2 rounded-md font-medium transition-colors text-sm ${
loginMethod === 'extension'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<Zap className="w-4 h-4 inline mr-1" />
Extension
</button>
<button
onClick={() => setLoginMethod('keys')}
className={`flex-1 px-3 py-2 rounded-md font-medium transition-colors text-sm ${
loginMethod === 'keys'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<Key className="w-4 h-4 inline mr-1" />
Keys
</button>
<button
onClick={() => setLoginMethod('create')}
className={`flex-1 px-3 py-2 rounded-md font-medium transition-colors text-sm ${
loginMethod === 'create'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<Plus className="w-4 h-4 inline mr-1" />
Create
</button>
<button
onClick={() => setLoginMethod('nip55')}
className={`flex-1 px-3 py-2 rounded-md font-medium transition-colors text-sm ${
loginMethod === 'nip55'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<ExternalLink className="w-4 h-4 inline mr-1" />
NIP-55
</button>
</div>
{loginMethod === 'extension' && (
<div>
<p className="text-gray-600 mb-4">
Connect using your Nostr browser extension (Alby, nos2x, etc.)
</p>
<button
onClick={handleExtensionLogin}
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
>
{loading ? 'Connecting...' : 'Connect Extension'}
</button>
</div>
)}
{loginMethod === 'keys' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Public or Private Key
</label>
<input
type="password"
value={keyInput}
onChange={(e) => handleKeyInputChange(e.target.value)}
placeholder="npub1... or nsec1..."
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
/>
{keyType !== 'unknown' && (
<p className="text-xs text-gray-500 mt-1">
Detected: {keyType === 'npub' ? 'Public Key (Read-only)' : 'Private Key (Full Access)'}
</p>
)}
</div>
<button
onClick={handleKeysLogin}
disabled={loading || keyType === 'unknown'}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
>
{loading ? 'Connecting...' : 'Connect with Keys'}
</button>
</div>
)}
{loginMethod === 'create' && createStep === 'username' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Choose a Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="your-username"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
/>
<p className="text-xs text-gray-500 mt-1">
This will be used to identify your account
</p>
</div>
<button
onClick={handleCreateAccount}
disabled={loading || !username.trim()}
className="w-full px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors font-medium disabled:opacity-50"
>
{loading ? 'Creating...' : 'Create Account'}
</button>
</div>
)}
{loginMethod === 'create' && createStep === 'keys' && (
<div className="space-y-4">
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<h3 className="font-semibold text-yellow-800 mb-2">Save Your Keys!</h3>
<p className="text-sm text-yellow-700 mb-3">
Store these keys safely. You'll need them to access your account.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Private Key (nsec) - Keep Secret!
</label>
<div className="relative">
<input
type="password"
value={newPrivateKey}
readOnly
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50"
/>
<button
onClick={() => copyToClipboard(newPrivateKey)}
className="absolute right-2 top-2 p-1 hover:bg-gray-200 rounded"
>
<Copy className="w-4 h-4 text-gray-500" />
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Public Key (npub)
</label>
<div className="relative">
<input
type="text"
value={newPublicKey}
readOnly
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50"
/>
<button
onClick={() => copyToClipboard(newPublicKey)}
className="absolute right-2 top-2 p-1 hover:bg-gray-200 rounded"
>
<Copy className="w-4 h-4 text-gray-500" />
</button>
</div>
</div>
<button
onClick={handleUseNewKeys}
disabled={loading}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
>
{loading ? 'Connecting...' : 'Use These Keys'}
</button>
</div>
)}
{loginMethod === 'nip55' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
NIP-55 URL
</label>
<input
type="url"
value={nip55Url}
onChange={(e) => setNip55Url(e.target.value)}
placeholder="https://example.com/.well-known/nostr.json"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={loading}
/>
<p className="text-xs text-gray-500 mt-1">
Enter a NIP-55 compatible URL to connect with an external signer
</p>
</div>
<button
onClick={handleNip55Login}
disabled={loading || !nip55Url.trim()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
>
{loading ? 'Connecting...' : 'Connect with NIP-55'}
</button>
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import { X, Send, Loader2 } from 'lucide-react';
import { useNDK } from '../contexts/NDKContext';
import type { Counter } from '../types';
import toast from 'react-hot-toast';
interface NostrShareModalProps {
isOpen: boolean;
onClose: () => void;
counter: Counter;
}
export const NostrShareModal: React.FC<NostrShareModalProps> = ({
isOpen,
onClose,
counter,
}) => {
const { ndk, user } = useNDK();
const [content, setContent] = useState('');
const [isPublishing, setIsPublishing] = useState(false);
// Generate default share text
const defaultShareText = `Check out my ${counter.type === 'since' ? 'progress' : 'countdown'}: ${counter.title}`;
// Initialize content with default text when modal opens
React.useEffect(() => {
if (isOpen) {
setContent(defaultShareText);
}
}, [isOpen, defaultShareText]);
const handlePublish = async () => {
if (!ndk || !user) {
toast.error('Please log in to share on Nostr');
return;
}
if (!content.trim()) {
toast.error('Please enter a message to share');
return;
}
try {
setIsPublishing(true);
// Create NIP-1 text note event
const { NDKEvent } = await import('@nostr-dev-kit/ndk');
const event = new NDKEvent(ndk);
event.kind = 1; // Text note
event.content = content;
// Add counter event as a reference
event.tags = [
['e', counter.id, '', 'mention'], // Reference the counter event
['p', counter.pubkey, '', 'mention'], // Reference the counter author
];
console.log('Publishing Nostr share event:', {
content: event.content,
tags: event.tags,
kind: event.kind,
});
await event.sign();
await event.publish();
toast.success('Shared on Nostr successfully!');
onClose();
} catch (error) {
console.error('Error publishing Nostr share:', error);
toast.error('Failed to share on Nostr. Please try again.');
} finally {
setIsPublishing(false);
}
};
const handleClose = () => {
if (!isPublishing) {
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[80vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">
Share on Nostr
</h3>
<button
onClick={handleClose}
disabled={isPublishing}
className="text-gray-400 hover:text-gray-600 transition-colors disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Your message
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your message..."
className="w-full h-32 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
disabled={isPublishing}
/>
</div>
<div className="text-xs text-gray-500 mb-4">
This will publish a NIP-1 text note that references the counter.
</div>
{/* Counter preview */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<div className="text-sm text-gray-600 mb-2">Sharing counter:</div>
<div className="font-medium text-gray-900">{counter.title}</div>
<div className="text-sm text-gray-600">
{counter.type === 'since' ? 'Days since' : 'Days until'} {new Date(counter.date).toLocaleDateString()}
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button
onClick={handleClose}
disabled={isPublishing}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg font-medium hover:bg-gray-200 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handlePublish}
disabled={isPublishing || !content.trim()}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPublishing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Publishing...
</>
) : (
<>
<Send className="w-4 h-4" />
Share on Nostr
</>
)}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { Zap } from 'lucide-react';
import type { Counter, UserProfile } from '../types';
import { extractLightningAddress } from '../utils/nostr';
interface ZapButtonProps {
counter: Counter;
userProfile: UserProfile | null;
className?: string;
}
export const ZapButton: React.FC<ZapButtonProps> = ({
counter,
userProfile,
className = '',
}) => {
const [isZapping, setIsZapping] = useState(false);
const [zapAmount, setZapAmount] = useState(1000);
const [zapComment, setZapComment] = useState('');
const [showZapForm, setShowZapForm] = useState(false);
const lightningAddress = userProfile ? extractLightningAddress(userProfile) : null;
if (!lightningAddress) {
return null;
}
const handleZap = async () => {
if (!lightningAddress) return;
try {
setIsZapping(true);
// Create a simple Lightning payment URL
const lightningUrl = `lightning:${lightningAddress}?amount=${zapAmount}&comment=${encodeURIComponent(zapComment || `Zapped ${counter.title}`)}`;
// Open the Lightning URL
window.open(lightningUrl, '_blank');
setShowZapForm(false);
} catch (error) {
console.error('Error creating zap:', error);
} finally {
setIsZapping(false);
}
};
const zapAmounts = [100, 500, 1000, 5000, 10000];
if (showZapForm) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
Zap {counter.title}
</h3>
<button
onClick={() => setShowZapForm(false)}
className="text-gray-400 hover:text-gray-600"
>
×
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Amount (sats)
</label>
<div className="grid grid-cols-3 gap-2 mb-2">
{zapAmounts.map((amount) => (
<button
key={amount}
onClick={() => setZapAmount(amount)}
className={`px-3 py-2 rounded-md border font-medium transition-colors ${
zapAmount === amount
? 'bg-yellow-500 text-white border-yellow-500'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{amount}
</button>
))}
</div>
<input
type="number"
value={zapAmount}
onChange={(e) => setZapAmount(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500"
placeholder="Custom amount"
min="1"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Comment (optional)
</label>
<textarea
value={zapComment}
onChange={(e) => setZapComment(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500"
rows={3}
placeholder="Add a comment..."
/>
</div>
<div className="flex gap-3">
<button
onClick={() => setShowZapForm(false)}
className="flex-1 px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors font-medium"
>
Cancel
</button>
<button
onClick={handleZap}
disabled={isZapping}
className="flex-1 px-4 py-2 bg-yellow-500 text-white rounded-md hover:bg-yellow-600 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
<Zap className="w-4 h-4" />
{isZapping ? 'Creating...' : 'Zap'}
</button>
</div>
</div>
</div>
</div>
);
}
return (
<button
onClick={() => setShowZapForm(true)}
className={`inline-flex items-center gap-2 px-4 py-2 bg-yellow-500 text-white rounded-md hover:bg-yellow-600 transition-colors font-medium ${className}`}
>
<Zap className="w-4 h-4" />
Zap
</button>
);
};

344
src/contexts/NDKContext.tsx Normal file
View File

@@ -0,0 +1,344 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import NDK, { NDKEvent, NDKUser, NDKSigner, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
import { getPublicKey, nip19 } from 'nostr-tools';
import { DEFAULT_RELAYS } from '../utils/nostr';
import type { UserProfile } from '../types';
interface NDKContextType {
ndk: NDK | null;
user: NDKUser | null;
userProfile: UserProfile | null;
isConnected: boolean;
isLoading: boolean;
login: () => Promise<void>;
loginWithKeys: (npub: string, nsec: string) => Promise<void>;
loginWithNip55: (url: string) => Promise<void>;
logout: () => void;
publishEvent: (event: Partial<NDKEvent>) => Promise<NDKEvent>;
fetchUserProfile: (pubkey: string) => Promise<UserProfile | null>;
updateUserProfile: (profile: Partial<UserProfile>) => Promise<void>;
}
const NDKContext = createContext<NDKContextType | null>(null);
export const useNDK = () => {
const context = useContext(NDKContext);
if (!context) {
throw new Error('useNDK must be used within NDKProvider');
}
return context;
};
interface NDKProviderProps {
children: React.ReactNode;
}
export const NDKProvider: React.FC<NDKProviderProps> = ({ children }) => {
const [ndk, setNdk] = useState<NDK | null>(null);
const [user, setUser] = useState<NDKUser | null>(null);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const initNDK = async () => {
try {
console.log('Initializing NDK with Dexie cache...');
// Initialize Dexie cache adapter
const dexieAdapter = new NDKCacheAdapterDexie({
dbName: 'nostrcount-cache'
});
const ndkInstance = new NDK({
explicitRelayUrls: DEFAULT_RELAYS,
cacheAdapter: dexieAdapter,
});
console.log('Connecting to relays...');
await ndkInstance.connect();
console.log('NDK connected successfully with cache');
setNdk(ndkInstance);
setIsLoading(false);
} catch (error) {
console.error('Failed to initialize NDK:', error);
setIsLoading(false);
}
};
initNDK();
}, []);
const login = async () => {
if (!ndk) return;
try {
setIsLoading(true);
console.log('Attempting login with NIP-07 extension...');
// Check for NIP-07 extension
if (window.nostr) {
console.log('Nostr extension found, getting public key...');
const pubkey = await window.nostr.getPublicKey();
console.log('Got public key:', pubkey);
const ndkUser = ndk.getUser({ pubkey });
// Set signer
ndk.signer = {
user: async () => ndkUser,
sign: async (event: NDKEvent) => {
console.log('Signing event with NIP-07 extension...');
if (!window.nostr) throw new Error('Nostr extension not available');
// Get the raw event data for signing
const rawEvent = {
kind: event.kind,
created_at: event.created_at || Math.floor(Date.now() / 1000),
tags: event.tags,
content: event.content,
pubkey: event.pubkey,
};
console.log('Raw event to sign:', rawEvent);
const signedEvent = await window.nostr.signEvent(rawEvent as any);
console.log('Event signed successfully:', signedEvent);
event.sig = signedEvent.sig;
return event.sig;
},
blockUntilReady: async () => true,
} as unknown as NDKSigner;
setUser(ndkUser);
setIsConnected(true);
console.log('Login successful with NIP-07');
// Fetch user profile
const profile = await fetchUserProfile(pubkey);
setUserProfile(profile);
} else {
throw new Error('No Nostr extension found');
}
} catch (error) {
console.error('Login failed:', error);
throw error;
} finally {
setIsLoading(false);
}
};
const loginWithKeys = async (npub: string, nsec: string) => {
if (!ndk) return;
try {
setIsLoading(true);
console.log('Attempting login with keys...');
let pubkey: string;
let privateKey: Uint8Array;
// If only nsec is provided, derive the public key
if (nsec && !npub) {
try {
const nsecDecoded = nip19.decode(nsec);
const privateKeyBytes = nsecDecoded.data as Uint8Array;
pubkey = getPublicKey(privateKeyBytes);
privateKey = privateKeyBytes;
console.log('Derived public key from private key:', pubkey);
} catch (error) {
throw new Error('Invalid nsec format');
}
} else if (npub && nsec) {
// Both provided - verify they match
try {
const npubDecoded = nip19.decode(npub);
pubkey = npubDecoded.data as string;
} catch (error) {
throw new Error('Invalid npub format');
}
try {
const nsecDecoded = nip19.decode(nsec);
privateKey = nsecDecoded.data as Uint8Array;
} catch (error) {
throw new Error('Invalid nsec format');
}
// Verify the keys match
const derivedPubkey = getPublicKey(privateKey);
if (derivedPubkey !== pubkey) {
throw new Error('Public key does not match private key');
}
} else {
throw new Error('Please provide a valid private key');
}
console.log('Keys verified successfully');
const ndkUser = ndk.getUser({ pubkey });
// Set signer using NDKPrivateKeySigner
// Convert Uint8Array to hex string for NDKPrivateKeySigner
const privateKeyHex = Array.from(privateKey).map(b => b.toString(16).padStart(2, '0')).join('');
const signer = new NDKPrivateKeySigner(privateKeyHex);
ndk.signer = signer;
console.log('Signer set successfully');
setUser(ndkUser);
setIsConnected(true);
console.log('Login successful with keys');
// Fetch user profile
const profile = await fetchUserProfile(pubkey);
setUserProfile(profile);
} catch (error) {
console.error('Keys login failed:', error);
throw error;
} finally {
setIsLoading(false);
}
};
const loginWithNip55 = async (url: string) => {
if (!ndk) return;
try {
setIsLoading(true);
console.log('Attempting NIP-55 login with URL:', url);
// TODO: Implement NIP-55 login
// This would involve:
// 1. Fetching the NIP-55 JSON from the URL
// 2. Validating the response
// 3. Setting up the signer for external signing
throw new Error('NIP-55 login not yet implemented');
} catch (error) {
console.error('NIP-55 login failed:', error);
throw error;
} finally {
setIsLoading(false);
}
};
const logout = () => {
console.log('Logging out...');
setUser(null);
setUserProfile(null);
setIsConnected(false);
if (ndk) {
ndk.signer = undefined;
}
};
const publishEvent = async (eventData: Partial<NDKEvent>): Promise<NDKEvent> => {
if (!ndk || !user) {
throw new Error('NDK not initialized or user not logged in');
}
try {
console.log('Creating NDK event with data:', eventData);
const event = new NDKEvent(ndk, {
...eventData,
pubkey: user.pubkey,
});
console.log('Signing event...');
await event.sign();
console.log('Event signed successfully');
console.log('Publishing event...');
await event.publish();
console.log('Event published successfully');
return event;
} catch (error) {
console.error('Error in publishEvent:', error);
throw error;
}
};
const fetchUserProfile = async (pubkey: string): Promise<UserProfile | null> => {
if (!ndk) return null;
try {
console.log('Fetching user profile for:', pubkey);
const user = ndk.getUser({ pubkey });
await user.fetchProfile();
if (user.profile) {
console.log('User profile fetched:', user.profile);
return {
pubkey,
name: user.profile.name,
display_name: user.profile.display_name as string | undefined,
about: user.profile.about,
picture: user.profile.picture,
nip05: user.profile.nip05,
lud16: user.profile.lud16 as string | undefined,
lud06: user.profile.lud06 as string | undefined,
};
}
console.log('No profile found for user');
return null;
} catch (error) {
console.error('Error fetching user profile:', error);
return null;
}
};
const updateUserProfile = async (profile: Partial<UserProfile>): Promise<void> => {
if (!ndk || !user) {
throw new Error('NDK not initialized or user not logged in');
}
try {
console.log('Updating user profile:', profile);
// Create kind 0 metadata event
const event = new NDKEvent(ndk);
event.kind = 0; // Metadata event
event.content = JSON.stringify(profile);
console.log('Publishing profile update...');
await event.sign();
await event.publish();
console.log('Profile updated successfully');
// Update local state
const updatedProfile = await fetchUserProfile(user.pubkey);
setUserProfile(updatedProfile);
} catch (error) {
console.error('Error updating user profile:', error);
throw error;
}
};
return (
<NDKContext.Provider
value={{
ndk,
user,
userProfile,
isConnected,
isLoading,
login,
loginWithKeys,
loginWithNip55,
logout,
publishEvent,
fetchUserProfile,
updateUserProfile,
}}
>
{children}
</NDKContext.Provider>
);
};

288
src/hooks/useCounters.ts Normal file
View File

@@ -0,0 +1,288 @@
import { useState, useEffect, useCallback } from 'react';
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useNDK } from '../contexts/NDKContext';
import { eventToCounter, getCounterFilter, deriveCounterType, isValidCounterEvent } from '../utils/nostr';
import type { Counter, CounterFormData } from '../types';
export function useCounters(pubkey?: string, isPublic?: boolean) {
const { ndk } = useNDK();
const [counters, setCounters] = useState<Counter[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchCounters = useCallback(async () => {
console.log('useCounters: fetchCounters called', { pubkey, isPublic, ndk: !!ndk });
if (!ndk) {
console.log('useCounters: NDK not initialized, skipping fetch');
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
// First, fetch deletion events (kind 5) to know which events are deleted
const deletionFilter: NDKFilter = {
kinds: [5],
limit: 100,
};
if (pubkey) {
deletionFilter.authors = [pubkey];
}
console.log('useCounters: Fetching deletion events...');
const deletionEvents = await ndk.fetchEvents(deletionFilter);
console.log('useCounters: Fetched deletion events:', deletionEvents.size);
// Extract deleted event IDs from kind 5 events
const deletedIds = new Set<string>();
for (const event of deletionEvents) {
for (const tag of event.tags) {
if (tag[0] === 'a' && tag[1]) {
// Extract event ID from 'a' tag (format: "30078:eventId")
const parts = tag[1].split(':');
if (parts.length === 2 && parts[0] === '30078') {
deletedIds.add(parts[1]);
}
}
}
}
console.log('useCounters: Deleted event IDs:', Array.from(deletedIds));
const filter = getCounterFilter(pubkey, isPublic);
console.log('useCounters: Using filter:', filter);
console.log('useCounters: Fetching counter events...');
const events = await ndk.fetchEvents(filter);
console.log('useCounters: Fetched events:', events.size);
const counterList: Counter[] = [];
for (const event of events) {
console.log('useCounters: Processing event:', event.id);
// Skip if this event is marked as deleted
if (deletedIds.has(event.id)) {
console.log('useCounters: Skipping deleted event:', event.id);
continue;
}
// First check if this looks like a valid counter event
// Pass requirePublic=true if we're fetching public counters
if (!isValidCounterEvent(event, isPublic)) {
console.log('useCounters: Skipping invalid counter event:', event.id);
continue;
}
const counter = eventToCounter(event, isPublic);
if (counter) {
console.log('useCounters: Valid counter found:', counter.title);
counterList.push(counter);
} else {
console.log('useCounters: Invalid counter event:', event.id);
}
}
console.log('useCounters: Total valid counters:', counterList.length);
// Sort by creation date (newest first)
counterList.sort((a, b) => b.createdAt - a.createdAt);
setCounters(counterList);
} catch (err) {
console.error('useCounters: Error fetching counters:', err);
setError('Failed to fetch counters');
} finally {
setLoading(false);
}
}, [ndk, pubkey, isPublic]);
useEffect(() => {
console.log('useCounters: useEffect triggered', { pubkey, isPublic, ndk: !!ndk });
fetchCounters();
}, [fetchCounters]);
const refetch = useCallback(() => {
console.log('useCounters: Manual refetch triggered');
fetchCounters();
}, [fetchCounters]);
return {
counters,
loading,
error,
refetch,
};
}
export function useCounter(slug: string) {
const { ndk } = useNDK();
const [counter, setCounter] = useState<Counter | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchCounter = async () => {
if (!ndk || !slug) return;
try {
setLoading(true);
setError(null);
const filter: NDKFilter = {
kinds: [30078],
ids: [slug],
};
const events = await ndk.fetchEvents(filter);
if (events.size === 0) {
setError('Counter not found');
return;
}
// Get the most recent event (in case of duplicates)
const sortedEvents = Array.from(events).sort((a, b) => b.created_at! - a.created_at!);
const latestEvent = sortedEvents[0];
const counterData = eventToCounter(latestEvent);
if (counterData) {
setCounter(counterData);
} else {
setError('Invalid counter data');
}
} catch (err) {
console.error('Error fetching counter:', err);
setError('Failed to fetch counter');
} finally {
setLoading(false);
}
};
fetchCounter();
}, [ndk, slug]);
return {
counter,
loading,
error,
};
}
export function useCreateCounter() {
const { ndk, user } = useNDK();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const createCounter = async (formData: CounterFormData) => {
if (!ndk || !user) {
throw new Error('NDK not initialized or user not logged in');
}
try {
setLoading(true);
setError(null);
console.log('Creating counter with form data:', formData);
// Auto-derive the type from the date
const derivedType = deriveCounterType(formData.date);
console.log('Derived type from date:', derivedType);
// Create event data
const eventData = {
kind: 30078,
content: '',
tags: [
['type', derivedType],
['title', formData.title],
['date', formData.date],
['visibility', formData.visibility],
],
created_at: Math.floor(Date.now() / 1000),
pubkey: user.pubkey,
};
console.log('Event data:', eventData);
// Create NDK event
const event = new NDKEvent(ndk, eventData);
console.log('NDK event created:', event);
console.log('Signing event...');
await event.sign();
console.log('Event signed successfully');
console.log('Publishing event...');
await event.publish();
console.log('Event published successfully');
// Convert to Counter object
const counter = eventToCounter(event);
if (!counter) {
throw new Error('Failed to parse created counter');
}
console.log('Counter created successfully:', counter);
return counter;
} catch (err) {
console.error('Error creating counter:', err);
const errorMessage = err instanceof Error ? err.message : 'Failed to create counter';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
};
return {
createCounter,
loading,
error,
};
}
export function useDeleteCounter() {
const { publishEvent } = useNDK();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const deleteCounter = async (slug: string) => {
try {
setLoading(true);
setError(null);
// Create a deletion event (NIP-09)
const eventData = {
kind: 5,
content: 'Deleted counter',
tags: [
['a', `30078:${slug}`],
],
created_at: Math.floor(Date.now() / 1000),
};
const event = await publishEvent(eventData);
return event;
} catch (err) {
console.error('Error deleting counter:', err);
setError('Failed to delete counter');
throw err;
} finally {
setLoading(false);
}
};
return {
deleteCounter,
loading,
error,
};
}

87
src/index.css Normal file
View File

@@ -0,0 +1,87 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: 'Fira Code', 'Courier New', monospace;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Loading animation */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
/* Responsive text */
@media (max-width: 640px) {
.responsive-text {
font-size: 0.875rem;
}
}
/* Custom button styles */
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors;
}
.btn-secondary {
@apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors;
}
/* Card hover effects */
.card-hover {
@apply transition-all duration-200 hover:shadow-lg hover:-translate-y-1;
}
/* Focus styles */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
}

9
src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,220 @@
import React, { useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Search, Calendar, Eye } from 'lucide-react';
import { CounterCard } from '../components/CounterCard';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { useCounters } from '../hooks/useCounters';
import { calculateDaysDiff } from '../utils/date';
export const BrowseCounters: React.FC = () => {
const { counters, loading, error } = useCounters(undefined, true); // Public counters only
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<'all' | 'since' | 'until'>('all');
// Filter and search counters
const filteredCounters = useMemo(() => {
let filtered = counters;
// Filter by type
if (filterType !== 'all') {
filtered = filtered.filter(counter => counter.type === filterType);
}
// Filter by search term
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
filtered = filtered.filter(counter =>
counter.title.toLowerCase().includes(term) ||
counter.date.includes(term)
);
}
// Sort by days (most recent/upcoming first)
filtered.sort((a, b) => {
const daysA = calculateDaysDiff(a.date, a.type);
const daysB = calculateDaysDiff(b.date, b.type);
return Math.abs(daysB) - Math.abs(daysA);
});
return filtered;
}, [counters, searchTerm, filterType]);
const stats = useMemo(() => {
const total = counters.length;
const since = counters.filter(c => c.type === 'since').length;
const until = counters.filter(c => c.type === 'until').length;
return { total, since, until };
}, [counters]);
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Error Loading Counters</h2>
<p className="text-gray-600 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Try Again
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Browse Public Counters
</h1>
<p className="text-gray-600">
Discover and celebrate milestones from around the Nostr network
</p>
</div>
<div className="mt-4 md:mt-0">
<Link
to="/dashboard"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Calendar className="w-4 h-4" />
Create Counter
</Link>
</div>
</div>
</div>
</div>
{/* Stats */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
<div className="text-sm text-gray-600">Total Counters</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{stats.since}</div>
<div className="text-sm text-gray-600">Days Since</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{stats.until}</div>
<div className="text-sm text-gray-600">Days Until</div>
</div>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search counters by title or date..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Type Filter */}
<div className="flex gap-2">
<button
onClick={() => setFilterType('all')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
filterType === 'all'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
All
</button>
<button
onClick={() => setFilterType('since')}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-1 ${
filterType === 'since'
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<Eye className="w-4 h-4" />
Since
</button>
<button
onClick={() => setFilterType('until')}
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-1 ${
filterType === 'until'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<Calendar className="w-4 h-4" />
Until
</button>
</div>
</div>
</div>
</div>
{/* Counters Grid */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{loading ? (
<div className="flex justify-center items-center py-12">
<LoadingSpinner size="lg" />
</div>
) : filteredCounters.length > 0 ? (
<>
<div className="mb-6">
<p className="text-gray-600">
Showing {filteredCounters.length} of {counters.length} counters
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredCounters.map((counter) => (
<Link
key={counter.id}
to={`/counter/${counter.slug}`}
className="block transform hover:scale-105 transition-transform"
>
<CounterCard counter={counter} />
</Link>
))}
</div>
</>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Search className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{searchTerm || filterType !== 'all' ? 'No counters found' : 'No public counters yet'}
</h3>
<p className="text-gray-600 mb-6">
{searchTerm || filterType !== 'all'
? 'Try adjusting your search terms or filters'
: 'Be the first to create and share a public counter!'}
</p>
{!searchTerm && filterType === 'all' && (
<Link
to="/dashboard"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Calendar className="w-4 h-4" />
Create First Counter
</Link>
)}
</div>
)}
</div>
</div>
);
};

293
src/pages/CounterDetail.tsx Normal file
View File

@@ -0,0 +1,293 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ArrowLeft, Calendar, Clock, User, Copy, MessageCircle, Edit, Trash2 } from 'lucide-react';
import { CounterCard } from '../components/CounterCard';
import { ZapButton } from '../components/ZapButton';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { NostrShareModal } from '../components/NostrShareModal';
import { EditCounterModal } from '../components/EditCounterModal';
import { useCounter } from '../hooks/useCounters';
import { useDeleteCounter } from '../hooks/useCounters';
import { useNDK } from '../contexts/NDKContext';
import type { UserProfile } from '../types';
import toast from 'react-hot-toast';
export const CounterDetail: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { counter, loading, error } = useCounter(slug!);
const { fetchUserProfile, user } = useNDK();
const { deleteCounter } = useDeleteCounter();
const [authorProfile, setAuthorProfile] = useState<UserProfile | null>(null);
const [loadingProfile, setLoadingProfile] = useState(false);
const [showNostrShareModal, setShowNostrShareModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
useEffect(() => {
if (counter && counter.pubkey) {
setLoadingProfile(true);
fetchUserProfile(counter.pubkey)
.then(setAuthorProfile)
.catch(console.error)
.finally(() => setLoadingProfile(false));
}
}, [counter, fetchUserProfile]);
const handleShare = () => {
const url = window.location.href;
navigator.clipboard.writeText(url);
toast.success('Counter link copied to clipboard!');
};
const handleShareNostr = () => {
if (!counter) return;
setShowNostrShareModal(true);
};
const handleEditCounter = () => {
if (!counter) return;
setShowEditModal(true);
};
const handleDeleteCounter = async () => {
if (!counter) return;
if (!window.confirm('Are you sure you want to delete this counter?')) {
return;
}
try {
await deleteCounter(counter.slug);
toast.success('Counter deleted successfully!');
// Redirect to home page after deletion
window.location.href = '/';
} catch (error) {
toast.error('Failed to delete counter. Please try again.');
}
};
const handleUpdateCounter = () => {
// Refresh the page to show updated counter
window.location.reload();
};
const isOwner = user?.pubkey === counter?.pubkey;
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}
if (error || !counter) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Counter not found
</h1>
<p className="text-gray-600 mb-6">
The counter you're looking for doesn't exist or has been deleted.
</p>
<Link
to="/"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Home
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Back Button */}
<Link
to="/"
className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-8 font-medium"
>
<ArrowLeft className="w-4 h-4" />
Back to Home
</Link>
{/* Counter Display */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="p-8">
<div className="max-w-md mx-auto">
<CounterCard counter={counter} />
</div>
</div>
</div>
{/* Author Info */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mt-6 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Counter Details
</h3>
<div className="space-y-4">
<div className="flex items-center gap-3">
<User className="w-5 h-5 text-gray-400" />
<div>
<span className="text-gray-600">Created by:</span>
{loadingProfile ? (
<LoadingSpinner size="sm" className="ml-2" />
) : (
<div className="flex items-center gap-2 mt-1">
{authorProfile?.picture && (
<img
src={authorProfile.picture}
alt={authorProfile.name || 'Author'}
className="w-6 h-6 rounded-full"
/>
)}
<span className="font-medium text-gray-900">
{authorProfile?.display_name ||
authorProfile?.name ||
`${counter.pubkey.slice(0, 8)}...${counter.pubkey.slice(-8)}`}
</span>
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Calendar className="w-5 h-5 text-gray-400" />
<div>
<span className="text-gray-600">Target date:</span>
<div className="font-medium text-gray-900 mt-1">
{new Date(counter.date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<span className="text-gray-600">Created:</span>
<div className="font-medium text-gray-900 mt-1">
{new Date(counter.createdAt * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mt-6 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Actions
</h3>
<div className="flex flex-wrap gap-3">
<button
onClick={handleShare}
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors"
>
<Copy className="w-4 h-4" />
Copy Link
</button>
<button
onClick={handleShareNostr}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded-lg font-medium hover:bg-blue-600 transition-colors"
>
<MessageCircle className="w-4 h-4" />
Share on Nostr
</button>
{isOwner && (
<>
<button
onClick={handleEditCounter}
className="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors"
>
<Edit className="w-4 h-4" />
Edit Counter
</button>
<button
onClick={handleDeleteCounter}
className="inline-flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
<Trash2 className="w-4 h-4" />
Delete Counter
</button>
</>
)}
{authorProfile && (
<ZapButton
counter={counter}
userProfile={authorProfile}
className="flex-shrink-0"
/>
)}
</div>
</div>
{/* Nostr Details */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mt-6 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Nostr Details
</h3>
<div className="space-y-3 text-sm">
<div>
<span className="text-gray-600">Event ID:</span>
<code className="ml-2 px-2 py-1 bg-gray-100 rounded text-xs font-mono">
{counter.id}
</code>
</div>
<div>
<span className="text-gray-600">Author Pubkey:</span>
<code className="ml-2 px-2 py-1 bg-gray-100 rounded text-xs font-mono">
{counter.pubkey}
</code>
</div>
<div>
<span className="text-gray-600">Kind:</span>
<code className="ml-2 px-2 py-1 bg-gray-100 rounded text-xs font-mono">
30078
</code>
</div>
</div>
</div>
</div>
{/* Nostr Share Modal */}
{counter && (
<NostrShareModal
isOpen={showNostrShareModal}
onClose={() => setShowNostrShareModal(false)}
counter={counter}
/>
)}
{/* Edit Counter Modal */}
{counter && (
<EditCounterModal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
counter={counter}
onUpdate={handleUpdateCounter}
/>
)}
</div>
);
};

226
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,226 @@
import React, { useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { CounterCard } from '../components/CounterCard';
import { CounterFormModal } from '../components/CounterFormModal';
import { EditCounterModal } from '../components/EditCounterModal';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { useCounters } from '../hooks/useCounters';
import { useCreateCounter, useDeleteCounter } from '../hooks/useCounters';
import { useNDK } from '../contexts/NDKContext';
import type { Counter, CounterFormData } from '../types';
import toast from 'react-hot-toast';
export const Dashboard: React.FC = () => {
const { user, isConnected } = useNDK();
const { counters, loading, refetch } = useCounters(user?.pubkey);
const { createCounter, loading: creating } = useCreateCounter();
const { deleteCounter } = useDeleteCounter();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingCounter, setEditingCounter] = useState<Counter | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<'all' | 'since' | 'until'>('all');
const [filterVisibility, setFilterVisibility] = useState<'all' | 'public' | 'private'>('all');
if (!isConnected) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Please log in to access your dashboard
</h1>
<p className="text-gray-600">
Connect your Nostr account to create and manage your counters.
</p>
</div>
</div>
);
}
const filteredCounters = counters.filter(counter => {
const matchesSearch = counter.title.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = filterType === 'all' || counter.type === filterType;
const matchesVisibility = filterVisibility === 'all' || counter.visibility === filterVisibility;
return matchesSearch && matchesType && matchesVisibility;
});
const handleCreateCounter = async (formData: CounterFormData) => {
try {
await createCounter(formData);
toast.success('Counter created successfully!');
refetch();
} catch (error) {
toast.error('Failed to create counter. Please try again.');
}
};
const handleDeleteCounter = async (slug: string) => {
if (!window.confirm('Are you sure you want to delete this counter?')) {
return;
}
try {
await deleteCounter(slug);
toast.success('Counter deleted successfully!');
refetch();
} catch (error) {
toast.error('Failed to delete counter. Please try again.');
}
};
const handleShareCounter = (counter: Counter) => {
const url = `${window.location.origin}/counter/${counter.slug}`;
navigator.clipboard.writeText(url);
toast.success('Counter link copied to clipboard!');
};
const handleEditCounter = (counter: Counter) => {
setEditingCounter(counter);
setShowEditModal(true);
};
const handleUpdateCounter = () => {
// The counter list will be refreshed automatically via refetch
refetch();
};
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
My Counters
</h1>
<p className="text-gray-600">
Track your progress and celebrate your milestones
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 md:mt-0 inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
Create Counter
</button>
</div>
{/* Filters and Search */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search counters..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex gap-2">
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Types</option>
<option value="since">Days Since</option>
<option value="until">Days Until</option>
</select>
<select
value={filterVisibility}
onChange={(e) => setFilterVisibility(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Visibility</option>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div>
</div>
</div>
{/* Counters Grid */}
{loading ? (
<div className="flex justify-center items-center py-12">
<LoadingSpinner size="lg" />
</div>
) : filteredCounters.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredCounters.map((counter) => (
<CounterCard
key={counter.id}
counter={counter}
onDelete={handleDeleteCounter}
onEdit={handleEditCounter}
onShare={handleShareCounter}
showActions={true}
/>
))}
</div>
) : (
<div className="text-center py-12">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12">
{counters.length === 0 ? (
<>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No counters yet
</h3>
<p className="text-gray-500 mb-6">
Create your first counter to start tracking your progress
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
Create Your First Counter
</button>
</>
) : (
<>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No counters match your filters
</h3>
<p className="text-gray-500">
Try adjusting your search or filter settings
</p>
</>
)}
</div>
</div>
)}
{/* Create Counter Modal */}
<CounterFormModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateCounter}
isLoading={creating}
/>
{/* Edit Counter Modal */}
{editingCounter && (
<EditCounterModal
isOpen={showEditModal}
onClose={() => {
setShowEditModal(false);
setEditingCounter(null);
}}
counter={editingCounter}
onUpdate={handleUpdateCounter}
/>
)}
</div>
</div>
);
};

242
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,242 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Plus, Calendar, Eye, Zap, Shield, Users, ArrowRight, RefreshCw } from 'lucide-react';
import { CounterCard } from '../components/CounterCard';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { useCounters } from '../hooks/useCounters';
import { useNDK } from '../contexts/NDKContext';
import { calculateDaysDiff } from '../utils/date';
export const Home: React.FC = () => {
const { isConnected, ndk } = useNDK();
const { counters, loading, error, refetch } = useCounters(undefined, true); // Public counters only
console.log('Home: Render state', {
isConnected,
ndkConnected: !!ndk,
countersCount: counters.length,
loading,
error
});
// Sort counters by days (most recent/upcoming first) and take the first 6
const featuredCounters = React.useMemo(() => {
const sorted = [...counters].sort((a, b) => {
const daysA = calculateDaysDiff(a.date, a.type);
const daysB = calculateDaysDiff(b.date, b.type);
return Math.abs(daysB) - Math.abs(daysA);
});
return sorted.slice(0, 6);
}, [counters]);
return (
<div className="min-h-screen bg-gray-50">
{/* Hero Section */}
<div className="bg-gradient-to-br from-blue-600 to-purple-700 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div className="text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6">
Track Your Life Milestones
</h1>
<p className="text-xl md:text-2xl mb-8 text-blue-100 max-w-3xl mx-auto">
Count the days since your last achievement or until your next big event.
Built on Nostr.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
{isConnected ? (
<Link
to="/dashboard"
className="inline-flex items-center gap-2 px-8 py-3 bg-white text-blue-600 rounded-lg font-semibold hover:bg-blue-50 transition-colors"
>
<Plus className="w-5 h-5" />
Create Your First Counter
</Link>
) : (
<button
onClick={() => window.scrollTo({ top: 800, behavior: 'smooth' })}
className="inline-flex items-center gap-2 px-8 py-3 bg-white text-blue-600 rounded-lg font-semibold hover:bg-blue-50 transition-colors"
>
<Calendar className="w-5 h-5" />
Get Started
</button>
)}
<Link
to="/browse"
className="inline-flex items-center gap-2 px-8 py-3 border-2 border-white text-white rounded-lg font-semibold hover:bg-white hover:text-blue-600 transition-colors"
>
<Eye className="w-5 h-5" />
Browse Public Counters
</Link>
</div>
</div>
</div>
</div>
{/* Features Section */}
<div className="py-20 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
Why NostrCount?
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Experience the future of personal tracking with decentralized technology
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-blue-600" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Censorship Resistant
</h3>
<p className="text-gray-600">
Your data is stored on the Nostr network, ensuring no single entity can control or delete your progress.
</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-purple-600" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Social & Shareable
</h3>
<p className="text-gray-600">
Share your milestones with friends and family. Get support from the community on your journey.
</p>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-yellow-600" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
Lightning Zaps
</h3>
<p className="text-gray-600">
Support others on their journey with Lightning Network tips. Every sat counts!
</p>
</div>
</div>
</div>
</div>
{/* Featured Counters Section */}
<div className="py-20 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-12">
<div className="text-center md:text-left">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
Featured Counters
</h2>
<p className="text-xl text-gray-600">
See what others are tracking and celebrating
</p>
</div>
<div className="mt-4 md:mt-0 flex gap-2">
<button
onClick={refetch}
disabled={loading}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 inline mr-1 ${loading ? 'animate-spin' : ''}`} />
{loading ? 'Loading...' : 'Refetch'}
</button>
<Link
to="/browse"
className="inline-flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
View All Counters
<ArrowRight className="w-4 h-4" />
</Link>
</div>
</div>
{loading ? (
<div className="flex justify-center items-center py-12">
<LoadingSpinner size="lg" />
</div>
) : error ? (
<div className="text-center py-12">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<RefreshCw className="w-8 h-8 text-red-600" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Error Loading Counters
</h3>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={refetch}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
</div>
) : featuredCounters.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{featuredCounters.map((counter) => (
<Link
key={counter.id}
to={`/counter/${counter.slug}`}
className="block transform hover:scale-105 transition-transform"
>
<CounterCard counter={counter} />
</Link>
))}
</div>
) : (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
No public counters yet
</h3>
<p className="text-gray-600 mb-6">
Be the first to create and share a public counter!
</p>
{isConnected && (
<Link
to="/dashboard"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
Create First Counter
</Link>
)}
</div>
)}
</div>
</div>
{/* CTA Section */}
<div className="bg-blue-600 text-white py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl md:text-4xl font-bold mb-4">
Ready to Start Tracking?
</h2>
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
Join the decentralized movement. Track your progress, share your journey,
and celebrate your milestones with the world.
</p>
{isConnected ? (
<Link
to="/dashboard"
className="inline-flex items-center gap-2 px-8 py-3 bg-white text-blue-600 rounded-lg font-semibold hover:bg-blue-50 transition-colors"
>
<Plus className="w-5 h-5" />
Create Your First Counter
</Link>
) : (
<p className="text-blue-100 mb-4">
Connect your Nostr account to get started
</p>
)}
</div>
</div>
</div>
);
};

270
src/pages/Test.tsx Normal file
View File

@@ -0,0 +1,270 @@
import React, { useState } from 'react';
import { useNDK } from '../contexts/NDKContext';
import { useCreateCounter } from '../hooks/useCounters';
import { deriveCounterType } from '../utils/nostr';
import type { CounterFormData } from '../types';
import { NDKEvent } from '@nostr-dev-kit/ndk';
export const Test: React.FC = () => {
const { isConnected, login, ndk, user } = useNDK();
const { createCounter, loading, error } = useCreateCounter();
const [testResult, setTestResult] = useState<string>('');
const handleTestCounter = async () => {
try {
setTestResult('Testing counter creation...');
const testData: CounterFormData = {
title: 'Test Counter',
date: '2024-01-01',
type: 'since', // This will be overridden by auto-derivation
visibility: 'public',
};
console.log('Creating test counter with data:', testData);
const event = await createCounter(testData);
console.log('Test counter created:', event);
setTestResult(`✅ Counter created successfully! Event ID: ${event.id}`);
} catch (err) {
console.error('Test failed:', err);
setTestResult(`❌ Test failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleTestConnection = async () => {
try {
setTestResult('Testing NDK connection...');
if (!ndk) {
setTestResult('❌ NDK not initialized');
return;
}
// Test fetching a simple event
const filter = { kinds: [1], limit: 1 };
const events = await ndk.fetchEvents(filter);
setTestResult(`✅ NDK connection working! Found ${events.size} events`);
} catch (err) {
console.error('Connection test failed:', err);
setTestResult(`❌ Connection test failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleSimpleTest = async () => {
if (!ndk || !user) {
setTestResult('❌ NDK not initialized or user not logged in');
return;
}
try {
setTestResult('Creating simple counter...');
// Create the simplest possible counter
const eventData = {
kind: 30078,
content: '',
tags: [
['type', 'since'],
['title', 'Simple Test'],
['date', '2024-01-01'],
['visibility', 'public'],
],
created_at: Math.floor(Date.now() / 1000),
pubkey: user.pubkey,
};
console.log('Simple event data:', eventData);
const event = new NDKEvent(ndk, eventData);
console.log('Simple event created:', event);
console.log('Event tags:', event.tags);
console.log('Event kind:', event.kind);
console.log('Signing simple event...');
await event.sign();
console.log('Simple event signed successfully');
console.log('Publishing simple event...');
await event.publish();
console.log('Simple event published successfully');
setTestResult(`✅ Simple counter created! Event ID: ${event.id}`);
} catch (err) {
console.error('Simple test failed:', err);
setTestResult(`❌ Simple test failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleManualCounter = async () => {
if (!ndk || !user) {
setTestResult('❌ NDK not initialized or user not logged in');
return;
}
try {
setTestResult('Creating counter manually...');
const testDate = '2024-01-01';
const derivedType = deriveCounterType(testDate);
console.log('Test date:', testDate);
console.log('Derived type:', derivedType);
// Create event manually following counter.md specification
const eventData = {
kind: 30078,
content: '',
tags: [
['type', derivedType],
['title', 'Manual Test Counter'],
['date', testDate],
['visibility', 'public'],
],
created_at: Math.floor(Date.now() / 1000),
pubkey: user.pubkey,
};
console.log('Manual event data:', eventData);
const event = new NDKEvent(ndk, eventData);
console.log('Event created with tags:', event.tags);
console.log('Signing event...');
await event.sign();
console.log('Event signed successfully');
console.log('Publishing event...');
await event.publish();
console.log('Event published successfully');
setTestResult(`✅ Manual counter created! Event ID: ${event.id}`);
} catch (err) {
console.error('Manual test failed:', err);
setTestResult(`❌ Manual test failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
const handleTestTypeDerivation = () => {
const testDates = [
{ date: '2023-01-01', expected: 'since' },
{ date: '2024-01-01', expected: 'since' },
{ date: '2025-01-01', expected: 'until' },
{ date: new Date().toISOString().split('T')[0], expected: 'since' },
];
let result = 'Testing type derivation:\n';
testDates.forEach(({ date, expected }) => {
const derived = deriveCounterType(date);
const status = derived === expected ? '✅' : '❌';
result += `${status} ${date}${derived} (expected: ${expected})\n`;
});
setTestResult(result);
};
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-8">NostrCount Test Page</h1>
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Connection Status</h2>
<p className="mb-4">
<strong>Connected:</strong> {isConnected ? '✅ Yes' : '❌ No'}
</p>
{!isConnected && (
<button
onClick={login}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Connect Nostr Extension
</button>
)}
</div>
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Connection Test</h2>
<button
onClick={handleTestConnection}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 mr-2"
>
Test NDK Connection
</button>
{testResult && (
<div className="mt-4 p-4 bg-gray-100 rounded">
<pre className="text-sm whitespace-pre-wrap">{testResult}</pre>
</div>
)}
</div>
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Type Derivation Test</h2>
<button
onClick={handleTestTypeDerivation}
className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
>
Test Type Derivation
</button>
</div>
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-xl font-semibold mb-4">Counter Creation Tests</h2>
{isConnected ? (
<div className="space-y-4">
<div>
<button
onClick={handleTestCounter}
disabled={loading}
className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 disabled:opacity-50 mr-2"
>
{loading ? 'Creating...' : 'Create Test Counter (Hook)'}
</button>
<button
onClick={handleManualCounter}
className="bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700 mr-2"
>
Create Manual Counter
</button>
<button
onClick={handleSimpleTest}
className="bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700"
>
Simple Test
</button>
</div>
{error && (
<p className="text-red-600 mt-2">Error: {error}</p>
)}
</div>
) : (
<p className="text-gray-600">Please connect your Nostr extension first.</p>
)}
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-xl font-semibold mb-4">Debug Information</h2>
<p className="text-sm text-gray-600">
Check the browser console for detailed logs about the counter creation process.
</p>
{user && (
<div className="mt-4 p-4 bg-gray-100 rounded">
<p className="text-sm">
<strong>User Pubkey:</strong> {user.pubkey}
</p>
</div>
)}
</div>
</div>
</div>
);
};

51
src/types/index.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface Counter {
id: string;
title: string;
date: string;
type: 'since' | 'until';
visibility: 'public' | 'private';
pubkey: string;
createdAt: number;
slug: string;
}
export interface CounterEvent {
kind: 30078;
content: string;
tags: string[][];
created_at: number;
pubkey: string;
id: string;
sig: string;
}
export interface UserProfile {
pubkey: string;
name?: string;
display_name?: string;
about?: string;
picture?: string;
nip05?: string;
lud16?: string;
lud06?: string;
}
export interface CounterFormData {
title: string;
date: string;
type: 'since' | 'until';
visibility: 'public' | 'private';
}
export interface ZapRequest {
amount: number;
comment?: string;
pubkey: string;
eventId?: string;
}
export interface RelayConfig {
url: string;
read: boolean;
write: boolean;
}

45
src/utils/date.ts Normal file
View File

@@ -0,0 +1,45 @@
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import duration from 'dayjs/plugin/duration';
dayjs.extend(relativeTime);
dayjs.extend(duration);
export function calculateDaysDiff(date: string, type: 'since' | 'until'): number {
const targetDate = dayjs(date);
const now = dayjs();
if (type === 'since') {
return now.diff(targetDate, 'day');
} else {
return targetDate.diff(now, 'day');
}
}
export function formatDate(date: string): string {
return dayjs(date).format('MMMM D, YYYY');
}
export function formatRelativeTime(date: string): string {
return dayjs(date).fromNow();
}
export function isDateInFuture(date: string): boolean {
return dayjs(date).isAfter(dayjs());
}
export function isDateValid(date: string): boolean {
return dayjs(date).isValid();
}
export function formatDuration(days: number): string {
if (days === 0) return 'Today';
if (days === 1) return '1 day';
if (days === -1) return '1 day ago';
if (days > 0) return `${days} days`;
return `${Math.abs(days)} days ago`;
}
export function getTodayISOString(): string {
return dayjs().format('YYYY-MM-DD');
}

193
src/utils/nostr.ts Normal file
View File

@@ -0,0 +1,193 @@
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import type { Counter } from '../types';
export function deriveCounterType(date: string): 'since' | 'until' {
const today = new Date();
const targetDate = new Date(date);
// If the date is in the past, it's "since"
// If the date is in the future, it's "until"
return targetDate <= today ? 'since' : 'until';
}
export function isValidCounterEvent(event: NDKEvent, requirePublic: boolean = false): boolean {
// Check if this looks like a NostrCount counter event
const tags = event.tags;
// Must have the required tags
const hasType = tags.some(tag => tag[0] === 'type' && (tag[1] === 'since' || tag[1] === 'until'));
const hasTitle = tags.some(tag => tag[0] === 'title' && tag[1] && tag[1].length > 0);
const hasDate = tags.some(tag => tag[0] === 'date' && tag[1] && /^\d{4}-\d{2}-\d{2}$/.test(tag[1]));
// Must have at least type, title, and date to be considered a valid counter
const isValid = hasType && hasTitle && hasDate;
if (!isValid) return false;
// If we require public counters, check visibility
if (requirePublic) {
const isPublic = tags.some(tag => tag[0] === 'visibility' && tag[1] === 'public');
return isPublic;
}
return true;
}
export function eventToCounter(event: NDKEvent, requirePublic: boolean = false): Counter | null {
try {
console.log('Parsing event:', event);
const tags = event.tags;
console.log('Event tags:', tags);
// Log each tag individually for debugging
tags.forEach((tag, index) => {
console.log(`Tag ${index}:`, tag);
});
// Try to find tags with different possible structures
let typeTag = tags.find(tag => tag[0] === 'type')?.[1];
let titleTag = tags.find(tag => tag[0] === 'title')?.[1];
let dateTag = tags.find(tag => tag[0] === 'date')?.[1];
let visibilityTag = tags.find(tag => tag[0] === 'visibility')?.[1];
// If we don't find the expected tags, try alternative approaches
if (!typeTag || !titleTag || !dateTag) {
console.log('Standard tags not found, trying alternative parsing...');
// Look for tags that might contain the data in different formats
for (const tag of tags) {
if (tag[0] === 'd' && tag[1]) {
// Some events might use 'd' tag for title
if (!titleTag) titleTag = tag[1];
}
if (tag[0] === 't' && tag[1]) {
// Some events might use 't' tag for type
if (!typeTag) typeTag = tag[1];
}
}
// Try to extract from content if it's JSON
if (event.content && event.content.trim() !== '') {
try {
const content = JSON.parse(event.content);
console.log('Parsed content:', content);
if (!titleTag && content.title) titleTag = content.title;
if (!typeTag && content.type) typeTag = content.type;
if (!dateTag && content.date) dateTag = content.date;
if (!visibilityTag && content.visibility) visibilityTag = content.visibility;
} catch (e) {
console.log('Content is not JSON:', event.content);
}
}
}
console.log('Parsed tags:', { typeTag, titleTag, dateTag, visibilityTag });
// For a valid counter, we need at least title and date
if (!titleTag || !dateTag) {
console.log('Missing required title or date, skipping event');
return null;
}
// If we still don't have required fields, try to create a basic counter
if (!typeTag) {
typeTag = 'since'; // Default to 'since'
}
if (!visibilityTag) {
visibilityTag = 'public'; // Default to public
}
if (typeTag !== 'since' && typeTag !== 'until') {
console.log('Invalid type tag:', typeTag);
return null;
}
if (visibilityTag !== 'public' && visibilityTag !== 'private') {
console.log('Invalid visibility tag:', visibilityTag);
return null;
}
// Additional validation: check if the date is in a reasonable format
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(dateTag)) {
console.log('Invalid date format:', dateTag);
return null;
}
// Check if the title is reasonable (not too short or too long)
if (titleTag.length < 2 || titleTag.length > 100) {
console.log('Invalid title length:', titleTag.length);
return null;
}
// For public counters, ensure visibility is actually public
if (requirePublic && visibilityTag !== 'public') {
console.log('Counter is not public, skipping:', visibilityTag);
return null;
}
const counter = {
id: event.id,
title: titleTag,
date: dateTag,
type: typeTag as 'since' | 'until',
visibility: visibilityTag as 'public' | 'private',
pubkey: event.pubkey,
createdAt: event.created_at || 0,
slug: event.id, // Use event ID as slug
};
console.log('Created counter:', counter);
return counter;
} catch (error) {
console.error('Error parsing counter event:', error);
return null;
}
}
export function getCounterFilter(pubkey?: string, isPublic?: boolean): NDKFilter {
const filter: NDKFilter = {
kinds: [30078],
limit: 100, // Limit to 100 events to prevent overwhelming relays
};
if (pubkey) {
filter.authors = [pubkey];
}
// For public counters, we'll fetch all kind 30078 events and filter by visibility in the parsing
// This is more inclusive and will catch events that might not have the visibility tag
if (isPublic) {
// Don't filter by visibility tag here - we'll handle it in parsing
// This allows us to see all counter events and filter them later
}
console.log('getCounterFilter: Created filter:', filter, { pubkey, isPublic });
return filter;
}
export const DEFAULT_RELAYS = [
'wss://relay.azzamo.net',
'wss://relay.damus.io',
'wss://nostr.oxtr.dev',
'wss://nos.lol',
'wss://relay.snort.social',
];
export function parseNip05(nip05: string): { name: string; domain: string } | null {
if (!nip05 || !nip05.includes('@')) return null;
const [name, domain] = nip05.split('@');
return { name, domain };
}
export function extractLightningAddress(profile: any): string | null {
if (profile.lud16) return profile.lud16;
if (profile.lud06) return profile.lud06;
return null;
}

38
tailwind.config.js Normal file
View File

@@ -0,0 +1,38 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

22
vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
define: {
global: 'globalThis',
},
server: {
port: 3000,
},
build: {
rollupOptions: {
output: {
manualChunks: {
ndk: ['@nostr-dev-kit/ndk'],
vendor: ['react', 'react-dom', 'react-router-dom'],
},
},
},
},
})