Add files via upload
This commit is contained in:
92
Context.md
Normal file
92
Context.md
Normal 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
177
README.md
@@ -1 +1,178 @@
|
|||||||
# NostrCount
|
# 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
121
architecture.md
Normal 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 user’s 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
163
counter.md
Normal 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
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
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
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
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
20
dist/index.html
vendored
Normal 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
6
dist/vite.svg
vendored
Normal 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
7
env.example
Normal 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
16
index.html
Normal 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
4158
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
6
public/vite.svg
Normal file
6
public/vite.svg
Normal 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
44
src/App.tsx
Normal 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;
|
||||||
135
src/components/CounterCard.tsx
Normal file
135
src/components/CounterCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
226
src/components/CounterFormModal.tsx
Normal file
226
src/components/CounterFormModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
212
src/components/EditCounterModal.tsx
Normal file
212
src/components/EditCounterModal.tsx
Normal 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
119
src/components/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
src/components/LoadingSpinner.tsx
Normal file
21
src/components/LoadingSpinner.tsx
Normal 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}`} />
|
||||||
|
);
|
||||||
|
};
|
||||||
419
src/components/LoginModal.tsx
Normal file
419
src/components/LoginModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
162
src/components/NostrShareModal.tsx
Normal file
162
src/components/NostrShareModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
140
src/components/ZapButton.tsx
Normal file
140
src/components/ZapButton.tsx
Normal 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
344
src/contexts/NDKContext.tsx
Normal 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
288
src/hooks/useCounters.ts
Normal 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
87
src/index.css
Normal 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
9
src/main.tsx
Normal 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>
|
||||||
|
);
|
||||||
220
src/pages/BrowseCounters.tsx
Normal file
220
src/pages/BrowseCounters.tsx
Normal 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
293
src/pages/CounterDetail.tsx
Normal 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
226
src/pages/Dashboard.tsx
Normal 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
242
src/pages/Home.tsx
Normal 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
270
src/pages/Test.tsx
Normal 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
51
src/types/index.ts
Normal 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
45
src/utils/date.ts
Normal 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
193
src/utils/nostr.ts
Normal 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
38
tailwind.config.js
Normal 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
21
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
22
vite.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user