Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 286572777b | |||
| 7fd4b7ab69 | |||
| c9525a0233 | |||
| 0b9cd5e1cb | |||
| a2600d1caa | |||
| 0722d900a2 | |||
| 918814371c | |||
| 517a72cce7 | |||
| 6fc68766c9 | |||
| ae81c13cc1 | |||
| 41358d27ce | |||
| ac8bffba23 | |||
| 748365de40 | |||
| 361f8b9506 | |||
| c1ec7a25ed | |||
| 272586d033 | |||
| c77c098843 | |||
| ea7afa94f7 | |||
| 0c29506402 | |||
| b0609e7877 | |||
| 946be28b81 | |||
| 89250c7472 | |||
| cfc7a0d31c | |||
| 21003e3aed | |||
| 93e8a6290f | |||
| 47831ffa64 | |||
| 1533420320 | |||
| e3ef542875 | |||
| 3bf55990c0 |
@@ -699,23 +699,47 @@ The `useCurrentUser` hook should be used to ensure that the user is logged in be
|
||||
|
||||
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a read-modify-write cycle: fetch the current event, modify its tags, then publish a new version. **Never read from TanStack Query cache before mutating** -- the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
|
||||
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation:
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
|
||||
|
||||
```typescript
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
// Inside a mutation function:
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
const currentTags = freshEvent?.tags ?? [];
|
||||
const currentTags = prev?.tags ?? [];
|
||||
// ...modify tags...
|
||||
await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newTags });
|
||||
await publishEvent({
|
||||
kind: 10003,
|
||||
content: prev?.content ?? '',
|
||||
tags: newTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
```
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
#### The `prev` Property on Event Templates
|
||||
|
||||
`useNostrPublish` accepts an optional `prev` property on the event template. This is the **previous version** of the event being replaced. The hook uses it to automatically manage the `published_at` tag (NIP-24) for replaceable and addressable events:
|
||||
|
||||
- **First publish (no `prev`)**: `published_at` is set equal to `created_at`
|
||||
- **Update (`prev` provided)**: `published_at` is preserved from the old event
|
||||
- **Old event lacks `published_at`**: nothing is fabricated
|
||||
- **Caller already set `published_at` in tags**: left alone
|
||||
|
||||
**Convention**: Name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
|
||||
|
||||
```typescript
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
// ...
|
||||
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
|
||||
```
|
||||
|
||||
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
|
||||
|
||||
### 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.).
|
||||
|
||||
Generated
+2
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"version": "2.5.2",
|
||||
"version": "2.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ditto",
|
||||
"version": "2.5.2",
|
||||
"version": "2.6.0",
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.1.0",
|
||||
@@ -5699,7 +5699,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5713,7 +5712,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5727,7 +5725,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5741,7 +5738,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5755,7 +5751,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5769,7 +5764,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5783,7 +5777,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5797,7 +5790,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5811,7 +5803,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5825,7 +5816,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5839,7 +5829,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5853,7 +5842,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5867,7 +5855,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5881,7 +5868,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5895,7 +5881,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5909,7 +5894,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5923,7 +5907,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5937,7 +5920,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5951,7 +5933,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5965,7 +5946,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5979,7 +5959,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5993,7 +5972,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6007,7 +5985,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6021,7 +5998,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6035,7 +6011,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// src/blobbi/actions/components/BlobbiActionInventoryModal.tsx
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Loader2, ShoppingBag, Minus, Plus, X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -37,8 +34,8 @@ interface BlobbiActionInventoryModalProps {
|
||||
action: InventoryAction;
|
||||
companion: BlobbiCompanion;
|
||||
profile: BlobbonautProfile | null;
|
||||
/** Called when user confirms using item(s). Now accepts quantity. */
|
||||
onUseItem: (itemId: string, quantity: number) => void;
|
||||
/** Called when user taps Use on an item. Always uses once. */
|
||||
onUseItem: (itemId: string) => void;
|
||||
onOpenShop: () => void;
|
||||
isUsingItem: boolean;
|
||||
usingItemId: string | null;
|
||||
@@ -49,24 +46,19 @@ export function BlobbiActionInventoryModal({
|
||||
onOpenChange,
|
||||
action,
|
||||
companion,
|
||||
profile,
|
||||
profile: _profile,
|
||||
onUseItem,
|
||||
onOpenShop,
|
||||
onOpenShop: _onOpenShop,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
}: BlobbiActionInventoryModalProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
|
||||
// State for confirmation dialog
|
||||
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
// Filter inventory by action type, respecting egg-compatible effects
|
||||
// Get all available items for this action from the catalog (not inventory).
|
||||
// Items are abilities/tools — no ownership required.
|
||||
const availableItems = useMemo(() => {
|
||||
if (!profile) return [];
|
||||
return filterInventoryByAction(profile.storage, action, { stage: companion.stage });
|
||||
}, [profile, action, companion.stage]);
|
||||
return filterInventoryByAction([], action, { stage: companion.stage });
|
||||
}, [action, companion.stage]);
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
const canUse = canUseAction(companion, action);
|
||||
@@ -74,46 +66,9 @@ export function BlobbiActionInventoryModal({
|
||||
|
||||
const isEmpty = availableItems.length === 0;
|
||||
|
||||
const handleSelectItem = (item: ResolvedInventoryItem) => {
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (isUsingItem) return;
|
||||
setSelectedItem(item);
|
||||
setQuantity(1);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmUse = () => {
|
||||
if (!selectedItem || isUsingItem) return;
|
||||
onUseItem(selectedItem.itemId, quantity);
|
||||
// Reset after starting use
|
||||
setShowConfirmDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
};
|
||||
|
||||
const handleCloseConfirmDialog = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setShowConfirmDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShop = () => {
|
||||
onOpenChange(false);
|
||||
onOpenShop();
|
||||
};
|
||||
|
||||
// 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));
|
||||
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
setQuantity(1);
|
||||
} else {
|
||||
setQuantity(Math.min(value, maxQuantity));
|
||||
}
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -161,14 +116,10 @@ export function BlobbiActionInventoryModal({
|
||||
<div className="size-16 rounded-2xl bg-muted/50 flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">{actionMeta.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm mb-4">
|
||||
You don't have any items for this action. Visit the shop to get some!
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
No items are available for this action at your Blobbi's current stage.
|
||||
</p>
|
||||
<Button onClick={handleOpenShop} className="gap-2">
|
||||
<ShoppingBag className="size-4" />
|
||||
Open Shop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -181,7 +132,7 @@ export function BlobbiActionInventoryModal({
|
||||
item={item}
|
||||
companion={companion}
|
||||
action={action}
|
||||
onUse={() => handleSelectItem(item)}
|
||||
onUse={() => handleUseItem(item)}
|
||||
isUsing={isUsingItem && usingItemId === item.itemId}
|
||||
disabled={isUsingItem}
|
||||
/>
|
||||
@@ -190,24 +141,6 @@ export function BlobbiActionInventoryModal({
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Confirmation Dialog with Quantity Selector */}
|
||||
{selectedItem && (
|
||||
<BlobbiUseItemConfirmDialog
|
||||
open={showConfirmDialog}
|
||||
onOpenChange={handleCloseConfirmDialog}
|
||||
item={selectedItem}
|
||||
companion={companion}
|
||||
action={action}
|
||||
quantity={quantity}
|
||||
maxQuantity={maxQuantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
onQuantityChange={handleQuantityInput}
|
||||
onConfirm={handleConfirmUse}
|
||||
isUsing={isUsingItem}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -238,15 +171,12 @@ function BlobbiInventoryUseRow({
|
||||
// Preview stat changes - handle egg-specific preview for medicine and clean
|
||||
const { normalStatChanges, eggStatChanges } = useMemo(() => {
|
||||
if (isEgg && isMedicine) {
|
||||
// For eggs using medicine, show health preview
|
||||
// Eggs use the 3-stat model: health, hygiene, happiness
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewMedicineForEgg(companion.stats.health, item.effect),
|
||||
};
|
||||
}
|
||||
if (isEgg && isClean) {
|
||||
// For eggs using hygiene items, show hygiene (and possibly happiness) preview
|
||||
return {
|
||||
normalStatChanges: [],
|
||||
eggStatChanges: previewCleanForEgg(
|
||||
@@ -255,7 +185,6 @@ function BlobbiInventoryUseRow({
|
||||
),
|
||||
};
|
||||
}
|
||||
// Normal stats preview
|
||||
return {
|
||||
normalStatChanges: previewStatChanges(companion.stats, item.effect),
|
||||
eggStatChanges: [] as EggStatPreview[],
|
||||
@@ -280,16 +209,12 @@ function BlobbiInventoryUseRow({
|
||||
<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 shrink-0">
|
||||
x{item.quantity}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Effect Preview - shown inline on desktop */}
|
||||
<div className="hidden sm:block">
|
||||
{hasChanges && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{/* Normal stat changes */}
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -308,7 +233,6 @@ function BlobbiInventoryUseRow({
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -350,7 +274,6 @@ function BlobbiInventoryUseRow({
|
||||
{/* Effect Preview - shown below on mobile */}
|
||||
{hasChanges && (
|
||||
<div className="sm:hidden flex flex-wrap gap-x-3 gap-y-1 pl-13">
|
||||
{/* Normal stat changes */}
|
||||
{normalStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -369,7 +292,6 @@ function BlobbiInventoryUseRow({
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{/* Egg stat changes (health for medicine) */}
|
||||
{eggStatChanges.map(({ stat, delta }) => (
|
||||
<span key={stat} className="text-xs">
|
||||
<span
|
||||
@@ -393,222 +315,3 @@ function BlobbiInventoryUseRow({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Use Item Confirmation Dialog ─────────────────────────────────────────────
|
||||
|
||||
interface BlobbiUseItemConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onConfirm: () => void;
|
||||
isUsing: boolean;
|
||||
}
|
||||
|
||||
function BlobbiUseItemConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
companion,
|
||||
action,
|
||||
quantity,
|
||||
maxQuantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
onQuantityChange,
|
||||
onConfirm,
|
||||
isUsing,
|
||||
}: BlobbiUseItemConfirmDialogProps) {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const isEgg = companion.stage === 'egg';
|
||||
const isMedicine = action === 'medicine';
|
||||
const isClean = action === 'clean';
|
||||
|
||||
// Preview stat changes for the selected quantity
|
||||
const statPreview = useMemo(() => {
|
||||
if (!item.effect) return { normalChanges: [], eggChanges: [] };
|
||||
|
||||
if (isEgg && isMedicine) {
|
||||
// Calculate health change for N items
|
||||
const healthDelta = item.effect.health ?? 0;
|
||||
let currentHealth = companion.stats.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHealth = Math.max(0, Math.min(100, currentHealth + healthDelta));
|
||||
}
|
||||
const totalDelta = currentHealth - (companion.stats.health ?? 0);
|
||||
return {
|
||||
normalChanges: [],
|
||||
eggChanges: totalDelta !== 0 ? [{ stat: 'health' as const, delta: totalDelta }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
if (isEgg && isClean) {
|
||||
// Calculate hygiene and happiness changes for N items
|
||||
const hygieneDelta = item.effect.hygiene ?? 0;
|
||||
const happinessDelta = item.effect.happiness ?? 0;
|
||||
let currentHygiene = companion.stats.hygiene ?? 0;
|
||||
let currentHappiness = companion.stats.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHygiene = Math.max(0, Math.min(100, currentHygiene + hygieneDelta));
|
||||
currentHappiness = Math.max(0, Math.min(100, currentHappiness + happinessDelta));
|
||||
}
|
||||
const changes: Array<{ stat: 'health' | 'hygiene' | 'happiness'; delta: number }> = [];
|
||||
const totalHygieneDelta = currentHygiene - (companion.stats.hygiene ?? 0);
|
||||
const totalHappinessDelta = currentHappiness - (companion.stats.happiness ?? 0);
|
||||
if (totalHygieneDelta !== 0) changes.push({ stat: 'hygiene', delta: totalHygieneDelta });
|
||||
if (totalHappinessDelta !== 0) changes.push({ stat: 'happiness', delta: totalHappinessDelta });
|
||||
return { normalChanges: [], eggChanges: changes };
|
||||
}
|
||||
|
||||
// Normal stats preview - simulate N applications
|
||||
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
|
||||
const currentStats = { ...companion.stats };
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
for (const stat of statKeys) {
|
||||
const delta = item.effect[stat];
|
||||
if (delta !== undefined) {
|
||||
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
const changes: Array<{ stat: string; delta: number }> = [];
|
||||
for (const stat of statKeys) {
|
||||
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
|
||||
if (delta !== 0) {
|
||||
changes.push({ stat, delta });
|
||||
}
|
||||
}
|
||||
return { normalChanges: changes, eggChanges: [] };
|
||||
}, [item.effect, companion.stats, quantity, isEgg, isMedicine, isClean]);
|
||||
|
||||
const hasChanges = statPreview.normalChanges.length > 0 || statPreview.eggChanges.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionMeta.label}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Item Preview */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.quantity} in inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Quantity</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {maxQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onDecrease}
|
||||
disabled={quantity <= 1 || isUsing}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQuantity}
|
||||
value={quantity}
|
||||
onChange={onQuantityChange}
|
||||
disabled={isUsing}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onIncrease}
|
||||
disabled={quantity >= maxQuantity || isUsing}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Effects Summary */}
|
||||
{hasChanges && (
|
||||
<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">
|
||||
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{statPreview.normalChanges.map(({ stat, delta }) => (
|
||||
<Badge
|
||||
key={stat}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
delta > 0
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta} {stat}
|
||||
</Badge>
|
||||
))}
|
||||
{statPreview.eggChanges.map(({ stat, delta }) => (
|
||||
<Badge
|
||||
key={stat}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
delta > 0
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-300'
|
||||
: 'bg-red-500/20 text-red-700 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{delta > 0 ? '+' : ''}{delta} {stat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUsing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isUsing}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isUsing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Using...
|
||||
</>
|
||||
) : (
|
||||
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,13 +98,6 @@ export function InlineSingCard({
|
||||
cleanup: cleanupPlayback,
|
||||
} = useAudioPlayback();
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAll();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup all resources
|
||||
const cleanupAll = useCallback(() => {
|
||||
// Stop timer
|
||||
@@ -138,6 +131,13 @@ export function InlineSingCard({
|
||||
}
|
||||
}, [audioUrl, cleanupPlayback]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAll();
|
||||
};
|
||||
}, [cleanupAll]);
|
||||
|
||||
// Reset recording
|
||||
const resetRecording = useCallback(() => {
|
||||
cleanupAll();
|
||||
|
||||
@@ -82,22 +82,6 @@ export function SingModal({
|
||||
// Track the actual MIME type used by the recorder
|
||||
const actualMimeTypeRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetRecording();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
// Stop timer
|
||||
if (timerRef.current) {
|
||||
@@ -142,6 +126,22 @@ export function SingModal({
|
||||
// Keep lyrics when re-recording so user can sing the same song
|
||||
}, [cleanup]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetRecording();
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
}, [open, cleanup, resetRecording]);
|
||||
|
||||
// Handle getting random lyrics
|
||||
const handleRandomLyrics = useCallback(() => {
|
||||
const lyrics = getRandomLyrics();
|
||||
|
||||
@@ -168,7 +168,7 @@ export function useActiveTaskProcess(
|
||||
}, [processType, hatchTasks, evolveTasks]);
|
||||
|
||||
// Extract tasks and state from active result
|
||||
const tasks = activeResult?.tasks ?? [];
|
||||
const tasks = useMemo(() => activeResult?.tasks ?? [], [activeResult]);
|
||||
const isLoading = activeResult?.isLoading ?? false;
|
||||
const allCompleted = activeResult?.allCompleted ?? false;
|
||||
const persistentTasksComplete = activeResult?.persistentTasksComplete ?? false;
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface UseBlobbiDirectActionParams {
|
||||
|
||||
/**
|
||||
* Hook to execute a direct action on a Blobbi companion.
|
||||
* Direct actions (play_music, sing) don't consume inventory items.
|
||||
* Direct actions (play_music, sing) don't require selecting an item.
|
||||
* They directly affect happiness stat.
|
||||
*
|
||||
* This hook:
|
||||
|
||||
@@ -6,19 +6,15 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
getStageRestrictionMessage,
|
||||
clampStat,
|
||||
@@ -37,23 +33,19 @@ import { HATCH_REQUIRED_INTERACTIONS } from './useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from './useEvolveTasks';
|
||||
|
||||
/**
|
||||
* Request payload for using an inventory item
|
||||
* Request payload for using an item on a Blobbi companion
|
||||
*/
|
||||
export interface UseItemRequest {
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
/** Number of items to use (defaults to 1) */
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of using an inventory item
|
||||
* Result of using an item on a Blobbi companion
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
itemName: string;
|
||||
action: InventoryAction;
|
||||
quantity: number;
|
||||
effectiveItemCount: number; // How many items actually changed stats (may be less than quantity due to caps)
|
||||
statsChanged: Record<string, number>;
|
||||
xpGained: number;
|
||||
newXP: number;
|
||||
@@ -71,9 +63,9 @@ export interface UseBlobbiUseInventoryItemParams {
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
/** Latest profile tags after migration (use instead of profile.allTags) */
|
||||
/** Latest profile tags after migration */
|
||||
profileAllTags: string[][];
|
||||
/** Latest profile storage after migration (use instead of profile.storage) */
|
||||
/** Latest profile storage after migration */
|
||||
profileStorage: import('@/blobbi/core/lib/blobbi').StorageItem[];
|
||||
} | null>;
|
||||
/** Update companion event in local cache */
|
||||
@@ -86,29 +78,29 @@ export interface UseBlobbiUseInventoryItemParams {
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Hook to use an inventory item on a Blobbi companion.
|
||||
* Hook to use an item on a Blobbi companion.
|
||||
*
|
||||
* Items are reusable abilities sourced from the shop catalog — no
|
||||
* inventory ownership or quantity is required.
|
||||
*
|
||||
* This hook:
|
||||
* 1. Validates the companion stage (eggs can't use items)
|
||||
* 2. Validates the item exists in storage
|
||||
* 3. Ensures canonical format before action
|
||||
* 4. Applies item effects to Blobbi stats
|
||||
* 5. Updates Blobbi state (kind 31124)
|
||||
* 6. Decrements item from profile storage (kind 11125)
|
||||
* 7. Invalidates relevant queries
|
||||
* 1. Validates the companion and item compatibility
|
||||
* 2. Ensures canonical format before action
|
||||
* 3. Applies accumulated decay, then item effects to Blobbi stats
|
||||
* 4. Updates Blobbi state (kind 31124)
|
||||
*/
|
||||
export function useBlobbiUseInventoryItem({
|
||||
companion,
|
||||
profile,
|
||||
ensureCanonicalBeforeAction,
|
||||
updateCompanionEvent,
|
||||
updateProfileEvent,
|
||||
updateProfileEvent: _updateProfileEvent,
|
||||
}: UseBlobbiUseInventoryItemParams) {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ itemId, action, quantity = 1 }: UseItemRequest): Promise<UseItemResult> => {
|
||||
mutationFn: async ({ itemId, action }: UseItemRequest): Promise<UseItemResult> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
throw new Error('You must be logged in to use items');
|
||||
@@ -122,11 +114,6 @@ export function useBlobbiUseInventoryItem({
|
||||
throw new Error('Profile not found');
|
||||
}
|
||||
|
||||
// Validate quantity
|
||||
if (quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
// Check stage restrictions for this specific action
|
||||
if (!canUseAction(companion, action)) {
|
||||
const message = getStageRestrictionMessage(companion, action);
|
||||
@@ -201,78 +188,25 @@ export function useBlobbiUseInventoryItem({
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Apply Item Effects ───
|
||||
// Apply effects multiple times (once per quantity) to simulate using items in sequence.
|
||||
// This ensures proper clamping at each step, e.g., using 5 health items when at 90 health
|
||||
// won't give more than 100 health total.
|
||||
//
|
||||
// CRITICAL: Track the number of items that actually produced INTENDED stat changes for XP.
|
||||
// XP counting is action-aware - only count positive intended effects, NOT negative side effects:
|
||||
// - feed: count when hunger/energy/health/happiness INCREASE (NOT when hygiene decreases)
|
||||
// - clean: count when hygiene or happiness INCREASES
|
||||
// - medicine: count when health/energy/happiness INCREASE (NOT negative side effects)
|
||||
// - play: EXCEPTION - count when happiness increases OR energy decreases (both are intended effects)
|
||||
//
|
||||
// Use canonical companion stage for egg checks
|
||||
// ─── Apply Item Effects (single use) ───
|
||||
const isEggCompanion = canonical.companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
let effectiveItemCount = 0; // Number of items that produced intended effects
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
// Egg medicine handling:
|
||||
// Eggs use the 3-stat model: health, hygiene, happiness
|
||||
// Medicine with health effect directly affects the egg's health stat
|
||||
// hunger and energy remain fixed at 100 for eggs
|
||||
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
// Apply health effect N times in sequence with clamping at each step
|
||||
// Only count items that actually INCREASED health (positive effect only)
|
||||
let currentHealth = statsAfterDecay.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHealth = currentHealth;
|
||||
currentHealth = applyStat(currentHealth, healthDelta);
|
||||
// Only count as effective if health increased (not just changed)
|
||||
if (healthDelta > 0 && currentHealth > prevHealth) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, healthDelta);
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
// Track total actual change (may be less than healthDelta * quantity due to clamping)
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
|
||||
// Apply decayed values for other egg stats
|
||||
statsUpdate.hygiene = (statsAfterDecay.hygiene ?? 0).toString();
|
||||
statsUpdate.happiness = (statsAfterDecay.happiness ?? 0).toString();
|
||||
// hunger and energy stay at 100 for eggs
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else if (isEggCompanion && action === 'clean') {
|
||||
// Egg clean/hygiene handling:
|
||||
// Hygiene items affect the egg's hygiene stat
|
||||
// Some hygiene items also give happiness (e.g., bubble bath)
|
||||
// hunger and energy remain fixed at 100 for eggs
|
||||
|
||||
const hygieneDelta = shopItem.effect.hygiene ?? 0;
|
||||
const happinessDelta = shopItem.effect.happiness ?? 0;
|
||||
|
||||
// Apply effects N times in sequence
|
||||
// Only count items that INCREASED hygiene or happiness (positive effects only)
|
||||
let currentHygiene = statsAfterDecay.hygiene ?? 0;
|
||||
let currentHappiness = statsAfterDecay.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevHygiene = currentHygiene;
|
||||
const prevHappiness = currentHappiness;
|
||||
currentHygiene = applyStat(currentHygiene, hygieneDelta);
|
||||
currentHappiness = applyStat(currentHappiness, happinessDelta);
|
||||
// Count as effective if hygiene OR happiness increased (positive effects only)
|
||||
const hygieneIncreased = hygieneDelta > 0 && currentHygiene > prevHygiene;
|
||||
const happinessIncreased = happinessDelta > 0 && currentHappiness > prevHappiness;
|
||||
if (hygieneIncreased || happinessIncreased) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
|
||||
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
@@ -283,58 +217,12 @@ export function useBlobbiUseInventoryItem({
|
||||
statsChanged.happiness = totalHappinessChange;
|
||||
}
|
||||
|
||||
// Apply decayed health
|
||||
statsUpdate.health = (statsAfterDecay.health ?? 0).toString();
|
||||
// hunger and energy stay at 100 for eggs
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
// Normal stats application for baby/adult
|
||||
// Apply item effects N times in sequence ON TOP of decayed stats
|
||||
// Use action-aware effectiveness checking for XP calculation
|
||||
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
|
||||
const effect = shopItem.effect;
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
const prevStats = { ...currentStats };
|
||||
currentStats = applyItemEffects(currentStats, effect);
|
||||
|
||||
// Action-aware effectiveness check:
|
||||
// Only count INTENDED positive effects, not negative side effects
|
||||
let isEffective = false;
|
||||
|
||||
if (action === 'feed') {
|
||||
// Feed: count when hunger/energy/health/happiness INCREASE
|
||||
// Do NOT count hygiene decrease (that's a side effect)
|
||||
const hungerIncreased = (effect.hunger ?? 0) > 0 && (currentStats.hunger ?? 0) > (prevStats.hunger ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hungerIncreased || energyIncreased || healthIncreased || happinessIncreased;
|
||||
} else if (action === 'clean') {
|
||||
// Clean: count when hygiene or happiness INCREASES
|
||||
const hygieneIncreased = (effect.hygiene ?? 0) > 0 && (currentStats.hygiene ?? 0) > (prevStats.hygiene ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = hygieneIncreased || happinessIncreased;
|
||||
} else if (action === 'medicine') {
|
||||
// Medicine: count when health/energy/happiness INCREASE
|
||||
// Do NOT count negative side effects (like happiness decrease on Super Medicine)
|
||||
const healthIncreased = (effect.health ?? 0) > 0 && (currentStats.health ?? 0) > (prevStats.health ?? 0);
|
||||
const energyIncreased = (effect.energy ?? 0) > 0 && (currentStats.energy ?? 0) > (prevStats.energy ?? 0);
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
isEffective = healthIncreased || energyIncreased || happinessIncreased;
|
||||
} else if (action === 'play') {
|
||||
// Play: EXCEPTION - both happiness increase AND energy decrease are intended effects
|
||||
// Playing naturally consumes energy, so energy decrease counts as valid
|
||||
const happinessIncreased = (effect.happiness ?? 0) > 0 && (currentStats.happiness ?? 0) > (prevStats.happiness ?? 0);
|
||||
const energyDecreased = (effect.energy ?? 0) < 0 && (currentStats.energy ?? 0) < (prevStats.energy ?? 0);
|
||||
isEffective = happinessIncreased || energyDecreased;
|
||||
}
|
||||
|
||||
if (isEffective) {
|
||||
effectiveItemCount++;
|
||||
}
|
||||
}
|
||||
// Normal stats application for baby/adult — apply once
|
||||
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
@@ -367,11 +255,8 @@ export function useBlobbiUseInventoryItem({
|
||||
// Get streak updates (will only update if needed based on day)
|
||||
const streakUpdates = getStreakTagUpdates(canonical.companion) ?? {};
|
||||
|
||||
// ─── Apply XP Gain (Based on effective item count) ───
|
||||
// Only grant XP for items that actually changed stats.
|
||||
// If user used 100 food items but hunger capped at item #4, only 4 items were effective.
|
||||
// This prevents XP farming by mass-using items after stats are already maxed.
|
||||
const xpGained = effectiveItemCount > 0 ? calculateInventoryActionXP(action, effectiveItemCount) : 0;
|
||||
// ─── Apply XP Gain ───
|
||||
const xpGained = calculateInventoryActionXP(action, 1);
|
||||
const currentXP = canonical.companion.experience ?? 0;
|
||||
const newXP = applyXPGain(currentXP, xpGained);
|
||||
|
||||
@@ -391,48 +276,25 @@ export function useBlobbiUseInventoryItem({
|
||||
|
||||
updateCompanionEvent(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
// Only decrement storage if the item actually exists in inventory.
|
||||
// Items are free to use regardless of inventory state.
|
||||
const hasItemInStorage = canonical.profileStorage.some(s => s.itemId === itemId && s.quantity > 0);
|
||||
if (hasItemInStorage) {
|
||||
const newStorage = decrementStorageItem(canonical.profileStorage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(canonical.profileAllTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
}
|
||||
|
||||
// No query invalidation needed — the optimistic updates above keep the
|
||||
// Items are free to use — no storage decrement needed.
|
||||
// No query invalidation needed — the optimistic update above keeps the
|
||||
// cache correct, and ensureCanonicalBeforeAction fetches fresh from relays
|
||||
// before every mutation (read-modify-write pattern).
|
||||
|
||||
return {
|
||||
itemName: shopItem.name,
|
||||
action,
|
||||
quantity,
|
||||
effectiveItemCount, // How many items actually changed stats
|
||||
statsChanged,
|
||||
xpGained,
|
||||
newXP,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ itemName, action, quantity, xpGained }) => {
|
||||
onSuccess: ({ itemName, action, xpGained }) => {
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
|
||||
const xpText = formatXPGain(xpGained);
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${itemName}${quantityText} on your Blobbi. ${xpText}`,
|
||||
description: `Used ${itemName} on your Blobbi. ${xpText}`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
import { STAT_MIN, STAT_MAX, type BlobbiCompanion, type BlobbiStats, type StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ItemEffect, ShopItemCategory } from '@/blobbi/shop/types/shop.types';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { getShopItemById, getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
|
||||
// ─── Action Types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Actions that consume inventory items
|
||||
* Item-based care actions (use a shop catalog item on the companion)
|
||||
*/
|
||||
export type InventoryAction = 'feed' | 'play' | 'clean' | 'medicine';
|
||||
|
||||
/**
|
||||
* Non-inventory actions that don't consume items
|
||||
* These actions affect stats directly without using shop items.
|
||||
* Direct actions that don't use items.
|
||||
* These actions affect stats directly without selecting a shop item.
|
||||
*/
|
||||
export type DirectAction = 'play_music' | 'sing';
|
||||
|
||||
/**
|
||||
* All Blobbi actions (inventory + direct)
|
||||
* All Blobbi actions (item-based + direct)
|
||||
*/
|
||||
export type BlobbiAction = InventoryAction | DirectAction;
|
||||
|
||||
@@ -33,7 +33,7 @@ export const ACTION_TO_ITEM_TYPE: Record<InventoryAction, ShopItemCategory> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for UI display (inventory actions)
|
||||
* Action metadata for UI display (item-based care actions)
|
||||
*/
|
||||
export const ACTION_METADATA: Record<InventoryAction, { label: string; description: string; icon: string }> = {
|
||||
feed: {
|
||||
@@ -59,7 +59,7 @@ export const ACTION_METADATA: Record<InventoryAction, { label: string; descripti
|
||||
};
|
||||
|
||||
/**
|
||||
* Action metadata for direct actions (non-inventory)
|
||||
* Action metadata for direct actions (no item required)
|
||||
*/
|
||||
export const DIRECT_ACTION_METADATA: Record<DirectAction, { label: string; description: string; icon: string }> = {
|
||||
play_music: {
|
||||
@@ -270,10 +270,10 @@ export function hasHappinessEffectForEgg(effects: ItemEffect | undefined): boole
|
||||
return effects.happiness !== undefined && effects.happiness !== 0;
|
||||
}
|
||||
|
||||
// ─── Inventory Helpers ────────────────────────────────────────────────────────
|
||||
// ─── Item Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolved inventory item with shop metadata
|
||||
* Resolved catalog item with shop metadata
|
||||
*/
|
||||
export interface ResolvedInventoryItem {
|
||||
itemId: string;
|
||||
@@ -285,7 +285,7 @@ export interface ResolvedInventoryItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for filtering inventory by action
|
||||
* Options for filtering catalog items by action
|
||||
*/
|
||||
export interface FilterInventoryOptions {
|
||||
/** Companion stage - used to filter items by egg-compatible effects */
|
||||
@@ -293,8 +293,8 @@ export interface FilterInventoryOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter inventory items by action type.
|
||||
* Returns resolved items with shop metadata.
|
||||
* Get all available items for an action type from the shop catalog.
|
||||
* Items are abilities/tools — no inventory ownership is required.
|
||||
*
|
||||
* Filtering rules:
|
||||
* - Only items matching the action's item type are included
|
||||
@@ -304,22 +304,20 @@ export interface FilterInventoryOptions {
|
||||
* - clean action: only items with hygiene or happiness effect
|
||||
*/
|
||||
export function filterInventoryByAction(
|
||||
storage: StorageItem[],
|
||||
_storage: StorageItem[],
|
||||
action: InventoryAction,
|
||||
options: FilterInventoryOptions = {}
|
||||
): ResolvedInventoryItem[] {
|
||||
const allowedType = ACTION_TO_ITEM_TYPE[action];
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
const isEgg = options.stage === 'egg';
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
for (const storageItem of storage) {
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
for (const shopItem of allItems) {
|
||||
if (shopItem.type !== allowedType) continue;
|
||||
if (storageItem.quantity <= 0) continue;
|
||||
|
||||
// Shell Repair Kit: only show for eggs in medicine modal
|
||||
if (storageItem.itemId === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
if (shopItem.id === SHELL_REPAIR_KIT_ID && !isEgg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -334,8 +332,8 @@ export function filterInventoryByAction(
|
||||
}
|
||||
|
||||
result.push({
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: shopItem.id,
|
||||
quantity: Infinity,
|
||||
name: shopItem.name,
|
||||
icon: shopItem.icon,
|
||||
type: shopItem.type,
|
||||
@@ -376,7 +374,7 @@ export function decrementStorageItem(
|
||||
// ─── Stage Restriction Helpers ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stages that can use general inventory items (food, toys, hygiene)
|
||||
* Stages that can use general items (food, toys, hygiene)
|
||||
*/
|
||||
export const GENERAL_ITEM_USABLE_STAGES = ['baby', 'adult'] as const;
|
||||
|
||||
@@ -409,14 +407,14 @@ export const EGG_VISIBLE_ACTIONS: BlobbiAction[] = ['clean', 'medicine', 'play_m
|
||||
export const EGG_ALLOWED_ACTIONS = EGG_ALLOWED_INVENTORY_ACTIONS;
|
||||
|
||||
/**
|
||||
* Check if a companion can use a specific inventory action.
|
||||
* Check if a companion can use a specific item action.
|
||||
*
|
||||
* Note: This function no longer hard-blocks egg actions at the domain layer.
|
||||
* UI visibility is handled separately by `isActionVisibleForStage()`.
|
||||
* The domain layer allows all actions - UI chooses what to show.
|
||||
*/
|
||||
export function canUseAction(_companion: BlobbiCompanion, _action: InventoryAction): boolean {
|
||||
// All stages can technically use all inventory actions at the domain layer.
|
||||
// All stages can technically use all item actions at the domain layer.
|
||||
// UI filtering determines what actions are shown to users.
|
||||
return true;
|
||||
}
|
||||
@@ -442,7 +440,7 @@ export function isActionVisibleForStage(stage: 'egg' | 'baby' | 'adult', action:
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a companion can use general inventory items (feed, play, clean).
|
||||
* Check if a companion can use general items (feed, play, clean).
|
||||
* Eggs cannot use food, toys, or hygiene items.
|
||||
* @deprecated Use canUseAction(companion, action) for action-specific checks
|
||||
*/
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* Design Philosophy:
|
||||
* - Different actions award different XP to reflect their complexity/value
|
||||
* - XP values are balanced to encourage variety in care activities
|
||||
* - Direct actions (sing, play_music) give moderate XP as they're free
|
||||
* - Inventory actions (feed, play, clean, medicine) give varied XP based on resource cost
|
||||
* - Item actions (feed, play, clean, medicine) give varied XP per action type
|
||||
* - Direct actions (sing, play_music) give moderate XP
|
||||
* - XP accumulates across all life stages and never resets
|
||||
*/
|
||||
|
||||
@@ -17,19 +17,18 @@ import type { BlobbiAction, InventoryAction, DirectAction } from './blobbi-actio
|
||||
// ─── XP Values by Action ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base XP values for inventory actions (feed, play, clean, medicine).
|
||||
* These actions consume items from the player's storage.
|
||||
* Base XP values for item-based care actions (feed, play, clean, medicine).
|
||||
*/
|
||||
export const INVENTORY_ACTION_XP: Record<InventoryAction, number> = {
|
||||
feed: 5, // Feeding is common and essential - moderate XP
|
||||
play: 8, // Playing toys provides good interaction - higher XP
|
||||
clean: 6, // Hygiene maintenance is important - moderate-high XP
|
||||
medicine: 10, // Medicine is costly and critical - highest inventory XP
|
||||
medicine: 10, // Medicine is critical - highest item XP
|
||||
};
|
||||
|
||||
/**
|
||||
* Base XP values for direct actions (play_music, sing).
|
||||
* These actions don't consume items - they're free activities.
|
||||
* These actions don't require selecting an item.
|
||||
*/
|
||||
export const DIRECT_ACTION_XP: Record<DirectAction, number> = {
|
||||
play_music: 7, // Playing music is engaging - good XP
|
||||
@@ -58,11 +57,10 @@ export function calculateActionXP(action: BlobbiAction): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total XP gain for using multiple items.
|
||||
* Each item use counts as a separate action for XP purposes.
|
||||
* Calculate XP gain for an item-based care action.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of items used (defaults to 1)
|
||||
* @param quantity - Number of times performed (always 1 in current usage)
|
||||
* @returns Total XP points earned
|
||||
*/
|
||||
export function calculateInventoryActionXP(action: InventoryAction, quantity: number = 1): number {
|
||||
@@ -88,8 +86,8 @@ export function applyXPGain(currentXP: number | undefined, xpGain: number): numb
|
||||
* Get XP gain summary for displaying to the user.
|
||||
*
|
||||
* @param action - The action performed
|
||||
* @param quantity - Number of times the action was performed (for inventory actions)
|
||||
* @returns Object with xpGained and total quantity
|
||||
* @param quantity - Number of times the action was performed (always 1 in current usage)
|
||||
* @returns Object with xpGained and quantity
|
||||
*/
|
||||
export function getXPGainSummary(
|
||||
action: BlobbiAction,
|
||||
|
||||
@@ -161,7 +161,7 @@ export function BlobbiCompanionLayer() {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await contextUseItem(item.id, action, 1);
|
||||
const result = await contextUseItem(item.id, action);
|
||||
|
||||
if (result.success) {
|
||||
if (import.meta.env.DEV) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { BlobbiBabyVisual } from '@/blobbi/ui/BlobbiBabyVisual';
|
||||
import { BlobbiAdultVisual } from '@/blobbi/ui/BlobbiAdultVisual';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { companionDataToBlobbi } from '@/blobbi/ui/lib/adapters';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/EmotionDevContext';
|
||||
import { useEffectiveEmotion } from '@/blobbi/dev/useEmotionDev';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { BodyEffectsSpec } from '@/blobbi/ui/lib/bodyEffects';
|
||||
|
||||
@@ -102,7 +102,7 @@ export function useBlobbiCompanionState({
|
||||
setState('walking');
|
||||
setDirection('right');
|
||||
setTargetX(targetX);
|
||||
}, [bounds.maxX]);
|
||||
}, [bounds.maxX, motionRef]);
|
||||
|
||||
/**
|
||||
* Generate a random observation target on screen.
|
||||
@@ -136,7 +136,7 @@ export function useBlobbiCompanionState({
|
||||
setState('walking');
|
||||
setDirection(newDirection);
|
||||
setTargetX(targetXPos);
|
||||
}, [bounds, generateObservationTarget]);
|
||||
}, [bounds, generateObservationTarget, motionRef]);
|
||||
|
||||
// Make a decision about what to do next
|
||||
const makeDecision = useCallback(() => {
|
||||
@@ -176,7 +176,7 @@ export function useBlobbiCompanionState({
|
||||
// Schedule next decision
|
||||
const duration = transition.duration ?? randomDuration(config.idleTime);
|
||||
timerRef.current = window.setTimeout(makeDecision, duration);
|
||||
}, [isActive, isSleeping, bounds, state, config, startObservation]);
|
||||
}, [isActive, isSleeping, bounds, state, config, startObservation, motionRef]);
|
||||
|
||||
// Handle reaching target
|
||||
const onReachedTarget = useCallback(() => {
|
||||
@@ -255,7 +255,7 @@ export function useBlobbiCompanionState({
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision]);
|
||||
}, [isActive, isSleeping, forceInitialWalk, startInitialWalk, makeDecision, motionRef]);
|
||||
|
||||
// Pause decisions while dragging
|
||||
// We poll isDragging via interval since motionRef changes don't trigger re-renders
|
||||
|
||||
+7
-9
@@ -15,17 +15,15 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
|
||||
import { useBlobbiItemUse } from './useBlobbiItemUse';
|
||||
import {
|
||||
BlobbiActionsContext,
|
||||
BlobbiActionsProvider,
|
||||
type UseItemFunction,
|
||||
type UseItemResult,
|
||||
type BlobbiActionsContextValue,
|
||||
type BlobbiActionsContextInternal,
|
||||
} from './BlobbiActionsProvider';
|
||||
} from './BlobbiActionsContextDef';
|
||||
|
||||
// Re-export everything from the provider module for backward compatibility
|
||||
// Re-export types and context from the def module for backward compatibility
|
||||
export {
|
||||
BlobbiActionsContext,
|
||||
BlobbiActionsProvider,
|
||||
type UseItemFunction,
|
||||
type UseItemResult,
|
||||
type BlobbiActionsContextValue,
|
||||
@@ -64,13 +62,13 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
// Create stable useItem function that:
|
||||
// 1. Uses registered function if available (from BlobbiPage)
|
||||
// 2. Falls back to built-in hook if no registration
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
// Try registered function first (from BlobbiPage)
|
||||
if (context?.registerRef.current) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[BlobbiActions] Using registered item-use function');
|
||||
}
|
||||
return context.registerRef.current(itemId, action, quantity);
|
||||
return context.registerRef.current(itemId, action);
|
||||
}
|
||||
|
||||
// Check if fallback can handle it
|
||||
@@ -88,7 +86,7 @@ export function useBlobbiActions(): BlobbiActionsContextValue {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('[BlobbiActions] Using fallback item-use hook');
|
||||
}
|
||||
return fallbackItemUse.useItem(itemId, action, quantity);
|
||||
return fallbackItemUse.useItem(itemId, action);
|
||||
}, [context, fallbackItemUse]);
|
||||
|
||||
// Determine canUseItems: true if registered OR fallback can use
|
||||
@@ -136,14 +134,14 @@ export function useBlobbiActionsRegistration(
|
||||
useItemRef.current = useItemFn;
|
||||
|
||||
// Create a stable wrapper that delegates to the ref
|
||||
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
const stableUseItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
if (!useItemRef.current) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Item use function not available',
|
||||
};
|
||||
}
|
||||
return useItemRef.current(itemId, action, quantity);
|
||||
return useItemRef.current(itemId, action);
|
||||
}, []);
|
||||
|
||||
// Update refs and notify only when canUseItems actually changes
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* BlobbiActionsContextDef
|
||||
*
|
||||
* Lightweight context definition and types for the Blobbi actions system.
|
||||
* Separated from the provider component to avoid react-refresh warnings.
|
||||
*/
|
||||
|
||||
import { createContext } from 'react';
|
||||
|
||||
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of using an item via the context.
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
/** Whether the use was successful */
|
||||
success: boolean;
|
||||
/** Stats that changed (key = stat name, value = delta) */
|
||||
statsChanged?: Record<string, number>;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function signature for using an item (always uses once).
|
||||
*/
|
||||
export type UseItemFunction = (
|
||||
itemId: string,
|
||||
action: InventoryAction,
|
||||
) => Promise<UseItemResult>;
|
||||
|
||||
/**
|
||||
* Context value for Blobbi actions (consumer side).
|
||||
*/
|
||||
export interface BlobbiActionsContextValue {
|
||||
/**
|
||||
* Use an item on the current companion.
|
||||
* Works even without BlobbiPage registration (uses fallback).
|
||||
*/
|
||||
useItem: UseItemFunction;
|
||||
|
||||
/** Whether an item use operation is currently in progress */
|
||||
isUsingItem: boolean;
|
||||
|
||||
/** Whether items can be used (companion exists and profile loaded) */
|
||||
canUseItems: boolean;
|
||||
|
||||
/** Check if an item is on cooldown (recently attempted) */
|
||||
isItemOnCooldown: (itemId: string) => boolean;
|
||||
|
||||
/** Clear cooldown for an item */
|
||||
clearItemCooldown: (itemId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context value (includes registration functions).
|
||||
*/
|
||||
export interface BlobbiActionsContextInternal {
|
||||
/** Register item-use functionality (called by BlobbiPage) */
|
||||
registerRef: React.MutableRefObject<UseItemFunction | null>;
|
||||
/** Whether items can currently be used (via registration) */
|
||||
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Whether an item is currently being used (via registration) */
|
||||
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Force update consumers (called sparingly) */
|
||||
notifyUpdate: () => void;
|
||||
/** Subscribe to updates */
|
||||
subscribe: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
|
||||
@@ -10,75 +10,13 @@
|
||||
* BlobbiPage, both of which are lazy-loaded.
|
||||
*/
|
||||
|
||||
import { createContext, useCallback, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
|
||||
|
||||
import type { InventoryAction } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of using an item via the context.
|
||||
*/
|
||||
export interface UseItemResult {
|
||||
/** Whether the use was successful */
|
||||
success: boolean;
|
||||
/** Stats that changed (key = stat name, value = delta) */
|
||||
statsChanged?: Record<string, number>;
|
||||
/** Error message if failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function signature for using an item.
|
||||
*/
|
||||
export type UseItemFunction = (
|
||||
itemId: string,
|
||||
action: InventoryAction,
|
||||
quantity?: number
|
||||
) => Promise<UseItemResult>;
|
||||
|
||||
/**
|
||||
* Context value for Blobbi actions (consumer side).
|
||||
*/
|
||||
export interface BlobbiActionsContextValue {
|
||||
/**
|
||||
* Use an inventory item on the current companion.
|
||||
* Works even without BlobbiPage registration (uses fallback).
|
||||
*/
|
||||
useItem: UseItemFunction;
|
||||
|
||||
/** Whether an item use operation is currently in progress */
|
||||
isUsingItem: boolean;
|
||||
|
||||
/** Whether items can be used (companion exists and profile loaded) */
|
||||
canUseItems: boolean;
|
||||
|
||||
/** Check if an item is on cooldown (recently attempted) */
|
||||
isItemOnCooldown: (itemId: string) => boolean;
|
||||
|
||||
/** Clear cooldown for an item */
|
||||
clearItemCooldown: (itemId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal context value (includes registration functions).
|
||||
*/
|
||||
export interface BlobbiActionsContextInternal {
|
||||
/** Register item-use functionality (called by BlobbiPage) */
|
||||
registerRef: React.MutableRefObject<UseItemFunction | null>;
|
||||
/** Whether items can currently be used (via registration) */
|
||||
canUseItemsRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Whether an item is currently being used (via registration) */
|
||||
isUsingItemRegisteredRef: React.MutableRefObject<boolean>;
|
||||
/** Force update consumers (called sparingly) */
|
||||
notifyUpdate: () => void;
|
||||
/** Subscribe to updates */
|
||||
subscribe: (callback: () => void) => () => void;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BlobbiActionsContext = createContext<BlobbiActionsContextInternal | null>(null);
|
||||
import {
|
||||
BlobbiActionsContext,
|
||||
type UseItemFunction,
|
||||
type BlobbiActionsContextInternal,
|
||||
} from './BlobbiActionsContextDef';
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
/**
|
||||
* HangingItems
|
||||
*
|
||||
* Displays inventory items as hanging elements from the top of the screen.
|
||||
* Displays available items as hanging elements from the top of the screen.
|
||||
* Each item appears as a circle connected to the top by a thin vertical line,
|
||||
* creating a playful, spatial feel.
|
||||
*
|
||||
* Items are reusable abilities sourced from the shop catalog — they are
|
||||
* always available and not consumed on use.
|
||||
*
|
||||
* State Model:
|
||||
* - Container states: hidden → opening → open → closing → hidden
|
||||
* - Hanging items = available inventory that can still be released
|
||||
* - Hanging items = catalog items available for the selected action
|
||||
* - Released/dropped items = instances currently in the world (tracked with unique IDs)
|
||||
* - Multiple instances of the same item type can exist simultaneously on the ground
|
||||
*
|
||||
* Key Design Principle:
|
||||
* The hanging row represents "releasable quantity" - clicking releases ONE instance
|
||||
* and immediately decrements the visible quantity. A new hanging copy remains if
|
||||
* quantity > 1. The released instance tracks separately with a unique instance ID.
|
||||
*
|
||||
* Features:
|
||||
* - Smooth open/close slide animations (items descend/ascend)
|
||||
* - Thin vertical lines from the top of screen
|
||||
* - Circular containers for hanging items
|
||||
* - Click releases item: one instance falls, remaining quantity stays hanging
|
||||
* - Click releases item: one instance falls to the ground
|
||||
* - Multiple dropped instances of same item type can exist
|
||||
* - Contact detection: items auto-use when touching Blobbi
|
||||
* - Click-to-use: click landed items to use them
|
||||
@@ -119,7 +117,7 @@ interface HangingItemsProps {
|
||||
onItemUse?: (item: CompanionItem) => Promise<ItemUseAttemptResult>;
|
||||
/**
|
||||
* Callback when an item is collected by Blobbi (contact).
|
||||
* @deprecated Use onItemUse instead for proper item consumption flow.
|
||||
* @deprecated Use onItemUse instead for proper item-use flow.
|
||||
*/
|
||||
onItemCollected?: (item: CompanionItem) => void;
|
||||
/**
|
||||
@@ -156,7 +154,7 @@ const HANGING_CONFIG = {
|
||||
baseFallDistance: 500,
|
||||
/** Ground offset from bottom of viewport */
|
||||
defaultGroundOffset: 40,
|
||||
/** Size of quantity badge */
|
||||
/** Size of badge (unused — kept for config consistency) */
|
||||
badgeSize: 20,
|
||||
/** Size of landed item hitbox for contact detection */
|
||||
landedItemSize: 40,
|
||||
@@ -406,7 +404,7 @@ export function HangingItems({
|
||||
|
||||
// Track how many instances of each item type have been released (not yet used)
|
||||
// Key: item.id (type ID), Value: count of released instances
|
||||
const [releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
|
||||
const [_releasedCountByItemId, setReleasedCountByItemId] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Counter for generating unique instance IDs
|
||||
const instanceCounterRef = useRef(0);
|
||||
@@ -566,7 +564,7 @@ export function HangingItems({
|
||||
|
||||
// Start the loop
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
}, []);
|
||||
}, [calculateFallDuration]);
|
||||
|
||||
// Cleanup animation on unmount
|
||||
useEffect(() => {
|
||||
@@ -670,7 +668,7 @@ export function HangingItems({
|
||||
});
|
||||
// Also remove from zone tracking
|
||||
itemsInZoneRef.current.delete(instanceId);
|
||||
// Decrement the released count for this item type (since the instance is now consumed)
|
||||
// Decrement the released count for this item type (instance removed from screen)
|
||||
setReleasedCountByItemId(prev => {
|
||||
const next = new Map(prev);
|
||||
const currentCount = next.get(item.id) || 0;
|
||||
@@ -985,15 +983,9 @@ export function HangingItems({
|
||||
return viewportCenterX + startX + index * HANGING_CONFIG.itemSpacing;
|
||||
};
|
||||
|
||||
// Calculate hanging items with their remaining quantities
|
||||
// An item appears in the hanging row if (quantity - releasedCount) > 0
|
||||
const hangingItems = items
|
||||
.map(item => {
|
||||
const releasedCount = releasedCountByItemId.get(item.id) || 0;
|
||||
const remainingQuantity = item.quantity - releasedCount;
|
||||
return { ...item, quantity: remainingQuantity };
|
||||
})
|
||||
.filter(item => item.quantity > 0);
|
||||
// All items are always visible — they are abilities, not consumable inventory.
|
||||
// No quantity filtering needed.
|
||||
const hangingItems = items;
|
||||
|
||||
// Should we render the hanging container?
|
||||
const shouldRenderContainer = containerState !== 'hidden' || (isVisible && selectedAction);
|
||||
@@ -1033,7 +1025,7 @@ export function HangingItems({
|
||||
>
|
||||
<div className="bg-background/95 backdrop-blur-sm rounded-2xl px-6 py-4 shadow-lg border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items in your inventory
|
||||
No {getMenuActionConfig(selectedAction)?.label.toLowerCase()} items available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1102,8 +1094,8 @@ export function HangingItems({
|
||||
marginLeft: (HANGING_CONFIG.circleSize / 2) * -1 + HANGING_CONFIG.lineWidth / 2,
|
||||
}}
|
||||
onClick={() => handleItemClick(item, itemX)}
|
||||
title={`${item.name} (x${item.quantity})`}
|
||||
aria-label={`${item.name}, quantity ${item.quantity}. Click to release.`}
|
||||
title={item.name}
|
||||
aria-label={`${item.name}. Click to release.`}
|
||||
>
|
||||
{/* Item emoji */}
|
||||
<span
|
||||
@@ -1114,24 +1106,6 @@ export function HangingItems({
|
||||
>
|
||||
{item.emoji}
|
||||
</span>
|
||||
|
||||
{/* Quantity badge */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -right-1",
|
||||
"flex items-center justify-center",
|
||||
"bg-primary text-primary-foreground",
|
||||
"text-xs font-semibold rounded-full",
|
||||
"shadow-md"
|
||||
)}
|
||||
style={{
|
||||
minWidth: HANGING_CONFIG.badgeSize,
|
||||
height: HANGING_CONFIG.badgeSize,
|
||||
padding: '0 5px',
|
||||
}}
|
||||
>
|
||||
{item.quantity}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -76,10 +76,10 @@ export { useBlobbiItemUse } from './useBlobbiItemUse';
|
||||
// Context
|
||||
export {
|
||||
BlobbiActionsContext,
|
||||
BlobbiActionsProvider,
|
||||
useBlobbiActions,
|
||||
useBlobbiActionsRegistration,
|
||||
} from './BlobbiActionsContext';
|
||||
export { BlobbiActionsProvider } from './BlobbiActionsProvider';
|
||||
|
||||
// Components
|
||||
export { CompanionActionMenu } from './CompanionActionMenu';
|
||||
|
||||
@@ -63,7 +63,7 @@ export function getItemCategoryForAction(actionId: CompanionMenuAction): ShopIte
|
||||
|
||||
/**
|
||||
* Normalized item representation for the companion UI.
|
||||
* This is a simplified view of inventory items optimized for rendering.
|
||||
* This is a simplified view of shop catalog items optimized for rendering.
|
||||
*/
|
||||
export interface CompanionItem {
|
||||
/** Unique item ID (matches shop item ID) */
|
||||
@@ -74,7 +74,7 @@ export interface CompanionItem {
|
||||
emoji: string;
|
||||
/** Item category */
|
||||
category: ShopItemCategory;
|
||||
/** Quantity available in inventory */
|
||||
/** Availability (always Infinity — items are reusable abilities) */
|
||||
quantity: number;
|
||||
/** Item effects when used */
|
||||
effect?: ItemEffect;
|
||||
|
||||
@@ -27,13 +27,10 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { BlobbiCompanion, BlobbonautProfile, BlobbiStats } from '@/blobbi/core/lib/blobbi';
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import {
|
||||
KIND_BLOBBI_STATE,
|
||||
KIND_BLOBBONAUT_PROFILE,
|
||||
updateBlobbiTags,
|
||||
updateBlobbonautTags,
|
||||
createStorageTags,
|
||||
parseBlobbiEvent,
|
||||
isValidBlobbiEvent,
|
||||
} from '@/blobbi/core/lib/blobbi';
|
||||
@@ -41,7 +38,6 @@ import { applyBlobbiDecay } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import {
|
||||
applyItemEffects,
|
||||
decrementStorageItem,
|
||||
canUseAction,
|
||||
canUseItemForStage,
|
||||
getStageRestrictionMessage,
|
||||
@@ -59,7 +55,7 @@ import { getStreakTagUpdates } from '@/blobbi/actions/lib/blobbi-streak';
|
||||
import { HATCH_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useHatchTasks';
|
||||
import { EVOLVE_REQUIRED_INTERACTIONS } from '@/blobbi/actions/hooks/useEvolveTasks';
|
||||
|
||||
import type { UseItemFunction } from './BlobbiActionsProvider';
|
||||
import type { UseItemFunction } from './BlobbiActionsContextDef';
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -126,7 +122,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch profile if not provided
|
||||
const { profile: fetchedProfile, updateProfileEvent } = useBlobbonautProfile();
|
||||
const { profile: fetchedProfile } = useBlobbonautProfile();
|
||||
const profile = options.profile ?? fetchedProfile;
|
||||
|
||||
// Per-item cooldown tracking (ref to avoid re-renders)
|
||||
@@ -232,16 +228,14 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
});
|
||||
}, [queryClient, user?.pubkey, profile?.currentCompanion]);
|
||||
|
||||
// Core mutation for using items
|
||||
// Core mutation for using items (always uses once)
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
itemId,
|
||||
action,
|
||||
quantity = 1,
|
||||
}: {
|
||||
itemId: string;
|
||||
action: InventoryAction;
|
||||
quantity?: number;
|
||||
}): Promise<{ statsChanged: Record<string, number> }> => {
|
||||
// ─── Validation ───
|
||||
if (!user?.pubkey) {
|
||||
@@ -259,11 +253,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
throw new Error('No companion selected');
|
||||
}
|
||||
|
||||
// Validate quantity
|
||||
if (quantity < 1) {
|
||||
throw new Error('Quantity must be at least 1');
|
||||
}
|
||||
|
||||
// Check stage restrictions
|
||||
if (!canUseAction(companion, action)) {
|
||||
const message = getStageRestrictionMessage(companion, action);
|
||||
@@ -283,15 +272,6 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
throw new Error(itemUsability.reason ?? 'This item cannot be used by this companion');
|
||||
}
|
||||
|
||||
// Validate item exists in storage with sufficient quantity
|
||||
const storageItem = profile.storage.find(s => s.itemId === itemId);
|
||||
if (!storageItem || storageItem.quantity <= 0) {
|
||||
throw new Error('Item not found in your inventory');
|
||||
}
|
||||
if (storageItem.quantity < quantity) {
|
||||
throw new Error(`Not enough items in inventory (have ${storageItem.quantity}, need ${quantity})`);
|
||||
}
|
||||
|
||||
// Validate item has effects
|
||||
if (!shopItem.effect) {
|
||||
throw new Error('This item has no effect');
|
||||
@@ -319,17 +299,13 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
// Start with decayed stats as the base
|
||||
const statsAfterDecay = decayResult.stats;
|
||||
|
||||
// ─── Apply Item Effects ───
|
||||
// ─── Apply Item Effects (single use) ───
|
||||
const isEggCompanion = companion.stage === 'egg';
|
||||
const statsUpdate: Record<string, string> = {};
|
||||
const statsChanged: Record<string, number> = {};
|
||||
|
||||
if (isEggCompanion && action === 'medicine') {
|
||||
const healthDelta = shopItem.effect.health ?? 0;
|
||||
let currentHealth = statsAfterDecay.health ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHealth = applyStat(currentHealth, healthDelta);
|
||||
}
|
||||
const currentHealth = applyStat(statsAfterDecay.health ?? 0, shopItem.effect.health ?? 0);
|
||||
|
||||
statsUpdate.health = currentHealth.toString();
|
||||
statsChanged.health = currentHealth - (statsAfterDecay.health ?? 0);
|
||||
@@ -339,15 +315,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else if (isEggCompanion && action === 'clean') {
|
||||
const hygieneDelta = shopItem.effect.hygiene ?? 0;
|
||||
const happinessDelta = shopItem.effect.happiness ?? 0;
|
||||
|
||||
let currentHygiene = statsAfterDecay.hygiene ?? 0;
|
||||
let currentHappiness = statsAfterDecay.happiness ?? 0;
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentHygiene = applyStat(currentHygiene, hygieneDelta);
|
||||
currentHappiness = applyStat(currentHappiness, happinessDelta);
|
||||
}
|
||||
const currentHygiene = applyStat(statsAfterDecay.hygiene ?? 0, shopItem.effect.hygiene ?? 0);
|
||||
const currentHappiness = applyStat(statsAfterDecay.happiness ?? 0, shopItem.effect.happiness ?? 0);
|
||||
|
||||
statsUpdate.hygiene = currentHygiene.toString();
|
||||
statsChanged.hygiene = currentHygiene - (statsAfterDecay.hygiene ?? 0);
|
||||
@@ -362,11 +331,8 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
statsUpdate.hunger = '100';
|
||||
statsUpdate.energy = '100';
|
||||
} else {
|
||||
// Normal stats application for baby/adult
|
||||
let currentStats: Partial<BlobbiStats> = { ...statsAfterDecay };
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
currentStats = applyItemEffects(currentStats, shopItem.effect);
|
||||
}
|
||||
// Normal stats application for baby/adult — apply once
|
||||
const currentStats = applyItemEffects({ ...statsAfterDecay }, shopItem.effect);
|
||||
|
||||
statsUpdate.hunger = clampStat(currentStats.hunger).toString();
|
||||
statsChanged.hunger = (currentStats.hunger ?? 0) - (statsAfterDecay.hunger ?? 0);
|
||||
@@ -414,36 +380,19 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
|
||||
updateCompanionInCache(blobbiEvent);
|
||||
|
||||
// ─── Update Profile Storage (kind 11125) ───
|
||||
const newStorage = decrementStorageItem(profile.storage, itemId, quantity);
|
||||
const storageValues = createStorageTags(newStorage).map(tag => tag[1]);
|
||||
|
||||
const profileTags = updateBlobbonautTags(profile.allTags, {
|
||||
storage: storageValues,
|
||||
});
|
||||
|
||||
const profileEvent = await publishEvent({
|
||||
kind: KIND_BLOBBONAUT_PROFILE,
|
||||
content: '',
|
||||
tags: profileTags,
|
||||
});
|
||||
|
||||
updateProfileEvent(profileEvent);
|
||||
|
||||
// ─── Invalidate Queries ───
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbonaut-profile', user.pubkey] });
|
||||
// Items are free to use — no storage decrement needed.
|
||||
queryClient.invalidateQueries({ queryKey: ['blobbi-collection', user.pubkey] });
|
||||
|
||||
return { statsChanged };
|
||||
},
|
||||
onSuccess: (_, { itemId, action, quantity = 1 }) => {
|
||||
onSuccess: (_, { itemId, action }) => {
|
||||
const shopItem = getShopItemById(itemId);
|
||||
const actionMeta = ACTION_METADATA[action];
|
||||
const quantityText = quantity > 1 ? ` (x${quantity})` : '';
|
||||
|
||||
toast({
|
||||
title: `${actionMeta.label} successful!`,
|
||||
description: `Used ${shopItem?.name ?? 'item'}${quantityText} on your Blobbi.`,
|
||||
description: `Used ${shopItem?.name ?? 'item'} on your Blobbi.`,
|
||||
});
|
||||
|
||||
// Track daily mission progress
|
||||
@@ -468,7 +417,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
});
|
||||
|
||||
// Wrapper function that matches UseItemFunction signature and includes cooldown check
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action, quantity = 1) => {
|
||||
const useItem = useCallback<UseItemFunction>(async (itemId, action) => {
|
||||
// Check cooldown first
|
||||
if (isItemOnCooldown(itemId)) {
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -481,7 +430,7 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await mutation.mutateAsync({ itemId, action, quantity });
|
||||
const result = await mutation.mutateAsync({ itemId, action });
|
||||
return {
|
||||
success: true,
|
||||
statsChanged: result.statsChanged,
|
||||
|
||||
@@ -18,8 +18,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useBlobbonautProfile } from '@/hooks/useBlobbonautProfile';
|
||||
import { getShopItemById } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import type { StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
|
||||
import type {
|
||||
@@ -68,7 +67,10 @@ interface UseCompanionActionMenuResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve inventory items for a specific action/category.
|
||||
* Resolve available items for a specific action/category from the shop catalog.
|
||||
*
|
||||
* Items are sourced from the full shop catalog — all items are
|
||||
* available as reusable abilities/tools, filtered only by stage.
|
||||
*
|
||||
* Uses the centralized `canUseItemForStage` function to ensure consistent
|
||||
* stage-based filtering across all UIs:
|
||||
@@ -80,7 +82,6 @@ interface UseCompanionActionMenuResult {
|
||||
* filters out all egg-only items from the companion interaction system.
|
||||
*/
|
||||
function resolveItemsForAction(
|
||||
storage: StorageItem[],
|
||||
action: CompanionMenuAction,
|
||||
stage: 'egg' | 'baby' | 'adult'
|
||||
): CompanionItem[] {
|
||||
@@ -89,13 +90,10 @@ function resolveItemsForAction(
|
||||
// Sleep action has no items
|
||||
if (!category) return [];
|
||||
|
||||
const allItems = getLiveShopItems();
|
||||
const items: CompanionItem[] = [];
|
||||
|
||||
for (const storageItem of storage) {
|
||||
if (storageItem.quantity <= 0) continue;
|
||||
|
||||
const shopItem = getShopItemById(storageItem.itemId);
|
||||
if (!shopItem) continue;
|
||||
for (const shopItem of allItems) {
|
||||
if (shopItem.type !== category) continue;
|
||||
|
||||
// Use centralized stage-based filtering
|
||||
@@ -104,17 +102,17 @@ function resolveItemsForAction(
|
||||
// - Food/Toys: only for baby/adult (excluded for eggs)
|
||||
// - Medicine: must have health effect
|
||||
// - Hygiene: must have hygiene or happiness effect
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
const usability = canUseItemForStage(shopItem.id, stage);
|
||||
if (!usability.canUse) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: storageItem.itemId,
|
||||
id: shopItem.id,
|
||||
name: shopItem.name,
|
||||
emoji: shopItem.icon,
|
||||
category: shopItem.type,
|
||||
quantity: storageItem.quantity,
|
||||
quantity: Infinity,
|
||||
effect: shopItem.effect,
|
||||
});
|
||||
}
|
||||
@@ -197,8 +195,8 @@ export function useCompanionActionMenu({
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve items for this action
|
||||
const items = resolveItemsForAction(profile.storage, action, stage);
|
||||
// Resolve items for this action from the catalog (not inventory)
|
||||
const items = resolveItemsForAction(action, stage);
|
||||
|
||||
setMenuState(prev => ({
|
||||
...prev,
|
||||
|
||||
@@ -42,7 +42,6 @@ export interface ItemUseResult {
|
||||
export type UseItemCallback = (
|
||||
itemId: string,
|
||||
action: InventoryAction,
|
||||
quantity: number
|
||||
) => Promise<{ success: boolean; statsChanged?: Record<string, number>; error?: string }>;
|
||||
|
||||
/**
|
||||
@@ -67,14 +66,14 @@ export interface UseCompanionItemUseResult {
|
||||
isUsingItem: boolean;
|
||||
/** Get the action type for an item category */
|
||||
getActionForCategory: (category: ShopItemCategory) => InventoryAction | null;
|
||||
/** Get the inventory action for a menu action */
|
||||
/** Get the care action for a menu action */
|
||||
getInventoryAction: (menuAction: CompanionMenuAction) => InventoryAction | null;
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map item categories to inventory actions.
|
||||
* Map item categories to care actions.
|
||||
* This is the canonical mapping for how items are used.
|
||||
*/
|
||||
export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null> = {
|
||||
@@ -85,14 +84,14 @@ export const CATEGORY_TO_ACTION: Record<ShopItemCategory, InventoryAction | null
|
||||
};
|
||||
|
||||
/**
|
||||
* Map menu actions to inventory actions (they match by design).
|
||||
* Map menu actions to item-based care actions (they match by design).
|
||||
*/
|
||||
export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, InventoryAction | null> = {
|
||||
feed: 'feed',
|
||||
play: 'play',
|
||||
medicine: 'medicine',
|
||||
clean: 'clean',
|
||||
sleep: null, // Sleep is a special action, not an inventory action
|
||||
sleep: null, // Sleep is a special action, not item-based
|
||||
};
|
||||
|
||||
// ─── Hook Implementation ──────────────────────────────────────────────────────
|
||||
@@ -108,8 +107,8 @@ export const MENU_ACTION_TO_INVENTORY_ACTION: Record<CompanionMenuAction, Invent
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { useItem, isUsingItem } = useCompanionItemUse({
|
||||
* onUseItem: async (itemId, action, qty) => {
|
||||
* return await executeUseItem({ itemId, action, quantity: qty });
|
||||
* onUseItem: async (itemId, action) => {
|
||||
* return await executeUseItem({ itemId, action });
|
||||
* },
|
||||
* onSuccess: (result) => removeItemFromScreen(result.item),
|
||||
* onFailure: (result) => keepItemOnScreen(result.item),
|
||||
@@ -134,7 +133,7 @@ export function useCompanionItemUse({
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the inventory action for a menu action.
|
||||
* Get the care action for a menu action.
|
||||
*/
|
||||
const getInventoryAction = useCallback((menuAction: CompanionMenuAction): InventoryAction | null => {
|
||||
return MENU_ACTION_TO_INVENTORY_ACTION[menuAction];
|
||||
@@ -187,7 +186,7 @@ export function useCompanionItemUse({
|
||||
|
||||
try {
|
||||
// Execute the use callback
|
||||
const useResult = await onUseItem(item.id, inventoryAction, 1);
|
||||
const useResult = await onUseItem(item.id, inventoryAction);
|
||||
|
||||
if (useResult.success) {
|
||||
const result: ItemUseResult = {
|
||||
|
||||
@@ -288,7 +288,7 @@ export interface BlobbiCompanion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored item in user's profile inventory
|
||||
* Stored item in user's profile (from purchases)
|
||||
*/
|
||||
export interface StorageItem {
|
||||
itemId: string; // Must match a ShopItem.id
|
||||
@@ -316,7 +316,7 @@ export interface BlobbonautProfile {
|
||||
coins: number;
|
||||
/** Petting level (interaction counter) */
|
||||
pettingLevel: number;
|
||||
/** Purchased items inventory */
|
||||
/** Purchased items storage */
|
||||
storage: StorageItem[];
|
||||
/** All tags preserved for republishing */
|
||||
allTags: string[][];
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Theater } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEmotionDev } from './EmotionDevContext';
|
||||
import { useEmotionDev } from './useEmotionDev';
|
||||
import { isLocalhostDev } from './index';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
|
||||
|
||||
@@ -10,26 +10,10 @@
|
||||
* - Is purely for visual testing/debugging
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||
import { useState, useCallback, type ReactNode } from 'react';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
import { isLocalhostDev } from './index';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface EmotionDevContextValue {
|
||||
/** Current dev emotion override (null = use default/neutral) */
|
||||
devEmotion: BlobbiEmotion | null;
|
||||
/** Set the dev emotion override */
|
||||
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
|
||||
/** Clear the dev emotion override (back to neutral) */
|
||||
clearDevEmotion: () => void;
|
||||
/** Whether dev emotion is active */
|
||||
isDevEmotionActive: boolean;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
|
||||
import { EmotionDevContext, type EmotionDevContextValue } from './useEmotionDev';
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -68,40 +52,4 @@ export function EmotionDevProvider({ children }: EmotionDevProviderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to access dev emotion state.
|
||||
* Returns null values in production for safety.
|
||||
*/
|
||||
export function useEmotionDev(): EmotionDevContextValue {
|
||||
const context = useContext(EmotionDevContext);
|
||||
|
||||
// Outside localhost dev or if no provider, return safe defaults
|
||||
if (!isLocalhostDev() || !context) {
|
||||
return {
|
||||
devEmotion: null,
|
||||
setDevEmotion: () => {},
|
||||
clearDevEmotion: () => {},
|
||||
isDevEmotionActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective emotion for a Blobbi.
|
||||
* In dev mode with an override, returns the dev emotion.
|
||||
* Otherwise returns the provided emotion or 'neutral'.
|
||||
*/
|
||||
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
|
||||
const { devEmotion, isDevEmotionActive } = useEmotionDev();
|
||||
|
||||
// Dev override takes precedence (only in localhost dev)
|
||||
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
|
||||
return devEmotion;
|
||||
}
|
||||
|
||||
return baseEmotion ?? 'neutral';
|
||||
}
|
||||
|
||||
@@ -35,5 +35,6 @@ export { BlobbiDevEditor, type BlobbiDevUpdates } from './BlobbiDevEditor';
|
||||
export { useBlobbiDevUpdate } from './useBlobbiDevUpdate';
|
||||
|
||||
// Emotion testing tools
|
||||
export { EmotionDevProvider, useEmotionDev, useEffectiveEmotion } from './EmotionDevContext';
|
||||
export { EmotionDevProvider } from './EmotionDevContext';
|
||||
export { useEmotionDev, useEffectiveEmotion } from './useEmotionDev';
|
||||
export { BlobbiEmotionPanel } from './BlobbiEmotionPanel';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotions';
|
||||
import { isLocalhostDev } from './index';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EmotionDevContextValue {
|
||||
/** Current dev emotion override (null = use default/neutral) */
|
||||
devEmotion: BlobbiEmotion | null;
|
||||
/** Set the dev emotion override */
|
||||
setDevEmotion: (emotion: BlobbiEmotion | null) => void;
|
||||
/** Clear the dev emotion override (back to neutral) */
|
||||
clearDevEmotion: () => void;
|
||||
/** Whether dev emotion is active */
|
||||
isDevEmotionActive: boolean;
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const EmotionDevContext = createContext<EmotionDevContextValue | null>(null);
|
||||
|
||||
// ─── Hooks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hook to access dev emotion state.
|
||||
* Returns null values in production for safety.
|
||||
*/
|
||||
export function useEmotionDev(): EmotionDevContextValue {
|
||||
const context = useContext(EmotionDevContext);
|
||||
|
||||
// Outside localhost dev or if no provider, return safe defaults
|
||||
if (!isLocalhostDev() || !context) {
|
||||
return {
|
||||
devEmotion: null,
|
||||
setDevEmotion: () => {},
|
||||
clearDevEmotion: () => {},
|
||||
isDevEmotionActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective emotion for a Blobbi.
|
||||
* In dev mode with an override, returns the dev emotion.
|
||||
* Otherwise returns the provided emotion or 'neutral'.
|
||||
*/
|
||||
export function useEffectiveEmotion(baseEmotion?: BlobbiEmotion): BlobbiEmotion {
|
||||
const { devEmotion, isDevEmotionActive } = useEmotionDev();
|
||||
|
||||
// Dev override takes precedence (only in localhost dev)
|
||||
if (isLocalhostDev() && isDevEmotionActive && devEmotion) {
|
||||
return devEmotion;
|
||||
}
|
||||
|
||||
return baseEmotion ?? 'neutral';
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// src/blobbi/rooms/components/BlobbiCareRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiCareRoom — Hygiene, care, and medicine room.
|
||||
*
|
||||
* Side actions depend on the currently focused carousel item:
|
||||
* - Hygiene focused: Towel (left) + Shower (right)
|
||||
* - Medicine focused: Treat (left) + spacer (right)
|
||||
*
|
||||
* Both left and right slots always render the same fixed width
|
||||
* so the bottom bar never shifts when switching item types.
|
||||
*/
|
||||
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { ShowerHead, Candy } from 'lucide-react';
|
||||
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
|
||||
|
||||
interface BlobbiCareRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiCareRoom({ ctx }: BlobbiCareRoomProps) {
|
||||
const {
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
handleUseItemFromTab,
|
||||
isPublishing,
|
||||
actionInProgress,
|
||||
isActiveFloatingCompanion,
|
||||
} = ctx;
|
||||
|
||||
const hygieneItems = useMemo(() =>
|
||||
getLiveShopItems().filter(i => i.type === 'hygiene'),
|
||||
[]);
|
||||
|
||||
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
|
||||
const towelItem = hygieneItems.find(i => i.id === 'hyg_towel');
|
||||
|
||||
// Carousel: hygiene (except towel) + medicine, each tagged with meta
|
||||
const carouselEntries = useMemo<CarouselEntry[]>(() => {
|
||||
const hygiene = getLiveShopItems()
|
||||
.filter(i => i.type === 'hygiene' && i.id !== 'hyg_towel')
|
||||
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'hygiene' }));
|
||||
const medicine = getLiveShopItems()
|
||||
.filter(i => i.type === 'medicine')
|
||||
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name, meta: 'medicine' }));
|
||||
return [...hygiene, ...medicine];
|
||||
}, []);
|
||||
|
||||
// Track the type of the currently focused carousel item
|
||||
const [focusedMeta, setFocusedMeta] = useState<string>(
|
||||
carouselEntries[0]?.meta ?? 'hygiene',
|
||||
);
|
||||
|
||||
const handleFocusChange = useCallback((entry: CarouselEntry) => {
|
||||
setFocusedMeta(entry.meta ?? 'hygiene');
|
||||
}, []);
|
||||
|
||||
const isHygieneFocused = focusedMeta === 'hygiene';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-between gap-1 sm:gap-3">
|
||||
{/* Left slot — always same width (button or spacer) */}
|
||||
{isHygieneFocused ? (
|
||||
towelItem ? (
|
||||
<RoomActionButton
|
||||
icon={<span className="text-2xl sm:text-3xl">{towelItem.icon}</span>}
|
||||
label="Towel"
|
||||
color="text-cyan-500"
|
||||
glowHex="#06b6d4"
|
||||
onClick={() => handleUseItemFromTab(towelItem.id)}
|
||||
disabled={isDisabled}
|
||||
loading={isUsingItem && usingItemId === towelItem.id}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 sm:w-20 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<RoomActionButton
|
||||
icon={<Candy className="size-7 sm:size-9" />}
|
||||
label="Treat"
|
||||
color="text-pink-400"
|
||||
glowHex="#f472b6"
|
||||
onClick={() => {
|
||||
// Comfort treat — use a small food item as a reward after medicine
|
||||
const treat = getLiveShopItems().find(i => i.type === 'food');
|
||||
if (treat) handleUseItemFromTab(treat.id);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Center carousel */}
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<ItemCarousel
|
||||
items={carouselEntries}
|
||||
onUse={handleUseItemFromTab}
|
||||
activeItemId={isUsingItem ? usingItemId : null}
|
||||
disabled={isDisabled}
|
||||
onFocusChange={handleFocusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right slot — always same width (button or spacer) */}
|
||||
{isHygieneFocused ? (
|
||||
<RoomActionButton
|
||||
icon={<ShowerHead className="size-7 sm:size-9" />}
|
||||
label="Shower"
|
||||
color="text-blue-500"
|
||||
glowHex="#3b82f6"
|
||||
onClick={() => {
|
||||
const shampoo = hygieneItems.find(i => i.id === 'hyg_shampoo');
|
||||
if (shampoo) handleUseItemFromTab(shampoo.id);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 sm:w-20 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// src/blobbi/rooms/components/BlobbiClosetRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiClosetRoom — Placeholder room for wardrobe / accessories.
|
||||
*
|
||||
* Uses the same bottom bar structure as other rooms for visual consistency,
|
||||
* with a centered placeholder message.
|
||||
*/
|
||||
|
||||
import { Shirt } from 'lucide-react';
|
||||
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
|
||||
interface BlobbiClosetRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiClosetRoom({ ctx }: BlobbiClosetRoomProps) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{/* Bottom bar — same structure as other rooms */}
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-center gap-2 py-1">
|
||||
<Shirt className="size-5 text-muted-foreground/30" />
|
||||
<p className="text-xs text-muted-foreground/40 font-medium">
|
||||
Closet coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
// src/blobbi/rooms/components/BlobbiHatcheryRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiHatcheryRoom — Incubation / evolution / progression room.
|
||||
*
|
||||
* Layout:
|
||||
* - BlobbiRoomHero (Blobbi visual + stats)
|
||||
* - Bottom center: main start/stop hatching or evolution button
|
||||
* - Bottom right: quests/tasks button
|
||||
* - Bottom left: Blobbis list/selector button
|
||||
*
|
||||
* Reuses existing hatch/evolve/missions logic from BlobbiPage.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Loader2, Sparkles, Egg, Target, Check, ListTodo,
|
||||
Wrench, Droplets, Heart, Zap, Moon, Camera, Music, Mic,
|
||||
Pill, Utensils, Plus, Footprints, ExternalLink, Theater,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { isLocalhostDev } from '@/blobbi/dev';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
|
||||
// ─── Helper: companionNeedsCare (reused from BlobbiPage) ──────────────────────
|
||||
|
||||
const CARE_THRESHOLD = 40;
|
||||
|
||||
function companionNeedsCare(companion: { stats: { hunger?: number; happiness?: number; hygiene?: number; health?: number } }): boolean {
|
||||
const { stats } = companion;
|
||||
return (
|
||||
(stats.hunger !== undefined && stats.hunger < CARE_THRESHOLD) ||
|
||||
(stats.happiness !== undefined && stats.happiness < CARE_THRESHOLD) ||
|
||||
(stats.hygiene !== undefined && stats.hygiene < CARE_THRESHOLD) ||
|
||||
(stats.health !== undefined && stats.health < CARE_THRESHOLD)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiHatcheryRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiHatcheryRoom({ ctx }: BlobbiHatcheryRoomProps) {
|
||||
const {
|
||||
companion,
|
||||
companions,
|
||||
selectedD,
|
||||
profile,
|
||||
isEgg,
|
||||
isBaby,
|
||||
isIncubating,
|
||||
isEvolvingState,
|
||||
canStartIncubation,
|
||||
canStartEvolution,
|
||||
isStartingIncubation,
|
||||
isStartingEvolution,
|
||||
isStoppingIncubation,
|
||||
isStoppingEvolution,
|
||||
isHatching,
|
||||
isEvolving,
|
||||
hatchTasks,
|
||||
evolveTasks,
|
||||
onStartIncubation,
|
||||
onStartEvolution,
|
||||
onStopIncubation,
|
||||
onStopEvolution,
|
||||
onEvolve,
|
||||
setShowPostModal,
|
||||
setShowHatchCeremony,
|
||||
isActiveFloatingCompanion,
|
||||
// Blobbi selector
|
||||
onSelectBlobbi,
|
||||
blobbiNaddr,
|
||||
// Adoption
|
||||
setShowAdoptionFlow,
|
||||
// Daily missions
|
||||
dailyMissions,
|
||||
onClaimReward,
|
||||
isClaimingReward,
|
||||
// DEV
|
||||
setShowDevEditor,
|
||||
setShowEmotionPanel,
|
||||
} = ctx;
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Side panels
|
||||
const [showQuestsPanel, setShowQuestsPanel] = useState(false);
|
||||
const [showBlobbisPanel, setShowBlobbisPanel] = useState(false);
|
||||
|
||||
const hasActiveProcess = (isIncubating && isEgg) || (isEvolvingState && isBaby);
|
||||
const isProcessBusy = isHatching || isEvolving || isStoppingIncubation || isStoppingEvolution;
|
||||
|
||||
const tasks = isIncubating ? hatchTasks.tasks : evolveTasks.tasks;
|
||||
const allCompleted = isIncubating ? hatchTasks.allCompleted : evolveTasks.allCompleted;
|
||||
const isTasksLoading = isIncubating ? hatchTasks.isLoading : evolveTasks.isLoading;
|
||||
|
||||
const completedCount = tasks.filter(t => t.completed).length;
|
||||
const totalCount = tasks.length;
|
||||
|
||||
const { missions } = dailyMissions;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* ── Hero ── */}
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{/* ── Bottom Action Bar ── */}
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-between gap-1 sm:gap-3">
|
||||
{/* Left — Blobbis selector */}
|
||||
<RoomActionButton
|
||||
icon={<Egg className="size-7 sm:size-9" />}
|
||||
label="Blobbis"
|
||||
color="text-primary"
|
||||
glowHex="var(--primary)"
|
||||
onClick={() => setShowBlobbisPanel(true)}
|
||||
badge={companions.length > 1 ? (
|
||||
<span className="size-4 sm:size-5 rounded-full bg-primary text-[9px] sm:text-[10px] text-primary-foreground font-bold flex items-center justify-center">
|
||||
{companions.length}
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
{/* Center — Main hatch/evolve action */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-1.5">
|
||||
{/* Active process: Hatch/Evolve CTA or progress */}
|
||||
{hasActiveProcess && allCompleted && !isTasksLoading && (
|
||||
<button
|
||||
onClick={isIncubating ? () => setShowHatchCeremony(true) : onEvolve}
|
||||
disabled={isProcessBusy}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
|
||||
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
isProcessBusy && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
style={{
|
||||
background: isIncubating
|
||||
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
|
||||
}}
|
||||
>
|
||||
{(isHatching || isEvolving) ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<span className="text-lg">{isIncubating ? '\uD83D\uDC23' : '\u2728'}</span>
|
||||
)}
|
||||
<span>{(isHatching || isEvolving) ? (isIncubating ? 'Hatching...' : 'Evolving...') : (isIncubating ? 'Hatch!' : 'Evolve!')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasActiveProcess && !allCompleted && !isTasksLoading && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
<span className="font-medium">{isIncubating ? 'Hatching' : 'Evolving'}</span>
|
||||
<span className="text-xs tabular-nums">{completedCount}/{totalCount}</span>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-40 h-1.5 rounded-full bg-muted/30 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%`,
|
||||
background: isIncubating
|
||||
? 'linear-gradient(90deg, #0ea5e9, #8b5cf6)'
|
||||
: 'linear-gradient(90deg, #8b5cf6, #ec4899)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveProcess && isTasksLoading && (
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* No active process — show start button */}
|
||||
{!hasActiveProcess && (canStartIncubation || canStartEvolution) && (
|
||||
<button
|
||||
onClick={() => canStartIncubation ? onStartIncubation('start') : onStartEvolution()}
|
||||
disabled={isStartingIncubation || isStartingEvolution}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-8 py-3 rounded-full text-white font-semibold transition-all duration-300',
|
||||
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
(isStartingIncubation || isStartingEvolution) && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
style={{
|
||||
background: canStartIncubation
|
||||
? 'linear-gradient(135deg, #0ea5e9, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
|
||||
}}
|
||||
>
|
||||
{(isStartingIncubation || isStartingEvolution) ? (
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-5" />
|
||||
)}
|
||||
<span>{canStartIncubation ? 'Begin Hatching' : 'Begin Evolution'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasActiveProcess && !canStartIncubation && !canStartEvolution && (
|
||||
<p className="text-xs text-muted-foreground/50">No journey available</p>
|
||||
)}
|
||||
|
||||
{/* Stop process link */}
|
||||
{hasActiveProcess && !isTasksLoading && (
|
||||
<button
|
||||
onClick={isIncubating ? onStopIncubation : onStopEvolution}
|
||||
disabled={isProcessBusy}
|
||||
className="text-[11px] text-muted-foreground/40 hover:text-destructive/60 transition-colors"
|
||||
>
|
||||
{(isStoppingIncubation || isStoppingEvolution) ? 'Stopping...' : `Stop ${isIncubating ? 'incubation' : 'evolution'}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right — Quests/Tasks */}
|
||||
<RoomActionButton
|
||||
icon={<ListTodo className="size-7 sm:size-9" />}
|
||||
label="Quests"
|
||||
color="text-amber-500"
|
||||
glowHex="#f59e0b"
|
||||
onClick={() => setShowQuestsPanel(true)}
|
||||
badge={hasActiveProcess && totalCount - completedCount > 0 ? (
|
||||
<span className="size-4 sm:size-5 rounded-full bg-amber-500 text-[9px] sm:text-[10px] text-white font-bold flex items-center justify-center">
|
||||
{totalCount - completedCount}
|
||||
</span>
|
||||
) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Quests Sheet ── */}
|
||||
<Sheet open={showQuestsPanel} onOpenChange={setShowQuestsPanel}>
|
||||
<SheetContent side="right" className="w-80 sm:w-96 p-0">
|
||||
<SheetHeader className="px-4 pt-4 pb-3 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 text-base">
|
||||
<Target className="size-4" />
|
||||
Quests
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-4rem)]">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Journey tasks */}
|
||||
{hasActiveProcess && (
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{isIncubating ? 'Hatching Journey' : 'Evolution Journey'}
|
||||
</h3>
|
||||
{isTasksLoading && (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!isTasksLoading && tasks.map(task => {
|
||||
const handleAction = () => {
|
||||
if (!task.action || !task.actionTarget) return;
|
||||
switch (task.action) {
|
||||
case 'navigate': navigate(task.actionTarget); setShowQuestsPanel(false); break;
|
||||
case 'external_link': openUrl(task.actionTarget); break;
|
||||
case 'open_modal': if (task.actionTarget === 'blobbi_post') { setShowPostModal(true); setShowQuestsPanel(false); } break;
|
||||
}
|
||||
};
|
||||
const isActionable = !task.completed && !!task.action && !!task.actionTarget;
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
onClick={isActionable ? handleAction : undefined}
|
||||
disabled={!isActionable}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all text-left',
|
||||
isActionable && 'hover:bg-accent/50 active:scale-[0.98] cursor-pointer',
|
||||
!isActionable && 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<QuestTaskIcon taskId={task.id} completed={task.completed} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-sm font-medium leading-tight', task.completed && 'text-muted-foreground line-through')}>{task.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5 line-clamp-1">{task.description}</p>
|
||||
</div>
|
||||
{task.required > 1 && !task.completed && (
|
||||
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{task.current}/{task.required}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasActiveProcess && (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-center">
|
||||
<Sparkles className="size-6 text-muted-foreground/30" />
|
||||
<p className="text-xs text-muted-foreground">Start a journey to unlock tasks</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily Bounties */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Daily Bounties
|
||||
</h3>
|
||||
{dailyMissions.noMissionsAvailable && (
|
||||
<div className="flex flex-col items-center gap-2 py-4 text-center">
|
||||
<Egg className="size-5 text-muted-foreground/30" />
|
||||
<p className="text-xs text-muted-foreground">Hatch your Blobbi to unlock bounties</p>
|
||||
</div>
|
||||
)}
|
||||
{!dailyMissions.noMissionsAvailable && missions.map(mission => {
|
||||
const canClaim = mission.completed && !mission.claimed;
|
||||
return (
|
||||
<div
|
||||
key={mission.id}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl transition-all',
|
||||
canClaim && 'bg-amber-500/[0.06]',
|
||||
)}
|
||||
>
|
||||
<DailyMissionIcon action={mission.action} claimed={mission.claimed} canClaim={canClaim} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn('text-sm font-medium leading-tight', mission.claimed && 'text-muted-foreground line-through')}>{mission.title}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{mission.description}</p>
|
||||
</div>
|
||||
{!mission.claimed && (
|
||||
<span className="text-[10px] tabular-nums font-medium text-muted-foreground shrink-0">{mission.currentCount}/{mission.requiredCount}</span>
|
||||
)}
|
||||
{canClaim && (
|
||||
<button
|
||||
onClick={() => onClaimReward(mission.id)}
|
||||
disabled={isClaimingReward}
|
||||
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
|
||||
>
|
||||
Claim
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Bonus row */}
|
||||
{!dailyMissions.noMissionsAvailable && dailyMissions.bonusAvailable && !dailyMissions.bonusClaimed && (
|
||||
<div className="w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl bg-amber-500/[0.06]">
|
||||
<div className="size-8 rounded-full bg-amber-500/15 flex items-center justify-center shrink-0">
|
||||
<Sparkles className="size-4 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight">Daily Champion</p>
|
||||
<p className="text-[10px] text-muted-foreground">All missions complete!</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onClaimReward('bonus_daily_complete')}
|
||||
disabled={isClaimingReward}
|
||||
className="shrink-0 text-xs font-semibold text-amber-600 dark:text-amber-400 hover:underline"
|
||||
>
|
||||
Claim
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ── Blobbis Sheet ── */}
|
||||
<Sheet open={showBlobbisPanel} onOpenChange={setShowBlobbisPanel}>
|
||||
<SheetContent side="left" className="w-80 sm:w-96 p-0">
|
||||
<SheetHeader className="px-4 pt-4 pb-3 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 text-base">
|
||||
<Egg className="size-4" />
|
||||
Your Blobbis
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-4rem)]">
|
||||
<div className="p-4">
|
||||
{/* Blobbi grid */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 py-3">
|
||||
{companions.map((c) => {
|
||||
const isSelected = c.d === selectedD;
|
||||
const isCompanion = c.d === profile?.currentCompanion;
|
||||
return (
|
||||
<button
|
||||
key={c.d}
|
||||
onClick={() => { onSelectBlobbi(c.d); setShowBlobbisPanel(false); }}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 transition-all duration-200',
|
||||
'hover:-translate-y-1 hover:scale-105 active:scale-95',
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className={cn(
|
||||
'rounded-full p-1 transition-all',
|
||||
isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
|
||||
)}>
|
||||
<BlobbiStageVisual companion={c} size="sm" />
|
||||
</div>
|
||||
{isCompanion && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5 size-5 rounded-full bg-background ring-2 ring-background flex items-center justify-center">
|
||||
<Footprints className="size-3 text-emerald-500" />
|
||||
</div>
|
||||
)}
|
||||
{companionNeedsCare(c) && !isCompanion && (
|
||||
<div className="absolute -top-0.5 -right-0.5 size-4 rounded-full bg-amber-500 flex items-center justify-center">
|
||||
<span className="text-[8px] text-white font-bold">!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{c.stage !== 'egg' && (
|
||||
<span className={cn(
|
||||
'text-[11px] font-medium max-w-[4.5rem] truncate',
|
||||
isSelected ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}>
|
||||
{c.name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Adopt + button */}
|
||||
<button
|
||||
onClick={() => { setShowBlobbisPanel(false); setShowAdoptionFlow(true); }}
|
||||
className="flex flex-col items-center gap-1 transition-all duration-200 hover:-translate-y-1 hover:scale-105 active:scale-95"
|
||||
>
|
||||
<div className="size-14 rounded-full flex items-center justify-center" style={{
|
||||
background: 'radial-gradient(circle at 40% 35%, color-mix(in srgb, currentColor 10%, transparent), color-mix(in srgb, currentColor 3%, transparent) 70%)',
|
||||
}}>
|
||||
<Plus className="size-6 text-muted-foreground/60" />
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-muted-foreground/60">Adopt</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick actions row */}
|
||||
<div className="flex items-center justify-center gap-6 pt-3 border-t mt-3">
|
||||
<Link
|
||||
to={`/${blobbiNaddr}`}
|
||||
onClick={() => setShowBlobbisPanel(false)}
|
||||
className="flex flex-col items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-5" />
|
||||
<span className="text-[10px]">View</span>
|
||||
</Link>
|
||||
{/* DEV tools */}
|
||||
{isLocalhostDev() && (
|
||||
<>
|
||||
{companion.stage !== 'adult' && (
|
||||
<button
|
||||
onClick={() => { setShowBlobbisPanel(false); if (isEgg) { setShowHatchCeremony(true); } else { onEvolve(); } }}
|
||||
disabled={isHatching || isEvolving}
|
||||
className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Sparkles className="size-5" />
|
||||
<span className="text-[10px]">{companion.stage === 'egg' ? 'Hatch' : 'Evolve'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => { setShowBlobbisPanel(false); setShowDevEditor(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
|
||||
<Wrench className="size-5" />
|
||||
<span className="text-[10px]">Editor</span>
|
||||
</button>
|
||||
<button onClick={() => { setShowBlobbisPanel(false); setShowEmotionPanel(true); }} className="flex flex-col items-center gap-1 text-amber-500 hover:text-amber-400 transition-colors">
|
||||
<Theater className="size-5" />
|
||||
<span className="text-[10px]">Emote</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Quest task icon (reused from BlobbiPage) ─────────────────────────────────
|
||||
|
||||
function QuestTaskIcon({ taskId, completed }: { taskId: string; completed: boolean }) {
|
||||
const iconClass = 'size-4';
|
||||
const icon = (() => {
|
||||
switch (taskId) {
|
||||
case 'create_themes': return <Sparkles className={iconClass} />;
|
||||
case 'color_moments': return <Droplets className={iconClass} />;
|
||||
case 'create_posts': return <Target className={iconClass} />;
|
||||
case 'interactions': return <Heart className={iconClass} />;
|
||||
case 'edit_profile': return <Wrench className={iconClass} />;
|
||||
case 'maintain_stats': return <Zap className={iconClass} />;
|
||||
default: return <Target className={iconClass} />;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className={cn(
|
||||
'size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
completed ? 'bg-emerald-500/15 text-emerald-500' : 'bg-muted/60 text-muted-foreground',
|
||||
)}>
|
||||
{completed ? <Check className="size-4" /> : icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Daily mission icon (reused from BlobbiPage) ──────────────────────────────
|
||||
|
||||
function DailyMissionIcon({ action, claimed, canClaim }: { action: string; claimed: boolean; canClaim: boolean }) {
|
||||
const iconClass = 'size-4';
|
||||
const icon = (() => {
|
||||
switch (action) {
|
||||
case 'interact': return <Heart className={iconClass} />;
|
||||
case 'feed': return <Utensils className={iconClass} />;
|
||||
case 'clean': return <Droplets className={iconClass} />;
|
||||
case 'sleep': return <Moon className={iconClass} />;
|
||||
case 'take_photo': return <Camera className={iconClass} />;
|
||||
case 'sing': return <Mic className={iconClass} />;
|
||||
case 'play_music': return <Music className={iconClass} />;
|
||||
case 'medicine': return <Pill className={iconClass} />;
|
||||
default: return <Target className={iconClass} />;
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className={cn(
|
||||
'size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
claimed ? 'bg-emerald-500/15 text-emerald-500' : canClaim ? 'bg-amber-500/15 text-amber-500' : 'bg-muted/60 text-muted-foreground',
|
||||
)}>
|
||||
{claimed ? <Check className="size-4" /> : icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// src/blobbi/rooms/components/BlobbiHomeRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiHomeRoom — The main living / play room.
|
||||
*
|
||||
* Layout:
|
||||
* - BlobbiRoomHero (stats crown, Blobbi visual, name)
|
||||
* - Unified bottom bar: Photo (left) | Carousel (center) | Companion (right)
|
||||
* - Inline activity (music player, sing card) above the bottom bar
|
||||
*
|
||||
* Sleep/wake has been moved to BlobbiRestRoom.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Camera, Footprints, Music, Mic } from 'lucide-react';
|
||||
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { InlineMusicPlayer, InlineSingCard } from '@/blobbi/actions';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
|
||||
|
||||
interface BlobbiHomeRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiHomeRoom({ ctx }: BlobbiHomeRoomProps) {
|
||||
const {
|
||||
isActiveFloatingCompanion,
|
||||
setShowPhotoModal,
|
||||
isCurrentCompanion,
|
||||
canBeCompanion,
|
||||
isUpdatingCompanion,
|
||||
handleSetAsCompanion,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
handleUseItemFromTab,
|
||||
handleDirectAction,
|
||||
isDirectActionPending,
|
||||
inlineActivity,
|
||||
handleConfirmSing,
|
||||
handleCloseInlineActivity,
|
||||
handleMusicPlaybackStart,
|
||||
handleMusicPlaybackStop,
|
||||
handleSingRecordingStart,
|
||||
handleSingRecordingStop,
|
||||
handleChangeTrack,
|
||||
isPublishing,
|
||||
actionInProgress,
|
||||
} = ctx;
|
||||
|
||||
// Build carousel entries: toys + music + sing
|
||||
const carouselItems = useMemo<CarouselEntry[]>(() => {
|
||||
const toys = getLiveShopItems()
|
||||
.filter(i => i.type === 'toy')
|
||||
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name }));
|
||||
|
||||
const actions: CarouselEntry[] = [
|
||||
{
|
||||
id: '__action_music',
|
||||
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-pink-500/15 text-pink-500"><Music className="size-5 sm:size-6" /></div>,
|
||||
label: 'Music',
|
||||
},
|
||||
{
|
||||
id: '__action_sing',
|
||||
icon: <div className="size-10 sm:size-12 rounded-full flex items-center justify-center bg-purple-500/15 text-purple-500"><Mic className="size-5 sm:size-6" /></div>,
|
||||
label: 'Sing',
|
||||
},
|
||||
];
|
||||
|
||||
return [...toys, ...actions];
|
||||
}, []);
|
||||
|
||||
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
|
||||
|
||||
const handleCarouselUse = (id: string) => {
|
||||
if (id === '__action_music') {
|
||||
handleDirectAction('play_music');
|
||||
} else if (id === '__action_sing') {
|
||||
handleDirectAction('sing');
|
||||
} else {
|
||||
handleUseItemFromTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* ── Hero (Blobbi + stats) ── */}
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{/* ── Inline Activity Area (music/sing) ── */}
|
||||
{inlineActivity.type === 'music' && (
|
||||
<div className="px-4 sm:px-6 pb-2">
|
||||
<InlineMusicPlayer
|
||||
selection={inlineActivity.selection}
|
||||
onChangeTrack={handleChangeTrack}
|
||||
onClose={handleCloseInlineActivity}
|
||||
onPlaybackStart={handleMusicPlaybackStart}
|
||||
onPlaybackStop={handleMusicPlaybackStop}
|
||||
isPublished={inlineActivity.isPublished}
|
||||
isPublishing={isDirectActionPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{inlineActivity.type === 'sing' && (
|
||||
<div className="px-4 sm:px-6 pb-2">
|
||||
<InlineSingCard
|
||||
onConfirm={handleConfirmSing}
|
||||
onClose={handleCloseInlineActivity}
|
||||
onRecordingStart={handleSingRecordingStart}
|
||||
onRecordingStop={handleSingRecordingStop}
|
||||
isPublishing={isDirectActionPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Unified Bottom Bar: Photo | Carousel | Companion ── */}
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-between gap-1 sm:gap-3">
|
||||
{/* Photo */}
|
||||
<RoomActionButton
|
||||
icon={<Camera className="size-7 sm:size-9" />}
|
||||
label="Photo"
|
||||
color="text-pink-500"
|
||||
glowHex="#ec4899"
|
||||
onClick={() => setShowPhotoModal(true)}
|
||||
/>
|
||||
|
||||
{/* Center carousel */}
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<ItemCarousel
|
||||
items={carouselItems}
|
||||
onUse={handleCarouselUse}
|
||||
activeItemId={isUsingItem ? usingItemId : null}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Companion toggle */}
|
||||
{canBeCompanion ? (
|
||||
<RoomActionButton
|
||||
icon={<Footprints className="size-7 sm:size-9" />}
|
||||
label={isCurrentCompanion ? 'With you' : 'Take along'}
|
||||
color={isCurrentCompanion ? 'text-emerald-500' : 'text-violet-500'}
|
||||
glowHex={isCurrentCompanion ? '#10b981' : '#8b5cf6'}
|
||||
onClick={handleSetAsCompanion}
|
||||
disabled={isUpdatingCompanion}
|
||||
loading={isUpdatingCompanion}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 sm:w-20 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// src/blobbi/rooms/components/BlobbiKitchenRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiKitchenRoom — The feeding room.
|
||||
*
|
||||
* Bottom bar: Shovel (left, when poop exists) | food carousel (center) | Fridge (right)
|
||||
* Poop appears at pre-computed safe positions in the lower corners.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Refrigerator, Shovel } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getLiveShopItems } from '@/blobbi/shop/lib/blobbi-shop-items';
|
||||
import { BlobbiActionInventoryModal } from '@/blobbi/actions/components/BlobbiActionInventoryModal';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { getPoopsInRoom, hasAnyPoop } from '../lib/poop-system';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
import { ItemCarousel, type CarouselEntry } from './ItemCarousel';
|
||||
|
||||
interface BlobbiKitchenRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiKitchenRoom({ ctx, poopState }: BlobbiKitchenRoomProps) {
|
||||
const {
|
||||
companion,
|
||||
profile,
|
||||
isUsingItem,
|
||||
usingItemId,
|
||||
handleUseItemFromTab,
|
||||
isPublishing,
|
||||
actionInProgress,
|
||||
isActiveFloatingCompanion,
|
||||
} = ctx;
|
||||
|
||||
const [showFridge, setShowFridge] = useState(false);
|
||||
|
||||
const foodEntries = useMemo<CarouselEntry[]>(() =>
|
||||
getLiveShopItems()
|
||||
.filter(i => i.type === 'food')
|
||||
.map(i => ({ id: i.id, icon: <span>{i.icon}</span>, label: i.name })),
|
||||
[]);
|
||||
|
||||
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
|
||||
|
||||
const handleFridgeUseItem = (itemId: string) => {
|
||||
if (isUsingItem) return;
|
||||
ctx.onUseItem(itemId, 'feed').finally(() => {
|
||||
setShowFridge(false);
|
||||
});
|
||||
};
|
||||
|
||||
const kitchenPoops = getPoopsInRoom(poopState.poops, 'kitchen');
|
||||
const anyPoopAnywhere = hasAnyPoop(poopState.poops);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* ── Hero + Poop layer ── */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{/* Poop at pre-computed safe positions */}
|
||||
{kitchenPoops.map((poop) => (
|
||||
<button
|
||||
key={poop.id}
|
||||
onClick={() => poopState.shovelMode && poopState.onRemovePoop(poop.id)}
|
||||
className={cn(
|
||||
'absolute z-10 transition-all duration-300',
|
||||
poopState.shovelMode
|
||||
? 'cursor-pointer hover:scale-150 active:scale-75'
|
||||
: 'pointer-events-none',
|
||||
)}
|
||||
style={{
|
||||
bottom: `${poop.position.bottom}%`,
|
||||
left: `${poop.position.left}%`,
|
||||
}}
|
||||
>
|
||||
<span className={cn(
|
||||
'text-2xl sm:text-3xl block',
|
||||
poopState.shovelMode && 'drop-shadow-lg',
|
||||
)}>
|
||||
{'💩'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Bottom bar ── */}
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-between gap-1 sm:gap-3">
|
||||
{/* Left — Shovel (when poop exists) or spacer */}
|
||||
{anyPoopAnywhere ? (
|
||||
<RoomActionButton
|
||||
icon={<Shovel className="size-7 sm:size-9" />}
|
||||
label={poopState.shovelMode ? 'Done' : 'Shovel'}
|
||||
color={poopState.shovelMode ? 'text-amber-600' : 'text-stone-500'}
|
||||
glowHex={poopState.shovelMode ? '#d97706' : '#78716c'}
|
||||
onClick={() => poopState.setShovelMode(prev => !prev)}
|
||||
className={poopState.shovelMode ? 'ring-2 ring-amber-500/40 ring-offset-2 ring-offset-background rounded-full' : ''}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 sm:w-20 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Center: food carousel */}
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<ItemCarousel
|
||||
items={foodEntries}
|
||||
onUse={handleUseItemFromTab}
|
||||
activeItemId={isUsingItem ? usingItemId : null}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right — Fridge */}
|
||||
<RoomActionButton
|
||||
icon={<Refrigerator className="size-7 sm:size-9" />}
|
||||
label="Fridge"
|
||||
color="text-orange-500"
|
||||
glowHex="#f97316"
|
||||
onClick={() => setShowFridge(true)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFridge && (
|
||||
<BlobbiActionInventoryModal
|
||||
open={showFridge}
|
||||
onOpenChange={setShowFridge}
|
||||
action="feed"
|
||||
companion={companion}
|
||||
profile={profile}
|
||||
onUseItem={handleFridgeUseItem}
|
||||
onOpenShop={() => setShowFridge(false)}
|
||||
isUsingItem={isUsingItem}
|
||||
usingItemId={usingItemId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// src/blobbi/rooms/components/BlobbiRestRoom.tsx
|
||||
|
||||
/**
|
||||
* BlobbiRestRoom — The bedroom / rest room.
|
||||
*
|
||||
* Bottom bar: Sleep/Wake button centered.
|
||||
*/
|
||||
|
||||
import { Moon, Sun, Loader2 } from 'lucide-react';
|
||||
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import { ROOM_BOTTOM_BAR_CLASS } from '../lib/room-layout';
|
||||
import { BlobbiRoomHero } from './BlobbiRoomHero';
|
||||
import { RoomActionButton } from './RoomActionButton';
|
||||
|
||||
interface BlobbiRestRoomProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
poopState: RoomPoopState;
|
||||
}
|
||||
|
||||
export function BlobbiRestRoom({ ctx }: BlobbiRestRoomProps) {
|
||||
const {
|
||||
isEgg,
|
||||
isSleeping,
|
||||
onRest,
|
||||
actionInProgress,
|
||||
isPublishing,
|
||||
isUsingItem,
|
||||
isActiveFloatingCompanion,
|
||||
} = ctx;
|
||||
|
||||
const isDisabled = isPublishing || actionInProgress !== null || isUsingItem;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<BlobbiRoomHero ctx={ctx} className="flex-1 min-h-0" />
|
||||
|
||||
{!isActiveFloatingCompanion && (
|
||||
<div className={ROOM_BOTTOM_BAR_CLASS}>
|
||||
<div className="flex items-center justify-center">
|
||||
{!isEgg && (
|
||||
<RoomActionButton
|
||||
icon={
|
||||
actionInProgress === 'rest'
|
||||
? <Loader2 className="size-7 sm:size-9 animate-spin" />
|
||||
: isSleeping
|
||||
? <Sun className="size-7 sm:size-9" />
|
||||
: <Moon className="size-7 sm:size-9" />
|
||||
}
|
||||
label={isSleeping ? 'Wake up' : 'Sleep'}
|
||||
color={isSleeping ? 'text-amber-500' : 'text-violet-500'}
|
||||
glowHex={isSleeping ? '#f59e0b' : '#8b5cf6'}
|
||||
onClick={onRest}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// src/blobbi/rooms/components/BlobbiRoomHero.tsx
|
||||
|
||||
/**
|
||||
* BlobbiRoomHero — Shared Blobbi visual display used in every room.
|
||||
*
|
||||
* This component does NOT clip or constrain the visual — it simply fills
|
||||
* available flex space and centers the Blobbi + stats within it.
|
||||
* The room owns the full-height surface; this just provides content.
|
||||
*
|
||||
* Top padding accounts for the floating room header overlay (~40px).
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Utensils, Gamepad2, Heart, Droplets, Zap, AlertTriangle,
|
||||
Footprints, Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { BlobbiStageVisual } from '@/blobbi/ui/BlobbiStageVisual';
|
||||
import { getVisibleStats, getStatStatus } from '@/blobbi/core/lib/blobbi-decay';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { BlobbiRoomContext } from '../lib/room-types';
|
||||
|
||||
// ─── Stat colour maps ─────────────────────────────────────────────────────────
|
||||
|
||||
const STAT_COLOR_MAP: Record<string, 'orange' | 'yellow' | 'green' | 'blue' | 'violet'> = {
|
||||
hunger: 'orange',
|
||||
happiness: 'yellow',
|
||||
health: 'green',
|
||||
hygiene: 'blue',
|
||||
energy: 'violet',
|
||||
};
|
||||
|
||||
const STAT_COLORS: Record<string, string> = {
|
||||
orange: 'text-orange-500',
|
||||
yellow: 'text-yellow-500',
|
||||
green: 'text-green-500',
|
||||
blue: 'text-blue-500',
|
||||
violet: 'text-violet-500',
|
||||
};
|
||||
|
||||
const STAT_BG_COLORS: Record<string, string> = {
|
||||
orange: 'bg-orange-500/10',
|
||||
yellow: 'bg-yellow-500/10',
|
||||
green: 'bg-green-500/10',
|
||||
blue: 'bg-blue-500/10',
|
||||
violet: 'bg-violet-500/10',
|
||||
};
|
||||
|
||||
const STAT_RING_HEX: Record<string, string> = {
|
||||
orange: '#f97316',
|
||||
yellow: '#eab308',
|
||||
green: '#22c55e',
|
||||
blue: '#3b82f6',
|
||||
violet: '#8b5cf6',
|
||||
};
|
||||
|
||||
const STAT_ICON_MAP: Record<string, React.ComponentType<{ className?: string; strokeWidth?: number }>> = {
|
||||
hunger: Utensils,
|
||||
happiness: Gamepad2,
|
||||
health: Heart,
|
||||
hygiene: Droplets,
|
||||
energy: Zap,
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiRoomHeroProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
className?: string;
|
||||
hideStats?: boolean;
|
||||
hideName?: boolean;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiRoomHero({ ctx, className, hideStats, hideName }: BlobbiRoomHeroProps) {
|
||||
const {
|
||||
companion,
|
||||
currentStats,
|
||||
isSleeping,
|
||||
isEgg,
|
||||
statusRecipe,
|
||||
statusRecipeLabel,
|
||||
effectiveEmotion,
|
||||
hasDevOverride,
|
||||
blobbiReaction,
|
||||
isActiveFloatingCompanion,
|
||||
isUpdatingCompanion,
|
||||
handleSetAsCompanion,
|
||||
heroRef,
|
||||
heroWidth,
|
||||
} = ctx;
|
||||
|
||||
if (isActiveFloatingCompanion) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-4 text-center flex-1 px-4', className)}>
|
||||
<Footprints className="size-12 text-muted-foreground/30" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{companion.name} is out exploring right now.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSetAsCompanion}
|
||||
disabled={isUpdatingCompanion}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-6 py-3 rounded-full text-white font-semibold transition-all duration-300 ease-out text-sm',
|
||||
'hover:-translate-y-0.5 hover:scale-105 hover:brightness-110 active:scale-95',
|
||||
isUpdatingCompanion && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
style={{ background: 'linear-gradient(135deg, #8b5cf6, #ec4899, #f59e0b)' }}
|
||||
>
|
||||
{isUpdatingCompanion ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Footprints className="size-4" />
|
||||
)}
|
||||
<span>Bring {companion.name} home</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={heroRef}
|
||||
className={cn(
|
||||
// No overflow-hidden — let the room own the visual surface.
|
||||
// pt-10 creates clearance for the floating room header overlay.
|
||||
'relative flex flex-col items-center justify-center pt-10 px-4 sm:px-6 flex-1 min-h-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">
|
||||
{/* Stats crown */}
|
||||
{!hideStats && <StatsCrown companion={companion} currentStats={currentStats} heroWidth={heroWidth} />}
|
||||
|
||||
{/* Blobbi visual */}
|
||||
<div
|
||||
className="relative transition-all duration-500"
|
||||
style={!isSleeping ? {
|
||||
animation: `blobbi-bob ${4 - (currentStats.happiness / 100) * 1.5}s ease-in-out infinite, blobbi-sway ${6 - (currentStats.happiness / 100) * 2}s ease-in-out infinite`,
|
||||
} : undefined}
|
||||
>
|
||||
<div className="absolute inset-0 -m-16 sm:-m-20 bg-primary/5 rounded-full blur-3xl pointer-events-none" />
|
||||
<BlobbiStageVisual
|
||||
companion={companion}
|
||||
size="lg"
|
||||
animated={!isSleeping}
|
||||
reaction={blobbiReaction}
|
||||
recipe={hasDevOverride ? undefined : statusRecipe}
|
||||
recipeLabel={hasDevOverride ? undefined : statusRecipeLabel}
|
||||
emotion={effectiveEmotion}
|
||||
className={isEgg
|
||||
? 'size-36 min-[400px]:size-44 sm:size-56 md:size-64 lg:size-72'
|
||||
: 'size-48 min-[400px]:size-60 sm:size-72 md:size-80 lg:size-96'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blobbi Name */}
|
||||
{!hideName && !isEgg && (
|
||||
<h2
|
||||
className="text-xl sm:text-2xl md:text-3xl font-bold text-center mt-1"
|
||||
style={{ color: companion.visualTraits.baseColor }}
|
||||
>
|
||||
{companion.name}
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stats Crown ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatsCrown({
|
||||
companion,
|
||||
currentStats,
|
||||
heroWidth,
|
||||
}: {
|
||||
companion: BlobbiRoomContext['companion'];
|
||||
currentStats: BlobbiRoomContext['currentStats'];
|
||||
heroWidth: number;
|
||||
}) {
|
||||
const allStats = useMemo(() =>
|
||||
getVisibleStats(companion.stage).map(stat => ({
|
||||
stat,
|
||||
value: currentStats[stat] ?? 100,
|
||||
status: getStatStatus(companion.stage, stat, currentStats[stat] ?? 100),
|
||||
color: STAT_COLOR_MAP[stat],
|
||||
})),
|
||||
[companion.stage, currentStats]);
|
||||
|
||||
if (allStats.length === 0) return null;
|
||||
|
||||
const count = allStats.length;
|
||||
const isSmall = heroWidth < 400;
|
||||
|
||||
// Balanced arc: mobile is compact, desktop has moderate breathing room.
|
||||
// These values produce a stable crown with no dramatic changes between
|
||||
// 375px (mobile) and 640px+ (desktop) — a smooth interpolation.
|
||||
const arcSpread = isSmall
|
||||
? (count <= 2 ? 80 : count <= 3 ? 110 : 140)
|
||||
: (count <= 2 ? 90 : count <= 3 ? 130 : 160);
|
||||
const arcHalf = arcSpread / 2;
|
||||
const angles = count === 1
|
||||
? [0]
|
||||
: allStats.map((_, i) => -arcHalf + (arcSpread / (count - 1)) * i);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-end justify-center w-full mb-4 sm:mb-8" style={{ height: 40 }}>
|
||||
{allStats.map((s, i) => {
|
||||
const angleDeg = angles[i];
|
||||
const angleRad = (angleDeg * Math.PI) / 180;
|
||||
// Smooth interpolation: 110px at 340px width → 200px at 640px+ width
|
||||
const radius = Math.min(200, Math.max(110, (heroWidth - 340) / (640 - 340) * (200 - 110) + 110));
|
||||
const x = Math.sin(angleRad) * radius;
|
||||
const y = Math.cos(angleRad) * radius - radius;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.stat}
|
||||
className="absolute transition-all duration-500"
|
||||
style={{
|
||||
transform: `translate(-50%, 0)`,
|
||||
left: `calc(50% + ${x.toFixed(1)}px)`,
|
||||
bottom: `${y.toFixed(1)}px`,
|
||||
}}
|
||||
>
|
||||
<StatIndicator
|
||||
stat={s.stat}
|
||||
value={s.value}
|
||||
color={s.color}
|
||||
status={s.status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat Indicator ───────────────────────────────────────────────────────────
|
||||
|
||||
interface StatIndicatorProps {
|
||||
stat: string;
|
||||
value: number | undefined;
|
||||
color: 'orange' | 'yellow' | 'green' | 'blue' | 'violet';
|
||||
status?: 'normal' | 'warning' | 'critical';
|
||||
}
|
||||
|
||||
function StatIndicator({ stat, value, color, status = 'normal' }: StatIndicatorProps) {
|
||||
const displayValue = value ?? 0;
|
||||
const isLow = status === 'warning' || status === 'critical';
|
||||
const ringHex = STAT_RING_HEX[color];
|
||||
const IconComponent = STAT_ICON_MAP[stat];
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'relative size-14 sm:size-[4.5rem] rounded-full flex items-center justify-center',
|
||||
STAT_BG_COLORS[color],
|
||||
status === 'critical' && 'animate-pulse',
|
||||
)}>
|
||||
<svg className="absolute inset-0 -rotate-90" viewBox="0 0 36 36">
|
||||
<circle cx="18" cy="18" r="15" fill="none" stroke="currentColor" strokeWidth="2.5" className="text-muted/15" />
|
||||
<circle
|
||||
cx="18" cy="18" r="15" fill="none" strokeWidth="2.5" strokeLinecap="round"
|
||||
stroke={ringHex}
|
||||
strokeDasharray={`${displayValue * 0.94} 100`}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="relative">
|
||||
{IconComponent && <IconComponent className={cn('size-5 sm:size-6', STAT_COLORS[color])} strokeWidth={2.5} />}
|
||||
{isLow && (
|
||||
<AlertTriangle
|
||||
className={cn('absolute -top-1.5 -right-2 size-3', status === 'critical' ? 'text-red-500' : 'text-amber-500')}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// src/blobbi/rooms/components/BlobbiRoomShell.tsx
|
||||
|
||||
/**
|
||||
* BlobbiRoomShell — The outer layout for the room-based Blobbi dashboard.
|
||||
*
|
||||
* Manages:
|
||||
* - Current room state + navigation
|
||||
* - Sleep dark overlay (scoped to this shell only)
|
||||
* - Ephemeral poop instances (local-only, no persistence)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import {
|
||||
type BlobbiRoomId,
|
||||
ROOM_META,
|
||||
DEFAULT_ROOM_ORDER,
|
||||
DEFAULT_INITIAL_ROOM,
|
||||
getNextRoom,
|
||||
getPreviousRoom,
|
||||
getRoomIndex,
|
||||
} from '../lib/room-config';
|
||||
import type { BlobbiRoomContext, RoomPoopState } from '../lib/room-types';
|
||||
import {
|
||||
generateInitialPoops,
|
||||
removePoop,
|
||||
type PoopInstance,
|
||||
} from '../lib/poop-system';
|
||||
|
||||
import { BlobbiHomeRoom } from './BlobbiHomeRoom';
|
||||
import { BlobbiKitchenRoom } from './BlobbiKitchenRoom';
|
||||
import { BlobbiCareRoom } from './BlobbiCareRoom';
|
||||
import { BlobbiHatcheryRoom } from './BlobbiHatcheryRoom';
|
||||
import { BlobbiRestRoom } from './BlobbiRestRoom';
|
||||
import { BlobbiClosetRoom } from './BlobbiClosetRoom';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlobbiRoomShellProps {
|
||||
ctx: BlobbiRoomContext;
|
||||
roomOrder?: BlobbiRoomId[];
|
||||
initialRoom?: BlobbiRoomId;
|
||||
}
|
||||
|
||||
interface RoomNavState {
|
||||
current: BlobbiRoomId;
|
||||
direction: 'left' | 'right' | null;
|
||||
}
|
||||
|
||||
// ─── Room Component Map ───────────────────────────────────────────────────────
|
||||
|
||||
const ROOM_COMPONENTS: Record<BlobbiRoomId, React.ComponentType<{ ctx: BlobbiRoomContext; poopState: RoomPoopState }>> = {
|
||||
care: BlobbiCareRoom,
|
||||
kitchen: BlobbiKitchenRoom,
|
||||
home: BlobbiHomeRoom,
|
||||
hatchery: BlobbiHatcheryRoom,
|
||||
rest: BlobbiRestRoom,
|
||||
closet: BlobbiClosetRoom,
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function BlobbiRoomShell({
|
||||
ctx,
|
||||
roomOrder = DEFAULT_ROOM_ORDER,
|
||||
initialRoom = DEFAULT_INITIAL_ROOM,
|
||||
}: BlobbiRoomShellProps) {
|
||||
const [nav, setNav] = useState<RoomNavState>({
|
||||
current: roomOrder.includes(initialRoom) ? initialRoom : roomOrder[0],
|
||||
direction: null,
|
||||
});
|
||||
|
||||
const goRight = useCallback(() => {
|
||||
setNav(prev => ({
|
||||
current: getNextRoom(prev.current, roomOrder),
|
||||
direction: 'right',
|
||||
}));
|
||||
}, [roomOrder]);
|
||||
|
||||
const goLeft = useCallback(() => {
|
||||
setNav(prev => ({
|
||||
current: getPreviousRoom(prev.current, roomOrder),
|
||||
direction: 'left',
|
||||
}));
|
||||
}, [roomOrder]);
|
||||
|
||||
const meta = ROOM_META[nav.current];
|
||||
const roomIndex = getRoomIndex(nav.current, roomOrder);
|
||||
const RoomComponent = ROOM_COMPONENTS[nav.current];
|
||||
|
||||
const dots = useMemo(() => roomOrder.map((id, i) => ({
|
||||
id,
|
||||
active: i === roomIndex,
|
||||
label: ROOM_META[id].label,
|
||||
})), [roomOrder, roomIndex]);
|
||||
|
||||
const isSleeping = ctx.isSleeping;
|
||||
|
||||
// ─── Poop system (ephemeral, local-only) ───
|
||||
const [poops, setPoops] = useState<PoopInstance[]>([]);
|
||||
const [shovelMode, setShovelMode] = useState(false);
|
||||
|
||||
// Generate poop on mount
|
||||
useEffect(() => {
|
||||
const hunger = ctx.currentStats.hunger;
|
||||
const lastFeed = ctx.lastFeedTimestamp ?? ctx.companion.lastInteraction * 1000;
|
||||
setPoops(generateInitialPoops(hunger, lastFeed));
|
||||
// Only run once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onRemovePoop = useCallback((poopId: string) => {
|
||||
setPoops(prev => {
|
||||
const { remaining, xpReward } = removePoop(prev, poopId);
|
||||
if (xpReward > 0) {
|
||||
toast({ title: `+${xpReward} XP`, description: 'Cleaned up!' });
|
||||
}
|
||||
if (remaining.length === 0) {
|
||||
setShovelMode(false);
|
||||
}
|
||||
return remaining;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const poopState: RoomPoopState = useMemo(() => ({
|
||||
poops,
|
||||
shovelMode,
|
||||
setShovelMode,
|
||||
onRemovePoop,
|
||||
}), [poops, shovelMode, onRemovePoop]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 relative">
|
||||
{/* ── Room Content — fills the entire shell ── */}
|
||||
<div className="flex-1 min-h-0 flex flex-col relative">
|
||||
<RoomComponent ctx={ctx} poopState={poopState} />
|
||||
</div>
|
||||
|
||||
{/* ── Sleep overlay — darkens the room when Blobbi sleeps ── */}
|
||||
{isSleeping && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 pointer-events-none transition-opacity duration-700"
|
||||
style={{ background: 'radial-gradient(ellipse at 50% 40%, rgba(0,0,0,0.25) 0%, rgba(0,0,0,0.45) 100%)' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Floating Room Header ── */}
|
||||
<div className="absolute inset-x-0 top-0 z-30 pointer-events-none">
|
||||
<div className="flex flex-col items-center pt-2 pb-1">
|
||||
<div className="flex items-center gap-1.5 pointer-events-auto">
|
||||
<span className="text-sm">{meta.icon}</span>
|
||||
<span className="text-xs sm:text-sm font-semibold text-foreground/70">{meta.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{dots.map(dot => (
|
||||
<div
|
||||
key={dot.id}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-300',
|
||||
dot.active
|
||||
? 'w-4 h-1 bg-primary'
|
||||
: 'w-1 h-1 bg-muted-foreground/20',
|
||||
)}
|
||||
title={dot.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Left / Right Navigation Arrows ── */}
|
||||
<button
|
||||
onClick={goLeft}
|
||||
className={cn(
|
||||
'absolute left-0.5 top-1/2 -translate-y-1/2 z-40',
|
||||
'size-9 rounded-full flex items-center justify-center',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
)}
|
||||
aria-label={`Go to ${ROOM_META[getPreviousRoom(nav.current, roomOrder)].label}`}
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={goRight}
|
||||
className={cn(
|
||||
'absolute right-0.5 top-1/2 -translate-y-1/2 z-40',
|
||||
'size-9 rounded-full flex items-center justify-center',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
)}
|
||||
aria-label={`Go to ${ROOM_META[getNextRoom(nav.current, roomOrder)].label}`}
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// src/blobbi/rooms/components/ItemCarousel.tsx
|
||||
|
||||
/**
|
||||
* ItemCarousel — Single-focus carousel for room items.
|
||||
*
|
||||
* Layout stability guarantees:
|
||||
* - The entire carousel width is deterministic (arrows + previews + focus slot)
|
||||
* - Focused item uses a fixed-size container with overflow-hidden
|
||||
* - Label is clamped to a fixed max-width and single line
|
||||
* - Switching items never causes reflow or arrow movement
|
||||
*
|
||||
* Mobile: focused item only + compact arrows (no prev/next previews)
|
||||
* Desktop: focused item + translucent prev/next previews + arrows
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CarouselEntry {
|
||||
id: string;
|
||||
/** Emoji string or ReactNode rendered at large size */
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
/** Optional metadata attached to the entry (e.g. item type) */
|
||||
meta?: string;
|
||||
}
|
||||
|
||||
interface ItemCarouselProps {
|
||||
items: CarouselEntry[];
|
||||
/** Called when the user taps the focused item */
|
||||
onUse: (id: string) => void;
|
||||
/** Item id currently being used (shows spinner) */
|
||||
activeItemId?: string | null;
|
||||
/** Whether any action is in progress */
|
||||
disabled?: boolean;
|
||||
/** Called when the focused item changes (for conditional side actions) */
|
||||
onFocusChange?: (entry: CarouselEntry) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ItemCarousel({
|
||||
items,
|
||||
onUse,
|
||||
activeItemId,
|
||||
disabled,
|
||||
onFocusChange,
|
||||
className,
|
||||
}: ItemCarouselProps) {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
const count = items.length;
|
||||
|
||||
const prev = useCallback(() => {
|
||||
setIndex(i => {
|
||||
const n = (i - 1 + count) % count;
|
||||
onFocusChange?.(items[n]);
|
||||
return n;
|
||||
});
|
||||
}, [count, items, onFocusChange]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
setIndex(i => {
|
||||
const n = (i + 1) % count;
|
||||
onFocusChange?.(items[n]);
|
||||
return n;
|
||||
});
|
||||
}, [count, items, onFocusChange]);
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
// Empty state matches the height of a populated carousel
|
||||
<div className={cn('flex items-center justify-center h-[4.5rem] sm:h-[5.5rem]', className)}>
|
||||
<p className="text-xs text-muted-foreground/50">Nothing here yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = items[index];
|
||||
const prevItem = items[(index - 1 + count) % count];
|
||||
const nextItem = items[(index + 1) % count];
|
||||
const isThisActive = activeItemId === current.id;
|
||||
const showPreviews = count >= 3;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)}>
|
||||
{/* Left arrow — fixed 28/32px */}
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
)}
|
||||
aria-label="Previous item"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* Preview (prev) — desktop only, fixed 40x48px slot */}
|
||||
{showPreviews && (
|
||||
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
|
||||
<div className="opacity-20 scale-[0.6]">
|
||||
<span className="text-2xl leading-none block">{prevItem.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focused item — FIXED 80x72 / 96x88 container, never resizes */}
|
||||
<button
|
||||
onClick={() => onUse(current.id)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center shrink-0 overflow-hidden',
|
||||
'w-20 h-[4.5rem] sm:w-24 sm:h-[5.5rem] rounded-2xl',
|
||||
'transition-colors duration-200',
|
||||
'hover:bg-accent/20 active:scale-95',
|
||||
isThisActive && 'bg-accent/40',
|
||||
disabled && !isThisActive && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
>
|
||||
<span className="text-4xl sm:text-5xl leading-none">
|
||||
{current.icon}
|
||||
</span>
|
||||
{/* Label: fixed max-width, single line, ellipsis */}
|
||||
<span className="text-[10px] sm:text-xs font-medium text-foreground/70 mt-0.5 w-16 sm:w-20 text-center truncate">
|
||||
{current.label}
|
||||
</span>
|
||||
{isThisActive && (
|
||||
<Loader2 className="size-3.5 animate-spin text-primary absolute bottom-0.5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Preview (next) — desktop only, fixed 40x48px slot */}
|
||||
{showPreviews && (
|
||||
<div className="hidden sm:flex items-center justify-center w-10 h-12 shrink-0 overflow-hidden pointer-events-none select-none">
|
||||
<div className="opacity-20 scale-[0.6]">
|
||||
<span className="text-2xl leading-none block">{nextItem.icon}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right arrow — fixed 28/32px */}
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7 sm:size-8 rounded-full flex items-center justify-center shrink-0',
|
||||
'text-muted-foreground/40 hover:text-foreground/70 hover:bg-accent/40',
|
||||
'transition-all duration-200 active:scale-90',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
)}
|
||||
aria-label="Next item"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// src/blobbi/rooms/components/RoomActionButton.tsx
|
||||
|
||||
/**
|
||||
* RoomActionButton — Unified circular action button for all rooms.
|
||||
*
|
||||
* Responsive sizing:
|
||||
* - Mobile: size-14 circle, size-7 icons
|
||||
* - Desktop (sm+): size-20 circle, size-9 icons
|
||||
*
|
||||
* Matches the soft radial glow of the original Photo / Companion buttons
|
||||
* but at a smaller scale so the bottom bar feels proportional on mobile.
|
||||
*/
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RoomActionButtonProps {
|
||||
/** Lucide icon or emoji element rendered inside the circle */
|
||||
icon: React.ReactNode;
|
||||
/** Small text label below the circle */
|
||||
label: string;
|
||||
/** CSS colour class applied to the icon (e.g. 'text-pink-500') */
|
||||
color: string;
|
||||
/** Hex colour used for the radial glow background */
|
||||
glowHex: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
/** Optional badge content rendered at top-right of the circle */
|
||||
badge?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RoomActionButton({
|
||||
icon,
|
||||
label,
|
||||
color,
|
||||
glowHex,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
badge,
|
||||
className,
|
||||
}: RoomActionButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 transition-all duration-300 ease-out shrink-0',
|
||||
'hover:-translate-y-1 hover:scale-110 active:scale-95',
|
||||
disabled && 'opacity-50 pointer-events-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn('size-14 sm:size-20 rounded-full flex items-center justify-center', color)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at 40% 35%, color-mix(in srgb, ${glowHex} 14%, transparent), color-mix(in srgb, ${glowHex} 4%, transparent) 70%)`,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="size-7 sm:size-9 animate-spin" />
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</div>
|
||||
{badge && (
|
||||
<div className="absolute -top-0.5 -right-0.5">
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] sm:text-xs font-medium text-muted-foreground">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// src/blobbi/rooms/index.ts — barrel export
|
||||
|
||||
export {
|
||||
type BlobbiRoomId,
|
||||
type BlobbiRoomMeta,
|
||||
ROOM_META,
|
||||
DEFAULT_ROOM_ORDER,
|
||||
DEFAULT_INITIAL_ROOM,
|
||||
getNextRoom,
|
||||
getPreviousRoom,
|
||||
getRoomIndex,
|
||||
} from './lib/room-config';
|
||||
|
||||
export { BlobbiRoomShell } from './components/BlobbiRoomShell';
|
||||
export { BlobbiHomeRoom } from './components/BlobbiHomeRoom';
|
||||
export { BlobbiKitchenRoom } from './components/BlobbiKitchenRoom';
|
||||
export { BlobbiCareRoom } from './components/BlobbiCareRoom';
|
||||
export { BlobbiHatcheryRoom } from './components/BlobbiHatcheryRoom';
|
||||
export { BlobbiRestRoom } from './components/BlobbiRestRoom';
|
||||
export { BlobbiClosetRoom } from './components/BlobbiClosetRoom';
|
||||
@@ -0,0 +1,137 @@
|
||||
// src/blobbi/rooms/lib/poop-system.ts
|
||||
|
||||
/**
|
||||
* Temporary local-only poop system.
|
||||
*
|
||||
* Generates poop based on:
|
||||
* A) Overfeeding: hunger >= 95 -> poop in kitchen
|
||||
* B) Time elapsed: every 2 hours since last feed -> poop in a random room
|
||||
*
|
||||
* This is entirely ephemeral -- no persistence to Nostr or localStorage.
|
||||
* The state is generated fresh on page load and managed in React state.
|
||||
*/
|
||||
|
||||
import type { BlobbiRoomId } from './room-config';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PoopInstance {
|
||||
id: string;
|
||||
room: BlobbiRoomId;
|
||||
/** 'overfeed' poops are kitchen-only and disappear on room change */
|
||||
source: 'overfeed' | 'time';
|
||||
/** Timestamp when this poop was generated */
|
||||
createdAt: number;
|
||||
/**
|
||||
* Safe-zone position for this poop.
|
||||
* Kept as % offsets so the layout stays responsive.
|
||||
* Positions are in the lower-left and lower-right corners,
|
||||
* avoiding the central Blobbi hero area.
|
||||
*/
|
||||
position: { bottom: number; left: number };
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const OVERFEED_THRESHOLD = 95;
|
||||
const HOURS_PER_POOP = 2;
|
||||
const XP_PER_POOP = 5;
|
||||
|
||||
/** Rooms where time-based poop can appear (not closet) */
|
||||
const POOP_ELIGIBLE_ROOMS: BlobbiRoomId[] = ['care', 'kitchen', 'home', 'hatchery', 'rest'];
|
||||
|
||||
/**
|
||||
* Pre-defined safe positions in the lower corners of the room.
|
||||
* Values are percentages. These avoid the central hero area
|
||||
* (roughly 30%–70% horizontal, above 35% vertical).
|
||||
*/
|
||||
const SAFE_POSITIONS: Array<{ bottom: number; left: number }> = [
|
||||
{ bottom: 22, left: 8 }, // lower-left
|
||||
{ bottom: 18, left: 78 }, // lower-right
|
||||
{ bottom: 28, left: 14 }, // mid-left
|
||||
{ bottom: 25, left: 82 }, // mid-right
|
||||
{ bottom: 15, left: 20 }, // bottom-left-ish
|
||||
{ bottom: 20, left: 72 }, // bottom-right-ish
|
||||
];
|
||||
|
||||
// ─── Generation ───────────────────────────────────────────────────────────────
|
||||
|
||||
let _idCounter = 0;
|
||||
function nextPoopId(): string {
|
||||
return `poop_${++_idCounter}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function pickPosition(index: number): { bottom: number; left: number } {
|
||||
return SAFE_POSITIONS[index % SAFE_POSITIONS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate initial poop instances based on current companion state.
|
||||
* Called once when the dashboard mounts.
|
||||
*/
|
||||
export function generateInitialPoops(
|
||||
hunger: number,
|
||||
lastFeedTimestamp: number | undefined,
|
||||
): PoopInstance[] {
|
||||
const poops: PoopInstance[] = [];
|
||||
const now = Date.now();
|
||||
let posIndex = 0;
|
||||
|
||||
// A) Overfeeding poop -- kitchen only
|
||||
if (hunger >= OVERFEED_THRESHOLD) {
|
||||
poops.push({
|
||||
id: nextPoopId(),
|
||||
room: 'kitchen',
|
||||
source: 'overfeed',
|
||||
createdAt: now,
|
||||
position: pickPosition(posIndex++),
|
||||
});
|
||||
}
|
||||
|
||||
// B) Time-based poop -- random room
|
||||
if (lastFeedTimestamp) {
|
||||
const hoursSinceFeed = (now - lastFeedTimestamp) / (1000 * 60 * 60);
|
||||
const poopCount = Math.floor(hoursSinceFeed / HOURS_PER_POOP);
|
||||
for (let i = 0; i < Math.min(poopCount, 3); i++) {
|
||||
const room = POOP_ELIGIBLE_ROOMS[Math.floor(Math.random() * POOP_ELIGIBLE_ROOMS.length)];
|
||||
poops.push({
|
||||
id: nextPoopId(),
|
||||
room,
|
||||
source: 'time',
|
||||
createdAt: now - i * 1000,
|
||||
position: pickPosition(posIndex++),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return poops;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get poops visible in a specific room.
|
||||
*/
|
||||
export function getPoopsInRoom(poops: PoopInstance[], room: BlobbiRoomId): PoopInstance[] {
|
||||
return poops.filter(p => p.room === room);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a poop by id and return the XP reward.
|
||||
*/
|
||||
export function removePoop(
|
||||
poops: PoopInstance[],
|
||||
poopId: string,
|
||||
): { remaining: PoopInstance[]; xpReward: number } {
|
||||
const remaining = poops.filter(p => p.id !== poopId);
|
||||
const wasRemoved = remaining.length < poops.length;
|
||||
return {
|
||||
remaining,
|
||||
xpReward: wasRemoved ? XP_PER_POOP : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any poop exists anywhere.
|
||||
*/
|
||||
export function hasAnyPoop(poops: PoopInstance[]): boolean {
|
||||
return poops.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// src/blobbi/rooms/lib/room-config.ts
|
||||
|
||||
/**
|
||||
* Blobbi Room System — Configuration & Navigation
|
||||
*
|
||||
* This module defines the room types, default ordering, and navigation helpers.
|
||||
* The design supports future per-user customisation: the default order is data,
|
||||
* not hardcoded control flow, so it can be replaced with a user-stored sequence.
|
||||
*/
|
||||
|
||||
// ─── Room IDs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unique identifier for each room in the Blobbi world.
|
||||
* New rooms can be added here without breaking existing code.
|
||||
*/
|
||||
export type BlobbiRoomId = 'care' | 'kitchen' | 'home' | 'hatchery' | 'rest' | 'closet';
|
||||
|
||||
// ─── Room Metadata ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlobbiRoomMeta {
|
||||
/** Unique room identifier */
|
||||
id: BlobbiRoomId;
|
||||
/** Human-readable display label */
|
||||
label: string;
|
||||
/** Short description (for tooltips / accessibility) */
|
||||
description: string;
|
||||
/** Emoji icon representing the room */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static metadata for every room.
|
||||
* This is a lookup — order does NOT matter here.
|
||||
*/
|
||||
export const ROOM_META: Record<BlobbiRoomId, BlobbiRoomMeta> = {
|
||||
care: {
|
||||
id: 'care',
|
||||
label: 'Care Room',
|
||||
description: 'Hygiene, care, and medicine',
|
||||
icon: '🩹',
|
||||
},
|
||||
kitchen: {
|
||||
id: 'kitchen',
|
||||
label: 'Kitchen',
|
||||
description: 'Feed your Blobbi',
|
||||
icon: '🍳',
|
||||
},
|
||||
home: {
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
description: 'Main living room',
|
||||
icon: '🏠',
|
||||
},
|
||||
hatchery: {
|
||||
id: 'hatchery',
|
||||
label: 'Hatchery',
|
||||
description: 'Evolution and quests',
|
||||
icon: '🥚',
|
||||
},
|
||||
rest: {
|
||||
id: 'rest',
|
||||
label: 'Bedroom',
|
||||
description: 'Rest and recharge',
|
||||
icon: '🌙',
|
||||
},
|
||||
closet: {
|
||||
id: 'closet',
|
||||
label: 'Closet',
|
||||
description: 'Wardrobe and accessories',
|
||||
icon: '👗',
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Default Room Order ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The default room sequence.
|
||||
*
|
||||
* IMPORTANT: This array is the ONLY place that defines order.
|
||||
* To support per-user customisation later, replace this with
|
||||
* a user-stored array of BlobbiRoomId values.
|
||||
*
|
||||
* Closet is excluded for now (not yet implemented).
|
||||
* To re-enable, add 'closet' back to the array.
|
||||
*/
|
||||
export const DEFAULT_ROOM_ORDER: BlobbiRoomId[] = [
|
||||
'care',
|
||||
'kitchen',
|
||||
'home',
|
||||
'hatchery',
|
||||
'rest',
|
||||
// 'closet', — re-enable when wardrobe feature is ready
|
||||
];
|
||||
|
||||
/**
|
||||
* The room that should be selected when the dashboard first loads.
|
||||
*/
|
||||
export const DEFAULT_INITIAL_ROOM: BlobbiRoomId = 'home';
|
||||
|
||||
// ─── Navigation Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the next room in a looping sequence.
|
||||
*
|
||||
* @param current - The currently active room
|
||||
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
|
||||
* @returns The next room id (wraps around)
|
||||
*/
|
||||
export function getNextRoom(
|
||||
current: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): BlobbiRoomId {
|
||||
const idx = order.indexOf(current);
|
||||
if (idx === -1) return order[0];
|
||||
return order[(idx + 1) % order.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the previous room in a looping sequence.
|
||||
*
|
||||
* @param current - The currently active room
|
||||
* @param order - The room sequence (defaults to DEFAULT_ROOM_ORDER)
|
||||
* @returns The previous room id (wraps around)
|
||||
*/
|
||||
export function getPreviousRoom(
|
||||
current: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): BlobbiRoomId {
|
||||
const idx = order.indexOf(current);
|
||||
if (idx === -1) return order[order.length - 1];
|
||||
return order[(idx - 1 + order.length) % order.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index of a room in the order array.
|
||||
* Returns -1 if the room is not in the order.
|
||||
*/
|
||||
export function getRoomIndex(
|
||||
room: BlobbiRoomId,
|
||||
order: BlobbiRoomId[] = DEFAULT_ROOM_ORDER,
|
||||
): number {
|
||||
return order.indexOf(room);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// src/blobbi/rooms/lib/room-layout.ts
|
||||
|
||||
/**
|
||||
* Shared layout constants for Blobbi room components.
|
||||
*/
|
||||
|
||||
/**
|
||||
* CSS class for the bottom action bar in every room.
|
||||
*
|
||||
* On mobile/tablet (max-sidebar), adds extra bottom padding so the
|
||||
* room controls clear the app's fixed bottom navigation bar.
|
||||
* On desktop (sidebar:), uses normal padding since there's no bottom nav.
|
||||
*/
|
||||
export const ROOM_BOTTOM_BAR_CLASS =
|
||||
'relative z-10 px-3 sm:px-6 pt-1 pb-4 sm:pb-6 max-sidebar:pb-[calc(var(--bottom-nav-height)+env(safe-area-inset-bottom,0px)+1rem)]';
|
||||
@@ -0,0 +1,194 @@
|
||||
// src/blobbi/rooms/lib/room-types.ts
|
||||
|
||||
/**
|
||||
* Shared prop types for Blobbi room components.
|
||||
*
|
||||
* These types are the "contract" that the BlobbiDashboard passes down
|
||||
* to each room. They mirror the existing BlobbiDashboard internal state
|
||||
* so rooms can reuse all existing logic without duplication.
|
||||
*/
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile, StorageItem } from '@/blobbi/core/lib/blobbi';
|
||||
import type {
|
||||
InventoryAction,
|
||||
DirectAction,
|
||||
InlineActivityState,
|
||||
BlobbiReactionState,
|
||||
SelectedTrack,
|
||||
StartIncubationMode,
|
||||
} from '@/blobbi/actions';
|
||||
import type { useHatchTasks, useEvolveTasks, useDailyMissions } from '@/blobbi/actions';
|
||||
import type { BlobbiEmotion } from '@/blobbi/ui/lib/emotion-types';
|
||||
import type { BlobbiVisualRecipe } from '@/blobbi/ui/lib/recipe';
|
||||
import type { ShopItem } from '@/blobbi/shop/types/shop.types';
|
||||
|
||||
// ─── Shared Dashboard Context ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a room needs from the dashboard.
|
||||
* Passed down by BlobbiRoomShell so rooms don't import dashboard state directly.
|
||||
*/
|
||||
export interface BlobbiRoomContext {
|
||||
// ── Core data ──
|
||||
companion: BlobbiCompanion;
|
||||
companions: BlobbiCompanion[];
|
||||
selectedD: string;
|
||||
profile: BlobbonautProfile | null;
|
||||
|
||||
// ── Projected / visual state ──
|
||||
currentStats: {
|
||||
hunger: number;
|
||||
happiness: number;
|
||||
health: number;
|
||||
hygiene: number;
|
||||
energy: number;
|
||||
};
|
||||
isSleeping: boolean;
|
||||
isEgg: boolean;
|
||||
isBaby: boolean;
|
||||
|
||||
// ── Visual recipe ──
|
||||
statusRecipe: BlobbiVisualRecipe | undefined;
|
||||
statusRecipeLabel: string | undefined;
|
||||
effectiveEmotion: BlobbiEmotion;
|
||||
hasDevOverride: boolean;
|
||||
blobbiReaction: BlobbiReactionState;
|
||||
|
||||
// ── Item use ──
|
||||
onUseItem: (itemId: string, action: InventoryAction) => Promise<void>;
|
||||
handleUseItemFromTab: (itemId: string) => void;
|
||||
isUsingItem: boolean;
|
||||
usingItemId: string | null;
|
||||
allShopItems: ShopItem[];
|
||||
|
||||
// ── Direct actions ──
|
||||
onDirectAction: (action: DirectAction) => Promise<void>;
|
||||
handleDirectAction: (action: DirectAction) => void;
|
||||
isDirectActionPending: boolean;
|
||||
|
||||
// ── Inline activity (music/sing) ──
|
||||
inlineActivity: InlineActivityState;
|
||||
setInlineActivity: React.Dispatch<React.SetStateAction<InlineActivityState>>;
|
||||
setBlobbiReaction: React.Dispatch<React.SetStateAction<BlobbiReactionState>>;
|
||||
setActionOverrideEmotion: React.Dispatch<React.SetStateAction<BlobbiEmotion | null>>;
|
||||
showTrackPickerModal: boolean;
|
||||
setShowTrackPickerModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleTrackSelected: (selection: SelectedTrack) => Promise<void>;
|
||||
handleConfirmSing: () => Promise<void>;
|
||||
handleCloseInlineActivity: () => void;
|
||||
handleMusicPlaybackStart: () => void;
|
||||
handleMusicPlaybackStop: () => void;
|
||||
handleSingRecordingStart: () => void;
|
||||
handleSingRecordingStop: () => void;
|
||||
handleChangeTrack: () => void;
|
||||
|
||||
// ── Rest / sleep ──
|
||||
onRest: () => void;
|
||||
actionInProgress: string | null;
|
||||
isPublishing: boolean;
|
||||
|
||||
// ── Companion toggle ──
|
||||
isCurrentCompanion: boolean;
|
||||
canBeCompanion: boolean;
|
||||
isUpdatingCompanion: boolean;
|
||||
isActiveFloatingCompanion: boolean;
|
||||
handleSetAsCompanion: () => Promise<void>;
|
||||
|
||||
// ── Photo ──
|
||||
showPhotoModal: boolean;
|
||||
setShowPhotoModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// ── Blobbi selector ──
|
||||
onSelectBlobbi: (d: string) => void;
|
||||
|
||||
// ── Incubation / Evolution / Tasks ──
|
||||
isIncubating: boolean;
|
||||
isEvolvingState: boolean;
|
||||
canStartIncubation: boolean;
|
||||
canStartEvolution: boolean;
|
||||
isStartingIncubation: boolean;
|
||||
isStartingEvolution: boolean;
|
||||
isStoppingIncubation: boolean;
|
||||
isStoppingEvolution: boolean;
|
||||
isHatching: boolean;
|
||||
isEvolving: boolean;
|
||||
hatchTasks: ReturnType<typeof useHatchTasks>;
|
||||
evolveTasks: ReturnType<typeof useEvolveTasks>;
|
||||
onStartIncubation: (mode: StartIncubationMode, stopOtherD?: string) => Promise<void>;
|
||||
onStartEvolution: () => Promise<void>;
|
||||
onStopIncubation: () => Promise<void>;
|
||||
onStopEvolution: () => Promise<void>;
|
||||
onHatch: () => Promise<void>;
|
||||
onEvolve: () => Promise<void>;
|
||||
showPostModal: boolean;
|
||||
setShowPostModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refetchCurrentTasks: () => void;
|
||||
|
||||
// ── Daily missions ──
|
||||
dailyMissions: ReturnType<typeof useDailyMissions>;
|
||||
onClaimReward: (id: string) => void;
|
||||
isClaimingReward: boolean;
|
||||
availableStages: ('egg' | 'baby' | 'adult')[];
|
||||
|
||||
// ── Adoption ──
|
||||
showAdoptionFlow: boolean;
|
||||
setShowAdoptionFlow: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// ── Adoption + Profile update props ──
|
||||
publishEvent: (params: { kind: number; content: string; tags: string[][] }) => Promise<NostrEvent>;
|
||||
updateProfileEvent: (event: NostrEvent) => void;
|
||||
updateCompanionEvent: (event: NostrEvent) => void;
|
||||
invalidateProfile: () => void;
|
||||
invalidateCompanion: () => void;
|
||||
setStoredSelectedD: (d: string) => void;
|
||||
ensureCanonicalBeforeAction: () => Promise<{
|
||||
companion: BlobbiCompanion;
|
||||
content: string;
|
||||
allTags: string[][];
|
||||
wasMigrated: boolean;
|
||||
profileAllTags: string[][];
|
||||
profileStorage: StorageItem[];
|
||||
} | null>;
|
||||
|
||||
// ── Naddr link ──
|
||||
blobbiNaddr: string;
|
||||
|
||||
// ── Hero measurement ──
|
||||
/** Callback ref for the hero container — re-attaches ResizeObserver on room switch */
|
||||
heroRef: React.RefCallback<HTMLDivElement> | React.RefObject<HTMLDivElement | null>;
|
||||
heroWidth: number;
|
||||
|
||||
// ── DEV ONLY ──
|
||||
showDevEditor: boolean;
|
||||
setShowDevEditor: (show: boolean) => void;
|
||||
onDevEditorApply: (updates: import('@/blobbi/dev').BlobbiDevUpdates) => Promise<void>;
|
||||
isDevUpdating: boolean;
|
||||
showEmotionPanel: boolean;
|
||||
setShowEmotionPanel: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showHatchCeremony: boolean;
|
||||
setShowHatchCeremony: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// ── Inventory modal (still used in kitchen) ──
|
||||
inventoryAction: InventoryAction | null;
|
||||
setInventoryAction: React.Dispatch<React.SetStateAction<InventoryAction | null>>;
|
||||
|
||||
// ── Last feed timestamp (for poop system) ──
|
||||
lastFeedTimestamp: number | undefined;
|
||||
}
|
||||
|
||||
// ─── Poop State (passed from shell to rooms) ──────────────────────────────────
|
||||
|
||||
import type { PoopInstance } from './poop-system';
|
||||
|
||||
export interface RoomPoopState {
|
||||
/** All poop instances across rooms */
|
||||
poops: PoopInstance[];
|
||||
/** Whether shovel mode is currently active */
|
||||
shovelMode: boolean;
|
||||
/** Toggle shovel mode on/off */
|
||||
setShovelMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
/** Remove a poop (returns XP reward via callback) */
|
||||
onRemovePoop: (poopId: string) => void;
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Package, Loader2, Minus, Plus, X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Package, Loader2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -20,7 +18,7 @@ import {
|
||||
|
||||
import type { BlobbiCompanion, BlobbonautProfile } from '@/blobbi/core/lib/blobbi';
|
||||
import type { ShopItem } from '../types/shop.types';
|
||||
import { getShopItemById } from '../lib/blobbi-shop-items';
|
||||
import { getLiveShopItems } from '../lib/blobbi-shop-items';
|
||||
import { canUseItemForStage } from '@/blobbi/actions/lib/blobbi-action-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ItemEffectDisplay } from './ItemEffectDisplay';
|
||||
@@ -31,228 +29,169 @@ interface BlobbiInventoryModalProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
/** The current companion (needed for stage-based restrictions) */
|
||||
companion: BlobbiCompanion | null;
|
||||
/** Called when user wants to use an item. Opens the use flow. */
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
/** Called when user wants to use an item. Always uses once. */
|
||||
onUseItem?: (itemId: string) => void;
|
||||
/** Whether an item is currently being used */
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
/** Resolved inventory item with shop metadata and usability info */
|
||||
/** Resolved catalog item with shop metadata and usability info */
|
||||
interface ResolvedInventoryItem extends ShopItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ── Shared inventory content (used by both standalone modal and unified shop modal) ──
|
||||
// ── Shared items content (used by both standalone modal and unified shop modal) ──
|
||||
|
||||
interface BlobbiInventoryContentProps {
|
||||
profile: BlobbonautProfile | null;
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
onUseItem?: (itemId: string) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
export function BlobbiInventoryContent({
|
||||
profile,
|
||||
profile: _profile,
|
||||
companion,
|
||||
onUseItem,
|
||||
isUsingItem = false,
|
||||
}: BlobbiInventoryContentProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<ResolvedInventoryItem | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [showUseDialog, setShowUseDialog] = useState(false);
|
||||
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
const allItems = getLiveShopItems();
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
for (const item of allItems) {
|
||||
const usability = canUseItemForStage(item.id, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: item.id,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
}, [companion?.stage]);
|
||||
|
||||
const isEmpty = inventoryItems.length === 0;
|
||||
|
||||
const handleSelectItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem) return;
|
||||
setSelectedItem(item);
|
||||
setQuantity(1);
|
||||
setShowUseDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmUse = () => {
|
||||
if (!selectedItem || !onUseItem || isUsingItem) return;
|
||||
onUseItem(selectedItem.itemId, quantity);
|
||||
setShowUseDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
};
|
||||
|
||||
const handleCloseUseDialog = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setShowUseDialog(false);
|
||||
setSelectedItem(null);
|
||||
setQuantity(1);
|
||||
}
|
||||
};
|
||||
|
||||
const maxQuantity = selectedItem?.quantity ?? 1;
|
||||
const handleIncrease = () => setQuantity(q => Math.min(q + 1, maxQuantity));
|
||||
const handleDecrease = () => setQuantity(q => Math.max(q - 1, 1));
|
||||
const handleQuantityInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (isNaN(value) || value < 1) {
|
||||
setQuantity(1);
|
||||
} else {
|
||||
setQuantity(Math.min(value, maxQuantity));
|
||||
}
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem || !onUseItem) return;
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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 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>
|
||||
) : (
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold mb-2">No Items Available</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-sm">
|
||||
No items are available for your Blobbi's current stage.
|
||||
</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 + 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 - 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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use Item Confirmation Dialog */}
|
||||
{selectedItem && companion && (
|
||||
<InventoryUseConfirmDialog
|
||||
open={showUseDialog}
|
||||
onOpenChange={handleCloseUseDialog}
|
||||
item={selectedItem}
|
||||
companion={companion}
|
||||
quantity={quantity}
|
||||
maxQuantity={maxQuantity}
|
||||
onIncrease={handleIncrease}
|
||||
onDecrease={handleDecrease}
|
||||
onQuantityChange={handleQuantityInput}
|
||||
onConfirm={handleConfirmUse}
|
||||
isUsing={isUsingItem}
|
||||
/>
|
||||
{/* Use Button */}
|
||||
{onUseItem && (
|
||||
item.canUse ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleUseItem(item)}
|
||||
disabled={isUsingItem}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isUsingItem ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
'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.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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,153 +237,3 @@ export function BlobbiInventoryModal({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Use Confirmation Dialog ──────────────────────────────────────────────────
|
||||
|
||||
interface InventoryUseConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: ResolvedInventoryItem;
|
||||
companion: BlobbiCompanion;
|
||||
quantity: number;
|
||||
maxQuantity: number;
|
||||
onIncrease: () => void;
|
||||
onDecrease: () => void;
|
||||
onQuantityChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onConfirm: () => void;
|
||||
isUsing: boolean;
|
||||
}
|
||||
|
||||
function InventoryUseConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
companion,
|
||||
quantity,
|
||||
maxQuantity,
|
||||
onIncrease,
|
||||
onDecrease,
|
||||
onQuantityChange,
|
||||
onConfirm,
|
||||
isUsing,
|
||||
}: InventoryUseConfirmDialogProps) {
|
||||
const totalEffect = useMemo(() => {
|
||||
if (!item.effect) return null;
|
||||
|
||||
const statKeys = ['hunger', 'happiness', 'energy', 'hygiene', 'health'] as const;
|
||||
const currentStats = { ...companion.stats };
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
for (const stat of statKeys) {
|
||||
const delta = item.effect[stat];
|
||||
if (delta !== undefined) {
|
||||
currentStats[stat] = Math.max(0, Math.min(100, (currentStats[stat] ?? 0) + delta));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, number> = {};
|
||||
for (const stat of statKeys) {
|
||||
const delta = (currentStats[stat] ?? 0) - (companion.stats[stat] ?? 0);
|
||||
if (delta !== 0) {
|
||||
result[stat] = delta;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null;
|
||||
}, [item.effect, companion.stats, quantity]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm w-[calc(100%-2rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Use Item</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Item Preview */}
|
||||
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||
<div className="text-3xl sm:text-4xl shrink-0">{item.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{item.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.quantity} in inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Quantity</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {maxQuantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onDecrease}
|
||||
disabled={quantity <= 1 || isUsing}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={maxQuantity}
|
||||
value={quantity}
|
||||
onChange={onQuantityChange}
|
||||
disabled={isUsing}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onIncrease}
|
||||
disabled={quantity >= maxQuantity || isUsing}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Effects Summary */}
|
||||
{totalEffect && (
|
||||
<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">
|
||||
Total effect{quantity > 1 ? ` (x${quantity})` : ''}
|
||||
</h4>
|
||||
<ItemEffectDisplay effect={totalEffect} variant="badges" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isUsing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isUsing}
|
||||
className="min-w-24"
|
||||
>
|
||||
{isUsing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Using...
|
||||
</>
|
||||
) : (
|
||||
`Use${quantity > 1 ? ` (x${quantity})` : ''}`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,17 +16,16 @@ import {
|
||||
|
||||
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 { getLiveShopItems } 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 */
|
||||
/** Resolved catalog item with shop metadata and usability info */
|
||||
interface ResolvedInventoryItem extends ShopItem {
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
canUse: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
@@ -39,7 +38,7 @@ interface BlobbiShopModalProps {
|
||||
initialTab?: TopTab;
|
||||
// ── Inventory props (passed through) ──
|
||||
companion: BlobbiCompanion | null;
|
||||
onUseItem?: (itemId: string, quantity: number) => void;
|
||||
onUseItem?: (itemId: string) => void;
|
||||
isUsingItem?: boolean;
|
||||
}
|
||||
|
||||
@@ -80,28 +79,24 @@ export function BlobbiShopModal({
|
||||
|
||||
const effectivePurchasingId = isPurchasing ? purchasingItemId : null;
|
||||
|
||||
// ── Inventory items resolution ──
|
||||
// ── Items resolution — sourced from the full catalog (not inventory) ──
|
||||
const inventoryItems = useMemo((): ResolvedInventoryItem[] => {
|
||||
if (!profile) return [];
|
||||
const stage = companion?.stage ?? 'egg';
|
||||
const allCatalogItems = getLiveShopItems();
|
||||
|
||||
const result: ResolvedInventoryItem[] = [];
|
||||
for (const storageItem of profile.storage) {
|
||||
const item = getShopItemById(storageItem.itemId);
|
||||
if (!item) continue;
|
||||
|
||||
const usability = canUseItemForStage(storageItem.itemId, stage);
|
||||
for (const item of allCatalogItems) {
|
||||
const usability = canUseItemForStage(item.id, stage);
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
itemId: storageItem.itemId,
|
||||
quantity: storageItem.quantity,
|
||||
itemId: item.id,
|
||||
canUse: usability.canUse,
|
||||
reason: usability.reason,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [profile, companion?.stage]);
|
||||
}, [companion?.stage]);
|
||||
|
||||
// ── Inventory use item handler ──
|
||||
const [usingItemId, setUsingItemId] = useState<string | null>(null);
|
||||
@@ -109,7 +104,7 @@ export function BlobbiShopModal({
|
||||
const handleUseItem = (item: ResolvedInventoryItem) => {
|
||||
if (!item.canUse || isUsingItem || !onUseItem) return;
|
||||
setUsingItemId(item.itemId);
|
||||
onUseItem(item.itemId, 1);
|
||||
onUseItem(item.itemId);
|
||||
};
|
||||
|
||||
// Clear usingItemId when isUsingItem goes false
|
||||
@@ -138,7 +133,7 @@ export function BlobbiShopModal({
|
||||
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)}
|
||||
{inventoryItems.length}
|
||||
</Badge>
|
||||
)}
|
||||
{topTab === 'items' && (
|
||||
@@ -265,7 +260,7 @@ function ShopGrid({ items, availableCoins, onBuy, purchasingItemId }: ShopGridPr
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Items Grid (inventory, tile layout) ──────────────────────────────────────
|
||||
// ─── Items Grid (catalog, tile layout) ────────────────────────────────────────
|
||||
|
||||
interface ItemsGridProps {
|
||||
items: ResolvedInventoryItem[];
|
||||
@@ -275,20 +270,16 @@ interface ItemsGridProps {
|
||||
onGoToShop: () => void;
|
||||
}
|
||||
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: ItemsGridProps) {
|
||||
function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop: _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 className="text-sm text-muted-foreground">
|
||||
No items are available for your Blobbi's current stage.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onGoToShop} className="gap-2">
|
||||
<ShoppingBag className="size-3.5" />
|
||||
Browse Shop
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -308,13 +299,6 @@ function ItemsGrid({ items, onUseItem, isUsingItem, usingItemId, onGoToShop }: I
|
||||
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>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* Used by:
|
||||
* - BlobbiShopItemRow (shop listing)
|
||||
* - BlobbiPurchaseDialog (purchase confirmation)
|
||||
* - BlobbiInventoryModal (inventory listing)
|
||||
* - BlobbiInventoryModal (items listing)
|
||||
* - BlobbiActionInventoryModal (action item selection)
|
||||
*
|
||||
* All consumers should use this component to ensure consistent display of item effects.
|
||||
@@ -192,30 +192,6 @@ export function ItemEffectDisplay({
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Utility Exports ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format effects as a summary string (for compatibility with existing code).
|
||||
* This is a drop-in replacement for formatEffectSummary in blobbi-shop-utils.ts.
|
||||
*
|
||||
* @deprecated Use <ItemEffectDisplay variant="inline" /> instead
|
||||
*/
|
||||
export function formatEffectSummary(effect: ItemEffect | undefined, maxEffects = 4): string {
|
||||
const entries = getSortedEffectEntries(effect);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return 'No effects';
|
||||
}
|
||||
|
||||
const displayEntries = maxEffects !== undefined ? entries.slice(0, maxEffects) : entries;
|
||||
|
||||
return displayEntries
|
||||
.map(([stat, value]) => `${formatStatValue(value)} ${STAT_LABELS[stat]}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sorted effect entries for custom rendering.
|
||||
* Useful when you need to iterate over effects yourself.
|
||||
*/
|
||||
export { getSortedEffectEntries };
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Play, Pause, Music, ListMusic, Podcast, Clock } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
|
||||
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
|
||||
/**
|
||||
* Auto-minimizes the audio player when the user navigates to a different page.
|
||||
|
||||
@@ -3,39 +3,7 @@ import { Award } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
|
||||
/** Parsed NIP-58 badge definition data. */
|
||||
export interface BadgeData {
|
||||
identifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
imageDimensions?: string;
|
||||
thumbs: Array<{ url: string; dimensions?: string }>;
|
||||
}
|
||||
|
||||
/** Parse a kind 30009 badge definition event into structured data. */
|
||||
export function parseBadgeDefinition(event: NostrEvent): BadgeData | null {
|
||||
if (event.kind !== 30009) return null;
|
||||
|
||||
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (!identifier) return null;
|
||||
|
||||
const name = event.tags.find(([n]) => n === 'name')?.[1] || identifier;
|
||||
const description = event.tags.find(([n]) => n === 'description')?.[1];
|
||||
const imageTag = event.tags.find(([n]) => n === 'image');
|
||||
const image = imageTag?.[1];
|
||||
const imageDimensions = imageTag?.[2];
|
||||
|
||||
const thumbs: Array<{ url: string; dimensions?: string }> = [];
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] === 'thumb' && tag[1]) {
|
||||
thumbs.push({ url: tag[1], dimensions: tag[2] });
|
||||
}
|
||||
}
|
||||
|
||||
return { identifier, name, description, image, imageDimensions, thumbs };
|
||||
}
|
||||
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
|
||||
|
||||
interface BadgeContentProps {
|
||||
event: NostrEvent;
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { parseBadgeDefinition } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
|
||||
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { BadgeData } from '@/components/BadgeContent';
|
||||
import type { BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BadgeDisplayItem {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Award } from 'lucide-react';
|
||||
|
||||
import type { BadgeData } from '@/components/BadgeContent';
|
||||
import type { BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useMemo, useState, useEffect, useId } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { hexToHslString, hexToRgb, rgbToHsl, hslToRgb, getLuminance, getContrastRatio, parseHsl, formatHsl, hexLuminance } from '@/lib/colorUtils';
|
||||
import type { CoreThemeColors } from '@/themes';
|
||||
import { getColors, paletteToTheme } from '@/lib/colorMomentUtils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
type Layout = 'horizontal' | 'vertical' | 'grid' | 'star' | 'checkerboard' | 'diagonalStripes';
|
||||
@@ -12,12 +11,7 @@ function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
export function getColors(tags: string[][]): string[] {
|
||||
return tags
|
||||
.filter(([n]) => n === 'c')
|
||||
.map(([, v]) => v)
|
||||
.filter((v) => /^#[0-9A-Fa-f]{6}$/.test(v));
|
||||
}
|
||||
|
||||
|
||||
/** Compute a best-fit grid: cols × rows for n items. */
|
||||
function gridDimensions(n: number): { cols: number; rows: number } {
|
||||
@@ -193,82 +187,6 @@ function DiagonalStripesLayout({ colors }: { colors: string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Palette → theme mapping ─────────────────────────────
|
||||
|
||||
function hexContrast(hex1: string, hex2: string): number {
|
||||
return getContrastRatio(hexToRgb(hex1), hexToRgb(hex2));
|
||||
}
|
||||
|
||||
function hexSaturation(hex: string): number {
|
||||
return rgbToHsl(...hexToRgb(hex)).s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust the lightness of an HSL string until it achieves at least `targetRatio`
|
||||
* contrast against `bgHsl`. Steps toward white or black depending on which
|
||||
* direction gives better contrast. Returns the adjusted HSL string.
|
||||
*/
|
||||
function enforceContrast(hsl: string, bgHsl: string, targetRatio: number): string {
|
||||
const bg = parseHsl(bgHsl);
|
||||
const bgLum = getLuminance(...hslToRgb(bg.h, bg.s, bg.l));
|
||||
const { h, s, l } = parseHsl(hsl);
|
||||
|
||||
// Decide direction: go lighter if bg is dark, darker if bg is light
|
||||
const goLighter = bgLum < 0.18;
|
||||
let current = l;
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
current = goLighter
|
||||
? Math.min(100, current + 2)
|
||||
: Math.max(0, current - 2);
|
||||
const rgb = hslToRgb(h, s, current);
|
||||
const lum = getLuminance(...rgb);
|
||||
const lighter = Math.max(bgLum, lum);
|
||||
const darker = Math.min(bgLum, lum);
|
||||
if ((lighter + 0.05) / (darker + 0.05) >= targetRatio) break;
|
||||
}
|
||||
|
||||
return formatHsl(h, s, current);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map palette hex colors to CoreThemeColors with guaranteed readability:
|
||||
* 1. background = darkest color
|
||||
* 2. text = lightest color; if contrast < 4.5:1, synthesize white or black
|
||||
* 3. primary = most saturated remaining color; if contrast < 3:1 against
|
||||
* background, adjust its lightness until it passes
|
||||
*/
|
||||
export function paletteToTheme(colors: string[]): CoreThemeColors {
|
||||
if (colors.length === 0) {
|
||||
return { background: '0 0% 10%', text: '0 0% 98%', primary: '258 70% 55%' };
|
||||
}
|
||||
|
||||
const sorted = [...colors].sort((a, b) => hexLuminance(a) - hexLuminance(b));
|
||||
const bgHex = sorted[0];
|
||||
const bgHsl = hexToHslString(bgHex);
|
||||
|
||||
// Text: lightest palette color; override with white/black if contrast is too low
|
||||
const textHex = sorted[sorted.length - 1];
|
||||
let textHsl = hexToHslString(textHex);
|
||||
if (hexContrast(textHex, bgHex) < 4.5) {
|
||||
// Pick white or black — whichever contrasts better
|
||||
const whiteContrast = hexContrast('#ffffff', bgHex);
|
||||
const blackContrast = hexContrast('#000000', bgHex);
|
||||
textHsl = whiteContrast >= blackContrast ? '0 0% 98%' : '222 20% 8%';
|
||||
}
|
||||
|
||||
// Primary: most saturated of remaining colors; nudge lightness if needed
|
||||
const rest = colors.filter((c) => c !== bgHex && c !== textHex);
|
||||
const pool = rest.length > 0 ? rest : [textHex];
|
||||
const primaryHex = pool.reduce((best, c) => hexSaturation(c) > hexSaturation(best) ? c : best, pool[0]);
|
||||
let primaryHsl = hexToHslString(primaryHex);
|
||||
if (hexContrast(primaryHex, bgHex) < 3) {
|
||||
primaryHsl = enforceContrast(primaryHsl, bgHsl, 3);
|
||||
}
|
||||
|
||||
return { background: bgHsl, text: textHsl, primary: primaryHsl };
|
||||
}
|
||||
|
||||
// ─── Main component ──────────────────────────────────────
|
||||
|
||||
const LAYOUT_MAP: Record<Layout, React.FC<{ colors: string[] }>> = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Link } from 'react-router-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
@@ -26,7 +26,7 @@ function getTag(tags: string[][], name: string): string | undefined {
|
||||
|
||||
// ── data hook ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useEventComments(event: NostrEvent | undefined) {
|
||||
function useEventComments(event: NostrEvent | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const aTag = event
|
||||
|
||||
@@ -1086,6 +1086,7 @@ export function ComposeBox({
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onPointerDown={expand}
|
||||
onFocus={expand}
|
||||
onPaste={handlePaste}
|
||||
placeholder={mode === 'poll' ? 'Ask a question…' : placeholder}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import {
|
||||
Users, Download, Loader2, X, Pencil, Home, Globe,
|
||||
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
|
||||
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -17,6 +17,7 @@ import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useInterests } from '@/hooks/useInterests';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -24,7 +25,7 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useMuteList, type MuteListItem } from '@/hooks/useMuteList';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { FeedEditModal } from '@/components/FeedEditModal';
|
||||
import { buildKindOptions } from '@/components/SavedFeedFiltersEditor';
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
@@ -556,6 +557,183 @@ function FeedTabsSection() {
|
||||
|
||||
{/* Saved Feeds */}
|
||||
<SavedFeedsSection />
|
||||
|
||||
{/* Interests (hashtag & geotag tabs) */}
|
||||
<InterestsSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Interests Section ───────────────────────────────────────────────────────
|
||||
|
||||
function InterestsSection() {
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { hashtags, addInterest: addHashtag, removeInterest: removeHashtag, isLoading: isLoadingHashtags } = useInterests('t');
|
||||
const { hashtags: geotags, addInterest: addGeotag, removeInterest: removeGeotag, isLoading: isLoadingGeotags } = useInterests('g');
|
||||
const [newHashtag, setNewHashtag] = useState('');
|
||||
const [newGeotag, setNewGeotag] = useState('');
|
||||
|
||||
const isLoading = isLoadingHashtags || isLoadingGeotags;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleRemoveHashtag = async (tag: string) => {
|
||||
await removeHashtag.mutateAsync(tag);
|
||||
toast({ title: `#${tag} removed from feed tabs` });
|
||||
};
|
||||
|
||||
const handleRemoveGeotag = async (tag: string) => {
|
||||
await removeGeotag.mutateAsync(tag);
|
||||
toast({ title: `${tag} removed from feed tabs` });
|
||||
};
|
||||
|
||||
const handleAddHashtag = async () => {
|
||||
const tag = newHashtag.trim().toLowerCase().replace(/^#/, '');
|
||||
if (!tag) return;
|
||||
if (hashtags.includes(tag)) {
|
||||
toast({ title: `#${tag} is already followed`, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
await addHashtag.mutateAsync(tag);
|
||||
setNewHashtag('');
|
||||
toast({ title: `#${tag} added to feed tabs` });
|
||||
};
|
||||
|
||||
const handleAddGeotag = async () => {
|
||||
const tag = newGeotag.trim().toLowerCase();
|
||||
if (!tag) return;
|
||||
if (geotags.includes(tag)) {
|
||||
toast({ title: `${tag} is already followed`, variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
await addGeotag.mutateAsync(tag);
|
||||
setNewGeotag('');
|
||||
toast({ title: `${tag} added to feed tabs` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 py-4 space-y-4 border-t border-border">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Interest Tabs</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Hashtags and locations you follow appear as tabs on the home feed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hashtags */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Hashtags</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="ditto"
|
||||
value={newHashtag}
|
||||
onChange={(e) => setNewHashtag(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddHashtag(); }}
|
||||
className="h-9"
|
||||
disabled={addHashtag.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddHashtag}
|
||||
disabled={addHashtag.isPending || !newHashtag.trim()}
|
||||
size="sm"
|
||||
className="h-9"
|
||||
>
|
||||
{addHashtag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : hashtags.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No followed hashtags yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{hashtags.map((tag) => (
|
||||
<div
|
||||
key={`hashtag:${tag}`}
|
||||
className="rounded-lg border border-border/50 bg-secondary/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-2 px-2.5">
|
||||
<Hash className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveHashtag(tag)}
|
||||
disabled={removeHashtag.isPending}
|
||||
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
|
||||
aria-label={`Remove #${tag}`}
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Geotags */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Locations</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="geohash (e.g. u4pru)"
|
||||
value={newGeotag}
|
||||
onChange={(e) => setNewGeotag(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddGeotag(); }}
|
||||
className="h-9"
|
||||
disabled={addGeotag.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddGeotag}
|
||||
disabled={addGeotag.isPending || !newGeotag.trim()}
|
||||
size="sm"
|
||||
className="h-9"
|
||||
>
|
||||
{addGeotag.isPending ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : geotags.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No followed locations yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{geotags.map((tag) => (
|
||||
<div
|
||||
key={`geotag:${tag}`}
|
||||
className="rounded-lg border border-border/50 bg-secondary/30"
|
||||
>
|
||||
<div className="flex items-center gap-2 py-2 px-2.5">
|
||||
<MapPin className="size-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium flex-1 min-w-0 truncate">{tag}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveGeotag(tag)}
|
||||
disabled={removeGeotag.isPending}
|
||||
className="size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-colors"
|
||||
aria-label={`Remove ${tag}`}
|
||||
>
|
||||
<X className="size-3.5" strokeWidth={4} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface EmojiPackData {
|
||||
}
|
||||
|
||||
/** Parse a kind 30030 emoji pack event into structured data. */
|
||||
export function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
|
||||
function parseEmojiPack(event: NostrEvent): EmojiPackData | null {
|
||||
if (event.kind !== 30030) return null;
|
||||
|
||||
const identifier = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
|
||||
@@ -332,14 +332,15 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
|
||||
// For edit mode, fetch fresh event to preserve any tags we don't manage
|
||||
let preservedTags: string[][] = [];
|
||||
let prev: NostrEvent | null = null;
|
||||
if (isEditMode) {
|
||||
const fresh = await fetchFreshEvent(nostr, {
|
||||
prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [30030],
|
||||
authors: [user.pubkey],
|
||||
'#d': [resolvedId],
|
||||
});
|
||||
if (fresh) {
|
||||
preservedTags = fresh.tags.filter(
|
||||
if (prev) {
|
||||
preservedTags = prev.tags.filter(
|
||||
([n]) => n !== 'd' && n !== 'name' && n !== 'about' && n !== 'emoji',
|
||||
);
|
||||
}
|
||||
@@ -357,7 +358,8 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial
|
||||
kind: 30030,
|
||||
content: '',
|
||||
tags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
// Clean up blob URLs
|
||||
for (const e of emojis) {
|
||||
|
||||
@@ -18,14 +18,13 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
|
||||
import {
|
||||
buildKindOptions,
|
||||
MultiKindPicker,
|
||||
AuthorChip,
|
||||
AuthorFilterDropdown,
|
||||
ScopeToggle,
|
||||
ListPackPicker,
|
||||
parseSelectedKinds,
|
||||
} from '@/components/SavedFeedFiltersEditor';
|
||||
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
|
||||
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { parseBadgeDefinition } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition } from '@/lib/parseBadgeDefinition';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAwardBadge } from '@/hooks/useAwardBadge';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
@@ -671,7 +671,7 @@ function ProfileStep({
|
||||
);
|
||||
if (validFields.length > 0)
|
||||
data.fields = validFields.map((f) => [f.label, f.value]);
|
||||
await publishEvent({ kind: 0, content: JSON.stringify(data) });
|
||||
await publishEvent({ kind: 0, content: JSON.stringify(data), tags: [] });
|
||||
queryClient.invalidateQueries({ queryKey: ["logins"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["author", user.pubkey] });
|
||||
} catch {
|
||||
@@ -949,15 +949,15 @@ function FollowsStep({
|
||||
})
|
||||
.catch((): NostrEvent[] => []);
|
||||
|
||||
const latestEvent =
|
||||
const prev =
|
||||
followEvents.length > 0
|
||||
? followEvents.reduce((latest, current) =>
|
||||
current.created_at > latest.created_at ? current : latest,
|
||||
)
|
||||
: null;
|
||||
|
||||
const existingFollows = latestEvent
|
||||
? latestEvent.tags
|
||||
const existingFollows = prev
|
||||
? prev.tags
|
||||
.filter(([name]) => name === "p")
|
||||
.map(([, pk]) => pk)
|
||||
: [];
|
||||
@@ -966,8 +966,9 @@ function FollowsStep({
|
||||
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: latestEvent?.content ?? "",
|
||||
content: prev?.content ?? "",
|
||||
tags: allFollows.map((pk) => ["p", pk]),
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
setFollowedPacks((prev) => new Set([...prev, packId]));
|
||||
|
||||
@@ -155,16 +155,19 @@ function QuotesTab({ quotes }: { quotes: QuoteEntry[] }) {
|
||||
|
||||
/* ──── Reactions Tab ──── */
|
||||
function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
|
||||
// Group reactions by emoji
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, ReactionEntry[]>();
|
||||
// Summary of unique emojis with counts, sorted by popularity
|
||||
const emojiSummary = useMemo(() => {
|
||||
const counts = new Map<string, { count: number; url?: string }>();
|
||||
for (const r of reactions) {
|
||||
const key = r.emoji;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(r);
|
||||
const existing = counts.get(r.emoji);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
counts.set(r.emoji, { count: 1, url: r.emojiUrl });
|
||||
}
|
||||
}
|
||||
// Sort groups by count (most popular first)
|
||||
return Array.from(groups.entries()).sort((a, b) => b[1].length - a[1].length);
|
||||
return Array.from(counts.entries())
|
||||
.sort((a, b) => b[1].count - a[1].count);
|
||||
}, [reactions]);
|
||||
|
||||
if (reactions.length === 0) {
|
||||
@@ -173,32 +176,31 @@ function ReactionsTab({ reactions }: { reactions: ReactionEntry[] }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{grouped.map(([emoji, entries]) => {
|
||||
// Check if this is a custom emoji — use the URL from the first entry
|
||||
const firstEntry = entries[0];
|
||||
const customUrl = firstEntry?.emojiUrl;
|
||||
const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined;
|
||||
{/* Emoji summary bar */}
|
||||
{emojiSummary.length > 1 && (
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-secondary/30 border-b border-border flex-wrap">
|
||||
{emojiSummary.map(([emoji, { count, url }]) => {
|
||||
const customName = isCustomEmoji(emoji) ? emoji.slice(1, -1) : undefined;
|
||||
return (
|
||||
<span key={emoji} className="inline-flex items-center gap-1 text-sm">
|
||||
{url && customName ? (
|
||||
<CustomEmojiImg name={customName} url={url} className="inline-block h-5 w-5 object-contain" />
|
||||
) : (
|
||||
<span>{emoji}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-medium tabular-nums">{count}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div key={emoji}>
|
||||
{/* 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 object-contain" />
|
||||
) : (
|
||||
<span className="text-lg">{emoji}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-medium">{entries.length}</span>
|
||||
</div>
|
||||
{/* Users who reacted with this emoji — each row links to the reaction event */}
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map((entry, i) => (
|
||||
<ReactionRow key={`${entry.pubkey}-${i}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Flat list — each row shows the emoji badge inline */}
|
||||
<div className="divide-y divide-border">
|
||||
{reactions.map((entry, i) => (
|
||||
<ReactionRow key={`${entry.pubkey}-${i}`} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -275,6 +277,7 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = metadata?.name || genUserName(entry.pubkey);
|
||||
const nevent = useMemo(() => nip19.neventEncode({ id: entry.eventId, author: entry.pubkey }), [entry.eventId, entry.pubkey]);
|
||||
const customName = isCustomEmoji(entry.emoji) ? entry.emoji.slice(1, -1) : undefined;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -302,6 +305,15 @@ function ReactionRow({ entry }: { entry: ReactionEntry }) {
|
||||
<span className="text-xs text-muted-foreground">{timeAgo(entry.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{/* Reaction emoji badge */}
|
||||
<div className="flex items-center justify-center shrink-0 bg-secondary/60 rounded-full size-8">
|
||||
{entry.emojiUrl && customName ? (
|
||||
<CustomEmojiImg name={customName} url={entry.emojiUrl} className="inline-block h-5 w-5 object-contain" />
|
||||
) : (
|
||||
<span className="text-base leading-none">{entry.emoji}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -19,48 +19,7 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { getContentWarning } from '@/lib/contentWarning';
|
||||
|
||||
// ── Media type detection ──────────────────────────────────────────────────────
|
||||
|
||||
export type MediaType = 'image' | 'video' | 'audio';
|
||||
|
||||
/** Event kinds that are inherently video content (vines, horizontal video, vertical video). */
|
||||
const VIDEO_KINDS = new Set([34236, 21, 22]);
|
||||
/** Event kinds that are inherently audio content (music tracks, podcast episodes/trailers). */
|
||||
const AUDIO_KINDS = new Set([36787, 34139, 30054, 30055, 1222]);
|
||||
|
||||
function detectType(url: string, mime?: string, eventKind?: number): MediaType {
|
||||
if (mime) {
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
}
|
||||
if (/\.(mp4|webm|mov|qt|m3u8)(\?.*)?$/i.test(url)) return 'video';
|
||||
if (/\.(mp3|ogg|flac|wav|aac|opus)(\?.*)?$/i.test(url)) return 'audio';
|
||||
// Fall back to event kind for extensionless URLs (e.g. Blossom content-addressed URLs)
|
||||
if (eventKind !== undefined) {
|
||||
if (VIDEO_KINDS.has(eventKind)) return 'video';
|
||||
if (AUDIO_KINDS.has(eventKind)) return 'audio';
|
||||
}
|
||||
return 'image';
|
||||
}
|
||||
|
||||
// ── Aspect ratio helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Default aspect ratio when dim tag is missing or unparseable. */
|
||||
const DEFAULT_ASPECT_RATIO = 1;
|
||||
|
||||
/** Parse a dim string like "1280x720" into a width/height aspect ratio number. */
|
||||
export function parseDimToAspectRatio(dim?: string): number {
|
||||
if (!dim) return DEFAULT_ASPECT_RATIO;
|
||||
const match = dim.match(/^(\d+)x(\d+)$/);
|
||||
if (!match) return DEFAULT_ASPECT_RATIO;
|
||||
const w = parseInt(match[1], 10);
|
||||
const h = parseInt(match[2], 10);
|
||||
if (!w || !h) return DEFAULT_ASPECT_RATIO;
|
||||
return w / h;
|
||||
}
|
||||
import { parseDimToAspectRatio, eventToMediaItem, type MediaType, type MediaItem } from '@/lib/mediaUtils';
|
||||
|
||||
/** A row of items in the justified layout. */
|
||||
interface JustifiedRow<T> {
|
||||
@@ -127,82 +86,6 @@ function justifiedLayout<T>(
|
||||
return { rows, lastRowIncomplete: false };
|
||||
}
|
||||
|
||||
// ── Media extraction ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface MediaItem {
|
||||
url: string;
|
||||
type: MediaType;
|
||||
blurhash?: string;
|
||||
dim?: string;
|
||||
alt?: string;
|
||||
mime?: string;
|
||||
allUrls: string[];
|
||||
allTypes: MediaType[];
|
||||
allDims: (string | undefined)[];
|
||||
event: NostrEvent;
|
||||
hasMultiple: boolean;
|
||||
/** NIP-36 content warning reason, or empty string if flagged with no reason, or undefined if clean. */
|
||||
contentWarning?: string;
|
||||
}
|
||||
|
||||
function parseImeta(tags: string[][]): { url: string; blurhash?: string; dim?: string; alt?: string; mime?: string }[] {
|
||||
const results: { url: string; blurhash?: string; dim?: string; alt?: string; mime?: string }[] = [];
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== 'imeta') continue;
|
||||
const parts: Record<string, string> = {};
|
||||
for (let i = 1; i < tag.length; i++) {
|
||||
const sp = tag[i].indexOf(' ');
|
||||
if (sp !== -1) parts[tag[i].slice(0, sp)] = tag[i].slice(sp + 1);
|
||||
}
|
||||
if (parts.url) results.push({ url: parts.url, blurhash: parts.blurhash, dim: parts.dim, alt: parts.alt, mime: parts.m });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractMediaUrls(content: string): string[] {
|
||||
return content.match(/https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|qt|mp3|ogg|flac|wav|aac|opus)(\?[^\s]*)?/gi) ?? [];
|
||||
}
|
||||
|
||||
export function eventToMediaItem(event: NostrEvent): MediaItem | null {
|
||||
const imeta = parseImeta(event.tags);
|
||||
const cw = getContentWarning(event);
|
||||
if (imeta.length > 0) {
|
||||
const first = imeta[0];
|
||||
const firstType = detectType(first.url, first.mime, event.kind);
|
||||
return {
|
||||
url: first.url,
|
||||
type: firstType,
|
||||
blurhash: first.blurhash,
|
||||
dim: first.dim,
|
||||
alt: first.alt,
|
||||
mime: first.mime,
|
||||
allUrls: imeta.map((e) => e.url),
|
||||
allTypes: imeta.map((e) => detectType(e.url, e.mime, event.kind)),
|
||||
allDims: imeta.map((e) => e.dim),
|
||||
event,
|
||||
hasMultiple: imeta.length > 1,
|
||||
contentWarning: cw,
|
||||
};
|
||||
}
|
||||
if (event.kind === 1) {
|
||||
const urls = extractMediaUrls(event.content);
|
||||
if (urls.length > 0) {
|
||||
const types = urls.map((u) => detectType(u));
|
||||
return {
|
||||
url: urls[0],
|
||||
type: types[0],
|
||||
allUrls: urls,
|
||||
allTypes: types,
|
||||
allDims: urls.map(() => undefined),
|
||||
event,
|
||||
hasMultiple: urls.length > 1,
|
||||
contentWarning: cw,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Flat entry — one per media URL across all events ─────────────────────────
|
||||
|
||||
interface FlatEntry {
|
||||
|
||||
@@ -3,33 +3,6 @@ import { Play, Pause } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
|
||||
/** Audio file extensions used to detect audio URLs. */
|
||||
const AUDIO_EXTENSIONS = /\.(mp3|mpga|ogg|oga|wav|flac|aac|m4a|opus|weba|webm|spx|caf)(\?.*)?$/i;
|
||||
|
||||
/** Image file extensions used to detect image URLs. */
|
||||
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i;
|
||||
|
||||
/** Video file extensions used to detect video URLs. */
|
||||
const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|qt)(\?.*)?$/i;
|
||||
|
||||
/** Check whether a URL points to an audio file by extension. */
|
||||
export function isAudioUrl(url: string): boolean {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
|
||||
return AUDIO_EXTENSIONS.test(url);
|
||||
}
|
||||
|
||||
/** Check whether a URL points to an image file by extension. */
|
||||
export function isImageUrl(url: string): boolean {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
|
||||
return IMAGE_EXTENSIONS.test(url);
|
||||
}
|
||||
|
||||
/** Check whether a URL points to a video file by extension. */
|
||||
export function isVideoUrl(url: string): boolean {
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) return false;
|
||||
return VIDEO_EXTENSIONS.test(url);
|
||||
}
|
||||
|
||||
interface MiniAudioPlayerProps {
|
||||
src: string;
|
||||
label?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Play, Pause, SkipBack, SkipForward, Maximize2, X, GripVertical } from 'lucide-react';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const POSITION_KEY = 'audio-minibar-position';
|
||||
|
||||
@@ -31,7 +31,7 @@ import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
|
||||
|
||||
|
||||
|
||||
+77
-136
@@ -16,6 +16,7 @@ import {
|
||||
Radio,
|
||||
Share2,
|
||||
SmilePlus,
|
||||
PartyPopper,
|
||||
Sparkles,
|
||||
Users,
|
||||
Zap,
|
||||
@@ -104,6 +105,7 @@ import { isSingleImagePost } from "@/lib/noteContent";
|
||||
import { shareOrCopy } from "@/lib/share";
|
||||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { formatNumber } from "@/lib/formatNumber";
|
||||
import { publishedAtAction } from "@/lib/publishedAtAction";
|
||||
import { getEffectiveStreamStatus } from "@/lib/streamStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1040,7 +1042,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
? isLive ? "text-primary" : "text-muted-foreground"
|
||||
: cfg.iconClassName
|
||||
}
|
||||
action={typeof cfg.action === "function" ? cfg.action(event.tags, event) : cfg.action}
|
||||
action={typeof cfg.action === "function" ? cfg.action(event) : cfg.action}
|
||||
noun={cfg.noun}
|
||||
nounRoute={cfg.nounRoute}
|
||||
/>
|
||||
@@ -1059,39 +1061,29 @@ export const NoteCard = memo(function NoteCard({
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
{threadedKindHeader}
|
||||
{isFollowPack ? (
|
||||
<div className={cn("min-w-0", threaded && "pb-3")}>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{avatarElement}
|
||||
{threaded && (
|
||||
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex-1 min-w-0", threaded && "pb-3")}>
|
||||
{authorInfo}
|
||||
{contentBlock}
|
||||
<FollowPackAuthorLine pubkey={event.pubkey} createdAt={event.created_at} />
|
||||
{actionButtons}
|
||||
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
<ReplyComposeModal
|
||||
event={event}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
{avatarElement}
|
||||
{threaded && (
|
||||
<div className={cn("w-0.5 flex-1 mt-2 rounded-full", threadedLineClassName || "bg-foreground/20")} />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex-1 min-w-0", threaded && "pb-3")}>
|
||||
{authorInfo}
|
||||
{contentBlock}
|
||||
{actionButtons}
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
<ReplyComposeModal
|
||||
event={event}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1134,7 +1126,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
}
|
||||
action={
|
||||
typeof cfg.action === "function"
|
||||
? cfg.action(event.tags, event)
|
||||
? cfg.action(event)
|
||||
: cfg.action
|
||||
}
|
||||
noun={cfg.noun}
|
||||
@@ -1144,46 +1136,29 @@ export const NoteCard = memo(function NoteCard({
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* For follow packs / lists: content-first layout with subtle author attribution */}
|
||||
{isFollowPack ? (
|
||||
<>
|
||||
{contentBlock}
|
||||
<FollowPackAuthorLine pubkey={event.pubkey} createdAt={event.created_at} />
|
||||
{!compact && (
|
||||
<>
|
||||
{actionButtons}
|
||||
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Header: avatar + name/handle stacked */}
|
||||
<div className="flex items-center gap-3">
|
||||
{avatarElement}
|
||||
{authorInfo}
|
||||
{isColor && <ColorMomentEyeButton event={event} />}
|
||||
</div>
|
||||
{/* Header: avatar + name/handle stacked */}
|
||||
<div className="flex items-center gap-3">
|
||||
{avatarElement}
|
||||
{authorInfo}
|
||||
{isColor && <ColorMomentEyeButton event={event} />}
|
||||
</div>
|
||||
|
||||
{contentBlock}
|
||||
{contentBlock}
|
||||
|
||||
{/* Action buttons — hidden in compact/embed mode */}
|
||||
{!compact && (
|
||||
<>
|
||||
{actionButtons}
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
<ReplyComposeModal
|
||||
event={event}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* Action buttons — hidden in compact/embed mode */}
|
||||
{!compact && (
|
||||
<>
|
||||
{actionButtons}
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
<ReplyComposeModal
|
||||
event={event}
|
||||
open={replyOpen}
|
||||
onOpenChange={setReplyOpen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
@@ -1703,52 +1678,6 @@ function StreamContent({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Subtle author attribution line for follow pack / list cards. */
|
||||
function FollowPackAuthorLine({ pubkey, createdAt }: { pubkey: string; createdAt: number }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const displayName = getDisplayName(metadata, pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-2 text-xs text-muted-foreground">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-4 rounded-full shrink-0" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-4">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[7px]">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0">{timeAgo(createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EventActionHeaderProps {
|
||||
/** Pubkey of the person performing the action. */
|
||||
pubkey: string;
|
||||
@@ -1768,8 +1697,8 @@ export interface EventActionHeaderProps {
|
||||
interface KindHeaderConfig {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
iconClassName?: string;
|
||||
/** Static action string, or a function that computes it from the event's tags (and optionally the full event). */
|
||||
action: string | ((tags: string[][], event?: NostrEvent) => string);
|
||||
/** Static action string, or a function that computes it from the event. */
|
||||
action: string | ((event: NostrEvent) => string);
|
||||
noun?: string;
|
||||
nounRoute?: string;
|
||||
}
|
||||
@@ -1794,7 +1723,7 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
},
|
||||
37516: {
|
||||
icon: ChestIcon,
|
||||
action: "hid a",
|
||||
action: (event) => publishedAtAction(event, { created: "hid a", updated: "updated a", fallback: "hid a" }),
|
||||
noun: "treasure",
|
||||
nounRoute: "/treasures",
|
||||
},
|
||||
@@ -1806,61 +1735,61 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
},
|
||||
37381: {
|
||||
icon: CardsIcon,
|
||||
action: "shared a",
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
|
||||
noun: "deck",
|
||||
nounRoute: "/decks",
|
||||
},
|
||||
36767: {
|
||||
icon: Sparkles,
|
||||
action: "shared a",
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
|
||||
noun: "theme",
|
||||
nounRoute: "/themes",
|
||||
},
|
||||
16767: {
|
||||
icon: Sparkles,
|
||||
action: "updated their",
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated their", fallback: "updated their" }),
|
||||
noun: "theme",
|
||||
nounRoute: "/themes",
|
||||
},
|
||||
30030: {
|
||||
icon: SmilePlus,
|
||||
action: "shared an",
|
||||
action: (event) => publishedAtAction(event, { created: "created an", updated: "updated an", fallback: "shared an" }),
|
||||
noun: "emoji pack",
|
||||
nounRoute: "/emojis",
|
||||
},
|
||||
30009: {
|
||||
icon: Award,
|
||||
action: "created a",
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
|
||||
noun: "badge",
|
||||
nounRoute: "/badges",
|
||||
},
|
||||
10008: {
|
||||
icon: Award,
|
||||
action: "updated their",
|
||||
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
|
||||
noun: "badges",
|
||||
nounRoute: "/badges",
|
||||
},
|
||||
30008: {
|
||||
icon: Award,
|
||||
action: "updated their",
|
||||
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
|
||||
noun: "badges",
|
||||
nounRoute: "/badges",
|
||||
},
|
||||
30311: {
|
||||
icon: Radio,
|
||||
iconClassName: undefined, // computed dynamically below
|
||||
action: (_tags, event) =>
|
||||
event && getEffectiveStreamStatus(event) === "live"
|
||||
action: (event) =>
|
||||
getEffectiveStreamStatus(event) === "live"
|
||||
? "is streaming"
|
||||
: "streamed",
|
||||
},
|
||||
32267: {
|
||||
icon: Package,
|
||||
action: "published a Zapstore app",
|
||||
action: (event) => publishedAtAction(event, { created: "published a Zapstore app", updated: "updated a Zapstore app", fallback: "published a Zapstore app" }),
|
||||
},
|
||||
30063: {
|
||||
icon: Package,
|
||||
action: "published a Zapstore release",
|
||||
action: (event) => publishedAtAction(event, { created: "published a Zapstore release", updated: "updated a Zapstore release", fallback: "published a Zapstore release" }),
|
||||
},
|
||||
3063: {
|
||||
icon: Package,
|
||||
@@ -1868,11 +1797,11 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
},
|
||||
31990: {
|
||||
icon: Package,
|
||||
action: "published an app",
|
||||
action: (event) => publishedAtAction(event, { created: "published an app", updated: "updated an app", fallback: "published an app" }),
|
||||
},
|
||||
30617: {
|
||||
icon: GitBranch,
|
||||
action: "shared a",
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
|
||||
noun: "repository",
|
||||
nounRoute: "/development",
|
||||
},
|
||||
@@ -1890,19 +1819,19 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
},
|
||||
30817: {
|
||||
icon: FileCode,
|
||||
action: "proposed a",
|
||||
action: (event) => publishedAtAction(event, { created: "proposed a", updated: "updated a", fallback: "proposed a" }),
|
||||
noun: "NIP",
|
||||
nounRoute: "/development",
|
||||
},
|
||||
15128: {
|
||||
icon: Rocket,
|
||||
action: "deployed an",
|
||||
action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }),
|
||||
noun: "nsite",
|
||||
nounRoute: "/development",
|
||||
},
|
||||
35128: {
|
||||
icon: Rocket,
|
||||
action: "deployed an",
|
||||
action: (event) => publishedAtAction(event, { created: "deployed an", updated: "redeployed an", fallback: "deployed an" }),
|
||||
noun: "nsite",
|
||||
nounRoute: "/development",
|
||||
},
|
||||
@@ -1912,10 +1841,22 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
},
|
||||
31124: {
|
||||
icon: Egg,
|
||||
action: "updated their",
|
||||
action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }),
|
||||
noun: "Blobbi",
|
||||
nounRoute: "/blobbi",
|
||||
},
|
||||
39089: {
|
||||
icon: PartyPopper,
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
|
||||
noun: "follow pack",
|
||||
nounRoute: "/packs",
|
||||
},
|
||||
30000: {
|
||||
icon: PartyPopper,
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }),
|
||||
noun: "follow set",
|
||||
nounRoute: "/packs",
|
||||
},
|
||||
};
|
||||
|
||||
/** Generic action header: icon · [author name] [action] [linked noun] */
|
||||
|
||||
@@ -31,7 +31,7 @@ import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useAudioPlayer } from '@/contexts/AudioPlayerContext';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { parsePodcastEpisode, parsePodcastTrailer, episodeToAudioTrack, trailerToAudioTrack } from '@/lib/podcastHelpers';
|
||||
|
||||
/** Format a full date. */
|
||||
|
||||
@@ -7,68 +7,13 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { BadgeThumbnail } from '@/components/BadgeThumbnail';
|
||||
import { isProfileBadgesKind } from '@/lib/badgeUtils';
|
||||
|
||||
/** Maximum badges to show in the preview grid before truncating. */
|
||||
const PREVIEW_LIMIT = 12;
|
||||
|
||||
/** A parsed badge reference from a profile badges event. */
|
||||
interface BadgeRef {
|
||||
/** The `a` tag value referencing a kind 30009 badge definition. */
|
||||
aTag: string;
|
||||
/** The `e` tag value referencing a kind 8 badge award event. */
|
||||
eTag?: string;
|
||||
/** Parsed components from the `a` tag. */
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
}
|
||||
|
||||
/** Parse a profile badges event (kind 10008 or legacy 30008) into badge references. */
|
||||
export function parseProfileBadges(event: NostrEvent): BadgeRef[] {
|
||||
if (!isProfileBadgesKind(event.kind)) return [];
|
||||
// Legacy kind 30008 requires d=profile_badges; kind 10008 doesn't need it
|
||||
if (event.kind === 30008) {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (dTag !== 'profile_badges') return [];
|
||||
}
|
||||
|
||||
const refs: BadgeRef[] = [];
|
||||
const tags = event.tags;
|
||||
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
if (tags[i][0] === 'a' && tags[i][1]) {
|
||||
const aTag = tags[i][1];
|
||||
const parts = aTag.split(':');
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const kind = parseInt(parts[0], 10);
|
||||
if (kind !== 30009) continue;
|
||||
|
||||
const pubkey = parts[1];
|
||||
const identifier = parts.slice(2).join(':');
|
||||
|
||||
// Look for the corresponding `e` tag immediately after
|
||||
let eTag: string | undefined;
|
||||
if (i + 1 < tags.length && tags[i + 1][0] === 'e') {
|
||||
eTag = tags[i + 1][1];
|
||||
}
|
||||
|
||||
refs.push({ aTag, eTag, kind, pubkey, identifier });
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by aTag — keep first occurrence only
|
||||
const seen = new Set<string>();
|
||||
return refs.filter((r) => {
|
||||
if (seen.has(r.aTag)) return false;
|
||||
seen.add(r.aTag);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
interface ProfileBadgesContentProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,10 @@ import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getContentWarning } from '@/lib/contentWarning';
|
||||
import { MiniAudioPlayer, isAudioUrl, isImageUrl, isVideoUrl } from '@/components/MiniAudioPlayer';
|
||||
import { MiniAudioPlayer } from '@/components/MiniAudioPlayer';
|
||||
import { isAudioUrl, isImageUrl, isVideoUrl } from '@/lib/mediaTypeDetection';
|
||||
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
import { parseDimToAspectRatio } from '@/components/MediaCollage';
|
||||
import { parseDimToAspectRatio } from '@/lib/mediaUtils';
|
||||
import { isWeatherFieldLabel } from '@/lib/weatherStation';
|
||||
import { WeatherStationCard } from '@/components/WeatherStationCard';
|
||||
|
||||
|
||||
@@ -21,17 +21,16 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
|
||||
import {
|
||||
buildKindOptions,
|
||||
MultiKindPicker,
|
||||
ScopeToggle,
|
||||
parseSelectedKinds,
|
||||
AuthorChip,
|
||||
AuthorFilterDropdown,
|
||||
ListPackPicker,
|
||||
} from '@/components/SavedFeedFiltersEditor';
|
||||
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
|
||||
import { useFollowPacks } from '@/hooks/useFollowPacks';
|
||||
import type { ProfileTab, TabFilter } from '@/lib/profileTabsEvent';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
@@ -107,7 +107,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
ref={dialogContentRef}
|
||||
className="max-w-[520px] max-h-[85vh] rounded-2xl p-0 gap-0 border-border overflow-visible [&>button]:hidden flex flex-col"
|
||||
className="max-w-[520px] max-h-[85dvh] rounded-2xl p-0 gap-0 border-border overflow-visible [&>button]:hidden !flex !flex-col"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
@@ -26,8 +26,6 @@ import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
|
||||
import { useFollowPacks } from '@/hooks/useFollowPacks';
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TabFilter } from '@/contexts/AppContext';
|
||||
import type { SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
@@ -46,40 +44,11 @@ type KindOption = {
|
||||
|
||||
// ─── Kind options (built once) ───────────────────────────────────────────────
|
||||
|
||||
export function buildKindOptions(): KindOption[] {
|
||||
const options: KindOption[] = [];
|
||||
for (const def of EXTRA_KINDS) {
|
||||
if (def.subKinds) {
|
||||
for (const sub of def.subKinds) {
|
||||
options.push({
|
||||
value: String(sub.kind),
|
||||
label: `${sub.label} (${sub.kind})`,
|
||||
description: sub.description,
|
||||
parentId: def.id,
|
||||
icon: CONTENT_KIND_ICONS[def.id],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
options.push({
|
||||
value: String(def.kind),
|
||||
label: `${def.label} (${def.kind})`,
|
||||
description: def.description,
|
||||
parentId: def.id,
|
||||
icon: CONTENT_KIND_ICONS[def.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
return options.filter((o) => {
|
||||
if (seen.has(o.value)) return false;
|
||||
seen.add(o.value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
|
||||
// ─── useScrollCarets ─────────────────────────────────────────────────────────
|
||||
|
||||
export function useScrollCarets() {
|
||||
function useScrollCarets() {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
@@ -534,12 +503,7 @@ export function ListPackPicker({ lists, followPacks, value, onSelectPubkeys, cla
|
||||
|
||||
// ─── parseSelectedKinds ───────────────────────────────────────────────────────
|
||||
|
||||
/** Parse a TabFilter's kinds array into an array of string kind values. */
|
||||
export function parseSelectedKinds(filter: TabFilter): string[] {
|
||||
const kinds = filter.kinds;
|
||||
if (!Array.isArray(kinds) || kinds.length === 0) return [];
|
||||
return kinds.map(String);
|
||||
}
|
||||
|
||||
|
||||
// ─── AuthorChip ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -630,7 +594,7 @@ export function SavedFeedFiltersEditor({
|
||||
);
|
||||
|
||||
const search = typeof value.search === 'string' ? value.search : '';
|
||||
const authorPubkeys = Array.isArray(value.authors) ? (value.authors as string[]) : [];
|
||||
const authorPubkeys = useMemo(() => Array.isArray(value.authors) ? (value.authors as string[]) : [], [value.authors]);
|
||||
// Local scope state so clicking "People" immediately shows the panel,
|
||||
// even before any authors have been added. Initialised from the filter value.
|
||||
const [authorScope, setAuthorScopeState] = useState<'anyone' | 'people'>(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { useNavHidden } from '@/contexts/LayoutContext';
|
||||
import { SubHeaderBarContext } from '@/components/SubHeaderBarContext';
|
||||
|
||||
interface HoverSlice {
|
||||
left: number;
|
||||
@@ -21,17 +22,6 @@ interface SubHeaderBarProps {
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
interface SubHeaderBarContextValue {
|
||||
onHover: (slice: HoverSlice | null) => void;
|
||||
onActive: (slice: HoverSlice | null) => void;
|
||||
}
|
||||
|
||||
export const SubHeaderBarContext = createContext<SubHeaderBarContextValue>({ onHover: () => {}, onActive: () => {} });
|
||||
|
||||
export function useSubHeaderBarHover() {
|
||||
return useContext(SubHeaderBarContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared sticky sub-header bar with a unified arc+background drawn as a single
|
||||
* SVG shape. Eliminates the sub-pixel seam between a bg-background/80 container
|
||||
@@ -52,6 +42,44 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const [atTop, setAtTop] = useState(false);
|
||||
|
||||
// Horizontal overflow scroll arrows (desktop only)
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const checkOverflow = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const tolerance = 2; // sub-pixel rounding tolerance
|
||||
setCanScrollLeft(el.scrollLeft > tolerance);
|
||||
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - tolerance);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
checkOverflow();
|
||||
el.addEventListener('scroll', checkOverflow, { passive: true });
|
||||
const ro = new ResizeObserver(checkOverflow);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', checkOverflow);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [checkOverflow]);
|
||||
|
||||
// Also re-check overflow when children change (new tabs added/removed)
|
||||
useEffect(() => {
|
||||
checkOverflow();
|
||||
}, [children, checkOverflow]);
|
||||
|
||||
const scrollBy = (direction: 'left' | 'right') => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const amount = el.clientWidth * 0.6;
|
||||
el.scrollBy({ left: direction === 'left' ? -amount : amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pinned) return;
|
||||
|
||||
@@ -76,7 +104,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
const showSafeAreaPadding = pinned && navHidden && atTop;
|
||||
|
||||
return (
|
||||
<SubHeaderBarContext.Provider value={{ onHover: setHover, onActive: setActive }}>
|
||||
<SubHeaderBarContext.Provider value={{ onHover: setHover, onActive: setActive, scrollContainerRef: scrollRef }}>
|
||||
<div
|
||||
ref={barRef}
|
||||
className={cn(
|
||||
@@ -132,8 +160,35 @@ export function SubHeaderBar({ children, className, innerClassName, noArc, pinne
|
||||
</svg>
|
||||
)}
|
||||
{/* Tab content sits above the SVG background */}
|
||||
<div className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}>
|
||||
{children}
|
||||
<div className="relative">
|
||||
{/* Left scroll arrow — desktop only, shown when overflowing */}
|
||||
{canScrollLeft && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll tabs left"
|
||||
onClick={() => scrollBy('left')}
|
||||
className="hidden sidebar:flex absolute left-0 top-0 bottom-0 z-10 items-center pl-0.5 pr-1 bg-gradient-to-r from-background/90 to-transparent cursor-pointer"
|
||||
>
|
||||
<ChevronLeft className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn('relative flex overflow-x-auto scrollbar-none', innerClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{/* Right scroll arrow — desktop only, shown when overflowing */}
|
||||
{canScrollRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll tabs right"
|
||||
onClick={() => scrollBy('right')}
|
||||
className="hidden sidebar:flex absolute right-0 top-0 bottom-0 z-10 items-center pr-0.5 pl-1 bg-gradient-to-l from-background/90 to-transparent cursor-pointer"
|
||||
>
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createContext, useContext, useCallback, useLayoutEffect, useEffect } from 'react';
|
||||
|
||||
interface HoverSlice {
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface SubHeaderBarContextValue {
|
||||
onHover: (slice: HoverSlice | null) => void;
|
||||
onActive: (slice: HoverSlice | null) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const SubHeaderBarContext = createContext<SubHeaderBarContextValue>({ onHover: () => {}, onActive: () => {}, scrollContainerRef: { current: null } });
|
||||
|
||||
export function useSubHeaderBarHover() {
|
||||
return useContext(SubHeaderBarContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared hook for reporting the active tab's position to SubHeaderBar's arc indicator.
|
||||
* Handles scroll-aware position reporting and cleans up on unmount/deactivation.
|
||||
*
|
||||
* @param active Whether this tab is currently active.
|
||||
* @param elRef Ref to the tab's DOM element (used for offsetLeft/offsetWidth).
|
||||
*/
|
||||
export function useActiveTabIndicator(active: boolean, elRef: React.RefObject<HTMLElement | null>) {
|
||||
const { onActive, scrollContainerRef } = useSubHeaderBarHover();
|
||||
|
||||
const reportSlice = useCallback(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return null;
|
||||
const scrollOffset = scrollContainerRef.current?.scrollLeft ?? 0;
|
||||
return { left: el.offsetLeft - scrollOffset, width: el.offsetWidth };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Report active slice to SubHeaderBar so the arc indicator renders.
|
||||
useLayoutEffect(() => {
|
||||
if (!active) return;
|
||||
const s = reportSlice();
|
||||
if (s) onActive(s);
|
||||
return () => onActive(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
|
||||
// Re-report position when the scroll container scrolls,
|
||||
// so the SVG clip-path stays aligned with the visually shifted tab.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const handleScroll = () => {
|
||||
const s = reportSlice();
|
||||
if (s) onActive(s);
|
||||
};
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
|
||||
return { reportSlice };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useLayoutEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSubHeaderBarHover } from '@/components/SubHeaderBar';
|
||||
import { useSubHeaderBarHover, useActiveTabIndicator } from '@/components/SubHeaderBarContext';
|
||||
|
||||
interface TabButtonProps {
|
||||
/** Tab display label. */
|
||||
@@ -26,18 +26,25 @@ interface TabButtonProps {
|
||||
*/
|
||||
export function TabButton({ label, active, onClick, disabled, className, children }: TabButtonProps) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const { onHover, onActive } = useSubHeaderBarHover();
|
||||
|
||||
const reportSlice = () => {
|
||||
const btn = ref.current;
|
||||
if (!btn) return;
|
||||
return { left: btn.offsetLeft, width: btn.offsetWidth };
|
||||
};
|
||||
const { onHover, scrollContainerRef } = useSubHeaderBarHover();
|
||||
const { reportSlice } = useActiveTabIndicator(active, ref);
|
||||
|
||||
// Auto-scroll the active tab into view when the container overflows
|
||||
useLayoutEffect(() => {
|
||||
if (!active) return;
|
||||
const s = reportSlice();
|
||||
if (s) onActive(s);
|
||||
const btn = ref.current;
|
||||
const container = scrollContainerRef.current;
|
||||
if (btn && container) {
|
||||
const btnLeft = btn.offsetLeft;
|
||||
const btnRight = btnLeft + btn.offsetWidth;
|
||||
const viewLeft = container.scrollLeft;
|
||||
const viewRight = viewLeft + container.clientWidth;
|
||||
if (btnLeft < viewLeft) {
|
||||
container.scrollTo({ left: btnLeft - 8, behavior: 'smooth' });
|
||||
} else if (btnRight > viewRight) {
|
||||
container.scrollTo({ left: btnRight - container.clientWidth + 8, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [active]);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PortalContainerProvider } from '@/contexts/PortalContainerContext';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
|
||||
/** Extracts HSL color string from a theme token value like "258 70% 55%" */
|
||||
function hsl(value: string): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useState } from 'react';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Maximum nesting depth before collapsing the rest of the thread. */
|
||||
const MAX_RENDER_DEPTH = 3;
|
||||
@@ -34,7 +35,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
|
||||
return (
|
||||
<div>
|
||||
<NoteCard event={node.event} threaded />
|
||||
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} />
|
||||
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} isLast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,11 +73,14 @@ function countDescendants(node: ReplyNode): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
function ExpandThreadButton({ count, onClick }: { count: number; onClick: () => void }) {
|
||||
function ExpandThreadButton({ count, onClick, isLast }: { count: number; onClick: () => void; isLast?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-3 px-4 pt-0 pb-2.5 w-full hover:bg-secondary/30 transition-colors group"
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 pt-0 pb-2.5 w-full hover:bg-secondary/30 transition-colors group",
|
||||
isLast && "border-b border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center w-10">
|
||||
<div className="w-0.5 flex-1 mb-1 bg-foreground/20" />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Blurhash } from 'react-blurhash';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
||||
import { usePlayerControls } from '@/hooks/usePlayerControls';
|
||||
import { useVideoThumbnail } from '@/hooks/useVideoThumbnail';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
@@ -30,145 +31,6 @@ function parseDim(dim: string | undefined): { width: number; height: number } |
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extracts a thumbnail frame from a video URL by loading it off-screen,
|
||||
* drawing the first frame to a canvas, and returning a data URL.
|
||||
* Works reliably on Android WebView where preload="metadata" doesn't render a visible frame.
|
||||
*/
|
||||
export function useVideoThumbnail(src: string, poster: string | undefined): string | undefined {
|
||||
const [thumbnail, setThumbnail] = useState<string | undefined>(poster);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if we already have a poster image
|
||||
if (poster) return;
|
||||
if (!src) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
function grabFrameFromUrl(videoSrc: string) {
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
video.src = videoSrc;
|
||||
|
||||
function captureFrame() {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth || 320;
|
||||
canvas.height = video.videoHeight || 180;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
||||
if (dataUrl.length > 1000) setThumbnail(dataUrl);
|
||||
}
|
||||
} catch { /* CORS or tainted canvas */ }
|
||||
video.src = '';
|
||||
video.load();
|
||||
}
|
||||
|
||||
// After metadata loads, seek to 0.1s — then capture on seeked
|
||||
const handleMetadata = () => { video.currentTime = 0.1; };
|
||||
const handleSeeked = () => captureFrame();
|
||||
|
||||
video.addEventListener('loadedmetadata', handleMetadata, { once: true });
|
||||
video.addEventListener('seeked', handleSeeked, { once: true });
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', handleMetadata);
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
video.src = '';
|
||||
video.load();
|
||||
};
|
||||
}
|
||||
|
||||
// For HLS: use hls.js to load the stream into an off-screen video, then grab a frame
|
||||
if (/\.m3u8(\?|$)/i.test(src)) {
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
// Safari — native HLS support, no need for hls.js
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
const grabFrame = () => {
|
||||
if (cancelled) return;
|
||||
video.play().then(() => {
|
||||
video.pause();
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth || 320;
|
||||
canvas.height = video.videoHeight || 180;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
||||
if (dataUrl.length > 1000) setThumbnail(dataUrl);
|
||||
}
|
||||
} catch { /* tainted canvas */ }
|
||||
video.src = '';
|
||||
}).catch(() => { /* ignore */ });
|
||||
};
|
||||
|
||||
video.src = src;
|
||||
video.addEventListener('loadeddata', grabFrame, { once: true });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
video.removeEventListener('loadeddata', grabFrame);
|
||||
video.src = '';
|
||||
};
|
||||
}
|
||||
|
||||
// Non-Safari: dynamically import hls.js
|
||||
let hlsInstance: Hls | null = null;
|
||||
import('hls.js').then(({ default: HlsLib }) => {
|
||||
if (cancelled || !HlsLib.isSupported()) return;
|
||||
|
||||
const grabFrame = () => {
|
||||
if (cancelled) return;
|
||||
video.play().then(() => {
|
||||
video.pause();
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth || 320;
|
||||
canvas.height = video.videoHeight || 180;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.7);
|
||||
if (dataUrl.length > 1000) setThumbnail(dataUrl);
|
||||
}
|
||||
} catch { /* tainted canvas */ }
|
||||
hlsInstance?.destroy();
|
||||
hlsInstance = null;
|
||||
video.src = '';
|
||||
}).catch(() => { hlsInstance?.destroy(); hlsInstance = null; });
|
||||
};
|
||||
|
||||
const hls = new HlsLib({ startLevel: -1, maxBufferLength: 5 });
|
||||
hlsInstance = hls;
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
hls.on(HlsLib.Events.MANIFEST_PARSED, () => {
|
||||
if (cancelled) { hls.destroy(); return; }
|
||||
grabFrame();
|
||||
});
|
||||
});
|
||||
|
||||
return () => { cancelled = true; hlsInstance?.destroy(); hlsInstance = null; video.src = ''; };
|
||||
}
|
||||
|
||||
// Regular video file
|
||||
const cleanupDirect = grabFrameFromUrl(src);
|
||||
return () => { cancelled = true; cleanupDirect(); };
|
||||
}, [src, poster]);
|
||||
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
/** Attaches hls.js to a video element for HLS streams on non-Safari browsers. */
|
||||
function useHls(videoRef: React.RefObject<HTMLVideoElement | null>, src: string) {
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
|
||||
@@ -113,6 +113,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||
await publishEvent({
|
||||
kind: 0,
|
||||
content: JSON.stringify(profileData),
|
||||
tags: [],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -33,7 +33,8 @@ import { DrawingCanvas } from './DrawingCanvas';
|
||||
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { StationeryBackground } from './StationeryBackground';
|
||||
import { SendAnimation, useEnvelopeDimensions } from './SendAnimation';
|
||||
import { SendAnimation } from './SendAnimation';
|
||||
import { useEnvelopeDimensions } from '@/hooks/useEnvelopeDimensions';
|
||||
|
||||
/** Lightweight letter preview used inside the send animation */
|
||||
function AnimationLetter({ content, width }: { content: LetterContent; width: number }) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useStationeryColors } from '@/hooks/useStationeryColors';
|
||||
import { LetterStickers } from './LetterStickers';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { paletteToTheme, getColors } from '@/components/ColorMomentContent';
|
||||
import { paletteToTheme, getColors } from '@/lib/colorMomentUtils';
|
||||
import { parseThemeDefinition } from '@/lib/themeEvent';
|
||||
import type { ThemeConfig } from '@/themes';
|
||||
import {
|
||||
|
||||
@@ -143,7 +143,7 @@ export function LetterEditor({
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
}, [cardRef]);
|
||||
|
||||
// Load the currently-selected font on mount/change so the card preview renders correctly.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { useId, useRef, useEffect, useLayoutEffect, useCallback, useState, useMemo } from 'react';
|
||||
import { hexToRgb, rgbToHex, darkenHex, blendHex } from '@/lib/colorUtils';
|
||||
import { useEnvelopeDimensions } from '@/hooks/useEnvelopeDimensions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Easing + animation driver
|
||||
@@ -40,34 +41,7 @@ function animateVal(
|
||||
// Envelope dimensions — responsive, using vw-based sizing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useEnvelopeDimensions() {
|
||||
const [dims, setDims] = useState(() => calcDims(window.innerWidth));
|
||||
useEffect(() => {
|
||||
const onResize = () => setDims(calcDims(window.innerWidth));
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
return dims;
|
||||
}
|
||||
|
||||
function calcDims(vw: number) {
|
||||
const envW = Math.min(Math.round(vw * 0.85), 420);
|
||||
const envH = Math.round(envW / 1.588);
|
||||
const r = Math.round(envW * 0.041);
|
||||
const s = envW / 54;
|
||||
|
||||
const flapY = Math.round(envH * 0.147);
|
||||
const vY = Math.round(envH * 0.647);
|
||||
const flapTriH = Math.round(vY * 1.08);
|
||||
|
||||
const letterW = Math.round(envW * 0.82);
|
||||
const letterH = letterW / (5 / 4);
|
||||
|
||||
const strokeV = Math.round(s * 1.6 * 10) / 10;
|
||||
const strokeCorner = Math.round(s * 1.4 * 10) / 10;
|
||||
|
||||
return { envW, envH, r, flapY, vY, flapTriH, letterW, letterH, strokeV, strokeCorner };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derive envelope palette from stationery background + primary colors
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { usePortalContainer } from "@/contexts/PortalContainerContext"
|
||||
import { usePortalContainer } from "@/hooks/usePortalContainer"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
|
||||
@@ -1,71 +1,7 @@
|
||||
import { createContext, useContext, useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** A track that can be played by the global audio player. */
|
||||
export interface AudioTrack {
|
||||
/** Nostr event ID. */
|
||||
id: string;
|
||||
/** Track title. */
|
||||
title: string;
|
||||
/** Artist or author name. */
|
||||
artist: string;
|
||||
/** Audio file URL. */
|
||||
url: string;
|
||||
/** Artwork/cover image URL. */
|
||||
artwork?: string;
|
||||
/** Duration in seconds (from metadata). */
|
||||
duration?: number;
|
||||
/** Navigation path to the track's detail page (e.g. /naddr1…). */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface AudioPlayerState {
|
||||
/** Currently loaded track. */
|
||||
currentTrack: AudioTrack | null;
|
||||
/** Playlist tracks (when playing a playlist). */
|
||||
playlist: AudioTrack[];
|
||||
/** Current index within the playlist. */
|
||||
currentIndex: number;
|
||||
/** Whether the player is minimized (floating bar). */
|
||||
minimized: boolean;
|
||||
/** Whether audio is currently playing. */
|
||||
isPlaying: boolean;
|
||||
/** Current playback time in seconds. */
|
||||
currentTime: number;
|
||||
/** Total duration in seconds. */
|
||||
duration: number;
|
||||
/** Volume (0–1). */
|
||||
volume: number;
|
||||
}
|
||||
|
||||
interface AudioPlayerActions {
|
||||
/** Play a single track. */
|
||||
playTrack: (track: AudioTrack) => void;
|
||||
/** Play a playlist starting at a given index. */
|
||||
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
|
||||
/** Pause playback. */
|
||||
pause: () => void;
|
||||
/** Resume playback. */
|
||||
resume: () => void;
|
||||
/** Seek to a position in seconds. */
|
||||
seek: (time: number) => void;
|
||||
/** Set volume (0–1). */
|
||||
setVolume: (v: number) => void;
|
||||
/** Skip to next track (playlist mode). */
|
||||
nextTrack: () => void;
|
||||
/** Skip to previous track (playlist mode). */
|
||||
prevTrack: () => void;
|
||||
/** Minimize the player (show floating bar). */
|
||||
minimize: () => void;
|
||||
/** Expand the player (navigate back to source). */
|
||||
expand: () => void;
|
||||
/** Stop playback and close the player. */
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
type AudioPlayerContextType = AudioPlayerState & AudioPlayerActions;
|
||||
|
||||
const AudioPlayerContext = createContext<AudioPlayerContextType | undefined>(undefined);
|
||||
import { AudioPlayerContext, type AudioTrack } from '@/contexts/audioPlayerContextDef';
|
||||
|
||||
const VOLUME_KEY = 'audio-player-volume';
|
||||
|
||||
@@ -330,8 +266,4 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function useAudioPlayer(): AudioPlayerContextType {
|
||||
const ctx = useContext(AudioPlayerContext);
|
||||
if (!ctx) throw new Error('useAudioPlayer must be used within AudioPlayerProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/** A track that can be played by the global audio player. */
|
||||
export interface AudioTrack {
|
||||
/** Nostr event ID. */
|
||||
id: string;
|
||||
/** Track title. */
|
||||
title: string;
|
||||
/** Artist or author name. */
|
||||
artist: string;
|
||||
/** Audio file URL. */
|
||||
url: string;
|
||||
/** Artwork/cover image URL. */
|
||||
artwork?: string;
|
||||
/** Duration in seconds (from metadata). */
|
||||
duration?: number;
|
||||
/** Navigation path to the track's detail page (e.g. /naddr1…). */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface AudioPlayerState {
|
||||
/** Currently loaded track. */
|
||||
currentTrack: AudioTrack | null;
|
||||
/** Playlist tracks (when playing a playlist). */
|
||||
playlist: AudioTrack[];
|
||||
/** Current index within the playlist. */
|
||||
currentIndex: number;
|
||||
/** Whether the player is minimized (floating bar). */
|
||||
minimized: boolean;
|
||||
/** Whether audio is currently playing. */
|
||||
isPlaying: boolean;
|
||||
/** Current playback time in seconds. */
|
||||
currentTime: number;
|
||||
/** Total duration in seconds. */
|
||||
duration: number;
|
||||
/** Volume (0–1). */
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface AudioPlayerActions {
|
||||
/** Play a single track. */
|
||||
playTrack: (track: AudioTrack) => void;
|
||||
/** Play a playlist starting at a given index. */
|
||||
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
|
||||
/** Pause playback. */
|
||||
pause: () => void;
|
||||
/** Resume playback. */
|
||||
resume: () => void;
|
||||
/** Seek to a position in seconds. */
|
||||
seek: (time: number) => void;
|
||||
/** Set volume (0–1). */
|
||||
setVolume: (v: number) => void;
|
||||
/** Skip to next track (playlist mode). */
|
||||
nextTrack: () => void;
|
||||
/** Skip to previous track (playlist mode). */
|
||||
prevTrack: () => void;
|
||||
/** Minimize the player (show floating bar). */
|
||||
minimize: () => void;
|
||||
/** Expand the player (navigate back to source). */
|
||||
expand: () => void;
|
||||
/** Stop playback and close the player. */
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
export type AudioPlayerContextType = AudioPlayerState & AudioPlayerActions;
|
||||
|
||||
export const AudioPlayerContext = createContext<AudioPlayerContextType | undefined>(undefined);
|
||||
|
||||
export function useAudioPlayer(): AudioPlayerContextType {
|
||||
const ctx = useContext(AudioPlayerContext);
|
||||
if (!ctx) throw new Error('useAudioPlayer must be used within AudioPlayerProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
@@ -24,9 +23,9 @@ export function useAcceptBadge() {
|
||||
if (!user) throw new Error('User is not logged in');
|
||||
|
||||
// Fetch the freshest profile badges event from relays (both kinds)
|
||||
const freshEvent = await fetchFreshProfileBadges(nostr, user.pubkey);
|
||||
const prev = await fetchFreshProfileBadges(nostr, user.pubkey);
|
||||
|
||||
const currentTags = freshEvent?.tags ?? [['d', 'profile_badges']];
|
||||
const currentTags = prev?.tags ?? [['d', 'profile_badges']];
|
||||
|
||||
// Don't add duplicates
|
||||
const alreadyHas = currentTags.some(
|
||||
@@ -48,7 +47,8 @@ export function useAcceptBadge() {
|
||||
kind: BADGE_PROFILE_KIND,
|
||||
content: '',
|
||||
tags: newTags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-badges', user?.pubkey] });
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/components/BadgeContent';
|
||||
import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition';
|
||||
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
|
||||
|
||||
/** Minimal reference to a badge definition for querying. */
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/** Hook to manage the user's NIP-51 bookmark list (kind 10003). */
|
||||
export function useBookmarks() {
|
||||
@@ -63,12 +62,12 @@ export function useBookmarks() {
|
||||
if (!user) throw new Error('User is not logged in');
|
||||
|
||||
// Fetch the freshest kind 10003 from relays before mutating
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
const currentTags = freshEvent?.tags ?? [];
|
||||
const currentTags = prev?.tags ?? [];
|
||||
const currentlyBookmarked = currentTags.some(
|
||||
([name, id]) => name === 'e' && id === eventId,
|
||||
);
|
||||
@@ -87,10 +86,11 @@ export function useBookmarks() {
|
||||
|
||||
await publishEvent({
|
||||
kind: 10003,
|
||||
content: freshEvent?.content ?? '',
|
||||
content: prev?.content ?? '',
|
||||
tags: newTags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bookmarks', user?.pubkey] });
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function calcDims(vw: number) {
|
||||
const envW = Math.min(Math.round(vw * 0.85), 420);
|
||||
const envH = Math.round(envW / 1.588);
|
||||
const r = Math.round(envW * 0.041);
|
||||
const s = envW / 54;
|
||||
|
||||
const flapY = Math.round(envH * 0.147);
|
||||
const vY = Math.round(envH * 0.647);
|
||||
const flapTriH = Math.round(vY * 1.08);
|
||||
|
||||
const letterW = Math.round(envW * 0.82);
|
||||
const letterH = letterW / (5 / 4);
|
||||
|
||||
const strokeV = Math.round(s * 1.6 * 10) / 10;
|
||||
const strokeCorner = Math.round(s * 1.4 * 10) / 10;
|
||||
|
||||
return { envW, envH, r, flapY, vY, flapTriH, letterW, letterH, strokeV, strokeCorner };
|
||||
}
|
||||
|
||||
export function useEnvelopeDimensions() {
|
||||
const [dims, setDims] = useState(() => calcDims(window.innerWidth));
|
||||
useEffect(() => {
|
||||
const onResize = () => setDims(calcDims(window.innerWidth));
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
return dims;
|
||||
}
|
||||
@@ -113,10 +113,10 @@ export function useFollowActions(): UseFollowActionsReturn {
|
||||
|
||||
try {
|
||||
// ① Fetch the freshest kind 3 event via pool
|
||||
const latestEvent = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
|
||||
// ② Separate tags into `p` tags (follow entries) and everything else
|
||||
const existingTags = latestEvent?.tags ?? [];
|
||||
const existingTags = prev?.tags ?? [];
|
||||
const pTags = existingTags.filter(([name]) => name === 'p');
|
||||
const nonPTags = existingTags.filter(([name]) => name !== 'p');
|
||||
|
||||
@@ -135,12 +135,13 @@ export function useFollowActions(): UseFollowActionsReturn {
|
||||
const newTags = [...nonPTags, ...newPTags];
|
||||
|
||||
// ⑤ Preserve the content field (relay hints / petnames in some clients)
|
||||
const content = latestEvent?.content ?? '';
|
||||
const content = prev?.content ?? '';
|
||||
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content,
|
||||
tags: newTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
// ⑥ Invalidate cached follow-list queries so UI updates
|
||||
|
||||
@@ -360,6 +360,7 @@ export function useInitialSync() {
|
||||
nostr,
|
||||
config.appId,
|
||||
config.relayMetadata.updatedAt,
|
||||
config.blossomServerMetadata.updatedAt,
|
||||
updateConfig,
|
||||
queryClient,
|
||||
markSyncComplete,
|
||||
|
||||
+11
-10
@@ -9,7 +9,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
export function useInterests(tagName: 't' | 'g' = 't') {
|
||||
const { nostr } = useNostr();
|
||||
@@ -57,12 +56,12 @@ export function useInterests(tagName: 't' | 'g' = 't') {
|
||||
if (!normalized) throw new Error('Empty tag');
|
||||
|
||||
// Fetch the freshest kind 10015 from relays before mutating
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10015],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
const currentTags = freshEvent?.tags ?? [];
|
||||
const currentTags = prev?.tags ?? [];
|
||||
|
||||
// Don't add duplicates
|
||||
if (currentTags.some(([n, v]) => n === tagName && v.toLowerCase() === normalized)) return;
|
||||
@@ -70,9 +69,10 @@ export function useInterests(tagName: 't' | 'g' = 't') {
|
||||
const newTags = [...currentTags, [tagName, normalized]];
|
||||
await publishEvent({
|
||||
kind: 10015,
|
||||
content: freshEvent?.content ?? '',
|
||||
content: prev?.content ?? '',
|
||||
tags: newTags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
@@ -84,21 +84,22 @@ export function useInterests(tagName: 't' | 'g' = 't') {
|
||||
const normalized = tag.toLowerCase().replace(/^#/, '');
|
||||
|
||||
// Fetch the freshest kind 10015 from relays before mutating
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10015],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
if (!freshEvent) return;
|
||||
if (!prev) return;
|
||||
|
||||
const newTags = freshEvent.tags.filter(
|
||||
const newTags = prev.tags.filter(
|
||||
([name, value]) => !(name === tagName && value.toLowerCase() === normalized),
|
||||
);
|
||||
await publishEvent({
|
||||
kind: 10015,
|
||||
content: freshEvent.content ?? '',
|
||||
content: prev.content ?? '',
|
||||
tags: newTags,
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
prev,
|
||||
});
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -204,8 +204,8 @@ export function useMuteList() {
|
||||
}
|
||||
|
||||
// ① Fetch the freshest kind 10000 from relays before mutating
|
||||
const freshEvent = await fetchFreshEvent(nostr, { kinds: [10000], authors: [user.pubkey] });
|
||||
const currentItems = await getAllMuteItems(freshEvent, user.signer, user.pubkey);
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [10000], authors: [user.pubkey] });
|
||||
const currentItems = await getAllMuteItems(prev, user.signer, user.pubkey);
|
||||
|
||||
// ② Add only if not already present (dedup)
|
||||
const alreadyMuted = currentItems.some(
|
||||
@@ -218,7 +218,7 @@ export function useMuteList() {
|
||||
// Update localStorage immediately so it survives page refresh
|
||||
setCachedMuteItems(user.pubkey, newItems);
|
||||
|
||||
await updateMuteList(newItems);
|
||||
await updateMuteList(newItems, prev);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['muteList', user?.pubkey] });
|
||||
@@ -231,8 +231,8 @@ export function useMuteList() {
|
||||
if (!user) throw new Error('User not logged in');
|
||||
|
||||
// ① Fetch the freshest kind 10000 from relays before mutating
|
||||
const freshEvent = await fetchFreshEvent(nostr, { kinds: [10000], authors: [user.pubkey] });
|
||||
const currentItems = await getAllMuteItems(freshEvent, user.signer, user.pubkey);
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [10000], authors: [user.pubkey] });
|
||||
const currentItems = await getAllMuteItems(prev, user.signer, user.pubkey);
|
||||
|
||||
// ② Remove the target item
|
||||
const newItems = currentItems.filter(
|
||||
@@ -242,7 +242,7 @@ export function useMuteList() {
|
||||
// Update localStorage immediately so it survives page refresh
|
||||
setCachedMuteItems(user.pubkey, newItems);
|
||||
|
||||
await updateMuteList(newItems);
|
||||
await updateMuteList(newItems, prev);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['muteList', user?.pubkey] });
|
||||
@@ -250,7 +250,7 @@ export function useMuteList() {
|
||||
});
|
||||
|
||||
// Update entire mute list
|
||||
const updateMuteList = async (items: MuteListItem[]) => {
|
||||
const updateMuteList = async (items: MuteListItem[], prev: NostrEvent | null) => {
|
||||
if (!user) throw new Error('User not logged in');
|
||||
if (!user.signer.nip44) throw new Error('NIP-44 encryption not supported');
|
||||
|
||||
@@ -274,7 +274,8 @@ export function useMuteList() {
|
||||
await publishEvent({
|
||||
kind: 10000,
|
||||
content,
|
||||
tags: [], // No public tags, everything encrypted
|
||||
tags: [],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,25 @@ import { useCurrentUser } from "./useCurrentUser";
|
||||
|
||||
import type { NostrEvent } from "@nostrify/nostrify";
|
||||
|
||||
/** Event template accepted by `useNostrPublish`. */
|
||||
export type EventTemplate = Omit<NostrEvent, 'id' | 'pubkey' | 'sig'> & {
|
||||
/**
|
||||
* The previous version of the event being replaced (for replaceable/addressable kinds).
|
||||
* When provided, `published_at` from the old event is preserved on the new one.
|
||||
* When omitted and the kind is replaceable or addressable, `published_at` is set
|
||||
* equal to `created_at` so the two always match on first publish.
|
||||
*/
|
||||
prev?: NostrEvent;
|
||||
};
|
||||
|
||||
/** Returns true if the kind falls in a replaceable or addressable range. */
|
||||
function isReplaceableKind(kind: number): boolean {
|
||||
// Legacy replaceable kinds
|
||||
if (kind === 0 || kind === 3) return true;
|
||||
// Replaceable (10000–19999) or addressable (30000–39999)
|
||||
return (kind >= 10000 && kind < 20000) || (kind >= 30000 && kind < 40000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a NIP-89 "client" tag from the app display name and an optional
|
||||
* `naddr1` identifier for the kind 31990 handler event.
|
||||
@@ -41,9 +60,11 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
|
||||
const { config } = useAppContext();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (t: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>) => {
|
||||
mutationFn: async (t: EventTemplate) => {
|
||||
if (user) {
|
||||
const tags = t.tags ?? [];
|
||||
// Extract `prev` before building the event — it's not part of the Nostr event schema.
|
||||
const { prev, ...template } = t;
|
||||
const tags = [...(template.tags ?? [])];
|
||||
|
||||
// Add the NIP-89 client tag if it doesn't exist
|
||||
if (location.protocol === "https:" && !tags.some(([name]) => name === "client")) {
|
||||
@@ -51,11 +72,27 @@ export function useNostrPublish(): UseMutationResult<NostrEvent> {
|
||||
tags.push(clientTag);
|
||||
}
|
||||
|
||||
const created_at = template.created_at ?? Math.floor(Date.now() / 1000);
|
||||
|
||||
// Handle published_at for replaceable/addressable events (NIP-24)
|
||||
if (isReplaceableKind(template.kind) && !tags.some(([name]) => name === "published_at")) {
|
||||
if (prev) {
|
||||
// Preserve published_at from the previous event if it had one
|
||||
const oldTag = prev.tags.find(([name]) => name === "published_at");
|
||||
if (oldTag) {
|
||||
tags.push(["published_at", oldTag[1]]);
|
||||
}
|
||||
} else {
|
||||
// First publish: set published_at equal to created_at
|
||||
tags.push(["published_at", String(created_at)]);
|
||||
}
|
||||
}
|
||||
|
||||
const event = await user.signer.signEvent({
|
||||
kind: t.kind,
|
||||
content: t.content ?? "",
|
||||
kind: template.kind,
|
||||
content: template.content ?? "",
|
||||
tags,
|
||||
created_at: t.created_at ?? Math.floor(Date.now() / 1000),
|
||||
created_at,
|
||||
});
|
||||
|
||||
if (event.pubkey !== user.pubkey) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Hook to manage NIP-51 pinned notes (kind 10001).
|
||||
@@ -50,12 +49,12 @@ export function usePinnedNotes(pubkey?: string) {
|
||||
if (!user) throw new Error('User is not logged in');
|
||||
|
||||
// Fetch the freshest kind 10001 from relays before mutating
|
||||
const freshEvent = await fetchFreshEvent(nostr, {
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10001],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
|
||||
const currentTags = freshEvent?.tags ?? [];
|
||||
const currentTags = prev?.tags ?? [];
|
||||
const currentlyPinned = currentTags.some(
|
||||
([name, id]) => name === 'e' && id === eventId,
|
||||
);
|
||||
@@ -74,10 +73,11 @@ export function usePinnedNotes(pubkey?: string) {
|
||||
|
||||
await publishEvent({
|
||||
kind: 10001,
|
||||
content: freshEvent?.content ?? '',
|
||||
content: prev?.content ?? '',
|
||||
tags: newTags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
} as Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>);
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pinned-notes', user?.pubkey] });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { parseProfileBadges } from '@/components/ProfileBadgesContent';
|
||||
import { parseProfileBadges } from '@/lib/parseProfileBadges';
|
||||
import { BADGE_PROFILE_KIND, BADGE_PROFILE_KIND_LEGACY } from '@/lib/badgeUtils';
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user