Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 213bbb21c1 | |||
| dd3ae4da4e | |||
| 681d2ab90b | |||
| 24a645277e | |||
| fa34922cce | |||
| 89c71ed073 | |||
| f49909dedf | |||
| ab43225f0c | |||
| 2bb1b07dd6 | |||
| f93c759bf2 | |||
| ef4ac2e3f4 | |||
| 32b36b2f54 | |||
| dee5c82fa8 | |||
| 22d66a28d7 | |||
| 984a56c412 | |||
| 207e7a13a2 | |||
| cc7feebbb0 | |||
| 925619b13c | |||
| ceb7bbc718 | |||
| 53a607fa53 | |||
| e9eeebc4b1 | |||
| b42d241882 | |||
| 68da609a9e | |||
| 1afa78ae39 | |||
| e0ff462f12 | |||
| f4e38123e4 | |||
| eb1c873b9a | |||
| 22f13c1505 | |||
| cbfc8f149f | |||
| 2e41859747 | |||
| 3b176a3e8f | |||
| a1e1e1d57f | |||
| eb973cc20b | |||
| f66ab92e51 | |||
| 4d573ffaa8 | |||
| 081189886a | |||
| 1efc8de880 | |||
| 8bf9db382e | |||
| 103b9c71bf | |||
| e27057788b | |||
| 4983b3c1ef | |||
| 197ab6c28a | |||
| fd0d47160d | |||
| 4697d269bc | |||
| 73bf03cfab | |||
| c3d4d5f06e | |||
| 2d1a3ff6f5 | |||
| 90bd10d87a | |||
| 280bcbd5ab | |||
| 65ecfca05e | |||
| 91f5afc110 | |||
| 1c980fb039 | |||
| e93c665123 | |||
| a80b306248 | |||
| c8c294a8ad |
@@ -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.
|
||||
|
||||
+44
-6
@@ -1,5 +1,43 @@
|
||||
# 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
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
@@ -25,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
|
||||
@@ -80,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
|
||||
@@ -103,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
|
||||
@@ -130,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.8"
|
||||
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.
|
||||
|
||||
@@ -60,7 +60,7 @@ const builtinThemes = {
|
||||
};
|
||||
```
|
||||
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `__DITTO_CONFIG__` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
Self-hosters can override these at build time via `ditto.json` (injected through `import.meta.env.DITTO_CONFIG` in `vite.config.ts`), or at runtime via the `ThemesConfig` in `AppConfig.themes`.
|
||||
|
||||
### ThemeConfig
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@ User preferences and computed flags.
|
||||
|
||||
| Tag | Required | Stages | Persistent | Source | Format | Default | Description |
|
||||
|-----|----------|--------|------------|--------|--------|---------|-------------|
|
||||
| `visible_to_others` | No | egg, baby, adult | Yes | user | `true\|false` | true | Public visibility |
|
||||
| `breeding_ready` | No | egg, baby, adult | Yes | computed | `true\|false` | false | Breeding eligibility |
|
||||
|
||||
### 10. Evolution Tags
|
||||
@@ -192,7 +191,7 @@ These tags are from legacy versions and MUST be removed when republishing events
|
||||
- All visual tags (colors, pattern, size)
|
||||
- All personality tags (if present)
|
||||
- All progression tags (`experience`, `care_streak`)
|
||||
- All social tags (`visible_to_others`, `breeding_ready`)
|
||||
- All social tags (`breeding_ready`)
|
||||
- All extension tags (`theme`, `crossover_app`)
|
||||
|
||||
### Evolve (baby → adult)
|
||||
|
||||
@@ -303,7 +303,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.2.8;
|
||||
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.8;
|
||||
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.8",
|
||||
"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",
|
||||
|
||||
+44
-6
@@ -1,5 +1,43 @@
|
||||
# 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
|
||||
- Emoji pack creator and editor with drag-and-drop image upload, auto-generated identifiers, and description field
|
||||
- Blobbi companions now appear in feeds and post detail pages
|
||||
|
||||
### Changed
|
||||
- Blobbi shop redesigned with a tile layout and instant buy -- no more categories or accessory tabs
|
||||
- Emoji packs without any valid emojis are now hidden from feeds
|
||||
- Custom emoji shortcode collisions across packs are automatically resolved with pack-prefixed names
|
||||
|
||||
## [2.2.8] - 2026-04-01
|
||||
|
||||
### Added
|
||||
@@ -25,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
|
||||
@@ -80,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
|
||||
@@ -103,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
|
||||
@@ -130,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
|
||||
|
||||
|
||||
+23
-5
@@ -16,12 +16,14 @@ import NostrProvider from "@/components/NostrProvider";
|
||||
import { NostrSync } from "@/components/NostrSync";
|
||||
import { PlausibleProvider } from "@/components/PlausibleProvider";
|
||||
import { SentryProvider } from "@/components/SentryProvider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
|
||||
import type { AppConfig } from "@/contexts/AppContext";
|
||||
import { NWCProvider } from "@/contexts/NWCContext";
|
||||
import { PROTOCOL_MODE } from "@/lib/dmConstants";
|
||||
import { DittoConfigSchema, type DittoConfig } from "@/lib/schemas";
|
||||
import { EmotionDevProvider } from "@/blobbi/dev/EmotionDevContext";
|
||||
import AppRouter from "./AppRouter";
|
||||
|
||||
@@ -114,6 +116,7 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeBadgeDefinitions: true,
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeVanish: true,
|
||||
feedIncludeBlobbi: true,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
@@ -147,15 +150,30 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse and validate build-time ditto.json overrides from the env string.
|
||||
* Returns an empty object when no config file was provided or validation fails.
|
||||
*/
|
||||
function parseDittoConfig(): DittoConfig {
|
||||
try {
|
||||
const json = JSON.parse(import.meta.env.DITTO_CONFIG);
|
||||
if (!json) return {};
|
||||
return DittoConfigSchema.parse(json);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge hardcoded defaults with build-time ditto.json overrides.
|
||||
* Deep-merges feedSettings so a partial override doesn't erase defaults.
|
||||
* Precedence (handled by AppProvider): user localStorage > build-time > hardcoded.
|
||||
*/
|
||||
const dittoConfig = parseDittoConfig();
|
||||
const defaultConfig: AppConfig = {
|
||||
...hardcodedConfig,
|
||||
...(typeof __DITTO_CONFIG__ !== "undefined" && __DITTO_CONFIG__
|
||||
? __DITTO_CONFIG__
|
||||
: {}),
|
||||
...dittoConfig,
|
||||
feedSettings: { ...hardcodedConfig.feedSettings, ...dittoConfig.feedSettings },
|
||||
};
|
||||
|
||||
export function App() {
|
||||
@@ -183,11 +201,11 @@ export function App() {
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<NativeNotifications />
|
||||
|
||||
<NWCProvider>
|
||||
<DMProvider config={dmConfig}>
|
||||
<EmotionDevProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<InitialSyncGate>
|
||||
<AppRouter />
|
||||
</InitialSyncGate>
|
||||
|
||||
+32
-10
@@ -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";
|
||||
@@ -22,6 +24,9 @@ const BlobbiCompanionLayer = lazy(() => import("@/blobbi/companion").then(m => (
|
||||
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
|
||||
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
|
||||
|
||||
// Lazy-loaded emoji pack dialog
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
|
||||
@@ -29,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 })));
|
||||
@@ -101,6 +107,26 @@ function PollsFeedPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
|
||||
function EmojiFeedPage() {
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<KindFeedPage
|
||||
kind={emojisDef.kind}
|
||||
title={emojisDef.label}
|
||||
icon={sidebarItemIcon("emojis", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
{composeOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
|
||||
function ProfileRedirect() {
|
||||
const { user, metadata } = useCurrentUser();
|
||||
@@ -113,6 +139,8 @@ export function AppRouter() {
|
||||
return (
|
||||
<AudioPlayerProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster />
|
||||
<VersionCheck />
|
||||
<MinimizedAudioBar />
|
||||
<AudioNavigationGuard />
|
||||
<DeepLinkHandler />
|
||||
@@ -184,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={
|
||||
@@ -191,6 +221,7 @@ export function AppRouter() {
|
||||
kind={articlesDef.kind}
|
||||
title={articlesDef.label}
|
||||
icon={sidebarItemIcon("articles", "size-5")}
|
||||
fabHref="/articles/new"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -204,16 +235,7 @@ export function AppRouter() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/emojis"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={emojisDef.kind}
|
||||
title={emojisDef.label}
|
||||
icon={sidebarItemIcon("emojis", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/emojis" element={<EmojiFeedPage />} />
|
||||
<Route
|
||||
path="/development"
|
||||
element={
|
||||
|
||||
@@ -217,11 +217,6 @@ export function canUseItemForStage(
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
// Accessories are disabled
|
||||
if (shopItem.type === 'accessory') {
|
||||
return { canUse: false, reason: 'Accessories are not usable yet' };
|
||||
}
|
||||
|
||||
return { canUse: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ const CATEGORY_TO_PRIMARY_STAT: Record<ShopItemCategory, (keyof BlobbiStats)[]>
|
||||
toy: ['happiness'],
|
||||
hygiene: ['hygiene'],
|
||||
medicine: ['health'],
|
||||
accessory: [], // Accessories don't address needs
|
||||
};
|
||||
|
||||
// ─── Need Detection Functions ─────────────────────────────────────────────────
|
||||
|
||||
@@ -82,7 +82,6 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
|
||||
toy: 'play',
|
||||
medicine: 'medicine',
|
||||
hygiene: 'clean',
|
||||
accessory: null, // Accessories are cosmetic, not usable
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,7 +34,7 @@ export type TagCategory =
|
||||
| 'state' // Lifecycle state (stage, state, timestamps)
|
||||
| 'progression' // Progress tracking (experience, care_streak)
|
||||
| 'task' // Task system (task, task_completed, state_started_at)
|
||||
| 'social' // Social flags (visible_to_others, breeding_ready)
|
||||
| 'social' // Social flags (breeding_ready)
|
||||
| 'evolution' // Evolution-specific (adult_type)
|
||||
| 'extension'; // Extension tags (theme, crossover_app)
|
||||
|
||||
@@ -509,19 +509,6 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SOCIAL / FLAG TAGS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
tag: 'visible_to_others',
|
||||
description: 'Whether the Blobbi is publicly visible to other users',
|
||||
category: 'social',
|
||||
required: false,
|
||||
stages: ['egg', 'baby', 'adult'],
|
||||
persistent: true,
|
||||
source: 'user',
|
||||
regenerable: false,
|
||||
format: 'true | false',
|
||||
defaultValue: 'true',
|
||||
notes: 'User preference. Persists across stages.',
|
||||
},
|
||||
{
|
||||
tag: 'breeding_ready',
|
||||
description: 'Whether the Blobbi is eligible for breeding',
|
||||
|
||||
@@ -253,8 +253,6 @@ export interface BlobbiCompanion {
|
||||
lastDecayAt: number | undefined;
|
||||
/** Stats (0-100) */
|
||||
stats: Partial<BlobbiStats>;
|
||||
/** Whether the Blobbi is publicly visible */
|
||||
visibleToOthers: boolean;
|
||||
/** Generation number */
|
||||
generation: number | undefined;
|
||||
/** Breeding eligibility */
|
||||
@@ -939,7 +937,6 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
|
||||
hygiene: parseNumericTag(tags, 'hygiene'),
|
||||
energy: parseNumericTag(tags, 'energy'),
|
||||
},
|
||||
visibleToOthers: parseBooleanTag(tags, 'visible_to_others', true),
|
||||
generation: parseNumericTag(tags, 'generation'),
|
||||
breedingReady: parseBooleanTag(tags, 'breeding_ready', false),
|
||||
experience: parseNumericTag(tags, 'experience'),
|
||||
@@ -1036,7 +1033,6 @@ export function buildEggTags(
|
||||
['stage', 'egg'],
|
||||
['state', 'active'],
|
||||
['seed', seed],
|
||||
['visible_to_others', 'true'],
|
||||
['generation', '1'],
|
||||
['breeding_ready', 'false'],
|
||||
['experience', '0'],
|
||||
@@ -1084,7 +1080,7 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([
|
||||
// Progression tags
|
||||
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
|
||||
// Social/flag tags
|
||||
'visible_to_others', 'breeding_ready',
|
||||
'breeding_ready',
|
||||
// Task system tags (removed after stage transitions)
|
||||
'state_started_at', 'task', 'task_completed',
|
||||
// Evolution tags (adult only)
|
||||
@@ -1463,7 +1459,7 @@ export function buildMigrationTags(
|
||||
// Progression tags
|
||||
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
|
||||
// Social/flag tags
|
||||
'visible_to_others', 'generation', 'breeding_ready',
|
||||
'generation', 'breeding_ready',
|
||||
// Personality tags (preserve if they exist, do NOT generate)
|
||||
'personality', 'trait', 'favorite_food', 'voice_type', 'mood',
|
||||
// Evolution tags
|
||||
|
||||
@@ -149,10 +149,6 @@ export interface Blobbi extends BlobbiVisualTraits {
|
||||
generation?: number;
|
||||
careStreak?: number;
|
||||
|
||||
/**
|
||||
* Visibility / social.
|
||||
*/
|
||||
visibleToOthers?: boolean;
|
||||
crossoverApp?: string | null;
|
||||
themeVariant?: string;
|
||||
|
||||
@@ -215,7 +211,6 @@ export function createDefaultBlobbi(overrides: Partial<Blobbi> = {}): Blobbi {
|
||||
generation: overrides.generation ?? 1,
|
||||
careStreak: overrides.careStreak ?? 0,
|
||||
|
||||
visibleToOthers: overrides.visibleToOthers ?? true,
|
||||
crossoverApp: overrides.crossoverApp ?? null,
|
||||
themeVariant: overrides.themeVariant,
|
||||
tags: overrides.tags ?? [],
|
||||
|
||||
@@ -58,8 +58,6 @@ export interface BlobbiDevUpdates {
|
||||
breedingReady?: boolean;
|
||||
/** Generation number */
|
||||
generation?: number;
|
||||
/** Visibility to others */
|
||||
visibleToOthers?: boolean;
|
||||
}
|
||||
|
||||
// ─── Stat Presets ─────────────────────────────────────────────────────────────
|
||||
@@ -189,7 +187,6 @@ export function BlobbiDevEditor({
|
||||
const [careStreak, setCareStreak] = useState(companion.careStreak ?? 0);
|
||||
const [breedingReady, setBreedingReady] = useState(companion.breedingReady);
|
||||
const [generation, setGeneration] = useState(companion.generation ?? 1);
|
||||
const [visibleToOthers, setVisibleToOthers] = useState(companion.visibleToOthers);
|
||||
|
||||
// Reset state when companion changes or modal opens
|
||||
const resetToCompanion = useCallback(() => {
|
||||
@@ -207,7 +204,6 @@ export function BlobbiDevEditor({
|
||||
setCareStreak(companion.careStreak ?? 0);
|
||||
setBreedingReady(companion.breedingReady);
|
||||
setGeneration(companion.generation ?? 1);
|
||||
setVisibleToOthers(companion.visibleToOthers);
|
||||
}, [companion]);
|
||||
|
||||
// Check if there are any changes
|
||||
@@ -224,10 +220,9 @@ export function BlobbiDevEditor({
|
||||
experience !== (companion.experience ?? 0) ||
|
||||
careStreak !== (companion.careStreak ?? 0) ||
|
||||
breedingReady !== companion.breedingReady ||
|
||||
generation !== (companion.generation ?? 1) ||
|
||||
visibleToOthers !== companion.visibleToOthers
|
||||
generation !== (companion.generation ?? 1)
|
||||
);
|
||||
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion]);
|
||||
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion]);
|
||||
|
||||
// Apply preset
|
||||
const applyPreset = useCallback((preset: StatPreset) => {
|
||||
@@ -270,11 +265,10 @@ export function BlobbiDevEditor({
|
||||
if (careStreak !== (companion.careStreak ?? 0)) updates.careStreak = careStreak;
|
||||
if (breedingReady !== companion.breedingReady) updates.breedingReady = breedingReady;
|
||||
if (generation !== (companion.generation ?? 1)) updates.generation = generation;
|
||||
if (visibleToOthers !== companion.visibleToOthers) updates.visibleToOthers = visibleToOthers;
|
||||
|
||||
await onApply(updates);
|
||||
onClose();
|
||||
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, visibleToOthers, companion, onApply, onClose]);
|
||||
}, [stage, state, adultType, stats, experience, careStreak, breedingReady, generation, companion, onApply, onClose]);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -533,13 +527,6 @@ export function BlobbiDevEditor({
|
||||
onCheckedChange={setBreedingReady}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">Visible to Others</Label>
|
||||
<Switch
|
||||
checked={visibleToOthers}
|
||||
onCheckedChange={setVisibleToOthers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,10 +140,6 @@ export function useBlobbiDevUpdate({
|
||||
tagUpdates.generation = updates.generation.toString();
|
||||
changedFields.push('generation');
|
||||
}
|
||||
if (updates.visibleToOthers !== undefined) {
|
||||
tagUpdates.visible = updates.visibleToOthers ? 'true' : 'false';
|
||||
changedFields.push('visible');
|
||||
}
|
||||
|
||||
// Always update last_interaction and last_decay_at
|
||||
tagUpdates.last_interaction = now.toString();
|
||||
|
||||
@@ -135,7 +135,6 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] {
|
||||
['stage', preview.stage],
|
||||
['state', preview.state],
|
||||
['seed', preview.seed],
|
||||
['visible_to_others', 'true'],
|
||||
['generation', '1'],
|
||||
['breeding_ready', 'false'],
|
||||
['experience', '0'],
|
||||
@@ -181,7 +180,6 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) {
|
||||
isLegacy: false,
|
||||
lastInteraction: preview.createdAt,
|
||||
lastDecayAt: preview.createdAt,
|
||||
visibleToOthers: true,
|
||||
generation: 1,
|
||||
breedingReady: false,
|
||||
experience: 0,
|
||||
|
||||
@@ -45,20 +45,25 @@ interface ResolvedInventoryItem extends ShopItem {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function BlobbiInventoryModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
|
||||
|
||||
interface BlobbiInventoryContentProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
export function BlobbiInventoryContent({
|
||||
profile,
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem = false,
|
||||
}: BlobbiInventoryModalProps) {
|
||||
// State for use confirmation dialog
|
||||
}: BlobbiInventoryContentProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showUseDialog, setShowUseDialog] = useState(false);
|
||||
|
||||
// Resolve storage items with their metadata and usability from the shop catalog
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
@@ -68,7 +73,6 @@ export function BlobbiInventoryModal({
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
// Check if item can be used for current stage
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
|
||||
result.push({
|
||||
@@ -84,7 +88,6 @@ export function BlobbiInventoryModal({
|
||||
|
||||
const isEmpty = inventoryItems.length === 0;
|
||||
|
||||
// Handlers for use dialog
|
||||
const handleSelectItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem) return;
|
||||
setSelectedItem(item);
|
||||
@@ -95,7 +98,6 @@ export function BlobbiInventoryModal({
|
||||
const handleConfirmUse = () => {
|
||||
if (!selectedItem || !onUseItem || isUsingItem) return;
|
||||
onUseItem(selectedItem.itemId, quantity);
|
||||
// Reset state
|
||||
setShowUseDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
@@ -109,7 +111,6 @@ export function BlobbiInventoryModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Quantity controls
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
@@ -123,142 +124,117 @@ export function BlobbiInventoryModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
|
||||
<Package className="size-4 sm:size-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
{isEmpty ? 'No items yet' : `${inventoryItems.length} ${inventoryItems.length === 1 ? 'item' : 'items'}`}
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4">
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Visit the Shop tab to purchase items for your Blobbi. Items you buy will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4">
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="size-20 rounded-3xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
Visit the shop to purchase items for your Blobbi. Items you buy will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:gap-3">
|
||||
{inventoryItems.map(item => (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
|
||||
item.canUse ? "hover:border-primary/30" : "opacity-70"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className={cn(
|
||||
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
|
||||
!item.canUse && "grayscale"
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:gap-3">
|
||||
{inventoryItems.map(item => (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
"flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3 sm:p-4 rounded-xl border bg-card/60 backdrop-blur-sm transition-colors",
|
||||
item.canUse ? "hover:border-primary/30" : "opacity-70"
|
||||
)}
|
||||
>
|
||||
{/* Top row on mobile: Icon + Name/Type + Quantity + Button */}
|
||||
<div className="flex items-center gap-3 sm:contents">
|
||||
{/* Item Icon */}
|
||||
<div className="relative shrink-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-primary/5 rounded-full blur-xl" />
|
||||
<div className={cn(
|
||||
"relative size-10 sm:size-14 rounded-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center text-2xl sm:text-3xl",
|
||||
!item.canUse && "grayscale"
|
||||
)}>
|
||||
{item.icon}
|
||||
</div>
|
||||
|
||||
{/* Item Info - Name and Type */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Effect preview - desktop only inline */}
|
||||
<div className="hidden sm:block">
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{/* Show blocked reason - desktop only inline */}
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity Badge */}
|
||||
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
|
||||
×{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSelectItem(item)}
|
||||
disabled={isUsingItem}
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile only: Effect preview and blocked reason below */}
|
||||
<div className="sm:hidden pl-13 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{/* Item Info - Name and Type */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 sm:mb-1">
|
||||
<h3 className="font-semibold text-sm sm:text-base truncate">{item.name}</h3>
|
||||
<Badge variant="secondary" className="text-xs capitalize shrink-0 hidden sm:inline-flex">
|
||||
{item.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Effect preview - desktop only inline */}
|
||||
<div className="hidden sm:block">
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{/* Show blocked reason on mobile */}
|
||||
{/* Show blocked reason - desktop only inline */}
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
<p className="hidden sm:block text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity Badge */}
|
||||
<Badge className="bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0 px-2 py-0.5 shrink-0 text-xs">
|
||||
×{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSelectItem(item)}
|
||||
disabled={isUsingItem}
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled
|
||||
className="shrink-0"
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Mobile only: Effect preview and blocked reason below */}
|
||||
<div className="sm:hidden pl-13 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs capitalize">
|
||||
{item.type}
|
||||
</Badge>
|
||||
<ItemEffectDisplay effect={item.effect} variant="inline" />
|
||||
</div>
|
||||
{!item.canUse && item.reason && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use Item Confirmation Dialog */}
|
||||
{selectedItem && companion && (
|
||||
@@ -276,6 +252,49 @@ export function BlobbiInventoryModal({
|
||||
isUsing={isUsingItem}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Standalone Inventory Modal (kept for backwards compatibility) ──
|
||||
|
||||
export function BlobbiInventoryModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
profile,
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem = false,
|
||||
}: BlobbiInventoryModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-500/20 flex items-center justify-center shrink-0">
|
||||
<Package className="size-4 sm:size-5 text-primary" />
|
||||
</div>
|
||||
<DialogTitle className="text-xl sm:text-2xl">Inventory</DialogTitle>
|
||||
</div>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<BlobbiInventoryContent
|
||||
profile={profile}
|
||||
companion={companion}
|
||||
onUseItem={onUseItem}
|
||||
isUsingItem={isUsingItem}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -309,15 +328,12 @@ function InventoryUseConfirmDialog({
|
||||
onConfirm,
|
||||
isUsing,
|
||||
}: InventoryUseConfirmDialogProps) {
|
||||
// Calculate total effect for the selected quantity by simulating sequential application
|
||||
// This matches the actual behavior when items are used (clamping at each step)
|
||||
const totalEffect = useMemo(() => {
|
||||
if (!item.effect) return null;
|
||||
|
||||
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
|
||||
const currentStats = { ...companion.stats };
|
||||
|
||||
// Apply effects N times in sequence with clamping at each step
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
for (const stat of statKeys) {
|
||||
const delta = item.effect[stat];
|
||||
@@ -327,7 +343,6 @@ function InventoryUseConfirmDialog({
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate actual deltas (may be less than effect * quantity due to clamping)
|
||||
const result: Record<string, number> = {};
|
||||
for (const stat of statKeys) {
|
||||
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import { ItemEffectDisplay } from './ItemEffectDisplay';
|
||||
import { formatCompactNumber } from '@/lib/utils';
|
||||
|
||||
interface BlobbiPurchaseDialogProps {
|
||||
@@ -150,13 +149,6 @@ export function BlobbiPurchaseDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Effects Summary */}
|
||||
{item.effect && Object.keys(item.effect).length > 0 && (
|
||||
<div className="p-4 rounded-lg bg-gradient-to-r from-emerald-500/10 to-green-500/10 border border-emerald-500/20">
|
||||
<h4 className="text-sm font-medium mb-2">Effects per item</h4>
|
||||
<ItemEffectDisplay effect={item.effect} variant="grid" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,178 +1,364 @@
|
||||
import { useState } from 'react';
|
||||
import { ShoppingBag, Utensils, Gamepad2, Heart, Droplets, Palette, X } from 'lucide-react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ShoppingBag, Package, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
import { BlobbiShopItemRow } from './BlobbiShopItemRow';
|
||||
import { BlobbiPurchaseDialog } from './BlobbiPurchaseDialog';
|
||||
|
||||
import type { ShopItem, ShopItemCategory } from '../types/shop.types';
|
||||
import type { BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { getShopItemsByType } from '../lib/blobbi-shop-items';
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { getLiveShopItems, getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { useBlobbiPurchaseItem } from '../hooks/useBlobbiPurchaseItem';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { cn, formatCompactNumber } from '@/lib/utils';
|
||||
|
||||
type TopTab = 'items' | 'shop';
|
||||
|
||||
/** Resolved inventory item with shop metadata and usability info */
|
||||
interface ResolvedInventoryItem extends ShopItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface BlobbiShopModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Initial tab to open on. Defaults to "items". */
|
||||
initialTab?: TopTab;
|
||||
// ── Inventory props (passed through) ──
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
const CATEGORIES: Array<{
|
||||
type: ShopItemCategory;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}> = [
|
||||
{ type: 'food', label: 'Food', icon: <Utensils className="size-4" /> },
|
||||
{ type: 'toy', label: 'Toys', icon: <Gamepad2 className="size-4" /> },
|
||||
{ type: 'medicine', label: 'Medicine', icon: <Heart className="size-4" /> },
|
||||
{ type: 'hygiene', label: 'Hygiene', icon: <Droplets className="size-4" /> },
|
||||
{ type: 'accessory', label: 'Accessories', icon: <Palette className="size-4" /> },
|
||||
];
|
||||
|
||||
export function BlobbiShopModal({ open, onOpenChange, profile }: BlobbiShopModalProps) {
|
||||
const [activeCategory, setActiveCategory] = useState<ShopItemCategory>('food');
|
||||
const [selectedItem, setSelectedItem] = useState<ShopItem | null>(null);
|
||||
const [showPurchaseDialog, setShowPurchaseDialog] = useState(false);
|
||||
export function BlobbiShopModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
profile,
|
||||
initialTab = 'items',
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem,
|
||||
}: BlobbiShopModalProps) {
|
||||
const [topTab, setTopTab] = useState<TopTab>(initialTab);
|
||||
|
||||
const { mutate: purchaseItem, isPending: isPurchasing } = useBlobbiPurchaseItem(profile);
|
||||
const [purchasingItemId, setPurchasingItemId] = useState<string | null>(null);
|
||||
|
||||
const availableCoins = profile?.coins ?? 0;
|
||||
const items = getShopItemsByType(activeCategory);
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
const handlePurchaseClick = (item: ShopItem) => {
|
||||
setSelectedItem(item);
|
||||
setShowPurchaseDialog(true);
|
||||
// Reset to initialTab when modal re-opens
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setTopTab(initialTab);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const handlePurchase = (quantity: number) => {
|
||||
if (!selectedItem) return;
|
||||
|
||||
// Instant purchase — one tap = one item
|
||||
const handleBuyItem = (item: ShopItem) => {
|
||||
if (isPurchasing || availableCoins < item.price) return;
|
||||
setPurchasingItemId(item.id);
|
||||
purchaseItem(
|
||||
{
|
||||
itemId: selectedItem.id,
|
||||
price: selectedItem.price,
|
||||
quantity,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowPurchaseDialog(false);
|
||||
setSelectedItem(null);
|
||||
},
|
||||
}
|
||||
{ itemId: item.id, price: item.price, quantity: 1 },
|
||||
{ onSettled: () => setPurchasingItemId(null) },
|
||||
);
|
||||
};
|
||||
|
||||
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
|
||||
|
||||
// ── Inventory items resolution ──
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
|
||||
// ── Inventory use item handler ──
|
||||
const [usingItemId, setUsingItemId] = useState<string | null>(null);
|
||||
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem || !onUseItem) return;
|
||||
setUsingItemId(item.itemId);
|
||||
onUseItem(item.itemId, 1);
|
||||
};
|
||||
|
||||
// Clear usingItemId when isUsingItem goes false
|
||||
const effectiveUsingItemId = isUsingItem ? usingItemId : null;
|
||||
|
||||
const inventoryEmpty = inventoryItems.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
||||
<div className="size-9 sm:size-10 rounded-xl bg-gradient-to-br from-purple-500/20 to-pink-500/20 flex items-center justify-center shrink-0">
|
||||
<ShoppingBag className="size-4 sm:size-5 text-primary" />
|
||||
</div>
|
||||
<DialogTitle className="text-xl sm:text-2xl truncate">Blobbi Shop</DialogTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-sm sm:text-base px-3 sm:px-4 py-1">
|
||||
{formatCompactNumber(availableCoins)} coins
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md w-[calc(100%-2rem)] max-h-[80vh] flex flex-col p-0 gap-0 overflow-hidden rounded-2xl [&>button:last-child]:hidden">
|
||||
|
||||
{/* Tab Bar (replaces header) */}
|
||||
<div className="flex items-center border-b bg-muted/30">
|
||||
{/* Tabs */}
|
||||
<button
|
||||
onClick={() => setTopTab('items')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
|
||||
topTab === 'items'
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground/70'
|
||||
)}
|
||||
>
|
||||
<Package className="size-4" />
|
||||
Items
|
||||
{!inventoryEmpty && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 min-w-4">
|
||||
{inventoryItems.reduce((sum, i) => sum + i.quantity, 0)}
|
||||
</Badge>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
)}
|
||||
{topTab === 'items' && (
|
||||
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTopTab('shop')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-2 px-4 py-3.5 text-sm font-medium transition-colors relative',
|
||||
topTab === 'shop'
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground/70'
|
||||
)}
|
||||
>
|
||||
<ShoppingBag className="size-4" />
|
||||
Shop
|
||||
{topTab === 'shop' && (
|
||||
<span className="absolute bottom-0 inset-x-4 h-0.5 bg-primary rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Category Tabs - Part of sticky header area */}
|
||||
<div className="sticky top-[60px] sm:top-[72px] z-10 bg-background px-4 sm:px-6 pt-3 sm:pt-4 pb-2 border-b">
|
||||
<div className="flex gap-1.5 sm:gap-2 overflow-x-auto pb-1 -mx-1 px-1">
|
||||
{CATEGORIES.map(category => {
|
||||
const isActive = activeCategory === category.type;
|
||||
const itemCount = getShopItemsByType(category.type).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.type}
|
||||
onClick={() => setActiveCategory(category.type)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg transition-all whitespace-nowrap',
|
||||
'border text-sm sm:text-base',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted/50 text-muted-foreground border-transparent hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{category.icon}
|
||||
<span className="font-medium hidden xs:inline">{category.label}</span>
|
||||
<Badge variant="secondary" className="ml-0.5 sm:ml-1 text-xs">
|
||||
{itemCount}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{/* Coin badge + Close */}
|
||||
<div className="flex items-center gap-1.5 pr-3 pl-2">
|
||||
<Badge className="bg-gradient-to-r from-yellow-500 to-amber-500 text-white border-0 text-xs px-2 py-0.5">
|
||||
<span className="mr-1">🪙</span>{formatCompactNumber(availableCoins)}
|
||||
</Badge>
|
||||
<DialogClose className="rounded-full p-1 opacity-60 hover:opacity-100 transition-opacity">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content Area */}
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Accessories Coming Soon Banner */}
|
||||
{activeCategory === 'accessory' && (
|
||||
<div className="mx-4 sm:mx-6 mt-3 sm:mt-4 p-4 sm:p-6 rounded-xl bg-gradient-to-br from-primary/10 to-primary/5 border border-primary/20">
|
||||
<div className="flex items-start gap-3 sm:gap-4">
|
||||
<div className="size-12 sm:size-16 rounded-xl sm:rounded-2xl bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center text-2xl sm:text-3xl relative shrink-0">
|
||||
🎨
|
||||
<div className="absolute -top-1 -right-1 text-base sm:text-xl">✨</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold mb-1">Accessories Coming Soon!</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Get ready to customize your Blobbi's appearance with amazing accessories and cosmetic items.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{topTab === 'shop' ? (
|
||||
<ShopGrid
|
||||
items={allItems}
|
||||
availableCoins={availableCoins}
|
||||
onBuy={handleBuyItem}
|
||||
purchasingItemId={effectivePurchasingId}
|
||||
/>
|
||||
) : (
|
||||
<ItemsGrid
|
||||
items={inventoryItems}
|
||||
onUseItem={handleUseItem}
|
||||
isUsingItem={isUsingItem}
|
||||
usingItemId={effectiveUsingItemId}
|
||||
onGoToShop={() => setTopTab('shop')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Items List */}
|
||||
<div className="px-4 sm:px-6 py-3 sm:py-4">
|
||||
<div className="space-y-2">
|
||||
{items.map(item => (
|
||||
<BlobbiShopItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
availableCoins={availableCoins}
|
||||
onPurchaseClick={handlePurchaseClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Purchase Dialog */}
|
||||
{selectedItem && (
|
||||
<BlobbiPurchaseDialog
|
||||
open={showPurchaseDialog}
|
||||
onOpenChange={setShowPurchaseDialog}
|
||||
item={selectedItem}
|
||||
availableCoins={availableCoins}
|
||||
onPurchase={handlePurchase}
|
||||
isPurchasing={isPurchasing}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Shop Grid (tile layout, all items, cost in button) ───────────────────────
|
||||
|
||||
interface ShopGridProps {
|
||||
items: ShopItem[];
|
||||
availableCoins: number;
|
||||
onBuy: (item: ShopItem) => void;
|
||||
purchasingItemId: string | null;
|
||||
}
|
||||
|
||||
function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridProps) {
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{items.map(item => {
|
||||
const isDisabled = item.status === 'disabled';
|
||||
const isAffordable = !isDisabled && availableCoins >= item.price;
|
||||
const isBuying = purchasingItemId === item.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center',
|
||||
'bg-card/60 backdrop-blur-sm',
|
||||
isDisabled && 'opacity-50',
|
||||
!isDisabled && !isAffordable && 'opacity-70',
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="text-3xl leading-none mt-1">{item.icon}</div>
|
||||
|
||||
{/* Name */}
|
||||
<span className="text-xs font-medium truncate w-full">{item.name}</span>
|
||||
|
||||
{/* Buy button with integrated cost */}
|
||||
<button
|
||||
onClick={() => onBuy(item)}
|
||||
disabled={isDisabled || !isAffordable || !!purchasingItemId}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-2 py-1.5 text-xs font-medium transition-colors',
|
||||
isDisabled
|
||||
? 'bg-muted text-muted-foreground cursor-not-allowed'
|
||||
: isAffordable
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 active:scale-95 transition-transform'
|
||||
: 'bg-muted text-muted-foreground cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isDisabled ? (
|
||||
'Soon'
|
||||
) : isBuying ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-1">
|
||||
<span>🪙</span> {formatCompactNumber(item.price)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
|
||||
|
||||
interface ItemsGridProps {
|
||||
items: ResolvedInventoryItem[];
|
||||
onUseItem: (item: ResolvedInventoryItem) => void;
|
||||
isUsingItem?: boolean;
|
||||
usingItemId: string | null;
|
||||
onGoToShop: () => void;
|
||||
}
|
||||
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<Package className="size-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No items yet. Visit the shop to stock up!
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
|
||||
<ShoppingBag className="size-3.5" />
|
||||
Browse Shop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{items.map(item => {
|
||||
const isThisUsing = isUsingItem && usingItemId === item.itemId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.itemId}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 p-3 rounded-xl border transition-all text-center relative',
|
||||
'bg-card/60 backdrop-blur-sm',
|
||||
item.canUse ? 'hover:border-primary/40 hover:bg-accent/40' : 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Quantity badge */}
|
||||
<Badge
|
||||
className="absolute top-1.5 right-1.5 text-[10px] px-1.5 py-0 h-4 min-w-4 bg-gradient-to-r from-blue-500 to-indigo-500 text-white border-0"
|
||||
>
|
||||
{item.quantity}
|
||||
</Badge>
|
||||
|
||||
{/* Icon */}
|
||||
<div className={cn('text-3xl leading-none mt-1', !item.canUse && 'grayscale')}>{item.icon}</div>
|
||||
|
||||
{/* Name */}
|
||||
<span className="text-xs font-medium truncate w-full">{item.name}</span>
|
||||
|
||||
{/* Use button */}
|
||||
{item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-7 text-xs"
|
||||
onClick={() => onUseItem(item)}
|
||||
disabled={isUsingItem}
|
||||
>
|
||||
{isThisUsing ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
'Use'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="w-full">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-7 text-xs"
|
||||
disabled
|
||||
>
|
||||
Use
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.reason || 'Cannot use this item'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,39 +177,6 @@ export const BLOBBI_SHOP_ITEMS: ShopItem[] = [
|
||||
status: 'live',
|
||||
},
|
||||
|
||||
// ─── Accessory Items (Disabled) ─────────────────────────────────────────────
|
||||
{
|
||||
id: 'acc_hat',
|
||||
name: 'Party Hat',
|
||||
type: 'accessory',
|
||||
price: 75,
|
||||
icon: '🎩',
|
||||
status: 'disabled',
|
||||
},
|
||||
{
|
||||
id: 'acc_glasses',
|
||||
name: 'Cool Glasses',
|
||||
type: 'accessory',
|
||||
price: 60,
|
||||
icon: '🕶️',
|
||||
status: 'disabled',
|
||||
},
|
||||
{
|
||||
id: 'acc_bow',
|
||||
name: 'Bow Tie',
|
||||
type: 'accessory',
|
||||
price: 50,
|
||||
icon: '🎀',
|
||||
status: 'disabled',
|
||||
},
|
||||
{
|
||||
id: 'acc_crown',
|
||||
name: 'Crown',
|
||||
type: 'accessory',
|
||||
price: 100,
|
||||
icon: '👑',
|
||||
status: 'disabled',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -237,7 +204,7 @@ export function getLiveShopItems(): ShopItem[] {
|
||||
* Get all shop item categories with their counts
|
||||
*/
|
||||
export function getShopCategories(): Array<{ type: ShopItemCategory; count: number; label: string }> {
|
||||
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene', 'accessory'];
|
||||
const categories: ShopItemCategory[] = ['food', 'toy', 'medicine', 'hygiene'];
|
||||
|
||||
return categories.map(type => ({
|
||||
type,
|
||||
|
||||
@@ -7,8 +7,7 @@ export type ShopItemCategory =
|
||||
| 'food'
|
||||
| 'toy'
|
||||
| 'medicine'
|
||||
| 'hygiene'
|
||||
| 'accessory';
|
||||
| 'hygiene';
|
||||
|
||||
/**
|
||||
* Stat effects that items can apply to Blobbi
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode, useLayoutEffect, useEffect, useRef } from 'react';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { AppContext, type AppConfig, type AppContextType, type Theme } from '@/contexts/AppContext';
|
||||
import { builtinThemes, themePresets, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
|
||||
import { builtinThemes, buildThemeCssFromCore, resolveTheme, resolveThemeConfig, type ThemeConfig, type ThemesConfig } from '@/themes';
|
||||
import { AppConfigSchema } from '@/lib/schemas';
|
||||
import { loadAndApplyFont, loadAndApplyTitleFont } from '@/lib/fontLoader';
|
||||
import { hslToRgb, parseHsl, rgbToHex } from '@/lib/colorUtils';
|
||||
@@ -47,13 +47,6 @@ export function AppProvider(props: AppProviderProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate legacy theme values ("black", "pink") to "custom" + customTheme
|
||||
const legacyTheme = result.theme as string | undefined;
|
||||
if (legacyTheme && legacyTheme in themePresets) {
|
||||
result.theme = 'custom';
|
||||
result.customTheme = { colors: themePresets[legacyTheme].colors };
|
||||
}
|
||||
|
||||
// Migrate legacy blossomServers (string[]) to blossomServerMetadata
|
||||
if (!result.blossomServerMetadata) {
|
||||
const legacyServers = parsed.blossomServers;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { parseBlobbiEvent } from '@/blobbi/core/lib/blobbi';
|
||||
|
||||
export function BlobbiStateCard({ event }: { event: NostrEvent }) {
|
||||
const companion = useMemo(() => parseBlobbiEvent(event), [event]);
|
||||
|
||||
if (!companion) return null;
|
||||
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center py-4">
|
||||
{/* Blobbi visual — same as /blobbi hero */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 -m-8 bg-primary/5 rounded-full blur-3xl" />
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="lg"
|
||||
animated={!isSleeping}
|
||||
lookMode="forward"
|
||||
className="size-48 sm:size-56"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h3
|
||||
className="mt-3 text-xl font-bold text-center"
|
||||
style={{ color: companion.visualTraits.baseColor }}
|
||||
>
|
||||
{companion.name}
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { type ReactNode, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film,
|
||||
Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film,
|
||||
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
|
||||
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles,
|
||||
Users, Zap,
|
||||
@@ -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',
|
||||
@@ -126,6 +127,7 @@ const KIND_LABELS: Record<number, string> = {
|
||||
37516: 'a treasure',
|
||||
39089: 'a follow pack',
|
||||
9735: 'a zap',
|
||||
31124: 'a Blobbi',
|
||||
};
|
||||
|
||||
/** Kind-specific icons — matches sidebar and NoteCard icons. */
|
||||
@@ -156,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,
|
||||
@@ -168,6 +171,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
39089: PartyPopper,
|
||||
3367: Palette,
|
||||
9735: Zap,
|
||||
31124: Egg,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -226,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>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { Plus, Check, Loader2 } from 'lucide-react';
|
||||
import { useMemo, useState, useCallback, lazy, Suspense } from 'react';
|
||||
import { Plus, Check, Loader2, Pencil } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EmojiPackDialog = lazy(() => import('@/components/EmojiPackDialog').then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
/** Maximum emojis to show in the preview grid before truncating. */
|
||||
const PREVIEW_LIMIT = 24;
|
||||
|
||||
@@ -60,6 +61,10 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isOwnPack = user?.pubkey === event.pubkey;
|
||||
|
||||
// Fetch the user's kind 10030 emoji list to check if this pack is already added
|
||||
const emojiListQuery = useQuery({
|
||||
@@ -135,8 +140,8 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
|
||||
|
||||
if (!pack) return null;
|
||||
|
||||
const showEmojis = pack.emojis.slice(0, PREVIEW_LIMIT);
|
||||
const remaining = pack.emojis.length - PREVIEW_LIMIT;
|
||||
const showEmojis = expanded ? pack.emojis : pack.emojis.slice(0, PREVIEW_LIMIT);
|
||||
const remaining = expanded ? 0 : pack.emojis.length - PREVIEW_LIMIT;
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
@@ -150,12 +155,7 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-[15px] truncate">{pack.name}</h3>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
{pack.emojis.length} emoji{pack.emojis.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="font-semibold text-[15px] truncate">{pack.name}</h3>
|
||||
{pack.about && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">{pack.about}</p>
|
||||
)}
|
||||
@@ -182,9 +182,13 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
|
||||
</div>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<div className="flex items-center justify-center size-8 rounded bg-muted text-muted-foreground text-xs font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(true); }}
|
||||
className="flex items-center justify-center size-8 rounded bg-muted text-muted-foreground text-xs font-medium hover:bg-accent hover:text-accent-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
+{remaining}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +217,25 @@ export function EmojiPackContent({ event }: EmojiPackContentProps) {
|
||||
{isAdded ? 'Added' : 'Add to Collection'}
|
||||
</Button>
|
||||
)}
|
||||
{isOwnPack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs gap-1.5"
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit dialog (lazy loaded) */}
|
||||
{editOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<EmojiPackDialog open={editOpen} onOpenChange={setEditOpen} editEvent={event} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Smile, Upload, Loader2, X } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { SortableList, SortableItem } from '@/components/SortableList';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
/** A single emoji entry in the pack being edited. */
|
||||
interface EmojiEntry {
|
||||
/** Client-side key for React list rendering. */
|
||||
id: string;
|
||||
shortcode: string;
|
||||
/** Display URL -- either a remote Blossom URL (for already-uploaded emojis) or a local blob URL (for pending files). */
|
||||
url: string;
|
||||
/** When set, this file still needs to be uploaded on submit. */
|
||||
file?: File;
|
||||
}
|
||||
|
||||
/** Convert a filename to a shortcode (alphanumeric, hyphens, underscores only). */
|
||||
function filenameToShortcode(filename: string): string {
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
const base = dotIndex > 0 ? filename.slice(0, dotIndex) : filename;
|
||||
return base
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Validate a shortcode against NIP-30 rules: alphanumeric, hyphens, underscores. */
|
||||
function isValidShortcode(s: string): boolean {
|
||||
return /^[a-zA-Z0-9_-]+$/.test(s);
|
||||
}
|
||||
|
||||
/** Convert a title into a URL-safe slug for the identifier. */
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
let entryCounter = 0;
|
||||
function nextEntryId(): string {
|
||||
return `entry-${++entryCounter}-${Date.now()}`;
|
||||
}
|
||||
|
||||
interface EmojiPackDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** When provided, the dialog opens in edit mode for an existing pack. */
|
||||
editEvent?: NostrEvent;
|
||||
}
|
||||
|
||||
export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile } = useUploadFile();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isEditMode = !!editEvent;
|
||||
|
||||
// Parse initial values from editEvent
|
||||
const initialData = useMemo(() => {
|
||||
if (!editEvent) return null;
|
||||
const identifier = editEvent.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
const name = editEvent.tags.find(([n]) => n === 'name')?.[1] ?? '';
|
||||
const emojis: EmojiEntry[] = [];
|
||||
for (const tag of editEvent.tags) {
|
||||
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
|
||||
emojis.push({ id: nextEntryId(), shortcode: tag[1], url: tag[2] });
|
||||
}
|
||||
}
|
||||
const about = editEvent.tags.find(([n]) => n === 'about')?.[1] ?? '';
|
||||
return { identifier, name, about, emojis };
|
||||
}, [editEvent]);
|
||||
|
||||
// Form state
|
||||
const [identifier, setIdentifier] = useState(initialData?.identifier ?? '');
|
||||
const [name, setName] = useState(initialData?.name ?? '');
|
||||
const [about, setAbout] = useState(initialData?.about ?? '');
|
||||
const [idTouched, setIdTouched] = useState(isEditMode);
|
||||
const [emojis, setEmojis] = useState<EmojiEntry[]>(initialData?.emojis ?? []);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const effectiveIdentifier = idTouched ? identifier : slugify(name);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setIdentifier(initialData?.identifier ?? '');
|
||||
setName(initialData?.name ?? '');
|
||||
setAbout(initialData?.about ?? '');
|
||||
setIdTouched(isEditMode);
|
||||
setEmojis((prev) => {
|
||||
for (const e of prev) {
|
||||
if (e.file) URL.revokeObjectURL(e.url);
|
||||
}
|
||||
return initialData?.emojis ?? [];
|
||||
});
|
||||
setIsDragOver(false);
|
||||
setIsSubmitting(false);
|
||||
}, [initialData, isEditMode]);
|
||||
|
||||
const handleNameChange = useCallback((value: string) => {
|
||||
setName(value);
|
||||
if (!idTouched) {
|
||||
setIdentifier(slugify(value));
|
||||
}
|
||||
}, [idTouched]);
|
||||
|
||||
const handleIdChange = useCallback((value: string) => {
|
||||
setIdTouched(true);
|
||||
setIdentifier(value);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
}, [onOpenChange, resetForm]);
|
||||
|
||||
/** Add image files to the emoji list as local previews (no upload yet). */
|
||||
const addFiles = useCallback((files: File[]) => {
|
||||
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
toast({ title: 'No images', description: 'No image files found.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newEntries: EmojiEntry[] = imageFiles.map((file) => ({
|
||||
id: nextEntryId(),
|
||||
shortcode: filenameToShortcode(file.name),
|
||||
url: URL.createObjectURL(file),
|
||||
file,
|
||||
}));
|
||||
|
||||
setEmojis((prev) => [...prev, ...newEntries]);
|
||||
}, [toast]);
|
||||
|
||||
/** Handle drop zone events. */
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const items = e.dataTransfer.items;
|
||||
const files: File[] = [];
|
||||
|
||||
if (items) {
|
||||
const entries: FileSystemEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry?.();
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
} else {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
const readAllEntries = async (dirEntries: FileSystemEntry[]): Promise<File[]> => {
|
||||
const allFiles: File[] = [];
|
||||
|
||||
const readEntry = (entry: FileSystemEntry): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
(entry as FileSystemFileEntry).file((file) => {
|
||||
allFiles.push(file);
|
||||
resolve();
|
||||
}, () => resolve());
|
||||
} else if (entry.isDirectory) {
|
||||
const dirReader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
dirReader.readEntries(async (childEntries) => {
|
||||
await Promise.all(childEntries.map(readEntry));
|
||||
resolve();
|
||||
}, () => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
await Promise.all(dirEntries.map(readEntry));
|
||||
return allFiles;
|
||||
};
|
||||
|
||||
readAllEntries(entries).then(addFiles);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < e.dataTransfer.files.length; i++) {
|
||||
files.push(e.dataTransfer.files[i]);
|
||||
}
|
||||
addFiles(files);
|
||||
}, [addFiles]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
/** File input handler. */
|
||||
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList) return;
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
files.push(fileList[i]);
|
||||
}
|
||||
addFiles(files);
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
|
||||
/** Update a single emoji's shortcode. */
|
||||
const updateShortcode = useCallback((id: string, newShortcode: string) => {
|
||||
setEmojis((prev) =>
|
||||
prev.map((e) => (e.id === id ? { ...e, shortcode: newShortcode } : e)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Remove an emoji entry. */
|
||||
const removeEmoji = useCallback((id: string) => {
|
||||
setEmojis((prev) => {
|
||||
const removed = prev.find((e) => e.id === id);
|
||||
if (removed?.file) URL.revokeObjectURL(removed.url);
|
||||
return prev.filter((e) => e.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** Reorder emojis after a drag completes (from SortableList). */
|
||||
const handleReorder = useCallback((reordered: EmojiEntry[]) => {
|
||||
setEmojis(reordered);
|
||||
}, []);
|
||||
|
||||
/** Upload all pending files, then publish the emoji pack event. */
|
||||
const handlePublish = useCallback(async () => {
|
||||
const resolvedId = effectiveIdentifier.trim();
|
||||
if (!user || !resolvedId || emojis.length === 0) return;
|
||||
|
||||
// Validate all shortcodes
|
||||
const invalid = emojis.find((e) => !isValidShortcode(e.shortcode));
|
||||
if (invalid) {
|
||||
toast({
|
||||
title: 'Invalid shortcode',
|
||||
description: `"${invalid.shortcode}" contains invalid characters. Use only letters, numbers, hyphens, and underscores.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for duplicate shortcodes
|
||||
const seen = new Set<string>();
|
||||
for (const e of emojis) {
|
||||
if (seen.has(e.shortcode)) {
|
||||
toast({
|
||||
title: 'Duplicate shortcode',
|
||||
description: `"${e.shortcode}" appears more than once. Each shortcode must be unique.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
seen.add(e.shortcode);
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Upload all pending files in parallel
|
||||
const pendingEntries = emojis.filter((e) => e.file);
|
||||
const uploadResults = new Map<string, string>();
|
||||
|
||||
if (pendingEntries.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
pendingEntries.map(async (entry) => {
|
||||
const [[, url]] = await uploadFile(entry.file!);
|
||||
return { id: entry.id, url };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
uploadResults.set(result.value.id, result.value.url);
|
||||
}
|
||||
}
|
||||
|
||||
const failedCount = pendingEntries.length - uploadResults.size;
|
||||
if (failedCount > 0) {
|
||||
toast({
|
||||
title: 'Some uploads failed',
|
||||
description: `${failedCount} file${failedCount !== 1 ? 's' : ''} failed to upload. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the final emoji list with resolved URLs
|
||||
const resolvedEmojis = emojis.map((e) => ({
|
||||
shortcode: e.shortcode,
|
||||
url: uploadResults.get(e.id) ?? e.url,
|
||||
}));
|
||||
|
||||
// For edit mode, fetch fresh event to preserve any tags we don't manage
|
||||
let preservedTags: string[][] = [];
|
||||
if (isEditMode) {
|
||||
const fresh = await fetchFreshEvent(nostr, {
|
||||
kinds: [30030],
|
||||
authors: [user.pubkey],
|
||||
'#d': [resolvedId],
|
||||
});
|
||||
if (fresh) {
|
||||
preservedTags = fresh.tags.filter(
|
||||
([n]) => n !== 'd' && n !== 'name' && n !== 'about' && n !== 'emoji',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
['d', resolvedId],
|
||||
...(name.trim() ? [['name', name.trim()]] : []),
|
||||
...(about.trim() ? [['about', about.trim()]] : []),
|
||||
...preservedTags,
|
||||
...resolvedEmojis.map((e) => ['emoji', e.shortcode, e.url]),
|
||||
];
|
||||
|
||||
await publishEvent({
|
||||
kind: 30030,
|
||||
content: '',
|
||||
tags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
|
||||
// Clean up blob URLs
|
||||
for (const e of emojis) {
|
||||
if (e.file) URL.revokeObjectURL(e.url);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['custom-emojis'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['emoji-list'] });
|
||||
|
||||
toast({
|
||||
title: isEditMode ? 'Emoji pack updated!' : 'Emoji pack created!',
|
||||
description: `"${name.trim() || resolvedId}" with ${resolvedEmojis.length} emoji${resolvedEmojis.length !== 1 ? 's' : ''}.`,
|
||||
});
|
||||
|
||||
handleOpenChange(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Failed to publish',
|
||||
description: 'Could not publish the emoji pack. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [user, effectiveIdentifier, name, about, emojis, isEditMode, nostr, publishEvent, uploadFile, queryClient, toast, handleOpenChange]);
|
||||
|
||||
// Validation
|
||||
const pendingCount = emojis.filter((e) => e.file).length;
|
||||
const hasValidEmojis = emojis.length > 0 && emojis.every((e) => e.shortcode);
|
||||
const canPublish = effectiveIdentifier.trim() && hasValidEmojis && !isSubmitting;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg gap-0 p-0 overflow-hidden">
|
||||
<DialogHeader className="px-5 pt-5 pb-3">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Smile className="size-5 text-primary" />
|
||||
{isEditMode ? 'Edit Emoji Pack' : 'Create Emoji Pack'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditMode
|
||||
? 'Update your custom emoji set. Drag and drop images to add more emojis.'
|
||||
: 'Create a custom emoji set. Drag and drop images or a folder to populate it.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="px-5 pb-5 space-y-4">
|
||||
{/* Title & ID side-by-side */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="emoji-pack-name">Title</Label>
|
||||
<Input
|
||||
id="emoji-pack-name"
|
||||
placeholder="e.g. Blobcats"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="emoji-pack-identifier">ID *</Label>
|
||||
<Input
|
||||
id="emoji-pack-identifier"
|
||||
placeholder="e.g. blobcats"
|
||||
value={idTouched ? identifier : effectiveIdentifier}
|
||||
onChange={(e) => handleIdChange(e.target.value)}
|
||||
disabled={isEditMode || isSubmitting}
|
||||
className={`font-mono text-sm ${isEditMode ? 'text-muted-foreground' : ''}`}
|
||||
/>
|
||||
{isEditMode && (
|
||||
<p className="text-xs text-muted-foreground">Cannot be changed.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="emoji-pack-about">Description</Label>
|
||||
<Textarea
|
||||
id="emoji-pack-about"
|
||||
placeholder="What's in this emoji pack?"
|
||||
value={about}
|
||||
onChange={(e) => setAbout(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="min-h-[60px] resize-none text-sm"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Drop zone for adding emojis */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Emojis</Label>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => !isSubmitting && fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onKeyDown={(e) => { if (!isSubmitting && (e.key === 'Enter' || e.key === ' ')) fileInputRef.current?.click(); }}
|
||||
className={`relative flex flex-col items-center justify-center w-full border-2 border-dashed rounded-xl transition-colors cursor-pointer overflow-hidden ${
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-secondary/5 hover:bg-secondary/10'
|
||||
} ${emojis.length > 0 ? 'h-20' : 'h-28'} ${isSubmitting ? 'pointer-events-none opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
|
||||
<Upload className="size-4 opacity-50" />
|
||||
<span className="text-xs text-center px-4">
|
||||
Drop images or a folder here, or click to browse
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emoji list */}
|
||||
{emojis.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{emojis.length} emoji{emojis.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="rounded-xl border border-border overflow-hidden divide-y divide-border">
|
||||
<SortableList
|
||||
items={emojis}
|
||||
getItemId={(emoji) => emoji.id}
|
||||
onReorder={handleReorder}
|
||||
renderItem={(emoji) => (
|
||||
<SortableItem
|
||||
key={emoji.id}
|
||||
id={emoji.id}
|
||||
enabled={!isSubmitting}
|
||||
className="items-center bg-background"
|
||||
draggingClassName="z-10 opacity-80 shadow-lg ring-2 ring-primary/20"
|
||||
gripClassName="w-7"
|
||||
>
|
||||
<div className="flex items-center gap-2 pr-2 py-1.5">
|
||||
<div className="size-8 shrink-0 rounded-md overflow-hidden bg-secondary/30 flex items-center justify-center">
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
className="size-8 object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
value={emoji.shortcode}
|
||||
onChange={(e) => updateShortcode(emoji.id, e.target.value)}
|
||||
className="h-7 text-xs font-mono px-1.5 border-none shadow-none focus-visible:ring-1"
|
||||
placeholder="shortcode"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeEmoji(emoji.id)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</SortableItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish button */}
|
||||
<Button
|
||||
onClick={handlePublish}
|
||||
disabled={!canPublish}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<><Loader2 className="size-4 animate-spin" /> {pendingCount > 0 ? `Uploading ${pendingCount} file${pendingCount !== 1 ? 's' : ''}...` : 'Publishing...'}</>
|
||||
) : (
|
||||
<><Smile className="size-4" /> {isEditMode ? 'Update Emoji Pack' : 'Create Emoji Pack'}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1082,10 +1082,12 @@ 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',
|
||||
35128: 'Nsite',
|
||||
31124: 'Blobbi',
|
||||
};
|
||||
|
||||
export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) {
|
||||
@@ -1108,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(
|
||||
|
||||
@@ -359,6 +359,7 @@ function SetupQuestionnaire({
|
||||
feedIncludeBadgeDefinitions: false,
|
||||
feedIncludeProfileBadges: false,
|
||||
feedIncludeVanish: true,
|
||||
feedIncludeBlobbi: true,
|
||||
followsFeedShowReplies: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isSyncDone } from "@/hooks/useInitialSync";
|
||||
import { parseBlossomServerList } from "@/lib/appBlossom";
|
||||
import { ACTIVE_THEME_KIND, parseActiveProfileTheme } from "@/lib/themeEvent";
|
||||
import type { ThemeConfig } from "@/themes";
|
||||
import { themePresets } from "@/themes";
|
||||
|
||||
|
||||
/**
|
||||
* NostrSync - Syncs user's Nostr data
|
||||
@@ -285,19 +285,7 @@ export function NostrSync() {
|
||||
const updates = { ...current };
|
||||
|
||||
if (encryptedSettings.theme) {
|
||||
// Migrate legacy theme values ("black", "pink") from older encrypted settings
|
||||
const remoteTheme = encryptedSettings.theme as string;
|
||||
if (remoteTheme in themePresets) {
|
||||
if (
|
||||
current.theme !== "custom" ||
|
||||
JSON.stringify(current.customTheme?.colors) !==
|
||||
JSON.stringify(themePresets[remoteTheme].colors)
|
||||
) {
|
||||
updates.theme = "custom";
|
||||
updates.customTheme = { colors: themePresets[remoteTheme].colors };
|
||||
changed = true;
|
||||
}
|
||||
} else if (encryptedSettings.theme !== current.theme) {
|
||||
if (encryptedSettings.theme !== current.theme) {
|
||||
updates.theme = encryptedSettings.theme;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NostrEvent } from "@nostrify/nostrify";
|
||||
import {
|
||||
Award,
|
||||
Camera,
|
||||
Egg,
|
||||
FileCode,
|
||||
FileText,
|
||||
GitBranch,
|
||||
@@ -24,6 +25,7 @@ import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState
|
||||
import { Link } from "react-router-dom";
|
||||
/** Lazy-loaded markdown-heavy components — keeps react-markdown + unified pipeline out of the main feed bundle. */
|
||||
const ArticleContent = lazy(() => import("@/components/ArticleContent").then(m => ({ default: m.ArticleContent })));
|
||||
const BlobbiStateCard = lazy(() => import("@/components/BlobbiStateCard").then(m => ({ default: m.BlobbiStateCard })));
|
||||
import {
|
||||
MusicPlaylistContent,
|
||||
MusicTrackContent,
|
||||
@@ -72,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";
|
||||
@@ -304,11 +307,13 @@ 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;
|
||||
const isZap = event.kind === 9735;
|
||||
const isProfile = event.kind === 0;
|
||||
const isBlobbiState = event.kind === 31124;
|
||||
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
|
||||
const isTextNote =
|
||||
!isVine &&
|
||||
@@ -333,11 +338,13 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isAudioKind &&
|
||||
!isDevKind &&
|
||||
!isZapstoreApp &&
|
||||
!isAppHandler &&
|
||||
!isEncryptedDM &&
|
||||
!isLetter &&
|
||||
!isVanish &&
|
||||
!isZap &&
|
||||
!isProfile;
|
||||
!isProfile &&
|
||||
!isBlobbiState;
|
||||
|
||||
// Kind 1 specific — images now render inline in NoteContent, only videos go to NoteMedia
|
||||
const videos = useMemo(
|
||||
@@ -528,12 +535,18 @@ 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 ? (
|
||||
<EncryptedLetterContent event={event} compact />
|
||||
) : isProfile ? (
|
||||
<ProfileCardContent event={event} />
|
||||
) : isBlobbiState ? (
|
||||
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
|
||||
<BlobbiStateCard event={event} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<TruncatedNoteContent
|
||||
event={event}
|
||||
@@ -781,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 && (
|
||||
@@ -862,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>
|
||||
|
||||
@@ -1994,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",
|
||||
@@ -2034,6 +2051,12 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
icon: Zap,
|
||||
action: "zapped",
|
||||
},
|
||||
31124: {
|
||||
icon: Egg,
|
||||
action: "updated their",
|
||||
noun: "Blobbi",
|
||||
nounRoute: "/blobbi",
|
||||
},
|
||||
};
|
||||
|
||||
/** Generic action header: icon · [author name] [action] [linked noun] */
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { ToastAction } from '@/components/ui/toast';
|
||||
import { parseChangelog } from '@/lib/changelog';
|
||||
|
||||
const STORAGE_KEY = 'ditto:app-version';
|
||||
|
||||
/** Fetch the first changelog item for the given version (or the latest entry). */
|
||||
async function fetchChangelogExcerpt(version: string): Promise<string | undefined> {
|
||||
try {
|
||||
const res = await fetch('/CHANGELOG.md');
|
||||
if (!res.ok) return undefined;
|
||||
const markdown = await res.text();
|
||||
const entries = parseChangelog(markdown);
|
||||
|
||||
// Try to find the entry matching the current version, otherwise use the first entry.
|
||||
const entry = entries.find((e) => e.version === version) ?? entries[0];
|
||||
if (!entry) return undefined;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compares the running app version against localStorage and shows a toast when the version changes. */
|
||||
export function VersionCheck() {
|
||||
useEffect(() => {
|
||||
const currentVersion = import.meta.env.VERSION;
|
||||
if (!currentVersion) return;
|
||||
|
||||
const storedVersion = localStorage.getItem(STORAGE_KEY);
|
||||
localStorage.setItem(STORAGE_KEY, currentVersion);
|
||||
|
||||
if (storedVersion && storedVersion !== currentVersion) {
|
||||
// Show the toast immediately, then enrich it with a changelog excerpt.
|
||||
const { update, id } = toast({
|
||||
title: `What's new in v${currentVersion}`,
|
||||
action: (
|
||||
<ToastAction altText="View changelog" asChild>
|
||||
<Link to="/changelog">See all</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
|
||||
fetchChangelogExcerpt(currentVersion).then((excerpt) => {
|
||||
if (excerpt) {
|
||||
update({
|
||||
id,
|
||||
title: `What's new in v${currentVersion}`,
|
||||
description: excerpt,
|
||||
action: (
|
||||
<ToastAction altText="View changelog" asChild>
|
||||
<Link to="/changelog">See all</Link>
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -148,6 +148,8 @@ export interface FeedSettings {
|
||||
feedIncludeProfileBadges: boolean;
|
||||
/** Include Request to Vanish events (kind 62) in the follows/global feed */
|
||||
feedIncludeVanish: boolean;
|
||||
/** Include Blobbi pet updates (kind 31124) in the follows/global feed */
|
||||
feedIncludeBlobbi: boolean;
|
||||
/** Include replies in the follows feed (default: true) */
|
||||
followsFeedShowReplies: boolean;
|
||||
}
|
||||
|
||||
@@ -34,14 +34,16 @@ export function useCustomEmojis() {
|
||||
if (listEvents.length === 0) return [];
|
||||
|
||||
const listEvent = listEvents[0];
|
||||
const emojis: CustomEmoji[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Step 2: Extract inline emoji tags
|
||||
// Collect all emojis with their source pack identifier so we can
|
||||
// detect shortcode collisions across packs and prefix them.
|
||||
interface RawEmoji { shortcode: string; url: string; packId: string }
|
||||
const raw: RawEmoji[] = [];
|
||||
|
||||
// Step 2: Extract inline emoji tags (no pack, so packId is empty)
|
||||
for (const tag of listEvent.tags) {
|
||||
if (tag[0] === 'emoji' && tag[1] && tag[2] && !seen.has(tag[1])) {
|
||||
seen.add(tag[1]);
|
||||
emojis.push({ shortcode: tag[1], url: tag[2] });
|
||||
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
|
||||
raw.push({ shortcode: tag[1], url: tag[2], packId: '' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +64,6 @@ export function useCustomEmojis() {
|
||||
}
|
||||
|
||||
if (packRefs.length > 0) {
|
||||
// Build filters for all referenced packs
|
||||
const filters = packRefs.map((ref) => ({
|
||||
kinds: [30030 as number],
|
||||
authors: [ref.pubkey],
|
||||
@@ -74,10 +75,10 @@ export function useCustomEmojis() {
|
||||
const packEvents = await nostr.query(filters, { signal });
|
||||
|
||||
for (const packEvent of packEvents) {
|
||||
const packId = packEvent.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
for (const tag of packEvent.tags) {
|
||||
if (tag[0] === 'emoji' && tag[1] && tag[2] && !seen.has(tag[1])) {
|
||||
seen.add(tag[1]);
|
||||
emojis.push({ shortcode: tag[1], url: tag[2] });
|
||||
if (tag[0] === 'emoji' && tag[1] && tag[2]) {
|
||||
raw.push({ shortcode: tag[1], url: tag[2], packId });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +87,42 @@ export function useCustomEmojis() {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Detect collisions and prefix with pack identifier.
|
||||
// First pass: find shortcodes that appear with different URLs.
|
||||
const byShortcode = new Map<string, RawEmoji[]>();
|
||||
for (const entry of raw) {
|
||||
const group = byShortcode.get(entry.shortcode);
|
||||
if (group) {
|
||||
group.push(entry);
|
||||
} else {
|
||||
byShortcode.set(entry.shortcode, [entry]);
|
||||
}
|
||||
}
|
||||
|
||||
const collisions = new Set<string>();
|
||||
for (const [shortcode, group] of byShortcode) {
|
||||
const uniqueUrls = new Set(group.map((e) => e.url));
|
||||
if (uniqueUrls.size > 1) {
|
||||
collisions.add(shortcode);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build final list. For collisions, prefix with packId.
|
||||
// Deduplicate by final shortcode (first-seen wins after prefixing).
|
||||
const emojis: CustomEmoji[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of raw) {
|
||||
const finalShortcode = collisions.has(entry.shortcode) && entry.packId
|
||||
? `${entry.packId}-${entry.shortcode}`
|
||||
: entry.shortcode;
|
||||
|
||||
if (!seen.has(finalShortcode)) {
|
||||
seen.add(finalShortcode);
|
||||
emojis.push({ shortcode: finalShortcode, url: entry.url });
|
||||
}
|
||||
}
|
||||
|
||||
return emojis;
|
||||
},
|
||||
enabled: !!user,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export type TagCategory =
|
||||
| 'state' // Lifecycle state (stage, state, timestamps)
|
||||
| 'progression' // Progress tracking (experience, care_streak)
|
||||
| 'task' // Task system (task, task_completed, state_started_at)
|
||||
| 'social' // Social flags (visible_to_others, breeding_ready)
|
||||
| 'social' // Social flags (breeding_ready)
|
||||
| 'evolution' // Evolution-specific (adult_type)
|
||||
| 'extension'; // Extension tags (theme, crossover_app)
|
||||
|
||||
@@ -509,19 +509,6 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SOCIAL / FLAG TAGS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
tag: 'visible_to_others',
|
||||
description: 'Whether the Blobbi is publicly visible to other users',
|
||||
category: 'social',
|
||||
required: false,
|
||||
stages: ['egg', 'baby', 'adult'],
|
||||
persistent: true,
|
||||
source: 'user',
|
||||
regenerable: false,
|
||||
format: 'true | false',
|
||||
defaultValue: 'true',
|
||||
notes: 'User preference. Persists across stages.',
|
||||
},
|
||||
{
|
||||
tag: 'breeding_ready',
|
||||
description: 'Whether the Blobbi is eligible for breeding',
|
||||
|
||||
+2
-6
@@ -253,8 +253,6 @@ export interface BlobbiCompanion {
|
||||
lastDecayAt: number | undefined;
|
||||
/** Stats (0-100) */
|
||||
stats: Partial<BlobbiStats>;
|
||||
/** Whether the Blobbi is publicly visible */
|
||||
visibleToOthers: boolean;
|
||||
/** Generation number */
|
||||
generation: number | undefined;
|
||||
/** Breeding eligibility */
|
||||
@@ -939,7 +937,6 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined
|
||||
hygiene: parseNumericTag(tags, 'hygiene'),
|
||||
energy: parseNumericTag(tags, 'energy'),
|
||||
},
|
||||
visibleToOthers: parseBooleanTag(tags, 'visible_to_others', true),
|
||||
generation: parseNumericTag(tags, 'generation'),
|
||||
breedingReady: parseBooleanTag(tags, 'breeding_ready', false),
|
||||
experience: parseNumericTag(tags, 'experience'),
|
||||
@@ -1036,7 +1033,6 @@ export function buildEggTags(
|
||||
['stage', 'egg'],
|
||||
['state', 'active'],
|
||||
['seed', seed],
|
||||
['visible_to_others', 'true'],
|
||||
['generation', '1'],
|
||||
['breeding_ready', 'false'],
|
||||
['experience', '0'],
|
||||
@@ -1084,7 +1080,7 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([
|
||||
// Progression tags
|
||||
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
|
||||
// Social/flag tags
|
||||
'visible_to_others', 'breeding_ready',
|
||||
'breeding_ready',
|
||||
// Task system tags (removed after stage transitions)
|
||||
'state_started_at', 'task', 'task_completed',
|
||||
// Evolution tags (adult only)
|
||||
@@ -1463,7 +1459,7 @@ export function buildMigrationTags(
|
||||
// Progression tags
|
||||
'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day',
|
||||
// Social/flag tags
|
||||
'visible_to_others', 'generation', 'breeding_ready',
|
||||
'generation', 'breeding_ready',
|
||||
// Personality tags (preserve if they exist, do NOT generate)
|
||||
'personality', 'trait', 'favorite_food', 'voice_type', 'mood',
|
||||
// Evolution tags
|
||||
|
||||
+12
-4
@@ -11,6 +11,14 @@ interface ChangelogEntry {
|
||||
}[];
|
||||
}
|
||||
|
||||
/** Apply basic typographic transformations to a changelog item string. */
|
||||
function prettify(text: string): string {
|
||||
return text
|
||||
.replace(/ -- /g, ' \u2014 ') // space-dash-dash-space → em dash
|
||||
.replace(/(\w)--(\w)/g, '$1\u2013$2') // word--word → en dash
|
||||
.replace(/ (\S+)$/, '\u00A0$1'); // prevent orphaned last word
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Keep a Changelog formatted markdown string into structured data.
|
||||
* @see https://keepachangelog.com/
|
||||
@@ -43,10 +51,10 @@ function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
if (itemMatch && current) {
|
||||
const section = current.sections[current.sections.length - 1];
|
||||
if (section) {
|
||||
section.items.push(itemMatch[1]);
|
||||
section.items.push(prettify(itemMatch[1]));
|
||||
} else {
|
||||
// Item without a category heading — treat as "Changed"
|
||||
current.sections.push({ category: 'Changed', items: [itemMatch[1]] });
|
||||
current.sections.push({ category: 'Changed', items: [prettify(itemMatch[1])] });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -58,10 +66,10 @@ function parseChangelog(markdown: string): ChangelogEntry[] {
|
||||
const section = current.sections[current.sections.length - 1];
|
||||
if (section) {
|
||||
// Append to last item or add new item
|
||||
section.items.push(trimmed);
|
||||
section.items.push(prettify(trimmed));
|
||||
} else {
|
||||
// Freeform text under version with no category — store in a generic section
|
||||
current.sections.push({ category: 'Changed', items: [trimmed] });
|
||||
current.sections.push({ category: 'Changed', items: [prettify(trimmed)] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -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
|
||||
{
|
||||
@@ -461,13 +460,25 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
// Blobbi (feed-only — dedicated page at /blobbi)
|
||||
{
|
||||
kind: 31124,
|
||||
id: 'blobbi',
|
||||
feedKey: 'feedIncludeBlobbi',
|
||||
label: 'Blobbi',
|
||||
description: 'Blobbi virtual pet updates',
|
||||
addressable: true,
|
||||
section: 'whimsy',
|
||||
feedOnly: true,
|
||||
blurb: 'Virtual pet companions living on Nostr. Care for them, watch them grow, and share their journey.',
|
||||
},
|
||||
// Development
|
||||
{
|
||||
kind: 30617,
|
||||
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',
|
||||
@@ -536,6 +547,7 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
|
||||
30008: 'profile badges',
|
||||
30817: 'repository issue',
|
||||
32267: 'app',
|
||||
31990: 'app',
|
||||
30063: 'release',
|
||||
};
|
||||
|
||||
|
||||
@@ -93,6 +93,8 @@ export function shouldHideFeedEvent(event: NostrEvent): boolean {
|
||||
if (event.kind === 37381 && event.tags.some(([n, v]) => n === 't' && v === 'unlisted')) return true;
|
||||
// Hidden treasures (kind 37516)
|
||||
if (event.kind === 37516 && event.tags.some(([n, v]) => n === 't' && v === 'hidden')) return true;
|
||||
// Emoji packs (kind 30030) without at least one valid emoji tag
|
||||
if (event.kind === 30030 && !event.tags.some(([n, sc, url]) => n === 'emoji' && sc && url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
+5
-11
@@ -8,11 +8,6 @@ import type { CoreThemeColors, ThemeConfig, ThemesConfig } from '@/themes';
|
||||
/** Zod schema for Theme validation */
|
||||
export const ThemeSchema = z.enum(['dark', 'light', 'system', 'custom']) satisfies z.ZodType<Theme>;
|
||||
|
||||
/**
|
||||
* Accepts current theme values as well as legacy values ("black", "pink")
|
||||
* from older configs. Consumers should migrate legacy values to "custom".
|
||||
*/
|
||||
export const ThemeSchemaCompat = z.enum(['dark', 'light', 'system', 'custom', 'black', 'pink']);
|
||||
|
||||
/** HSL value string like "258 70% 55%" */
|
||||
const HslValue = z.string().regex(/^\d/);
|
||||
@@ -182,6 +177,7 @@ export const FeedSettingsSchema = z.looseObject({
|
||||
feedIncludePodcastTrailers: z.boolean().optional(),
|
||||
showDevelopment: z.boolean().optional(),
|
||||
feedIncludeDevelopment: z.boolean().optional(),
|
||||
feedIncludeBlobbi: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Schema for a NIP-01 filter object (lenient — allows variable placeholder strings). */
|
||||
@@ -207,10 +203,8 @@ export const SavedFeedSchema = z.object({
|
||||
/**
|
||||
* Zod schema for the full AppConfig stored in localStorage.
|
||||
*
|
||||
* Uses compat sub-schemas (ThemeSchemaCompat, ThemeConfigCompatSchema) so
|
||||
* legacy values parse successfully. Migration from legacy theme values
|
||||
* ("black", "pink") to "custom" + customTheme is handled downstream by
|
||||
* the AppProvider deserializer.
|
||||
* Uses ThemeConfigCompatSchema for the customTheme field so legacy
|
||||
* 19-token color objects still parse successfully.
|
||||
*/
|
||||
export const AppConfigSchema = z.object({
|
||||
appName: z.string().optional(),
|
||||
@@ -219,7 +213,7 @@ export const AppConfigSchema = z.object({
|
||||
clientName: z.string().optional(),
|
||||
client: z.string().optional(),
|
||||
magicMouse: z.boolean().optional(),
|
||||
theme: ThemeSchemaCompat,
|
||||
theme: ThemeSchema,
|
||||
customTheme: ThemeConfigCompatSchema.optional(),
|
||||
autoShareTheme: z.boolean(),
|
||||
themes: ThemesConfigSchema.optional(),
|
||||
@@ -297,7 +291,7 @@ export const ContentFilterSchema = z.object({
|
||||
* Uses looseObject to preserve unknown keys from newer app versions.
|
||||
*/
|
||||
export const EncryptedSettingsSchema = z.looseObject({
|
||||
theme: ThemeSchemaCompat.optional(),
|
||||
theme: ThemeSchema.optional(),
|
||||
customTheme: ThemeConfigCompatSchema.optional(),
|
||||
autoShareTheme: z.boolean().optional(),
|
||||
useAppRelays: z.boolean().optional(),
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Egg,
|
||||
Repeat2,
|
||||
Scroll,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Smile,
|
||||
@@ -133,6 +134,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
||||
requiresAuth: true,
|
||||
},
|
||||
{ id: "settings", label: "Settings", path: "/settings", icon: Settings },
|
||||
{ id: "changelog", label: "Changelog", path: "/changelog", icon: ScrollText },
|
||||
{
|
||||
id: "letters",
|
||||
label: "Letters",
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
+143
-382
@@ -1,8 +1,8 @@
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Egg, Moon, Sun, Eye, EyeOff, Loader2, RefreshCw, Check, Info, Target, ShoppingBag, Package, Sparkles, HeartHandshake, Plus, Camera, ArrowLeft, AlertTriangle, X, Footprints, Wrench, Theater } from 'lucide-react';
|
||||
// TODO: Re-import when features are implemented: Footprints, PictureInPicture2
|
||||
// Note: Eye/EyeOff kept for BlobbiSelectorCard visibility badge display
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Egg, Moon, Sun, Loader2, RefreshCw, Check, Target, Package, Sparkles, HeartHandshake, Plus, Camera, ArrowLeft, AlertTriangle, X, Footprints, Wrench, Theater, MoreHorizontal, ExternalLink } from 'lucide-react';
|
||||
// Note: Sparkles kept for BlobbiBottomBar center action button
|
||||
// Note: Plus kept for AdoptAnotherBlobbiCard
|
||||
// Note: AlertTriangle kept for stat warning indicators
|
||||
@@ -25,7 +25,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose } from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { BlobbiPhotoModal } from '@/blobbi/ui/BlobbiPhotoModal';
|
||||
import { useBlobbiCompanionData } from '@/blobbi/companion/hooks/useBlobbiCompanionData';
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
|
||||
import { BlobbiShopModal } from '@/blobbi/shop/components/BlobbiShopModal';
|
||||
import { BlobbiInventoryModal } from '@/blobbi/shop/components/BlobbiInventoryModal';
|
||||
|
||||
import {
|
||||
BlobbiActionsModal,
|
||||
BlobbiActionInventoryModal,
|
||||
@@ -842,6 +842,13 @@ function BlobbiDashboard({
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
const isEgg = companion.stage === 'egg';
|
||||
|
||||
// Build naddr for linking to the Blobbi's detail page
|
||||
const blobbiNaddr = useMemo(() => nip19.naddrEncode({
|
||||
kind: KIND_BLOBBI_STATE,
|
||||
pubkey: companion.event.pubkey,
|
||||
identifier: companion.d,
|
||||
}), [companion.event.pubkey, companion.d]);
|
||||
|
||||
// Derive available stages from all companions (for daily mission filtering)
|
||||
const availableStages = useMemo(() => {
|
||||
const stages = new Set<'egg' | 'baby' | 'adult'>();
|
||||
@@ -863,8 +870,6 @@ function BlobbiDashboard({
|
||||
const [showActionsModal, setShowActionsModal] = useState(false);
|
||||
const [showMissionsModal, setShowMissionsModal] = useState(false);
|
||||
const [showShopModal, setShowShopModal] = useState(false);
|
||||
const [showInventoryModal, setShowInventoryModal] = useState(false);
|
||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||
const [showPhotoModal, setShowPhotoModal] = useState(false);
|
||||
|
||||
// DEV ONLY: Emotion panel state
|
||||
@@ -1319,8 +1324,8 @@ function BlobbiDashboard({
|
||||
setActionOverrideEmotion(getActionEmotion(action as ActionType));
|
||||
try {
|
||||
await onUseItem(itemId, action, quantity);
|
||||
// Close the inventory modal on success
|
||||
setShowInventoryModal(false);
|
||||
// Close the shop modal on success (inventory is a tab within it)
|
||||
setShowShopModal(false);
|
||||
} finally {
|
||||
setUsingItemId(null);
|
||||
// Clear action emotion after a brief delay for visual feedback
|
||||
@@ -1344,40 +1349,7 @@ function BlobbiDashboard({
|
||||
{/* Hero Section */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 py-4 sm:px-6">
|
||||
{/* Floating Dashboard Controls */}
|
||||
<BlobbiDashboardFloatingControls
|
||||
stage={companion.stage}
|
||||
onSetAsCompanion={handleSetAsCompanion}
|
||||
isCurrentCompanion={isCurrentCompanion}
|
||||
isUpdatingCompanion={isUpdatingCompanion}
|
||||
onTakePhoto={() => setShowPhotoModal(true)}
|
||||
onOpenPiP={() => console.log('TODO: open PiP')}
|
||||
onEvolve={
|
||||
// For eggs not yet incubating: show incubation dialog
|
||||
// For eggs incubating with all tasks complete: hatch action handled in HatchTasksPanel
|
||||
// For baby not yet evolving: show evolution dialog
|
||||
// For baby evolving with all tasks complete: evolve action handled in MissionsModal
|
||||
canStartIncubation
|
||||
? () => setShowIncubationDialog(true)
|
||||
: canStartEvolution
|
||||
? () => setShowEvolutionDialog(true)
|
||||
: isEgg
|
||||
? onHatch
|
||||
: onEvolve
|
||||
}
|
||||
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
|
||||
onInfo={() => setShowInfoModal(true)}
|
||||
// Hide button when actively incubating or evolving (actions are in MissionsModal instead)
|
||||
hideEvolveButton={isIncubating || isEvolvingState}
|
||||
// When canStartIncubation or canStartEvolution is true, the button triggers the respective dialog
|
||||
isIncubationAction={canStartIncubation}
|
||||
isEvolutionAction={canStartEvolution}
|
||||
// DEV ONLY: Instant stage transition (bypasses tasks)
|
||||
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
|
||||
// DEV ONLY: Open state editor modal
|
||||
onDevOpenEditor={() => setShowDevEditor(true)}
|
||||
// DEV ONLY: Open emotion tester panel
|
||||
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
|
||||
/>
|
||||
<BlobbiDashboardFloatingControls />
|
||||
|
||||
{/* Blobbi Name */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
@@ -1485,11 +1457,32 @@ function BlobbiDashboard({
|
||||
onMissionsClick={() => setShowMissionsModal(true)}
|
||||
onActionsClick={() => setShowActionsModal(true)}
|
||||
onShopClick={() => setShowShopModal(true)}
|
||||
onInventoryClick={() => setShowInventoryModal(true)}
|
||||
needyBlobbiesCount={companions.filter(companionNeedsCare).length}
|
||||
isInTaskProcess={isInTaskProcess}
|
||||
remainingTasksCount={remainingTasksCount}
|
||||
allTasksComplete={allTasksComplete}
|
||||
stage={companion.stage}
|
||||
blobbiNaddr={blobbiNaddr}
|
||||
onSetAsCompanion={handleSetAsCompanion}
|
||||
isCurrentCompanion={isCurrentCompanion}
|
||||
isUpdatingCompanion={isUpdatingCompanion}
|
||||
onTakePhoto={() => setShowPhotoModal(true)}
|
||||
onEvolve={
|
||||
canStartIncubation
|
||||
? () => setShowIncubationDialog(true)
|
||||
: canStartEvolution
|
||||
? () => setShowEvolutionDialog(true)
|
||||
: isEgg
|
||||
? onHatch
|
||||
: onEvolve
|
||||
}
|
||||
isTransitioning={isHatching || isEvolving || isStartingIncubation || isStartingEvolution}
|
||||
hideEvolveButton={isIncubating || isEvolvingState}
|
||||
isIncubationAction={canStartIncubation}
|
||||
isEvolutionAction={canStartEvolution}
|
||||
onDevInstantTransition={isEgg ? onHatch : isBaby ? onEvolve : undefined}
|
||||
onDevOpenEditor={() => setShowDevEditor(true)}
|
||||
onDevOpenEmotionPanel={() => setShowEmotionPanel(true)}
|
||||
/>
|
||||
|
||||
{/* Blobbi Selector Modal */}
|
||||
@@ -1605,30 +1598,16 @@ function BlobbiDashboard({
|
||||
availableStages={availableStages}
|
||||
/>
|
||||
|
||||
{/* Shop Modal */}
|
||||
{/* Shop & Inventory Modal (unified) */}
|
||||
<BlobbiShopModal
|
||||
open={showShopModal}
|
||||
onOpenChange={setShowShopModal}
|
||||
profile={profile}
|
||||
/>
|
||||
|
||||
{/* Inventory Modal */}
|
||||
<BlobbiInventoryModal
|
||||
open={showInventoryModal}
|
||||
onOpenChange={setShowInventoryModal}
|
||||
profile={profile}
|
||||
companion={companion}
|
||||
onUseItem={handleUseItemFromInventory}
|
||||
isUsingItem={isUsingItem}
|
||||
/>
|
||||
|
||||
{/* Blobbi Info Modal */}
|
||||
<BlobbiInfoModal
|
||||
open={showInfoModal}
|
||||
onOpenChange={setShowInfoModal}
|
||||
companion={companion}
|
||||
/>
|
||||
|
||||
{/* Blobbi Post Modal - for hatch or evolve task */}
|
||||
<BlobbiPostModal
|
||||
open={showPostModal}
|
||||
@@ -1719,58 +1698,6 @@ function QuickActionButton({ children, tooltip, onClick, disabled, loading }: Qu
|
||||
|
||||
// ─── Dashboard Floating Controls ──────────────────────────────────────────────
|
||||
|
||||
/** Button definition for floating action buttons */
|
||||
interface FloatingActionDef {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
onClick: () => void;
|
||||
variant?: 'default' | 'accent';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface BlobbiDashboardFloatingControlsProps {
|
||||
stage: 'egg' | 'baby' | 'adult';
|
||||
onBack?: () => void;
|
||||
onSetAsCompanion: () => void;
|
||||
/** Whether this Blobbi is currently set as the user's companion */
|
||||
isCurrentCompanion: boolean;
|
||||
/** Whether the companion update is in progress */
|
||||
isUpdatingCompanion?: boolean;
|
||||
onTakePhoto: () => void;
|
||||
onOpenPiP: () => void;
|
||||
onEvolve: () => void;
|
||||
/** Whether a stage transition is in progress (hatch or evolve) */
|
||||
isTransitioning?: boolean;
|
||||
onInfo: () => void;
|
||||
/** Whether to hide the evolve/hatch button (e.g., when incubating or evolving) */
|
||||
hideEvolveButton?: boolean;
|
||||
/** Whether the button should show incubation action (for eggs not yet incubating) */
|
||||
isIncubationAction?: boolean;
|
||||
/** Whether the button should show evolution action (for babies not yet evolving) */
|
||||
isEvolutionAction?: boolean;
|
||||
/** DEV ONLY: Instant stage transition callback (bypasses tasks) */
|
||||
onDevInstantTransition?: () => void;
|
||||
/** DEV ONLY: Open state editor callback */
|
||||
onDevOpenEditor?: () => void;
|
||||
/** DEV ONLY: Open emotion tester callback */
|
||||
onDevOpenEmotionPanel?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate icon for the evolve/hatch button based on stage and incubation state.
|
||||
* - egg stage (not incubating): Egg icon (start incubation action)
|
||||
* - egg stage (incubating): Egg icon (hatching action)
|
||||
* - baby/adult stages: Sparkles icon (evolution/transformation)
|
||||
*/
|
||||
function getEvolveIcon(stage: 'egg' | 'baby' | 'adult', _isIncubationAction?: boolean): React.ReactNode {
|
||||
if (stage === 'egg') {
|
||||
return <Egg className="size-4" />;
|
||||
}
|
||||
// Sparkles communicates magical transformation, fitting the Blobbi theme
|
||||
return <Sparkles className="size-4" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate tooltip for the evolve/hatch button based on stage and action state.
|
||||
*/
|
||||
@@ -1788,183 +1715,15 @@ function getEvolveTooltip(
|
||||
return 'Evolve';
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating action controls for the Blobbi dashboard.
|
||||
* Renders top-left and top-right button clusters.
|
||||
*/
|
||||
function BlobbiDashboardFloatingControls({
|
||||
stage,
|
||||
onBack,
|
||||
onSetAsCompanion,
|
||||
isCurrentCompanion,
|
||||
isUpdatingCompanion = false,
|
||||
onTakePhoto,
|
||||
onOpenPiP: _onOpenPiP, // TODO: Re-enable when PiP feature is implemented
|
||||
onEvolve,
|
||||
isTransitioning = false,
|
||||
onInfo,
|
||||
hideEvolveButton = false,
|
||||
isIncubationAction = false,
|
||||
isEvolutionAction = false,
|
||||
onDevInstantTransition,
|
||||
onDevOpenEditor,
|
||||
onDevOpenEmotionPanel,
|
||||
}: BlobbiDashboardFloatingControlsProps) {
|
||||
// Left-side buttons
|
||||
const leftButtons: FloatingActionDef[] = [
|
||||
...(onBack ? [{
|
||||
id: 'back',
|
||||
icon: <ArrowLeft className="size-4" />,
|
||||
tooltip: 'Go Back',
|
||||
onClick: onBack,
|
||||
}] : []),
|
||||
];
|
||||
|
||||
// Check if this Blobbi can be set as companion (eggs cannot)
|
||||
const canBeCompanion = stage !== 'egg';
|
||||
|
||||
// Right-side buttons (top cluster)
|
||||
const rightButtons: FloatingActionDef[] = [
|
||||
// Only show "Set as companion" for baby/adult (eggs cannot be companions)
|
||||
...(canBeCompanion ? [{
|
||||
id: 'set-companion',
|
||||
icon: <Footprints className={cn('size-4', isCurrentCompanion && 'text-green-500')} />,
|
||||
tooltip: isCurrentCompanion
|
||||
? 'Current Companion'
|
||||
: 'Set as Companion',
|
||||
onClick: onSetAsCompanion,
|
||||
disabled: isUpdatingCompanion,
|
||||
}] : []),
|
||||
{
|
||||
id: 'photo',
|
||||
icon: <Camera className="size-4" />,
|
||||
tooltip: 'Take a Photo',
|
||||
onClick: onTakePhoto,
|
||||
},
|
||||
// TODO: Re-enable when PiP feature is implemented
|
||||
// {
|
||||
// id: 'pip',
|
||||
// icon: <PictureInPicture2 className="size-4" />,
|
||||
// tooltip: 'Open PiP',
|
||||
// onClick: onOpenPiP,
|
||||
// },
|
||||
{
|
||||
id: 'info',
|
||||
icon: <Info className="size-4" />,
|
||||
tooltip: 'Blobbi Info',
|
||||
onClick: onInfo,
|
||||
},
|
||||
];
|
||||
|
||||
// Evolve/Hatch/Incubation button (emphasized, at the bottom of right cluster)
|
||||
// Icon and tooltip are stage-aware and action-state-aware
|
||||
const evolveButton: FloatingActionDef = {
|
||||
id: 'evolve',
|
||||
icon: getEvolveIcon(stage, isIncubationAction),
|
||||
tooltip: getEvolveTooltip(stage, isIncubationAction, isEvolutionAction),
|
||||
onClick: onEvolve,
|
||||
variant: 'accent',
|
||||
};
|
||||
|
||||
/** Floating back button for the Blobbi dashboard. */
|
||||
function BlobbiDashboardFloatingControls({ onBack }: { onBack?: () => void }) {
|
||||
if (!onBack) return null;
|
||||
return (
|
||||
<>
|
||||
{/* Left-side floating buttons */}
|
||||
{leftButtons.length > 0 && (
|
||||
<div className="absolute top-28 sm:top-32 left-4 sm:left-6 flex flex-col gap-2 z-20">
|
||||
{leftButtons.map((btn) => (
|
||||
<QuickActionButton
|
||||
key={btn.id}
|
||||
tooltip={btn.tooltip}
|
||||
onClick={btn.onClick}
|
||||
>
|
||||
{btn.icon}
|
||||
</QuickActionButton>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right-side floating buttons */}
|
||||
<div className="absolute top-28 sm:top-32 right-4 sm:right-6 flex flex-col gap-2 z-20">
|
||||
{rightButtons.map((btn) => (
|
||||
<QuickActionButton
|
||||
key={btn.id}
|
||||
tooltip={btn.tooltip}
|
||||
onClick={btn.onClick}
|
||||
disabled={btn.disabled}
|
||||
>
|
||||
{btn.icon}
|
||||
</QuickActionButton>
|
||||
))}
|
||||
|
||||
{/* Evolve/Hatch button with accent styling */}
|
||||
{/* Adults can't evolve further, so hide the button */}
|
||||
{/* Also hide when explicitly requested (e.g., during incubation) */}
|
||||
{stage !== 'adult' && !hideEvolveButton && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={evolveButton.onClick}
|
||||
disabled={isTransitioning}
|
||||
className="size-10 rounded-full bg-primary/10 backdrop-blur-sm border-primary/30 hover:bg-primary/20 hover:border-primary/50 transition-all shadow-sm text-primary disabled:opacity-50"
|
||||
>
|
||||
{isTransitioning ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
evolveButton.icon
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{isTransitioning ? 'Transitioning...' : evolveButton.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* DEV ONLY: Developer tools dropdown */}
|
||||
{isLocalhostDev() && (onDevInstantTransition || onDevOpenEditor || onDevOpenEmotionPanel) && (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-10 rounded-full bg-amber-500/10 backdrop-blur-sm border-dashed border-amber-500/50 hover:bg-amber-500/20 hover:border-amber-500/70 transition-all shadow-sm text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
<Wrench className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p className="text-amber-600 dark:text-amber-400">Dev Tools</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="left" align="start">
|
||||
{stage !== 'adult' && onDevInstantTransition && (
|
||||
<DropdownMenuItem onClick={onDevInstantTransition} disabled={isTransitioning}>
|
||||
{stage === 'egg' ? <Egg className="size-4 mr-2" /> : <Sparkles className="size-4 mr-2" />}
|
||||
{stage === 'egg' ? 'Dev Hatch' : 'Dev Evolve'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDevOpenEditor && (
|
||||
<DropdownMenuItem onClick={onDevOpenEditor}>
|
||||
<Wrench className="size-4 mr-2" />
|
||||
State Editor
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDevOpenEmotionPanel && (
|
||||
<DropdownMenuItem onClick={onDevOpenEmotionPanel}>
|
||||
<Theater className="size-4 mr-2" />
|
||||
Emotion Tester
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="absolute top-28 sm:top-32 left-4 sm:left-6 flex flex-col gap-2 z-20">
|
||||
<QuickActionButton tooltip="Go Back" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</QuickActionButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2191,19 +1950,6 @@ function BlobbiSelectorCard({ companion, onSelect, isSelected, isCurrentCompanio
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{companion.visibleToOthers ? (
|
||||
<>
|
||||
<Eye className="size-3 mr-1" />
|
||||
Visible
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="size-3 mr-1" />
|
||||
Hidden
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2299,7 +2045,6 @@ interface BlobbiBottomBarProps {
|
||||
onMissionsClick: () => void;
|
||||
onActionsClick: () => void;
|
||||
onShopClick: () => void;
|
||||
onInventoryClick: () => void;
|
||||
/** Number of Blobbies that need care (any stat below threshold) */
|
||||
needyBlobbiesCount?: number;
|
||||
/** Whether the current Blobbi is in an active task process (incubating or evolving) */
|
||||
@@ -2308,6 +2053,22 @@ interface BlobbiBottomBarProps {
|
||||
remainingTasksCount?: number;
|
||||
/** Whether all tasks are complete (show "!" badge) */
|
||||
allTasksComplete?: boolean;
|
||||
// ── 3-dots menu actions ──
|
||||
stage: 'egg' | 'baby' | 'adult';
|
||||
blobbiNaddr: string;
|
||||
onSetAsCompanion: () => void;
|
||||
isCurrentCompanion: boolean;
|
||||
isUpdatingCompanion?: boolean;
|
||||
onTakePhoto: () => void;
|
||||
onEvolve: () => void;
|
||||
isTransitioning?: boolean;
|
||||
hideEvolveButton?: boolean;
|
||||
isIncubationAction?: boolean;
|
||||
isEvolutionAction?: boolean;
|
||||
// ── Dev-only actions ──
|
||||
onDevInstantTransition?: () => void;
|
||||
onDevOpenEditor?: () => void;
|
||||
onDevOpenEmotionPanel?: () => void;
|
||||
}
|
||||
|
||||
function BlobbiBottomBar({
|
||||
@@ -2315,11 +2076,26 @@ function BlobbiBottomBar({
|
||||
onMissionsClick,
|
||||
onActionsClick,
|
||||
onShopClick,
|
||||
onInventoryClick,
|
||||
needyBlobbiesCount,
|
||||
isInTaskProcess,
|
||||
remainingTasksCount,
|
||||
allTasksComplete,
|
||||
// 3-dots menu props
|
||||
stage,
|
||||
blobbiNaddr,
|
||||
onSetAsCompanion,
|
||||
isCurrentCompanion,
|
||||
isUpdatingCompanion = false,
|
||||
onTakePhoto,
|
||||
onEvolve,
|
||||
isTransitioning = false,
|
||||
hideEvolveButton = false,
|
||||
isIncubationAction = false,
|
||||
isEvolutionAction = false,
|
||||
// Dev-only props
|
||||
onDevInstantTransition,
|
||||
onDevOpenEditor,
|
||||
onDevOpenEmotionPanel,
|
||||
}: BlobbiBottomBarProps) {
|
||||
// Determine what to show on missions badge:
|
||||
// - If all tasks complete during active process: show "!"
|
||||
@@ -2327,6 +2103,9 @@ function BlobbiBottomBar({
|
||||
// - Otherwise: no badge
|
||||
// Works for BOTH incubating (hatch) and evolving processes
|
||||
const missionsBadge = allTasksComplete ? '!' : (isInTaskProcess && remainingTasksCount && remainingTasksCount > 0 ? remainingTasksCount : undefined);
|
||||
|
||||
const canBeCompanion = stage !== 'egg';
|
||||
const showEvolveButton = stage !== 'adult' && !hideEvolveButton;
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-2">
|
||||
@@ -2361,8 +2140,67 @@ function BlobbiBottomBar({
|
||||
|
||||
{/* Right Group - aligned to start (closer to center) */}
|
||||
<div className="flex items-center justify-start gap-0 sm:gap-1 overflow-hidden">
|
||||
<BottomBarButton onClick={onShopClick} icon={<ShoppingBag className="size-4" />} label="Shop" />
|
||||
<BottomBarButton onClick={onInventoryClick} icon={<Package className="size-4" />} label="Inventory" />
|
||||
<BottomBarButton onClick={onShopClick} icon={<Package className="size-4" />} label="Items" />
|
||||
|
||||
{/* 3-dots menu */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex flex-col items-center gap-0.5 px-2 sm:px-3 py-1.5 rounded-xl hover:bg-accent/50 active:bg-accent transition-colors min-w-0 sm:min-w-[56px]"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="text-[10px] text-muted-foreground truncate max-w-[48px] sm:max-w-none">More</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" align="end">
|
||||
<DropdownMenuItem onClick={onTakePhoto}>
|
||||
<Camera className="size-4 mr-2" />
|
||||
Take a Photo
|
||||
</DropdownMenuItem>
|
||||
{canBeCompanion && (
|
||||
<DropdownMenuItem onClick={onSetAsCompanion} disabled={isUpdatingCompanion}>
|
||||
<Footprints className={cn('size-4 mr-2', isCurrentCompanion && 'text-green-500')} />
|
||||
{isCurrentCompanion ? 'Current Companion' : 'Set as Companion'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showEvolveButton && (
|
||||
<DropdownMenuItem onClick={onEvolve} disabled={isTransitioning}>
|
||||
{stage === 'egg' ? <Egg className="size-4 mr-2" /> : <Sparkles className="size-4 mr-2" />}
|
||||
{getEvolveTooltip(stage, isIncubationAction, isEvolutionAction)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/${blobbiNaddr}`}>
|
||||
<ExternalLink className="size-4 mr-2" />
|
||||
View Blobbi
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* DEV ONLY: Developer tools */}
|
||||
{isLocalhostDev() && (onDevInstantTransition || onDevOpenEditor || onDevOpenEmotionPanel) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{stage !== 'adult' && onDevInstantTransition && (
|
||||
<DropdownMenuItem onClick={onDevInstantTransition} disabled={isTransitioning} className="text-amber-600 dark:text-amber-400">
|
||||
{stage === 'egg' ? <Egg className="size-4 mr-2" /> : <Sparkles className="size-4 mr-2" />}
|
||||
{stage === 'egg' ? 'Dev Hatch' : 'Dev Evolve'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDevOpenEditor && (
|
||||
<DropdownMenuItem onClick={onDevOpenEditor} className="text-amber-600 dark:text-amber-400">
|
||||
<Wrench className="size-4 mr-2" />
|
||||
State Editor
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDevOpenEmotionPanel && (
|
||||
<DropdownMenuItem onClick={onDevOpenEmotionPanel} className="text-amber-600 dark:text-amber-400">
|
||||
<Theater className="size-4 mr-2" />
|
||||
Emotion Tester
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2414,83 +2252,6 @@ function BottomBarButton({ onClick, icon, label, badge, badgeVariant = 'default'
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Blobbi Info Modal ────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiInfoModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
companion: BlobbiCompanion;
|
||||
}
|
||||
|
||||
function BlobbiInfoModal({ open, onOpenChange, companion }: BlobbiInfoModalProps) {
|
||||
const isSleeping = companion.state === 'sleeping';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)] max-h-[85vh] flex flex-col p-0 gap-0 [&>button:last-child]:hidden">
|
||||
{/* Header - Sticky */}
|
||||
<DialogHeader className="sticky top-0 z-10 bg-background px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 border-b">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<DialogTitle style={{ color: companion.visualTraits.baseColor }}>
|
||||
{companion.name}
|
||||
</DialogTitle>
|
||||
<DialogClose className="rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0">
|
||||
<X className="size-5" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 sm:px-6 py-3 sm:py-4 space-y-4">
|
||||
{/* Blobbi Visual */}
|
||||
<div className="flex justify-center">
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="md"
|
||||
animated={!isSleeping}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-muted-foreground text-xs">Stage</p>
|
||||
<p className="font-medium capitalize">{companion.stage}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-muted-foreground text-xs">State</p>
|
||||
<p className="font-medium capitalize">{companion.state}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-muted-foreground text-xs">Generation</p>
|
||||
<p className="font-medium">{companion.generation ?? 1}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-muted-foreground text-xs">Experience</p>
|
||||
<p className="font-medium">{companion.experience ?? 0}</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-muted-foreground text-xs">Care Streak</p>
|
||||
<p className="font-medium">{companion.careStreak ?? 0} days</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50">
|
||||
<p className="text-muted-foreground text-xs">Visibility</p>
|
||||
<p className="font-medium">{companion.visibleToOthers ? 'Public' : 'Private'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legacy Notice */}
|
||||
{companion.isLegacy && (
|
||||
<div className="px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/30">
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
This pet uses a legacy format and will be upgraded on next interaction.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+187
-78
@@ -1,43 +1,25 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Bug, CalendarDays, FlaskConical, Minus, Package, Plus, RefreshCw, ScrollText, ShieldAlert, Tag } from 'lucide-react';
|
||||
import { Bug, FlaskConical, Minus, Package, Plus, RefreshCw, ScrollText, ShieldAlert } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { parseChangelog } from '@/lib/changelog';
|
||||
import type { ChangelogCategory } from '@/lib/changelog';
|
||||
import type { ChangelogCategory, ChangelogEntry } from '@/lib/changelog';
|
||||
|
||||
const GITLAB_REPO = 'https://gitlab.com/soapbox-pub/ditto';
|
||||
|
||||
/** Per-category badge color + icon. */
|
||||
const CATEGORY_STYLES: Record<ChangelogCategory, { icon: typeof Plus; className: string }> = {
|
||||
Added: {
|
||||
icon: Plus,
|
||||
className: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
},
|
||||
Changed: {
|
||||
icon: RefreshCw,
|
||||
className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
},
|
||||
Deprecated: {
|
||||
icon: Package,
|
||||
className: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
},
|
||||
Removed: {
|
||||
icon: Minus,
|
||||
className: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
},
|
||||
Fixed: {
|
||||
icon: Bug,
|
||||
className: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
},
|
||||
Security: {
|
||||
icon: ShieldAlert,
|
||||
className: 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
},
|
||||
/** Per-category icon + color used as inline list bullets. */
|
||||
const CATEGORY_STYLES: Record<ChangelogCategory, { icon: typeof Plus; colorClass: string }> = {
|
||||
Added: { icon: Plus, colorClass: 'text-emerald-600 dark:text-emerald-400' },
|
||||
Changed: { icon: RefreshCw, colorClass: 'text-blue-600 dark:text-blue-400' },
|
||||
Deprecated: { icon: Package, colorClass: 'text-orange-600 dark:text-orange-400' },
|
||||
Removed: { icon: Minus, colorClass: 'text-red-600 dark:text-red-400' },
|
||||
Fixed: { icon: Bug, colorClass: 'text-amber-600 dark:text-amber-400' },
|
||||
Security: { icon: ShieldAlert, colorClass: 'text-purple-600 dark:text-purple-400' },
|
||||
};
|
||||
|
||||
/** Format "2026-03-26" as a readable date string. */
|
||||
@@ -92,55 +74,21 @@ export function ChangelogPage() {
|
||||
<>
|
||||
{isPreRelease && latestVersion && <PreReleaseBanner latestVersion={latestVersion} />}
|
||||
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.version} className="rounded-2xl border border-border overflow-hidden">
|
||||
{/* Version header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-secondary/30">
|
||||
<Tag className="size-4 text-primary shrink-0" />
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-sm hover:underline"
|
||||
>
|
||||
v{entry.version}
|
||||
</a>
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
|
||||
>
|
||||
<CalendarDays className="size-3.5" />
|
||||
<span>{formatDate(entry.date)}</span>
|
||||
</a>
|
||||
<LatestRelease entry={entries[0]} />
|
||||
|
||||
{entries.length > 1 && (
|
||||
<>
|
||||
<div className="flex items-center gap-3 pt-4 pb-1">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Past releases</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="divide-y divide-border">
|
||||
{entry.sections.map((section) => {
|
||||
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
|
||||
const Icon = style.icon;
|
||||
|
||||
return (
|
||||
<div key={section.category} className="px-4 py-3 space-y-2">
|
||||
<Badge variant="secondary" className={`gap-1 text-[10px] px-1.5 py-0 ${style.className}`}>
|
||||
<Icon className="size-3" />
|
||||
{section.category}
|
||||
</Badge>
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-foreground/90 pl-3 relative before:absolute before:left-0 before:top-[0.6em] before:size-1 before:rounded-full before:bg-muted-foreground/40">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{entries.slice(1).map((entry) => (
|
||||
<ChangelogEntryCard key={entry.version} entry={entry} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -148,6 +96,167 @@ export function ChangelogPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Hero treatment for the most recent release — no card, centered version + date. */
|
||||
function LatestRelease({ entry }: { entry: ChangelogEntry }) {
|
||||
const contentRef = useRef<HTMLUListElement>(null);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = contentRef.current;
|
||||
if (el) setOverflows(el.scrollHeight > ENTRY_MAX_HEIGHT);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [measure]);
|
||||
|
||||
return (
|
||||
<div className="pt-2 pb-1 px-4">
|
||||
{/* Big centered version + date */}
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-center text-2xl font-bold tracking-tight hover:underline"
|
||||
>
|
||||
v{entry.version}
|
||||
</a>
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mt-1"
|
||||
>
|
||||
{formatDate(entry.date)}
|
||||
</a>
|
||||
|
||||
{/* Items */}
|
||||
<div className="relative mt-4">
|
||||
<ul
|
||||
ref={contentRef}
|
||||
style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined}
|
||||
className="space-y-2.5"
|
||||
>
|
||||
{entry.sections.flatMap((section) => {
|
||||
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
|
||||
const Icon = style.icon;
|
||||
|
||||
return section.items.map((item, i) => (
|
||||
<li key={`${section.category}-${i}`} className="flex gap-2 text-base text-foreground/90">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon className={`size-4 shrink-0 mt-1 cursor-default ${style.colorClass}`} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{section.category}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
));
|
||||
})}
|
||||
</ul>
|
||||
{!expanded && overflows && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{overflows && (
|
||||
<button
|
||||
className="w-full text-sm text-primary hover:underline mt-1"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ENTRY_MAX_HEIGHT = 240; // px — entries taller than this get a "Read more" button
|
||||
|
||||
/** A single changelog release card with truncation for long entries. */
|
||||
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
const contentRef = useRef<HTMLUListElement>(null);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = contentRef.current;
|
||||
if (el) setOverflows(el.scrollHeight > ENTRY_MAX_HEIGHT);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measure();
|
||||
window.addEventListener('resize', measure);
|
||||
return () => window.removeEventListener('resize', measure);
|
||||
}, [measure]);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border overflow-hidden">
|
||||
{/* Version header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-sm hover:underline"
|
||||
>
|
||||
v{entry.version}
|
||||
</a>
|
||||
<a
|
||||
href={`${GITLAB_REPO}/-/releases/v${entry.version}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
|
||||
>
|
||||
<span>{formatDate(entry.date)}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="relative">
|
||||
<ul
|
||||
ref={contentRef}
|
||||
style={!expanded && overflows ? { maxHeight: ENTRY_MAX_HEIGHT, overflow: 'hidden' } : undefined}
|
||||
className="px-4 py-3 space-y-2.5"
|
||||
>
|
||||
{entry.sections.flatMap((section) => {
|
||||
const style = CATEGORY_STYLES[section.category] ?? CATEGORY_STYLES.Changed;
|
||||
const Icon = style.icon;
|
||||
|
||||
return section.items.map((item, i) => (
|
||||
<li key={`${section.category}-${i}`} className="flex gap-2 text-sm text-foreground/90">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon className={`size-3.5 shrink-0 mt-[3px] cursor-default ${style.colorClass}`} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{section.category}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
));
|
||||
})}
|
||||
</ul>
|
||||
{!expanded && overflows && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{overflows && (
|
||||
<button
|
||||
className="w-full text-sm text-primary hover:underline py-2"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Read more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Banner shown at the top of the changelog for untagged (pre-release) builds. */
|
||||
function PreReleaseBanner({ latestVersion }: { latestVersion: string }) {
|
||||
return (
|
||||
@@ -186,7 +295,7 @@ function ChangelogSkeleton() {
|
||||
<div className="space-y-4 pt-1">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i} className="rounded-2xl border border-border overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-secondary/30">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
|
||||
@@ -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,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
ReactionEmoji,
|
||||
RenderResolvedEmoji,
|
||||
} from "@/components/CustomEmoji";
|
||||
const BlobbiStateCard = lazy(() => import("@/components/BlobbiStateCard").then(m => ({ default: m.BlobbiStateCard })));
|
||||
const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.CustomNipCard })));
|
||||
import { FileMetadataContent } from "@/components/FileMetadataContent";
|
||||
import { FollowPackContent } from "@/components/FollowPackContent";
|
||||
@@ -50,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";
|
||||
@@ -82,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";
|
||||
@@ -132,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";
|
||||
@@ -141,6 +146,7 @@ function shellTitleForKind(kind?: number): string {
|
||||
if (kind === 7) return "Reaction";
|
||||
if (kind === 9735) return "Zap";
|
||||
if (kind === 0) return "Profile";
|
||||
if (kind === 31124) return "Blobbi";
|
||||
return "Post Details";
|
||||
}
|
||||
|
||||
@@ -996,11 +1002,13 @@ 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;
|
||||
const isZap = event.kind === 9735;
|
||||
const isProfile = event.kind === 0;
|
||||
const isBlobbiState = event.kind === 31124;
|
||||
const isDevKind = isGitRepo || isPatch || isPullRequest || isCustomNip || isNsite;
|
||||
const isTextNote =
|
||||
!isVine &&
|
||||
@@ -1022,11 +1030,13 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
!isCommunity &&
|
||||
!isDevKind &&
|
||||
!isZapstoreApp &&
|
||||
!isAppHandler &&
|
||||
!isEncryptedDM &&
|
||||
!isLetter &&
|
||||
!isVanish &&
|
||||
!isZap &&
|
||||
!isProfile;
|
||||
!isProfile &&
|
||||
!isBlobbiState;
|
||||
|
||||
const videos = useMemo(
|
||||
() => (isTextNote ? extractVideoUrls(event.content) : []),
|
||||
@@ -1434,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>
|
||||
|
||||
@@ -1929,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 ? (
|
||||
@@ -2040,10 +2058,16 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
) : isZapstoreApp ? (
|
||||
<ZapstoreAppContent event={event} />
|
||||
) : isAppHandler ? (
|
||||
<AppHandlerContent event={event} />
|
||||
) : isEncryptedDM ? (
|
||||
<EncryptedMessageContent event={event} />
|
||||
) : isLetter ? (
|
||||
<EncryptedLetterContent event={event} />
|
||||
) : isBlobbiState ? (
|
||||
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
|
||||
<BlobbiStateCard event={event} />
|
||||
</Suspense>
|
||||
) : isVine ||
|
||||
isPoll ||
|
||||
isGeocache ||
|
||||
@@ -2140,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>
|
||||
|
||||
@@ -89,6 +89,7 @@ export function TestApp({ children }: TestAppProps) {
|
||||
feedIncludeBadgeDefinitions: false,
|
||||
feedIncludeProfileBadges: false,
|
||||
feedIncludeVanish: true,
|
||||
feedIncludeBlobbi: true,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [],
|
||||
|
||||
@@ -149,10 +149,6 @@ export interface Blobbi extends BlobbiVisualTraits {
|
||||
generation?: number;
|
||||
careStreak?: number;
|
||||
|
||||
/**
|
||||
* Visibility / social.
|
||||
*/
|
||||
visibleToOthers?: boolean;
|
||||
crossoverApp?: string | null;
|
||||
themeVariant?: string;
|
||||
|
||||
@@ -215,7 +211,6 @@ export function createDefaultBlobbi(overrides: Partial<Blobbi> = {}): Blobbi {
|
||||
generation: overrides.generation ?? 1,
|
||||
careStreak: overrides.careStreak ?? 0,
|
||||
|
||||
visibleToOthers: overrides.visibleToOthers ?? true,
|
||||
crossoverApp: overrides.crossoverApp ?? null,
|
||||
themeVariant: overrides.themeVariant,
|
||||
tags: overrides.tags ?? [],
|
||||
|
||||
Vendored
+2
-6
@@ -15,10 +15,6 @@ interface ImportMetaEnv {
|
||||
readonly COMMIT_SHA: string;
|
||||
/** Git tag for the current commit (e.g., "v2.0.0"). Empty string if untagged (pre-release build). */
|
||||
readonly COMMIT_TAG: string;
|
||||
/** Build-time configuration injected from ditto.json as a JSON string. `"null"` when no config file was provided. */
|
||||
readonly DITTO_CONFIG: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build-time configuration injected by Vite from ditto.json.
|
||||
* `null` when no config file was provided at build time.
|
||||
*/
|
||||
declare const __DITTO_CONFIG__: Partial<import('@/contexts/AppContext').AppConfig> | null;
|
||||
|
||||
+1
-1
@@ -136,7 +136,7 @@ export default defineConfig(() => {
|
||||
...(publicDir ? [mergePublicDir(publicDir)] : []),
|
||||
],
|
||||
define: {
|
||||
__DITTO_CONFIG__: JSON.stringify(dittoConfig ?? null),
|
||||
'import.meta.env.DITTO_CONFIG': JSON.stringify(JSON.stringify(dittoConfig ?? null)),
|
||||
'import.meta.env.VERSION': JSON.stringify(pkg.version),
|
||||
'import.meta.env.BUILD_DATE': JSON.stringify(new Date().toISOString()),
|
||||
'import.meta.env.COMMIT_SHA': JSON.stringify(getCommitSha()),
|
||||
|
||||
Reference in New Issue
Block a user