Merge branch 'main' into update-hatch-action
This commit is contained in:
@@ -108,6 +108,7 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Use present tense ("Add dark mode toggle", not "Added dark mode toggle")
|
||||
- Focus on what the user sees/experiences, not internal implementation details
|
||||
- Use the current date in YYYY-MM-DD format
|
||||
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
|
||||
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
|
||||
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
|
||||
|
||||
@@ -716,6 +716,43 @@ await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newT
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
### D-Tag Collision Prevention for Addressable Events
|
||||
|
||||
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
|
||||
|
||||
#### When to Check for Collisions
|
||||
|
||||
**Must check before publishing** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.). **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
|
||||
|
||||
#### Implementation Pattern
|
||||
|
||||
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
|
||||
|
||||
```typescript
|
||||
// Before publishing a new addressable event:
|
||||
const slug = slugify(title, { lower: true, strict: true });
|
||||
|
||||
const existing = await nostr.query([
|
||||
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
|
||||
]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
toast({
|
||||
title: 'Slug already in use',
|
||||
description: 'Change the slug or edit the existing item.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe to publish
|
||||
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
|
||||
```
|
||||
|
||||
**Skip the check in edit mode** -- when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
|
||||
|
||||
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
|
||||
|
||||
### Nostr Login
|
||||
|
||||
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
|
||||
|
||||
+33
-6
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
@@ -36,7 +63,7 @@
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
@@ -91,11 +118,11 @@
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring kind 10008 profile badge lists
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
@@ -114,7 +141,7 @@
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
@@ -141,10 +168,10 @@
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber and NIP-46 users on Android
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.2.9"
|
||||
versionName "2.3.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.9;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -325,7 +325,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.9;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
Generated
+1853
-20
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.2.9",
|
||||
"version": "2.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
@@ -53,6 +53,17 @@
|
||||
"@fontsource/special-elite": "^5.2.8",
|
||||
"@getalby/sdk": "^5.1.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@milkdown/core": "^7.20.0",
|
||||
"@milkdown/ctx": "^7.20.0",
|
||||
"@milkdown/plugin-clipboard": "^7.20.0",
|
||||
"@milkdown/plugin-history": "^7.20.0",
|
||||
"@milkdown/plugin-listener": "^7.20.0",
|
||||
"@milkdown/plugin-upload": "^7.20.0",
|
||||
"@milkdown/preset-commonmark": "^7.20.0",
|
||||
"@milkdown/preset-gfm": "^7.20.0",
|
||||
"@milkdown/prose": "^7.20.0",
|
||||
"@milkdown/react": "^7.20.0",
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@nostrify/nostrify": "^0.51.0",
|
||||
"@nostrify/react": "^0.4.0",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
@@ -117,6 +128,7 @@
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^2.12.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"slugify": "^1.6.8",
|
||||
"smol-toml": "^1.6.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
+33
-6
@@ -1,5 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
## [2.3.0] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- In-app article editor with a rich text toolbar, image uploads, auto-saving drafts, and a "My Articles" tab to manage drafts and published articles
|
||||
|
||||
### Fixed
|
||||
- Custom emoji no longer stretch to fill their container
|
||||
- Mobile drawer now closes when tapping footer links like Changelog or Privacy
|
||||
- Logged-out users now default to the global tab on content feeds instead of seeing an empty follows tab
|
||||
|
||||
## [2.2.11] - 2026-04-02
|
||||
|
||||
### Fixed
|
||||
- Fix crash caused by the "What's new" toast firing outside the router
|
||||
|
||||
## [2.2.10] - 2026-04-02
|
||||
|
||||
### Added
|
||||
- App cards for Nostr apps now display in feeds and detail pages with hero images, icons, and quick-launch buttons
|
||||
- "What's new" toast appears after an app update with a changelog preview and link to the full changelog
|
||||
|
||||
### Changed
|
||||
- Changelog page redesigned with a hero section for the latest release, collapsible older entries, and category icons inline with each item
|
||||
|
||||
### Fixed
|
||||
- Compose box now fully resets to its collapsed state after posting, including poll options and media trays
|
||||
|
||||
## [2.2.9] - 2026-04-01
|
||||
|
||||
### Added
|
||||
@@ -36,7 +63,7 @@
|
||||
### Added
|
||||
- Encrypted letters now appear as interactive 3D envelopes with Nushu script -- flip and open them to reveal the secret writing inside
|
||||
- Zap receipts and profile metadata events now render in feeds and detail pages
|
||||
- Remote signer callback page for NIP-46 login flows (Amber, Primal)
|
||||
- Remote signer callback page for login flows with Amber, Primal, and other signing apps
|
||||
|
||||
### Changed
|
||||
- Post action buttons extracted into a reusable PostActionBar component
|
||||
@@ -91,11 +118,11 @@
|
||||
## [2.2.2] - 2026-03-29
|
||||
|
||||
### Added
|
||||
- Dedicated photo upload flow for sharing photos as NIP-68 kind 20 events
|
||||
- Dedicated photo upload flow for sharing photos
|
||||
- Pull-to-refresh on all feed pages
|
||||
- 3D tilt effect on badge images -- hover over badges to see them pop
|
||||
- Multi-select badge awarding with indicators for already-sent badges
|
||||
- Badge list recovery dialog for restoring kind 10008 profile badge lists
|
||||
- Badge list recovery dialog for restoring profile badge lists
|
||||
- Compact badge row preview in embedded profile badges events
|
||||
- Custom emoji usage tracking so your most-used custom emojis appear in the quick-react bar
|
||||
- Release notes now included in Zapstore publishing
|
||||
@@ -114,7 +141,7 @@
|
||||
- Double-tap reactions now properly show the emoji on the post
|
||||
- Emoji shortcode autocomplete text and highlight colors
|
||||
- Profile skeleton no longer flickers for brand-new users with no metadata
|
||||
- Addressable event routing now works correctly for replaceable events (kind 10000-19999)
|
||||
- Event links now route correctly for all event types
|
||||
- Badge notifications are now clickable
|
||||
- Custom profile tab form no longer retains fields from a previously edited tab
|
||||
- Double line under profile tabs in edit mode
|
||||
@@ -141,10 +168,10 @@
|
||||
- Blobbi shop and inventory system with items that affect your pet's stats
|
||||
- Daily missions with reroll, care streaks, and stage-based rewards
|
||||
- Immersive full-screen divines experience on both mobile and desktop with floating controls
|
||||
- NIP-11 relay information panel on the network settings page
|
||||
- Relay information panel on the network settings page
|
||||
- Link preview cards now display inside quoted posts instead of raw URLs
|
||||
- Nsec paste guard warns you before accidentally pasting private keys outside the login field
|
||||
- Remote signer UX improvements for Amber and NIP-46 users on Android
|
||||
- Remote signer UX improvements for Amber users on Android
|
||||
- Badge awards now trigger push notifications
|
||||
- Badges display in profile bio section with a "Give badge" option in the profile menu
|
||||
|
||||
|
||||
+3
-4
@@ -16,8 +16,8 @@ import NostrProvider from "@/components/NostrProvider";
|
||||
import { NostrSync } from "@/components/NostrSync";
|
||||
import { PlausibleProvider } from "@/components/PlausibleProvider";
|
||||
import { SentryProvider } from "@/components/SentryProvider";
|
||||
import { VersionCheck } from "@/components/VersionCheck";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
@@ -201,12 +201,11 @@ export function App() {
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
<VersionCheck />
|
||||
|
||||
<NWCProvider>
|
||||
<DMProvider config={dmConfig}>
|
||||
<EmotionDevProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
|
||||
@@ -6,8 +6,10 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
|
||||
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
|
||||
import { BlobbiActionsProvider } from "@/blobbi/companion/interaction/BlobbiActionsProvider";
|
||||
import { sidebarItemIcon } from "@/lib/sidebarItems";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import { VersionCheck } from "./components/VersionCheck";
|
||||
import { useCurrentUser } from "./hooks/useCurrentUser";
|
||||
import { useProfileUrl } from "./hooks/useProfileUrl";
|
||||
import { getExtraKindDef } from "./lib/extraKinds";
|
||||
@@ -32,6 +34,7 @@ const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.H
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
|
||||
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
|
||||
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
|
||||
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
|
||||
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
|
||||
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
|
||||
@@ -136,6 +139,8 @@ export function AppRouter() {
|
||||
return (
|
||||
<AudioPlayerProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<MinimizedAudioBar />
|
||||
<AudioNavigationGuard />
|
||||
<DeepLinkHandler />
|
||||
@@ -207,6 +212,8 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
<Route path="/webxdc" element={<WebxdcFeedPage />} />
|
||||
<Route path="/articles/new" element={<ArticleEditorPage />} />
|
||||
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
|
||||
<Route
|
||||
path="/articles"
|
||||
element={
|
||||
@@ -214,6 +221,7 @@ export function AppRouter() {
|
||||
kind={articlesDef.kind}
|
||||
title={articlesDef.label}
|
||||
icon={sidebarItemIcon("articles", "size-5")}
|
||||
fabHref="/articles/new"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { ExternalLink, GitFork, Package } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { NostrURI } from '@/lib/NostrURI';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Get a tag value by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
/** Get all values for a tag name. */
|
||||
function getAllTags(tags: string[][], name: string): string[] {
|
||||
return tags.filter(([n]) => n === name).map(([, v]) => v);
|
||||
}
|
||||
|
||||
/** Parse kind-0-style metadata from the content field. */
|
||||
function parseHandlerMetadata(content: string): NostrMetadata {
|
||||
if (!content) return {};
|
||||
try {
|
||||
return JSON.parse(content) as NostrMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the website URL from web handler tags or metadata. */
|
||||
function getWebsiteUrl(tags: string[][], metadata: NostrMetadata): string | undefined {
|
||||
const webTags = tags.filter(([n]) => n === 'web');
|
||||
for (const tag of webTags) {
|
||||
const url = tag[1];
|
||||
if (url) {
|
||||
const base = url.replace(/<bech32>/g, '').replace(/\/+$/, '');
|
||||
return base;
|
||||
}
|
||||
}
|
||||
return metadata.website;
|
||||
}
|
||||
|
||||
/** Extract the display domain from a URL. */
|
||||
function displayDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a Shakespeare "Edit with Shakespeare" URL from a kind 30617 `a` tag, if present. */
|
||||
function getShakespeareUrl(tags: string[][]): string | undefined {
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== 'a') continue;
|
||||
const parts = tag[1]?.split(':');
|
||||
if (!parts || parts[0] !== '30617' || parts.length < 3) continue;
|
||||
const pubkey = parts[1];
|
||||
const identifier = parts.slice(2).join(':');
|
||||
const nostrUri = new NostrURI({ pubkey, identifier }).toString();
|
||||
return `https://shakespeare.diy/clone?url=${encodeURIComponent(nostrUri)}`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface AppHandlerContentProps {
|
||||
event: NostrEvent;
|
||||
/** If true, show compact preview (used in NoteCard feed). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/** Renders a kind 31990 NIP-89 application handler event as a showcase-style card. */
|
||||
export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
const metadata = useMemo(() => parseHandlerMetadata(event.content), [event.content]);
|
||||
|
||||
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
|
||||
const about = metadata.about;
|
||||
const picture = metadata.picture;
|
||||
const websiteUrl = getWebsiteUrl(event.tags, metadata);
|
||||
const hashtags = getAllTags(event.tags, 't');
|
||||
|
||||
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
|
||||
|
||||
const { data: preview, isLoading: previewLoading } = useLinkPreview(websiteUrl ?? null);
|
||||
const thumbnailUrl = preview?.thumbnail_url;
|
||||
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const showThumbnail = thumbnailUrl && !imgError;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
|
||||
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
|
||||
{(previewLoading || showThumbnail) && (
|
||||
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
|
||||
{previewLoading ? (
|
||||
<Skeleton className="absolute inset-0" />
|
||||
) : (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={name}
|
||||
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 px-3.5 pb-3.5 space-y-2">
|
||||
{/* App icon — overlaps the screenshot hero like a profile avatar */}
|
||||
<div className={showThumbnail || previewLoading ? '-mt-7' : 'pt-3.5'}>
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt={name}
|
||||
className="size-14 rounded-xl object-cover shrink-0 border-3 border-background bg-background shadow-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-14 rounded-xl bg-primary/10 flex items-center justify-center shrink-0 border-3 border-background shadow-sm">
|
||||
<Package className="size-6 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + domain */}
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-[15px] leading-snug truncate">{name}</h3>
|
||||
{websiteUrl && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<ExternalFavicon url={websiteUrl} size={12} />
|
||||
<span className="truncate">{displayDomain(websiteUrl)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{about && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{about}</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hashtags.slice(0, 4).map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${encodeURIComponent(tag)}`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{websiteUrl && (
|
||||
<Button asChild size="sm" className="h-7 text-xs">
|
||||
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Open App
|
||||
<ExternalLink className="size-3 ml-1.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{shakespeareUrl && (
|
||||
<Button asChild variant="secondary" size="sm" className="h-7 text-xs">
|
||||
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Fork
|
||||
<GitFork className="size-3 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full detail view
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
|
||||
{(previewLoading || showThumbnail) && (
|
||||
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
|
||||
{previewLoading ? (
|
||||
<Skeleton className="absolute inset-0" />
|
||||
) : (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={name}
|
||||
className="size-full object-cover"
|
||||
loading="lazy"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 px-4 pb-4 space-y-3">
|
||||
{/* App icon — overlaps the screenshot hero like a profile avatar */}
|
||||
<div className={cn(
|
||||
'flex items-end justify-between',
|
||||
showThumbnail || previewLoading ? '-mt-10' : 'pt-4',
|
||||
)}>
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt={name}
|
||||
className="size-20 rounded-2xl object-cover shrink-0 border-4 border-background bg-background shadow-sm"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-20 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0 border-4 border-background shadow-sm">
|
||||
<Package className="size-8 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + domain */}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold leading-snug truncate">{name}</h2>
|
||||
{websiteUrl && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
<ExternalFavicon url={websiteUrl} size={14} />
|
||||
<span className="truncate">{displayDomain(websiteUrl)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{about && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{about}</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hashtags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${encodeURIComponent(tag)}`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{websiteUrl && (
|
||||
<Button asChild size="sm">
|
||||
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Open App
|
||||
<ExternalLink className="size-3 ml-1.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{shakespeareUrl && (
|
||||
<Button asChild variant="secondary" size="sm">
|
||||
<a href={shakespeareUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Fork
|
||||
<GitFork className="size-3.5 ml-1.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Skeleton loading state for AppHandlerContent. */
|
||||
export function AppHandlerSkeleton() {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<Skeleton className="aspect-[2/1] w-full" />
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
<div className="-mt-10">
|
||||
<Skeleton className="size-20 rounded-2xl border-4 border-background" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,6 +115,7 @@ const KIND_LABELS: Record<number, string> = {
|
||||
30817: 'a custom NIP',
|
||||
31922: 'a calendar event',
|
||||
31923: 'a calendar event',
|
||||
31990: 'an app',
|
||||
32267: 'an app',
|
||||
34139: 'a playlist',
|
||||
34236: 'a divine',
|
||||
@@ -157,6 +158,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
30063: Package,
|
||||
30311: Radio,
|
||||
30617: GitBranch,
|
||||
31990: Package,
|
||||
32267: Package,
|
||||
34236: Clapperboard,
|
||||
36767: Sparkles,
|
||||
@@ -228,6 +230,16 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co
|
||||
return { text: siteName ? `${siteName} nsite` : 'an nsite', icon };
|
||||
}
|
||||
|
||||
// NIP-89 apps: name lives in JSON content, not in tags
|
||||
if (event.kind === 31990) {
|
||||
try {
|
||||
const meta = JSON.parse(event.content);
|
||||
const appName = meta?.name || event.tags.find(([n]) => n === 'name')?.[1];
|
||||
if (appName) return { text: `${appName} app`, icon };
|
||||
} catch { /* fall through */ }
|
||||
return { text: 'an app', icon };
|
||||
}
|
||||
|
||||
// Extract a title-like string from tags
|
||||
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
||||
const name = event.tags.find(([name]) => name === 'name')?.[1];
|
||||
|
||||
@@ -224,6 +224,26 @@ export function ComposeBox({
|
||||
const voiceRecorder = useVoiceRecorder();
|
||||
const [isPublishingVoice, setIsPublishingVoice] = useState(false);
|
||||
|
||||
const resetComposeState = useCallback(() => {
|
||||
setContent('');
|
||||
setCwEnabled(false);
|
||||
setCwText('');
|
||||
setExpanded(false);
|
||||
setPickerOpen(false);
|
||||
setTrayOpen(false);
|
||||
setInternalPreviewMode(false);
|
||||
setMode(initialMode);
|
||||
setPollQuestion('');
|
||||
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
|
||||
setPollType('singlechoice');
|
||||
setPollDuration(7);
|
||||
setRemovedEmbeds(new Set());
|
||||
setUploadedFileTags([]);
|
||||
setUploadedFileGroups(new Map());
|
||||
setWebxdcUuids(new Map());
|
||||
setWebxdcMetas(new Map());
|
||||
}, [initialMode]);
|
||||
|
||||
// Use controlled preview mode if provided, otherwise use internal state
|
||||
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
|
||||
|
||||
@@ -928,15 +948,7 @@ export function ComposeBox({
|
||||
});
|
||||
}
|
||||
|
||||
setContent('');
|
||||
setCwEnabled(false);
|
||||
setCwText('');
|
||||
setExpanded(false);
|
||||
setRemovedEmbeds(new Set());
|
||||
setUploadedFileTags([]);
|
||||
setUploadedFileGroups(new Map());
|
||||
setWebxdcUuids(new Map());
|
||||
setWebxdcMetas(new Map());
|
||||
resetComposeState();
|
||||
// Optimistically bump the reply count on the parent event
|
||||
if (replyTo && !(replyTo instanceof URL)) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
|
||||
@@ -984,12 +996,7 @@ export function ComposeBox({
|
||||
|
||||
try {
|
||||
await createEvent({ kind: 1068, content: pollQuestion.trim(), tags });
|
||||
// Reset poll state
|
||||
setMode('post');
|
||||
setPollQuestion('');
|
||||
setPollOptions([{ id: pollOptionId(), label: '' }, { id: pollOptionId(), label: '' }]);
|
||||
setPollType('singlechoice');
|
||||
setPollDuration(7);
|
||||
resetComposeState();
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
toast({ title: 'Poll published!' });
|
||||
onSuccess?.();
|
||||
@@ -1007,7 +1014,7 @@ export function ComposeBox({
|
||||
if (!user && compact) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-3 bg-background/50")}>
|
||||
<div className={cn("px-4 py-3 bg-background/85")}>
|
||||
{/* Preview toggle at top when not controlled and has previewable content */}
|
||||
{hasPreviewableContent && controlledPreviewMode === undefined && (
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
|
||||
@@ -15,7 +15,7 @@ interface CustomEmojiImgProps {
|
||||
/**
|
||||
* Renders a single custom emoji as an inline image.
|
||||
*/
|
||||
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] align-text-bottom' }: CustomEmojiImgProps) {
|
||||
export function CustomEmojiImg({ name, url, className = 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom' }: CustomEmojiImgProps) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
@@ -52,7 +52,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
|
||||
const url = getCustomEmojiUrl(emoji, tags);
|
||||
if (url) {
|
||||
const name = emoji.slice(1, -1);
|
||||
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-text-bottom'} />;
|
||||
return <CustomEmojiImg name={name} url={url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-text-bottom'} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function ReactionEmoji({ content, tags, className }: ReactionEmojiProps)
|
||||
*/
|
||||
export function RenderResolvedEmoji({ emoji, className }: { emoji: ResolvedEmoji; className?: string }) {
|
||||
if (emoji.url && emoji.name) {
|
||||
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] align-middle'} />;
|
||||
return <CustomEmojiImg name={emoji.name} url={emoji.url} className={className ?? 'inline h-[1.2em] w-[1.2em] object-contain align-middle'} />;
|
||||
}
|
||||
return <span className={cn('inline-block leading-none', className)}>{emoji.content}</span>;
|
||||
}
|
||||
|
||||
@@ -1082,6 +1082,7 @@ function hasVideo(tags: string[][]): boolean {
|
||||
|
||||
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
|
||||
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
|
||||
31990: 'App',
|
||||
32267: 'App',
|
||||
30063: 'Release',
|
||||
15128: 'Nsite',
|
||||
@@ -1109,7 +1110,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey
|
||||
const KindIcon = useMemo(() => {
|
||||
if (kindDef?.id) return CONTENT_KIND_ICONS[kindDef.id] ?? FileText;
|
||||
// Fallback icons for well-known kinds not in EXTRA_KINDS
|
||||
if (addr.kind === 32267 || addr.kind === 30063) return Package;
|
||||
if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063) return Package;
|
||||
if (addr.kind === 15128 || addr.kind === 35128) return Globe;
|
||||
return FileText;
|
||||
}, [kindDef, addr.kind]);
|
||||
|
||||
@@ -108,10 +108,14 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const { startSignup } = useOnboarding();
|
||||
|
||||
// Kind-specific pages only support Follows + Global. Clamp any other
|
||||
// persisted tab (e.g. 'ditto', 'communities') back to 'follows'.
|
||||
const activeTab: FeedTab = kinds && rawActiveTab !== 'follows' && rawActiveTab !== 'global'
|
||||
? 'follows'
|
||||
: rawActiveTab;
|
||||
// persisted tab (e.g. 'ditto', 'communities') back to the appropriate default.
|
||||
// Logged-out users must land on 'global' since 'follows' requires a user.
|
||||
const activeTab: FeedTab = (() => {
|
||||
if (!kinds) return rawActiveTab; // Home feed: no clamping
|
||||
if (rawActiveTab === 'global') return 'global';
|
||||
if (rawActiveTab === 'follows' && user) return 'follows';
|
||||
return user ? 'follows' : 'global';
|
||||
})();
|
||||
|
||||
// Is the active tab a saved feed?
|
||||
const activeSavedFeed = useMemo(
|
||||
|
||||
@@ -184,7 +184,7 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
|
||||
{/* Emoji group header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-secondary/30 sticky top-0 z-[1]">
|
||||
{customUrl && customName ? (
|
||||
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6" />
|
||||
<CustomEmojiImg name={customName} url={customUrl} className="inline-block h-6 w-6 object-contain" />
|
||||
) : (
|
||||
<span className="text-lg">{emoji}</span>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface LinkFooterProps {
|
||||
/** Optional callback fired when an internal (React Router) link is clicked. */
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
/** Shared footer links used in both sidebars. */
|
||||
export function LinkFooter() {
|
||||
export function LinkFooter({ onNavigate }: LinkFooterProps) {
|
||||
return (
|
||||
<footer className="mt-auto pt-4 pb-4 text-left bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -23,7 +28,7 @@ export function LinkFooter() {
|
||||
Docs
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/privacy" className="text-primary hover:underline">
|
||||
<Link to="/privacy" className="text-primary hover:underline" onClick={onNavigate}>
|
||||
Privacy
|
||||
</Link>
|
||||
{' · '}
|
||||
@@ -36,7 +41,7 @@ export function LinkFooter() {
|
||||
Source
|
||||
</a>
|
||||
{' · '}
|
||||
<Link to="/changelog" className="text-primary hover:underline">
|
||||
<Link to="/changelog" className="text-primary hover:underline" onClick={onNavigate}>
|
||||
Changelog
|
||||
</Link>
|
||||
{' · '}
|
||||
|
||||
@@ -319,7 +319,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<LinkFooter />
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -363,7 +363,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</nav>
|
||||
|
||||
<div className="px-2">
|
||||
<LinkFooter />
|
||||
<LinkFooter onNavigate={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -74,6 +74,7 @@ import { EncryptedMessageContent } from "@/components/EncryptedMessageContent";
|
||||
import { EncryptedLetterContent } from "@/components/EncryptedLetterContent";
|
||||
import { VanishCardCompact } from "@/components/VanishEventContent";
|
||||
import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
|
||||
import { AppHandlerContent } from "@/components/AppHandlerContent";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getAvatarShape } from "@/lib/avatarShape";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -306,6 +307,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isCustomNip = event.kind === 30817;
|
||||
const isNsite = event.kind === 15128 || event.kind === 35128;
|
||||
const isZapstoreApp = event.kind === 32267;
|
||||
const isAppHandler = event.kind === 31990;
|
||||
const isEncryptedDM = event.kind === 4;
|
||||
const isLetter = event.kind === 8211;
|
||||
const isVanish = event.kind === 62;
|
||||
@@ -336,6 +338,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isAudioKind &&
|
||||
!isDevKind &&
|
||||
!isZapstoreApp &&
|
||||
!isAppHandler &&
|
||||
!isEncryptedDM &&
|
||||
!isLetter &&
|
||||
!isVanish &&
|
||||
@@ -532,6 +535,8 @@ export const NoteCard = memo(function NoteCard({
|
||||
<NsiteCard event={event} />
|
||||
) : isZapstoreApp ? (
|
||||
<ZapstoreAppContent event={event} compact />
|
||||
) : isAppHandler ? (
|
||||
<AppHandlerContent event={event} compact />
|
||||
) : isEncryptedDM ? (
|
||||
<EncryptedMessageContent event={event} compact />
|
||||
) : isLetter ? (
|
||||
@@ -789,11 +794,11 @@ export const NoteCard = memo(function NoteCard({
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Reaction emoji bubble instead of avatar */}
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-lg leading-none">
|
||||
<ReactionEmoji
|
||||
content={event.content}
|
||||
tags={event.tags}
|
||||
className="text-lg leading-none"
|
||||
className="h-5 w-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
{threaded && (
|
||||
@@ -870,11 +875,11 @@ export const NoteCard = memo(function NoteCard({
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Large reaction emoji */}
|
||||
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0">
|
||||
<div className="flex items-center justify-center size-11 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
|
||||
<ReactionEmoji
|
||||
content={event.content}
|
||||
tags={event.tags}
|
||||
className="text-xl leading-none"
|
||||
className="h-6 w-6 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2002,6 +2007,10 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
icon: Package,
|
||||
action: "published an app",
|
||||
},
|
||||
31990: {
|
||||
icon: Package,
|
||||
action: "published an app",
|
||||
},
|
||||
30617: {
|
||||
icon: GitBranch,
|
||||
action: "shared a",
|
||||
|
||||
@@ -545,7 +545,7 @@ export function NoteContent({
|
||||
{groupedTokens.map((token, i) => {
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 align-text-bottom' : undefined))}</span>;
|
||||
return <span key={i}>{linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}</span>;
|
||||
case 'image-embed': {
|
||||
if (disableEmbeds) {
|
||||
// In preview contexts (e.g. triple-dot menu), replace image URLs
|
||||
|
||||
@@ -186,7 +186,7 @@ export function ReactionButton({
|
||||
{filledHeart ? (
|
||||
<Heart className="size-6" fill={hasReacted ? 'currentColor' : 'none'} />
|
||||
) : hasReacted && userReaction ? (
|
||||
<RenderResolvedEmoji emoji={userReaction} className="size-5 leading-none translate-y-px" />
|
||||
<RenderResolvedEmoji emoji={userReaction} className="h-5 w-5 object-contain leading-none translate-y-px" />
|
||||
) : (
|
||||
<Heart className="size-5" />
|
||||
)}
|
||||
|
||||
@@ -19,8 +19,11 @@ async function fetchChangelogExcerpt(version: string): Promise<string | undefine
|
||||
const entry = entries.find((e) => e.version === version) ?? entries[0];
|
||||
if (!entry) return undefined;
|
||||
|
||||
// Return the first item from the first section.
|
||||
return entry.sections[0]?.items[0];
|
||||
// Return a truncated first item from the first section.
|
||||
const item = entry.sections[0]?.items[0];
|
||||
if (!item) return undefined;
|
||||
if (item.length <= 60) return item;
|
||||
return item.slice(0, 60).trimEnd() + '…';
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Image,
|
||||
Save,
|
||||
Send,
|
||||
Loader2,
|
||||
Hash,
|
||||
FileText,
|
||||
X,
|
||||
Clock,
|
||||
Cloud,
|
||||
HardDrive,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import slugify from 'slugify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { FabButton } from '@/components/FabButton';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDrafts, type Draft } from '@/hooks/useDrafts';
|
||||
import { usePublishedArticles } from '@/hooks/usePublishedArticles';
|
||||
import { saveDraft as saveLocalDraft, deleteDraftBySlug, deleteLocalDraftById, getLocalDrafts } from '@/lib/localDrafts';
|
||||
import type { ArticleFields } from '@/lib/articleHelpers';
|
||||
import { MilkdownEditor } from './MilkdownEditor';
|
||||
|
||||
export type ArticleData = ArticleFields;
|
||||
|
||||
interface ArticleEditorProps {
|
||||
/** Pre-filled data for editing an existing article or loading a draft. */
|
||||
initialData?: Partial<ArticleData> & { publishedAt?: number };
|
||||
/** Whether the editor is in edit mode (updating an existing article). */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
type EditorTab = 'write' | 'drafts';
|
||||
|
||||
export function ArticleEditor({ initialData, editMode = false }: ArticleEditorProps) {
|
||||
const navigate = useNavigate();
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutate: publishEvent, isPending: isPublishing } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { drafts: relayDrafts, isLoading: isDraftsLoading, saveDraft: saveRelayDraft, deleteDraft: deleteRelayDraft, isDeleting } = useDrafts();
|
||||
const { articles: publishedArticles } = usePublishedArticles();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<EditorTab>('write');
|
||||
const [localDrafts, setLocalDrafts] = useState<Draft[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; slug: string; isLocal: boolean } | null>(null);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const slugManuallyEdited = useRef(!!initialData?.slug);
|
||||
const [isPublished, setIsPublished] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(editMode);
|
||||
const [originalPublishedAt, setOriginalPublishedAt] = useState<number | null>(
|
||||
initialData?.publishedAt ?? null,
|
||||
);
|
||||
|
||||
const [article, setArticle] = useState<ArticleData>({
|
||||
title: initialData?.title || '',
|
||||
summary: initialData?.summary || '',
|
||||
content: initialData?.content || '',
|
||||
image: initialData?.image || '',
|
||||
tags: initialData?.tags || [],
|
||||
slug: initialData?.slug || '',
|
||||
});
|
||||
|
||||
// Keep a ref to the latest article data so the auto-save timer doesn't
|
||||
// need `article` in its dependency array (which would reset it on every keystroke).
|
||||
const articleRef = useRef(article);
|
||||
articleRef.current = article;
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => () => { mountedRef.current = false; }, []);
|
||||
|
||||
/** Save draft to relay (with localStorage fallback). Shared by manual save + auto-save. */
|
||||
const persistDraft = useCallback(async (data: ArticleData, { silent }: { silent?: boolean } = {}) => {
|
||||
if (user) {
|
||||
try {
|
||||
await saveRelayDraft(data);
|
||||
if (!mountedRef.current) return;
|
||||
setLastSaved(new Date());
|
||||
setHasUnsavedChanges(false);
|
||||
if (!silent) {
|
||||
toast({ title: 'Draft saved', description: 'Your article has been saved to Nostr relays.' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save draft to relay:', error);
|
||||
saveLocalDraft(data);
|
||||
if (!mountedRef.current) return;
|
||||
setLastSaved(new Date());
|
||||
setHasUnsavedChanges(false);
|
||||
if (!silent) {
|
||||
toast({ title: 'Draft saved locally', description: 'Could not sync to relays. Saved to your browser.', variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
saveLocalDraft(data);
|
||||
if (!mountedRef.current) return;
|
||||
setLastSaved(new Date());
|
||||
setHasUnsavedChanges(false);
|
||||
if (!silent) {
|
||||
toast({ title: 'Draft saved', description: 'Your article has been saved locally.' });
|
||||
}
|
||||
}
|
||||
}, [user, saveRelayDraft]);
|
||||
|
||||
// Auto-save 30s after the first unsaved change. The timer starts once and
|
||||
// is only reset when `hasUnsavedChanges` transitions, not on every keystroke.
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return;
|
||||
|
||||
autoSaveTimeoutRef.current = setTimeout(() => {
|
||||
const current = articleRef.current;
|
||||
if (current.content.length === 0) return;
|
||||
persistDraft(current, { silent: true });
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [hasUnsavedChanges, persistDraft]);
|
||||
|
||||
// Reference to handlers for keyboard shortcuts
|
||||
const handlePublishRef = useRef<(() => void) | null>(null);
|
||||
const handleSaveDraftRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Warn before leaving page with unsaved changes
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges && (article.title || article.content)) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [hasUnsavedChanges, article.title, article.content]);
|
||||
|
||||
// Auto-generate slug from title (skip if user manually edited the slug)
|
||||
useEffect(() => {
|
||||
if (article.title && !slugManuallyEdited.current) {
|
||||
const newSlug = slugify(article.title, {
|
||||
lower: true,
|
||||
strict: true,
|
||||
trim: true,
|
||||
});
|
||||
setArticle((prev) => ({ ...prev, slug: newSlug }));
|
||||
}
|
||||
}, [article.title]);
|
||||
|
||||
// Derived stats
|
||||
const wordCount = useMemo(() => article.content.trim().split(/\s+/).filter(Boolean).length, [article.content]);
|
||||
const readingTime = Math.ceil(wordCount / 200);
|
||||
|
||||
// Load local drafts when drafts tab is shown
|
||||
useEffect(() => {
|
||||
if (activeTab === 'drafts') {
|
||||
setLocalDrafts(getLocalDrafts());
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Combine relay and local drafts, avoiding duplicates by slug
|
||||
const combinedDrafts = useMemo(() => {
|
||||
const drafts: (Draft & { isLocal: boolean })[] = [];
|
||||
const seenSlugs = new Set<string>();
|
||||
|
||||
for (const draft of relayDrafts) {
|
||||
if (draft.slug) seenSlugs.add(draft.slug);
|
||||
drafts.push({ ...draft, isLocal: false });
|
||||
}
|
||||
|
||||
for (const draft of localDrafts) {
|
||||
if (!draft.slug || !seenSlugs.has(draft.slug)) {
|
||||
drafts.push({ ...draft, isLocal: true });
|
||||
}
|
||||
}
|
||||
|
||||
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}, [relayDrafts, localDrafts]);
|
||||
|
||||
/** Load a draft or published article into the editor. */
|
||||
const handleLoadItem = useCallback((item: ArticleData & { publishedAt?: number }, isPublishedArticle: boolean) => {
|
||||
setArticle({
|
||||
title: item.title,
|
||||
summary: item.summary,
|
||||
content: item.content,
|
||||
image: item.image,
|
||||
tags: item.tags,
|
||||
slug: item.slug,
|
||||
});
|
||||
slugManuallyEdited.current = !!item.slug;
|
||||
setIsEditMode(isPublishedArticle);
|
||||
setOriginalPublishedAt(item.publishedAt ?? null);
|
||||
setHasUnsavedChanges(false);
|
||||
setActiveTab('write');
|
||||
toast({
|
||||
title: isPublishedArticle ? 'Article loaded for editing' : 'Draft loaded',
|
||||
description: isPublishedArticle
|
||||
? 'Make changes and publish to update your article.'
|
||||
: 'Your draft has been loaded into the editor.',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteDraft = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
if (deleteTarget.isLocal) {
|
||||
setLocalDrafts(deleteLocalDraftById(deleteTarget.id));
|
||||
toast({ title: 'Draft deleted', description: 'Removed from your browser.' });
|
||||
} else {
|
||||
try {
|
||||
await deleteRelayDraft(deleteTarget.slug);
|
||||
toast({ title: 'Draft deleted', description: 'Deletion published to relays.' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
toast({ title: 'Delete failed', description: message || 'Could not delete draft.', variant: 'destructive' });
|
||||
}
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
}, [deleteTarget, deleteRelayDraft]);
|
||||
|
||||
const updateArticle = useCallback(
|
||||
(field: keyof ArticleData, value: string | string[]) => {
|
||||
setArticle((prev) => ({ ...prev, [field]: value }));
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAddTag = useCallback(() => {
|
||||
const newTag = tagInput.trim().toLowerCase().replace(/^#/, '');
|
||||
if (newTag && !article.tags.includes(newTag)) {
|
||||
setArticle((prev) => ({ ...prev, tags: [...prev.tags, newTag] }));
|
||||
setTagInput('');
|
||||
}
|
||||
}, [tagInput, article.tags]);
|
||||
|
||||
const handleRemoveTag = useCallback((tagToRemove: string) => {
|
||||
setArticle((prev) => ({
|
||||
...prev,
|
||||
tags: prev.tags.filter((tag) => tag !== tagToRemove),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleImageUpload = useCallback(
|
||||
async (file: File) => {
|
||||
try {
|
||||
const [[, url]] = await uploadFile(file);
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: 'Could not upload the image. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[uploadFile],
|
||||
);
|
||||
|
||||
const handleHeaderImageUpload = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const url = await handleImageUpload(file);
|
||||
if (url) {
|
||||
updateArticle('image', url);
|
||||
toast({
|
||||
title: 'Image uploaded',
|
||||
description: 'Header image has been set successfully.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleImageUpload, updateArticle],
|
||||
);
|
||||
|
||||
const handleSaveDraft = useCallback(async () => {
|
||||
await persistDraft(article);
|
||||
}, [article, persistDraft]);
|
||||
|
||||
/** Perform the actual publish (called directly or after overwrite confirmation). */
|
||||
const doPublish = useCallback(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Use original published_at when editing, current time for new articles
|
||||
const publishedAtTimestamp =
|
||||
isEditMode && originalPublishedAt
|
||||
? Math.floor(originalPublishedAt / 1000)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const tags: string[][] = [
|
||||
['d', article.slug || slugify(article.title, { lower: true, strict: true })],
|
||||
['title', article.title],
|
||||
['published_at', publishedAtTimestamp.toString()],
|
||||
];
|
||||
|
||||
if (article.summary) {
|
||||
tags.push(['summary', article.summary]);
|
||||
}
|
||||
|
||||
if (article.image) {
|
||||
tags.push(['image', article.image]);
|
||||
}
|
||||
|
||||
article.tags.forEach((tag) => {
|
||||
tags.push(['t', tag]);
|
||||
});
|
||||
|
||||
publishEvent(
|
||||
{
|
||||
kind: 30023,
|
||||
content: article.content,
|
||||
tags,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
setIsPublished(true);
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Remove draft after publishing
|
||||
if (article.slug) {
|
||||
deleteDraftBySlug(article.slug);
|
||||
try {
|
||||
await deleteRelayDraft(article.slug);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete draft from relay:', error);
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: isEditMode ? 'Article updated' : 'Article published',
|
||||
description: isEditMode
|
||||
? 'Your article has been updated on Nostr.'
|
||||
: 'Your article is now live on Nostr.',
|
||||
});
|
||||
|
||||
// Navigate back to articles feed
|
||||
navigate('/articles');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Publishing failed',
|
||||
description:
|
||||
error.message || 'Could not publish your article. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [
|
||||
user,
|
||||
article,
|
||||
publishEvent,
|
||||
deleteRelayDraft,
|
||||
isEditMode,
|
||||
originalPublishedAt,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'Login required',
|
||||
description: 'Please login to publish your article.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!article.title.trim()) {
|
||||
toast({
|
||||
title: 'Title required',
|
||||
description: 'Please add a title to your article.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!article.content.trim()) {
|
||||
toast({
|
||||
title: 'Content required',
|
||||
description: 'Please write some content for your article.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// In edit mode we're intentionally overwriting, so skip the collision check
|
||||
if (!isEditMode) {
|
||||
const slug = article.slug || slugify(article.title, { lower: true, strict: true });
|
||||
|
||||
try {
|
||||
const existing = await nostr.query([
|
||||
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
|
||||
]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
toast({
|
||||
title: 'Slug already in use',
|
||||
description: 'You already have a published article with this slug. Change the slug or edit the existing article from My Articles.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If the check fails (e.g. relay timeout), proceed anyway
|
||||
}
|
||||
}
|
||||
|
||||
doPublish();
|
||||
}, [user, article, isEditMode, nostr, doPublish]);
|
||||
|
||||
// Set refs for keyboard shortcuts
|
||||
handlePublishRef.current = handlePublish;
|
||||
handleSaveDraftRef.current = handleSaveDraft;
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSaveDraftRef.current?.();
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && user) {
|
||||
e.preventDefault();
|
||||
handlePublishRef.current?.();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [user]);
|
||||
|
||||
// Handle back navigation with unsaved changes check
|
||||
const handleBack = useCallback(() => {
|
||||
if (hasUnsavedChanges && (article.title || article.content)) {
|
||||
setShowLeaveDialog(true);
|
||||
} else {
|
||||
navigate('/articles');
|
||||
}
|
||||
}, [hasUnsavedChanges, article.title, article.content, navigate]);
|
||||
|
||||
const handleLeaveWithoutSaving = useCallback(() => {
|
||||
setShowLeaveDialog(false);
|
||||
navigate('/articles');
|
||||
}, [navigate]);
|
||||
|
||||
const handleSaveAndLeave = useCallback(async () => {
|
||||
await handleSaveDraft();
|
||||
setShowLeaveDialog(false);
|
||||
navigate('/articles');
|
||||
}, [handleSaveDraft, navigate]);
|
||||
|
||||
const statusLabel = isPublished ? (
|
||||
<span className="text-green-600 dark:text-green-400 text-sm">
|
||||
{isEditMode ? 'Updated' : 'Published'}
|
||||
</span>
|
||||
) : isEditMode ? (
|
||||
hasUnsavedChanges ? (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||
Editing
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-blue-600 dark:text-blue-400 text-sm">Editing</span>
|
||||
)
|
||||
) : hasUnsavedChanges ? (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||
Unsaved
|
||||
</span>
|
||||
) : lastSaved ? (
|
||||
<span className="text-sm text-muted-foreground">Saved</span>
|
||||
) : null;
|
||||
|
||||
const totalDrafts = combinedDrafts.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Sticky header */}
|
||||
<div className="sticky top-0 z-20">
|
||||
<SubHeaderBar pinned className="relative !top-0">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="pl-3 pr-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
|
||||
<TabButton
|
||||
label="New"
|
||||
active={activeTab === 'write'}
|
||||
onClick={() => setActiveTab('write')}
|
||||
/>
|
||||
|
||||
<TabButton
|
||||
label="My Articles"
|
||||
active={activeTab === 'drafts'}
|
||||
onClick={() => setActiveTab('drafts')}
|
||||
/>
|
||||
</SubHeaderBar>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleHeaderImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
{/* ── New article tab ──────────────────────────────────────── */}
|
||||
{activeTab === 'write' && (
|
||||
<div className="px-4 py-6 pb-24 space-y-6">
|
||||
{/* Header Image */}
|
||||
{article.image ? (
|
||||
<div className="relative rounded-xl overflow-hidden group">
|
||||
<img
|
||||
src={article.image}
|
||||
alt="Header"
|
||||
className="w-full h-48 sm:h-64 object-cover"
|
||||
/>
|
||||
{/* Desktop: centered overlay on hover */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors hidden sm:flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Image className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Change Image
|
||||
</Button>
|
||||
</div>
|
||||
{/* Mobile: persistent corner button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 rounded-full shadow-md sm:hidden"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Image className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="w-full h-32 border-2 border-dashed border-border rounded-xl flex flex-col items-center justify-center text-muted-foreground hover:border-primary/50 hover:text-primary/70 transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-8 h-8 animate-spin mb-2" />
|
||||
) : (
|
||||
<Image className="w-8 h-8 mb-2" />
|
||||
)}
|
||||
<span className="text-sm">Add a header image</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<input
|
||||
type="text"
|
||||
value={article.title}
|
||||
onChange={(e) => updateArticle('title', e.target.value)}
|
||||
placeholder="Your article title..."
|
||||
className="w-full text-3xl sm:text-4xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40"
|
||||
/>
|
||||
|
||||
{/* Metadata — inline between title and body */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="summary" className="text-muted-foreground text-sm">Summary</Label>
|
||||
<Textarea
|
||||
id="summary"
|
||||
value={article.summary}
|
||||
onChange={(e) => updateArticle('summary', e.target.value)}
|
||||
placeholder="A brief description of your article..."
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={article.slug}
|
||||
onChange={(e) => {
|
||||
slugManuallyEdited.current = true;
|
||||
updateArticle('slug', e.target.value);
|
||||
}}
|
||||
placeholder="article-url-slug"
|
||||
className="h-8 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
|
||||
<Hash className="w-3 h-3 shrink-0" />
|
||||
Tags
|
||||
</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
|
||||
}
|
||||
placeholder="Add a tag..."
|
||||
className="h-8 text-xs flex-1"
|
||||
/>
|
||||
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
|
||||
<span className="text-base leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{article.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{article.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-1">
|
||||
#{tag}
|
||||
<button onClick={() => handleRemoveTag(tag)} className="ml-1 hover:text-destructive">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<MilkdownEditor
|
||||
value={article.content}
|
||||
onChange={(value) => updateArticle('content', value || '')}
|
||||
onUploadImage={handleImageUpload}
|
||||
placeholder="Start writing your article..."
|
||||
className="rounded-xl border border-border bg-card min-h-[250px] sm:min-h-[400px]"
|
||||
/>
|
||||
|
||||
{/* Stats + Save */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground min-w-0">
|
||||
<span className="shrink-0">{wordCount} words</span>
|
||||
<span>·</span>
|
||||
<span className="shrink-0">{readingTime} min read</span>
|
||||
{statusLabel && (
|
||||
<>
|
||||
<span>·</span>
|
||||
{statusLabel}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveDraft}
|
||||
className="rounded-full gap-1.5 shrink-0"
|
||||
>
|
||||
<Save className="size-3.5" />
|
||||
Save Draft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Drafts tab ───────────────────────────────────────────── */}
|
||||
{activeTab === 'drafts' && (
|
||||
<div className="px-4 py-4">
|
||||
{isDraftsLoading && user ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">Loading drafts...</p>
|
||||
</div>
|
||||
) : totalDrafts === 0 && publishedArticles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<FileText className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-muted-foreground">No drafts or articles yet</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-1">
|
||||
Save a draft or publish to see content here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Drafts section */}
|
||||
{totalDrafts > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground px-1">Drafts ({totalDrafts})</h3>
|
||||
{combinedDrafts.map((draft) => (
|
||||
<div
|
||||
key={draft.id}
|
||||
className="group p-4 rounded-xl border border-border hover:border-primary/30 hover:bg-card transition-all cursor-pointer"
|
||||
onClick={() => handleLoadItem(draft, false)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium truncate">
|
||||
{draft.title || 'Untitled Draft'}
|
||||
</h3>
|
||||
{draft.summary && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-1">
|
||||
{draft.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
|
||||
{draft.isLocal ? (
|
||||
<HardDrive className="w-3 h-3 shrink-0" />
|
||||
) : (
|
||||
<Cloud className="w-3 h-3 text-primary shrink-0" />
|
||||
)}
|
||||
<Clock className="w-3 h-3 shrink-0" />
|
||||
<span>{formatDistanceToNow(draft.updatedAt, { addSuffix: true })}</span>
|
||||
{draft.tags.length > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{draft.tags.length} tags</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
className="p-1 rounded-full text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget({ id: draft.id, slug: draft.slug, isLocal: draft.isLocal });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Published articles section */}
|
||||
{publishedArticles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground px-1">Published ({publishedArticles.length})</h3>
|
||||
{publishedArticles.map((pub) => (
|
||||
<div
|
||||
key={pub.id}
|
||||
className="group p-4 rounded-xl border border-border hover:border-green-500/30 hover:bg-card transition-all cursor-pointer"
|
||||
onClick={() => handleLoadItem(pub, true)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium truncate">
|
||||
{pub.title || 'Untitled Article'}
|
||||
</h3>
|
||||
{pub.summary && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-1">
|
||||
{pub.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3 shrink-0" />
|
||||
<span>Published {formatDistanceToNow(pub.publishedAt, { addSuffix: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish FAB — mobile: fixed bottom right */}
|
||||
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
|
||||
<FabButton
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing || !user}
|
||||
title={isEditMode ? 'Update article' : 'Publish article'}
|
||||
icon={isPublishing
|
||||
? <Loader2 size={18} className="animate-spin" />
|
||||
: <Send strokeWidth={3} size={18} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Publish FAB — desktop: sticky within column */}
|
||||
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
|
||||
<div className="flex justify-end pr-4">
|
||||
<div className="pointer-events-auto">
|
||||
<FabButton
|
||||
onClick={handlePublish}
|
||||
disabled={isPublishing || !user}
|
||||
title={isEditMode ? 'Update article' : 'Publish article'}
|
||||
icon={isPublishing
|
||||
? <Loader2 size={18} className="animate-spin" />
|
||||
: <Send strokeWidth={3} size={18} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leave Confirmation Dialog */}
|
||||
<AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You have unsaved changes. Would you like to save your draft before
|
||||
leaving?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<AlertDialogCancel onClick={() => setShowLeaveDialog(false)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<Button variant="outline" onClick={handleLeaveWithoutSaving}>
|
||||
Discard
|
||||
</Button>
|
||||
<AlertDialogAction onClick={handleSaveAndLeave}>
|
||||
Save Draft
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Delete Draft Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete draft?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget?.isLocal
|
||||
? 'This draft will be permanently deleted from your browser.'
|
||||
: 'This draft will be deleted from Nostr relays.'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteDraft}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface LinkDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedText?: string;
|
||||
onSubmit: (text: string, url: string) => void;
|
||||
}
|
||||
|
||||
export function LinkDialog({ open, onOpenChange, selectedText, onSubmit }: LinkDialogProps) {
|
||||
const [text, setText] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setText(selectedText || '');
|
||||
setUrl('');
|
||||
}
|
||||
}, [open, selectedText]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (url.trim()) {
|
||||
const finalText = text.trim() || url.trim();
|
||||
let finalUrl = url.trim();
|
||||
|
||||
// Add https:// if no protocol specified
|
||||
if (!/^https?:\/\//i.test(finalUrl)) {
|
||||
finalUrl = 'https://' + finalUrl;
|
||||
}
|
||||
|
||||
onSubmit(finalText, finalUrl);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasSelectedText = !!selectedText;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Link</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{!hasSelectedText && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-text">Link Text</Label>
|
||||
<Input
|
||||
id="link-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Enter link text..."
|
||||
autoFocus={!hasSelectedText}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasSelectedText && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">Link Text</Label>
|
||||
<p className="text-sm bg-muted px-3 py-2 rounded-md">{selectedText}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-url">URL</Label>
|
||||
<Input
|
||||
id="link-url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
autoFocus={hasSelectedText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!url.trim()}>
|
||||
Insert Link
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
|
||||
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
|
||||
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, insertHrCommand, turnIntoTextCommand, wrapInHeadingCommand, toggleInlineCodeCommand, wrapInBulletListCommand, wrapInOrderedListCommand } from '@milkdown/preset-commonmark';
|
||||
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm';
|
||||
import { history } from '@milkdown/plugin-history';
|
||||
import { clipboard } from '@milkdown/plugin-clipboard';
|
||||
import { listener, listenerCtx } from '@milkdown/plugin-listener';
|
||||
import { upload, uploadConfig } from '@milkdown/plugin-upload';
|
||||
import { Decoration } from '@milkdown/prose/view';
|
||||
import { replaceAll, callCommand } from '@milkdown/utils';
|
||||
import { MilkdownToolbar } from './MilkdownToolbar';
|
||||
import { LinkDialog } from './LinkDialog';
|
||||
|
||||
interface MilkdownEditorInnerProps {
|
||||
value: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onUploadImage?: (file: File) => Promise<string | null>;
|
||||
placeholder?: string;
|
||||
showToolbar?: boolean;
|
||||
sourceMode?: boolean;
|
||||
onToggleSource?: () => void;
|
||||
}
|
||||
|
||||
function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, showToolbar = true, sourceMode, onToggleSource }: MilkdownEditorInnerProps) {
|
||||
const initialValueRef = useRef(value);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const lastExternalValue = useRef(value);
|
||||
const onUploadImageRef = useRef(onUploadImage);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Link dialog state
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
|
||||
const selectionRef = useRef<{ from: number; to: number } | null>(null);
|
||||
|
||||
// Keep ref updated
|
||||
useEffect(() => {
|
||||
onUploadImageRef.current = onUploadImage;
|
||||
}, [onUploadImage]);
|
||||
|
||||
const { get } = useEditor((root) => {
|
||||
const editor = Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root);
|
||||
ctx.set(defaultValueCtx, initialValueRef.current);
|
||||
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
|
||||
lastExternalValue.current = markdown;
|
||||
onChange(markdown);
|
||||
});
|
||||
|
||||
// Configure upload plugin
|
||||
ctx.set(uploadConfig.key, {
|
||||
uploader: async (files, schema) => {
|
||||
const images: File[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files.item(i);
|
||||
if (!file) continue;
|
||||
|
||||
// Only handle images
|
||||
if (!file.type.includes('image')) continue;
|
||||
|
||||
images.push(file);
|
||||
}
|
||||
|
||||
const nodes: ReturnType<typeof schema.nodes.image.createAndFill>[] = [];
|
||||
|
||||
for (const image of images) {
|
||||
try {
|
||||
// Use the upload handler if provided
|
||||
if (onUploadImageRef.current) {
|
||||
const url = await onUploadImageRef.current(image);
|
||||
if (url) {
|
||||
const node = schema.nodes.image.createAndFill({
|
||||
src: url,
|
||||
alt: image.name,
|
||||
});
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
} else {
|
||||
// Fallback to base64 if no upload handler
|
||||
const reader = new FileReader();
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(image);
|
||||
});
|
||||
const node = schema.nodes.image.createAndFill({
|
||||
src: dataUrl,
|
||||
alt: image.name,
|
||||
});
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.filter((node): node is NonNullable<typeof node> => node !== null);
|
||||
},
|
||||
enableHtmlFileUploader: true,
|
||||
uploadWidgetFactory: (pos, spec) => {
|
||||
// Create a placeholder widget while uploading
|
||||
const widgetEl = document.createElement('span');
|
||||
widgetEl.className = 'milkdown-upload-placeholder';
|
||||
widgetEl.textContent = 'Uploading...';
|
||||
return Decoration.widget(pos, widgetEl, spec);
|
||||
},
|
||||
});
|
||||
})
|
||||
.use(commonmark)
|
||||
.use(gfm)
|
||||
.use(history)
|
||||
.use(clipboard)
|
||||
.use(listener)
|
||||
.use(upload);
|
||||
|
||||
return editor;
|
||||
});
|
||||
|
||||
// Store editor reference
|
||||
useEffect(() => {
|
||||
editorRef.current = get() ?? null;
|
||||
}, [get]);
|
||||
|
||||
// Toggle `has-content` class on blur so CSS can hide the placeholder
|
||||
// when the editor has real content (including trailing whitespace that
|
||||
// ProseMirror collapses out of the DOM).
|
||||
useEffect(() => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
let dom: HTMLElement;
|
||||
try {
|
||||
dom = editor.ctx.get(editorViewCtx).dom;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const check = () => {
|
||||
const hasContent = !!lastExternalValue.current.replace(/\n/g, '');
|
||||
dom.classList.toggle('has-content', hasContent);
|
||||
};
|
||||
// Set initial state
|
||||
check();
|
||||
dom.addEventListener('blur', check);
|
||||
return () => dom.removeEventListener('blur', check);
|
||||
}, [get]);
|
||||
|
||||
// Handle external value changes (e.g., loading a draft)
|
||||
useEffect(() => {
|
||||
const editor = get();
|
||||
if (editor && value !== lastExternalValue.current) {
|
||||
// Only update if the value changed externally (not from user typing)
|
||||
editor.action(replaceAll(value));
|
||||
lastExternalValue.current = value;
|
||||
}
|
||||
}, [value, get]);
|
||||
|
||||
// Handle link dialog open
|
||||
const handleLinkButtonClick = useCallback(() => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
const { state } = view;
|
||||
const { from, to } = state.selection;
|
||||
const selectedText = state.doc.textBetween(from, to);
|
||||
|
||||
// Store selection for later use
|
||||
selectionRef.current = { from, to };
|
||||
setSelectedTextForLink(selectedText);
|
||||
setLinkDialogOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to get selection:', error);
|
||||
}
|
||||
}, [get]);
|
||||
|
||||
// Handle link insertion from dialog
|
||||
const handleLinkSubmit = useCallback((text: string, url: string) => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
const { state, dispatch } = view;
|
||||
const { schema } = state;
|
||||
|
||||
// Create a link mark
|
||||
const linkMark = schema.marks.link.create({ href: url });
|
||||
|
||||
// Create text node with link mark
|
||||
const linkNode = schema.text(text, [linkMark]);
|
||||
|
||||
const tr = state.tr;
|
||||
|
||||
if (selectionRef.current) {
|
||||
const { from, to } = selectionRef.current;
|
||||
// Replace selection with linked text
|
||||
tr.replaceWith(from, to, linkNode);
|
||||
} else {
|
||||
// Insert at current position
|
||||
const { from } = state.selection;
|
||||
tr.insert(from, linkNode);
|
||||
}
|
||||
|
||||
dispatch(tr);
|
||||
view.focus();
|
||||
} catch (error) {
|
||||
console.error('Failed to insert link:', error);
|
||||
}
|
||||
}, [get]);
|
||||
|
||||
// Handle image upload via file picker + ProseMirror insertion
|
||||
const handleImageButtonClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleImageFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !onUploadImageRef.current) return;
|
||||
|
||||
const url = await onUploadImageRef.current(file);
|
||||
if (!url) return;
|
||||
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
const { state, dispatch } = view;
|
||||
const { schema } = state;
|
||||
const node = schema.nodes.image.createAndFill({ src: url, alt: file.name });
|
||||
if (node) {
|
||||
const { from } = state.selection;
|
||||
dispatch(state.tr.insert(from, node));
|
||||
view.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to insert image:', error);
|
||||
}
|
||||
|
||||
// Reset so the same file can be re-selected
|
||||
e.target.value = '';
|
||||
}, [get]);
|
||||
|
||||
// Handle toolbar commands
|
||||
const handleCommand = useCallback((command: string) => {
|
||||
const editor = get();
|
||||
if (!editor) return;
|
||||
|
||||
try {
|
||||
const view = editor.ctx.get(editorViewCtx);
|
||||
|
||||
switch (command) {
|
||||
case 'toggleBold':
|
||||
editor.action(callCommand(toggleStrongCommand.key));
|
||||
break;
|
||||
case 'toggleItalic':
|
||||
editor.action(callCommand(toggleEmphasisCommand.key));
|
||||
break;
|
||||
case 'toggleStrikethrough':
|
||||
editor.action(callCommand(toggleStrikethroughCommand.key));
|
||||
break;
|
||||
case 'toggleInlineCode':
|
||||
editor.action(callCommand(toggleInlineCodeCommand.key));
|
||||
break;
|
||||
case 'heading1':
|
||||
editor.action(callCommand(wrapInHeadingCommand.key, 1));
|
||||
break;
|
||||
case 'heading2':
|
||||
editor.action(callCommand(wrapInHeadingCommand.key, 2));
|
||||
break;
|
||||
case 'heading3':
|
||||
editor.action(callCommand(wrapInHeadingCommand.key, 3));
|
||||
break;
|
||||
case 'bulletList':
|
||||
editor.action(callCommand(wrapInBulletListCommand.key));
|
||||
break;
|
||||
case 'orderedList':
|
||||
editor.action(callCommand(wrapInOrderedListCommand.key));
|
||||
break;
|
||||
case 'blockquote':
|
||||
editor.action(callCommand(wrapInBlockquoteCommand.key));
|
||||
break;
|
||||
case 'link':
|
||||
handleLinkButtonClick();
|
||||
return; // Don't refocus, dialog will handle it
|
||||
case 'hr':
|
||||
editor.action(callCommand(insertHrCommand.key));
|
||||
break;
|
||||
case 'paragraph':
|
||||
editor.action(callCommand(turnIntoTextCommand.key));
|
||||
break;
|
||||
}
|
||||
|
||||
// Refocus the editor
|
||||
view.focus();
|
||||
} catch (error) {
|
||||
console.error('Command failed:', error);
|
||||
}
|
||||
}, [get, handleLinkButtonClick]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showToolbar && (
|
||||
<MilkdownToolbar
|
||||
onCommand={handleCommand}
|
||||
onImageUpload={onUploadImage ? handleImageButtonClick : undefined}
|
||||
sourceMode={sourceMode}
|
||||
onToggleSource={onToggleSource}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
{sourceMode ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full min-h-[250px] sm:min-h-[350px] p-3 bg-transparent font-mono text-sm resize-y outline-none"
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="milkdown-content"
|
||||
style={placeholder ? { '--ph': `"${placeholder.replace(/"/g, '\\"')}"` } as React.CSSProperties : undefined}
|
||||
>
|
||||
<Milkdown />
|
||||
</div>
|
||||
)}
|
||||
<LinkDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
selectedText={selectedTextForLink}
|
||||
onSubmit={handleLinkSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface MilkdownEditorProps {
|
||||
value: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onUploadImage?: (file: File) => Promise<string | null>;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
showToolbar?: boolean;
|
||||
}
|
||||
|
||||
export function MilkdownEditor({ value, onChange, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
|
||||
const [sourceMode, setSourceMode] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`milkdown-editor ${className || ''}`}>
|
||||
<MilkdownProvider>
|
||||
<MilkdownEditorInner
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onUploadImage={onUploadImage}
|
||||
placeholder={placeholder}
|
||||
showToolbar={showToolbar}
|
||||
sourceMode={sourceMode}
|
||||
onToggleSource={() => setSourceMode((s) => !s)}
|
||||
/>
|
||||
</MilkdownProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Code,
|
||||
Link,
|
||||
Image,
|
||||
Minus,
|
||||
HelpCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function MarkdownHelpPopover() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="end">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">Markdown Quick Reference</h4>
|
||||
<div className="text-xs space-y-1.5 font-mono text-muted-foreground">
|
||||
<div className="flex justify-between"><span>**bold**</span><span className="font-sans font-bold">bold</span></div>
|
||||
<div className="flex justify-between"><span>*italic*</span><span className="font-sans italic">italic</span></div>
|
||||
<div className="flex justify-between"><span># Heading 1</span><span className="font-sans">H1</span></div>
|
||||
<div className="flex justify-between"><span>## Heading 2</span><span className="font-sans">H2</span></div>
|
||||
<div className="flex justify-between"><span>- list item</span><span className="font-sans">* item</span></div>
|
||||
<div className="flex justify-between"><span>1. numbered</span><span className="font-sans">1. item</span></div>
|
||||
<div className="flex justify-between"><span>[text](url)</span><span className="font-sans text-primary">link</span></div>
|
||||
<div className="flex justify-between"><span></span><span className="font-sans">image</span></div>
|
||||
<div className="flex justify-between"><span>> quote</span><span className="font-sans border-l-2 pl-1">quote</span></div>
|
||||
<div className="flex justify-between"><span>`code`</span><span className="font-sans bg-muted px-1 rounded">code</span></div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pt-2 border-t">
|
||||
Drag & drop or paste images to upload
|
||||
</p>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const hasPointerFine = typeof window !== 'undefined'
|
||||
&& window.matchMedia('(pointer: fine)').matches;
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButtonProps) {
|
||||
const button = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"h-8 w-8 text-muted-foreground hover:text-foreground",
|
||||
active && "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!hasPointerFine) return button;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span>{label}</span>
|
||||
{shortcut && <span className="ml-2 text-muted-foreground text-xs">{shortcut}</span>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface MilkdownToolbarProps {
|
||||
onCommand: (command: string) => void;
|
||||
onImageUpload?: () => void;
|
||||
sourceMode?: boolean;
|
||||
onToggleSource?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MilkdownToolbar({ onCommand, onImageUpload, sourceMode, onToggleSource, className }: MilkdownToolbarProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-center gap-0.5 p-1.5 border-b border-border bg-card/95 backdrop-blur-sm flex-wrap sticky top-0 z-10 rounded-t-xl",
|
||||
className
|
||||
)}>
|
||||
{!sourceMode && (
|
||||
<>
|
||||
{/* Text formatting */}
|
||||
<ToolbarButton
|
||||
icon={<Bold className="h-4 w-4" />}
|
||||
label="Bold"
|
||||
shortcut="Ctrl+B"
|
||||
onClick={() => onCommand('toggleBold')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Italic className="h-4 w-4" />}
|
||||
label="Italic"
|
||||
shortcut="Ctrl+I"
|
||||
onClick={() => onCommand('toggleItalic')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Strikethrough className="h-4 w-4" />}
|
||||
label="Strikethrough"
|
||||
onClick={() => onCommand('toggleStrikethrough')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Code className="h-4 w-4" />}
|
||||
label="Inline Code"
|
||||
onClick={() => onCommand('toggleInlineCode')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarButton
|
||||
icon={<Heading1 className="h-4 w-4" />}
|
||||
label="Heading 1"
|
||||
onClick={() => onCommand('heading1')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Heading2 className="h-4 w-4" />}
|
||||
label="Heading 2"
|
||||
onClick={() => onCommand('heading2')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Heading3 className="h-4 w-4" />}
|
||||
label="Heading 3"
|
||||
onClick={() => onCommand('heading3')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarButton
|
||||
icon={<List className="h-4 w-4" />}
|
||||
label="Bullet List"
|
||||
onClick={() => onCommand('bulletList')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<ListOrdered className="h-4 w-4" />}
|
||||
label="Numbered List"
|
||||
onClick={() => onCommand('orderedList')}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Quote className="h-4 w-4" />}
|
||||
label="Blockquote"
|
||||
onClick={() => onCommand('blockquote')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
{/* Links and media */}
|
||||
<ToolbarButton
|
||||
icon={<Link className="h-4 w-4" />}
|
||||
label="Insert Link"
|
||||
onClick={() => onCommand('link')}
|
||||
/>
|
||||
{onImageUpload && (
|
||||
<ToolbarButton
|
||||
icon={<Image className="h-4 w-4" />}
|
||||
label="Insert Image"
|
||||
onClick={onImageUpload}
|
||||
/>
|
||||
)}
|
||||
<ToolbarButton
|
||||
icon={<Minus className="h-4 w-4" />}
|
||||
label="Horizontal Rule"
|
||||
onClick={() => onCommand('hr')}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-1 h-6" />
|
||||
|
||||
<MarkdownHelpPopover />
|
||||
</>
|
||||
)}
|
||||
|
||||
{sourceMode && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground px-1.5">Markdown Source</span>
|
||||
<span className="flex-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{onToggleSource && (
|
||||
<ToolbarButton
|
||||
icon={sourceMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
label={sourceMode ? 'Rich text editor' : 'Markdown source'}
|
||||
active={sourceMode}
|
||||
onClick={onToggleSource}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { type ArticleFields } from '@/lib/articleHelpers';
|
||||
|
||||
/** Kind 31234 — NIP-37 Draft Wrap. */
|
||||
const DRAFT_WRAP_KIND = 31234;
|
||||
/** The inner draft kind we're wrapping. */
|
||||
const ARTICLE_KIND = 30023;
|
||||
|
||||
export interface Draft extends ArticleFields {
|
||||
id: string;
|
||||
updatedAt: number;
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
type DraftData = ArticleFields;
|
||||
|
||||
/** Build an unsigned kind-30023 event object from draft data. */
|
||||
function buildInnerDraftEvent(draft: DraftData): Record<string, unknown> {
|
||||
const tags: string[][] = [
|
||||
['d', draft.slug],
|
||||
['title', draft.title],
|
||||
];
|
||||
|
||||
if (draft.summary) tags.push(['summary', draft.summary]);
|
||||
if (draft.image) tags.push(['image', draft.image]);
|
||||
draft.tags.forEach(tag => tags.push(['t', tag]));
|
||||
|
||||
return {
|
||||
kind: ARTICLE_KIND,
|
||||
content: draft.content,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a decrypted inner draft event back into a Draft. */
|
||||
function parseDraftPayload(inner: Record<string, unknown>, wrapEvent: NostrEvent): Draft | null {
|
||||
const tags = (inner.tags ?? []) as string[][];
|
||||
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
|
||||
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
|
||||
|
||||
return {
|
||||
id: wrapEvent.id,
|
||||
eventId: wrapEvent.id,
|
||||
title: getTag('title'),
|
||||
summary: getTag('summary'),
|
||||
content: (inner.content as string) || '',
|
||||
image: getTag('image'),
|
||||
tags: getTags('t'),
|
||||
slug: getTag('d'),
|
||||
updatedAt: wrapEvent.created_at * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDrafts() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
// Query and decrypt drafts from relay
|
||||
const query = useQuery<Draft[]>({
|
||||
queryKey: ['drafts', user?.pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey || !user.signer.nip44) return [];
|
||||
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [DRAFT_WRAP_KIND], authors: [user.pubkey], '#k': [String(ARTICLE_KIND)], limit: 100 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
|
||||
const drafts: Draft[] = [];
|
||||
|
||||
for (const event of events) {
|
||||
// Blank content means deleted
|
||||
if (!event.content.trim()) continue;
|
||||
|
||||
try {
|
||||
const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content);
|
||||
const inner = JSON.parse(decrypted) as Record<string, unknown>;
|
||||
const draft = parseDraftPayload(inner, event);
|
||||
if (draft && draft.content.trim()) drafts.push(draft);
|
||||
} catch {
|
||||
// Skip events that fail to decrypt or parse
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
},
|
||||
enabled: !!user?.pubkey && !!user?.signer.nip44,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
// Save draft: encrypt inner event and publish as kind 31234
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (draft: DraftData) => {
|
||||
if (!user?.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
|
||||
|
||||
const inner = buildInnerDraftEvent(draft);
|
||||
const plaintext = JSON.stringify(inner);
|
||||
const encrypted = await user.signer.nip44.encrypt(user.pubkey, plaintext);
|
||||
|
||||
return publishEvent({
|
||||
kind: DRAFT_WRAP_KIND,
|
||||
content: encrypted,
|
||||
tags: [
|
||||
['d', draft.slug],
|
||||
['k', String(ARTICLE_KIND)],
|
||||
],
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['drafts', user?.pubkey] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete draft (publish kind 5 deletion event)
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (slug: string) => {
|
||||
if (!user) throw new Error('User is not logged in');
|
||||
|
||||
const event = await publishEvent({
|
||||
kind: 5,
|
||||
content: '',
|
||||
tags: [['a', `${DRAFT_WRAP_KIND}:${user.pubkey}:${slug}`]],
|
||||
});
|
||||
return { event, slug };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['drafts', user?.pubkey], (oldData: Draft[] | undefined) => {
|
||||
if (!oldData) return [];
|
||||
return oldData.filter(d => d.slug !== data?.slug);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
drafts: query.data || [],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
saveDraft: saveMutation.mutateAsync,
|
||||
isSaving: saveMutation.isPending,
|
||||
deleteDraft: deleteMutation.mutateAsync,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { parseArticleEvent, type ArticleFields } from '@/lib/articleHelpers';
|
||||
|
||||
export interface PublishedArticle extends ArticleFields {
|
||||
id: string;
|
||||
eventId: string;
|
||||
publishedAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function eventToArticle(event: NostrEvent): PublishedArticle {
|
||||
const parsed = parseArticleEvent(event);
|
||||
return {
|
||||
...parsed,
|
||||
id: event.id,
|
||||
eventId: event.id,
|
||||
updatedAt: event.created_at * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePublishedArticles() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const query = useQuery<PublishedArticle[]>({
|
||||
queryKey: ['published-articles', user?.pubkey ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!user?.pubkey) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30023], authors: [user.pubkey], limit: 100 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
|
||||
return events
|
||||
.filter(e => e.content.trim().length > 0)
|
||||
.map(eventToArticle)
|
||||
.sort((a, b) => b.publishedAt - a.publishedAt);
|
||||
},
|
||||
enabled: !!user?.pubkey,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
articles: query.data || [],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
+158
@@ -494,3 +494,161 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Milkdown Editor Styles ─────────────────────────────────────────────────── */
|
||||
|
||||
.milkdown-editor .milkdown {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
.milkdown-editor .editor {
|
||||
@apply outline-none min-h-[400px] p-3;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.milkdown-editor .ProseMirror {
|
||||
@apply outline-none min-h-[400px];
|
||||
}
|
||||
|
||||
.milkdown-editor .ProseMirror:focus {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.milkdown-editor h1 {
|
||||
@apply text-3xl font-bold mt-6 mb-4;
|
||||
}
|
||||
|
||||
.milkdown-editor h2 {
|
||||
@apply text-2xl font-semibold mt-5 mb-3;
|
||||
}
|
||||
|
||||
.milkdown-editor h3 {
|
||||
@apply text-xl font-semibold mt-4 mb-2;
|
||||
}
|
||||
|
||||
.milkdown-editor h4 {
|
||||
@apply text-lg font-medium mt-3 mb-2;
|
||||
}
|
||||
|
||||
/* Inline styles */
|
||||
.milkdown-editor strong {
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
.milkdown-editor em {
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
.milkdown-editor del {
|
||||
@apply line-through text-muted-foreground;
|
||||
}
|
||||
|
||||
.milkdown-editor code {
|
||||
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono;
|
||||
}
|
||||
|
||||
/* Block elements */
|
||||
.milkdown-editor p {
|
||||
@apply my-1.5;
|
||||
}
|
||||
|
||||
.milkdown-editor blockquote {
|
||||
@apply border-l-4 border-primary/40 pl-4 my-4 italic text-muted-foreground;
|
||||
}
|
||||
|
||||
.milkdown-editor pre {
|
||||
@apply bg-muted rounded-lg p-4 my-4 overflow-x-auto;
|
||||
}
|
||||
|
||||
.milkdown-editor pre code {
|
||||
@apply bg-transparent p-0 font-mono;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.milkdown-editor ul {
|
||||
@apply list-disc list-inside my-3 space-y-1;
|
||||
}
|
||||
|
||||
.milkdown-editor ol {
|
||||
@apply list-decimal list-inside my-3 space-y-1;
|
||||
}
|
||||
|
||||
.milkdown-editor li {
|
||||
@apply pl-1;
|
||||
}
|
||||
|
||||
.milkdown-editor li p {
|
||||
@apply inline my-0;
|
||||
}
|
||||
|
||||
/* Task lists (GFM) */
|
||||
.milkdown-editor ul.task-list {
|
||||
@apply list-none pl-0;
|
||||
}
|
||||
|
||||
.milkdown-editor li.task-list-item {
|
||||
@apply flex items-start gap-2 pl-0;
|
||||
}
|
||||
|
||||
.milkdown-editor li.task-list-item input[type="checkbox"] {
|
||||
@apply mt-1.5 h-4 w-4 rounded border-border;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.milkdown-editor a {
|
||||
@apply text-primary underline underline-offset-2 hover:text-primary/80 transition-colors;
|
||||
}
|
||||
|
||||
/* Horizontal rule */
|
||||
.milkdown-editor hr {
|
||||
@apply my-6 border-border;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.milkdown-editor img {
|
||||
@apply max-w-full h-auto rounded-lg my-4;
|
||||
}
|
||||
|
||||
/* Tables (GFM) */
|
||||
.milkdown-editor table {
|
||||
@apply w-full border-collapse my-4;
|
||||
}
|
||||
|
||||
.milkdown-editor th,
|
||||
.milkdown-editor td {
|
||||
@apply border border-border px-3 py-2 text-left;
|
||||
}
|
||||
|
||||
.milkdown-editor th {
|
||||
@apply bg-muted font-semibold;
|
||||
}
|
||||
|
||||
.milkdown-editor tr:nth-child(even) {
|
||||
@apply bg-muted/30;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Upload placeholder */
|
||||
.milkdown-upload-placeholder {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-md text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
.milkdown-upload-placeholder::before {
|
||||
content: '';
|
||||
@apply w-4 h-4 border-2 border-muted-foreground/30 border-t-primary rounded-full animate-spin;
|
||||
}
|
||||
|
||||
/* Placeholder — only when unfocused AND content is empty. */
|
||||
.milkdown-editor .ProseMirror:not(:focus):not(.has-content) > p:first-child::before {
|
||||
@apply text-muted-foreground pointer-events-none float-left h-0;
|
||||
content: var(--ph, '');
|
||||
}
|
||||
|
||||
/* Milkdown content area */
|
||||
.milkdown-editor .milkdown-content .ProseMirror {
|
||||
@apply min-h-[350px];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/** Fields shared by drafts and published articles. */
|
||||
export interface ArticleFields {
|
||||
title: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract common article fields from a Nostr event's tags + content.
|
||||
* Works for kind 30023 (published) events and the inner event of NIP-37 draft wraps.
|
||||
*/
|
||||
export function parseArticleEvent(event: NostrEvent): ArticleFields & { publishedAt: number } {
|
||||
const getTag = (name: string) => event.tags.find(t => t[0] === name)?.[1] || '';
|
||||
const getTags = (name: string) => event.tags.filter(t => t[0] === name).map(t => t[1]);
|
||||
|
||||
const publishedAtTag = getTag('published_at');
|
||||
const publishedAt = publishedAtTag ? parseInt(publishedAtTag) * 1000 : event.created_at * 1000;
|
||||
|
||||
return {
|
||||
title: getTag('title'),
|
||||
summary: getTag('summary'),
|
||||
content: event.content,
|
||||
image: getTag('image'),
|
||||
tags: getTags('t'),
|
||||
slug: getTag('d'),
|
||||
publishedAt,
|
||||
};
|
||||
}
|
||||
@@ -128,8 +128,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
route: 'articles',
|
||||
addressable: true,
|
||||
section: 'feed',
|
||||
blurb: 'Blog posts, essays, and guides. Write and publish from a dedicated editor.',
|
||||
sites: [{ url: 'https://inkwell.shakespeare.wtf' }],
|
||||
blurb: 'Blog posts, essays, and guides. Write and publish long-form articles.',
|
||||
},
|
||||
// Media
|
||||
{
|
||||
@@ -479,7 +478,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
id: 'development',
|
||||
showKey: 'showDevelopment',
|
||||
feedKey: 'feedIncludeDevelopment',
|
||||
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267],
|
||||
extraFeedKinds: [1617, 1618, 30817, 15128, 35128, 32267, 31990],
|
||||
label: 'Development',
|
||||
description: 'Git repos, patches, PRs, nsites, apps, and custom NIPs',
|
||||
route: 'development',
|
||||
@@ -548,6 +547,7 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
|
||||
30008: 'profile badges',
|
||||
30817: 'repository issue',
|
||||
32267: 'app',
|
||||
31990: 'app',
|
||||
30063: 'release',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Draft } from '@/hooks/useDrafts';
|
||||
|
||||
const DRAFTS_KEY = 'article-drafts';
|
||||
|
||||
/** Save a draft to localStorage. Returns the draft ID or null on failure. */
|
||||
export function saveDraft(draft: Omit<Draft, 'id' | 'updatedAt'> & { id?: string }): string | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(DRAFTS_KEY);
|
||||
const drafts: Draft[] = stored ? JSON.parse(stored) : [];
|
||||
|
||||
const existingIndex = draft.id
|
||||
? drafts.findIndex(d => d.id === draft.id)
|
||||
: drafts.findIndex(d => d.slug === draft.slug);
|
||||
|
||||
const newDraft: Draft = {
|
||||
...draft,
|
||||
id: draft.id || (existingIndex >= 0 ? drafts[existingIndex].id : crypto.randomUUID()),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
drafts[existingIndex] = newDraft;
|
||||
} else {
|
||||
drafts.unshift(newDraft);
|
||||
}
|
||||
|
||||
// Keep only the 20 most recent drafts
|
||||
const trimmedDrafts = drafts.slice(0, 20);
|
||||
localStorage.setItem(DRAFTS_KEY, JSON.stringify(trimmedDrafts));
|
||||
|
||||
return newDraft.id;
|
||||
} catch (error) {
|
||||
console.error('Failed to save draft:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a draft by slug from localStorage. */
|
||||
export function deleteDraftBySlug(slug: string): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(DRAFTS_KEY);
|
||||
if (!stored) return;
|
||||
|
||||
const drafts: Draft[] = JSON.parse(stored);
|
||||
const filtered = drafts.filter(d => d.slug !== slug);
|
||||
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete draft:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a draft by id from localStorage. Returns the remaining drafts. */
|
||||
export function deleteLocalDraftById(id: string): Draft[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(DRAFTS_KEY);
|
||||
if (!stored) return [];
|
||||
|
||||
const drafts: Draft[] = JSON.parse(stored);
|
||||
const filtered = drafts.filter(d => d.id !== id);
|
||||
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
|
||||
return filtered;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete draft:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all local drafts. */
|
||||
export function getLocalDrafts(): Draft[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(DRAFTS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams, useParams } from 'react-router-dom';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { AddressPointer } from 'nostr-tools/nip19';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { ArticleEditor, type ArticleData } from '@/components/articles/ArticleEditor';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { getLocalDrafts } from '@/lib/localDrafts';
|
||||
import { parseArticleEvent } from '@/lib/articleHelpers';
|
||||
|
||||
/** Thin page wrapper for /articles/new and /articles/edit/:naddr */
|
||||
export function ArticleEditorPage() {
|
||||
useLayoutOptions({ showFAB: false, hasSubHeader: true });
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const { naddr: naddrParam } = useParams<{ naddr: string }>();
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const draftSlug = searchParams.get('draft');
|
||||
|
||||
const [initialData, setInitialData] = useState<(Partial<ArticleData> & { publishedAt?: number }) | undefined>(undefined);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [loading, setLoading] = useState(!!naddrParam || !!draftSlug);
|
||||
|
||||
// Load draft from relay (NIP-37 kind 31234, encrypted) or localStorage if ?draft=<slug>
|
||||
useEffect(() => {
|
||||
if (!draftSlug) return;
|
||||
|
||||
const loadDraft = async () => {
|
||||
if (user?.signer.nip44) {
|
||||
try {
|
||||
const events = await nostr.query([
|
||||
{ kinds: [31234], authors: [user.pubkey], '#d': [draftSlug], limit: 1 },
|
||||
]);
|
||||
if (events.length > 0 && events[0].content.trim()) {
|
||||
const decrypted = await user.signer.nip44.decrypt(user.pubkey, events[0].content);
|
||||
const inner = JSON.parse(decrypted) as Record<string, unknown>;
|
||||
const tags = (inner.tags ?? []) as string[][];
|
||||
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
|
||||
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
|
||||
setInitialData({
|
||||
title: getTag('title'),
|
||||
summary: getTag('summary'),
|
||||
content: (inner.content as string) || '',
|
||||
image: getTag('image'),
|
||||
tags: getTags('t'),
|
||||
slug: getTag('d'),
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to localStorage
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
const drafts = getLocalDrafts();
|
||||
const draft = drafts.find((d) => d.slug === draftSlug);
|
||||
if (draft) {
|
||||
setInitialData({
|
||||
title: draft.title,
|
||||
summary: draft.summary,
|
||||
content: draft.content,
|
||||
image: draft.image,
|
||||
tags: draft.tags,
|
||||
slug: draft.slug,
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadDraft();
|
||||
}, [draftSlug, user, nostr]);
|
||||
|
||||
// Load existing article for editing if /articles/edit/:naddr
|
||||
useEffect(() => {
|
||||
if (!naddrParam) return;
|
||||
|
||||
let decoded: { type: string; data: AddressPointer };
|
||||
try {
|
||||
decoded = nip19.decode(naddrParam) as { type: 'naddr'; data: AddressPointer };
|
||||
if (decoded.type !== 'naddr') {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const addr = decoded.data;
|
||||
|
||||
// Only allow editing your own articles
|
||||
if (user && addr.pubkey !== user.pubkey) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
nostr
|
||||
.query([
|
||||
{
|
||||
kinds: [addr.kind],
|
||||
authors: [addr.pubkey],
|
||||
'#d': [addr.identifier],
|
||||
limit: 1,
|
||||
},
|
||||
])
|
||||
.then((events) => {
|
||||
if (events.length > 0) {
|
||||
setInitialData(parseArticleEvent(events[0]));
|
||||
setEditMode(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load article for editing:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [naddrParam, nostr, user]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ArticleEditor initialData={initialData} editMode={editMode} />;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo
|
||||
description: `${title} on Nostr`,
|
||||
});
|
||||
|
||||
const fabClick = onFabClick ?? (resolvedDef ? () => setInfoOpen(true) : undefined);
|
||||
const fabClick = onFabClick ?? (!fabHref && resolvedDef ? () => setInfoOpen(true) : undefined);
|
||||
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: !!user });
|
||||
|
||||
const kinds = Array.isArray(kind) ? kind : [kind];
|
||||
|
||||
@@ -469,7 +469,7 @@ function LikeNotification({ item, isNew }: { item: NotificationItem; isNew: bool
|
||||
actorPubkey={item.event.pubkey}
|
||||
icon={
|
||||
<span className="text-base leading-none size-4 flex items-center justify-center">
|
||||
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4" />
|
||||
<ReactionEmoji content={item.event.content.trim()} tags={item.event.tags} className="inline-block h-4 w-4 object-contain" />
|
||||
</span>
|
||||
}
|
||||
action={`reacted to your ${noun}`}
|
||||
@@ -569,7 +569,7 @@ function LikeNotificationGroup({ group }: { group: GroupedNotificationItem }) {
|
||||
actors={group.actors}
|
||||
icon={
|
||||
<span className="text-base leading-none size-4 flex items-center justify-center">
|
||||
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4" />
|
||||
<ReactionEmoji content={firstEvent.content.trim()} tags={firstEvent.tags} className="inline-block h-4 w-4 object-contain" />
|
||||
</span>
|
||||
}
|
||||
action={`reacted to your ${noun}`}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
MessageCircle,
|
||||
MoreHorizontal,
|
||||
Radio,
|
||||
Package,
|
||||
Rocket,
|
||||
Share2,
|
||||
Star,
|
||||
Zap,
|
||||
@@ -51,7 +53,7 @@ import { RepostIcon } from "@/components/icons/RepostIcon";
|
||||
import { LiveStreamPage } from "@/components/LiveStreamPage";
|
||||
import { MagicDeckContent } from "@/components/MagicDeckContent";
|
||||
import { MusicDetailContent } from "@/components/MusicDetailContent";
|
||||
import { NoteCard } from "@/components/NoteCard";
|
||||
import { EventActionHeader, NoteCard } from "@/components/NoteCard";
|
||||
import { NoteContent } from "@/components/NoteContent";
|
||||
import { NsiteCard } from "@/components/NsiteCard";
|
||||
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
|
||||
@@ -83,6 +85,7 @@ import { VoiceMessagePlayer } from "@/components/VoiceMessagePlayer";
|
||||
import { WebxdcEmbed } from "@/components/WebxdcEmbed";
|
||||
import { ProfileCard } from "@/components/ProfileCard";
|
||||
import { ZapstoreAppContent } from "@/components/ZapstoreAppContent";
|
||||
import { AppHandlerContent } from "@/components/AppHandlerContent";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent";
|
||||
import { type ImetaEntry, parseImetaMap } from "@/lib/imeta";
|
||||
@@ -133,6 +136,7 @@ function shellTitleForKind(kind?: number): string {
|
||||
if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection";
|
||||
if (kind === BOOK_REVIEW_KIND) return "Book Review";
|
||||
if (kind === 32267) return "App Details";
|
||||
if (kind === 31990) return "App";
|
||||
if (kind === 15128 || kind === 35128) return "Nsite";
|
||||
if (kind === VANISH_KIND) return "Request to Vanish";
|
||||
if (kind === 20) return "Photo";
|
||||
@@ -998,6 +1002,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
const isCustomNip = event.kind === 30817;
|
||||
const isNsite = event.kind === 15128 || event.kind === 35128;
|
||||
const isZapstoreApp = event.kind === 32267;
|
||||
const isAppHandler = event.kind === 31990;
|
||||
const isEncryptedDM = event.kind === 4;
|
||||
const isLetter = event.kind === 8211;
|
||||
const isVanish = event.kind === VANISH_KIND;
|
||||
@@ -1025,6 +1030,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
!isCommunity &&
|
||||
!isDevKind &&
|
||||
!isZapstoreApp &&
|
||||
!isAppHandler &&
|
||||
!isEncryptedDM &&
|
||||
!isLetter &&
|
||||
!isVanish &&
|
||||
@@ -1438,11 +1444,11 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Reaction emoji bubble — size-10 matches the threaded ancestor avatar column */}
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-pink-500/10 shrink-0 text-xl leading-none">
|
||||
<ReactionEmoji
|
||||
content={event.content}
|
||||
tags={event.tags}
|
||||
className="text-xl leading-none"
|
||||
className="h-6 w-6 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1933,6 +1939,14 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
{/* Main post — expanded Ditto-style view */}
|
||||
{!isReaction && !isRepost && !isVanish && !isZap && !isProfile && (
|
||||
<article ref={focusedPostRef} className="px-4 pt-3 pb-0">
|
||||
{/* Kind action header for app handlers */}
|
||||
{isAppHandler && (
|
||||
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published an app" />
|
||||
)}
|
||||
{isNsite && (
|
||||
<EventActionHeader pubkey={event.pubkey} icon={Rocket} action="deployed an" noun="nsite" nounRoute="/development" />
|
||||
)}
|
||||
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-3">
|
||||
{author.isLoading ? (
|
||||
@@ -2044,6 +2058,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
) : isZapstoreApp ? (
|
||||
<ZapstoreAppContent event={event} />
|
||||
) : isAppHandler ? (
|
||||
<AppHandlerContent event={event} />
|
||||
) : isEncryptedDM ? (
|
||||
<EncryptedMessageContent event={event} />
|
||||
) : isLetter ? (
|
||||
@@ -2148,7 +2164,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
<RenderResolvedEmoji
|
||||
key={i}
|
||||
emoji={emoji}
|
||||
className="h-4 w-4 leading-none"
|
||||
className="h-4 w-4 object-contain leading-none"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user